mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +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>
714 lines
24 KiB
JavaScript
714 lines
24 KiB
JavaScript
import fs from 'fs';
|
||
import path from 'path';
|
||
|
||
// Per-project artifacts live inside `scripts/` of an installed skill but
|
||
// belong to the consuming project, not the distributable skill. The build
|
||
// excludes them from dist, and the harness-sync step preserves them across
|
||
// the rm+recopy so local state isn't destroyed on every rebuild.
|
||
// - config.json: legacy live-mode inject target list for existing projects.
|
||
// New installs write project config at .impeccable/live/config.json instead.
|
||
export const PER_PROJECT_SCRIPT_ARTIFACTS = new Set(['config.json']);
|
||
|
||
// Walk the harness-dir skill tree and return any per-project script
|
||
// artifacts found, ready for restoration after a full sync rm+recopy.
|
||
// Returns [{ relPath, content: Buffer }], where relPath is relative to
|
||
// the passed-in rootDir (typically `<configDir>/skills`).
|
||
export function stashPerProjectArtifacts(rootDir) {
|
||
if (!fs.existsSync(rootDir)) return [];
|
||
const out = [];
|
||
const walk = (dir) => {
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const p = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) { walk(p); continue; }
|
||
// Only preserve files inside a skill's scripts/ directory.
|
||
if (path.basename(path.dirname(p)) !== 'scripts') continue;
|
||
if (PER_PROJECT_SCRIPT_ARTIFACTS.has(entry.name)) {
|
||
out.push({ relPath: path.relative(rootDir, p), content: fs.readFileSync(p) });
|
||
}
|
||
}
|
||
};
|
||
walk(rootDir);
|
||
return out;
|
||
}
|
||
|
||
export function restorePerProjectArtifacts(rootDir, stashed) {
|
||
for (const { relPath, content } of stashed) {
|
||
const target = path.join(rootDir, relPath);
|
||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||
fs.writeFileSync(target, content);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse frontmatter from markdown content
|
||
* Returns { frontmatter: object, body: string }
|
||
*/
|
||
export function parseFrontmatter(content) {
|
||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
|
||
const match = content.match(frontmatterRegex);
|
||
|
||
if (!match) {
|
||
return { frontmatter: {}, body: content };
|
||
}
|
||
|
||
const [, frontmatterText, body] = match;
|
||
const frontmatter = {};
|
||
|
||
// Simple YAML parser (handles basic key-value and arrays)
|
||
const lines = frontmatterText.split(/\r?\n/);
|
||
let currentKey = null;
|
||
let currentArray = null;
|
||
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
|
||
// Calculate indent level
|
||
const leadingSpaces = line.length - line.trimStart().length;
|
||
const trimmed = line.trim();
|
||
|
||
// Array item at level 2 (nested under a key)
|
||
if (trimmed.startsWith('- ') && leadingSpaces >= 2) {
|
||
if (currentArray) {
|
||
if (trimmed.startsWith('- name:')) {
|
||
// New object in array
|
||
const obj = {};
|
||
obj.name = trimmed.slice(7).trim();
|
||
currentArray.push(obj);
|
||
} else {
|
||
// Simple string item in array
|
||
currentArray.push(trimmed.slice(2));
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Property of array object (indented further)
|
||
if (leadingSpaces >= 4 && currentArray && currentArray.length > 0) {
|
||
const colonIndex = trimmed.indexOf(':');
|
||
if (colonIndex > 0) {
|
||
const key = trimmed.slice(0, colonIndex).trim();
|
||
const value = trimmed.slice(colonIndex + 1).trim();
|
||
const lastObj = currentArray[currentArray.length - 1];
|
||
lastObj[key] = value === 'true' ? true : value === 'false' ? false : value;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Top-level key-value pair
|
||
if (leadingSpaces === 0) {
|
||
const colonIndex = trimmed.indexOf(':');
|
||
if (colonIndex > 0) {
|
||
const key = trimmed.slice(0, colonIndex).trim();
|
||
const value = trimmed.slice(colonIndex + 1).trim();
|
||
const isQuoted = /^(".*"|'.*')$/.test(value);
|
||
const unquotedValue = isQuoted ? value.slice(1, -1) : value;
|
||
const shouldCoerceBoolean =
|
||
key === 'user-invocable' || key === 'user-invokable' || !isQuoted;
|
||
|
||
if (value) {
|
||
frontmatter[key] = shouldCoerceBoolean
|
||
? unquotedValue === 'true'
|
||
? true
|
||
: unquotedValue === 'false'
|
||
? false
|
||
: unquotedValue
|
||
: unquotedValue;
|
||
currentKey = key;
|
||
currentArray = null;
|
||
} else {
|
||
// Start of array
|
||
currentKey = key;
|
||
currentArray = [];
|
||
frontmatter[key] = currentArray;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return { frontmatter, body: body.trim() };
|
||
}
|
||
|
||
/**
|
||
* Recursively read all .md files from a directory
|
||
*/
|
||
export function readFilesRecursive(dir, fileList = []) {
|
||
if (!fs.existsSync(dir)) {
|
||
return fileList;
|
||
}
|
||
|
||
const files = fs.readdirSync(dir);
|
||
|
||
for (const file of files) {
|
||
const filePath = path.join(dir, file);
|
||
const stat = fs.statSync(filePath);
|
||
|
||
if (stat.isDirectory()) {
|
||
readFilesRecursive(filePath, fileList);
|
||
} else if (file.endsWith('.md')) {
|
||
fileList.push(filePath);
|
||
}
|
||
}
|
||
|
||
return fileList;
|
||
}
|
||
|
||
/**
|
||
* Read and parse the impeccable skill source.
|
||
* After v3.0 the repo holds exactly one user-invocable skill, flat at skill/.
|
||
* Returns { skills: [oneEntry] } so downstream array-shaped consumers stay happy.
|
||
*/
|
||
export function readSourceFiles(rootDir) {
|
||
const skillDir = path.join(rootDir, 'skill');
|
||
const skills = [];
|
||
|
||
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
||
if (!fs.existsSync(skillMdPath)) {
|
||
return { skills };
|
||
}
|
||
|
||
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
||
const { frontmatter, body } = parseFrontmatter(content);
|
||
|
||
const references = [];
|
||
const referenceDir = path.join(skillDir, 'reference');
|
||
if (fs.existsSync(referenceDir)) {
|
||
const refFiles = fs.readdirSync(referenceDir).filter(f => f.endsWith('.md'));
|
||
for (const refFile of refFiles) {
|
||
const refPath = path.join(referenceDir, refFile);
|
||
references.push({
|
||
name: path.basename(refFile, '.md'),
|
||
content: fs.readFileSync(refPath, 'utf-8'),
|
||
filePath: refPath
|
||
});
|
||
}
|
||
}
|
||
|
||
// PER_PROJECT_SCRIPT_ARTIFACTS (defined at module top) are excluded from
|
||
// the distributable skill so the build never bundles one project's state
|
||
// into another's.
|
||
const scripts = [];
|
||
const scriptsDir = path.join(skillDir, 'scripts');
|
||
if (fs.existsSync(scriptsDir)) {
|
||
const scriptFiles = fs.readdirSync(scriptsDir).filter(f => {
|
||
if (PER_PROJECT_SCRIPT_ARTIFACTS.has(f)) return false;
|
||
return fs.statSync(path.join(scriptsDir, f)).isFile();
|
||
});
|
||
for (const scriptFile of scriptFiles) {
|
||
const scriptPath = path.join(scriptsDir, scriptFile);
|
||
scripts.push({
|
||
name: scriptFile,
|
||
content: fs.readFileSync(scriptPath, 'utf-8'),
|
||
filePath: scriptPath
|
||
});
|
||
}
|
||
}
|
||
|
||
const agents = [];
|
||
const agentsDir = path.join(skillDir, 'agents');
|
||
if (fs.existsSync(agentsDir)) {
|
||
const agentFiles = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md'));
|
||
for (const agentFile of agentFiles) {
|
||
const agentPath = path.join(agentsDir, agentFile);
|
||
const agentContent = fs.readFileSync(agentPath, 'utf-8');
|
||
const { frontmatter: agentFrontmatter, body: agentBody } = parseFrontmatter(agentContent);
|
||
const name = agentFrontmatter.name || path.basename(agentFile, '.md');
|
||
const providersRaw = agentFrontmatter.providers;
|
||
let providers = null;
|
||
if (Array.isArray(providersRaw)) {
|
||
providers = providersRaw.map(p => String(p).trim()).filter(Boolean);
|
||
} else if (typeof providersRaw === 'string' && providersRaw.trim()) {
|
||
providers = providersRaw.split(',').map(p => p.trim()).filter(Boolean);
|
||
}
|
||
agents.push({
|
||
name,
|
||
codexName: agentFrontmatter['codex-name'] || name.replace(/-/g, '_'),
|
||
claudeName: agentFrontmatter['claude-name'] || name,
|
||
description: agentFrontmatter.description || '',
|
||
tools: agentFrontmatter.tools || '',
|
||
model: agentFrontmatter.model || '',
|
||
effort: agentFrontmatter.effort || '',
|
||
maxTurns: agentFrontmatter['max-turns'] ? Number(agentFrontmatter['max-turns']) : '',
|
||
nicknameCandidates: agentFrontmatter['nickname-candidates'] || [],
|
||
providers,
|
||
body: agentBody,
|
||
filePath: agentPath,
|
||
});
|
||
}
|
||
}
|
||
|
||
skills.push({
|
||
name: frontmatter.name || 'impeccable',
|
||
description: frontmatter.description || '',
|
||
license: frontmatter.license || '',
|
||
compatibility: frontmatter.compatibility || '',
|
||
metadata: frontmatter.metadata || null,
|
||
allowedTools: frontmatter['allowed-tools'] || '',
|
||
userInvocable: frontmatter['user-invocable'] === true || frontmatter['user-invocable'] === 'true',
|
||
argumentHint: frontmatter['argument-hint'] || '',
|
||
context: frontmatter.context || null,
|
||
body,
|
||
filePath: skillMdPath,
|
||
references,
|
||
scripts,
|
||
agents
|
||
});
|
||
|
||
return { skills };
|
||
}
|
||
|
||
/**
|
||
* Ensure directory exists, create if needed
|
||
*/
|
||
export function ensureDir(dirPath) {
|
||
if (!fs.existsSync(dirPath)) {
|
||
fs.mkdirSync(dirPath, { recursive: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clean directory (remove all contents)
|
||
*/
|
||
export function cleanDir(dirPath) {
|
||
if (fs.existsSync(dirPath)) {
|
||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write file with automatic directory creation
|
||
*/
|
||
export function writeFile(filePath, content) {
|
||
const dir = path.dirname(filePath);
|
||
ensureDir(dir);
|
||
fs.writeFileSync(filePath, content, 'utf-8');
|
||
}
|
||
|
||
/**
|
||
* Extract DO/DON'T patterns from a skill markdown file, grouped by section
|
||
* (h3 `### ` headings). Recognizes both formats:
|
||
* - Markdown bullet form: `**DO**: …` / `**DON'T**: …`
|
||
* - Prose form: `DO …` / `DO NOT …`
|
||
*
|
||
* Defaults to the main impeccable SKILL.md but accepts any relative path so
|
||
* rules in `cli/engine/detect-antipatterns.mjs` can anchor to register-specific
|
||
* reference files (e.g. `reference/editorial.md`) via an optional `skillFile`
|
||
* field. Callers that don't pass `relativePath` get the legacy behavior.
|
||
*
|
||
* Returns { patterns: [...], antipatterns: [...] }
|
||
*/
|
||
// Curated short-list for the homepage Antidote section. Intentionally
|
||
// hand-written (not auto-extracted) so the copy stays tight and
|
||
// editorial. The long-form catalog lives on /slop — this is the teaser.
|
||
const CURATED_CATEGORIES = [
|
||
{
|
||
name: 'Typography',
|
||
do: [
|
||
'Pair a distinctive display face with a restrained body face; vary across projects.',
|
||
'Use a ≥1.25 scale ratio between hierarchy steps. Flat scales read as bland.',
|
||
'Cap body line length at 65–75ch. Wider is fatiguing.',
|
||
],
|
||
dont: [
|
||
'Inter, Roboto, Plex, Fraunces, or any other reflex default. Look further.',
|
||
'Monospace as lazy shorthand for "technical."',
|
||
'Long passages in uppercase. Reserve all-caps for short labels.',
|
||
],
|
||
},
|
||
{
|
||
name: 'Color & Contrast',
|
||
do: [
|
||
'Use OKLCH. Reduce chroma near lightness extremes.',
|
||
'Tint neutrals toward the brand hue. Chroma 0.005–0.01 is enough.',
|
||
'Pick a color strategy before picking colors (Restrained, Committed, Full, Drenched).',
|
||
],
|
||
dont: [
|
||
'Pure #000 or #fff. Always tint.',
|
||
'Dark mode + purple-to-cyan gradients. The AI tell.',
|
||
'Gradient text via background-clip. Use weight or size for emphasis.',
|
||
],
|
||
},
|
||
{
|
||
name: 'Layout & Space',
|
||
do: [
|
||
'Vary spacing for rhythm. Tight groupings, generous separations.',
|
||
'Use the simplest tool: Flexbox for 1D, Grid for 2D, plain flow often enough.',
|
||
'Let whitespace carry hierarchy before reaching for color or scale.',
|
||
],
|
||
dont: [
|
||
'Wrap everything in cards. Nested cards are always wrong.',
|
||
'Identical card grids of icon + heading + text, repeated endlessly.',
|
||
'The hero-metric template: big number, small label, supporting stats, gradient accent.',
|
||
],
|
||
},
|
||
{
|
||
name: 'Visual Details',
|
||
do: [
|
||
'Commit to an aesthetic direction and execute it with precision.',
|
||
'Use ornament only where it earns its place.',
|
||
],
|
||
dont: [
|
||
'Side-stripe borders (border-left/-right > 1px). The dashboard tell.',
|
||
'Glassmorphism everywhere. Rare and purposeful or nothing.',
|
||
'Rounded rectangles with generic drop shadows. "Could be any AI output."',
|
||
],
|
||
},
|
||
{
|
||
name: 'Motion',
|
||
do: [
|
||
'Use transform and opacity. Animate the composited properties only.',
|
||
'Ease out with exponential curves (quart / quint / expo).',
|
||
'Respect prefers-reduced-motion on every transition.',
|
||
],
|
||
dont: [
|
||
'Animate layout (width, height, padding, margin).',
|
||
'Bounce or elastic easing. Feels dated and tacky.',
|
||
'Decorative motion for its own sake. Motion should signal state.',
|
||
],
|
||
},
|
||
{
|
||
name: 'Interaction',
|
||
do: [
|
||
'Use optimistic UI: update immediately, sync later.',
|
||
'Design empty states that teach the interface, not just say "nothing here."',
|
||
'Progressive disclosure: start simple, reveal sophistication on demand.',
|
||
],
|
||
dont: [
|
||
'Make every button primary. Hierarchy matters.',
|
||
'Default to a modal. Exhaust inline alternatives first.',
|
||
'Repeat information the user can already see.',
|
||
],
|
||
},
|
||
];
|
||
|
||
export function readPatterns(_rootDir, _relativePath) {
|
||
// Hand-curated list — see CURATED_CATEGORIES above. The homepage
|
||
// Antidote teaser uses this; the full catalog lives on /slop.
|
||
return {
|
||
patterns: CURATED_CATEGORIES.map((c) => ({ name: c.name, items: c.do })),
|
||
antipatterns: CURATED_CATEGORIES.map((c) => ({ name: c.name, items: c.dont })),
|
||
};
|
||
}
|
||
|
||
// Previous SKILL.md parser retained below but disabled; kept as a
|
||
// reference for how prefix-style extraction used to work.
|
||
function _legacyReadPatterns(rootDir, relativePath = 'skill/SKILL.md') {
|
||
const skillPath = path.join(rootDir, relativePath);
|
||
|
||
if (!fs.existsSync(skillPath)) {
|
||
return { patterns: [], antipatterns: [] };
|
||
}
|
||
|
||
const content = fs.readFileSync(skillPath, 'utf-8');
|
||
const lines = content.split('\n');
|
||
|
||
const patternsMap = {}; // category -> items[]
|
||
const antipatternsMap = {}; // category -> items[]
|
||
let currentSection = null;
|
||
|
||
const pushPattern = (item) => {
|
||
if (!currentSection) return;
|
||
if (!patternsMap[currentSection]) patternsMap[currentSection] = [];
|
||
patternsMap[currentSection].push(item);
|
||
};
|
||
const pushAntipattern = (item) => {
|
||
if (!currentSection) return;
|
||
if (!antipatternsMap[currentSection]) antipatternsMap[currentSection] = [];
|
||
antipatternsMap[currentSection].push(item);
|
||
};
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
|
||
// Track section headings (### Typography, ### Color & Theme, etc.)
|
||
if (trimmed.startsWith('### ')) {
|
||
currentSection = trimmed.slice(4).trim();
|
||
// Normalize "Color & Theme" to "Color & Contrast" for consistency
|
||
if (currentSection === 'Color & Theme') {
|
||
currentSection = 'Color & Contrast';
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Markdown bullet form (legacy): **DO**: ... and **DON'T**: ...
|
||
if (trimmed.startsWith('**DO**:')) {
|
||
pushPattern(trimmed.slice(7).trim());
|
||
continue;
|
||
}
|
||
if (trimmed.startsWith("**DON'T**:")) {
|
||
pushAntipattern(trimmed.slice(10).trim());
|
||
continue;
|
||
}
|
||
|
||
// XML-block prose form (current). Both space and colon variants:
|
||
// "DO NOT use ..." / "DO NOT: Use ..."
|
||
// "DO use ..." / "DO: Use ..."
|
||
// IMPORTANT: check `DO NOT` BEFORE `DO` so the prefix doesn't get
|
||
// gobbled by the wrong matcher.
|
||
if (trimmed.startsWith('DO NOT: ')) {
|
||
pushAntipattern(trimmed.slice('DO NOT: '.length).trim());
|
||
continue;
|
||
}
|
||
if (trimmed.startsWith('DO NOT ')) {
|
||
pushAntipattern(trimmed.slice('DO NOT '.length).trim());
|
||
continue;
|
||
}
|
||
if (trimmed.startsWith('DO: ')) {
|
||
pushPattern(trimmed.slice('DO: '.length).trim());
|
||
continue;
|
||
}
|
||
if (trimmed.startsWith('DO ')) {
|
||
pushPattern(trimmed.slice('DO '.length).trim());
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Convert maps to arrays in consistent order
|
||
const sectionOrder = ['Typography', 'Color & Contrast', 'Layout & Space', 'Visual Details', 'Motion', 'Interaction', 'Responsive', 'UX Writing'];
|
||
|
||
const patterns = [];
|
||
const antipatterns = [];
|
||
|
||
for (const section of sectionOrder) {
|
||
if (patternsMap[section] && patternsMap[section].length > 0) {
|
||
patterns.push({ name: section, items: patternsMap[section] });
|
||
}
|
||
if (antipatternsMap[section] && antipatternsMap[section].length > 0) {
|
||
antipatterns.push({ name: section, items: antipatternsMap[section] });
|
||
}
|
||
}
|
||
|
||
return { patterns, antipatterns };
|
||
}
|
||
|
||
/**
|
||
* Provider-specific placeholders
|
||
*/
|
||
export const PROVIDER_PLACEHOLDERS = {
|
||
'claude-code': {
|
||
model: 'Claude',
|
||
config_file: 'CLAUDE.md',
|
||
ask_instruction: 'STOP and call the AskUserQuestion tool to clarify.',
|
||
command_prefix: '/'
|
||
},
|
||
'cursor': {
|
||
model: 'the model',
|
||
config_file: '.cursorrules',
|
||
ask_instruction: 'ask the user directly to clarify what you cannot infer.',
|
||
command_prefix: '/'
|
||
},
|
||
'gemini': {
|
||
model: 'Gemini',
|
||
config_file: 'GEMINI.md',
|
||
ask_instruction: 'ask the user directly to clarify what you cannot infer.',
|
||
command_prefix: '/'
|
||
},
|
||
'codex': {
|
||
model: 'GPT',
|
||
config_file: 'AGENTS.md',
|
||
ask_instruction: "STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.",
|
||
command_prefix: '$'
|
||
},
|
||
'agents': {
|
||
model: 'the model',
|
||
config_file: '.github/copilot-instructions.md',
|
||
ask_instruction: 'ask the user directly to clarify what you cannot infer.',
|
||
command_prefix: '/'
|
||
},
|
||
'kiro': {
|
||
model: 'Claude',
|
||
config_file: '.kiro/settings.json',
|
||
ask_instruction: 'ask the user directly to clarify what you cannot infer.',
|
||
command_prefix: '/'
|
||
},
|
||
opencode: {
|
||
model: 'Claude',
|
||
config_file: 'AGENTS.md',
|
||
ask_instruction: 'STOP and call the `question` tool to clarify.',
|
||
command_prefix: '/'
|
||
},
|
||
'pi': {
|
||
model: 'the model',
|
||
config_file: 'AGENTS.md',
|
||
ask_instruction: 'ask the user directly to clarify what you cannot infer.',
|
||
command_prefix: '/'
|
||
},
|
||
'qoder': {
|
||
model: 'the model',
|
||
config_file: 'AGENTS.md',
|
||
ask_instruction: 'ask the user directly to clarify what you cannot infer.',
|
||
command_prefix: '/'
|
||
},
|
||
'trae': {
|
||
model: 'the model',
|
||
config_file: 'RULES.md',
|
||
ask_instruction: 'ask the user directly to clarify what you cannot infer.',
|
||
command_prefix: '/'
|
||
},
|
||
'rovo-dev': {
|
||
model: 'Rovo Dev',
|
||
config_file: 'AGENTS.md',
|
||
ask_instruction: 'ask the user directly to clarify what you cannot infer.',
|
||
command_prefix: '/'
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Replace all {{placeholder}} tokens with provider-specific values
|
||
*/
|
||
function escapeRegex(str) {
|
||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
const EXCLUDED_FROM_SUGGESTIONS = new Set([
|
||
'impeccable', // foundational skill, not a steering command
|
||
'teach-impeccable', // deprecated shim
|
||
'frontend-design', // deprecated shim
|
||
]);
|
||
|
||
// Sub-commands of /impeccable that should appear in {{available_commands}}.
|
||
// These are the commands that audit/critique/etc. reference when suggesting next steps.
|
||
const IMPECCABLE_SUB_COMMANDS = [
|
||
'adapt', 'animate', 'audit', 'bolder', 'clarify', 'colorize',
|
||
'critique', 'delight', 'distill', 'document', 'harden', 'layout',
|
||
'onboard', 'optimize', 'overdrive', 'polish', 'quieter', 'shape', 'typeset',
|
||
];
|
||
|
||
export function replacePlaceholders(content, provider, commandNames = [], allSkillNames = []) {
|
||
const placeholders = PROVIDER_PLACEHOLDERS[provider] || PROVIDER_PLACEHOLDERS['cursor'];
|
||
const cmdPrefix = placeholders.command_prefix || '/';
|
||
|
||
// Build the available_commands list.
|
||
// After the v3.0 consolidation, commands are sub-commands of /impeccable.
|
||
// If there's only one user-invocable skill (impeccable), generate sub-command references.
|
||
// Otherwise fall back to listing skill names (backwards compat for forks).
|
||
const nonExcluded = commandNames.filter(n => !EXCLUDED_FROM_SUGGESTIONS.has(n));
|
||
let commandList;
|
||
if (nonExcluded.length === 0) {
|
||
// Single-skill architecture: list sub-commands as /impeccable <sub>
|
||
commandList = IMPECCABLE_SUB_COMMANDS
|
||
.map(n => `${cmdPrefix}impeccable ${n}`)
|
||
.join(', ');
|
||
} else {
|
||
// Multi-skill architecture (backwards compat)
|
||
commandList = nonExcluded.map(n => `${cmdPrefix}${n}`).join(', ');
|
||
}
|
||
|
||
let result = content
|
||
.replace(/\{\{model\}\}/g, placeholders.model)
|
||
.replace(/\{\{config_file\}\}/g, placeholders.config_file)
|
||
.replace(/\{\{ask_instruction\}\}/g, placeholders.ask_instruction)
|
||
.replace(/\{\{command_prefix\}\}/g, cmdPrefix)
|
||
.replace(/\{\{available_commands\}\}/g, commandList);
|
||
|
||
// Replace `/skillname` invocations with the correct command prefix for this provider
|
||
// (e.g., `/normalize` → `$normalize` for Codex)
|
||
if (cmdPrefix !== '/' && allSkillNames.length > 0) {
|
||
const sorted = [...allSkillNames].sort((a, b) => b.length - a.length);
|
||
for (const name of sorted) {
|
||
result = result.replace(
|
||
new RegExp(`\\/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
|
||
cmdPrefix
|
||
);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Decide whether a YAML scalar string value must be quoted to survive parsing.
|
||
*
|
||
* Plain (unquoted) YAML scalars cannot contain `: ` or ` #`, cannot start with
|
||
* a YAML indicator character, cannot look like a boolean/null/number, and
|
||
* cannot carry leading/trailing whitespace. parseFrontmatter strips surrounding
|
||
* quotes on input, so we must re-detect the need to quote on output — otherwise
|
||
* descriptions like "Handles: critique/review..." round-trip into invalid YAML.
|
||
*/
|
||
function yamlNeedsQuoting(value) {
|
||
if (typeof value !== 'string') return false;
|
||
if (value === '') return true;
|
||
// Leading or trailing whitespace
|
||
if (/^\s|\s$/.test(value)) return true;
|
||
// Starts with a YAML flow/indicator character
|
||
if (/^[\[\]{},&*!|>'"%@`#]/.test(value)) return true;
|
||
// Starts with `?`, `:`, or `-` followed by space or end of string
|
||
if (/^[?:-](\s|$)/.test(value)) return true;
|
||
// Contains `: ` (ends plain scalar) or ` #` (starts comment), or ends with `:`
|
||
if (/: |\s#|:$/.test(value)) return true;
|
||
// Reserved keywords that YAML 1.1 parsers coerce to boolean/null
|
||
if (/^(true|false|null|yes|no|on|off|~)$/i.test(value)) return true;
|
||
// Looks like a number
|
||
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) return true;
|
||
return false;
|
||
}
|
||
|
||
function formatYamlScalar(value) {
|
||
if (typeof value !== 'string') return String(value);
|
||
if (yamlNeedsQuoting(value)) {
|
||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function appendYamlObject(lines, data, indent = 0) {
|
||
const space = ' '.repeat(indent);
|
||
|
||
for (const [key, value] of Object.entries(data)) {
|
||
if (Array.isArray(value)) {
|
||
lines.push(`${space}${key}:`);
|
||
for (const item of value) {
|
||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||
lines.push(`${space} -`);
|
||
appendYamlObject(lines, item, indent + 4);
|
||
} else {
|
||
lines.push(`${space} - ${formatYamlScalar(item)}`);
|
||
}
|
||
}
|
||
} else if (value && typeof value === 'object') {
|
||
lines.push(`${space}${key}:`);
|
||
appendYamlObject(lines, value, indent + 2);
|
||
} else if (typeof value === 'boolean') {
|
||
lines.push(`${space}${key}: ${value}`);
|
||
} else {
|
||
lines.push(`${space}${key}: ${formatYamlScalar(value)}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate YAML frontmatter string
|
||
*/
|
||
export function generateYamlFrontmatter(data) {
|
||
const lines = ['---'];
|
||
|
||
for (const [key, value] of Object.entries(data)) {
|
||
if (Array.isArray(value)) {
|
||
lines.push(`${key}:`);
|
||
for (const item of value) {
|
||
if (typeof item === 'object') {
|
||
lines.push(` - name: ${formatYamlScalar(item.name)}`);
|
||
if (item.description) lines.push(` description: ${formatYamlScalar(item.description)}`);
|
||
if (item.required !== undefined) lines.push(` required: ${item.required}`);
|
||
} else {
|
||
lines.push(` - ${formatYamlScalar(item)}`);
|
||
}
|
||
}
|
||
} else if (typeof value === 'boolean') {
|
||
lines.push(`${key}: ${value}`);
|
||
} else {
|
||
lines.push(`${key}: ${formatYamlScalar(value)}`);
|
||
}
|
||
}
|
||
|
||
lines.push('---');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Generate a plain YAML document string.
|
||
*/
|
||
export function generateYamlDocument(data) {
|
||
const lines = [];
|
||
appendYamlObject(lines, data);
|
||
return lines.join('\n');
|
||
}
|