@cloverleaf/reference-impl 0.2.0 → 0.3.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.
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 |
@@ -54,10 +54,18 @@ Two JSON config files in `config/` (overridable per consumer project):
54
54
 
55
55
  ### Known limitations
56
56
 
57
- - Playwright installs ~300MB into each `git worktree` (v0.3 will cache).
58
57
  - Concurrent `/cloverleaf-run` on the same repo may race on preview ports.
59
- - UI Reviewer visual diff + multi-viewport deferred to v0.3.
58
+ - UI Reviewer visual diff + multi-viewport deferred to v0.4.
60
59
  - QA does not produce HTML reports (no `report_uri`).
60
+ - Astro `base` path is parsed best-effort; if misdetected, UI Reviewer may return escalate.
61
+
62
+ ### Prerequisites for UI Reviewer
63
+
64
+ Run once per machine:
65
+
66
+ npx playwright install chromium
67
+
68
+ 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.
61
69
 
62
70
  ## Quick start — toy repo
63
71
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.3.0
@@ -0,0 +1,12 @@
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
+ }
@@ -0,0 +1,66 @@
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
+ };
38
+ }
39
+ export function computeAffectedRoutes(changedFiles, config) {
40
+ const routes = new Set();
41
+ let inScopeButUnmatched = false;
42
+ for (const file of changedFiles) {
43
+ if (matchesAny(file, config.globalPatterns)) {
44
+ return 'all';
45
+ }
46
+ let mapped = null;
47
+ for (const root of config.pageRoots) {
48
+ const r = routeForPage(file, root);
49
+ if (r) {
50
+ mapped = r;
51
+ break;
52
+ }
53
+ }
54
+ if (mapped) {
55
+ routes.add(mapped);
56
+ continue;
57
+ }
58
+ if (matchesAny(file, config.routeScope)) {
59
+ inScopeButUnmatched = true;
60
+ }
61
+ }
62
+ if (inScopeButUnmatched) {
63
+ return 'all';
64
+ }
65
+ return Array.from(routes).sort();
66
+ }
package/dist/cli.mjs CHANGED
@@ -21,6 +21,7 @@ import { emitGateDecision } from './events.mjs';
21
21
  import { writeFeedback, latestFeedback } from './feedback.mjs';
22
22
  import { nextTaskId, inferProject } from './ids.mjs';
23
23
  import { matchesUiPaths, loadDefaultPatterns } from './ui-paths.mjs';
24
+ import { computeAffectedRoutes, loadDefaultConfig } from './affected-routes.mjs';
24
25
  function die(msg, code = 1) {
25
26
  process.stderr.write(msg + '\n');
26
27
  process.exit(code);
@@ -181,6 +182,33 @@ try {
181
182
  process.stdout.write(`${result}\n`);
182
183
  process.exit(0);
183
184
  }
185
+ case 'affected-routes': {
186
+ const [repoRoot, taskId] = rest;
187
+ if (!repoRoot || !taskId) {
188
+ console.error('usage: affected-routes <repo_root> <task-id>');
189
+ process.exit(1);
190
+ }
191
+ const branch = `cloverleaf/${taskId}`;
192
+ let changed;
193
+ try {
194
+ const out = execSync(`git diff --name-only main..${branch}`, {
195
+ cwd: repoRoot,
196
+ encoding: 'utf-8',
197
+ stdio: ['pipe', 'pipe', 'pipe'],
198
+ });
199
+ changed = out.split('\n').map((l) => l.trim()).filter(Boolean);
200
+ }
201
+ catch (e) {
202
+ const err = e;
203
+ const stderrStr = typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '';
204
+ console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
205
+ process.exit(2);
206
+ }
207
+ const config = loadDefaultConfig();
208
+ const result = computeAffectedRoutes(changed, config);
209
+ process.stdout.write(`${JSON.stringify(result)}\n`);
210
+ process.exit(0);
211
+ }
184
212
  default:
185
213
  usage(`Unknown command: ${command}`);
186
214
  }
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,80 @@
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
+ }
13
+
14
+ function globToRegex(pattern: string): RegExp {
15
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
16
+ const regex = escaped
17
+ .replace(/\*\*/g, '\u0000')
18
+ .replace(/\*/g, '[^/]*')
19
+ .replace(/\u0000/g, '.*');
20
+ return new RegExp(`^${regex}$`);
21
+ }
22
+
23
+ function matchesAny(file: string, patterns: string[]): boolean {
24
+ return patterns.some((p) => globToRegex(p).test(file));
25
+ }
26
+
27
+ function routeForPage(file: string, pageRoot: string): string | null {
28
+ if (!file.startsWith(pageRoot)) return null;
29
+ const rel = file.slice(pageRoot.length);
30
+ const withoutExt = rel.replace(/\.(astro|mdx)$/, '');
31
+ if (!withoutExt || withoutExt === rel) return null;
32
+ if (withoutExt === 'index') return '/';
33
+ return `/${withoutExt}/`;
34
+ }
35
+
36
+ export function loadDefaultConfig(): AffectedRoutesConfig {
37
+ if (!existsSync(DEFAULT_CONFIG)) {
38
+ throw new Error(`affected-routes config not found at ${DEFAULT_CONFIG}`);
39
+ }
40
+ const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as Partial<AffectedRoutesConfig>;
41
+ return {
42
+ pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
43
+ globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
44
+ routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
45
+ };
46
+ }
47
+
48
+ export function computeAffectedRoutes(
49
+ changedFiles: string[],
50
+ config: AffectedRoutesConfig
51
+ ): string[] | 'all' {
52
+ const routes = new Set<string>();
53
+ let inScopeButUnmatched = false;
54
+
55
+ for (const file of changedFiles) {
56
+ if (matchesAny(file, config.globalPatterns)) {
57
+ return 'all';
58
+ }
59
+ let mapped: string | null = null;
60
+ for (const root of config.pageRoots) {
61
+ const r = routeForPage(file, root);
62
+ if (r) {
63
+ mapped = r;
64
+ break;
65
+ }
66
+ }
67
+ if (mapped) {
68
+ routes.add(mapped);
69
+ continue;
70
+ }
71
+ if (matchesAny(file, config.routeScope)) {
72
+ inScopeButUnmatched = true;
73
+ }
74
+ }
75
+
76
+ if (inScopeButUnmatched) {
77
+ return 'all';
78
+ }
79
+ return Array.from(routes).sort();
80
+ }
package/lib/cli.ts CHANGED
@@ -22,6 +22,7 @@ import { emitGateDecision } from './events.js';
22
22
  import { writeFeedback, latestFeedback } from './feedback.js';
