@cloverleaf/reference-impl 0.12.0 → 0.13.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/lib/events.ts CHANGED
@@ -13,7 +13,6 @@ export interface StatusTransitionParams {
13
13
  to: string;
14
14
  actor: 'agent' | 'human' | 'system';
15
15
  gate?: string;
16
- path?: 'fast_lane' | 'full_pipeline';
17
16
  }
18
17
 
19
18
  export interface GateDecisionParams {
@@ -31,11 +30,8 @@ function actorObject(kind: 'agent' | 'human' | 'system'): { kind: string; id: st
31
30
  return { kind, id };
32
31
  }
33
32
 
34
- export function formatReason(opts: { gate?: string; path?: string }): string | undefined {
35
- const parts: string[] = [];
36
- if (opts.gate) parts.push(`gate=${opts.gate}`);
37
- if (opts.path) parts.push(`path=${opts.path}`);
38
- return parts.length > 0 ? parts.join('; ') : undefined;
33
+ export function formatReason(opts: { gate?: string }): string | undefined {
34
+ return opts.gate ? `gate=${opts.gate}` : undefined;
39
35
  }
40
36
 
41
37
  /**
@@ -56,8 +52,8 @@ export function emitStatusTransition(repoRoot: string, params: StatusTransitionP
56
52
  const filename = `${workItemId}-${seqStr}-status.json`;
57
53
  const filePath = join(eventsDir(repoRoot), filename);
58
54
 
59
- // Build reason from gate and/or path if provided (schema only allows reason, not gate/path at top level).
60
- const reason = formatReason({ gate: params.gate, path: params.path });
55
+ // Build reason from the gate if provided (the schema allows only `reason` at top level).
56
+ const reason = formatReason({ gate: params.gate });
61
57
 
62
58
  const doc: Record<string, unknown> = {
63
59
  event_id: randomUUID(),
package/lib/ids.ts CHANGED
@@ -22,6 +22,13 @@ export function nextEventId(repoRoot: string, workItemId: string): number {
22
22
  // simultaneously. A global per-project counter (the pre-v0.6 scheme) produced
23
23
  // filename collisions when the walker merged sibling feature branches. Per-work-item
24
24
  // scoping means each task's counter is independent; merges union cleanly.
25
+ //
26
+ // The `(\d+)` segment is load-bearing, not cosmetic: `.cloverleaf/events/` in
27
+ // long-lived repos also contains pre-v0.6 files named `<PROJECT>-<NNN>-status.json`
28
+ // (a global counter), whose names collide with the task-id namespace — e.g.
29
+ // `CLV-109-status.json` is the 109th project event, not an event for task CLV-109.
30
+ // Loosening this regex to a bare prefix match would fold unrelated tasks' history
31
+ // into a task's counter, including transitions through states retired in 0.8.0.
25
32
  const re = new RegExp(`^${escapeRegex(workItemId)}-(\\d+)-(status|gate)\\.json$`);
26
33
  const nums = readdirSync(dir)
27
34
  .map((f) => f.match(re))
package/lib/qa-rules.ts CHANGED
@@ -18,19 +18,40 @@ function loadDefaultRules(): QaRule[] {
18
18
  return Array.isArray(doc.rules) ? doc.rules : [];
19
19
  }
20
20
 
21
- export function loadQaRulesConfig(repoRoot: string): QaRule[] {
21
+ export interface QaRulesDocument {
22
+ rules: QaRule[];
23
+ }
24
+
25
+ /**
26
+ * The qa-rules document in the shape the prompts consume it: the `{ rules: [...] }`
27
+ * object, not a bare array. `reviewer.md` ({{test_rules}}), `implementer.md`
28
+ * ({{test_rules}}) and `qa.md` ({{qa_rules}}) all document the token as an object —
29
+ * 0.10.1 shipped a fix precisely because `qa.md` had described it as an array, and an
30
+ * agent iterating a non-existent top-level array is the bug that fix closed. Callers
31
+ * substituting one of those tokens must stringify THIS, never `loadQaRulesConfig()`.
32
+ *
33
+ * Precedence matches what the standalone skills `cat`: the consumer's
34
+ * `.cloverleaf/config/qa-rules.json` when it exists and parses to a `rules` array,
35
+ * otherwise the packaged default.
36
+ */
37
+ export function loadQaRulesDocument(repoRoot: string): QaRulesDocument {
22
38
  const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'qa-rules.json');
23
39
  if (existsSync(consumerPath)) {
24
40
  try {
25
41
  const doc = JSON.parse(readFileSync(consumerPath, 'utf-8')) as { rules?: QaRule[] };
26
42
  if (Array.isArray(doc.rules)) {
27
- return doc.rules;
43
+ return { rules: doc.rules };
28
44
  }
29
45
  } catch {
30
46
  // fall through
31
47
  }
32
48
  }
33
- return loadDefaultRules();
49
+ return { rules: loadDefaultRules() };
50
+ }
51
+
52
+ /** The rules array alone, for callers that select/execute commands rather than prompt with them. */
53
+ export function loadQaRulesConfig(repoRoot: string): QaRule[] {
54
+ return loadQaRulesDocument(repoRoot).rules;
34
55
  }
35
56
 
36
57
  export function selectTestCommands(changedFiles: string[], rules: QaRule[]): QaRule[] {
package/lib/task.ts CHANGED
@@ -53,16 +53,16 @@ export function advanceStatus(
53
53
  taskId: string,
54
54
  toStatus: string,
55
55
  actor: 'agent' | 'human',
56
- options: { gate?: string; path?: 'fast_lane' | 'full_pipeline' } = {}
56
+ options: { gate?: string } = {}
57
57
  ): TaskDoc {
58
58
  let task = loadTask(repoRoot, taskId);
59
59
  const from = task.status;
60
60
  const sm = loadStateMachine('task');
61
61
 
62
- const targetTransition = sm.transitions.find((t) => t.from === from && t.to === toStatus);
63
-
64
- // Security-gate writeback: classify the task's security and upgrade if needed.
65
- if (targetTransition?.security_gate) {
62
+ // Security classification at council entry: a declared-low task whose diff touches a
63
+ // sensitive path is upgraded to security_class:high so the delivery council runs its
64
+ // blocking security member. (v0.8.0: replaces the retired security_gate FSM annotation.)
65
+ if (from === 'documenting' && toStatus === 'council') {
66
66
  let classification;
67
67
  try {
68
68
  classification = classifyTaskSecurity(repoRoot, taskId);
@@ -96,17 +96,12 @@ export function advanceStatus(
96
96
  }
97
97
  }
98
98
 
99
- const riskClass: 'low' | 'high' =
100
- options.path === 'fast_lane' ? 'low'
101
- : options.path === 'full_pipeline' ? 'high'
102
- : (task.risk_class ?? 'low');
103
-
104
99
  const workItemForValidator: SMTask = {
105
100
  type: 'task',
106
101
  id: task.id,
107
102
  project: task.project,
108
103
  status: task.status,
109
- risk_class: riskClass,
104
+ risk_class: task.risk_class ?? 'low',
110
105
  security_class: task.security_class,
111
106
  security_review_verdict: task.security_review_verdict,
112
107
  context: { rfc: { project: task.project, id: task.id } },
@@ -114,12 +109,7 @@ export function advanceStatus(
114
109
  acceptance_criteria: task.acceptance_criteria,
115
110
  };
116
111
 
117
- const resetsVerdict = targetTransition?.resets_security_verdict === true;
118
- const proposed: TaskDoc = {
119
- ...task,
120
- status: toStatus,
121
- ...(resetsVerdict ? { security_review_verdict: null } : {}),
122
- };
112
+ const proposed: TaskDoc = { ...task, status: toStatus };
123
113
 
124
114
  advanceWorkItemStatus({
125
115
  repoRoot,
@@ -134,23 +124,7 @@ export function advanceStatus(
134
124
  save: (p) => saveTask(repoRoot, p as TaskDoc),
135
125
  proposed,
136
126
  gate: options.gate,
137
- path: options.path,
138
127
  });
139
128
 
140
- // After a successful status change + verdict reset, emit a single commit covering both.
141
- if (resetsVerdict) {
142
- const taskFilePath = join(tasksDir(repoRoot), `${taskId}.json`);
143
- try {
144
- execFileSync('git', ['-C', repoRoot, 'add', taskFilePath], { stdio: 'pipe' });
145
- execFileSync(
146
- 'git',
147
- ['-C', repoRoot, 'commit', '-m', `cloverleaf: ${taskId} status ${from} → ${toStatus}; security_review_verdict → null (rework)`],
148
- { stdio: 'pipe' }
149
- );
150
- } catch {
151
- // No-op: commit is best-effort when running outside a git repo (e.g., test environments).
152
- }
153
- }
154
-
155
129
  return proposed;
156
130
  }
package/lib/work-item.ts CHANGED
@@ -26,7 +26,6 @@ export interface AdvanceWorkItemParams<T> {
26
26
  save: (proposed: T & { status: string }) => void;
27
27
  proposed: T;
28
28
  gate?: string;
29
- path?: 'fast_lane' | 'full_pipeline';
30
29
  }
31
30
 
32
31
  export interface AdvanceWorkItemResult {
@@ -35,9 +34,9 @@ export interface AdvanceWorkItemResult {
35
34
  }
36
35
 
37
36
  export function advanceWorkItemStatus<T>(params: AdvanceWorkItemParams<T>): AdvanceWorkItemResult {
38
- const { repoRoot, workItemType, project, id, from, to, actor, stateMachine, validateFixture, save, gate, path } = params;
37
+ const { repoRoot, workItemType, project, id, from, to, actor, stateMachine, validateFixture, save, gate } = params;
39
38
 
40
- const reason = formatReason({ gate, path });
39
+ const reason = formatReason({ gate });
41
40
  const event = {
42
41
  event_id: randomUUID(),
43
42
  event_type: 'status_transition' as const,
@@ -74,7 +73,6 @@ export function advanceWorkItemStatus<T>(params: AdvanceWorkItemParams<T>): Adva
74
73
  to,
75
74
  actor,
76
75
  gate,
77
- path,
78
76
  });
79
77
 
80
78
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloverleaf/reference-impl",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",
@@ -49,10 +49,10 @@
49
49
  "prepublishOnly": "node scripts/check-standard-prepped.mjs && npm test && npm run build"
50
50
  },
51
51
  "peerDependencies": {
52
- "@cloverleaf/standard": "^0.7.1"
52
+ "@cloverleaf/standard": "^0.8.0"
53
53
  },
54
54
  "dependencies": {
55
- "@cloverleaf/standard": "^0.7.0",
55
+ "@cloverleaf/standard": "^0.8.0",
56
56
  "ajv": "^8.17.1",
57
57
  "ajv-formats": "^3.0.1",
58
58
  "axe-core": "^4.10.0",
@@ -64,6 +64,8 @@
64
64
  "@types/node": "^22.0.0",
65
65
  "@types/pixelmatch": "^5.2.6",
66
66
  "@types/pngjs": "^6.0.5",
67
+ "@types/semver": "^7.8.0",
68
+ "semver": "^7.8.5",
67
69
  "tsx": "^4.19.0",
68
70
  "typescript": "^5.5.0",
69
71
  "vitest": "^2.0.0"
@@ -36,7 +36,7 @@ Inspect the diff. For each category below that matches, update the listed docs:
36
36
 
37
37
  | Diff touches | Docs to update |
38
38
  |---|---|
39
- | `standard/src/**`, `standard/schemas/**`, `standard/conformance/**` | `standard/CHANGELOG.md` (Unreleased), relevant `standard/docs/*.md` sections if behavior/conformance changed |
39
+ | `standard/schemas/**`, `standard/state-machines/**`, `standard/validators/**`, `standard/agent-contracts/**`, `standard/conformance/**` | `standard/CHANGELOG.md` (Unreleased), relevant `standard/docs/*.md` sections if behavior/conformance changed |
40
40
  | `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) |
41
41
  | `site/src/**`, `site/public/**` | `site/CHANGELOG.md` ONLY if that file already exists; otherwise skip |
42
42
  | Root-level package additions, version bumps | Root `README.md`, root `CHANGELOG.md` |
@@ -25,11 +25,17 @@ You are the Cloverleaf Implementer agent. Your job: take a Task and produce work
25
25
  **Scope nudge.** Your declared scope is `task.scope.files_touched`. You may freely modify any file listed there. If you discover during implementation that you need to touch a file outside that list, you may do so only if no sibling task in the same Plan declares that file — the walker auto-extends your scope on merge. If a file you need is already declared by a sibling task, that is a contested modification: stop, surface the conflict to the human, and do not merge. The walker enforces this at merge time and will refuse contested merges; auto-resolution is never attempted.
26
26
 
27
27
  2. If `feedback` is present, re-read each finding; plan how to address them. If the prior bounce came from a chair council (`.cloverleaf/runs/<task.id>/council/task.review.json` has `rule: "chair"`), prioritize the members listed in its `forward` array and the chair's `rationale`.
28
- 3. Create a new branch named `cloverleaf/<task.id>` from `base_branch` using `git checkout -b cloverleaf/<task.id>`.
28
+ 3. Get onto the task's branch, `cloverleaf/<task.id>`:
29
+ - If it does **not** exist, create it from `base_branch`: `git checkout -b cloverleaf/<task.id>`.
30
+ - If it **does** exist, you are reworking after a council bounce. Check it out — `git checkout cloverleaf/<task.id>` — and re-implement in place. **Never recreate or reset the branch:** it already carries earlier work, including the Documenter's doc commits, and rebuilding it from `base_branch` silently destroys them.
29
31
  4. Implement the code + tests needed to satisfy every acceptance criterion.
30
32
  5. Run the project's tests. Your test rules are provided as `{{test_rules}}` — a JSON object `{ rules: [...] }` whose `rules` is a list of `{cwd, match, command}` entries; each `match` is a list of glob patterns. For each rule whose `match` covers a file you changed, run its `command` in its `cwd`. All must pass. (If no rule matches your changes, there is nothing to run.)
33
+
34
+ Run your verification to completion **before returning**. If a command may exceed the default tool timeout, pass an explicit longer timeout rather than backgrounding it and returning early — a prose "I'll wait" message is not a valid result, and the orchestrator has no defined recovery for a non-conforming response. (This project's own `reference-impl` suite takes roughly five minutes, which exceeds the default.)
35
+
36
+ **Capture suite results safely:** redirect output to a file and check the exit code — `<command> > /tmp/suite.log 2>&1; echo "EXIT=$?"` — and never pipe the run through `| tail` or `| head`. A pipe reports the *last* command's exit status, so a failing suite reads as success.
31
37
  6. Stage and commit your changes with message `feat: <task.title> [<task.id>]`.
32
- 7. Return a structured JSON result to stdout:
38
+ 7. Return a structured JSON result as your final message — **exactly one JSON object and nothing else**, no prose before or after it, no markdown fence commentary:
33
39
 
34
40
  ```json
35
41
  {
package/prompts/qa.md CHANGED
@@ -43,18 +43,19 @@ The Standard's QA contract requires a `preview_uri`. You were passed the sentine
43
43
 
44
44
  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:
45
45
  ```json
46
- {"verdict": "pass", "summary": "No testable packages changed.", "findings": [], "results": {"passed": 0, "failed": 0, "total": 0}}
46
+ {"verdict": "pass", "summary": "No testable packages changed. Test counts: passed 0, failed 0, total 0.", "findings": []}
47
47
  ```
48
48
 
49
49
  4. For each queued command:
50
50
  - Run it in `"$TMPDIR/<cwd>"`
51
51
  - Capture stdout, stderr, exit code
52
+ - **Capture suite results safely:** redirect output to a file and check the exit code — `<command> > /tmp/suite.log 2>&1; echo "EXIT=$?"` — and never pipe the run through `| tail` or `| head`. A pipe reports the *last* command's exit status, so a failing suite reads as success.
52
53
  - Parse test output to extract `passed`, `failed`, `total`:
53
54
  - Exit code is the universal signal: exit 0 = the command's checks passed; non-zero = failed.
54
55
  - When the output format is recognized, also extract counts, e.g. Vitest (`Tests N passed | M failed`), pytest (`N passed, M failed`), or a plain build/lint (exit 0 → `{passed: 1, failed: 0, total: 1}`).
55
56
  - On failure, collect up to 10 failure names/messages as findings with `severity: "error"` and `rule: "qa.<suite>.<test-name>"`
56
57
 
57
- 5. Aggregate results: sum `passed`, `failed`, `total` across all runs.
58
+ 5. Aggregate results: sum `passed`, `failed`, `total` across all runs, and state them at the end of `summary`. Do not add a top-level `results` key — the feedback envelope schema forbids properties beyond `verdict`, `summary` and `findings`.
58
59
 
59
60
  6. Compute verdict:
60
61
  - `pass` — every command exited 0 AND aggregated `failed === 0`
@@ -94,6 +95,7 @@ The CLI creates the output directory.
94
95
 
95
96
  In the feedback you emit, include the report as an attachment on a single info-level finding (or on whichever summary finding you already emit):
96
97
 
98
+ <!-- cloverleaf-schema: feedback.schema.json#/$defs/finding -->
97
99
  ```json
98
100
  {
99
101
  "severity": "info",
@@ -114,7 +116,7 @@ Respond with exactly one JSON object and nothing else:
114
116
  ```json
115
117
  {
116
118
  "verdict": "pass" | "bounce" | "escalate",
117
- "summary": "<one-sentence summary>",
119
+ "summary": "<one-sentence summary, ending with the aggregate counts, e.g. 'Test counts: passed 153, failed 0, total 153.'>",
118
120
  "findings": [
119
121
  {
120
122
  "severity": "error",
@@ -122,11 +124,6 @@ Respond with exactly one JSON object and nothing else:
122
124
  "message": "<test failure message>",
123
125
  "location": "<file:line if known>"
124
126
  }
125
- ],
126
- "results": {
127
- "passed": <integer>,
128
- "failed": <integer>,
129
- "total": <integer>
130
- }
127
+ ]
131
128
  }
132
129
  ```
@@ -21,13 +21,13 @@ You are the Cloverleaf Reviewer agent. Your job: perform a fresh-eyes review of
21
21
  Run this as the first executable step before anything else. Session B sessions may inherit an arbitrary `cwd` from the walker harness; this anchors you at the repo root.
22
22
 
23
23
  1. Read the task's `acceptance_criteria` and `definition_of_done`.
24
- 2. Run `git diff <base_branch>..<branch> --stat` and `git diff <base_branch>..<branch>` to see the change.
24
+ 2. Run `git diff <base_branch>..<branch> --stat -- ':(exclude).cloverleaf/'` and `git diff <base_branch>..<branch> -- ':(exclude).cloverleaf/'` to see the change. Excluding `.cloverleaf/` matters: the orchestrator commits FSM state to `<base_branch>` while the feature branch stays behind, so an unfiltered two-dot diff shows the branch "deleting" event files and reverting the task's `status`. That is branch/base divergence, not implementer drift — never bounce a task for it.
25
25
  3. For each acceptance criterion, determine whether the diff satisfies it. Note any unsatisfied criteria as findings.
26
26
  4. Check for defects: missing tests, obvious logic errors, security issues, hygiene problems.
27
27
  5. Decide verdict:
28
28
  - `pass` if every acceptance criterion is satisfied and no blocking defects exist.
29
29
  - `bounce` otherwise.
30
- 6. Return a feedback envelope (per `feedback.schema.json`) to stdout as JSON:
30
+ 6. Return a feedback envelope (per `feedback.schema.json`) as your final message — **exactly one JSON object and nothing else**:
31
31
 
32
32
  ```json
33
33
  {
@@ -67,6 +67,8 @@ A `pass` verdict MAY have an empty `findings` array or omit it. A `bounce` verdi
67
67
  Use `--detach` with a SHA rather than a branch name: when running inside a walker worktree, the feature branch (and main) may already be checked out in another worktree, causing `git worktree add` to fail with "fatal: branch … is already checked out". Detaching at a SHA bypasses this constraint entirely.
68
68
 
69
69
  This keeps `.cloverleaf/` on main intact.
70
+
71
+ **Capture suite results safely:** redirect output to a file and check the exit code — `<command> > /tmp/suite.log 2>&1; echo "EXIT=$?"` — and never pipe the run through `| tail` or `| head`. A pipe reports the *last* command's exit status, so a failing suite reads as success.
70
72
  - **Loading or running a module directly (TypeScript projects).** If your project is TypeScript, do not improvise `node -e "import('./lib/x.js')"` to spot-check a module — sources are `.ts` and the build emits `.mjs`, so a bare `.js` import resolves to neither. Use `npx tsx` instead (resolves `.ts` sources and `.js`-style import specifiers):
71
73
 
72
74
  ```bash
@@ -25,13 +25,53 @@ Run this as the first executable step before anything else. Session B sessions m
25
25
 
26
26
  You operate in two filesystem locations — keep them straight:
27
27
 
28
- - `<worktree>` — the ephemeral worktree at `$WT` (set up in step 2 of the Runtime procedure). You run the dev server here and execute Playwright here. Any standalone `.mjs` driver scripts must be placed INSIDE `$WT/site/` so that Node can resolve `playwright` from `$WT/site/node_modules/`; do NOT write them outside the worktree where no `node_modules` is present.
28
+ - `<worktree>` — the ephemeral worktree at `$WT` (set up in step 2 of the Runtime procedure). You run the dev server here and drive Playwright against it. A standalone `.mjs` driver may live wherever you find convenient its imports are anchored at the plugin root, not at its own directory (see "Resolving your dependencies" below) — but you MUST delete any driver you write during teardown (step 13).
29
29
  - `<repoRoot>` — the main repository root at `{{repo_root}}` (always an absolute path). This is the ONLY location where baselines, diff PNGs, candidate PNGs, and artifacts are written.
30
30
 
31
31
  **All `compareVisual` paths MUST be rooted at `{{repo_root}}`, NOT at `$WT`.**
32
32
 
33
33
  The rationale: baselines on `{{repo_root}}/.cloverleaf/baselines/` get picked up by subsequent `git add` + `git commit` steps in the UI Reviewer, which run on the feature branch. The merge skill (v0.4.1+) then merges those commits to main via `git merge --no-ff`. Writing to the worktree's `.cloverleaf/` would strand the files and `git worktree remove --force` would discard them on teardown.
34
34
 
35
+ ## Resolving your dependencies
36
+
37
+ `playwright`, `axe-core`, `pixelmatch` and `pngjs` are runtime dependencies of `@cloverleaf/reference-impl`, so they are installed under the **plugin root** — never under the repository you are reviewing. The repo's UI directory declares none of them, and `prep-worktree` copies no `node_modules` into `$WT/site/`. **A bare specifier therefore cannot resolve, from any directory**: `import 'playwright'` and `import 'axe-core'` both fail with `ERR_MODULE_NOT_FOUND` regardless of where your driver sits.
38
+
39
+ Export the plugin root once, before you run anything:
40
+
41
+ ```bash
42
+ export CLOVERLEAF_PLUGIN_ROOT="$(npx cloverleaf-cli plugin-root)"
43
+ ```
44
+
45
+ Then anchor every import at it. `createRequire` resolves each package by name from the plugin root, so you never hard-code a package's internal file layout:
46
+
47
+ ```javascript
48
+ import { createRequire } from 'node:module';
49
+
50
+ const PLUGIN_ROOT = process.env.CLOVERLEAF_PLUGIN_ROOT;
51
+ const require = createRequire(PLUGIN_ROOT + '/');
52
+
53
+ const { chromium, firefox, webkit } = require('playwright');
54
+ const axe = require('axe-core');
55
+ ```
56
+
57
+ Cloverleaf's own helpers are ESM, one `dist/<module>.mjs` per `lib/<module>.ts`. Import them by absolute path:
58
+
59
+ ```javascript
60
+ const { compareVisual } = await import(`${PLUGIN_ROOT}/dist/visual-diff.mjs`);
61
+ const { slugifyRoute } = await import(`${PLUGIN_ROOT}/dist/route-slug.mjs`);
62
+ const { dedupeAxeFindings } = await import(`${PLUGIN_ROOT}/dist/axe-dedupe.mjs`);
63
+ const { applyMaxCombinationsCap, buildBrowserEscalationFinding } =
64
+ await import(`${PLUGIN_ROOT}/dist/ui-browser.mjs`);
65
+ ```
66
+
67
+ If an import fails, fix the anchor. Do **not** relocate the driver, do **not** add packages to the reviewed repo's `package.json`, and do **not** `npm install` anything into a scratch directory — none of that changes what resolves, and editing the repo's manifest would pollute the diff under review.
68
+
69
+ Keep whatever path you pick for the driver in a shell variable — the steps below call it `$DRIVER` — so teardown can delete it:
70
+
71
+ ```bash
72
+ DRIVER="$WT/site/ui-review-driver.mjs" # any path works; this one is also swept up with the worktree
73
+ ```
74
+
35
75
  ## Scope (v0.5)
36
76
 
37
77
  - **Browsers**: the reviewer runs separate Playwright sessions for each engine listed in `{{ui_review_config}}.browsers` (e.g., `["chromium", "webkit", "firefox"]`). Browser is the **outermost** loop, wrapping the viewport × route loops.
@@ -61,7 +101,15 @@ The cap enforcement helper is available in `lib/ui-browser.ts` as `applyMaxCombi
61
101
 
62
102
  ## Playwright cache
63
103
 
64
- The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playwright` before you are invoked. Before launching each browser session, verify that the required engine binary exists in `PLAYWRIGHT_BROWSERS_PATH`. If a browser binary is absent, return `verdict: "escalate"` with a synthetic finding per missing engine:
104
+ Playwright keeps its engine binaries in a shared cache outside the worktree, so a review never re-downloads ~300 MB. Establish that location yourself do not assume a caller set it for you:
105
+
106
+ ```bash
107
+ export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}"
108
+ ```
109
+
110
+ This defers to a dispatcher or operator who already set the variable (a non-default cache directory is a supported override) and falls back to Playwright's own default otherwise. Write `$HOME`, not `~`: a tilde inside double quotes is not expanded, and `"~/.cache/ms-playwright"` would create a literal `~` directory.
111
+
112
+ Before launching each browser session, verify that the required engine binary exists in `$PLAYWRIGHT_BROWSERS_PATH`. If a browser binary is absent, return `verdict: "escalate"` with a synthetic finding per missing engine:
65
113
 
66
114
  ```
67
115
  "Playwright {engine} not installed. Run 'npx playwright install webkit firefox' on this machine."
@@ -84,17 +132,34 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
84
132
  npx cloverleaf-cli prep-worktree {{repo_root}} "$WT"
85
133
  ```
86
134
 
87
- 3. For this repo, UI lives in `site/` (or another directory if ui-paths.json scopes it elsewhere). Install dependencies and start the dev server:
135
+ 3. For this repo, UI lives in `site/` (or another directory if ui-paths.json scopes it elsewhere). Install dependencies and start the dev server.
136
+
137
+ **Capture command results safely:** redirect output to a file and check the exit code — `<command> > /tmp/step.log 2>&1; echo "EXIT=$?"` — and never pipe the run through `| tail` or `| head`. A pipe reports the *last* command's exit status, so a failing command reads as success.
138
+
139
+ **Disable dev-only overlay UI before the server starts.** A baseline must contain only what ships. Astro's dev server injects a dev toolbar over the bottom-centre of every page: it never reaches production, it occludes the exact region a bottom-of-page regression appears in, and it couples every baseline to the Astro version that drew it. It is in the DOM too, so the axe pass in step 8c would report the toolbar's own violations as the site's.
140
+
88
141
  ```bash
89
142
  cd "$WT/site"
90
- npm ci
143
+ npm ci > /tmp/ui-npm-ci.log 2>&1; echo "EXIT=$?"
144
+ # EXIT must be 0 before you go on. On any other value, read /tmp/ui-npm-ci.log
145
+ # to see why and return verdict `escalate` — do NOT continue with a broken install.
146
+ npx astro preferences disable devToolbar > /tmp/ui-devtoolbar.log 2>&1; echo "EXIT=$?"
147
+ # Non-zero EXIT means this UI directory is not an Astro project — see below.
91
148
  npm run dev -- --port={{preview_port}} &
92
149
  SERVER_PID=$!
93
150
  ```
94
151
 
95
- > **Playwright/driver script placement (Bug #3 fix) applies to EVERY script you write, including retries and ad-hoc fallbacks:** place any standalone `.mjs` driver **inside the worktree** (e.g., `$WT/site/playwright-driver.mjs`) and run it from there (`node "$WT/site/playwright-driver.mjs"`). Node's ESM module resolution walks up from the script's own directory a script placed anywhere outside the worktree (e.g. `/tmp`) cannot resolve the `playwright` import (or any `node_modules` package) and fails with `ERR_MODULE_NOT_FOUND`. If a driver errors and you retry, fix it in place in `$WT/site/` never relocate or recreate it under `/tmp`.
152
+ `astro preferences disable devToolbar` is **project-scoped** by default: it writes into `$WT/site/.astro/`, which step 13 deletes along with the worktree, so it turns the toolbar off for this capture alone and nothing outside this run changes. Never pass `--global` that writes to the operator's home directory and silently changes every other Astro project on the machine. Disable it before backgrounding the server, not after: Astro decides whether to inject the toolbar when the dev server boots.
153
+
154
+ If that command exits non-zero, this UI directory is **not an Astro project**. Disable that toolchain's own dev overlay instead. If it has none you can turn off, emit an `info` finding recording that the baseline may contain dev-only UI rather than capturing a contaminated one silently.
155
+
156
+ Apply the same capture rule to your driver run and to every other command whose success you judge:
157
+
158
+ ```bash
159
+ node "$DRIVER" > /tmp/ui-driver.log 2>&1; echo "EXIT=$?"
160
+ ```
96
161
 
97
- 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`.
162
+ 4. Wait up to 30s for `http://localhost:{{preview_port}}/` to respond 200. If the server fails to start in 30s, run teardown (step 13) — `kill $SERVER_PID`, never a command-line pattern — and return verdict `escalate`.
98
163
 
99
164
  5. Determine the site base path:
100
165
  1. Check `{{repo_root}}/.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.)
@@ -108,7 +173,7 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
108
173
  - Use only the returned `routes` list for the browser passes below.
109
174
 
110
175
  7. **Verify browser binaries** — before starting any browser session:
111
- - Check each engine in `{{ui_review_config}}.browsers` against `PLAYWRIGHT_BROWSERS_PATH`.
176
+ - Export `PLAYWRIGHT_BROWSERS_PATH` first if you have not already (see "Playwright cache"), then check each engine in `{{ui_review_config}}.browsers` against `$PLAYWRIGHT_BROWSERS_PATH`.
112
177
  - Collect all missing engines.
113
178
  - If any engine is missing, call `buildBrowserEscalationFinding(engine, process.platform)` for each, teardown the worktree (step 13), and return `verdict: "escalate"` with those findings.
114
179
 
@@ -118,7 +183,7 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
118
183
 
119
184
  b. **Visual-diff pass (when `visualDiff.enabled` is true):**
120
185
 
121
- **Visual diffing is ONLY `compareVisual` (pixelmatch). There is no ImageMagick.** Never shell out to `convert`, `compare`, `magick`, or any external image tool to diff or convert images — they are not installed and not a dependency. Screenshot via Playwright (`page.screenshot`) → pass the buffer to `compareVisual` (`lib/visual-diff.ts`). If `compareVisual` is hard to invoke, your driver script failed to resolve — fix the script (see the placement rule), do **not** substitute an external tool.
186
+ **Visual diffing is ONLY `compareVisual` (pixelmatch). There is no ImageMagick.** Never shell out to `convert`, `compare`, `magick`, or any external image tool to diff or convert images — they are not installed and not a dependency. Screenshot via Playwright (`page.screenshot`) → pass the buffer to `compareVisual` (`lib/visual-diff.ts`). If `compareVisual` is hard to invoke, your driver failed to import it — fix the import anchor (see "Resolving your dependencies"), do **not** substitute an external tool.
122
187
 
123
188
  For each route in the (capped) route list × each viewport in `{{ui_review_config}}.viewports`:
124
189
  - Set Playwright viewport to `{ width, height }` from the config.
@@ -147,10 +212,10 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
147
212
  - Set Playwright viewport to `{ width, height }`.
148
213
  - For each route in the (capped) route list:
149
214
  - Navigate.
150
- - Inject and run axe-core:
215
+ - Inject and run axe-core. `axe` is the plugin-root-anchored `require('axe-core')` from "Resolving your dependencies"; axe runs **inside the page**, so inject its source and evaluate there:
151
216
  ```javascript
152
- import axe from 'axe-core';
153
- const results = await axe.run(document);
217
+ await page.addScriptTag({ content: axe.source });
218
+ const results = await page.evaluate(async () => await window.axe.run(document));
154
219
  ```
155
220
  - Collect each violation as a raw tuple: `{ viewport, ruleId, target, impact, message, helpUrl }` (from `axe.run` output).
156
221
 
@@ -172,11 +237,13 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
172
237
  12. **Write ui-review state sidecar** — after all browser passes complete and before teardown, determine whether any `compareVisual` call returned `new-baseline` or `dimension-mismatch` across all routes, viewports, and browsers in this run.
173
238
 
174
239
  - If **yes**: write `{{repo_root}}/.cloverleaf/runs/{{taskId}}/ui-review/state.json` containing:
240
+ <!-- cloverleaf-schema: none -->
175
241
  ```json
176
242
  {"baselines_pending": true}
177
243
  ```
178
244
  (Create intermediate directories as needed.)
179
245
  - If **no**: write `{{repo_root}}/.cloverleaf/runs/{{taskId}}/ui-review/state.json` containing:
246
+ <!-- cloverleaf-schema: none -->
180
247
  ```json
181
248
  {"baselines_pending": false}
182
249
  ```
@@ -186,10 +253,15 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
186
253
  13. Teardown:
187
254
  ```bash
188
255
  kill $SERVER_PID 2>/dev/null || true
256
+ rm -f "$DRIVER"
189
257
  cd {{repo_root}}
190
258
  git worktree remove --force "$WT"
191
259
  ```
192
260
 
261
+ Kill the server by the PID you captured in step 3, never by command-line pattern. `pkill -f "astro dev"` also matches the command line of the shell running it, so the shell kills itself: exit 144, and every command after it in the same compound statement — the `rm -f` and `git worktree remove` above — silently never runs.
262
+
263
+ Delete every driver script you wrote, wherever you put it. `git worktree remove --force` only clears what lives inside `$WT`; a driver written anywhere else survives the run and leaks into the next one.
264
+
193
265
  ## Tool constraints
194
266
 
195
267
  - Read-only for source files and tests.
@@ -1,21 +1,23 @@
1
1
  ---
2
2
  name: cloverleaf-approve-baselines
3
- description: Human baseline-approval gate for the Cloverleaf UI Review pipeline. When the UI Reviewer captures new or resized visual baselines it sets baselines_pending=true in .cloverleaf/runs/{taskId}/ui-review/state.json and blocks the ui-review qa transition. Run this skill after inspecting the new baseline images to approve them and allow the task to advance to qa. Usage — /cloverleaf-approve-baselines <TASK-ID>.
3
+ description: Human baseline-approval gate for the Cloverleaf delivery council. When the UI member captures new or resized visual baselines it sets baselines_pending=true in .cloverleaf/runs/{taskId}/ui-review/state.json, and the council runner holds the council pass until the baselines are approved. This skill is clear-only — it presents the new baseline images, records human approval, and clears baselines_pending (it drives no FSM transition). Re-run /cloverleaf-run <TASK-ID> afterwards so the delivery council can pass. Usage — /cloverleaf-approve-baselines <TASK-ID>.
4
4
  ---
5
5
 
6
6
  # Cloverleaf — approve-baselines
7
7
 
8
8
  ## Trigger condition
9
9
 
10
- This skill is invoked **only** when the `cloverleaf-ui-review` skill reports that `baselines_pending` is `true` — i.e., the UI Reviewer captured at least one `new-baseline` or `dimension-mismatch` result during its run, meaning one or more baseline PNGs under `.cloverleaf/baselines/{browser}/` were created or replaced.
10
+ This skill is invoked **only** when the delivery council's `ui` member reports that `baselines_pending` is `true` — i.e., the UI Reviewer captured at least one `new-baseline` or `dimension-mismatch` result during its run, meaning one or more baseline PNGs under `.cloverleaf/baselines/{browser}/` were created or replaced. The council runner (`/cloverleaf-run` §4.1) holds the council `pass` (which would advance `council → final-gate`) until `baselines_pending` is cleared.
11
11
 
12
- Do not run this skill if the task is not in `ui-review` status or if `state.json` already has `baselines_pending: false`.
12
+ Do not run this skill if `state.json` already has `baselines_pending: false` (there is nothing to approve).
13
13
 
14
14
  ## Effect
15
15
 
16
- 1. Writes `baselines_pending: false` to `.cloverleaf/runs/{taskId}/ui-review/state.json`.
17
- 2. Advances the task from `ui-review` → `qa` via the normal agent transition.
18
- 3. Commits the updated state and status to the feature branch.
16
+ 1. Presents the new baseline images and records human approval.
17
+ 2. Writes `baselines_pending: false` to `.cloverleaf/runs/{taskId}/ui-review/state.json`.
18
+ 3. Commits the updated state to the feature branch.
19
+
20
+ This skill is **clear-only**: it drives **no** FSM transition. Once `baselines_pending` is `false`, re-running `/cloverleaf-run <TASK-ID>` re-dispatches the delivery council; the `ui` member now passes and the council `pass` applies (`council → final-gate`).
19
21
 
20
22
  ---
21
23
 
@@ -33,19 +35,14 @@ Do not run this skill if the task is not in `ui-review` status or if `state.json
33
35
 
34
36
  1. Capture the TASK-ID argument.
35
37
 
36
- 2. Load the task and verify status:
38
+ 2. Load the task (context only) and read the ui-review state to confirm there is something to approve:
37
39
  ```bash
38
40
  cloverleaf-cli load-task <repo_root> <TASK-ID>
39
- ```
40
- Verify `status === "ui-review"`. If not, report and stop.
41
-
42
- 3. Read the current ui-review state:
43
- ```bash
44
41
  cloverleaf-cli read-ui-review-state <repo_root> <TASK-ID>
45
42
  ```
46
- If `baselines_pending` is already `false` (or the file is absent), report that no approval is needed and stop.
43
+ Verify `baselines_pending === true`. If it is already `false` (or the state file is absent), report that no approval is needed and stop. (This skill gates on the `baselines_pending` flag, **not** on task `status` — under the collapsed council FSM the task sits at `council` while the UI member's baselines await approval.)
47
44
 
48
- 4. Present the new baseline images to the human for review. The baselines live at:
45
+ 3. Present the new baseline images to the human for review. The baselines live at:
49
46
  ```
50
47
  <repo_root>/.cloverleaf/baselines/{browser}/{slug}-{viewport}.png
51
48
  ```
@@ -55,25 +52,20 @@ Do not run this skill if the task is not in `ui-review` status or if `state.json
55
52
  ```
56
53
  Display the list. Ask the human to confirm they have reviewed the images and approve the baselines before proceeding.
57
54
 
58
- 5. Once approved, write `baselines_pending: false`:
55
+ 4. Once approved, clear the flag — write `baselines_pending: false`:
59
56
  ```bash
60
57
  cloverleaf-cli write-ui-review-state <repo_root> <TASK-ID> false
61
58
  ```
62
59
 
63
- 6. Advance the task to qa:
64
- ```bash
65
- cloverleaf-cli advance-status <repo_root> <TASK-ID> qa agent '' full_pipeline
66
- ```
67
-
68
- 7. Commit the changes to the feature branch:
60
+ 5. Commit the changes to the feature branch:
69
61
  ```bash
70
62
  cd <repo_root>
71
63
  git add .cloverleaf/
72
- git commit -m "cloverleaf: <TASK-ID> baselines approved qa"
64
+ git commit -m "cloverleaf: <TASK-ID> baselines approved (baselines_pending cleared)"
73
65
  ```
74
66
 
75
- 8. Report:
76
- > "✓ Baselines approved. `baselines_pending` cleared. State → qa. Next: `/cloverleaf-qa <TASK-ID>`."
67
+ 6. Report:
68
+ > "✓ Baselines approved (baselines_pending cleared). Re-run `/cloverleaf-run <TASK-ID>` so the delivery council can now pass."
77
69
 
78
70
  ---
79
71
 
@@ -81,5 +73,5 @@ Do not run this skill if the task is not in `ui-review` status or if `state.json
81
73
 
82
74
  - Never push.
83
75
  - Do not modify source code or test files.
84
- - Do not skip step 4 — the human must acknowledge the baseline images before approval is recorded.
85
- - On illegal state transition, report and stop without partial commits.
76
+ - This skill is **clear-only**it drives **no** FSM transition (no `advance-status`). It only clears the `baselines_pending` flag; the council runner re-runs the council once the flag is `false`.
77
+ - Do not skip step 3 the human must acknowledge the baseline images before approval is recorded.