@cloverleaf/reference-impl 0.6.0 → 0.6.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
@@ -146,7 +146,7 @@ The Reviewer never switches branches. It reads files via `git show` and runs tes
146
146
 
147
147
  ## Package layout
148
148
 
149
- - `lib/` — TypeScript library used by the CLI. State, events, feedback, IDs, paths. Includes `buildBaselinePath(repoRoot, browser, slug, viewport)` (`lib/visual-diff.ts`) for constructing canonical baseline paths under `.cloverleaf/baselines/{browser}/`. `lib/ui-browser.ts` exports `buildBrowserEscalationFinding` and `applyMaxCombinationsCap` (used by the UI Reviewer prompt for per-engine escalation and combination-count capping). `lib/ui-review-state.ts` exports `readUiReviewState`, `writeUiReviewState`, and `uiReviewStatePath` — the baseline-approval sidecar API for `.cloverleaf/runs/{taskId}/ui-review/state.json`.
149
+ - `lib/` — TypeScript library used by the CLI. State, events, feedback, IDs, paths. Includes `buildBaselinePath(repoRoot, browser, slug, viewport)` (`lib/visual-diff.ts`) for constructing canonical baseline paths under `.cloverleaf/baselines/{browser}/`. `lib/ui-browser.ts` exports `buildBrowserEscalationFinding` and `applyMaxCombinationsCap` (used by the UI Reviewer prompt for per-engine escalation and combination-count capping). `lib/ui-review-state.ts` exports `readUiReviewState`, `writeUiReviewState`, and `uiReviewStatePath` — the baseline-approval sidecar API for `.cloverleaf/runs/{taskId}/ui-review/state.json`. The CLI exposes `write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>` as the safe write path for baselines; it enforces the `baselines_pending` guard and uses `buildBaselinePath` internally.
150
150
  - `skills/` — Claude Code skill markdown files.
151
151
  - `prompts/` — Implementer/Reviewer subagent system prompts.
152
152
  - `examples/toy-repo/` — standalone demo repo.
package/dist/cli.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  * ui-review-config --repo-root <repoRoot>
16
16
  * read-ui-review-state <repoRoot> <taskId>
17
17
  * write-ui-review-state <repoRoot> <taskId> <baselines_pending>
18
+ * write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>
18
19
  * plugin-root
19
20
  * load-rfc <repoRoot> <id>
20
21
  * save-rfc <repoRoot> <filePath>
@@ -34,7 +35,8 @@
34
35
  * walk-state-read <repoRoot> <planId>
35
36
  * walk-state-write <repoRoot> <walkStateJsonPath>
36
37
  */
37
- import { readFileSync } from 'node:fs';
38
+ import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
39
+ import { dirname } from 'node:path';
38
40
  import { execSync } from 'node:child_process';
39
41
  import { loadTask } from './task.mjs';
40
42
  import { advanceStatus } from './task.mjs';
@@ -53,6 +55,7 @@ import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan } from
53
55
  import { loadDiscoveryConfig } from './discovery-config.mjs';
54
56
  import { prepWorktree } from './prep-worktree.mjs';
55
57
  import { readUiReviewState, writeUiReviewState } from './ui-review-state.mjs';
58
+ import { buildBaselinePath } from './visual-diff.mjs';
56
59
  import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
57
60
  import { readWalkState, writeWalkState, walkStatePath } from './walk-state.mjs';