23
23
  import { nextTaskId, inferProject } from './ids.js';
24
24
  import { matchesUiPaths, loadDefaultPatterns } from './ui-paths.js';
25
+ import { computeAffectedRoutes, loadDefaultConfig } from './affected-routes.js';
25
26
  import type { FeedbackEnvelope } from './feedback.js';
26
27
 
27
28
  function die(msg: string, code = 1): never {
@@ -191,6 +192,33 @@ try {
191
192
  process.exit(0);
192
193
  }
193
194
 
195
+ case 'affected-routes': {
196
+ const [repoRoot, taskId] = rest;
197
+ if (!repoRoot || !taskId) {
198
+ console.error('usage: affected-routes <repo_root> <task-id>');
199
+ process.exit(1);
200
+ }
201
+ const branch = `cloverleaf/${taskId}`;
202
+ let changed: string[];
203
+ try {
204
+ const out = execSync(`git diff --name-only main..${branch}`, {
205
+ cwd: repoRoot,
206
+ encoding: 'utf-8',
207
+ stdio: ['pipe', 'pipe', 'pipe'],
208
+ });
209
+ changed = out.split('\n').map((l) => l.trim()).filter(Boolean);
210
+ } catch (e: unknown) {
211
+ const err = e as { stderr?: Buffer | string; message?: string };
212
+ const stderrStr = typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '';
213
+ console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
214
+ process.exit(2);
215
+ }
216
+ const config = loadDefaultConfig();
217
+ const result = computeAffectedRoutes(changed, config);
218
+ process.stdout.write(`${JSON.stringify(result)}\n`);
219
+ process.exit(0);
220
+ }
221
+
194
222
  default:
195
223
  usage(`Unknown command: ${command}`);
196
224
  }
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.0",
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,32 @@ 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: read `astro.config.*` in the worktree for a `base: '<path>'` entry. Default to empty string if not found or unparseable.
38
50
 
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`:
51
+ 6. For each route in `{{affected_routes}}` (or the crawl set, if `"all"`):
52
+ - Construct URL `http://localhost:{{preview_port}}<base><route>`.
53
+ - Navigate. If 404, retry at `http://localhost:{{preview_port}}<route>` (without base).
54
+ - Inject and run axe-core:
44
55
  ```javascript
45
56
  import axe from 'axe-core';
46
57
  const results = await axe.run(document);
47
58
  ```
48
- - Collect all violations
59
+ - Collect violations.
49
60
 
50
- 5. Map violations to findings:
61
+ 7. Map violations to findings:
51
62
  - axe `impact: "critical"` → `severity: "blocker"`
52
63
  - axe `impact: "serious"` → `severity: "error"`
53
64
  - axe `impact: "moderate"` → `severity: "warning"`
54
65
  - axe `impact: "minor"` → `severity: "info"`
55
- - Each finding: `{severity, rule: "a11y.<wcag-id-or-rule-id>", message: <axe description>, location: <page url>}`
56
66
 
57
- 6. Compute verdict:
67
+ 8. Compute verdict:
58
68
  - `pass` — zero findings with severity `blocker` or `error`
59
69
  - `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)
70
+ - `escalate` — preview server failed to start, OR axe threw ≥3 consecutive times, OR Playwright chromium missing.
61
71
 
62
- 7. Teardown:
72
+ 9. Teardown:
63
73
  ```bash
64
74
  kill $SERVER_PID 2>/dev/null || true
65
75
  cd {{repo_root}}
@@ -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.