@cloverleaf/reference-impl 0.2.0 → 0.3.1

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.
package/README.md CHANGED
@@ -15,7 +15,7 @@ npm install # pulls @cloverleaf/standard + deps
15
15
  ./install.sh --project # local install into ./.claude/plugins/cloverleaf/
16
16
  ```
17
17
 
18
- ## Scope (v0.2)
18
+ ## Scope (v0.3)
19
19
 
20
20
  v0.2 implements both paths of the Delivery track:
21
21
 
@@ -29,7 +29,7 @@ v0.2 implements both paths of the Delivery track:
29
29
  | Implementer | Real | Subagent, code + tests on feature branch |
30
30
  | Documenter | Real (v0.2) | Subagent, doc-only commits per file-path rules |
31
31
  | Reviewer | Real | Subagent, read-only review of diff |
32
- | UI Reviewer | Real (v0.2) | Playwright + axe-core, single viewport, a11y only |
32
+ | UI Reviewer | Real (v0.3) | Playwright + axe-core, diff-scoped to affected routes, single viewport, a11y only |
33
33
  | QA | Real (v0.2) | Per-package test runner via `git worktree` |
34
34
  | Plan | Stub | Deferred to v0.3 |
35
35
  | Researcher | Stub | Deferred to v0.3 |
@@ -52,13 +52,52 @@ Two JSON config files in `config/` (overridable per consumer project):
52
52
  - `config/ui-paths.json` — glob patterns that trigger UI Reviewer (default: `site/**`)
53
53
  - `config/qa-rules.json` — per-package test commands
54
54
 
55
+ ### Customizing for your repo
56
+
57
+ The package ships with cloverleaf-flavored defaults in `config/`. Your repo overrides any of them by placing a file of the same name at `<repoRoot>/.cloverleaf/config/<name>.json`. Consumer override is a **full replacement** — your file becomes the complete source of truth for that config.
58
+
59
+ Available overrides:
60
+
61
+ | Override file | Purpose |
62
+ |---|---|
63
+ | `.cloverleaf/config/ui-paths.json` | Controls `detect-ui-paths` — which diffs trigger the `ui-review` state |
64
+ | `.cloverleaf/config/qa-rules.json` | Per-package test commands for the QA agent |
65
+ | `.cloverleaf/config/affected-routes.json` | Rules for mapping diffs to site routes (UI Reviewer scope); includes `contentRoutes` for content-collection mapping |
66
+ | `.cloverleaf/config/astro-base.json` | Explicit Astro `base` path — avoids best-effort parsing of `astro.config.*` |
67
+
68
+ Example `affected-routes.json` override for a Next.js project:
69
+
70
+ ```json
71
+ {
72
+ "pageRoots": ["apps/web/app/"],
73
+ "globalPatterns": ["apps/web/components/**", "apps/web/styles/**"],
74
+ "routeScope": ["apps/web/**"],
75
+ "contentRoutes": {}
76
+ }
77
+ ```
78
+
79
+ Example `astro-base.json`:
80
+
81
+ ```json
82
+ { "base": "/my-docs" }
83
+ ```
84
+
85
+ All overrides are read fresh on every skill invocation; no caching. Edit and the next `/cloverleaf-run` picks it up.
86
+
55
87
  ### Known limitations
56
88
 
57
- - Playwright installs ~300MB into each `git worktree` (v0.3 will cache).
58
89
  - Concurrent `/cloverleaf-run` on the same repo may race on preview ports.
59
- - UI Reviewer visual diff + multi-viewport deferred to v0.3.
90
+ - UI Reviewer visual diff + multi-viewport deferred to v0.4.
60
91
  - QA does not produce HTML reports (no `report_uri`).
61
92
 
93
+ ### Prerequisites for UI Reviewer
94
+
95
+ Run once per machine:
96
+
97
+ npx playwright install chromium
98
+
99
+ This installs chromium into `~/.cache/ms-playwright/` (the default `PLAYWRIGHT_BROWSERS_PATH`). Subsequent `/cloverleaf-ui-review` invocations reuse this cache — no ~300 MB re-download per run.
100
+
62
101
  ## Quick start — toy repo
63
102
 
64
103
  ```bash
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.3.1
@@ -0,0 +1,13 @@
1
+ {
2
+ "pageRoots": ["site/src/pages/"],
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/**"
10
+ ],
11
+ "routeScope": ["site/src/**", "site/public/**"],
12
+ "contentRoutes": {}
13
+ }
@@ -0,0 +1,99 @@
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 DEFAULT_CONFIG = join(here, '..', 'config', 'affected-routes.json');
6
+ function globToRegex(pattern) {
7
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
8
+ const regex = escaped
9
+ .replace(/\*\*/g, '\u0000')
10
+ .replace(/\*/g, '[^/]*')
11
+ .replace(/\u0000/g, '.*');
12
+ return new RegExp(`^${regex}$`);
13
+ }
14
+ function matchesAny(file, patterns) {
15
+ return patterns.some((p) => globToRegex(p).test(file));
16
+ }
17
+ function routeForPage(file, pageRoot) {
18
+ if (!file.startsWith(pageRoot))
19
+ return null;
20
+ const rel = file.slice(pageRoot.length);
21
+ const withoutExt = rel.replace(/\.(astro|mdx)$/, '');
22
+ if (!withoutExt || withoutExt === rel)
23
+ return null;
24
+ if (withoutExt === 'index')
25
+ return '/';
26
+ return `/${withoutExt}/`;
27
+ }
28
+ export function loadDefaultConfig() {
29
+ if (!existsSync(DEFAULT_CONFIG)) {
30
+ throw new Error(`affected-routes config not found at ${DEFAULT_CONFIG}`);
31
+ }
32
+ const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
33
+ return {
34
+ pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
35
+ globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
36
+ routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
37
+ contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
38
+ ? doc.contentRoutes
39
+ : {},
40
+ };
41
+ }
42
+ export function loadAffectedRoutesConfig(repoRoot) {
43
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'affected-routes.json');
44
+ if (existsSync(consumerPath)) {
45
+ try {
46
+ const doc = JSON.parse(readFileSync(consumerPath, 'utf-8'));
47
+ return {
48
+ pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
49
+ globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
50
+ routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
51
+ contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
52
+ ? doc.contentRoutes
53
+ : {},
54
+ };
55
+ }
56
+ catch {
57
+ // fall through
58
+ }
59
+ }
60
+ return loadDefaultConfig();
61
+ }
62
+ export function computeAffectedRoutes(changedFiles, config) {
63
+ const routes = new Set();
64
+ let inScopeButUnmatched = false;
65
+ for (const file of changedFiles) {
66
+ if (matchesAny(file, config.globalPatterns)) {
67
+ return 'all';
68
+ }
69
+ let mapped = null;
70
+ for (const root of config.pageRoots) {
71
+ const r = routeForPage(file, root);
72
+ if (r) {
73
+ mapped = r;
74
+ break;
75
+ }
76
+ }
77
+ if (mapped) {
78
+ routes.add(mapped);
79
+ continue;
80
+ }
81
+ // contentRoutes: map content-collection files to specific routes
82
+ for (const [pattern, route] of Object.entries(config.contentRoutes)) {
83
+ if (globToRegex(pattern).test(file)) {
84
+ routes.add(route);
85
+ mapped = route;
86
+ break;
87
+ }
88
+ }
89
+ if (mapped)
90
+ continue;
91
+ if (matchesAny(file, config.routeScope)) {
92
+ inScopeButUnmatched = true;
93
+ }
94
+ }
95
+ if (inScopeButUnmatched) {
96
+ return 'all';
97
+ }
98
+ return Array.from(routes).sort();
99
+ }
package/dist/cli.mjs CHANGED
@@ -20,7 +20,10 @@ import { advanceStatus } from './state.mjs';
20
20
  import { emitGateDecision } from './events.mjs';
