@cloverleaf/reference-impl 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +18 -0
  2. package/VERSION +1 -1
  3. package/config/affected-routes.json +6 -8
  4. package/config/qa-rules.json +3 -13
  5. package/config/ui-paths.json +6 -1
  6. package/config/ui-review.json +17 -0
  7. package/dist/affected-routes.mjs +1 -1
  8. package/dist/axe-dedupe.mjs +38 -0
  9. package/dist/cli.mjs +25 -1
  10. package/dist/qa-report.mjs +65 -0
  11. package/dist/qa-rules.mjs +1 -1
  12. package/dist/route-slug.mjs +23 -0
  13. package/dist/ui-paths.mjs +1 -1
  14. package/dist/ui-review-config.mjs +37 -0
  15. package/dist/visual-diff.mjs +62 -0
  16. package/install.sh +30 -44
  17. package/lib/affected-routes.ts +1 -1
  18. package/lib/axe-dedupe.ts +53 -0
  19. package/lib/cli.ts +24 -1
  20. package/lib/feedback.ts +7 -0
  21. package/lib/qa-report.ts +77 -0
  22. package/lib/qa-rules.ts +1 -1
  23. package/lib/route-slug.ts +21 -0
  24. package/lib/ui-paths.ts +1 -1
  25. package/lib/ui-review-config.ts +57 -0
  26. package/lib/visual-diff.ts +97 -0
  27. package/package.json +8 -3
  28. package/prompts/qa.md +21 -0
  29. package/prompts/ui-reviewer.md +76 -38
  30. package/skills/{cloverleaf-new-task.md → cloverleaf-new-task/SKILL.md} +18 -1
  31. package/skills/{cloverleaf-qa.md → cloverleaf-qa/SKILL.md} +11 -6
  32. package/skills/{cloverleaf-ui-review.md → cloverleaf-ui-review/SKILL.md} +21 -14
  33. /package/skills/{cloverleaf-document.md → cloverleaf-document/SKILL.md} +0 -0
  34. /package/skills/{cloverleaf-implement.md → cloverleaf-implement/SKILL.md} +0 -0
  35. /package/skills/{cloverleaf-merge.md → cloverleaf-merge/SKILL.md} +0 -0
  36. /package/skills/{cloverleaf-review.md → cloverleaf-review/SKILL.md} +0 -0
  37. /package/skills/{cloverleaf-run.md → cloverleaf-run/SKILL.md} +0 -0
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "cloverleaf",
3
+ "description": "Cloverleaf reference implementation — Claude Code skills for task scaffolding and the Delivery pipeline (implementer, documenter, reviewer, UI reviewer with multi-viewport visual diff, QA, merge).",
4
+ "version": "0.4.0",
5
+ "author": {
6
+ "name": "Renato D'Arrigo",
7
+ "email": "renato.darrigo@gmail.com"
8
+ },
9
+ "homepage": "https://github.com/cloverleaf-org/cloverleaf",
10
+ "repository": "https://github.com/cloverleaf-org/cloverleaf",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "cloverleaf",
14
+ "methodology",
15
+ "ai-first",
16
+ "reference-implementation"
17
+ ]
18
+ }
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.3.1
1
+ 0.4.0
@@ -1,13 +1,11 @@
1
1
  {
2
- "pageRoots": ["site/src/pages/"],
2
+ "pageRoots": ["src/pages/"],
3
3
  "globalPatterns": [
4
- "site/src/layouts/**",
5
- "site/src/components/**",
6
- "site/src/styles/**",
7
- "site/public/**",
8
- "site/astro.config.*",
9
- "site/src/content/**"
4
+ "src/layouts/**",
5
+ "src/components/**",
6
+ "src/styles/**",
7
+ "public/**"
10
8
  ],
11
- "routeScope": ["site/src/**", "site/public/**"],
9
+ "routeScope": ["src/**", "public/**"],
12
10
  "contentRoutes": {}
13
11
  }