58
61
  function die(msg, code = 1) {
@@ -74,6 +77,7 @@ function usage(msg) {
74
77
  ' ui-review-config --repo-root <repoRoot>\n' +
75
78
  ' read-ui-review-state <repoRoot> <taskId>\n' +
76
79
  ' write-ui-review-state <repoRoot> <taskId> <baselines_pending>\n' +
80
+ ' write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>\n' +
77
81
  ' plugin-root\n' +
78
82
  ' load-rfc <repoRoot> <id>\n' +
79
83
  ' save-rfc <repoRoot> <filePath>\n' +
@@ -300,6 +304,25 @@ try {
300
304
  writeUiReviewState(repoRoot, taskId, { baselines_pending });
301
305
  break;
302
306
  }
307
+ case 'write-baseline': {
308
+ const [repoRoot, taskId, browser, slug, viewport, sourceFile] = rest;
309
+ if (!repoRoot || !taskId || !browser || !slug || !viewport || !sourceFile)
310
+ usage('write-baseline requires <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>');
311
+ // Guard: refuse writes under .cloverleaf/baselines/ when baselines_pending is true.
312
+ // This prevents the UI Reviewer from bypassing the human baseline-approval gate.
313
+ const uiState = readUiReviewState(repoRoot, taskId);
314
+ if (uiState.baselines_pending) {
315
+ die(`write-baseline refused: baselines_pending is true for task ${taskId}.\n` +
316
+ `A human must approve the pending baselines via the baseline-approval gate before new baselines can be written.\n` +
317
+ `Run: cloverleaf-cli write-ui-review-state <repoRoot> ${taskId} false` +
318
+ ` after the human approves the baselines.`);
319
+ }
320
+ const destPath = buildBaselinePath(repoRoot, browser, slug, viewport);
321
+ mkdirSync(dirname(destPath), { recursive: true });
322
+ copyFileSync(sourceFile, destPath);
323
+ process.stdout.write(destPath + '\n');
324
+ break;
325
+ }
303
326
  case 'plugin-root': {
304
327
  process.stdout.write(getPluginRoot());
305
328
  process.exit(0);
@@ -1,6 +1,6 @@
1
1
  import { cpSync, existsSync, rmSync } from 'node:fs';
2
2
  import { execSync } from 'node:child_process';
3
- import { join } from 'node:path';
3
+ import { join, dirname } from 'node:path';
4
4
  /**
5
5
  * Prepare a freshly-created git worktree of the cloverleaf monorepo for running reference-impl
6
6
  * tests. Addresses the v0.5 dogfood finding (CLV-16, CLV-17 Delivery runs) where Reviewer/QA
@@ -19,10 +19,73 @@ import { join } from 'node:path';
19
19
  * - Copy `<main>/reference-impl/node_modules` → `<wt>/reference-impl/node_modules`
20
20
  * The `@cloverleaf/standard → ../../../standard` relative symlink is preserved verbatim so
21
21
  * it resolves to the worktree's OWN standard/, not main's.
22
+ *
23
+ * Walker-mode resilience (CLV-37): when `mainRoot` is itself a walker worktree without
24
+ * node_modules, walk up ancestor directories until one is found that contains both
25
+ * `standard/node_modules` and `reference-impl/node_modules`. This allows the orchestrator to
26
+ * pass the current walker worktree path without needing to know the actual primary repo root.
22
27
  */
23
- export function prepWorktree(mainRoot, worktreePath) {
28
+ /**
29
+ * Walk up the directory tree from `startDir` until a directory is found that contains both
30
+ * `standard/node_modules` and `reference-impl/node_modules`. Returns that directory, or null
31
+ * if the filesystem root is reached without finding one.
32
+ */
33
+ function findPrimaryRoot(startDir) {
34
+ let candidate = startDir;
35
+ while (true) {
36
+ if (existsSync(join(candidate, 'standard', 'node_modules')) &&
37
+ existsSync(join(candidate, 'reference-impl', 'node_modules'))) {
38
+ return candidate;
39
+ }
40
+ const parent = dirname(candidate);
41
+ if (parent === candidate) {
42
+ // Reached filesystem root without finding a match.
43
+ return null;
44
+ }
45
+ candidate = parent;
46
+ }
47
+ }
48
+ /**
49
+ * Walk up from `startDir` to find the nearest ancestor where the given `subdir` exists.
50
+ * Returns the ancestor path, or null if the filesystem root is reached without a match.
51
+ */
52
+ function findNearestAncestorWithSubdir(startDir, subdir) {
53
+ let candidate = startDir;
54
+ while (true) {
55
+ if (existsSync(join(candidate, subdir))) {
56
+ return candidate;
57
+ }
58
+ const parent = dirname(candidate);
59
+ if (parent === candidate) {
60
+ return null;
61
+ }
62
+ candidate = parent;
63
+ }
64
+ }
65
+ /**
66
+ * Build a diagnostic error message when `findPrimaryRoot` fails to find an ancestor with
67
+ * both `standard/node_modules` and `reference-impl/node_modules`. Walks up separately for
68
+ * each subdirectory to produce a precise message naming the specific missing directory.
69
+ */
70
+ function buildMissingNodeModulesError(mainRoot) {
71
+ const hasStandard = findNearestAncestorWithSubdir(mainRoot, join('standard', 'node_modules'));
72
+ const hasRefImpl = findNearestAncestorWithSubdir(mainRoot, join('reference-impl', 'node_modules'));
73
+ if (hasStandard !== null && hasRefImpl === null) {
74
+ // standard/node_modules exists somewhere in the tree but reference-impl/node_modules does not.
75
+ const missing = join(hasStandard, 'reference-impl', 'node_modules');
76
+ return new Error(`main missing reference-impl/node_modules at ${missing} — run \`npm ci\` in main's reference-impl/ first`);
77
+ }
78
+ if (hasRefImpl !== null && hasStandard === null) {
79
+ // reference-impl/node_modules exists somewhere in the tree but standard/node_modules does not.
80
+ const missing = join(hasRefImpl, 'standard', 'node_modules');
81
+ return new Error(`main missing standard/node_modules at ${missing} — run \`npm ci\` in main's standard/ first`);
82
+ }
83
+ // Neither found (or both missing): fall back to reporting standard/node_modules against the
84
+ // original mainRoot argument (preserves prior behaviour for the truly-empty case).
24
85
  const mainStandardNm = join(mainRoot, 'standard', 'node_modules');
25
- const mainRefImplNm = join(mainRoot, 'reference-impl', 'node_modules');
86
+ return new Error(`main missing standard/node_modules at ${mainStandardNm} — run \`npm ci\` in main's standard/ first`);
87
+ }
88
+ export function prepWorktree(mainRoot, worktreePath) {
26
89
  const wtStandardPkg = join(worktreePath, 'standard', 'package.json');
27
90
  const wtRefImplPkg = join(worktreePath, 'reference-impl', 'package.json');
28
91
  if (!existsSync(wtStandardPkg)) {
@@ -31,12 +94,14 @@ export function prepWorktree(mainRoot, worktreePath) {
31
94
  if (!existsSync(wtRefImplPkg)) {
32
95
  throw new Error(`worktree missing reference-impl/package.json at ${wtRefImplPkg}`);
33
96
  }
34
- if (!existsSync(mainStandardNm)) {
35
- throw new Error(`main missing standard/node_modules at ${mainStandardNm} — run \`npm ci\` in main's standard/ first`);
36
- }
37
- if (!existsSync(mainRefImplNm)) {
38
- throw new Error(`main missing reference-impl/node_modules at ${mainRefImplNm} — run \`npm ci\` in main's reference-impl/ first`);
97
+ // Resolve the actual primary repo root: start from mainRoot and walk up until we find a
98
+ // directory containing both standard/node_modules and reference-impl/node_modules.
99
+ const resolvedMain = findPrimaryRoot(mainRoot);
100
+ if (resolvedMain === null) {
101
+ throw buildMissingNodeModulesError(mainRoot);
39
102
  }
103
+ const mainStandardNm = join(resolvedMain, 'standard', 'node_modules');
104
+ const mainRefImplNm = join(resolvedMain, 'reference-impl', 'node_modules');
40
105
  const wtStandardNm = join(worktreePath, 'standard', 'node_modules');
41
106
  const wtRefImplNm = join(worktreePath, 'reference-impl', 'node_modules');
42
107
  // verbatimSymlinks keeps relative symlink targets byte-identical, so the @cloverleaf/standard
package/lib/cli.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * ui-review-config --repo-root <repoRoot>
16
16
  * read-ui-review-state <repoRoot> <taskId>
17
17
  * write-ui-review-state <repoRoot> <taskId> <baselines_pending>
18
+ * write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>
18
19
  * plugin-root
19
20
  * load-rfc <repoRoot> <id>
20
21
  * save-rfc <repoRoot> <filePath>
@@ -35,7 +36,8 @@
35
36
  * walk-state-write <repoRoot> <walkStateJsonPath>
36
37
  */
37
38
 
38
- import { readFileSync } from 'node:fs';
39
+ import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
40
+ import { dirname } from 'node:path';
39
41
  import { execSync } from 'node:child_process';
40
42
  import { loadTask } from './task.js';
41
43
  import { advanceStatus } from './task.js';
@@ -55,6 +57,7 @@ import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan, type P
55
57
  import { loadDiscoveryConfig } from './discovery-config.js';
56
58
  import { prepWorktree } from './prep-worktree.js';
57
59
  import { readUiReviewState, writeUiReviewState } from './ui-review-state.js';
60
+ import { buildBaselinePath } from './visual-diff.js';
58
61
  import { computeReadyTasks, detectCycle } from './dag-walker.js';
59
62
  import { readWalkState, writeWalkState, walkStatePath } from './walk-state.js';
60
63
 
@@ -78,6 +81,7 @@ function usage(msg?: string): never {
78
81
  ' ui-review-config --repo-root <repoRoot>\n' +
79
82
  ' read-ui-review-state <repoRoot> <taskId>\n' +
80
83
  ' write-ui-review-state <repoRoot> <taskId> <baselines_pending>\n' +
84
+ ' write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>\n' +
81
85
  ' plugin-root\n' +
82
86
  ' load-rfc <repoRoot> <id>\n' +
83
87
  ' save-rfc <repoRoot> <filePath>\n' +
@@ -310,6 +314,30 @@ try {
310
314
  break;
311
315
  }
312
316
 
317
+ case 'write-baseline': {
318
+ const [repoRoot, taskId, browser, slug, viewport, sourceFile] = rest;
319
+ if (!repoRoot || !taskId || !browser || !slug || !viewport || !sourceFile)
320
+ usage(
321
+ 'write-baseline requires <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>'
322
+ );
323
+ // Guard: refuse writes under .cloverleaf/baselines/ when baselines_pending is true.
324
+ // This prevents the UI Reviewer from bypassing the human baseline-approval gate.
325
+ const uiState = readUiReviewState(repoRoot, taskId);
326
+ if (uiState.baselines_pending) {
327
+ die(
328
+ `write-baseline refused: baselines_pending is true for task ${taskId}.\n` +
329
+ `A human must approve the pending baselines via the baseline-approval gate before new baselines can be written.\n` +
330
+ `Run: cloverleaf-cli write-ui-review-state <repoRoot> ${taskId} false` +
331
+ ` after the human approves the baselines.`
332
+ );
333
+ }
334
+ const destPath = buildBaselinePath(repoRoot, browser, slug, viewport);
335
+ mkdirSync(dirname(destPath), { recursive: true });
336
+ copyFileSync(sourceFile, destPath);
337
+ process.stdout.write(destPath + '\n');
338
+ break;
339
+ }
340
+
313
341
  case 'plugin-root': {
314
342
  process.stdout.write(getPluginRoot());
315
343
  process.exit(0);
@@ -1,6 +1,6 @@
1
1
  import { cpSync, existsSync, rmSync } from 'node:fs';
2
2
  import { execSync } from 'node:child_process';
3
- import { join } from 'node:path';
3
+ import { join, dirname } from 'node:path';
4
4
 
5
5
  /**
6
6
  * Prepare a freshly-created git worktree of the cloverleaf monorepo for running reference-impl
@@ -20,10 +20,80 @@ import { join } from 'node:path';
20
20
  * - Copy `<main>/reference-impl/node_modules` → `<wt>/reference-impl/node_modules`
21
21
  * The `@cloverleaf/standard → ../../../standard` relative symlink is preserved verbatim so
22
22
  * it resolves to the worktree's OWN standard/, not main's.
23
+ *
24
+ * Walker-mode resilience (CLV-37): when `mainRoot` is itself a walker worktree without
25
+ * node_modules, walk up ancestor directories until one is found that contains both
26
+ * `standard/node_modules` and `reference-impl/node_modules`. This allows the orchestrator to
27
+ * pass the current walker worktree path without needing to know the actual primary repo root.
23
28
  */
24
- export function prepWorktree(mainRoot: string, worktreePath: string): void {
29
+
30
+ /**
31
+ * Walk up the directory tree from `startDir` until a directory is found that contains both
32
+ * `standard/node_modules` and `reference-impl/node_modules`. Returns that directory, or null
33
+ * if the filesystem root is reached without finding one.
34
+ */
35
+ function findPrimaryRoot(startDir: string): string | null {
36
+ let candidate = startDir;
37
+ while (true) {
38
+ if (
39
+ existsSync(join(candidate, 'standard', 'node_modules')) &&
40
+ existsSync(join(candidate, 'reference-impl', 'node_modules'))
41
+ ) {
42
+ return candidate;
43
+ }
44
+ const parent = dirname(candidate);
45
+ if (parent === candidate) {
46
+ // Reached filesystem root without finding a match.
47
+ return null;
48
+ }
49
+ candidate = parent;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Walk up from `startDir` to find the nearest ancestor where the given `subdir` exists.
55
+ * Returns the ancestor path, or null if the filesystem root is reached without a match.
56
+ */
57
+ function findNearestAncestorWithSubdir(startDir: string, subdir: string): string | null {
58
+ let candidate = startDir;
59
+ while (true) {
60
+ if (existsSync(join(candidate, subdir))) {
61
+ return candidate;
62
+ }
63
+ const parent = dirname(candidate);
64
+ if (parent === candidate) {
65
+ return null;
66
+ }
67
+ candidate = parent;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Build a diagnostic error message when `findPrimaryRoot` fails to find an ancestor with
73
+ * both `standard/node_modules` and `reference-impl/node_modules`. Walks up separately for
74
+ * each subdirectory to produce a precise message naming the specific missing directory.
75
+ */
76
+ function buildMissingNodeModulesError(mainRoot: string): Error {
77
+ const hasStandard = findNearestAncestorWithSubdir(mainRoot, join('standard', 'node_modules'));
78
+ const hasRefImpl = findNearestAncestorWithSubdir(mainRoot, join('reference-impl', 'node_modules'));
79
+
80
+ if (hasStandard !== null && hasRefImpl === null) {
81
+ // standard/node_modules exists somewhere in the tree but reference-impl/node_modules does not.
82
+ const missing = join(hasStandard, 'reference-impl', 'node_modules');
83
+ return new Error(`main missing reference-impl/node_modules at ${missing} — run \`npm ci\` in main's reference-impl/ first`);
84
+ }
85
+ if (hasRefImpl !== null && hasStandard === null) {
86
+ // reference-impl/node_modules exists somewhere in the tree but standard/node_modules does not.
87
+ const missing = join(hasRefImpl, 'standard', 'node_modules');
88
+ return new Error(`main missing standard/node_modules at ${missing} — run \`npm ci\` in main's standard/ first`);
89
+ }
90
+ // Neither found (or both missing): fall back to reporting standard/node_modules against the
91
+ // original mainRoot argument (preserves prior behaviour for the truly-empty case).
25
92
  const mainStandardNm = join(mainRoot, 'standard', 'node_modules');
26
- const mainRefImplNm = join(mainRoot, 'reference-impl', 'node_modules');
93
+ return new Error(`main missing standard/node_modules at ${mainStandardNm} — run \`npm ci\` in main's standard/ first`);
94
+ }
95
+
96
+ export function prepWorktree(mainRoot: string, worktreePath: string): void {
27
97
  const wtStandardPkg = join(worktreePath, 'standard', 'package.json');
28
98
  const wtRefImplPkg = join(worktreePath, 'reference-impl', 'package.json');
29
99
 
@@ -33,13 +103,17 @@ export function prepWorktree(mainRoot: string, worktreePath: string): void {
33
103
  if (!existsSync(wtRefImplPkg)) {
34
104
  throw new Error(`worktree missing reference-impl/package.json at ${wtRefImplPkg}`);
35
105
  }
36
- if (!existsSync(mainStandardNm)) {
37
- throw new Error(`main missing standard/node_modules at ${mainStandardNm} run \`npm ci\` in main's standard/ first`);
38
- }
39
- if (!existsSync(mainRefImplNm)) {
40
- throw new Error(`main missing reference-impl/node_modules at ${mainRefImplNm} — run \`npm ci\` in main's reference-impl/ first`);
106
+
107
+ // Resolve the actual primary repo root: start from mainRoot and walk up until we find a
108
+ // directory containing both standard/node_modules and reference-impl/node_modules.
109
+ const resolvedMain = findPrimaryRoot(mainRoot);
110
+ if (resolvedMain === null) {
111
+ throw buildMissingNodeModulesError(mainRoot);
41
112
  }
42
113
 
114
+ const mainStandardNm = join(resolvedMain, 'standard', 'node_modules');
115
+ const mainRefImplNm = join(resolvedMain, 'reference-impl', 'node_modules');
116
+
43
117
  const wtStandardNm = join(worktreePath, 'standard', 'node_modules');
44
118
  const wtRefImplNm = join(worktreePath, 'reference-impl', 'node_modules');
45
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloverleaf/reference-impl",
3
- "version": "0.6.0",
3
+ "version": "0.6.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",
package/prompts/qa.md CHANGED
@@ -24,10 +24,13 @@ The Standard's QA contract requires a `preview_uri`. You were passed the sentine
24
24
  '@cloverleaf/standard/validators/index.js'` because git worktrees don't inherit node_modules.)
25
25
  ```bash
26
26
  TMPDIR=$(mktemp -d)
27
- git worktree add "$TMPDIR" {{branch}}
27
+ SHA=$(git rev-parse {{branch}})
28
+ git worktree add --detach "$TMPDIR" "$SHA"
28
29
  cloverleaf-cli prep-worktree {{repo_root}} "$TMPDIR"
29
30
  ```
30
31
 
32
+ Use `--detach` with a SHA rather than a branch name: the calling context (e.g., the walker) may already have `{{branch}}` 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.
33
+
31
34
  2. Inspect the changed files (from the diff). For each QA rule whose `match` patterns match ≥1 changed file, queue its command.
32
35
 
33
36
  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,7 +45,8 @@ A `pass` verdict MAY have an empty `findings` array or omit it. A `bounce` verdi
45
45
 
46
46
  ```bash
47
47
  MAIN=$(pwd)
48
- git worktree add /tmp/cl-review-<task-id> cloverleaf/<task-id>
48
+ SHA=$(git rev-parse cloverleaf/<task-id>)
49
+ git worktree add --detach /tmp/cl-review-<task-id> "$SHA"
49
50
  cloverleaf-cli prep-worktree "$MAIN" /tmp/cl-review-<task-id>
50
51
  cd /tmp/cl-review-<task-id>/reference-impl
51
52
  npm test
@@ -53,6 +54,8 @@ A `pass` verdict MAY have an empty `findings` array or omit it. A `bounce` verdi
53
54
  git worktree remove /tmp/cl-review-<task-id>
54
55
  ```
55
56
 
57
+ 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.
58
+
56
59
  This keeps `.cloverleaf/` on main intact.
57
60
  - Severities (per the Cloverleaf feedback schema): `blocker` = wrong behavior / missing AC / broken tests; `error` = notable defect that should be fixed but doesn't break AC; `warning` = should fix; `info` = nit / style. Use `blocker` and `error` for bounces.
58
61
  - If a criterion is subjective, lean toward pass — the task author chose those words deliberately.
@@ -17,10 +17,10 @@ You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes at mult
17
17
 
18
18
  You operate in two filesystem locations — keep them straight:
19
19
 
20
- - `<worktree>` — the ephemeral worktree at `$TMPDIR` (set up in step 2 of the Runtime procedure). You run the dev server here and execute Playwright here.
20
+ - `<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.
21
21
  - `<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.
22
22
 
23
- **All `compareVisual` paths MUST be rooted at `{{repo_root}}`, NOT at `$TMPDIR`.**
23
+ **All `compareVisual` paths MUST be rooted at `{{repo_root}}`, NOT at `$WT`.**
24
24
 
25
25
  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.
26
26
 
@@ -71,19 +71,21 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
71
71
 
72
72
  2. Set up an isolated worktree of the feature branch:
73
73
  ```bash
74
- TMPDIR=$(mktemp -d)
75
- git worktree add "$TMPDIR" {{branch}}
76
- npx cloverleaf-cli prep-worktree {{repo_root}} "$TMPDIR"
74
+ WT=$(mktemp -d)
75
+ git worktree add "$WT" {{branch}}
76
+ npx cloverleaf-cli prep-worktree {{repo_root}} "$WT"
77
77
  ```
78
78
 
79
79
  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:
80
80
  ```bash
81
- cd "$TMPDIR/site"
81
+ cd "$WT/site"
82
82
  npm ci
83
83
  npm run dev -- --port={{preview_port}} &
84
84
  SERVER_PID=$!
85
85
  ```
86
86
 
87
+ > **Playwright script placement (Bug #3 fix):** If you need to write a standalone `.mjs` driver script at any point, place it **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 outside the worktree (where `node_modules/playwright` was installed by `npm ci`) cannot resolve the `playwright` import and will fail.
88
+
87
89
  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`.
88
90
 
89
91
  5. Determine the site base path:
@@ -113,7 +115,7 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
113
115
  - Navigate to `http://localhost:{{preview_port}}<base><route>`. If 404, retry without the base.
114
116
  - `page.screenshot({ fullPage: false })` → candidate PNG buffer.
115
117
  - Compute slug for the route (lowercase, strip leading/trailing slashes, replace slashes with hyphens; `/` → `index`).
116
- - Note: use `{{repo_root}}` (the absolute main-repo path), NOT `$TMPDIR` or the worktree. See the "Paths" section.
118
+ - Note: use `{{repo_root}}` (the absolute main-repo path), NOT `$WT` or the worktree. See the "Paths" section.
117
119
  - Call `compareVisual` (from `lib/visual-diff.ts`) with:
118
120
  - `baselinePath = {{repo_root}}/.cloverleaf/baselines/{browser}/{slug}-{viewport}.png`
119
121
  - `candidateBuf = <candidate PNG>`
@@ -174,7 +176,7 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
174
176
  ```bash
175
177
  kill $SERVER_PID 2>/dev/null || true
176
178
  cd {{repo_root}}
177
- git worktree remove --force "$TMPDIR"
179
+ git worktree remove --force "$WT"
178
180
  ```
179
181
 
180
182
  ## Tool constraints
@@ -77,7 +77,8 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
77
77
  Per ready task:
78
78
 
79
79
  ```bash
80
- WT="/tmp/walker-<PLAN-ID>-<TASK-ID>"
80
+ WT="${XDG_CACHE_HOME:-$HOME/.cache}/cloverleaf/walker/<PLAN-ID>-<TASK-ID>"
81
+ mkdir -p "$(dirname "$WT")"
81
82
  rm -rf "$WT" # idempotent: clean any leftover from a prior run
82
83
  git -C <repo_root> worktree add "$WT" -b cloverleaf/<TASK-ID> main
83
84
  ```
@@ -118,12 +119,24 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
118
119
  ```
119
120
  2. Read the user's response.
120
121
  3. If it matches `^y(es)?$|^Y(ES)?$` → perform the merge in the primary repo:
122
+
123
+ First, **guard against conflict markers** — scan every file changed on the task branch for unresolved conflict markers before attempting the merge:
121
124
  ```bash
122
125
  cd <repo_root>
123
126
  git checkout main
124
- git merge --no-ff cloverleaf/<TASK-ID> -m "cloverleaf: <TASK-ID> merged (<fast_lane | full_pipeline>)"
127
+ CHANGED_FILES=$(git diff --name-only main..cloverleaf/<TASK-ID>)
128
+ if [ -n "$CHANGED_FILES" ] && echo "$CHANGED_FILES" | xargs grep -l -E '^(<{7}|={7}|>{7})' 2>/dev/null | grep -q .; then
129
+ echo "ERROR: conflict markers found in changed files — aborting merge for <TASK-ID>"
130
+ echo "$CHANGED_FILES" | xargs grep -l -E '^(<{7}|={7}|>{7})' 2>/dev/null
131
+ # Do NOT proceed; mark task escalated and surface to user
132
+ else
133
+ git merge --no-ff cloverleaf/<TASK-ID> -m "cloverleaf: <TASK-ID> merged (<fast_lane | full_pipeline>)"
134
+ fi
125
135
  ```
126
- Then advance state and commit:
136
+
137
+ If conflict markers are found, abort the merge: mark task `state: "escalated"` in walk-state, surface to the user with the list of affected files, and do NOT advance state. Continue with the next queued task.
138
+
139
+ After a **successful** `git merge --no-ff`, advance state and commit:
127
140
  ```bash
128
141
  # Fast lane:
129
142
  cloverleaf-cli emit-gate-decision <repo_root> <TASK-ID> human_merge approve human
@@ -135,7 +148,19 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
135
148
  ```bash
136
149
  git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> merged"
137
150
  ```
138
- Capture the merge commit SHA. Mark task `state: "merged"` with `merge_commit` in walk-state. Send `y` (informational) back to Session B so it can record the outcome and exit, but the walker is the authoritative merge-performer.
151
+ Capture the merge commit SHA:
152
+ ```bash
153
+ MERGE_COMMIT=$(git rev-parse HEAD)
154
+ ```
155
+ Immediately update walk-state to record the successful merge (bug #7 fix — walk-state must reflect `merged` state):
156
+ ```bash
157
+ # Write a temporary walk-state JSON with state: "merged" and merge_commit, then persist atomically
158
+ # (build the updated walk-state object in-memory and call walk-state-write)
159
+ cloverleaf-cli walk-state-write <repo_root> <updated-walk-state-json-path>
160
+ # The updated walk-state sets tasks["<TASK-ID>"].state = "merged"
161
+ # and tasks["<TASK-ID>"].merge_commit = "$MERGE_COMMIT"
162
+ ```
163
+ Send `y` (informational) back to Session B so it can record the outcome and exit, but the walker is the authoritative merge-performer.
139
164
  **Tear down the worktree**: `git -C <repo_root> worktree remove --force <worktree_path>`. Delete the branch is optional (keep if useful for post-hoc inspection).
140
165
  4. If it matches `^n(o)?$|^N(O)?$` → mark task `state: "awaiting_final_gate"`. Send `n` to Session B. **Keep the worktree** so the user can re-run `/cloverleaf-merge <TASK-ID>` manually pointing at it, or fix and retry. Continue with the next queued task.
141
166
  5. Otherwise → forward the user's text as a user turn to Session B via `mcp__claw-drive__send_turn` (it's a question). Wait for the session's next `turn_completed`. Print the answer. **Re-surface the same y/N prompt** (with the Q&A appended to shown context). Loop until step 3 or 4 fires.
package/dist/state.mjs DELETED
@@ -1,97 +0,0 @@
1
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { createRequire } from 'node:module';
4
- import { randomUUID } from 'node:crypto';
5
- import { tasksDir, projectsDir } from './paths.mjs';
6
- import { emitStatusTransition, formatReason } from './events.mjs';
7
- // Import validator from @cloverleaf/standard.
8
- // The standard package ships TypeScript source only with no exports map.
9
- // Vitest (via vite-node) resolves .js → .ts for workspace symlinked packages,
10
- // so the .js convention works here. If it ever fails with "module not found",
11
- // switch the specifier to '@cloverleaf/standard/validators/index.ts'.
12
- import { validateStatusTransitionLegality } from '@cloverleaf/standard/validators/index.js';
13
- import { validateOrThrow } from './validate.mjs';
14
- const req = createRequire(import.meta.url);
15
- export function loadTask(repoRoot, taskId) {
16
- const path = join(tasksDir(repoRoot), `${taskId}.json`);
17
- if (!existsSync(path))
18
- throw new Error(`Task ${taskId} not found at ${path}`);
19
- return JSON.parse(readFileSync(path, 'utf-8'));
20
- }
21
- export function saveTask(repoRoot, task) {
22
- validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
23
- const path = join(tasksDir(repoRoot), `${task.id}.json`);
24
- writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
25
- }
26
- export function loadProject(repoRoot, projectId) {
27
- const path = join(projectsDir(repoRoot), `${projectId}.json`);
28
- if (!existsSync(path))
29
- throw new Error(`Project ${projectId} not found at ${path}`);
30
- return JSON.parse(readFileSync(path, 'utf-8'));
31
- }
32
- function loadTaskStateMachine() {
33
- // state-machines/task.json is a static JSON asset. Navigate from standard's
34
- // package.json — no exports map support needed.
35
- const pkgPath = req.resolve('@cloverleaf/standard/package.json');
36
- const pkgDir = pkgPath.replace(/\/package\.json$/, '');
37
- return JSON.parse(readFileSync(`${pkgDir}/state-machines/task.json`, 'utf-8'));
38
- }
39
- export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
40
- const task = loadTask(repoRoot, taskId);
41
- const from = task.status;
42
- const sm = loadTaskStateMachine();
43
- // Read risk_class directly from the task (defaulting to 'low' if absent).
44
- // The validator derives itemPath from workItem.risk_class: low → fast_lane, else full_pipeline.
45
- // If caller passed options.path, translate it back to risk_class for the validator.
46
- const riskClass = options.path === 'fast_lane' ? 'low'
47
- : options.path === 'full_pipeline' ? 'high'
48
- : (task.risk_class ?? 'low');
49
- // Build a minimal Task-shaped object so the validator can resolve path-tagged transitions.
50
- const workItemForValidator = {
51
- type: 'task',
52
- id: task.id,
53
- project: task.project,
54
- status: task.status,
55
- risk_class: riskClass,
56
- context: { rfc: { project: task.project, id: task.id } },
57
- definition_of_done: task.definition_of_done,
58
- acceptance_criteria: task.acceptance_criteria,
59
- };
60
- const reason = formatReason({ gate: options.gate, path: options.path });
61
- const event = {
62
- event_id: randomUUID(),
63
- event_type: 'status_transition',
64
- occurred_at: new Date().toISOString(),
65
- work_item_id: { project: task.project, id: task.id },
66
- work_item_type: 'task',
67
- from_status: from,
68
- to_status: toStatus,
69
- actor: { kind: actor, id: actor },
70
- ...(reason ? { reason } : {}),
71
- };
72
- const result = validateStatusTransitionLegality(event, sm, workItemForValidator);
73
- if (!result.ok) {
74
- const msgs = result.violations.map((v) => v.message).join('; ');
75
- throw new Error(`Illegal transition ${from} → ${toStatus}: ${msgs}`);
76
- }
77
- // NEW: emit first, save second. validateStatusTransitionLegality stays above.
78
- const emittedPath = emitStatusTransition(repoRoot, {
79
- project: task.project,
80
- workItemType: 'task',
81
- workItemId: task.id,
82
- from,
83
- to: toStatus,
84
- actor,
85
- gate: options.gate,
86
- path: options.path,
87
- });
88
- const proposed = { ...task, status: toStatus };
89
- try {
90
- saveTask(repoRoot, proposed);
91
- }
92
- catch (err) {
93
- const inner = err instanceof Error ? err.message : String(err);
94
- throw new Error(`orphan event written to ${emittedPath} but task save failed: ${inner}`);
95
- }
96
- return proposed;
97
- }