mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
* skill: drop quality tiers, keep the real brand-craft guardrails Codex's craft/brand pass introduced fast/ship/showpiece "quality bars" plus brand-specific build gates, asset ledgers, sub-agent review, and self-graded fallback labels. In practice those tiers became escape hatches rather than craft pressure: the final output should always be 10/10, and the real decision points are splashiness and maximalism, not quality. Removed: - All quality-bar / showpiece / fast / ship framing in shape.md and craft.md - Standalone Brand Direction (#4) and Asset Requirements (#10) sections in shape's brief; renumbered back to 1-10 - The Brand hard rules section in brand.md (folded its real prohibitions into the existing Imagery and Brand bans sections) - Brand-specific build-gate item, mock-fidelity bullet, production-bar bullet, present-step bullet in craft.md - Asset ledger ceremony in craft Step 4 - Review-only sub-agents and "self-reviewed fallback, not independently validated" machinery in craft.md and polish.md - The For brand surfaces, assess hard failures subsection in polish.md and the brand checklist row - tests/brand-showpiece-reference.test.mjs (and its package.json wiring) Kept (the real nuggets): - Asset-substitution prohibition: image-led briefs ship real/generated assets or canvas/SVG/WebGL, not generic CSS panels, cards, bullets, or copy - Repeated tiny uppercase tracked kicker labels as a brand ban - Detector/QA output is defect evidence only, never proof of quality - "What visual assets are real content here?" discovery question - Inspect each major section individually for brand and long-form work - repeated-section-kickers detection rule + fixture - CLI improvements (JSON to stdout, -json/-fast aliases, severity field) - critique.md: npx impeccable detect --json fix Harness output dirs refreshed via bun run build. Full test suite (186) passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * skill: strip gate ceremony; require shape pause; allow compact briefs The setup gate table and IMPECCABLE_PREFLIGHT banner pushed every craft run through ritual restatement (PRODUCT.md → original prompt → round 1 → round 2 → 70-line "confirmed brief" → critique → summary, all saying the same thing). Replaced with imperative prose that still demands the same work but skips the user-facing telemetry. Specifically: SKILL.md - Drop the Setup gate table and IMPECCABLE_PREFLIGHT banner. - Keep the imperative steps explicitly: load context, identify register and load brand.md or product.md, AND load the matching command reference (craft.md / shape.md / etc.) when a sub-command is invoked. The command-reference step is non-negotiable; without craft.md loaded the agent skips the shape-and-confirm pause. craft.md - Drop the Build Gate / Craft Contract formal sections; replace with one paragraph stating prerequisites. - Step 1 explicitly requires ending the response after presenting the shape output; the user must confirm before any code lands. Allows a compact 3-5 bullet brief when the prompt + PRODUCT.md already pin direction (full 10-section structure reserved for genuinely ambiguous tasks). - Step 3 image gate skips silently when image generation isn't natively available; no user-facing announcement. - Step 6 explicitly legitimizes "first pass clean, shipping" as a valid endpoint and bans inventing fake defects to demonstrate iteration. shape.md - Cap discovery at 1 round by default; second round only when first leaves material gaps. - Adds an "assert-then-confirm, not menu-with-escape" rule: when PRODUCT.md and the prompt make one option obvious, name it and ask for confirm or override instead of enumerating "Restrained / Committed / Or something else?" as a real choice. - Phase 2 brief has two forms now: compact (default for clear briefs) and full structured (genuinely ambiguous). Open Questions can't double as leading-with-Recommend; if you'd write "Recommend: X", decide X. - Image gate same as craft.md. Validated end-to-end with a Haiku skill-on observability run: agent loads craft.md plus the brief's recommended implementation refs, pauses for one productive question (accent color, trace fidelity, CTA), and ships an artifact with zero side-tab violations vs. the original v1 baseline. Cost trades up modestly for that quality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * craft.md Step 6: Reading the screenshot is the inspection, not taking it A v4 eval run took 4 targeted screenshots (hero, mobile, tablet, query-section) and then never Read any of them back. The agent treated browser_screenshot itself as "I inspected" and shipped without the multimodal feedback loop ever closing. Detector caught the resulting slop (5+ side-tab violations) on adjacent runs that did the same thing. Step 6 now spells out the pattern explicitly: take the screenshot, then Read the resulting PNG so its image content enters the conversation as multimodal input, then critique what you actually see in the image. With a check: "if your critique could have been written without looking at the image, you didn't look at the image." Validated with v5b: agent took 6 screenshots, Read all 6 back, and shipped with zero detector findings (vs the previous greenfield runs that hit 1-12 findings each). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * craft + brand: framework foundation, build-pipeline respect, image verification Three closely-linked additions surfaced by an eval-harness session investigating why the agent always shipped flat single-file HTML and zero imagery on greenfield brand briefs. 1. craft.md gains a new Step 0 "Project Foundation" before Shape. Detects existing framework / component library / icon set and uses what's there. Greenfield: ask the user via AskUserQuestion with sensible defaults framed by the brief (Astro for content/ brand sites, SvelteKit/Next/Nuxt for app surfaces, single index.html only for one-shot demos). Skipping the framework decision and writing flat HTML "to satisfy the spec" produces work that reads as a 2018 prototype regardless of visual quality. 2. craft.md Step 5 production bar gains two bullets: - Respect the build pipeline. Edit source files and run the project's `npm run build`; do not write to build/ / dist/ / .next/ directly with cat/heredoc/Bash redirects. Bypassing the pipeline skips asset hashing, image optimization, code splitting, and CSS extraction. - Verify external image URLs before referencing them. Use an image-search MCP, web-fetch tool, or browser if available; guessed photo IDs ship as broken-image placeholders. 3. brand.md "Imagery" section: - Generalizes the Unsplash URL guidance to "verify URLs before referencing them" with a hierarchy: image-search MCP > web-fetch > confidence-restricted manual selection > fewer photos. - Tightens the tech/dev-tool exception. Old line "zero imagery can be correct" gave models a permission slip. New framing keeps the underlying truth (typography + code + diagrams primarily carry voice) but raises the floor: imagery still earns its place when it serves the brief, and skipping it requires naming the typographic/diagrammatic move that's carrying the visual weight instead. "Zero imagery is the failure mode of laziness, not restraint." Eval-harness corpus that prompted this: 19/19 brand landing tasks shipped 0 images each, including ones where Opus had taste enough to break the dev-tool color default lane. The skill needs to teach both halves of the decision; the harness shouldn't have to nudge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * detector: body-text-viewport-edge rule + OKLCH/var-resolution + anchor-inherit FP fixes New rule: body-text-viewport-edge flags body paragraphs that render flush against the left/right viewport edges (no container padding). Tested via the new tests/fixtures/antipatterns/body-text-viewport-edge.html fixture (3 flag cases, 5 pass cases) and the test in detect-antipatterns-browser. False-positive class fixes — all jsdom-mode only (real browsers resolve the cascade correctly so these gates stay inert there). Five related gaps that compounded into ~14× spurious contrast findings on Tailwind v4 pages with OKLCH color tokens: • OKLCH parser. jsdom returns the literal "oklch(...)" string from getComputedStyle; the detector now converts to sRGB via Björn Ottosson's matrices. Handles Tailwind v4's compact minified form "oklch(21.5%.02 50)" (no space after %). • var() resolution. resolveBackground + checkElementColors now accept the existing customPropMap and parse `var(--color-paper)` etc. as proper RGB via the new parseColorResolved helper. • bg-color before bg-image. The old order bailed on any gradient ancestor before checking for a solid background-color underneath, causing the body's decorative paper-grain gradient to be measured against instead of the page's actual `bg-paper` cream. • body/html-level gradient → white fallback. When the only opaque ancestor we can read is body/html with a gradient overlay (and jsdom can't decompose `background: var(--paper) gradient` to extract the solid color), return white instead of falling through to resolveGradientStops — which was picking up paper-grain noise colors and using them as the bg. • Anchor-inherit workaround for jsdom :link UA specificity. Tailwind v4's preflight declares `a { color: inherit }` (0,0,1). jsdom's UA stylesheet has `:link { color: blue }` at (0,1,1) and wins the cascade. Real Chrome wraps :link in :where() (0,0,0) so the page rule wins. When the page declares the inherit rule AND we see jsdom's default `rgb(0,0,238)` on an anchor, walk to the nearest non-anchor ancestor and use its color. • Alpha-fallback safety gate. When text has alpha<1 AND we couldn't find an opaque ancestor (effectiveBg null), skip the contrast finding. Covers any remaining FP class the deeper fixes miss. Verified end-to-end against an Opus iter-1 artifact on Tailwind v4 with 14 cream/cream FPs + 2 blue-link UA FPs before; 0 findings after, while the color.html fixture's 12 real low-contrast cases continue to flag (verified via direct detectHtml calls). cli/engine/detect-antipatterns-browser.js is the generated browser distribution — regenerated from .mjs via scripts/build-browser-detector.js (no manual edits to the generated file). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * craft.md: tighten verbose passages, de-codex Step 6, cut redundancies Cumulative reduction: 218 → 155 lines (-29%). Step 0: drop the "Why this matters" paragraph at the end. The body of Step 0 already makes the framework-pick point; the paragraph just re-explains it with extra rhetoric. Step 1: replace the 4-sentence "you must end your response" block with a single line. The original said the same thing three different ways. Step 3: trim the conditional / defensive scaffolding (Purpose subsection, "do not skip because the eventual UI is semantic..." paragraph, duplicated approval-loop guidance). Mock fidelity inventory preserved. Step 4: drop the "keep UI text semantic" sentence; it duplicates Step 5's "Semantic first" rule. The rasterized-vs-semantic decision rule stays. Step 5: tighten each production-bar bullet to bold-lead + specifics format. All 15 rules preserved (real content, mock ingredients, semantic first, spacing/alignment, typography, state coverage, interaction quality, icon set, build pipeline, image URL verification, optimized imagery, premium motion, maintainability, technical cleanliness, ask-when-uncertain). Step 6: rewrite around "look at what you built like a designer would — your eyes are whatever the harness gives you." Drops Codex-specific "In Codex, use browser-use" bias. Drops the verbose 3-step Read pattern (condensed to one sentence). Drops the 1-8 numbered checklist (replaced by a tight paragraph). Keeps the load-bearing rules: read the PNG, don't fabricate iteration, mock fidelity reference, exit bar = studio defensibility. Step 7: drop the closing "Iterate based on feedback. Good design is rarely right on the first pass" preachy filler. All em-dashes converted to semicolons / colons / periods to satisfy the skill prose validator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * build: native subagent pipeline + Codex-only asset producer Adds an agent cross-compile pipeline alongside the existing skill pipeline. Sources live at skill/agents/*.md; providers that declare agentFormat (codex-toml, claude-md) emit native subagent files. An optional providers: <list> field on an agent gates which harnesses get a copy; default (no field) ships everywhere. The impeccable-asset-producer agent is opt-in to Codex only. It's useful for Codex's native image generation path and is untested elsewhere; Claude has no native image gen anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * brand: inverse-test + cultural-symbol palette guardrail Two additions to the brand register reference: - Inverse slop test: describe the page the way a competitor would describe theirs. If that sentence fits the modal landing page in the category, restart. - Palette guardrail: when a cultural-symbol palette is the obvious pull, reach past it. Let cultural reading come from typography, imagery, and copy. Harness mirrors regenerated; some also catch up to the image- verification paragraph frome3ad2efthat hadn't been re-synced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PRODUCT.md: widen audience beyond developers Designers, product managers, and engineers all use AI coding tools and want better design output. Keeping the audience narrow to "frontend and full-stack developers" understates who the skill is actually for. Also retitles "developer" to "user/builder" in the purpose statement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * site + build: bump rule count to 29, strip changelog from detector check Two changes: - site/pages/index.astro: three live mentions of "28 rules / checks" bumped to 29 after the body-text-viewport-edge rule landed inb9bf496. - scripts/build.js: the detection-count validator was reading the unstripped content, so historical counts inside changelog entries (e.g. "28 rules" from an older release note) were flagging against the current detector total. The command-count check already strips the changelog ul; the detection check now does the same. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: align hero-eyebrow-chip fixture with relaxed rule gatesb9bf496intentionally relaxed two gates in checkHeroEyebrow: - removed the heading-size ≥ 48px anchor (modern hero h1s use clamp/vw/var that jsdom can't resolve) - raised the eyebrow text ceiling from 30 to 60 chars Two fixture cases that satisfied the negative side of the old gates now match the rule: - "Body-Sized Heading Below Eyebrow" — 24px h1 with tracked-caps label above. Per the rule's stated intent ("a tiny tan label directly above any h1 is the antipattern regardless of how big the h1 ends up"), this is a flag. - "Long Uppercase Sentence Above Hero" — 46-char tracked-caps label is under the new 60-char ceiling, so still eyebrow-shaped. Both cases moved from the should-pass column to should-flag, with case descriptions rewritten to explain the gate they exercise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
771 lines
31 KiB
JavaScript
771 lines
31 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Build System for Cross-Provider Design Skills
|
|
*
|
|
* Transforms source skills into provider-specific formats:
|
|
* - Cursor: .cursor/skills/
|
|
* - Claude Code: .claude/skills/
|
|
* - Gemini: .gemini/skills/
|
|
* - Codex: dist/codex/ only (OpenAI-metadata bundle; not synced to repo root)
|
|
* - Agents: .agents/skills/ (Codex repo/user installs)
|
|
* - GitHub: .github/skills/ (GitHub Copilot)
|
|
*
|
|
* Also assembles a universal ZIP containing all providers,
|
|
* and builds Tailwind CSS for production deployment.
|
|
*/
|
|
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js';
|
|
import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
|
|
import { createAllZips } from './lib/zip.js';
|
|
// Sub-page generation is now handled by Astro content collections.
|
|
|
|
/**
|
|
* Generate authoritative counts from source data and write to site/public/js/generated/counts.js.
|
|
* Also validates that key HTML files reference the correct numbers.
|
|
*/
|
|
function generateCounts(rootDir, skills, buildDir) {
|
|
// Count active commands. After the v3.0 consolidation, commands are sub-commands
|
|
// of /impeccable. Count them from the command router table in SKILL.md.
|
|
const impeccableSkill = skills.find(s => s.name === 'impeccable');
|
|
let commandCount;
|
|
if (impeccableSkill) {
|
|
// Count lines in the command table that start with | `...` | — tolerant
|
|
// of argument hints inside the backticks (e.g. `craft [feature]`) and of
|
|
// multi-word commands (e.g. `pin <command>`).
|
|
const routerMatches = impeccableSkill.body.match(/^\| `[^`]+` \|/gm);
|
|
commandCount = routerMatches ? routerMatches.length : 0;
|
|
} else {
|
|
// Fallback: count user-invocable skills
|
|
const activeCommands = skills.filter(s => {
|
|
if (!s.userInvocable) return false;
|
|
const content = fs.readFileSync(s.filePath, 'utf-8');
|
|
return !content.includes('DEPRECATED');
|
|
});
|
|
commandCount = activeCommands.length;
|
|
}
|
|
|
|
// Count detection rules from impeccable package
|
|
const detectPkgPath = path.join(rootDir, 'cli/engine/detect-antipatterns.mjs');
|
|
const detectorSrc = fs.readFileSync(detectPkgPath, 'utf-8');
|
|
const ruleIds = new Set();
|
|
for (const match of detectorSrc.matchAll(/^\s+id: '([^']+)'/gm)) {
|
|
ruleIds.add(match[1]);
|
|
}
|
|
const detectionCount = ruleIds.size;
|
|
|
|
// Write generated counts module
|
|
const genDir = path.join(rootDir, 'site/public/js/generated');
|
|
fs.mkdirSync(genDir, { recursive: true });
|
|
fs.writeFileSync(path.join(genDir, 'counts.js'),
|
|
`// GENERATED by build.js — do not edit\n` +
|
|
`export const COMMAND_COUNT = ${commandCount};\n` +
|
|
`export const DETECTION_COUNT = ${detectionCount};\n`
|
|
);
|
|
|
|
// Validate counts in key files
|
|
const filesToCheck = [
|
|
'site/pages/index.astro',
|
|
'README.md',
|
|
'NOTICE.md',
|
|
'AGENTS.md',
|
|
'.claude-plugin/plugin.json',
|
|
'.claude-plugin/marketplace.json',
|
|
];
|
|
|
|
let errors = 0;
|
|
for (const relPath of filesToCheck) {
|
|
const absPath = path.join(rootDir, relPath);
|
|
if (!fs.existsSync(absPath)) continue;
|
|
const content = fs.readFileSync(absPath, 'utf-8');
|
|
|
|
// Check for stale command counts (look for "N commands" or "N skills" patterns)
|
|
// Strip changelog list content to avoid flagging historical counts
|
|
const strippedContent = content.replace(/<ul class="changelog-items">[\s\S]*?<\/ul>/g, '');
|
|
const countPattern = /\b(\d+)\s+(design\s+)?(commands|sub-commands|skills|steering commands)/gi;
|
|
for (const match of strippedContent.matchAll(countPattern)) {
|
|
const num = parseInt(match[1]);
|
|
// Allow 1 (for "1 skill") and the correct count
|
|
if (num !== commandCount && num !== 1) {
|
|
console.error(` ❌ ${relPath}: found "${match[0]}" but active command count is ${commandCount}`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
// Check for stale detection counts. Use the changelog-stripped content
|
|
// so historical counts in changelog entries (e.g. "28 rules" from an
|
|
// older release) don't flag against the current detector total.
|
|
const detectPattern = /\b(\d+)\s+(deterministic\s+)?(checks|patterns|rules|detections)/gi;
|
|
for (const match of strippedContent.matchAll(detectPattern)) {
|
|
const num = parseInt(match[1]);
|
|
if (num !== detectionCount && num > 10) { // ignore small numbers like "3 patterns"
|
|
console.error(` ❌ ${relPath}: found "${match[0]}" but detection count is ${detectionCount}`);
|
|
errors++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errors > 0) {
|
|
console.error(`\n❌ ${errors} stale count reference(s) found. Update them to match source of truth.`);
|
|
}
|
|
|
|
console.log(`✓ Generated counts: ${commandCount} commands, ${detectionCount} detection rules`);
|
|
return errors;
|
|
}
|
|
|
|
function validateSkillFrontmatter(skills) {
|
|
let errors = 0;
|
|
|
|
for (const skill of skills) {
|
|
if (skill.description && skill.description.length > 1024) {
|
|
console.error(`❌ ${skill.filePath}: invalid description: exceeds maximum length of 1024 characters (${skill.description.length})`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Scan user-facing copy for AI-prose anti-patterns:
|
|
* - em dashes (— or —)
|
|
* - double-hyphen substitutes (` -- `)
|
|
* - denylisted phrases that read as AI tells in marketing copy
|
|
*
|
|
* The denylist is the editorial brief in STYLE.md, enforced. Each rule has a
|
|
* rationale that prints with the failure so the next author understands why.
|
|
*
|
|
* Scope: every surface a reader sees. Not skill/, where
|
|
* LLM-facing reference instructions can use technical phrasings the marketing
|
|
* copy can't.
|
|
*
|
|
* Returns the number of occurrences found. Build fails if > 0.
|
|
*/
|
|
function validateProse(rootDir) {
|
|
const targets = [
|
|
'site/components',
|
|
'site/content',
|
|
'site/layouts',
|
|
'site/pages',
|
|
'README.md',
|
|
'README.npm.md',
|
|
];
|
|
const extensions = new Set(['.html', '.md', '.js', '.mjs', '.css', '.astro']);
|
|
const emDashPatterns = [/—/g, /—/gi, /—/gi, /—/gi];
|
|
// Phrase rules: { re, rationale }. Add to STYLE.md when adding here.
|
|
const phraseRules = [
|
|
{ re: /\bload-bearing\b/i, rationale: 'AI tell. Stolen-engineer diction; almost always vague. Name what the thing actually does.' },
|
|
{ re: /\bhighest-leverage\b/i, rationale: 'AI tell. Vague claim of impact. Say what specifically pays off.' },
|
|
{ re: /\bbiggest unlock\b/i, rationale: 'AI tell. Marketing-speak. Describe the actual change.' },
|
|
{ re: /\breflex defaults?\b/i, rationale: 'Internal jargon leaking into user-facing copy. Say "instincts" or "first guesses".' },
|
|
{ re: /\bcollapses? into monoculture\b/i, rationale: 'Internal eval-speak. Describe what actually went wrong.' },
|
|
{ re: /\bdata-driven\b/i, rationale: 'Empty marketing adjective. Cite the data instead.' },
|
|
{ re: /\bseamless(?:ly)?\b/i, rationale: 'Hollow positive. Say what specifically works without friction.' },
|
|
{ re: /\brobust(?:ness)?\b/i, rationale: 'Hollow positive. Cite the failure mode it handles.' },
|
|
{ re: /\bdelves?\b|\bdelved\b|\bdelving\b/i, rationale: 'Top AI tell. Use "explore", "look at", or just delete.' },
|
|
{ re: /\belevate(?:s|d)?\b/i, rationale: 'Marketing verb. Use the specific verb (improve, raise, sharpen).' },
|
|
{ re: /\bempower(?:s|ed|ing)?\b/i, rationale: 'Marketing verb. Use "let you" or "make possible".' },
|
|
{ re: /\bunderscore(?:s|d)?\b/i, rationale: 'AI tell. Use "show" or "make clear".' },
|
|
{ re: /\bpivotal\b/i, rationale: 'Hollow positive. Use "central", "key", or describe the role.' },
|
|
{ re: /\bin today's\b/i, rationale: 'Throat-clearing opener. Cut the clause; start at the point.' },
|
|
{ re: /\bgone are the days\b/i, rationale: 'Throat-clearing. Make the point directly.' },
|
|
{ re: /\bwhether you're\b/i, rationale: 'Audience-pandering. Pick one reader; write to them.' },
|
|
{ re: /\blet's dive in\b/i, rationale: 'Throat-clearing. Just start.' },
|
|
{ re: /\bin summary\b|\bin conclusion\b/i, rationale: 'Summarizing closer. End on the strongest sentence; trust the reader.' },
|
|
{ re: /\bmoreover\b|\bfurthermore\b/i, rationale: 'Transition crutch on a metronome. Drop, or use "also".' },
|
|
{ re: /\btapestry\b/i, rationale: 'AI scenery noun. Cut.' },
|
|
];
|
|
let errors = 0;
|
|
|
|
const checkLine = (line, rel, lineNum) => {
|
|
for (const re of emDashPatterns) {
|
|
if (re.test(line)) {
|
|
console.error(` ❌ ${rel}:${lineNum}: em dash → ${line.trim().slice(0, 120)}`);
|
|
console.error(` Use commas, colons, semicolons, periods, or parentheses.`);
|
|
errors++;
|
|
re.lastIndex = 0;
|
|
break;
|
|
}
|
|
re.lastIndex = 0;
|
|
}
|
|
if (/ -- /.test(line)) {
|
|
console.error(` ❌ ${rel}:${lineNum}: \` -- \` em-dash substitute → ${line.trim().slice(0, 120)}`);
|
|
console.error(` Worse than the em dash. Pick real punctuation.`);
|
|
errors++;
|
|
}
|
|
for (const rule of phraseRules) {
|
|
if (rule.re.test(line)) {
|
|
const matched = line.match(rule.re)?.[0] ?? '';
|
|
console.error(` ❌ ${rel}:${lineNum}: "${matched}" → ${line.trim().slice(0, 120)}`);
|
|
console.error(` ${rule.rationale}`);
|
|
errors++;
|
|
}
|
|
}
|
|
};
|
|
|
|
const scan = (absPath, rel) => {
|
|
const stat = fs.statSync(absPath);
|
|
if (stat.isDirectory()) {
|
|
for (const entry of fs.readdirSync(absPath)) {
|
|
scan(path.join(absPath, entry), path.join(rel, entry));
|
|
}
|
|
return;
|
|
}
|
|
if (!extensions.has(path.extname(absPath))) return;
|
|
const src = fs.readFileSync(absPath, 'utf-8');
|
|
const lines = src.split('\n');
|
|
lines.forEach((line, i) => checkLine(line, rel, i + 1));
|
|
};
|
|
|
|
for (const target of targets) {
|
|
const full = path.join(rootDir, target);
|
|
if (fs.existsSync(full)) scan(full, target);
|
|
}
|
|
|
|
if (errors === 0) {
|
|
console.log(`✓ Prose validator: no AI tells in user-facing copy`);
|
|
} else {
|
|
console.error(`\n❌ ${errors} prose issue(s) in user-facing copy. See STYLE.md for the rules.`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Narrow prose check for the impeccable skill source.
|
|
*
|
|
* The full validateProse rules don't fit LLM-facing reference instructions:
|
|
* the hardening repetition and triadic checklists those files use exist on
|
|
* purpose, and the structural-prose rules in STYLE.md require human judgment.
|
|
* This validator only enforces the mechanical wins: em dashes (which are
|
|
* pure punctuation laziness regardless of audience) and the small handful
|
|
* of denylisted phrases that have no technical reading. Em-dash creep is the
|
|
* only thing likely to come back at scale once humans stop watching.
|
|
*
|
|
* Returns the number of occurrences found. Build fails if > 0.
|
|
*/
|
|
function validateSkillProse(rootDir) {
|
|
const target = 'skill';
|
|
const extensions = new Set(['.md']);
|
|
const emDashPatterns = [/—/g, /—/gi, /—/gi, /—/gi];
|
|
// Tighter than validateProse: only the rules that have no technical reading.
|
|
// Skipping `data-driven` here would be a mistake (it slipped through twice
|
|
// in live.md before this pass); but `seamless`, `robust`, etc. have
|
|
// legitimate technical uses elsewhere we may want to allow.
|
|
const phraseRules = [
|
|
{ re: /\bload-bearing\b/i, rationale: 'AI tell. Name what the thing actually does.' },
|
|
{ re: /\bhighest-leverage\b/i, rationale: 'AI tell. Say what specifically pays off.' },
|
|
{ re: /\bbiggest unlock\b/i, rationale: 'Marketing-speak. Describe the actual change.' },
|
|
{ re: /\breflex defaults?\b/i, rationale: 'Internal jargon. Say "instincts" or "first guesses".' },
|
|
{ re: /\bcollapses? into monoculture\b/i, rationale: 'Eval-speak. Describe what actually went wrong.' },
|
|
{ re: /\bdata-driven\b/i, rationale: 'Empty marketing adjective. Cite the data instead.' },
|
|
{ re: /\bdelves?\b|\bdelved\b|\bdelving\b/i, rationale: 'Top AI tell. Use "explore" or "look at".' },
|
|
{ re: /\btapestry\b/i, rationale: 'AI scenery noun. Cut.' },
|
|
{ re: /\bin today's\b/i, rationale: 'Throat-clearing opener. Start at the point.' },
|
|
{ re: /\bgone are the days\b/i, rationale: 'Throat-clearing. Make the point directly.' },
|
|
{ re: /\blet's dive in\b/i, rationale: 'Throat-clearing. Just start.' },
|
|
{ re: /\bin summary\b|\bin conclusion\b/i, rationale: 'Summarizing closer. End on the strongest sentence.' },
|
|
];
|
|
let errors = 0;
|
|
|
|
const checkLine = (line, rel, lineNum) => {
|
|
for (const re of emDashPatterns) {
|
|
if (re.test(line)) {
|
|
console.error(` ❌ ${rel}:${lineNum}: em dash → ${line.trim().slice(0, 120)}`);
|
|
console.error(` Use commas, colons, semicolons, periods, or parentheses.`);
|
|
errors++;
|
|
re.lastIndex = 0;
|
|
break;
|
|
}
|
|
re.lastIndex = 0;
|
|
}
|
|
if (/ -- /.test(line)) {
|
|
console.error(` ❌ ${rel}:${lineNum}: \` -- \` em-dash substitute → ${line.trim().slice(0, 120)}`);
|
|
console.error(` Worse than the em dash. Pick real punctuation.`);
|
|
errors++;
|
|
}
|
|
for (const rule of phraseRules) {
|
|
if (rule.re.test(line)) {
|
|
const matched = line.match(rule.re)?.[0] ?? '';
|
|
console.error(` ❌ ${rel}:${lineNum}: "${matched}" → ${line.trim().slice(0, 120)}`);
|
|
console.error(` ${rule.rationale}`);
|
|
errors++;
|
|
}
|
|
}
|
|
};
|
|
|
|
const scan = (absPath, rel) => {
|
|
const stat = fs.statSync(absPath);
|
|
if (stat.isDirectory()) {
|
|
for (const entry of fs.readdirSync(absPath)) {
|
|
scan(path.join(absPath, entry), path.join(rel, entry));
|
|
}
|
|
return;
|
|
}
|
|
if (!extensions.has(path.extname(absPath))) return;
|
|
const src = fs.readFileSync(absPath, 'utf-8');
|
|
const lines = src.split('\n');
|
|
lines.forEach((line, i) => checkLine(line, rel, i + 1));
|
|
};
|
|
|
|
const full = path.join(rootDir, target);
|
|
if (fs.existsSync(full)) scan(full, target);
|
|
|
|
if (errors === 0) {
|
|
console.log(`✓ Skill prose validator: skill/ is clean`);
|
|
} else {
|
|
console.error(`\n❌ ${errors} prose issue(s) in skill/. See STYLE.md.`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Validate that every hand-authored HTML page carries the shared site header.
|
|
* The partial is stamped with `<!-- site-header v1 -->` so drift is loud.
|
|
*
|
|
* Returns the number of validation errors. Build fails if > 0.
|
|
*/
|
|
function validateSiteHeader(_rootDir) {
|
|
// With Astro, the shared header is a component (site/components/Header.astro).
|
|
// There's nothing to validate per-page — the component is imported by Base.astro
|
|
// and rendered identically everywhere. This function is kept as a no-op so the
|
|
// call site doesn't need to change.
|
|
console.log('✓ Site header is a shared Astro component (no per-page validation needed)');
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Copy directory recursively
|
|
*/
|
|
function copyDirSync(src, dest) {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const srcPath = path.join(src, entry.name);
|
|
const destPath = path.join(dest, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copyDirSync(srcPath, destPath);
|
|
} else {
|
|
fs.copyFileSync(srcPath, destPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const ROOT_DIR = path.resolve(__dirname, '..');
|
|
const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
|
|
|
// buildStaticSite (Bun HTML bundler) removed — now handled by Astro.
|
|
|
|
/**
|
|
* Assemble universal directory from all provider outputs
|
|
*/
|
|
function assembleUniversal(distDir) {
|
|
const universalDir = path.join(distDir, 'universal');
|
|
|
|
// Clean and recreate
|
|
if (fs.existsSync(universalDir)) {
|
|
fs.rmSync(universalDir, { recursive: true, force: true });
|
|
}
|
|
|
|
const providerConfigs = Object.values(PROVIDERS);
|
|
|
|
for (const { provider, configDir } of providerConfigs) {
|
|
const src = path.join(distDir, provider, configDir);
|
|
const dest = path.join(universalDir, configDir);
|
|
if (fs.existsSync(src)) {
|
|
copyDirSync(src, dest);
|
|
}
|
|
}
|
|
|
|
// Add a visible README so macOS users don't see an empty folder
|
|
// (all provider dirs are dotfiles, hidden by default in Finder)
|
|
fs.writeFileSync(path.join(universalDir, 'README.txt'),
|
|
`Impeccable. Design fluency for AI harnesses.
|
|
https://impeccable.style
|
|
|
|
This folder contains skills for all supported tools:
|
|
|
|
.cursor/ -> Cursor
|
|
.claude/ -> Claude Code
|
|
.gemini/ -> Gemini CLI
|
|
.codex/ -> Codex custom agents (Codex skills use .agents/)
|
|
.agents/ -> Codex CLI
|
|
.github/ -> GitHub Copilot
|
|
.kiro/ -> Kiro
|
|
.opencode/ -> OpenCode
|
|
.pi/ -> Pi
|
|
.trae-cn/ -> Trae China
|
|
.trae/ -> Trae International
|
|
|
|
To install, copy the relevant folder(s) into your project root.
|
|
For Codex, repo and user skill installs come from .agents/skills.
|
|
These are hidden folders (dotfiles). Press Cmd+Shift+. in Finder to see them.
|
|
`);
|
|
|
|
console.log(`✓ Assembled universal directory (${providerConfigs.length} providers)`);
|
|
}
|
|
|
|
/**
|
|
* Generate static API data for Cloudflare Pages deployment.
|
|
* Pre-builds all API responses as JSON files so they can be served
|
|
* as static assets via _redirects rewrites (no function invocations needed).
|
|
*/
|
|
function generateApiData(buildDir, skills, patterns) {
|
|
const apiDir = path.join(buildDir, '_data', 'api');
|
|
fs.mkdirSync(apiDir, { recursive: true });
|
|
|
|
// skills.json
|
|
const skillsData = skills.map(s => ({
|
|
id: path.basename(path.dirname(s.filePath)),
|
|
name: s.name,
|
|
description: s.description,
|
|
userInvocable: s.userInvocable,
|
|
}));
|
|
fs.writeFileSync(path.join(apiDir, 'skills.json'), JSON.stringify(skillsData));
|
|
|
|
// commands.json - after v3.0 consolidation, commands are sub-commands of
|
|
// /impeccable. Load them from command-metadata.json and include the root
|
|
// impeccable skill itself so UI surfaces like the cheatsheet can list them.
|
|
// Each entry also picks up a short `tagline` from its editorial file
|
|
// (site/content/skills/<id>.md) when one exists. Taglines are used by UI
|
|
// surfaces that need a human-friendly one-liner, while `description` stays
|
|
// optimized for auto-trigger keyword matching in the AI harness.
|
|
const readTagline = (id) => {
|
|
const editorialPath = path.join(ROOT_DIR, 'site/content/skills', `${id}.md`);
|
|
if (!fs.existsSync(editorialPath)) return null;
|
|
const raw = fs.readFileSync(editorialPath, 'utf-8');
|
|
const match = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
if (!match) return null;
|
|
const taglineMatch = match[1].match(/tagline:\s*"([^"]+)"/);
|
|
return taglineMatch ? taglineMatch[1] : null;
|
|
};
|
|
|
|
const metadataPath = path.join(ROOT_DIR, 'skill/scripts/command-metadata.json');
|
|
if (!fs.existsSync(metadataPath)) {
|
|
throw new Error(`command-metadata.json is missing at ${metadataPath}. This file is required to generate the commands API.`);
|
|
}
|
|
const impeccable = skills.find(s => s.name === 'impeccable');
|
|
if (!impeccable) {
|
|
throw new Error('impeccable skill not found at skill/SKILL.md. The build system expects exactly one skill at that path.');
|
|
}
|
|
|
|
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
|
const commandsData = [
|
|
{
|
|
id: 'impeccable',
|
|
name: 'impeccable',
|
|
description: impeccable.description,
|
|
tagline: readTagline('impeccable'),
|
|
userInvocable: true,
|
|
},
|
|
...Object.entries(metadata).map(([id, meta]) => ({
|
|
id,
|
|
name: id,
|
|
description: meta.description,
|
|
tagline: readTagline(id),
|
|
userInvocable: true,
|
|
})),
|
|
];
|
|
fs.writeFileSync(path.join(apiDir, 'commands.json'), JSON.stringify(commandsData));
|
|
|
|
// patterns.json
|
|
fs.writeFileSync(path.join(apiDir, 'patterns.json'), JSON.stringify(patterns));
|
|
|
|
// command-source/{id}.json (one per skill)
|
|
const cmdSourceDir = path.join(apiDir, 'command-source');
|
|
fs.mkdirSync(cmdSourceDir, { recursive: true });
|
|
for (const skill of skills) {
|
|
const id = path.basename(path.dirname(skill.filePath));
|
|
const content = fs.readFileSync(skill.filePath, 'utf-8');
|
|
fs.writeFileSync(
|
|
path.join(cmdSourceDir, `${id}.json`),
|
|
JSON.stringify({ content })
|
|
);
|
|
}
|
|
|
|
const skillWord = skillsData.length === 1 ? 'skill' : 'skills';
|
|
console.log(`✓ Generated static API data (${skillsData.length} ${skillWord}, ${commandsData.length} commands)`);
|
|
}
|
|
|
|
/**
|
|
* Copy dist files to build output for Cloudflare Pages Functions access.
|
|
* Download functions use env.ASSETS.fetch() to read these files.
|
|
*/
|
|
function copyDistToBuild(distDir, buildDir) {
|
|
const destDir = path.join(buildDir, '_data', 'dist');
|
|
copyDirSync(distDir, destDir);
|
|
console.log('✓ Copied dist files to build output');
|
|
}
|
|
|
|
/**
|
|
* Generate Cloudflare Pages config files (_headers, _redirects)
|
|
*/
|
|
function generateCFConfig(buildDir) {
|
|
// _headers: security + cache headers
|
|
const headers = `/*
|
|
X-Content-Type-Options: nosniff
|
|
X-Frame-Options: SAMEORIGIN
|
|
|
|
# HTML pages: browser always revalidates, CDN caches 1h
|
|
/*.html
|
|
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=600
|
|
|
|
# Hashed JS/CSS bundles: immutable (filename changes on content change)
|
|
/assets/*.js
|
|
Cache-Control: public, max-age=31536000, immutable
|
|
|
|
/assets/*.css
|
|
Cache-Control: public, max-age=31536000, immutable
|
|
|
|
# Static images and logos: 1 week + 1 day stale
|
|
/assets/*.png
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/assets/*.svg
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/assets/*.webp
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/antipattern-images/*
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
# Root static assets (favicon, og-image, etc.)
|
|
/favicon.svg
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/og-image.jpg
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/apple-touch-icon.png
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
# ZIP downloads: 1h cache
|
|
/dist/*.zip
|
|
Cache-Control: public, max-age=3600, stale-while-revalidate=600
|
|
|
|
# API routes: CDN caches 24h
|
|
/api/*
|
|
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
|
|
|
|
/_data/api/*
|
|
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
|
|
`;
|
|
fs.writeFileSync(path.join(buildDir, '_headers'), headers);
|
|
|
|
// _redirects: rewrite JSON API routes to static files (200 = rewrite, not redirect).
|
|
// Plus permanent redirects for legacy URLs.
|
|
const redirects = `/api/skills /_data/api/skills.json 200
|
|
/api/commands /_data/api/commands.json 200
|
|
/api/patterns /_data/api/patterns.json 200
|
|
/api/command-source/:id /_data/api/command-source/:id.json 200
|
|
/gallery /slop#try-it-live 301
|
|
/cheatsheet /docs 301
|
|
/skills /docs 301
|
|
/skills/:id /docs/:id 301
|
|
/anti-patterns /slop#catalog 301
|
|
/visual-mode /slop#see-it 301
|
|
/neon-mirai /neo-mirai/ 301
|
|
/neon-mirai/ /neo-mirai/ 301
|
|
/cases/neon-mirai /cases/neo-mirai 301
|
|
/cases/neon-mirai/ /cases/neo-mirai 301
|
|
`;
|
|
fs.writeFileSync(path.join(buildDir, '_redirects'), redirects);
|
|
|
|
// _routes.json: tell Cloudflare Pages which paths invoke Functions
|
|
// Without this, the SPA fallback serves index.html for function routes
|
|
const routes = {
|
|
version: 1,
|
|
include: ['/api/download/*'],
|
|
exclude: [],
|
|
};
|
|
fs.writeFileSync(path.join(buildDir, '_routes.json'), JSON.stringify(routes, null, 2));
|
|
|
|
console.log('✓ Generated Cloudflare Pages config (_headers, _redirects, _routes.json)');
|
|
}
|
|
|
|
/**
|
|
* Main build process
|
|
*/
|
|
async function build() {
|
|
console.log('🔨 Building cross-provider design skills...\n');
|
|
|
|
// Sub-page generation, HTML bundling, and static-asset copying are now
|
|
// handled by Astro (bun run build:site). This script focuses on skills,
|
|
// API data, and Cloudflare config.
|
|
|
|
// Copy browser detector to site/public/js/ so the antipattern examples can
|
|
// reference it (Astro serves site/public/ as-is).
|
|
const detectorSrc = path.join(ROOT_DIR, 'cli', 'engine', 'detect-antipatterns-browser.js');
|
|
if (fs.existsSync(detectorSrc)) {
|
|
const jsDir = path.join(ROOT_DIR, 'site', 'public', 'js');
|
|
fs.mkdirSync(jsDir, { recursive: true });
|
|
fs.copyFileSync(detectorSrc, path.join(jsDir, 'detect-antipatterns-browser.js'));
|
|
}
|
|
|
|
const buildDir = path.join(ROOT_DIR, 'build');
|
|
|
|
// Read source files (unified skills architecture)
|
|
const { skills } = readSourceFiles(ROOT_DIR);
|
|
const patterns = readPatterns(ROOT_DIR);
|
|
const userInvocableCount = skills.filter(s => s.userInvocable).length;
|
|
console.log(`📖 Read ${skills.length} skills (${userInvocableCount} user-invocable) and ${patterns.patterns.length + patterns.antipatterns.length} pattern categories\n`);
|
|
|
|
const frontmatterErrors = validateSkillFrontmatter(skills);
|
|
if (frontmatterErrors > 0) {
|
|
process.exit(1);
|
|
}
|
|
|
|
// Read skills version from plugin.json
|
|
const pluginJson = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
|
|
const skillsVersion = pluginJson.version;
|
|
|
|
// Transform for each provider
|
|
for (const config of Object.values(PROVIDERS)) {
|
|
const transform = createTransformer(config);
|
|
transform(skills, DIST_DIR, { skillsVersion });
|
|
}
|
|
|
|
// Assemble universal directory
|
|
assembleUniversal(DIST_DIR);
|
|
|
|
// Create ZIP bundles (individual + universal)
|
|
await createAllZips(DIST_DIR);
|
|
|
|
// Generate static API data and Cloudflare Pages config
|
|
// Write API data and CF config to site/public/ so Astro copies them to build/.
|
|
// Astro wipes build/ before writing, so anything written directly to build/
|
|
// during build:skills would be destroyed when build:site runs.
|
|
const publicDir = path.join(ROOT_DIR, 'site', 'public');
|
|
generateApiData(publicDir, skills, patterns);
|
|
generateCFConfig(publicDir);
|
|
|
|
// Copy all provider outputs to project root for local testing.
|
|
// `.codex/` is intentionally excluded: Codex no longer consumes that layout; keep
|
|
// generated bundles under dist/ only.
|
|
const syncConfigs = Object.values(PROVIDERS).filter(({ configDir }) => configDir !== '.codex');
|
|
|
|
for (const { provider, configDir } of syncConfigs) {
|
|
const skillsSrc = path.join(DIST_DIR, provider, configDir, 'skills');
|
|
const skillsDest = path.join(ROOT_DIR, configDir, 'skills');
|
|
|
|
if (fs.existsSync(skillsSrc)) {
|
|
// Preserve legacy per-project script artifacts (e.g. live-mode config.json)
|
|
// across the rm + recopy. The build intentionally doesn't ship them,
|
|
// so without this the sync destroys local state on every rebuild.
|
|
const stashed = stashPerProjectArtifacts(skillsDest);
|
|
if (fs.existsSync(skillsDest)) fs.rmSync(skillsDest, { recursive: true });
|
|
copyDirSync(skillsSrc, skillsDest);
|
|
restorePerProjectArtifacts(skillsDest, stashed);
|
|
}
|
|
}
|
|
|
|
for (const { provider, configDir, agentFormat } of Object.values(PROVIDERS)) {
|
|
if (!agentFormat) continue;
|
|
|
|
const agentsSrc = path.join(DIST_DIR, provider, configDir, 'agents');
|
|
const agentsDest = path.join(ROOT_DIR, configDir, 'agents');
|
|
|
|
if (fs.existsSync(agentsDest)) fs.rmSync(agentsDest, { recursive: true, force: true });
|
|
if (fs.existsSync(agentsSrc)) {
|
|
copyDirSync(agentsSrc, agentsDest);
|
|
}
|
|
}
|
|
|
|
// Remove deprecated skill stubs from local harness dirs. They exist
|
|
// in dist/ so the cleanup script can redirect users, but they should
|
|
// not clutter the repo's own skill directories.
|
|
const deprecatedLocalSkills = [
|
|
'frontend-design', 'teach-impeccable',
|
|
'arrange', 'normalize', 'onboard', 'extract',
|
|
// v3.0 consolidation: standalone skills -> /impeccable sub-commands
|
|
'adapt', 'animate', 'audit', 'bolder', 'clarify', 'colorize',
|
|
'critique', 'delight', 'distill', 'harden', 'layout', 'optimize',
|
|
'overdrive', 'polish', 'quieter', 'shape', 'typeset',
|
|
];
|
|
for (const { configDir } of syncConfigs) {
|
|
for (const name of deprecatedLocalSkills) {
|
|
const p = path.join(ROOT_DIR, configDir, 'skills', name);
|
|
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
console.log(`📋 Synced skills to: ${syncConfigs.map(p => p.configDir).join(', ')}`);
|
|
|
|
// Build the Claude Code plugin subtree at ./plugin/.
|
|
// The Claude Code marketplace is configured with `source: "./plugin"`, so
|
|
// the plugin cache only copies this slim directory (~0.3 MB) instead of
|
|
// the entire monorepo (~291 MB on the previous "./" source). The harness
|
|
// dirs above stay where they are because `npx skills add pbakaus/impeccable`
|
|
// reads them directly from the GitHub repo at install time.
|
|
const pluginRoot = path.join(ROOT_DIR, 'plugin');
|
|
const pluginManifestDir = path.join(pluginRoot, '.claude-plugin');
|
|
const pluginSkillsDir = path.join(pluginRoot, 'skills');
|
|
const pluginAgentsDir = path.join(pluginRoot, 'agents');
|
|
if (fs.existsSync(pluginManifestDir)) fs.rmSync(pluginManifestDir, { recursive: true });
|
|
if (fs.existsSync(pluginSkillsDir)) fs.rmSync(pluginSkillsDir, { recursive: true });
|
|
if (fs.existsSync(pluginAgentsDir)) fs.rmSync(pluginAgentsDir, { recursive: true });
|
|
|
|
const rootManifest = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
|
|
const claudeAgentsSrc = path.join(DIST_DIR, 'claude-code', '.claude', 'agents');
|
|
const pluginAgentEntries = fs.existsSync(claudeAgentsSrc)
|
|
? fs.readdirSync(claudeAgentsSrc)
|
|
.filter(file => file.endsWith('.md'))
|
|
.sort()
|
|
.map(file => `./agents/${file}`)
|
|
: [];
|
|
// Trailing slash on the skills path matches the documented schema in
|
|
// code.claude.com/docs/en/plugins-reference. Issue #86 has 3 reporters
|
|
// converging on "add trailing slash to fix slash commands not registering";
|
|
// the docs schema example consistently uses `"./custom/skills/"` form.
|
|
const pluginManifest = { ...rootManifest, skills: './skills/' };
|
|
if (pluginAgentEntries.length) {
|
|
pluginManifest.agents = pluginAgentEntries;
|
|
} else {
|
|
delete pluginManifest.agents;
|
|
}
|
|
fs.mkdirSync(pluginManifestDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginManifestDir, 'plugin.json'),
|
|
JSON.stringify(pluginManifest, null, 2) + '\n',
|
|
);
|
|
|
|
const claudeSkillsSrc = path.join(DIST_DIR, 'claude-code', '.claude', 'skills', 'impeccable');
|
|
if (fs.existsSync(claudeSkillsSrc)) {
|
|
fs.mkdirSync(pluginSkillsDir, { recursive: true });
|
|
copyDirSync(claudeSkillsSrc, path.join(pluginSkillsDir, 'impeccable'));
|
|
}
|
|
|
|
if (fs.existsSync(claudeAgentsSrc)) {
|
|
copyDirSync(claudeAgentsSrc, pluginAgentsDir);
|
|
}
|
|
|
|
console.log('📦 Built Claude Code plugin subtree at ./plugin/');
|
|
|
|
// Generate authoritative counts and validate references
|
|
const countErrors = generateCounts(ROOT_DIR, skills, buildDir);
|
|
|
|
// Verify every hand-authored HTML page carries the shared site header
|
|
const headerErrors = validateSiteHeader(ROOT_DIR);
|
|
|
|
// Scan user-facing copy for AI tells (em dashes, marketing fluff, denylisted phrases)
|
|
const proseErrors = validateProse(ROOT_DIR);
|
|
|
|
// Narrow scan of LLM-facing skill instructions: em dashes + a tighter denylist
|
|
// that has no technical reading. Hardening repetition is intentionally allowed.
|
|
const skillProseErrors = validateSkillProse(ROOT_DIR);
|
|
|
|
if (countErrors > 0 || headerErrors > 0 || proseErrors > 0 || skillProseErrors > 0) {
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('\n✨ Build complete!');
|
|
}
|
|
|
|
// Run the build
|
|
build();
|