feat: build local skills registry
This commit is contained in:
17
app/dashboard/audits/[id]/page.tsx
Normal file
17
app/dashboard/audits/[id]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AuditDetail } from "@/components/audits/audit-detail";
|
||||
|
||||
export default async function AuditDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl">
|
||||
<AuditDetail id={id as unknown as string} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
|
||||
import { AuditsBoard } from "@/components/audits/audits-board";
|
||||
|
||||
export default function AuditsPage() {
|
||||
return (
|
||||
<DashboardPlaceholderPage
|
||||
description="Audit-Review, Screenshots und oeffentliche Freigaben folgen in TASK-12 und TASK-13."
|
||||
title="Audits"
|
||||
/>
|
||||
<main className="px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
||||
<AuditsBoard />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
id: TASK-10
|
||||
title: Build the local skills registry
|
||||
status: To Do
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-06-03 19:13'
|
||||
updated_date: '2026-06-05 07:28'
|
||||
labels:
|
||||
- mvp
|
||||
- agent
|
||||
@@ -24,19 +25,34 @@ Create the local skills registry concept for the agent. Design and marketing ski
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 A project-local skills directory or convention exists for imported design and marketing skills
|
||||
- [ ] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category
|
||||
- [ ] #3 Agent code can load and parse the skills registry into structured skill metadata
|
||||
- [ ] #4 Audit records store the list of used skills, including skill name/category and version or source where available
|
||||
- [ ] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not
|
||||
- [x] #1 A project-local skills directory or convention exists for imported design and marketing skills
|
||||
- [x] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category
|
||||
- [x] #3 Agent code can load and parse the skills registry into structured skill metadata
|
||||
- [x] #4 Audit records store the list of used skills, including skill name/category and version or source where available
|
||||
- [x] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Define project-local skill storage conventions.
|
||||
2. Create the initial skills.md registry format and seed entries for design, UX, marketing, copy, SEO, and offer-writing skills.
|
||||
3. Add parser/loader for registry metadata.
|
||||
4. Store selected skill metadata with each audit.
|
||||
5. Show used skills in the internal audit detail UI only.
|
||||
1. Worker A uses TDD to add project-local skills conventions, seed skills.md, skills source files, and a strict skills registry parser/loader.
|
||||
2. Worker B uses TDD to extend Convex audit persistence so audit records can store used skill metadata with name, category, version, and source.
|
||||
3. Worker C uses TDD to add the internal dashboard audit detail/list UI and compact Verwendete Skills overview while keeping public audit pages free of skill metadata.
|
||||
4. Orchestrator reviews subagent outputs, resolves integration issues through focused subagents, runs full verification, and checks TASK-10 acceptance criteria without marking Done until user confirmation.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Started implementation using subagent-driven and test-driven workflow with parallel agents where write scopes are independent. Orchestrator will not hand-code feature changes; workers own implementation patches and tests.
|
||||
|
||||
Worker C: implemented audits dashboard internals for TASK-10. Added new tests (tests/audit-skills-ui.test.ts), new components/audits/{audits-board,audit-detail}.tsx and routes app/dashboard/audits/page.tsx + app/dashboard/audits/[id]/page.tsx. Internal detail route still passes raw id from params Promise; public audit page unchanged and remains skill-free.
|
||||
|
||||
Implementation completed through parallel subagent-driven TDD slices. Worker scopes: registry/parser, Convex audit persistence, dashboard audit UI. Review findings addressed by follow-up workers for getDetail result shape/useQuery FunctionReference and indented skills.md field parsing. Fresh orchestrator verification: pnpm test passed with 179/179 tests; pnpm lint passed with 0 errors and 2 existing generated BetterAuth warnings; pnpm exec convex codegen --dry-run --typecheck enable passed after network escalation; pnpm build passed after network escalation. Sandbox-only failures before escalation were DNS/Sentry for Convex and Google Fonts for Next build.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Shipped the local skills registry with project-local skills.md and skills/ source files, parser/loader tests, Convex audit usedSkills persistence, and internal dashboard audit skill overview. Verified with pnpm test; task remains public-audit safe because used skills are only shown in the dashboard detail route.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
184
components/audits/audit-detail.tsx
Normal file
184
components/audits/audit-detail.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
type UsedSkill = {
|
||||
name: string;
|
||||
purpose?: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type LeadContext = {
|
||||
_id: Id<"leads">;
|
||||
companyName?: string;
|
||||
websiteDomain?: string;
|
||||
websiteUrl?: string;
|
||||
city?: string;
|
||||
niche?: string;
|
||||
};
|
||||
|
||||
type SkillAwareAudit = {
|
||||
_id: Id<"audits">;
|
||||
slug: string;
|
||||
checkedDomain: string;
|
||||
status: "draft" | "approved" | "published" | "deactivated";
|
||||
checkedPages: string[];
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
usedSkills?: UsedSkill[];
|
||||
internalSummary?: string | null;
|
||||
};
|
||||
|
||||
type AuditDetailResult = {
|
||||
audit: SkillAwareAudit;
|
||||
lead: LeadContext | null;
|
||||
} | null;
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
draft: "Entwurf",
|
||||
approved: "Freigegeben",
|
||||
published: "Veröffentlicht",
|
||||
deactivated: "Deaktiviert",
|
||||
};
|
||||
|
||||
function getStatusLabel(status: SkillAwareAudit["status"]) {
|
||||
return statusText[status] ?? "Unbekannt";
|
||||
}
|
||||
|
||||
function leadSummary(lead: LeadContext | null | undefined) {
|
||||
if (!lead) {
|
||||
return "Kein Lead-Kontext gespeichert";
|
||||
}
|
||||
|
||||
const detail = [lead.city, lead.niche].filter(Boolean).join(" • ");
|
||||
let leadDomain = lead.websiteDomain ?? "—";
|
||||
|
||||
if (!leadDomain && lead.websiteUrl) {
|
||||
try {
|
||||
leadDomain = new URL(lead.websiteUrl).hostname;
|
||||
} catch {
|
||||
leadDomain = lead.websiteUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="font-medium">{lead.companyName ?? "Lead ohne Name"}</p>
|
||||
<p className="text-sm text-muted-foreground">{detail || "Kein Kontext textlich"}</p>
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Globe className="size-3.5" />
|
||||
{leadDomain}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
||||
const result = useQuery(api.audits.getDetail, {
|
||||
id: id as Id<"audits">,
|
||||
}) as AuditDetailResult | undefined;
|
||||
const audit = result?.audit;
|
||||
const lead = result?.lead;
|
||||
|
||||
const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]);
|
||||
|
||||
if (result === null) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit nicht gefunden</CardTitle>
|
||||
<CardDescription>
|
||||
Der gewünschte Audit-Datensatz konnte nicht geladen werden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (audit === undefined) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit wird geladen...</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Audit-Detail</CardDescription>
|
||||
<CardTitle className="text-xl">#{audit.slug}</CardTitle>
|
||||
<p className="inline-flex max-w-full items-center gap-1 truncate text-sm text-muted-foreground">
|
||||
<Globe className="size-3.5" />
|
||||
{audit.checkedDomain}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
<p>
|
||||
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Seitenanzahl</p>
|
||||
<p>{audit.checkedPages.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Lead-Kontext</p>
|
||||
<div className="text-sm">{leadSummary(lead)}</div>
|
||||
</div>
|
||||
{audit.internalSummary ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Interne Notiz</p>
|
||||
<p className="text-sm text-muted-foreground">{audit.internalSummary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verwendete Skills</CardTitle>
|
||||
<CardDescription>Skills, die an diesem Audit beteiligt wurden.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usedSkills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Skills gespeichert</p>
|
||||
) : (
|
||||
<ul className="grid gap-2">
|
||||
{usedSkills.map((skill, index) => (
|
||||
<li
|
||||
className="rounded-md border p-2 text-sm"
|
||||
key={`${skill.name}-${index}`}
|
||||
>
|
||||
<p className="font-medium">{skill.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skill.purpose ?? "Keine Zweckbeschreibung"}
|
||||
</p>
|
||||
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
|
||||
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
|
||||
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
|
||||
{skill.source ? <span className="text-xs text-muted-foreground">{skill.source}</span> : null}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
components/audits/audits-board.tsx
Normal file
134
components/audits/audits-board.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { Files, SquarePen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type AuditsListResult = FunctionReturnType<typeof api.audits.list>;
|
||||
type AuditRow = NonNullable<AuditsListResult>[number];
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
draft: "Entwurf",
|
||||
approved: "Freigegeben",
|
||||
published: "Veröffentlicht",
|
||||
deactivated: "Deaktiviert",
|
||||
};
|
||||
|
||||
const fallbackStatus = "Unbekannt";
|
||||
|
||||
function formatPageCount(pages: AuditRow["checkedPages"]) {
|
||||
return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`;
|
||||
}
|
||||
|
||||
function getStatusLabel(status: AuditRow["status"]) {
|
||||
return statusText[status] ?? fallbackStatus;
|
||||
}
|
||||
|
||||
function AuditsBoardLoading() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
|
||||
</header>
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid gap-2 p-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton className="h-20 rounded-md" key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditsBoard() {
|
||||
const audits = useQuery(api.audits.list, { limit: 100 });
|
||||
const rows = useMemo(() => {
|
||||
if (!audits) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...audits].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}, [audits]);
|
||||
|
||||
if (audits === undefined) {
|
||||
return <AuditsBoardLoading />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<article className="rounded-lg border p-4">
|
||||
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte
|
||||
Zeilen.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>Slug</span>
|
||||
<span>Domain</span>
|
||||
<span>Status</span>
|
||||
<span>Seitenanzahl</span>
|
||||
<span className="text-right">Aktion</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((audit: AuditRow) => (
|
||||
<article
|
||||
className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
key={audit._id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{audit.slug}</p>
|
||||
</div>
|
||||
<p className="truncate text-muted-foreground">{audit.checkedDomain}</p>
|
||||
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Files className="size-3.5" />
|
||||
{formatPageCount(audit.checkedPages)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
|
||||
href={`/dashboard/audits/${audit._id}`}
|
||||
>
|
||||
<SquarePen className="size-4" />
|
||||
Öffnen
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,14 @@ const auditStatus = v.union(
|
||||
v.literal("published"),
|
||||
v.literal("deactivated"),
|
||||
);
|
||||
const usedSkillsValidator = v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
category: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
}),
|
||||
);
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
@@ -16,6 +24,7 @@ export const create = mutation({
|
||||
slug: v.string(),
|
||||
checkedDomain: v.string(),
|
||||
checkedPages: v.array(v.string()),
|
||||
usedSkills: v.optional(usedSkillsValidator),
|
||||
status: v.optional(auditStatus),
|
||||
internalSummary: v.optional(v.string()),
|
||||
publicSummary: v.optional(v.string()),
|
||||
@@ -42,6 +51,19 @@ export const create = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const getDetail = query({
|
||||
args: { id: v.id("audits") },
|
||||
handler: async (ctx, args) => {
|
||||
const audit = await ctx.db.get(args.id);
|
||||
if (!audit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lead = await ctx.db.get(audit.leadId);
|
||||
return { audit, lead };
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { id: v.id("audits") },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -231,6 +231,16 @@ export default defineSchema({
|
||||
pageSpeedSummary: v.optional(auditMetricSummary),
|
||||
playwrightSummary: v.optional(playwrightSummary),
|
||||
textFindings: v.optional(v.array(v.string())),
|
||||
usedSkills: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
category: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
skillSummaries: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
|
||||
178
lib/skills-registry.ts
Normal file
178
lib/skills-registry.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const SKILL_CATEGORIES = [
|
||||
"design",
|
||||
"ux",
|
||||
"marketing",
|
||||
"copy",
|
||||
"seo",
|
||||
"offer",
|
||||
] as const;
|
||||
|
||||
export type SkillCategory = (typeof SKILL_CATEGORIES)[number];
|
||||
|
||||
export type SkillRegistryEntry = {
|
||||
name: string;
|
||||
purpose: string;
|
||||
whenToUse: string;
|
||||
whenNotToUse: string;
|
||||
requiredInput: string;
|
||||
expectedOutput: string;
|
||||
category: SkillCategory;
|
||||
version?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type AuditUsedSkill = {
|
||||
name: string;
|
||||
category: SkillCategory;
|
||||
version?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
type ParsedFieldName =
|
||||
| "Purpose"
|
||||
| "When to use"
|
||||
| "When not to use"
|
||||
| "Required input"
|
||||
| "Expected output"
|
||||
| "Category"
|
||||
| "Version"
|
||||
| "Source";
|
||||
|
||||
const REQUIRED_FIELDS: ParsedFieldName[] = [
|
||||
"Purpose",
|
||||
"When to use",
|
||||
"When not to use",
|
||||
"Required input",
|
||||
"Expected output",
|
||||
"Category",
|
||||
];
|
||||
|
||||
const FIELD_LABELS_RE = /^(Purpose|When to use|When not to use|Required input|Expected output|Category|Version|Source):\s*(.*?)\s*$/;
|
||||
|
||||
function normalizeCategory(value: string): SkillCategory {
|
||||
const normalized = value.toLowerCase();
|
||||
if (!isValidSkillCategory(normalized)) {
|
||||
throw new Error(
|
||||
`Unknown category "${value}". Valid categories are: ${SKILL_CATEGORIES.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isValidSkillCategory(
|
||||
value: string,
|
||||
): value is SkillCategory {
|
||||
return (SKILL_CATEGORIES as ReadonlyArray<string>).includes(value);
|
||||
}
|
||||
|
||||
function parseSection(lines: string[], sectionIndex: number): SkillRegistryEntry {
|
||||
let name: string | null = null;
|
||||
const values: Partial<Record<ParsedFieldName, string>> = {};
|
||||
let currentField: ParsedFieldName | null = null;
|
||||
|
||||
const sectionTitle = lines[0];
|
||||
if (!sectionTitle.startsWith("##")) {
|
||||
throw new Error(`Expected section ${sectionIndex} to start with a skill header.`);
|
||||
}
|
||||
name = sectionTitle.replace(/^##\s*/, "").trim();
|
||||
if (name.length === 0) {
|
||||
throw new Error(`Skill section ${sectionIndex} has an empty name.`);
|
||||
}
|
||||
|
||||
for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) {
|
||||
const line = lines[lineIndex];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = trimmedLine.match(FIELD_LABELS_RE);
|
||||
if (match) {
|
||||
const field = match[1] as ParsedFieldName;
|
||||
currentField = field;
|
||||
values[field] = match[2].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentField === null) {
|
||||
throw new Error(`Unexpected line in section "${name}": ${line}`);
|
||||
}
|
||||
|
||||
values[currentField] = `${values[currentField] ?? ""}\n${line.trim()}`.trim();
|
||||
}
|
||||
|
||||
for (const requiredField of REQUIRED_FIELDS) {
|
||||
const value = values[requiredField]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
`Missing required field "${requiredField}" for skill "${name}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const category = normalizeCategory(values.Category!.trim());
|
||||
|
||||
return {
|
||||
name,
|
||||
purpose: values["Purpose"]!,
|
||||
whenToUse: values["When to use"]!,
|
||||
whenNotToUse: values["When not to use"]!,
|
||||
requiredInput: values["Required input"]!,
|
||||
expectedOutput: values["Expected output"]!,
|
||||
category,
|
||||
version: values["Version"]?.trim() || undefined,
|
||||
source: values["Source"]?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSkillsRegistry(source: string): SkillRegistryEntry[] {
|
||||
const normalized = source.replace(/\r\n/g, "\n");
|
||||
const rawSections = normalized
|
||||
.split(/^##\s+/m)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const entries: SkillRegistryEntry[] = [];
|
||||
const names = new Set<string>();
|
||||
|
||||
for (let index = 0; index < rawSections.length; index += 1) {
|
||||
const rawSection = rawSections[index];
|
||||
const lines = rawSection
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line, lineIndex) => line.length > 0 || lineIndex === 0);
|
||||
|
||||
const sectionLines = [`## ${lines.at(0) ?? ""}`, ...lines.slice(1)];
|
||||
const parsed = parseSection(sectionLines, index + 1);
|
||||
|
||||
const normalizedName = parsed.name.trim().toLowerCase();
|
||||
if (names.has(normalizedName)) {
|
||||
throw new Error(`Duplicate skill name "${parsed.name}" in skills registry.`);
|
||||
}
|
||||
|
||||
names.add(normalizedName);
|
||||
entries.push(parsed);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function loadSkillsRegistry(
|
||||
registryPath = join(process.cwd(), "skills.md"),
|
||||
): Promise<SkillRegistryEntry[]> {
|
||||
const source = await readFile(registryPath, "utf8");
|
||||
return parseSkillsRegistry(source);
|
||||
}
|
||||
|
||||
export function toAuditUsedSkill(skill: SkillRegistryEntry): AuditUsedSkill {
|
||||
return {
|
||||
name: skill.name,
|
||||
category: skill.category,
|
||||
version: skill.version,
|
||||
source: skill.source,
|
||||
};
|
||||
}
|
||||
59
skills.md
Normal file
59
skills.md
Normal file
@@ -0,0 +1,59 @@
|
||||
## Design Audit
|
||||
Purpose: Prüft die visuelle Qualität einer Seite auf Konsistenz, Orientierung und Conversion-Relevanz.
|
||||
When to use: Nutze diesen Skill, wenn das Website-Layout, visuelle Hierarchie oder die Markenwirkung bewertet werden soll.
|
||||
When not to use: Nicht für technische Crawling- oder Performance-Analyse.
|
||||
Required input: URL, Seitenstruktur, Kernaussagen, Designsystem- oder Style-Constraints.
|
||||
Expected output: Konkrete Verbesserungsvorschläge mit Priorität nach Wirkung auf Wahrnehmung und Conversion.
|
||||
Category: design
|
||||
Version: 1.0
|
||||
Source: skills/design-audit.md
|
||||
|
||||
## UX Friction Review
|
||||
Purpose: Findet Reibungspunkte in Nutzerfluss, Formularen und Aktionen, die Nutzer vom Ziel abhalten.
|
||||
When to use: Nutze diesen Skill für Kontaktformulare, Terminbuchung und erste Kontaktversuche.
|
||||
When not to use: Nicht verwenden, wenn keine klaren User-Flows sichtbar sind oder Rohdaten fehlen.
|
||||
Required input: URL, beobachtete Nutzerwege, wichtigste Aktionen und Ziel-CTA.
|
||||
Expected output: Priorisierte Liste der größten UX-Hürden mit klaren, umsetzbaren Gegenmaßnahmen.
|
||||
Category: ux
|
||||
Version: 1.0
|
||||
Source: skills/ux-friction-review.md
|
||||
|
||||
## Marketing Positioning
|
||||
Purpose: Schärft die Positionierung für lokales B2B/B2C-Webdesign im Wettbewerb.
|
||||
When to use: Nutze ihn, wenn Differenzierung, Angebotsschwerpunkt und Nutzenstory fehlen.
|
||||
When not to use: Nicht für reine SEO-Fix-Lists oder technische Fehlerbehebungen.
|
||||
Required input: Leistungsversprechen, Zielkundensegment, lokale Wettbewerbslage, bisheriger Auftritt.
|
||||
Expected output: Klare Positionierungshypothese mit passenden USPs und Zielgruppen-Fokus.
|
||||
Category: marketing
|
||||
Version: 1.0
|
||||
Source: skills/marketing-positioning.md
|
||||
|
||||
## Copy Clarity
|
||||
Purpose: Optimiert Klarheit, Lesbarkeit und Tonalität für Webseiten- und Outreach-Texte.
|
||||
When to use: Nutze diesen Skill bei unklaren, langen oder unpassend abstrakten Texten.
|
||||
When not to use: Nicht bei legalen Texten mit festen Formulierungen oder Produkt-CTAs ohne Kontext.
|
||||
Required input: Zielgruppe, Zweck des Textes, bestehender Entwurf, bevorzugter Sprachstil.
|
||||
Expected output: Überarbeitete, verständliche und handlungsstarke Textvorschläge.
|
||||
Category: copy
|
||||
Version: 1.0
|
||||
Source: skills/copy-clarity.md
|
||||
|
||||
## Local SEO
|
||||
Purpose: Bewertet lokale Auffindbarkeit, Konsistenz und Relevanz der Website für eine definierte Ortsmarke.
|
||||
When to use: Nutze diesen Skill, wenn lokale Suchrelevanz, Google-Präsenz oder Impressum/Struktur geprüft werden.
|
||||
When not to use: Nicht als Ersatz für technische SEO-Fehlerdiagnose ohne inhaltlichen Kontext.
|
||||
Required input: Standortdaten, Nischenfokus, Seitenstruktur, Kontakt-/Nennungsdaten.
|
||||
Expected output: Konkrete Maßnahmen für lokale Sichtbarkeit, NAP-Konsistenz und Local-Authority.
|
||||
Category: seo
|
||||
Version: 1.0
|
||||
Source: skills/local-seo.md
|
||||
|
||||
## Offer Writing
|
||||
Purpose: Erstellt klare, konkrete Angebote mit Problemfokus und glaubwürdiger Preislogik.
|
||||
When to use: Nutze diesen Skill, wenn ein Vorschlag oder Angebotsentwurf in Audit- oder Outreach-Schritten gebraucht wird.
|
||||
When not to use: Nicht für reine Analyse-Ausgaben ohne Angebotsreifheit.
|
||||
Required input: Projektumfang, Zielprobleme, Budgetrahmen, Leistungsumfang, Risiko-/Zeitaspekte.
|
||||
Expected output: Offer-Prompt, Paketstruktur und überzeugende, ehrliche Angebots-Formulierungen.
|
||||
Category: offer
|
||||
Version: 1.0
|
||||
Source: skills/offer-writing.md
|
||||
9
skills/copy-clarity.md
Normal file
9
skills/copy-clarity.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copy Clarity
|
||||
|
||||
Use this file for headline/body copy improvements.
|
||||
|
||||
- Replace jargon with clear benefit language.
|
||||
- Tighten sentences to one core message per paragraph.
|
||||
- Keep benefit first, proof second, next step third.
|
||||
- Preserve the requested tone and avoid overpromising.
|
||||
- Output a concise before/after improvement set.
|
||||
9
skills/design-audit.md
Normal file
9
skills/design-audit.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Design Audit
|
||||
|
||||
Use this file as the concrete operating guide for design-oriented analysis.
|
||||
|
||||
- Check visual hierarchy and above-the-fold clarity.
|
||||
- Validate CTA prominence, spacing, and action visibility.
|
||||
- Flag inconsistent components, mismatched states, and weak contrast.
|
||||
- Verify tone alignment between typography, imagery, and brand intent.
|
||||
- Prioritize 3–5 actionable fixes by expected conversion impact.
|
||||
9
skills/local-seo.md
Normal file
9
skills/local-seo.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Local SEO
|
||||
|
||||
Use this file for local discovery and trust-relevance analysis.
|
||||
|
||||
- Verify NAP consistency across page and metadata.
|
||||
- Check service-location clarity and service-area fit.
|
||||
- Review local proof signals: reviews, case examples, local references.
|
||||
- Validate schema/markup hints and local keyword fit.
|
||||
- Recommend realistic, high-impact local SEO adjustments.
|
||||
9
skills/marketing-positioning.md
Normal file
9
skills/marketing-positioning.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Marketing Positioning
|
||||
|
||||
Use this file to sharpen market message and differentiation.
|
||||
|
||||
- Define one-liner positioning for the target segment.
|
||||
- List the top 3 differentiators vs local alternatives.
|
||||
- Connect positioning to pain points and decision criteria.
|
||||
- Keep claims evidence-backed from website and audit findings.
|
||||
- Produce a short positioning paragraph for outreach and landing intro.
|
||||
9
skills/offer-writing.md
Normal file
9
skills/offer-writing.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Offer Writing
|
||||
|
||||
Use this guide when producing outreach-ready offer text.
|
||||
|
||||
- Translate findings into 1–2 concrete pain-result pairings.
|
||||
- Keep scope, deliverables, and next step explicit.
|
||||
- Tie price framing to measurable benefit and timeline.
|
||||
- Use transparent language and avoid inflated promises.
|
||||
- Return a compact offer draft and optional follow-up variant.
|
||||
9
skills/ux-friction-review.md
Normal file
9
skills/ux-friction-review.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# UX Friction Review
|
||||
|
||||
Use this file when analyzing onboarding, lead capture, and first conversion path friction.
|
||||
|
||||
- Map the main path from landing → action.
|
||||
- Find blockers: too many clicks, hidden links, field overload, unclear status.
|
||||
- Identify trust anchors: who, what, how long, what happens next.
|
||||
- Assess mobile ergonomics and cognitive load.
|
||||
- Recommend friction-reduction experiments with effort and impact.
|
||||
231
tests/audit-skills-schema.test.ts
Normal file
231
tests/audit-skills-schema.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
const schemaPath = join(process.cwd(), "convex", "schema.ts");
|
||||
const auditsPath = join(process.cwd(), "convex", "audits.ts");
|
||||
const schemaSource = readFileSync(schemaPath, "utf8");
|
||||
const auditsSource = readFileSync(auditsPath, "utf8");
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
"audits.ts",
|
||||
auditsSource,
|
||||
ts.ScriptTarget.ES2022,
|
||||
true,
|
||||
);
|
||||
|
||||
function extractTableSection(tableName: string) {
|
||||
const marker = `${tableName}: defineTable({`;
|
||||
const markerIndex = schemaSource.indexOf(marker);
|
||||
assert.notEqual(
|
||||
markerIndex,
|
||||
-1,
|
||||
`Expected schema table definition for ${tableName}.`,
|
||||
);
|
||||
|
||||
const objectStart = schemaSource.indexOf("{", markerIndex);
|
||||
let depth = 0;
|
||||
let objectEnd = -1;
|
||||
|
||||
for (let index = objectStart; index < schemaSource.length; index += 1) {
|
||||
if (schemaSource[index] === "{") {
|
||||
depth += 1;
|
||||
} else if (schemaSource[index] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
objectEnd = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(
|
||||
objectEnd,
|
||||
-1,
|
||||
`Could not parse schema object for ${tableName}.`,
|
||||
);
|
||||
const objectBlock = schemaSource.slice(objectStart, objectEnd + 1);
|
||||
|
||||
return { objectBlock };
|
||||
}
|
||||
|
||||
function extractExportSource(name: string) {
|
||||
const marker = `export const ${name} = `;
|
||||
const declarationIndex = auditsSource.indexOf(marker);
|
||||
assert.notEqual(
|
||||
declarationIndex,
|
||||
-1,
|
||||
`Expected declaration for ${name}.`,
|
||||
);
|
||||
|
||||
const openBraceIndex = auditsSource.indexOf("{", declarationIndex);
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
|
||||
for (let index = openBraceIndex; index < auditsSource.length; index += 1) {
|
||||
const char = auditsSource[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(
|
||||
end,
|
||||
-1,
|
||||
`Expected balanced braces for export ${name}.`,
|
||||
);
|
||||
return auditsSource.slice(openBraceIndex, end + 1);
|
||||
}
|
||||
|
||||
function extractFieldSection(source: string, fieldName: string, nextFieldName: string) {
|
||||
const match = source.match(
|
||||
new RegExp(
|
||||
`${fieldName}:\\s*v\\.optional\\([\\s\\S]*?(?=\\s*${nextFieldName}:)`,
|
||||
),
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
match,
|
||||
null,
|
||||
`Expected ${fieldName} field with expected object structure in schema.`,
|
||||
);
|
||||
|
||||
return match![0];
|
||||
}
|
||||
|
||||
function hasPattern(source: string, pattern: RegExp, message: string) {
|
||||
assert.equal(pattern.test(source), true, message);
|
||||
}
|
||||
|
||||
test("audits schema stores compact usedSkills metadata", () => {
|
||||
const { objectBlock } = extractTableSection("audits");
|
||||
const usedSkillsSection = extractFieldSection(
|
||||
objectBlock,
|
||||
"usedSkills",
|
||||
"skillSummaries",
|
||||
);
|
||||
const skillSummariesSection = extractFieldSection(
|
||||
objectBlock,
|
||||
"skillSummaries",
|
||||
"multimodalSummary",
|
||||
);
|
||||
|
||||
hasPattern(usedSkillsSection, /usedSkills:\s*v\.optional\(/, "usedSkills should be optional.");
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/name:\s*v\.string\(\)/,
|
||||
"usedSkills.name should be string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/category:\s*v\.string\(\)/,
|
||||
"usedSkills.category should be string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/version:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"usedSkills.version should be optional string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"usedSkills.source should be optional string.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/v\.array\(/,
|
||||
"usedSkills should be an optional array of objects.",
|
||||
);
|
||||
hasPattern(
|
||||
usedSkillsSection,
|
||||
/v\.object\(/,
|
||||
"usedSkills should be defined with v.object fields.",
|
||||
);
|
||||
|
||||
hasPattern(skillSummariesSection, /skillSummaries:/, "skillSummaries should still exist.");
|
||||
hasPattern(
|
||||
skillSummariesSection,
|
||||
/name:\s*v\.string\(\)/,
|
||||
"skillSummaries.name should stay string.",
|
||||
);
|
||||
hasPattern(
|
||||
skillSummariesSection,
|
||||
/purpose:\s*v\.string\(\)/,
|
||||
"skillSummaries.purpose should stay string.",
|
||||
);
|
||||
hasPattern(
|
||||
skillSummariesSection,
|
||||
/summary:\s*v\.string\(\)/,
|
||||
"skillSummaries.summary should stay string.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audits.create accepts usedSkills validator and persists metadata payloads", () => {
|
||||
const createSource = extractExportSource("create");
|
||||
|
||||
hasPattern(
|
||||
auditsSource,
|
||||
/const usedSkillsValidator\s*=\s*v\.array\(/,
|
||||
"audits.ts should define a reusable usedSkillsValidator.",
|
||||
);
|
||||
hasPattern(
|
||||
auditsSource,
|
||||
/v\.object\([\s\S]*?name:\s*v\.string\(\)[\s\S]*?category:\s*v\.string\(\)[\s\S]*?version:\s*v\.optional\(\s*v\.string\(\)\s*\)[\s\S]*?source:\s*v\.optional\(\s*v\.string\(\)\s*\)/,
|
||||
"audits.ts should define a reusable usedSkillsValidator.",
|
||||
);
|
||||
|
||||
hasPattern(
|
||||
createSource,
|
||||
/usedSkills:\s*v\.optional\(usedSkillsValidator\)/,
|
||||
"create args should include optional usedSkills field.",
|
||||
);
|
||||
hasPattern(
|
||||
createSource,
|
||||
/ctx\.db\.insert\(\s*["']audits["'][\s\S]*?args[\s\S]*\}/,
|
||||
"create should persist audit payload from args (so usedSkills is stored when provided).",
|
||||
);
|
||||
});
|
||||
|
||||
test("audits.getDetail returns audit + lead context with null-safe lead lookup", () => {
|
||||
const getDetailSource = extractExportSource("getDetail");
|
||||
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/args:\s*{[\s\S]*id:\s*v\.id\(["']audits["']\)[\s\S]*}/,
|
||||
"getDetail should require id argument for audits.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/const\s+audit\s*=\s*await\s+ctx\.db\.get\s*\(\s*args\.id\s*\)/,
|
||||
"getDetail should load audit by id.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/if\s*\(\s*!audit\s*\)\s*{\s*return null;\s*}/,
|
||||
"getDetail should return null when audit is missing.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/const\s+lead\s*=\s*await\s+ctx\.db\.get\s*\(\s*audit\.leadId\s*\)/,
|
||||
"getDetail should load lead by leadId from the audit.",
|
||||
);
|
||||
hasPattern(
|
||||
getDetailSource,
|
||||
/return\s*{\s*audit,\s*lead\s*}/,
|
||||
"getDetail should return { audit, lead }.",
|
||||
);
|
||||
hasPattern(
|
||||
sourceFile.getFullText(),
|
||||
/export const getDetail = query\(/,
|
||||
"audits.ts should export a getDetail query.",
|
||||
);
|
||||
});
|
||||
162
tests/audit-skills-ui.test.ts
Normal file
162
tests/audit-skills-ui.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const source = async (relativePath: string) => {
|
||||
return await readFile(
|
||||
join(process.cwd(), ...relativePath.split("/")),
|
||||
"utf8",
|
||||
);
|
||||
};
|
||||
|
||||
test("audits dashboard page uses a dedicated board component", async () => {
|
||||
const dashboardPageSource = await source("app/dashboard/audits/page.tsx");
|
||||
|
||||
assert.doesNotMatch(
|
||||
dashboardPageSource,
|
||||
/DashboardPlaceholderPage/i,
|
||||
"Dashboard audits route should not render the placeholder page.",
|
||||
);
|
||||
assert.match(
|
||||
dashboardPageSource,
|
||||
/<AuditsBoard \/>/,
|
||||
"Audits board should be mounted from route page.",
|
||||
);
|
||||
assert.match(
|
||||
dashboardPageSource,
|
||||
/"@\/components\/audits\/audits-board"/,
|
||||
"Audits board should be imported from components.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audits board renders compact list with convex list query and core columns", async () => {
|
||||
const boardSource = await source("components/audits/audits-board.tsx");
|
||||
|
||||
assert.match(
|
||||
boardSource,
|
||||
/\"use client\"/,
|
||||
"AuditsBoard must be a Client Component for useQuery.",
|
||||
);
|
||||
assert.match(
|
||||
boardSource,
|
||||
/useQuery\s*\(\s*api\.audits\.list,\s*\{\s*limit:\s*100\s*\}\s*\)/,
|
||||
"AuditsBoard should call api.audits.list with { limit: 100 }.",
|
||||
);
|
||||
assert.match(
|
||||
boardSource,
|
||||
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/,
|
||||
"Audits should be sorted newest first.",
|
||||
);
|
||||
assert.match(boardSource, /Loading|lädt|Lade/i);
|
||||
assert.match(boardSource, /Keine Audits|keine Audits/i);
|
||||
assert.match(boardSource, /Slug/);
|
||||
assert.match(boardSource, /Domain/);
|
||||
assert.match(boardSource, /Status/);
|
||||
assert.match(boardSource, /Seiten/);
|
||||
assert.match(
|
||||
boardSource,
|
||||
/href=\{`\/dashboard\/audits\/\$\{audit\._id\}`\}/,
|
||||
"Each audit row should link to /dashboard/audits/{id}.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audit detail component uses getDetail query and renders skills overview section", async () => {
|
||||
const detailSource = await source("components/audits/audit-detail.tsx");
|
||||
|
||||
assert.match(
|
||||
detailSource,
|
||||
/\"use client\"/,
|
||||
"AuditDetail must be client-side for Convex query calls.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/api\.audits[\s\S]{0,80}getDetail/,
|
||||
"AuditDetail should use api.audits.getDetail query.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/useQuery\(\s*api\.audits\.getDetail,\s*\{/,
|
||||
"AuditDetail should call useQuery with api.audits.getDetail directly.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailSource,
|
||||
/const\s+auditDetailQueryRef/,
|
||||
"AuditDetail should not use a cast-based query fallback variable.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/const\s+audit\s*=\s*result\?\.audit;/,
|
||||
"AuditDetail should destructure audit from result.audit.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/const\s+lead\s*=\s*result\?\.lead;/,
|
||||
"AuditDetail should destructure lead from result.lead.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/leadSummary\(\s*lead\s*\)/,
|
||||
"AuditDetail should pass lead into leadSummary from result.lead.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/usedSkills/,
|
||||
"AuditDetail should inspect usedSkills for overview rendering.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/Keine Skills gespeichert/,
|
||||
"AuditDetail should show fallback text when no skills are saved.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/Verwendete Skills/,
|
||||
"AuditDetail should render Verwendete Skills heading.",
|
||||
);
|
||||
assert.match(
|
||||
detailSource,
|
||||
/Lead|lead/,
|
||||
"AuditDetail should surface lead context when available.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailSource,
|
||||
/<p[^>]*>\s*\{leadSummary\(\s*lead\|[\s\S]*?\)\s*\}\s*<\/p>/,
|
||||
"Lead summary should not wrap leadSummary output in a nested <p>.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailSource,
|
||||
/<p[^>]*>\s*\{leadSummary\(\s*audit\.lead\)\s*\}\s*<\/p>/,
|
||||
"Lead summary should not wrap leadSummary output in a nested <p>.",
|
||||
);
|
||||
});
|
||||
|
||||
test("audits detail route passes id to AuditDetail via Promise params", async () => {
|
||||
const pageSource = await source("app/dashboard/audits/[id]/page.tsx");
|
||||
|
||||
assert.match(
|
||||
pageSource,
|
||||
/params:\s*Promise<\{\s*id:\s*string\s*\}>/,
|
||||
"Audit detail route should accept params as Promise in Next.js 16 style.",
|
||||
);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/const \{\s*id\s*\}\s*=\s*await params/,
|
||||
"Audit detail route should unwrap Promise params.",
|
||||
);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/<AuditDetail\s+id=/,
|
||||
"Audit detail route should pass id prop into AuditDetail.",
|
||||
);
|
||||
});
|
||||
|
||||
test("public audit page does not expose used skills", async () => {
|
||||
const publicAuditSource = await source("app/audit/[slug]/page.tsx");
|
||||
|
||||
assert.doesNotMatch(
|
||||
publicAuditSource,
|
||||
/Verwendete Skills|usedSkills/i,
|
||||
"Public audit page must not show used skills.",
|
||||
);
|
||||
});
|
||||
250
tests/skills-registry.test.ts
Normal file
250
tests/skills-registry.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, sep } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
type AuditUsedSkill,
|
||||
loadSkillsRegistry,
|
||||
parseSkillsRegistry,
|
||||
toAuditUsedSkill,
|
||||
SKILL_CATEGORIES,
|
||||
} from "../lib/skills-registry";
|
||||
|
||||
function assertIncludes(values: readonly string[], value: string) {
|
||||
assert.ok(values.includes(value), `Expected ${value} in [${values.join(", ")}]`);
|
||||
}
|
||||
|
||||
function withTempProjectRegistry(
|
||||
source: string,
|
||||
run: () => Promise<void> | void,
|
||||
) {
|
||||
return mkdtemp(`${tmpdir()}${sep}`).then(async (projectRoot) => {
|
||||
const registryPath = join(projectRoot, "skills.md");
|
||||
const originalCwd = process.cwd();
|
||||
await writeFile(registryPath, source, "utf8");
|
||||
process.chdir(projectRoot);
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("parseSkillsRegistry parses valid entries, trims whitespace, and normalizes category", async () => {
|
||||
const registrySource = `
|
||||
## Design Audit
|
||||
Purpose: Evaluate layout, visual hierarchy, and CTA clarity.
|
||||
When to use: Use when a homepage is available and should be assessed for conversion quality.
|
||||
When not to use: Don't run during technical outages or non-web touchpoints.
|
||||
Required input: Homepage URL, top-level page sections, style language, brand context.
|
||||
Expected output: Prioritized improvement list with concrete design changes.
|
||||
Category: design
|
||||
Version: 2026.06
|
||||
Source: skills/design-audit.md
|
||||
`;
|
||||
|
||||
const parsed = parseSkillsRegistry(registrySource);
|
||||
|
||||
assert.equal(parsed.length, 1);
|
||||
const entry = parsed.at(0);
|
||||
assert.ok(entry);
|
||||
assert.equal(entry!.name, "Design Audit");
|
||||
assert.equal(entry!.purpose, "Evaluate layout, visual hierarchy, and CTA clarity.");
|
||||
assert.equal(entry!.category, "design");
|
||||
assert.equal(entry!.version, "2026.06");
|
||||
assert.equal(entry!.source, "skills/design-audit.md");
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry accepts indented field labels", () => {
|
||||
const registrySource = `
|
||||
## Local SEO Boost
|
||||
Purpose: Evaluate visibility for local search with nearby intent.
|
||||
When to use: Use for local business pages and service locations.
|
||||
When not to use: Avoid for non-local marketing pages.
|
||||
Required input: City, address, NAP consistency.
|
||||
Expected output: Prioritized local SEO recommendations.
|
||||
Category: seo
|
||||
`;
|
||||
|
||||
const parsed = parseSkillsRegistry(registrySource);
|
||||
|
||||
assert.equal(parsed.length, 1);
|
||||
const entry = parsed.at(0);
|
||||
assert.ok(entry);
|
||||
assert.equal(entry!.name, "Local SEO Boost");
|
||||
assert.equal(entry!.purpose, "Evaluate visibility for local search with nearby intent.");
|
||||
assert.equal(entry!.category, "seo");
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry throws for missing required fields", () => {
|
||||
const registrySource = `
|
||||
## UX Friction Review
|
||||
Purpose: Review interaction patterns for friction points.
|
||||
When to use: Use for lead capture and booking flows.
|
||||
When not to use: Use only when there is a user journey.
|
||||
Required input: Session flow and target action.
|
||||
Category: ux
|
||||
`;
|
||||
|
||||
assert.throws(
|
||||
() => parseSkillsRegistry(registrySource),
|
||||
/missing required field "Expected output"/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry throws for unknown category", () => {
|
||||
const registrySource = `
|
||||
## Bad Category Example
|
||||
Purpose: Example.
|
||||
When to use: Example scenario.
|
||||
When not to use: Never for this case.
|
||||
Required input: Example data.
|
||||
Expected output: Example output.
|
||||
Category: analytics
|
||||
`;
|
||||
|
||||
assert.throws(
|
||||
() => parseSkillsRegistry(registrySource),
|
||||
/unknown category "analytics"/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry throws for duplicate skill names", () => {
|
||||
const registrySource = `
|
||||
## Local SEO Boost
|
||||
Purpose: Strengthen local SERPs.
|
||||
When to use: Use for local service businesses.
|
||||
When not to use: Not for international-only landing pages.
|
||||
Required input: Name, address, service area.
|
||||
Expected output: Local SEO gaps and quick wins.
|
||||
Category: seo
|
||||
|
||||
## Local SEO Boost
|
||||
Purpose: Another local SEO pass.
|
||||
When to use: Use for new regions.
|
||||
When not to use: Skip for pure lead-gen pages.
|
||||
Required input: Name, address, service area.
|
||||
Expected output: Competitor baseline.
|
||||
Category: seo
|
||||
`;
|
||||
|
||||
assert.throws(
|
||||
() => parseSkillsRegistry(registrySource),
|
||||
/duplicate skill name "Local SEO Boost"/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSkillsRegistry accepts all configured categories", () => {
|
||||
assertIncludes(SKILL_CATEGORIES, "design");
|
||||
assertIncludes(SKILL_CATEGORIES, "ux");
|
||||
assertIncludes(SKILL_CATEGORIES, "marketing");
|
||||
assertIncludes(SKILL_CATEGORIES, "copy");
|
||||
assertIncludes(SKILL_CATEGORIES, "seo");
|
||||
assertIncludes(SKILL_CATEGORIES, "offer");
|
||||
|
||||
const registrySource = SKILL_CATEGORIES.map(
|
||||
(category) => `
|
||||
## ${category}-skill
|
||||
Purpose: Valid for ${category}.
|
||||
When to use: Use for ${category} tasks.
|
||||
When not to use: Skip when ${category} is not in scope.
|
||||
Required input: Category inputs.
|
||||
Expected output: Category-specific recommendations.
|
||||
Category: ${category}
|
||||
`,
|
||||
).join("\n\n");
|
||||
|
||||
const parsed = parseSkillsRegistry(registrySource);
|
||||
assert.equal(parsed.length, SKILL_CATEGORIES.length);
|
||||
for (const category of SKILL_CATEGORIES) {
|
||||
const match = parsed.find((entry) => entry.name === `${category}-skill`);
|
||||
assert.ok(match, `Expected parsed entry for ${category}`);
|
||||
assert.equal(match.category, category);
|
||||
}
|
||||
});
|
||||
|
||||
test("loadSkillsRegistry reads skills.md from process.cwd() by default", async () => {
|
||||
await withTempProjectRegistry(
|
||||
`
|
||||
## Offer Writing
|
||||
Purpose: Build offer-focused copy for outreach.
|
||||
When to use: Use before drafting proposals.
|
||||
When not to use: Avoid when no offer exists.
|
||||
Required input: Offer structure and pricing envelope.
|
||||
Expected output: Offer draft and pricing emphasis.
|
||||
Category: offer
|
||||
`,
|
||||
async () => {
|
||||
const parsed = await loadSkillsRegistry();
|
||||
const parsedEntry = parsed.find((entry) => entry.name === "Offer Writing");
|
||||
|
||||
assert.ok(parsedEntry);
|
||||
assert.equal(parsedEntry.category, "offer");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("loadSkillsRegistry accepts an explicit registry path", async () => {
|
||||
const projectRoot = await mkdtemp(`${tmpdir()}${sep}`);
|
||||
const registryPath = join(projectRoot, "seed-skills.md");
|
||||
|
||||
await writeFile(
|
||||
registryPath,
|
||||
`
|
||||
## Design Audit
|
||||
Purpose: Validate design quality for local business pages.
|
||||
When to use: Use for a quick visual prioritization pass.
|
||||
When not to use: Skip when no public page exists.
|
||||
Required input: Homepage URL and target conversion goal.
|
||||
Expected output: Ranked design actions with confidence.
|
||||
Category: design
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
const parsed = await loadSkillsRegistry(registryPath);
|
||||
assert.equal(parsed.at(0)?.name, "Design Audit");
|
||||
} finally {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("toAuditUsedSkill returns only required audit-facing fields", () => {
|
||||
const skill = {
|
||||
name: "Copy Clarity",
|
||||
purpose: "Reduce complexity and improve readability.",
|
||||
whenToUse: "When existing copy is verbose.",
|
||||
whenNotToUse: "Skip if website is plain text-only.",
|
||||
requiredInput: "Page sections and CTAs.",
|
||||
expectedOutput: "A concise writing pass.",
|
||||
category: "copy",
|
||||
version: "1.0",
|
||||
source: "skills/copy-clarity.md",
|
||||
} satisfies {
|
||||
name: string;
|
||||
purpose: string;
|
||||
whenToUse: string;
|
||||
whenNotToUse: string;
|
||||
requiredInput: string;
|
||||
expectedOutput: string;
|
||||
category: "copy";
|
||||
version: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
const auditUsed = toAuditUsedSkill(skill);
|
||||
const expected: AuditUsedSkill = {
|
||||
name: "Copy Clarity",
|
||||
category: "copy",
|
||||
version: "1.0",
|
||||
source: "skills/copy-clarity.md",
|
||||
};
|
||||
|
||||
assert.deepEqual(auditUsed, expected);
|
||||
});
|
||||
Reference in New Issue
Block a user