21
21
  import { writeFeedback, latestFeedback } from './feedback.mjs';
22
22
  import { nextTaskId, inferProject } from './ids.mjs';
23
- import { matchesUiPaths, loadDefaultPatterns } from './ui-paths.mjs';
23
+ import { matchesUiPaths } from './ui-paths.mjs';
24
+ import { loadUiPathsConfig } from './ui-paths.mjs';
25
+ import { computeAffectedRoutes } from './affected-routes.mjs';
26
+ import { loadAffectedRoutesConfig } from './affected-routes.mjs';
24
27
  function die(msg, code = 1) {
25
28
  process.stderr.write(msg + '\n');
26
29
  process.exit(code);
@@ -176,11 +179,38 @@ try {
176
179
  console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
177
180
  process.exit(2);
178
181
  }
179
- const patterns = loadDefaultPatterns();
182
+ const { patterns } = loadUiPathsConfig(repoRoot);
180
183
  const result = matchesUiPaths(changed, patterns);
181
184
  process.stdout.write(`${result}\n`);
182
185
  process.exit(0);
183
186
  }
187
+ case 'affected-routes': {
188
+ const [repoRoot, taskId] = rest;
189
+ if (!repoRoot || !taskId) {
190
+ console.error('usage: affected-routes <repo_root> <task-id>');
191
+ process.exit(1);
192
+ }
193
+ const branch = `cloverleaf/${taskId}`;
194
+ let changed;
195
+ try {
196
+ const out = execSync(`git diff --name-only main..${branch}`, {
197
+ cwd: repoRoot,
198
+ encoding: 'utf-8',
199
+ stdio: ['pipe', 'pipe', 'pipe'],
200
+ });
201
+ changed = out.split('\n').map((l) => l.trim()).filter(Boolean);
202
+ }
203
+ catch (e) {
204
+ const err = e;
205
+ const stderrStr = typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '';
206
+ console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
207
+ process.exit(2);
208
+ }
209
+ const config = loadAffectedRoutesConfig(repoRoot);
210
+ const result = computeAffectedRoutes(changed, config);
211
+ process.stdout.write(`${JSON.stringify(result)}\n`);
212
+ process.exit(0);
213
+ }
184
214
  default:
185
215
  usage(`Unknown command: ${command}`);
186
216
  }
package/dist/qa-rules.mjs CHANGED
@@ -10,6 +10,21 @@ export function loadDefaultRules() {
10
10
  const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
11
11
  return Array.isArray(doc.rules) ? doc.rules : [];
12
12
  }
13
+ export function loadQaRulesConfig(repoRoot) {
14
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'qa-rules.json');
15
+ if (existsSync(consumerPath)) {
16
+ try {
17
+ const doc = JSON.parse(readFileSync(consumerPath, 'utf-8'));
18
+ if (Array.isArray(doc.rules)) {
19
+ return doc.rules;
20
+ }
21
+ }
22
+ catch {
23
+ // fall through
24
+ }
25
+ }
26
+ return loadDefaultRules();
27
+ }
13
28
  export function selectTestCommands(changedFiles, rules) {
14
29
  return rules.filter((rule) => matchesUiPaths(changedFiles, rule.match));
15
30
  }
package/dist/ui-paths.mjs CHANGED
@@ -17,6 +17,21 @@ function globToRegex(pattern) {
17
17
  .replace(/\u0000/g, '.*');
18
18
  return new RegExp(`^${regex}$`);
19
19
  }
20
+ export function loadUiPathsConfig(repoRoot) {
21
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-paths.json');
22
+ if (existsSync(consumerPath)) {
23
+ try {
24
+ const doc = JSON.parse(readFileSync(consumerPath, 'utf-8'));
25
+ if (Array.isArray(doc.patterns)) {
26
+ return { patterns: doc.patterns };
27
+ }
28
+ }
29
+ catch {
30
+ // fall through to package default
31
+ }
32
+ }
33
+ return { patterns: loadDefaultPatterns() };
34
+ }
20
35
  export function matchesUiPaths(changedFiles, patterns) {
21
36
  if (changedFiles.length === 0)
22
37
  return false;
package/install.sh CHANGED
@@ -55,3 +55,10 @@ echo "Skills available: $(ls "${INSTALL_ROOT}/skills" | wc -l | tr -d ' ')"
55
55
  echo ""
56
56
  echo "Add ${INSTALL_ROOT}/bin to your PATH if you want to invoke cloverleaf-cli directly,"
57
57
  echo "or reference it by absolute path from your skill calls."
58
+
59
+ # Post-install: warn about Playwright chromium if not cached
60
+ if [ ! -d "${HOME}/.cache/ms-playwright" ] || [ -z "$(ls -A "${HOME}/.cache/ms-playwright" 2>/dev/null)" ]; then
61
+ echo ""
62
+ echo "Note: UI Reviewer uses Playwright chromium. If you plan to run /cloverleaf-ui-review, install once with:"
63
+ echo " npx playwright install chromium"
64
+ fi
@@ -0,0 +1,113 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ const DEFAULT_CONFIG = join(here, '..', 'config', 'affected-routes.json');
7
+
8
+ export interface AffectedRoutesConfig {
9
+ pageRoots: string[];
10
+ globalPatterns: string[];
11
+ routeScope: string[];
12
+ contentRoutes: Record<string, string>;
13
+ }
14
+
15
+ function globToRegex(pattern: string): RegExp {
16
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
17
+ const regex = escaped
18
+ .replace(/\*\*/g, '\u0000')
19
+ .replace(/\*/g, '[^/]*')
20
+ .replace(/\u0000/g, '.*');
21
+ return new RegExp(`^${regex}$`);
22
+ }
23
+
24
+ function matchesAny(file: string, patterns: string[]): boolean {
25
+ return patterns.some((p) => globToRegex(p).test(file));
26
+ }
27
+
28
+ function routeForPage(file: string, pageRoot: string): string | null {
29
+ if (!file.startsWith(pageRoot)) return null;
30
+ const rel = file.slice(pageRoot.length);
31
+ const withoutExt = rel.replace(/\.(astro|mdx)$/, '');
32
+ if (!withoutExt || withoutExt === rel) return null;
33
+ if (withoutExt === 'index') return '/';
34
+ return `/${withoutExt}/`;
35
+ }
36
+
37
+ export function loadDefaultConfig(): AffectedRoutesConfig {
38
+ if (!existsSync(DEFAULT_CONFIG)) {
39
+ throw new Error(`affected-routes config not found at ${DEFAULT_CONFIG}`);
40
+ }
41
+ const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as Partial<AffectedRoutesConfig>;
42
+ return {
43
+ pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
44
+ globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
45
+ routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
46
+ contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
47
+ ? doc.contentRoutes
48
+ : {},
49
+ };
50
+ }
51
+
52
+ export function loadAffectedRoutesConfig(repoRoot: string): AffectedRoutesConfig {
53
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'affected-routes.json');
54
+ if (existsSync(consumerPath)) {
55
+ try {
56
+ const doc = JSON.parse(readFileSync(consumerPath, 'utf-8')) as Partial<AffectedRoutesConfig>;
57
+ return {
58
+ pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
59
+ globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
60
+ routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
61
+ contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
62
+ ? doc.contentRoutes
63
+ : {},
64
+ };
65
+ } catch {
66
+ // fall through
67
+ }
68
+ }
69
+ return loadDefaultConfig();
70
+ }
71
+
72
+ export function computeAffectedRoutes(
73
+ changedFiles: string[],
74
+ config: AffectedRoutesConfig
75
+ ): string[] | 'all' {
76
+ const routes = new Set<string>();
77
+ let inScopeButUnmatched = false;
78
+
79
+ for (const file of changedFiles) {
80
+ if (matchesAny(file, config.globalPatterns)) {
81
+ return 'all';
82
+ }
83
+ let mapped: string | null = null;
84
+ for (const root of config.pageRoots) {
85
+ const r = routeForPage(file, root);
86
+ if (r) {
87
+ mapped = r;
88
+ break;
89
+ }
90
+ }
91
+ if (mapped) {
92
+ routes.add(mapped);
93
+ continue;
94
+ }
95
+ // contentRoutes: map content-collection files to specific routes
96
+ for (const [pattern, route] of Object.entries(config.contentRoutes)) {
97
+ if (globToRegex(pattern).test(file)) {
98
+ routes.add(route);
99
+ mapped = route;
100
+ break;
101
+ }
102
+ }
103
+ if (mapped) continue;
104
+ if (matchesAny(file, config.routeScope)) {
105
+ inScopeButUnmatched = true;
106
+ }
107
+ }
108
+
109
+ if (inScopeButUnmatched) {
110
+ return 'all';
111
+ }
112
+ return Array.from(routes).sort();
113
+ }
package/lib/cli.ts CHANGED
@@ -21,7 +21,10 @@ import { advanceStatus } from './state.js';
21
21
  import { emitGateDecision } from './events.js';