@@ -1,19 +1,9 @@
1
1
  {
2
2
  "rules": [
3
3
  {
4
- "cwd": "standard",
5
- "match": ["standard/**"],
6
- "command": "npm ci && npm test"
7
- },
8
- {
9
- "cwd": "reference-impl",
10
- "match": ["reference-impl/**"],
11
- "command": "npm ci && npm test"
12
- },
13
- {
14
- "cwd": "site",
15
- "match": ["site/**"],
16
- "command": "npm ci && npm run build"
4
+ "cwd": ".",
5
+ "match": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"],
6
+ "command": "npm test"
17
7
  }
18
8
  ]
19
9
  }
@@ -1,3 +1,8 @@
1
1
  {
2
- "patterns": ["site/**"]
2
+ "patterns": [
3
+ "src/pages/**",
4
+ "src/components/**",
5
+ "src/layouts/**",
6
+ "src/styles/**"
7
+ ]
3
8
  }
@@ -0,0 +1,17 @@
1
+ {
2
+ "viewports": {
3
+ "mobile": { "width": 375, "height": 667 },
4
+ "tablet": { "width": 768, "height": 1024 },
5
+ "desktop": { "width": 1280, "height": 800 }
6
+ },
7
+ "visualDiff": {
8
+ "enabled": true,
9
+ "threshold": 0.1,
10
+ "maxDiffRatio": 0.01,
11
+ "mask": []
12
+ },
13
+ "axe": {
14
+ "viewports": ["desktop"],
15
+ "dedupeBy": ["ruleId", "target"]
16
+ }
17
+ }
@@ -25,7 +25,7 @@ function routeForPage(file, pageRoot) {
25
25
  return '/';
26
26
  return `/${withoutExt}/`;
27
27
  }
