@cloverleaf/reference-impl 0.3.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
@@ -52,12 +52,43 @@ 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
89
  - Concurrent `/cloverleaf-run` on the same repo may race on preview ports.
58
90
  - UI Reviewer visual diff + multi-viewport deferred to v0.4.
59
91
  - 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
92
 
62
93
  ### Prerequisites for UI Reviewer
63
94
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.3.0
1
+ 0.3.1
@@ -8,5 +8,6 @@
8
8
  "site/astro.config.*",
9
9
  "site/src/content/**"
10
10
  ],
11
- "routeScope": ["site/src/**", "site/public/**"]
11
+ "routeScope": ["site/src/**", "site/public/**"],
12
+ "contentRoutes": {}
12
13
  }
@@ -34,8 +34,31 @@ export function loadDefaultConfig() {
34
34
  pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
35
35
  globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
36
36
  routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
37
+ contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
38
+ ? doc.contentRoutes
39
+ : {},
37
40
  };
38
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
+ }
39
62
  export function computeAffectedRoutes(changedFiles, config) {
40
63
  const routes = new Set();
41
64
  let inScopeButUnmatched = false;
@@ -55,6 +78,16 @@ export function computeAffectedRoutes(changedFiles, config) {
55
78
  routes.add(mapped);
56
79
  continue;
57
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;
58
91
  if (matchesAny(file, config.routeScope)) {
59
92
  inScopeButUnmatched = true;
60
93
  }
package/dist/cli.mjs CHANGED
@@ -20,8 +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';
24
- import { computeAffectedRoutes, loadDefaultConfig } from './affected-routes.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';
25
27
  function die(msg, code = 1) {
26
28
  process.stderr.write(msg + '\n');
27
29
  process.exit(code);
@@ -177,7 +179,7 @@ try {
177
179
  console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
178
180
  process.exit(2);
179
181
  }
180
- const patterns = loadDefaultPatterns();
182
+ const { patterns } = loadUiPathsConfig(repoRoot);
181
183
  const result = matchesUiPaths(changed, patterns);
182
184
  process.stdout.write(`${result}\n`);
183
185
  process.exit(0);
@@ -204,7 +206,7 @@ try {
204
206
  console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
205
207
  process.exit(2);
206
208
  }
207
- const config = loadDefaultConfig();
209
+ const config = loadAffectedRoutesConfig(repoRoot);
208
210
  const result = computeAffectedRoutes(changed, config);
209
211
  process.stdout.write(`${JSON.stringify(result)}\n`);
210
212
  process.exit(0);
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;
@@ -9,6 +9,7 @@ export interface AffectedRoutesConfig {
9
9
  pageRoots: string[];
10
10
  globalPatterns: string[];
11
11
  routeScope: string[];
12
+ contentRoutes: Record<string, string>;
12
13
  }
13
14
 
14
15
  function globToRegex(pattern: string): RegExp {
@@ -42,9 +43,32 @@ export function loadDefaultConfig(): AffectedRoutesConfig {
42
43
  pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
43
44
  globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
44
45
  routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
46
+ contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
47
+ ? doc.contentRoutes
48
+ : {},
45
49
  };
46
50
  }
47
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
+
48
72
  export function computeAffectedRoutes(
49
73
  changedFiles: string[],
50
74
  config: AffectedRoutesConfig
@@ -68,6 +92,15 @@ export function computeAffectedRoutes(
68
92
  routes.add(mapped);
69
93
  continue;
70
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;
71
104
  if (matchesAny(file, config.routeScope)) {
72
105
  inScopeButUnmatched = true;
73
106
  }
package/lib/cli.ts CHANGED
@@ -21,8 +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';
25
- import { computeAffectedRoutes, loadDefaultConfig } from './affected-routes.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';
26
28
  import type { FeedbackEnvelope } from './feedback.js';
27
29
 
28
30
  function die(msg: string, code = 1): never {
@@ -186,7 +188,7 @@ try {
186
188
  console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
187
189
  process.exit(2);
188
190
  }
189
- const patterns = loadDefaultPatterns();
191
+ const { patterns } = loadUiPathsConfig(repoRoot);
190
192
  const result = matchesUiPaths(changed, patterns);
191
193
  process.stdout.write(`${result}\n`);
192
194
  process.exit(0);
@@ -213,7 +215,7 @@ try {
213
215
  console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
214
216
  process.exit(2);
215
217
  }
216
- const config = loadDefaultConfig();
218
+ const config = loadAffectedRoutesConfig(repoRoot);
217
219
  const result = computeAffectedRoutes(changed, config);
218
220
  process.stdout.write(`${JSON.stringify(result)}\n`);
219
221
  process.exit(0);
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.3.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",
@@ -46,7 +46,10 @@ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playw
46
46
 
47
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
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.
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.
50
53
 
51
54
  6. For each route in `{{affected_routes}}` (or the crawl set, if `"all"`):
52
55
  - Construct URL `http://localhost:{{preview_port}}<base><route>`.
@@ -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