22
22
  import { writeFeedback, latestFeedback } from './feedback.js';
23
23
  import { nextTaskId, inferProject } from './ids.js';
24
- import { matchesUiPaths, loadDefaultPatterns } from './ui-paths.js';
24
+ import { matchesUiPaths } from './ui-paths.js';
25
+ import { loadUiPathsConfig } from './ui-paths.js';
26
+ import { computeAffectedRoutes } from './affected-routes.js';
27
+ import { loadAffectedRoutesConfig } from './affected-routes.js';
25
28
  import type { FeedbackEnvelope } from './feedback.js';
26
29
 
27
30
  function die(msg: string, code = 1): never {
@@ -185,12 +188,39 @@ try {
185
188
  console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
186
189
  process.exit(2);
187
190
  }
188
- const patterns = loadDefaultPatterns();
191
+ const { patterns } = loadUiPathsConfig(repoRoot);
189
192
  const result = matchesUiPaths(changed, patterns);
190
193
  process.stdout.write(`${result}\n`);
191
194
  process.exit(0);
192
195
  }
193
196
 
197
+ case 'affected-routes': {
198
+ const [repoRoot, taskId] = rest;
199
+ if (!repoRoot || !taskId) {
200
+ console.error('usage: affected-routes <repo_root> <task-id>');
201
+ process.exit(1);
202
+ }
203
+ const branch = `cloverleaf/${taskId}`;
204
+ let changed: string[];
205
+ try {
206
+ const out = execSync(`git diff --name-only main..${branch}`, {
207
+ cwd: repoRoot,
208
+ encoding: 'utf-8',
209
+ stdio: ['pipe', 'pipe', 'pipe'],
210
+ });
211
+ changed = out.split('\n').map((l) => l.trim()).filter(Boolean);
212
+ } catch (e: unknown) {
213
+ const err = e as { stderr?: Buffer | string; message?: string };
214
+ const stderrStr = typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '';
215
+ console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
216
+ process.exit(2);
217
+ }
218
+ const config = loadAffectedRoutesConfig(repoRoot);
219
+ const result = computeAffectedRoutes(changed, config);
220
+ process.stdout.write(`${JSON.stringify(result)}\n`);
221
+ process.exit(0);
222
+ }
223
+
194
224
  default:
195
225
  usage(`Unknown command: ${command}`);
196
226
  }
package/lib/qa-rules.ts CHANGED
@@ -18,6 +18,21 @@ export function loadDefaultRules(): QaRule[] {
18
18
  return Array.isArray(doc.rules) ? doc.rules : [];
19
19
  }
20
20
 
21
+ export function loadQaRulesConfig(repoRoot: string): QaRule[] {
22
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'qa-rules.json');
23
+ if (existsSync(consumerPath)) {
24
+ try {
25
+ const doc = JSON.parse(readFileSync(consumerPath, 'utf-8')) as { rules?: QaRule[] };
26
+ if (Array.isArray(doc.rules)) {
27
+ return doc.rules;
28
+ }
29
+ } catch {
30
+ // fall through
31
+ }
32
+ }
33
+ return loadDefaultRules();
34
+ }
35
+
21
36
  export function selectTestCommands(changedFiles: string[], rules: QaRule[]): QaRule[] {
22
37
  return rules.filter((rule) => matchesUiPaths(changedFiles, rule.match));
23
38
  }
package/lib/ui-paths.ts CHANGED
@@ -20,6 +20,21 @@ function globToRegex(pattern: string): RegExp {
20
20
  return new RegExp(`^${regex}$`);
21
21
  }
22
22
 