28
- export function loadDefaultConfig() {
28
+ function loadDefaultConfig() {
29
29
  if (!existsSync(DEFAULT_CONFIG)) {
30
30
  throw new Error(`affected-routes config not found at ${DEFAULT_CONFIG}`);
31
31
  }
@@ -0,0 +1,38 @@
1
+ const SEVERITY_MAP = {
2
+ critical: 'blocker',
3
+ serious: 'error',
4
+ moderate: 'warning',
5
+ minor: 'info',
6
+ };
7
+ function dedupeKeyOf(raw, keys) {
8
+ return keys.map((k) => raw[k]).join('||');
9
+ }
10
+ export function dedupeAxeFindings(raws, keys) {
11
+ const groups = new Map();
12
+ for (const raw of raws) {
13
+ const key = dedupeKeyOf(raw, keys);
14
+ const existing = groups.get(key);
15
+ if (existing) {
16
+ if (!existing.viewports.includes(raw.viewport))
17
+ existing.viewports.push(raw.viewport);
18
+ }
19
+ else {
20
+ groups.set(key, { first: raw, viewports: [raw.viewport] });
21
+ }
22
+ }
23
+ const out = [];
24
+ for (const { first, viewports } of groups.values()) {
25
+ out.push({
26
+ severity: SEVERITY_MAP[first.impact],
27
+ message: first.message,
28
+ rule: first.ruleId,
29
+ metadata: {
30
+ target: first.target,
31
+ impact: first.impact,
32
+ viewports,
33
+ ...(first.helpUrl ? { helpUrl: first.helpUrl } : {}),
34
+ },
35
+ });
36
+ }
37
+ return out;
38
+ }
package/dist/cli.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  * write-feedback <repoRoot> <taskId> <envelopeJsonPath>
13
13
  * latest-feedback <repoRoot> <taskId>
14
14
  * emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
15
+ * ui-review-config --repo-root <repoRoot>
15
16
  */
16
17
  import { readFileSync } from 'node:fs';
17
18
  import { execSync } from 'node:child_process';
@@ -24,6 +25,7 @@ import { matchesUiPaths } from './ui-paths.mjs';
24
25
  import { loadUiPathsConfig } from './ui-paths.mjs';
25
26
  import { computeAffectedRoutes } from './affected-routes.mjs';
26
27
  import { loadAffectedRoutesConfig } from './affected-routes.mjs';
28
+ import { loadUiReviewConfig } from './ui-review-config.mjs';
27
29
  function die(msg, code = 1) {
28
30
  process.stderr.write(msg + '\n');
29
31
  process.exit(code);
@@ -39,7 +41,8 @@ function usage(msg) {
39
41
  ' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
40
42
  ' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
41
43
  ' latest-feedback <repoRoot> <taskId>\n' +
42
- ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n');
44
+ ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
45
+ ' ui-review-config --repo-root <repoRoot>\n');
43
46
  process.exit(2);
44
47
  }
45
48
  const [, , command, ...rest] = process.argv;
@@ -211,6 +214,27 @@ try {
211
214
  process.stdout.write(`${JSON.stringify(result)}\n`);
212
215
  process.exit(0);
213
216
  }
217
+ case 'ui-review-config': {
218
+ const flags = rest.filter((a) => a.startsWith('--'));
219
+ const repoRootFlag = flags.find((f) => f.startsWith('--repo-root=') || f === '--repo-root');
220
+ let repoRoot;
221
+ if (repoRootFlag === '--repo-root') {
222
+ repoRoot = rest[rest.indexOf('--repo-root') + 1];
223
+ }
224
+ else if (repoRootFlag) {
225
+ repoRoot = repoRootFlag.replace('--repo-root=', '');
226
+ }
227
+ else {
228
+ repoRoot = rest.filter((a) => !a.startsWith('--'))[0];
229
+ }
230
+ if (!repoRoot) {
231
+ console.error('usage: ui-review-config --repo-root <repoRoot>');
232
+ process.exit(1);
233
+ }
234
+ const config = loadUiReviewConfig(repoRoot);
235
+ process.stdout.write(JSON.stringify(config, null, 2));
236
+ process.exit(0);
237
+ }
214
238
  default:
215
239
  usage(`Unknown command: ${command}`);
216
240
  }
@@ -0,0 +1,65 @@
1
+ function escape(s) {
2
+ return s
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&#39;');
8
+ }
9
+ function renderRow(r) {
10
+ const status = r.passed ? 'PASS' : 'FAIL';
11
+ const statusClass = r.passed ? 'pass' : 'fail';
12
+ return `
13
+ <tr class="${statusClass}">
14
+ <td>${escape(r.ruleId)}</td>
15
+ <td><code>${escape(r.command)}</code></td>
16
+ <td>${escape(r.cwd)}</td>
17
+ <td>${r.durationMs}ms</td>
18
+ <td class="status">${status}</td>
19
+ </tr>
20
+ <tr class="detail ${statusClass}">
21
+ <td colspan="5">
22
+ ${r.stdoutTail ? `<details><summary>stdout (tail)</summary><pre>${escape(r.stdoutTail)}</pre></details>` : ''}
23
+ ${r.stderrTail ? `<details open><summary>stderr (tail)</summary><pre>${escape(r.stderrTail)}</pre></details>` : ''}
24
+ </td>
25
+ </tr>
26
+ `;
27
+ }
28
+ export function renderQaReport(runs) {
29
+ const empty = runs.length === 0
30
+ ? `<p class="empty">No runs / results.</p>`
31
+ : '';
32
+ const rows = runs.map(renderRow).join('');
33
+ return `<!DOCTYPE html>
34
+ <html lang="en">
35
+ <head>
36
+ <meta charset="utf-8">
37
+ <title>Cloverleaf QA Report</title>
38
+ <style>
39
+ body { font: 14px/1.4 system-ui, sans-serif; margin: 2rem; color: #111; }
40
+ table { width: 100%; border-collapse: collapse; }
41
+ th, td { padding: 0.5rem; border-bottom: 1px solid #ddd; text-align: left; vertical-align: top; }
42
+ .status { font-weight: 600; }
43
+ .pass .status { color: #0a7; }
44
+ .fail .status { color: #c33; }
45
+ tr.detail td { background: #fafafa; padding-top: 0; }
46
+ pre { overflow: auto; background: #f4f4f4; padding: 0.5rem; }
47
+ .empty { color: #888; }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <h1>Cloverleaf QA Report</h1>
52
+ ${empty}
53
+ ${runs.length > 0 ? `
54
+ <table>
55
+ <thead>
56
+ <tr><th>Rule</th><th>Command</th><th>CWD</th><th>Duration</th><th>Status</th></tr>
57
+ </thead>
58
+ <tbody>
59
+ ${rows}
60
+ </tbody>
61
+ </table>
62
+ ` : ''}
63
+ </body>
64
+ </html>`;
65
+ }
package/dist/qa-rules.mjs CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { matchesUiPaths } from './ui-paths.mjs';
5
5
  const here = dirname(fileURLToPath(import.meta.url));
6
6
  const DEFAULT_CONFIG = join(here, '..', 'config', 'qa-rules.json');
7
- export function loadDefaultRules() {
7
+ function loadDefaultRules() {
8
8
  if (!existsSync(DEFAULT_CONFIG))
9
9
  return [];
10
10
  const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Turn a URL path into a filesystem-safe slug used in baseline/diff filenames.
3
+ * - "/" → "index"
4
+ * - "/faq/" → "faq"
5
+ * - "/guide/chapter-3/" → "guide-chapter-3"
6
+ * - "/docs/v1.2/getting started/" → "docs-v1-2-getting-started"
7
+ * Query/hash are stripped.
8
+ */
9
+ export function slugifyRoute(route) {
10
+ const pathOnly = route.split(/[?#]/)[0];
11
+ if (pathOnly === '/' || pathOnly === '')
12
+ return 'index';
13
+ const trimmed = pathOnly.replace(/^\/+|\/+$/g, '');
14
+ if (trimmed === '')
15
+ return 'index';
16
+ const slugged = trimmed
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9/-]+/g, '-')
19
+ .replace(/\/+/g, '-')
20
+ .replace(/-+/g, '-')
21
+ .replace(/^-|-$/g, '');
22
+ return slugged || 'index';
23
+ }
package/dist/ui-paths.mjs CHANGED
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join } from 'node:path';
4
4
  const here = dirname(fileURLToPath(import.meta.url));
5
5
  const DEFAULT_CONFIG = join(here, '..', 'config', 'ui-paths.json');
6
- export function loadDefaultPatterns() {
6
+ function loadDefaultPatterns() {
7
7
  if (!existsSync(DEFAULT_CONFIG))
8
8
  return ['site/**'];
9
9
  const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
@@ -0,0 +1,37 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ const here = dirname(fileURLToPath(import.meta.url));
5
+ const PACKAGE_DEFAULT = join(here, '..', 'config', 'ui-review.json');
6
+ const HARDCODED_FALLBACK = {
7
+ viewports: {
8
+ mobile: { width: 375, height: 667 },
9
+ tablet: { width: 768, height: 1024 },
10
+ desktop: { width: 1280, height: 800 },
11
+ },
12
+ visualDiff: { enabled: true, threshold: 0.1, maxDiffRatio: 0.01, mask: [] },
13
+ axe: { viewports: ['desktop'], dedupeBy: ['ruleId', 'target'] },
14
+ };
15
+ function readAsConfig(path) {
16
+ try {
17
+ const doc = JSON.parse(readFileSync(path, 'utf-8'));
18
+ return doc;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function loadUiReviewConfig(repoRoot) {
25
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-review.json');
26
+ if (existsSync(consumerPath)) {
27
+ const parsed = readAsConfig(consumerPath);
28
+ if (parsed)
29
+ return parsed;
30
+ }
31
+ if (existsSync(PACKAGE_DEFAULT)) {
32
+ const parsed = readAsConfig(PACKAGE_DEFAULT);
33
+ if (parsed)
34
+ return parsed;
35
+ }
36
+ return HARDCODED_FALLBACK;
37
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import pixelmatch from 'pixelmatch';
4
+ import { PNG } from 'pngjs';
5
+ function ensureDir(path) {
6
+ mkdirSync(dirname(path), { recursive: true });
7
+ }
8
+ function writeBaseline(baselinePath, buf) {
9
+ ensureDir(baselinePath);
10
+ writeFileSync(baselinePath, buf);
11
+ }
12
+ export function compareVisual(args) {
13
+ const candidatePng = PNG.sync.read(args.candidateBuf);
14
+ if (!existsSync(args.baselinePath)) {
15
+ writeBaseline(args.baselinePath, args.candidateBuf);
16
+ return {
17
+ status: 'new-baseline',
18
+ diffPixels: 0,
19
+ diffRatio: 0,
20
+ width: candidatePng.width,
21
+ height: candidatePng.height,
22
+ };
23
+ }
24
+ const baselineBuf = readFileSync(args.baselinePath);
25
+ const baselinePng = PNG.sync.read(baselineBuf);
26
+ if (baselinePng.width !== candidatePng.width || baselinePng.height !== candidatePng.height) {
27
+ writeBaseline(args.baselinePath, args.candidateBuf);
28
+ return {
29
+ status: 'dimension-mismatch',
30
+ diffPixels: 0,
31
+ diffRatio: 0,
32
+ width: candidatePng.width,
33
+ height: candidatePng.height,
34
+ };
35
+ }
36
+ const diffPng = new PNG({ width: candidatePng.width, height: candidatePng.height });
37
+ const diffPixels = pixelmatch(baselinePng.data, candidatePng.data, diffPng.data, candidatePng.width, candidatePng.height, { threshold: args.threshold });
38
+ const totalPixels = candidatePng.width * candidatePng.height;
39
+ const diffRatio = diffPixels / totalPixels;
40
+ if (diffRatio > args.maxDiffRatio) {
41
+ ensureDir(args.diffPath);
42
+ writeFileSync(args.diffPath, PNG.sync.write(diffPng));
43
+ ensureDir(args.candidateOutPath);
44
+ writeFileSync(args.candidateOutPath, args.candidateBuf);
45
+ writeBaseline(args.baselinePath, args.candidateBuf);
46
+ return {
47
+ status: 'diff',
48
+ diffPixels,
49
+ diffRatio,
50
+ width: candidatePng.width,
51
+ height: candidatePng.height,
52
+ };
53
+ }
54
+ writeBaseline(args.baselinePath, args.candidateBuf);
55
+ return {
56
+ status: 'match',
57
+ diffPixels,
58
+ diffRatio,
59
+ width: candidatePng.width,
60
+ height: candidatePng.height,
61
+ };
62
+ }
package/install.sh CHANGED
@@ -2,59 +2,45 @@
2
2
  set -euo pipefail
3
3
 
4
4
  # Cloverleaf Reference Impl installer.
5
- # Default: installs skills + CLI shim into ~/.claude/plugins/cloverleaf/.
6
- # --project: installs locally into ./.claude/plugins/cloverleaf/.
5
+ #
6
+ # As of v0.4.0, cloverleaf installs as a proper Claude Code plugin via the
7
+ # `claude plugin` CLI. Point Claude Code at the cloverleaf repo root (where
8
+ # .claude-plugin/marketplace.json lives), then install the plugin from the
9
+ # resulting marketplace.
7
10
 
8
11
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
- MODE="user"
12
+ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
10
13
 
11
- while [[ $# -gt 0 ]]; do
12
- case "$1" in
13
- --project) MODE="project"; shift ;;
14
- --help|-h)
15
- echo "Usage: ./install.sh [--project]"
16
- echo " --project: install locally in .claude/plugins/cloverleaf/"
17
- echo " (default): install at ~/.claude/plugins/cloverleaf/"
18
- exit 0 ;;
19
- *) echo "Unknown arg: $1"; exit 2 ;;
20
- esac
21
- done
22
-
23
- if [[ "$MODE" == "user" ]]; then
24
- INSTALL_ROOT="${HOME}/.claude/plugins/cloverleaf"
25
- else
26
- INSTALL_ROOT="$(pwd)/.claude/plugins/cloverleaf"
14
+ if ! command -v claude >/dev/null 2>&1; then
15
+ echo "error: 'claude' CLI not found on PATH."
16
+ echo "Install Claude Code first: https://docs.claude.com/claude-code"
17
+ exit 1
27
18
  fi
28
19
 
29
- mkdir -p "${INSTALL_ROOT}/skills" "${INSTALL_ROOT}/prompts" "${INSTALL_ROOT}/bin"
30
-
31
- # Symlink config directory
32
- ln -sf "${SCRIPT_DIR}/config" "${INSTALL_ROOT}/config"
33
-
34
- # Symlink skills
35
- for f in "${SCRIPT_DIR}/skills/"*.md; do
36
- name="$(basename "$f")"
37
- ln -sf "$f" "${INSTALL_ROOT}/skills/${name}"
38
- done
20
+ if [ ! -f "${REPO_ROOT}/.claude-plugin/marketplace.json" ]; then
21
+ echo "error: ${REPO_ROOT}/.claude-plugin/marketplace.json not found."
22
+ echo "This script expects to be run from inside a cloverleaf checkout."
23
+ exit 1
24
+ fi
39
25
 
40
- # Symlink prompts
41
- for f in "${SCRIPT_DIR}/prompts/"*.md; do
42
- name="$(basename "$f")"
43
- ln -sf "$f" "${INSTALL_ROOT}/prompts/${name}"
44
- done
26
+ echo "Registering cloverleaf marketplace from ${REPO_ROOT}..."
27
+ claude plugin marketplace add "${REPO_ROOT}"
45
28
 
46
- # Write the CLI shim
47
- cat > "${INSTALL_ROOT}/bin/cloverleaf-cli" <<EOF
48
- #!/usr/bin/env bash
49
- exec npx --yes tsx "${SCRIPT_DIR}/lib/cli.ts" "\$@"
50
- EOF
51
- chmod +x "${INSTALL_ROOT}/bin/cloverleaf-cli"
29
+ echo "Installing cloverleaf plugin..."
30
+ claude plugin install cloverleaf@cloverleaf-local
52
31
 
53
- echo "Cloverleaf reference impl installed at: ${INSTALL_ROOT}"
54
- echo "Skills available: $(ls "${INSTALL_ROOT}/skills" | wc -l | tr -d ' ')"
55
32
  echo ""
56
- echo "Add ${INSTALL_ROOT}/bin to your PATH if you want to invoke cloverleaf-cli directly,"
57
- echo "or reference it by absolute path from your skill calls."
33
+ echo "Cloverleaf installed. Slash commands:"
34
+ echo " /cloverleaf-new-task — scaffold a Task from a brief"
35
+ echo " /cloverleaf-run — full pipeline orchestrator"
36
+ echo " /cloverleaf-implement — run Implementer"
37
+ echo " /cloverleaf-document — run Documenter"
38
+ echo " /cloverleaf-review — run Reviewer"
39
+ echo " /cloverleaf-ui-review — run UI Reviewer (visual diff + multi-viewport + axe)"
40
+ echo " /cloverleaf-qa — run QA"
41
+ echo " /cloverleaf-merge — merge gate"
42
+ echo ""
43
+ echo "Restart any open Claude Code sessions to pick up the new skills."
58
44
 
59
45
  # Post-install: warn about Playwright chromium if not cached
60
46
  if [ ! -d "${HOME}/.cache/ms-playwright" ] || [ -z "$(ls -A "${HOME}/.cache/ms-playwright" 2>/dev/null)" ]; then
@@ -34,7 +34,7 @@ function routeForPage(file: string, pageRoot: string): string | null {
34
34
  return `/${withoutExt}/`;
35
35
  }
36
36
 
37
- export function loadDefaultConfig(): AffectedRoutesConfig {
37
+ function loadDefaultConfig(): AffectedRoutesConfig {
38
38
  if (!existsSync(DEFAULT_CONFIG)) {
39
39
  throw new Error(`affected-routes config not found at ${DEFAULT_CONFIG}`);
40
40
  }
@@ -0,0 +1,53 @@
1
+ import type { Finding, FindingSeverity } from './feedback.js';
2
+
3
+ export type AxeImpact = 'critical' | 'serious' | 'moderate' | 'minor';
4
+
5
+ export interface RawAxeFinding {
6
+ viewport: string;
7
+ ruleId: string;
8
+ target: string;
9
+ impact: AxeImpact;
10
+ message: string;
11
+ helpUrl?: string;
12
+ }
13
+
14
+ export type DedupeKey = 'ruleId' | 'target';
15
+
16
+ const SEVERITY_MAP: Record<AxeImpact, FindingSeverity> = {
17
+ critical: 'blocker',
18
+ serious: 'error',
19
+ moderate: 'warning',
20
+ minor: 'info',
21
+ };
22
+
23
+ function dedupeKeyOf(raw: RawAxeFinding, keys: DedupeKey[]): string {
24
+ return keys.map((k) => raw[k]).join('||');
25
+ }
26
+
27
+ export function dedupeAxeFindings(raws: RawAxeFinding[], keys: DedupeKey[]): Finding[] {
28
+ const groups = new Map<string, { first: RawAxeFinding; viewports: string[] }>();
29
+ for (const raw of raws) {
30
+ const key = dedupeKeyOf(raw, keys);
31
+ const existing = groups.get(key);
32
+ if (existing) {
33
+ if (!existing.viewports.includes(raw.viewport)) existing.viewports.push(raw.viewport);
34
+ } else {
35
+ groups.set(key, { first: raw, viewports: [raw.viewport] });
36
+ }
37
+ }
38
+ const out: Finding[] = [];
39
+ for (const { first, viewports } of groups.values()) {
40
+ out.push({
41
+ severity: SEVERITY_MAP[first.impact],
42
+ message: first.message,
43
+ rule: first.ruleId,
44
+ metadata: {
45
+ target: first.target,
46
+ impact: first.impact,
47
+ viewports,
48
+ ...(first.helpUrl ? { helpUrl: first.helpUrl } : {}),
49
+ },
50
+ });
51
+ }
52
+ return out;
53
+ }
package/lib/cli.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * write-feedback <repoRoot> <taskId> <envelopeJsonPath>
13
13
  * latest-feedback <repoRoot> <taskId>
14
14
  * emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
15
+ * ui-review-config --repo-root <repoRoot>
15
16
  */
16
17
 
17
18
  import { readFileSync } from 'node:fs';
@@ -25,6 +26,7 @@ import { matchesUiPaths } from './ui-paths.js';
25
26
  import { loadUiPathsConfig } from './ui-paths.js';
26
27
  import { computeAffectedRoutes } from './affected-routes.js';
27
28
  import { loadAffectedRoutesConfig } from './affected-routes.js';
29
+ import { loadUiReviewConfig } from './ui-review-config.js';
28
30
  import type { FeedbackEnvelope } from './feedback.js';
29
31
 
30
32
  function die(msg: string, code = 1): never {
@@ -43,7 +45,8 @@ function usage(msg?: string): never {
43
45
  ' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
44
46
  ' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
45
47
  ' latest-feedback <repoRoot> <taskId>\n' +
46
- ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n'
48
+ ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
49
+ ' ui-review-config --repo-root <repoRoot>\n'
47
50
  );
48
51
  process.exit(2);
49
52
  }
@@ -221,6 +224,26 @@ try {
221
224
  process.exit(0);
222
225
  }
223
226
 
227
+ case 'ui-review-config': {
228
+ const flags = rest.filter((a) => a.startsWith('--'));
229
+ const repoRootFlag = flags.find((f) => f.startsWith('--repo-root=') || f === '--repo-root');
230
+ let repoRoot: string | undefined;
231
+ if (repoRootFlag === '--repo-root') {
232
+ repoRoot = rest[rest.indexOf('--repo-root') + 1];
233
+ } else if (repoRootFlag) {
234
+ repoRoot = repoRootFlag.replace('--repo-root=', '');
235
+ } else {
236
+ repoRoot = rest.filter((a) => !a.startsWith('--'))[0];
237
+ }
238
+ if (!repoRoot) {
239
+ console.error('usage: ui-review-config --repo-root <repoRoot>');
240
+ process.exit(1);
241
+ }
242
+ const config = loadUiReviewConfig(repoRoot);
243
+ process.stdout.write(JSON.stringify(config, null, 2));
244
+ process.exit(0);
245
+ }
246
+
224
247
  default:
225
248
  usage(`Unknown command: ${command}`);
226
249
  }
package/lib/feedback.ts CHANGED
@@ -13,12 +13,19 @@ export interface FindingLocation {
13
13
  work_item_id?: { project: string; id: string };
14
14
  }
15
15
 
16
+ export interface Attachment {
17
+ label: string;
18
+ path: string;
19
+ }
20
+
16
21
  export interface Finding {
17
22
  severity: FindingSeverity;
18
23
  message: string;
19
24
  location?: FindingLocation;
20
25
  suggestion?: string;
21
26
  rule?: string;
27
+ attachments?: Attachment[];
28
+ metadata?: Record<string, unknown>;
22
29
  }
23
30
 
24
31
  export interface FeedbackEnvelope {