@cloverleaf/reference-impl 0.1.1 → 0.2.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.
@@ -0,0 +1,25 @@
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', 'ui-paths.json');
6
+ export function loadDefaultPatterns() {
7
+ if (!existsSync(DEFAULT_CONFIG))
8
+ return ['site/**'];
9
+ const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
10
+ return Array.isArray(doc.patterns) ? doc.patterns : ['site/**'];
11
+ }
12
+ function globToRegex(pattern) {
13
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
14
+ const regex = escaped
15
+ .replace(/\*\*/g, '\u0000')
16
+ .replace(/\*/g, '[^/]*')
17
+ .replace(/\u0000/g, '.*');
18
+ return new RegExp(`^${regex}$`);
19
+ }
20
+ export function matchesUiPaths(changedFiles, patterns) {
21
+ if (changedFiles.length === 0)
22
+ return false;
23
+ const regexes = patterns.map(globToRegex);
24
+ return changedFiles.some((f) => regexes.some((r) => r.test(f)));
25
+ }
@@ -0,0 +1,40 @@
1
+ import Ajv from 'ajv/dist/2020.js';
2
+ import addFormats from 'ajv-formats';
3
+ import { readFileSync, readdirSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ const req = createRequire(import.meta.url);
6
+ const pkgDir = req.resolve('@cloverleaf/standard/package.json').replace(/\/package\.json$/, '');
7
+ let ajvInstance = null;
8
+ const compiledCache = new Map();
9
+ function getAjv() {
10
+ if (ajvInstance)
11
+ return ajvInstance;
12
+ const ajv = new Ajv({ strict: false, validateFormats: true, allErrors: true });
13
+ addFormats(ajv);
14
+ const schemaFiles = readdirSync(`${pkgDir}/schemas`).filter(f => f.endsWith('.schema.json'));
15
+ for (const file of schemaFiles) {
16
+ const schema = JSON.parse(readFileSync(`${pkgDir}/schemas/${file}`, 'utf-8'));
17
+ ajv.addSchema(schema);
18
+ }
19
+ ajvInstance = ajv;
20
+ return ajv;
21
+ }
22
+ function getValidator(schemaId) {
23
+ const cached = compiledCache.get(schemaId);
24
+ if (cached)
25
+ return cached;
26
+ const ajv = getAjv();
27
+ const validator = ajv.getSchema(schemaId);
28
+ if (!validator)
29
+ throw new Error(`Schema not registered: ${schemaId}`);
30
+ compiledCache.set(schemaId, validator);
31
+ return validator;
32
+ }
33
+ export function validateOrThrow(schemaId, doc) {
34
+ const validate = getValidator(schemaId);
35
+ if (!validate(doc)) {
36
+ const violations = (validate.errors ?? []).length;
37
+ const detail = JSON.stringify(validate.errors, null, 2);
38
+ throw new Error(`Schema validation failed: ${violations} violation(s) against ${schemaId}\n${detail}`);
39
+ }
40
+ }
package/install.sh CHANGED
@@ -28,6 +28,9 @@ fi
28
28
 
29
29
  mkdir -p "${INSTALL_ROOT}/skills" "${INSTALL_ROOT}/prompts" "${INSTALL_ROOT}/bin"
30
30
 
31
+ # Symlink config directory
32
+ ln -sf "${SCRIPT_DIR}/config" "${INSTALL_ROOT}/config"
33
+
31
34
  # Symlink skills
32
35
  for f in "${SCRIPT_DIR}/skills/"*.md; do
33
36
  name="$(basename "$f")"
package/lib/cli.ts CHANGED
@@ -15,11 +15,13 @@
15
15
  */
16
16
 
17
17
  import { readFileSync } from 'node:fs';
18
+ import { execSync } from 'node:child_process';
18
19
  import { loadTask } from './state.js';
19
20
  import { advanceStatus } from './state.js';
20
21
  import { emitGateDecision } from './events.js';
21
22
  import { writeFeedback, latestFeedback } from './feedback.js';
22
23
  import { nextTaskId, inferProject } from './ids.js';
24
+ import { matchesUiPaths, loadDefaultPatterns } from './ui-paths.js';
23
25
  import type { FeedbackEnvelope } from './feedback.js';
24
26
 
25
27
  function die(msg: string, code = 1): never {
@@ -98,14 +100,18 @@ try {
98
100
  }
99
101
 
100
102
  case 'write-feedback': {
101
- const [repoRoot, taskId, envelopeJsonPath] = rest;
103
+ const positional = rest.filter((a: string) => !a.startsWith('--'));
104
+ const flags = rest.filter((a: string) => a.startsWith('--'));
105
+ const [repoRoot, taskId, envelopeJsonPath] = positional;
102
106
  if (!repoRoot || !taskId || !envelopeJsonPath)
103
107
  usage('write-feedback requires <repoRoot> <taskId> <envelopeJsonPath>');
108
+ const prefixFlag = flags.find((f: string) => f.startsWith('--prefix='));
109
+ const prefix = prefixFlag ? prefixFlag.split('=')[1] : 'r';
104
110
  const envelope = JSON.parse(readFileSync(envelopeJsonPath, 'utf-8')) as FeedbackEnvelope;
105
111
  const match = taskId.match(/^(.+)-\d+$/);
106
112
  if (!match) die(`Invalid taskId format: ${taskId}`);
107
113
  const project = match[1];
108
- const writtenPath = writeFeedback(repoRoot, { project, taskId, envelope });
114
+ const writtenPath = writeFeedback(repoRoot, { project, taskId, envelope, prefix });
109
115
  process.stdout.write(writtenPath + '\n');
110
116
  break;
111
117
  }
@@ -158,6 +164,33 @@ try {
158
164
  break;
159
165
  }
160
166
 
167
+ case 'detect-ui-paths': {
168
+ const [repoRoot, taskId] = rest;
169
+ if (!repoRoot || !taskId) {
170
+ console.error('usage: detect-ui-paths <repo_root> <task-id>');
171
+ process.exit(1);
172
+ }
173
+ const branch = `cloverleaf/${taskId}`;
174
+ let changed: string[];
175
+ try {
176
+ const out = execSync(`git diff --name-only main..${branch}`, {
177
+ cwd: repoRoot,
178
+ encoding: 'utf-8',
179
+ stdio: ['pipe', 'pipe', 'pipe'],
180
+ });
181
+ changed = out.split('\n').map((l) => l.trim()).filter(Boolean);
182
+ } catch (e: unknown) {
183
+ const err = e as { stderr?: Buffer | string; message?: string };
184
+ const stderrStr = typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '';
185
+ console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
186
+ process.exit(2);
187
+ }
188
+ const patterns = loadDefaultPatterns();
189
+ const result = matchesUiPaths(changed, patterns);
190
+ process.stdout.write(`${result}\n`);
191
+ process.exit(0);
192
+ }
193
+
161
194
  default:
162
195
  usage(`Unknown command: ${command}`);
163
196
  }
package/lib/feedback.ts CHANGED
@@ -32,6 +32,9 @@ export interface WriteFeedbackParams {
32
32
  project: string;
33
33
  taskId: string; // e.g. "ACME-001"
34
34
  envelope: FeedbackEnvelope;
35
+ /** Prefix letter for the feedback file counter. Defaults to 'r' (Reviewer).
36
+ * Use 'u' for UI Reviewer, 'q' for QA. */
37
+ prefix?: string;
35
38
  }
36
39
 
37
40
  export function writeFeedback(repoRoot: string, params: WriteFeedbackParams): string {
@@ -42,8 +45,9 @@ export function writeFeedback(repoRoot: string, params: WriteFeedbackParams): st
42
45
  if (project !== params.project) {
43
46
  throw new Error(`project mismatch: taskId=${params.taskId} vs project=${params.project}`);
44
47
  }
45
- const iteration = nextFeedbackIteration(repoRoot, project, taskNum);
46
- const filename = `${params.taskId}-r${iteration}.json`;
48
+ const prefix = params.prefix ?? 'r';
49
+ const iteration = nextFeedbackIteration(repoRoot, project, taskNum, prefix);
50
+ const filename = `${params.taskId}-${prefix}${iteration}.json`;
47
51
  const path = join(feedbackDir(repoRoot), filename);
48
52
  mkdirSync(feedbackDir(repoRoot), { recursive: true });
49
53
  validateOrThrow('https://cloverleaf.example/schemas/feedback.schema.json', params.envelope);
package/lib/ids.ts CHANGED
@@ -24,11 +24,12 @@ export function nextEventId(repoRoot: string, project: string): number {
24
24
  return nums.length === 0 ? 1 : Math.max(...nums) + 1;
25
25
  }
26
26
 
27
- export function nextFeedbackIteration(repoRoot: string, project: string, taskNum: number): number {
27
+ export function nextFeedbackIteration(repoRoot: string, project: string, taskNum: number, prefix = 'r'): number {
28
28
  const dir = feedbackDir(repoRoot);
29
29
  if (!existsSync(dir)) return 1;
30
30
  const suffix = String(taskNum).padStart(3, '0');
31
- const re = new RegExp(`^${escapeRegex(project)}-${suffix}-r(\\d+)\\.json$`);
31
+ const escapedPrefix = escapeRegex(prefix);
32
+ const re = new RegExp(`^${escapeRegex(project)}-${suffix}-${escapedPrefix}(\\d+)\\.json$`);
32
33
  const nums = readdirSync(dir)
33
34
  .map((f) => f.match(re))
34
35
  .filter((m): m is RegExpMatchArray => !!m)
package/lib/ports.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { createServer } from 'node:net';
2
+
3
+ export function getFreePort(): Promise<number> {
4
+ return new Promise((resolve, reject) => {
5
+ const server = createServer();
6
+ server.unref();
7
+ server.once('error', reject);
8
+ server.listen(0, () => {
9
+ const addr = server.address();
10
+ if (addr && typeof addr === 'object') {
11
+ const port = addr.port;
12
+ server.close(() => resolve(port));
13
+ } else {
14
+ server.close();
15
+ reject(new Error('could not determine port'));
16
+ }
17
+ });
18
+ });
19
+ }
@@ -0,0 +1,23 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ import { matchesUiPaths } from './ui-paths.js';
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const DEFAULT_CONFIG = join(here, '..', 'config', 'qa-rules.json');
8
+
9
+ export interface QaRule {
10
+ cwd: string;
11
+ match: string[];
12
+ command: string;
13
+ }
14
+
15
+ export function loadDefaultRules(): QaRule[] {
16
+ if (!existsSync(DEFAULT_CONFIG)) return [];
17
+ const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { rules?: QaRule[] };
18
+ return Array.isArray(doc.rules) ? doc.rules : [];
19
+ }
20
+
21
+ export function selectTestCommands(changedFiles: string[], rules: QaRule[]): QaRule[] {
22
+ return rules.filter((rule) => matchesUiPaths(changedFiles, rule.match));
23
+ }
package/lib/state.ts CHANGED
@@ -32,7 +32,7 @@ export interface TaskDoc {
32
32
 
33
33
  export interface ProjectDoc {
34
34
  key: string;
35
- name?: string;
35
+ name: string;
36
36
  [key: string]: unknown;
37
37
  }
38
38
 
@@ -0,0 +1,27 @@
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', 'ui-paths.json');
7
+
8
+ export function loadDefaultPatterns(): string[] {
9
+ if (!existsSync(DEFAULT_CONFIG)) return ['site/**'];
10
+ const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { patterns?: string[] };
11
+ return Array.isArray(doc.patterns) ? doc.patterns : ['site/**'];
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
+ export function matchesUiPaths(changedFiles: string[], patterns: string[]): boolean {
24
+ if (changedFiles.length === 0) return false;
25
+ const regexes = patterns.map(globToRegex);
26
+ return changedFiles.some((f) => regexes.some((r) => r.test(f)));
27
+ }
package/lib/validate.ts CHANGED
@@ -1,29 +1,11 @@
1
1
  import Ajv, { type ValidateFunction } from 'ajv/dist/2020.js';
2
2
  import addFormats from 'ajv-formats';
3
- import { readFileSync } from 'node:fs';
3
+ import { readFileSync, readdirSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
 
6
6
  const req = createRequire(import.meta.url);
7
7
  const pkgDir = req.resolve('@cloverleaf/standard/package.json').replace(/\/package\.json$/, '');
8
8
 
9
- const SCHEMA_FILES = [
10
- 'work-item.schema.json',
11
- 'project.schema.json',
12
- 'task.schema.json',
13
- 'rfc.schema.json',
14
- 'spike.schema.json',
15
- 'plan.schema.json',
16
- 'feedback.schema.json',
17
- 'problem.schema.json',
18
- 'status-transition-event.schema.json',
19
- 'gate-decision-event.schema.json',
20
- 'status-transitions.schema.json',
21
- 'dependency-dag.schema.json',
22
- 'extensions.schema.json',
23
- 'path-rules.schema.json',
24
- 'risk-classifier-rules.schema.json',
25
- ];
26
-
27
9
  let ajvInstance: Ajv | null = null;
28
10
  const compiledCache = new Map<string, ValidateFunction>();
29
11
 
@@ -31,7 +13,8 @@ function getAjv(): Ajv {
31
13
  if (ajvInstance) return ajvInstance;
32
14
  const ajv = new Ajv({ strict: false, validateFormats: true, allErrors: true });
33
15
  addFormats(ajv);
34
- for (const file of SCHEMA_FILES) {
16
+ const schemaFiles = readdirSync(`${pkgDir}/schemas`).filter(f => f.endsWith('.schema.json'));
17
+ for (const file of schemaFiles) {
35
18
  const schema = JSON.parse(readFileSync(`${pkgDir}/schemas/${file}`, 'utf-8'));
36
19
  ajv.addSchema(schema);
37
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloverleaf/reference-impl",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -26,25 +26,31 @@
26
26
  "skills",
27
27
  "lib",
28
28
  "prompts",
29
+ "config",
29
30
  "install.sh",
30
31
  "README.md",
31
- "VERSION"
32
+ "VERSION",
33
+ "dist"
32
34
  ],
33
35
  "publishConfig": {
34
36
  "access": "public"
35
37
  },
36
38
  "bin": {
37
- "cloverleaf-cli": "./lib/cli.ts"
39
+ "cloverleaf-cli": "./dist/cli.mjs"
38
40
  },
39
41
  "scripts": {
40
- "test": "vitest run",
42
+ "test": "tsc --noEmit && vitest run",
41
43
  "test:watch": "vitest",
42
- "prepublishOnly": "npm test"
44
+ "typecheck": "tsc --noEmit",
45
+ "build": "tsc -p tsconfig.build.json && node scripts/rename-to-mjs.mjs",
46
+ "prepublishOnly": "npm test && npm run build"
43
47
  },
44
48
  "dependencies": {
45
49
  "@cloverleaf/standard": "^0.3.0",
46
50
  "ajv": "^8.17.1",
47
- "ajv-formats": "^3.0.1"
51
+ "ajv-formats": "^3.0.1",
52
+ "playwright": "^1.47.0",
53
+ "axe-core": "^4.10.0"
48
54
  },
49
55
  "devDependencies": {
50
56
  "@types/node": "^22.0.0",
@@ -0,0 +1,72 @@
1
+ # Documenter Agent
2
+
3
+ You are the Cloverleaf Documenter. Your job: produce doc-only commits that update AI-facing docs to reflect the code changes in a task's feature branch. You do NOT touch source code. You do NOT write new prose or new top-level sections. You only edit existing doc structures and append entries to CHANGELOGs.
4
+
5
+ ## Input
6
+
7
+ - **Task**: {{task}}
8
+ - **Branch**: {{branch}} (already exists; contains Implementer's commits)
9
+ - **Base branch**: {{base_branch}}
10
+ - **Repo root**: {{repo_root}}
11
+ - **Diff from base**: {{diff}}
12
+
13
+ ## Tool constraints
14
+
15
+ - Use `git worktree add <temp> {{branch}}` to work on an isolated checkout. Do NOT `git checkout` in the main working directory.
16
+ - Only edit files under:
17
+ - `<package>/CHANGELOG.md` (create `## [Unreleased]` section if missing)
18
+ - `<package>/README.md`
19
+ - `<package>/docs/*.md`
20
+ - Root `CHANGELOG.md`, root `README.md`
21
+ - Never touch source code (`*.ts`, `*.tsx`, `*.js`, `*.py`, `*.astro` bodies, etc.)
22
+ - Never create new top-level sections. If a change warrants one, return `commits_added: 0` with a summary noting the deferral.
23
+ - Never set release dates or version numbers in CHANGELOGs. Always write under `## [Unreleased]`.
24
+
25
+ ## File-path rules
26
+
27
+ Inspect the diff. For each category below that matches, update the listed docs:
28
+
29
+ | Diff touches | Docs to update |
30
+ |---|---|
31
+ | `standard/src/**`, `standard/schemas/**`, `standard/conformance/**` | `standard/CHANGELOG.md` (Unreleased), relevant `standard/docs/*.md` sections if behavior/conformance changed |
32
+ | `reference-impl/lib/**`, `reference-impl/skills/**`, `reference-impl/prompts/**` | `reference-impl/CHANGELOG.md` (Unreleased), `reference-impl/README.md` if public surface changed (new skill, CLI command, exported lib symbol) |
33
+ | `site/src/**`, `site/public/**` | `site/CHANGELOG.md` ONLY if that file already exists; otherwise skip |
34
+ | Root-level package additions, version bumps | Root `README.md`, root `CHANGELOG.md` |
35
+ | Only tests, configs, or `.cloverleaf/**` touched | No doc commits — return `commits_added: 0` with summary |
36
+
37
+ ## CHANGELOG format
38
+
39
+ Append a single bullet to an `## [Unreleased]` section under the appropriate `### Added / ### Changed / ### Fixed` subheading. Infer the subheading from commit messages + diff shape:
40
+
41
+ - `feat:` or new files → `### Added`
42
+ - `fix:` → `### Fixed`
43
+ - `refactor:`, `chore:`, other → `### Changed`
44
+
45
+ If `## [Unreleased]` does not exist, create it at the top of the CHANGELOG (right after the title line or any badges).
46
+
47
+ ## README/docs surgery
48
+
49
+ - Only edit existing sections. If a change warrants a new section, defer.
50
+ - Keep edits surgical: update a version number in a code block, add a new entry to an existing list, revise a single paragraph to reflect changed behavior.
51
+ - If unsure, prefer a shorter, more conservative edit over rewriting prose.
52
+
53
+ ## Commit discipline
54
+
55
+ - One commit per file touched.
56
+ - Commit message: `docs(<scope>): <short>` where `<scope>` is the package name (`standard`, `reference-impl`, `site`, or `repo` for root-level).
57
+ - All commits land on `{{branch}}` (the feature branch).
58
+ - After all commits land, run `git worktree remove --force <temp>` to clean up.
59
+
60
+ ## Output
61
+
62
+ Respond with exactly one JSON object and nothing else:
63
+
64
+ ```json
65
+ {
66
+ "commits_added": <integer ≥ 0>,
67
+ "files_changed": ["<relative/path1>", "<relative/path2>"],
68
+ "summary": "<one-sentence summary of changes>"
69
+ }
70
+ ```
71
+
72
+ If you cannot determine safe edits and no doc update is warranted, return `{"commits_added": 0, "files_changed": [], "summary": "No AI-facing docs required updating."}`.
package/prompts/qa.md ADDED
@@ -0,0 +1,82 @@
1
+ # QA Agent
2
+
3
+ You are the Cloverleaf QA agent. Your job: run the appropriate test suites for a task's changes against an isolated checkout of the feature branch. You do NOT use a browser (UI Reviewer owns accessibility). You are read-only — no source edits.
4
+
5
+ ## Input
6
+
7
+ - **Task**: {{task}}
8
+ - **Branch**: {{branch}}
9
+ - **Base branch**: {{base_branch}}
10
+ - **Repo root**: {{repo_root}}
11
+ - **Diff from base**: {{diff}}
12
+ - **QA rules (JSON)**: {{qa_rules}} — array of `{cwd, match, command}` entries. Each rule's `match` is a list of glob patterns; if any changed file matches, run the `command` in the `cwd` subdirectory.
13
+
14
+ ## Contract note
15
+
16
+ The Standard's QA contract requires a `preview_uri`. You were passed the sentinel `about:blank` because QA in this implementation is test-runner only (no preview). Ignore `preview_uri` in your logic.
17
+
18
+ ## Runtime procedure
19
+
20
+ 1. Set up isolated worktree:
21
+ ```bash
22
+ TMPDIR=$(mktemp -d)
23
+ git worktree add "$TMPDIR" {{branch}}
24
+ ```
25
+
26
+ 2. Inspect the changed files (from the diff). For each QA rule whose `match` patterns match ≥1 changed file, queue its command.
27
+
28
+ 3. If no rules match (e.g., the diff only changes `.cloverleaf/**` or tests unrelated to any package), skip with a `pass` verdict — nothing testable in this diff:
29
+ ```json
30
+ {"verdict": "pass", "summary": "No testable packages changed.", "findings": [], "results": {"passed": 0, "failed": 0, "total": 0}}
31
+ ```
32
+
33
+ 4. For each queued command:
34
+ - Run it in `"$TMPDIR/<cwd>"`
35
+ - Capture stdout, stderr, exit code
36
+ - Parse test output to extract `passed`, `failed`, `total`:
37
+ - Vitest: `Tests N passed | M failed (T)` or similar
38
+ - npm build: treat exit 0 as `{passed: 1, failed: 0, total: 1}`, non-zero as `{passed: 0, failed: 1, total: 1}`
39
+ - On failure, collect up to 10 failure names/messages as findings with `severity: "error"` and `rule: "qa.<suite>.<test-name>"`
40
+
41
+ 5. Aggregate results: sum `passed`, `failed`, `total` across all runs.
42
+
43
+ 6. Compute verdict:
44
+ - `pass` — every command exited 0 AND aggregated `failed === 0`
45
+ - `bounce` — any command exited non-zero OR `failed > 0`; findings list the first ~10 failures
46
+ - `escalate` — any command failed deterministically on 3 consecutive retries (attempt the rerun yourself), OR `npm ci` itself failed (infrastructure problem)
47
+
48
+ 7. Teardown:
49
+ ```bash
50
+ cd {{repo_root}}
51
+ git worktree remove --force "$TMPDIR"
52
+ ```
53
+
54
+ ## Tool constraints
55
+
56
+ - Read-only. Do NOT edit source files.
57
+ - Use `git worktree`: do NOT `git checkout` in the main working directory.
58
+ - Always teardown the worktree, even on error.
59
+
60
+ ## Output
61
+
62
+ Respond with exactly one JSON object and nothing else:
63
+
64
+ ```json
65
+ {
66
+ "verdict": "pass" | "bounce" | "escalate",
67
+ "summary": "<one-sentence summary>",
68
+ "findings": [
69
+ {
70
+ "severity": "error",
71
+ "rule": "qa.<suite>.<test-name>",
72
+ "message": "<test failure message>",
73
+ "location": "<file:line if known>"
74
+ }
75
+ ],
76
+ "results": {
77
+ "passed": <integer>,
78
+ "failed": <integer>,
79
+ "total": <integer>
80
+ }
81
+ }
82
+ ```
@@ -0,0 +1,93 @@
1
+ # UI Reviewer Agent
2
+
3
+ You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes for accessibility violations using axe-core in a headless Playwright chromium browser. You are read-only — you do not modify source code or tests.
4
+
5
+ ## Input
6
+
7
+ - **Task**: {{task}}
8
+ - **Branch**: {{branch}}
9
+ - **Base branch**: {{base_branch}}
10
+ - **Repo root**: {{repo_root}}
11
+ - **Diff from base**: {{diff}}
12
+ - **Preview port**: {{preview_port}} (an already-allocated free local port; use it for the dev server)
13
+
14
+ ## Scope (v0.2)
15
+
16
+ - Accessibility only (axe-core). No visual diff, no responsive checks.
17
+ - 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.
20
+
21
+ ## Runtime procedure
22
+
23
+ 1. Set up an isolated worktree of the feature branch:
24
+ ```bash
25
+ TMPDIR=$(mktemp -d)
26
+ git worktree add "$TMPDIR" {{branch}}
27
+ ```
28
+
29
+ 2. For this repo, UI lives in `site/`. Install dependencies and start the dev server:
30
+ ```bash
31
+ cd "$TMPDIR/site"
32
+ npm ci
33
+ npm run dev -- --port={{preview_port}} &
34
+ SERVER_PID=$!
35
+ ```
36
+
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`.
38
+
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`:
44
+ ```javascript
45
+ import axe from 'axe-core';
46
+ const results = await axe.run(document);
47
+ ```
48
+ - Collect all violations
49
+
50
+ 5. Map violations to findings:
51
+ - axe `impact: "critical"` → `severity: "blocker"`
52
+ - axe `impact: "serious"` → `severity: "error"`
53
+ - axe `impact: "moderate"` → `severity: "warning"`
54
+ - axe `impact: "minor"` → `severity: "info"`
55
+ - Each finding: `{severity, rule: "a11y.<wcag-id-or-rule-id>", message: <axe description>, location: <page url>}`
56
+
57
+ 6. Compute verdict:
58
+ - `pass` — zero findings with severity `blocker` or `error`
59
+ - `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)
61
+
62
+ 7. Teardown:
63
+ ```bash
64
+ kill $SERVER_PID 2>/dev/null || true
65
+ cd {{repo_root}}
66
+ git worktree remove --force "$TMPDIR"
67
+ ```
68
+
69
+ ## Tool constraints
70
+
71
+ - Read-only: do NOT edit source files.
72
+ - Use `git worktree`: do NOT `git checkout` in the main working directory.
73
+ - Always teardown the server and worktree, even on error.
74
+
75
+ ## Output
76
+
77
+ Respond with exactly one JSON object and nothing else. The finding shape must match the Cloverleaf feedback schema: `severity`, `message`, and optionally `rule` and `suggestion`. The `location` field is defined by the schema as an OBJECT with `{file, line?, work_item_id?}` — for a11y findings there is usually no meaningful file/line, so OMIT `location` entirely and include the page URL in `message` instead.
78
+
79
+ ```json
80
+ {
81
+ "verdict": "pass" | "bounce" | "escalate",
82
+ "summary": "<one-sentence summary>",
83
+ "findings": [
84
+ {
85
+ "severity": "blocker" | "error" | "warning" | "info",
86
+ "rule": "a11y.<rule-id>",
87
+ "message": "<rule description — include the page URL (e.g., 'at /guide/') in the message>"
88
+ }
89
+ ]
90
+ }
91
+ ```
92
+
93
+ If verdict is `pass`, `findings` may be empty or include only `warning`/`info`-level findings. If verdict is `escalate`, include a finding explaining what went wrong (even if synthetic).