23
+ export function loadUiPathsConfig(repoRoot: string): { patterns: string[] } {
24
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-paths.json');
25
+ if (existsSync(consumerPath)) {
26
+ try {
27
+ const doc = JSON.parse(readFileSync(consumerPath, 'utf-8')) as { patterns?: string[] };
28
+ if (Array.isArray(doc.patterns)) {
29
+ return { patterns: doc.patterns };
30
+ }
31
+ } catch {
32
+ // fall through to package default
33
+ }
34
+ }
35
+ return { patterns: loadDefaultPatterns() };
36
+ }
37
+
23
38
  export function matchesUiPaths(changedFiles: string[], patterns: string[]): boolean {
24
39
  if (changedFiles.length === 0) return false;
25
40
  const regexes = patterns.map(globToRegex);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloverleaf/reference-impl",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,23 +10,33 @@ You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes for acc
10
10
  - **Repo root**: {{repo_root}}
11
11
  - **Diff from base**: {{diff}}
12
12
  - **Preview port**: {{preview_port}} (an already-allocated free local port; use it for the dev server)
13
+ - **Affected routes**: {{affected_routes}} — either a JSON array of route paths (e.g., `["/faq/"]`), or the string `"all"`, or `[]`
13
14
 
14
- ## Scope (v0.2)
15
+ ## Scope (v0.3)
15
16
 
16
17
  - Accessibility only (axe-core). No visual diff, no responsive checks.
17
18
  - Single viewport: 1280×800.
18
- - Up to 20 pages reachable from `/` via same-origin link discovery.
19
- - Visual diff, viewports loop, and `visual_diff_uri` are deferred to v0.3.
19
+ - Run axe ONLY on the pages listed in `{{affected_routes}}`.
20
+ - If `{{affected_routes}}` is `"all"`: crawl up to 20 pages reachable from `/` via same-origin link discovery (v0.2 fallback behavior).
21
+ - If `{{affected_routes}}` is `[]`: return `verdict: "pass"` with summary "No renderable routes affected, skipping axe." Do NOT start the preview server.
22
+ - Otherwise: visit exactly the URLs listed. No link-discovery crawl.
23
+ - Visual diff, viewports loop, and `visual_diff_uri` are deferred to v0.4.
24
+
25
+ ## Playwright cache
26
+
27
+ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playwright` before you are invoked. Playwright resolves chromium from this shared cache, so `npm ci` in the worktree does NOT re-download ~300 MB of browser binaries. If the browser is missing, return `verdict: "escalate"` with a synthetic finding: `"Playwright chromium not installed. Run 'npx playwright install chromium' on this machine."`
20
28
 
21
29
  ## Runtime procedure
22
30
 
23
- 1. Set up an isolated worktree of the feature branch:
31
+ 1. If `{{affected_routes}}` is `[]`, return immediately (pass-skip) — no worktree, no server, no browser.
32
+
33
+ 2. Set up an isolated worktree of the feature branch:
24
34
  ```bash
25
35
  TMPDIR=$(mktemp -d)
26
36
  git worktree add "$TMPDIR" {{branch}}
27
37
  ```
28
38
 
29
- 2. For this repo, UI lives in `site/`. Install dependencies and start the dev server:
39
+ 3. For this repo, UI lives in `site/`. Install dependencies and start the dev server:
30
40
  ```bash
31
41
  cd "$TMPDIR/site"
32
42
  npm ci
@@ -34,32 +44,35 @@ You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes for acc
34
44
  SERVER_PID=$!
35
45
  ```
36
46
 
37
- 3. Wait up to 30s for `http://localhost:{{preview_port}}/` to respond 200. If the server fails to start in 30s, kill it and return verdict `escalate`.
47
+ 4. Wait up to 30s for `http://localhost:{{preview_port}}/` to respond 200. If the server fails to start in 30s, kill it and return verdict `escalate`.
48
+
49
+ 5. Determine the site base path:
50
+ 1. Check `<repoRoot>/.cloverleaf/config/astro-base.json`. Expected shape: `{ "base": "<path>" }`. If present, use the `base` field verbatim and skip to step 6. (Consumer override — checked before parsing astro config.)
51
+ 2. Otherwise, attempt to locate and parse an astro config file (common locations: `site/astro.config.mjs`, `astro.config.mjs` at repo root, `apps/web/astro.config.mjs`). This is best-effort; the v0.3 behavior is preserved. Consumers with non-conventional layouts should supply `astro-base.json` rather than relying on parse.
52
+ 3. If both fail, treat base as empty string.
38
53
 
39
- 4. Use Playwright chromium (headless) to:
40
- - Navigate to `/`
41
- - Discover same-origin links (collect `<a href>` values pointing to the same origin)
42
- - Visit up to 20 distinct pages (including `/`)
43
- - On each page, inject and run `axe-core`:
54
+ 6. For each route in `{{affected_routes}}` (or the crawl set, if `"all"`):
55
+ - Construct URL `http://localhost:{{preview_port}}<base><route>`.
56
+ - Navigate. If 404, retry at `http://localhost:{{preview_port}}<route>` (without base).
57
+ - Inject and run axe-core:
44
58
  ```javascript
45
59
  import axe from 'axe-core';
46
60
  const results = await axe.run(document);
47
61
  ```
48
- - Collect all violations
62
+ - Collect violations.
49
63
 
50
- 5. Map violations to findings:
64
+ 7. Map violations to findings:
51
65
  - axe `impact: "critical"` → `severity: "blocker"`
52
66
  - axe `impact: "serious"` → `severity: "error"`
53
67
  - axe `impact: "moderate"` → `severity: "warning"`
54
68
  - axe `impact: "minor"` → `severity: "info"`
55
- - Each finding: `{severity, rule: "a11y.<wcag-id-or-rule-id>", message: <axe description>, location: <page url>}`
56
69
 
57
- 6. Compute verdict:
70
+ 8. Compute verdict:
58
71
  - `pass` — zero findings with severity `blocker` or `error`
59
72
  - `bounce` — ≥1 finding with severity `blocker` or `error`
60
- - `escalate` — preview server failed to start, OR axe threw ≥3 consecutive times (infrastructure-level problem, not a real UI issue)
73
+ - `escalate` — preview server failed to start, OR axe threw ≥3 consecutive times, OR Playwright chromium missing.
61
74
 
62
- 7. Teardown:
75
+ 9. Teardown:
63
76
  ```bash
64
77
  kill $SERVER_PID 2>/dev/null || true
65
78
  cd {{repo_root}}
@@ -21,7 +21,12 @@ description: Run the QA agent on a task in the `qa` state (full pipeline only).
21
21
 
22
22
  4. Load QA rules JSON:
23
23
  ```bash
24
- cat ~/.claude/plugins/cloverleaf/config/qa-rules.json
24
+ # Consumer override takes precedence over the package default.
25
+ if [ -f "<repo_root>/.cloverleaf/config/qa-rules.json" ]; then
26
+ cat "<repo_root>/.cloverleaf/config/qa-rules.json"
27
+ else
28
+ cat ~/.claude/plugins/cloverleaf/config/qa-rules.json
29
+ fi
25
30
  ```
26
31
  Capture for the subagent as `qa_rules`.
27
32
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cloverleaf-ui-review
3
- description: Run the UI Reviewer agent on a task in the `ui-review` state (full pipeline only). Dispatches a subagent to run Playwright + axe-core against a local preview of the feature branch; emits feedback envelope; advances ui-review → qa on pass or loops back to implementing on bounce. Usage — /cloverleaf-ui-review <TASK-ID>.
3
+ description: Run the UI Reviewer agent on a task in the `ui-review` state (full pipeline only). Computes diff-affected routes via CLI; if empty, skips axe and advances ui-review → qa. Otherwise dispatches a subagent with Playwright + axe-core scoped to those routes. Usage — /cloverleaf-ui-review <TASK-ID>.
4
4
  ---
5
5
 
6
6
  # Cloverleaf — ui-review
@@ -27,47 +27,67 @@ description: Run the UI Reviewer agent on a task in the `ui-review` state (full
27
27
 
28
28
  3. Confirm feature branch exists: `git rev-parse --verify cloverleaf/<TASK-ID>`. If missing, report and stop.
29
29
 
30
- 4. Allocate a free `preview_port` via Node:
30
+ 4. Compute affected routes:
31
+ ```bash
32
+ AFFECTED=$(~/.claude/plugins/cloverleaf/bin/cloverleaf-cli affected-routes <repo_root> <TASK-ID>)
33
+ ```
34
+
35
+ 5. **Empty-set early-exit.** If `AFFECTED` is `[]`, skip the subagent entirely:
36
+ ```bash
37
+ cloverleaf-cli advance-status <repo_root> <TASK-ID> qa agent '' full_pipeline
38
+ cd <repo_root>
39
+ git add .cloverleaf/
40
+ git commit -m "cloverleaf: <TASK-ID> ui-review skipped (no renderable routes) → qa"
41
+ ```
42
+ Report: "✓ UI Review skipped (no renderable routes affected). State → qa. Next: `/cloverleaf-qa <TASK-ID>`."
43
+ Stop here.
44
+
45
+ 6. Allocate a free preview port:
31
46
  ```bash
32
47
  PREVIEW_PORT=$(node -e "const net=require('net');const s=net.createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})")
33
48
  ```
34
49
 
35
- 5. Compute diff for the subagent context:
50
+ 7. Compute diff:
36
51
  ```bash
37
52
  git diff main..cloverleaf/<TASK-ID>
38
53
  ```
39
54
 
40
- 6. Dispatch the UI Reviewer subagent via the Task tool:
55
+ 8. **Browser cache env var.** Before the Task-tool dispatch, ensure `PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright` is exported so the subagent inherits it. This keeps Playwright from re-downloading ~300 MB of browser binaries inside the worktree.
56
+
57
+ 9. Dispatch the UI Reviewer subagent via the Task tool:
41
58
  - `subagent_type`: `general-purpose`
42
59
  - `model`: `sonnet`
43
- - Prompt: contents of `~/.claude/plugins/cloverleaf/prompts/ui-reviewer.md` with substitutions for `{{task}}`, `{{diff}}`, `{{branch}}`, `{{base_branch}}`, `{{repo_root}}`, `{{preview_port}}`.
60
+ - Prompt: contents of `~/.claude/plugins/cloverleaf/prompts/ui-reviewer.md` with substitutions:
61
+ - `{{task}}`, `{{diff}}`, `{{branch}}`, `{{base_branch}}`, `{{repo_root}}`, `{{preview_port}}`
62
+ - `{{affected_routes}}` → the value of `$AFFECTED` (verbatim — may be `"all"`, a JSON array, or `[]` but step 5 handled `[]` already)
44
63
 
45
- 7. Parse the subagent's response. Expect `{"verdict": "pass"|"bounce"|"escalate", "summary": "...", "findings": [...]}`.
64
+ 10. Parse the subagent's response. Expect `{"verdict": "pass"|"bounce"|"escalate", "summary": "...", "findings": [...]}`.
46
65
 
47
- 8. Branch on verdict:
66
+ 11. Branch on verdict:
48
67
 
49
- **Pass:**
50
- ```
51
- cloverleaf-cli advance-status <repo_root> <TASK-ID> qa agent --path=full_pipeline
52
- ```
53
- Commit: `git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> ui-review passed → qa"`.
54
- Report: "✓ UI Review passed. State → qa. Next: `/cloverleaf-qa <TASK-ID>`."
68
+ **Pass:**
69
+ ```
70
+ cloverleaf-cli advance-status <repo_root> <TASK-ID> qa agent '' full_pipeline
71
+ ```
72
+ Commit: `git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> ui-review passed → qa"`.
73
+ Report: "✓ UI Review passed. State → qa. Next: `/cloverleaf-qa <TASK-ID>`."
55
74
 
56
- **Bounce:**
57
- 1. Write feedback envelope to temp file: `echo '<envelope-json>' > /tmp/cloverleaf-fb-u.json`
58
- 2. `cloverleaf-cli write-feedback <repo_root> <TASK-ID> /tmp/cloverleaf-fb-u.json --prefix=u` — captures path like `.cloverleaf/feedback/<TASK-ID>-u<N>.json`.
59
- 3. `cloverleaf-cli advance-status <repo_root> <TASK-ID> implementing agent --path=full_pipeline`
60
- 4. Commit: `git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> ui-review bounced → implementing"`.
61
- 5. Report: "✗ UI Review bounced. Findings: <summarize by severity>. State → implementing. Next: `/cloverleaf-implement <TASK-ID>`."
75
+ **Bounce:**
76
+ 1. Write feedback: `echo '<envelope-json>' > /tmp/cloverleaf-fb-u.json`
77
+ 2. `cloverleaf-cli write-feedback <repo_root> <TASK-ID> /tmp/cloverleaf-fb-u.json --prefix=u`
78
+ 3. `cloverleaf-cli advance-status <repo_root> <TASK-ID> implementing agent '' full_pipeline`
79
+ 4. Commit: `git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> ui-review bounced → implementing"`.
80
+ 5. Report: "✗ UI Review bounced. Findings: <summary by severity>. State → implementing. Next: `/cloverleaf-implement <TASK-ID>`."
62
81
 
63
- **Escalate:**
64
- 1. `cloverleaf-cli advance-status <repo_root> <TASK-ID> escalated agent`
65
- 2. Commit: `git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> ui-review escalated"`.
66
- 3. Report: "✗ UI Review escalated (infrastructure issue). Review and retry manually."
82
+ **Escalate:**
83
+ 1. `cloverleaf-cli advance-status <repo_root> <TASK-ID> escalated agent`
84
+ 2. Commit: `git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> ui-review escalated"`.
85
+ 3. Report: "✗ UI Review escalated (infrastructure issue). Review and retry manually."
67
86
 
68
87
  ## Rules
69
88
 
70
89
  - Never push.
71
90
  - Do not modify source code — UI Reviewer is read-only.
72
91
  - Always teardown preview server + worktree on error.
92
+ - Empty-set early-exit (step 5) skips the browser entirely — no Playwright invocation, no worktree.
73
93
  - On illegal state transition, report and stop without partial commits.