@cloverleaf/reference-impl 0.5.5 → 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/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/VERSION +1 -1
- package/dist/cli.mjs +88 -2
- package/dist/dag-walker.mjs +93 -0
- package/dist/events.mjs +11 -6
- package/dist/ids.mjs +8 -2
- package/dist/index.mjs +2 -0
- package/dist/prep-worktree.mjs +73 -8
- package/dist/walk-state.mjs +31 -0
- package/lib/cli.ts +94 -2
- package/lib/dag-walker.ts +133 -0
- package/lib/events.ts +11 -6
- package/lib/ids.ts +8 -2
- package/lib/index.ts +2 -0
- package/lib/prep-worktree.ts +82 -8
- package/lib/walk-state.ts +39 -0
- package/package.json +2 -1
- package/prompts/qa.md +4 -1
- package/prompts/reviewer.md +4 -1
- package/prompts/ui-reviewer.md +10 -8
- package/skills/cloverleaf-merge/SKILL.md +9 -1
- package/skills/cloverleaf-run-plan/SKILL.md +228 -0
- package/dist/state.mjs +0 -97
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { PlanDoc } from './plan.js';
|
|
2
|
+
|
|
3
|
+
export interface WalkState {
|
|
4
|
+
plan_id: string;
|
|
5
|
+
started: string;
|
|
6
|
+
max_concurrent: number;
|
|
7
|
+
tasks: Record<
|
|
8
|
+
string,
|
|
9
|
+
| { state: 'pending' }
|
|
10
|
+
| { state: 'running'; session_id: string; started_at: string; last_seq: number }
|
|
11
|
+
| {
|
|
12
|
+
state: 'awaiting_final_gate';
|
|
13
|
+
session_id: string;
|
|
14
|
+
started_at: string;
|
|
15
|
+
last_seq: number;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
state: 'merged';
|
|
19
|
+
session_id: string;
|
|
20
|
+
merged_at: string;
|
|
21
|
+
merge_commit: string;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
state: 'escalated';
|
|
25
|
+
session_id: string;
|
|
26
|
+
escalated_at: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
}
|
|
29
|
+
>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Scheduler for the DAG walker. Given a Plan and the current walk state, returns the
|
|
34
|
+
* task IDs that are safe to spawn a Session B for right now:
|
|
35
|
+
*
|
|
36
|
+
* 1. Status is effectively `pending` (no state recorded, or recorded as `pending`).
|
|
37
|
+
* 2. Every ancestor in the task_dag has recorded state === 'merged'.
|
|
38
|
+
* 3. The count of returned IDs is capped at `maxConcurrent - currentlyRunning`.
|
|
39
|
+
*
|
|
40
|
+
* Order is deterministic (sorted ascending by task id) so callers can be reproducible.
|
|
41
|
+
*/
|
|
42
|
+
export function computeReadyTasks(
|
|
43
|
+
plan: PlanDoc,
|
|
44
|
+
walkState: WalkState,
|
|
45
|
+
maxConcurrent: number,
|
|
46
|
+
): string[] {
|
|
47
|
+
const running = Object.values(walkState.tasks).filter(
|
|
48
|
+
(t) => t.state === 'running' || t.state === 'awaiting_final_gate',
|
|
49
|
+
).length;
|
|
50
|
+
|
|
51
|
+
const slots = Math.max(0, maxConcurrent - running);
|
|
52
|
+
if (slots === 0) return [];
|
|
53
|
+
|
|
54
|
+
const parents: Record<string, string[]> = {};
|
|
55
|
+
for (const node of plan.task_dag.nodes) {
|
|
56
|
+
parents[node.id] = [];
|
|
57
|
+
}
|
|
58
|
+
for (const edge of plan.task_dag.edges) {
|
|
59
|
+
const to = edge.to.id;
|
|
60
|
+
const from = edge.from.id;
|
|
61
|
+
if (!parents[to]) parents[to] = [];
|
|
62
|
+
parents[to].push(from);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ready: string[] = [];
|
|
66
|
+
const allIds = plan.task_dag.nodes.map((n) => n.id).sort();
|
|
67
|
+
for (const id of allIds) {
|
|
68
|
+
const current = walkState.tasks[id];
|
|
69
|
+
const isPending = !current || current.state === 'pending';
|
|
70
|
+
if (!isPending) continue;
|
|
71
|
+
|
|
72
|
+
const ancestorsMerged = (parents[id] ?? []).every(
|
|
73
|
+
(p) => walkState.tasks[p]?.state === 'merged',
|
|
74
|
+
);
|
|
75
|
+
if (!ancestorsMerged) continue;
|
|
76
|
+
|
|
77
|
+
ready.push(id);
|
|
78
|
+
if (ready.length >= slots) break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return ready;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns `null` if the Plan's task_dag is acyclic. If a cycle is present, returns
|
|
86
|
+
* `{ cycle: string[] }` naming the task IDs involved in one cycle (order is the
|
|
87
|
+
* traversal order, which is a valid witness of the cycle).
|
|
88
|
+
*
|
|
89
|
+
* Uses Tarjan-style DFS with a white/grey/black colouring.
|
|
90
|
+
*/
|
|
91
|
+
export function detectCycle(plan: PlanDoc): { cycle: string[] } | null {
|
|
92
|
+
const adj: Record<string, string[]> = {};
|
|
93
|
+
for (const node of plan.task_dag.nodes) adj[node.id] = [];
|
|
94
|
+
for (const edge of plan.task_dag.edges) {
|
|
95
|
+
const from = edge.from.id;
|
|
96
|
+
if (!adj[from]) adj[from] = [];
|
|
97
|
+
adj[from].push(edge.to.id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const WHITE = 0;
|
|
101
|
+
const GREY = 1;
|
|
102
|
+
const BLACK = 2;
|
|
103
|
+
const color: Record<string, number> = {};
|
|
104
|
+
for (const id of Object.keys(adj)) color[id] = WHITE;
|
|
105
|
+
|
|
106
|
+
const path: string[] = [];
|
|
107
|
+
|
|
108
|
+
function dfs(id: string): string[] | null {
|
|
109
|
+
color[id] = GREY;
|
|
110
|
+
path.push(id);
|
|
111
|
+
for (const next of adj[id] ?? []) {
|
|
112
|
+
if (color[next] === GREY) {
|
|
113
|
+
const start = path.indexOf(next);
|
|
114
|
+
return path.slice(start);
|
|
115
|
+
}
|
|
116
|
+
if (color[next] === WHITE) {
|
|
117
|
+
const cycle = dfs(next);
|
|
118
|
+
if (cycle) return cycle;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
color[id] = BLACK;
|
|
122
|
+
path.pop();
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const id of Object.keys(adj)) {
|
|
127
|
+
if (color[id] === WHITE) {
|
|
128
|
+
const cycle = dfs(id);
|
|
129
|
+
if (cycle) return { cycle };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
package/lib/events.ts
CHANGED
|
@@ -40,16 +40,20 @@ export function formatReason(opts: { gate?: string; path?: string }): string | u
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Emits a status-transition event to `.cloverleaf/events/`.
|
|
43
|
-
* File name: `<
|
|
43
|
+
* File name: `<workItemId>-<NNN>-status.json` where NNN is the next per-work-item
|
|
44
44
|
* sequential number derived from existing event files.
|
|
45
45
|
*
|
|
46
|
+
* The filename scopes the counter to a single work item (v0.6 change), so
|
|
47
|
+
* parallel Delivery sessions on sibling tasks produce non-colliding event
|
|
48
|
+
* filenames that merge cleanly.
|
|
49
|
+
*
|
|
46
50
|
* Returns the absolute path of the written file.
|
|
47
51
|
*/
|
|
48
52
|
export function emitStatusTransition(repoRoot: string, params: StatusTransitionParams): string {
|
|
49
53
|
const { project, workItemType, workItemId, from, to, actor } = params;
|
|
50
|
-
const seq = nextEventId(repoRoot,
|
|
54
|
+
const seq = nextEventId(repoRoot, workItemId);
|
|
51
55
|
const seqStr = String(seq).padStart(3, '0');
|
|
52
|
-
const filename = `${
|
|
56
|
+
const filename = `${workItemId}-${seqStr}-status.json`;
|
|
53
57
|
const filePath = join(eventsDir(repoRoot), filename);
|
|
54
58
|
|
|
55
59
|
// Build reason from gate and/or path if provided (schema only allows reason, not gate/path at top level).
|
|
@@ -77,15 +81,16 @@ export function emitStatusTransition(repoRoot: string, params: StatusTransitionP
|
|
|
77
81
|
|
|
78
82
|
/**
|
|
79
83
|
* Emits a gate-decision event to `.cloverleaf/events/`.
|
|
80
|
-
* File name: `<
|
|
84
|
+
* File name: `<workItemId>-<NNN>-gate.json`. Counter is scoped to the work item
|
|
85
|
+
* (v0.6 change — see `emitStatusTransition` for rationale).
|
|
81
86
|
*
|
|
82
87
|
* Returns the absolute path of the written file.
|
|
83
88
|
*/
|
|
84
89
|
export function emitGateDecision(repoRoot: string, params: GateDecisionParams): string {
|
|
85
90
|
const { project, workItemId, gate, decision, actor, reasoning } = params;
|
|
86
|
-
const seq = nextEventId(repoRoot,
|
|
91
|
+
const seq = nextEventId(repoRoot, workItemId);
|
|
87
92
|
const seqStr = String(seq).padStart(3, '0');
|
|
88
|
-
const filename = `${
|
|
93
|
+
const filename = `${workItemId}-${seqStr}-gate.json`;
|
|
89
94
|
const filePath = join(eventsDir(repoRoot), filename);
|
|
90
95
|
|
|
91
96
|
const doc: Record<string, unknown> = {
|
package/lib/ids.ts
CHANGED
|
@@ -13,10 +13,16 @@ export function nextTaskId(repoRoot: string, project: string): string {
|
|
|
13
13
|
return `${project}-${String(next).padStart(3, '0')}`;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function nextEventId(repoRoot: string,
|
|
16
|
+
export function nextEventId(repoRoot: string, workItemId: string): number {
|
|
17
17
|
const dir = eventsDir(repoRoot);
|
|
18
18
|
if (!existsSync(dir)) return 1;
|
|
19
|
-
|
|
19
|
+
// Per-work-item sequence. Filenames are `<workItemId>-<NNN>-<status|gate>.json`,
|
|
20
|
+
// which keeps counters scoped to a single task / RFC / Spike / Plan — this matters
|
|
21
|
+
// for the v0.6 DAG walker's parallel mode, where multiple worktrees emit events
|
|
22
|
+
// simultaneously. A global per-project counter (the pre-v0.6 scheme) produced
|
|
23
|
+
// filename collisions when the walker merged sibling feature branches. Per-work-item
|
|
24
|
+
// scoping means each task's counter is independent; merges union cleanly.
|
|
25
|
+
const re = new RegExp(`^${escapeRegex(workItemId)}-(\\d+)-(status|gate)\\.json$`);
|
|
20
26
|
const nums = readdirSync(dir)
|
|
21
27
|
.map((f) => f.match(re))
|
|
22
28
|
.filter((m): m is RegExpMatchArray => !!m)
|
package/lib/index.ts
CHANGED
package/lib/prep-worktree.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import type { WalkState } from './dag-walker.js';
|
|
11
|
+
|
|
12
|
+
export function walkStatePath(repoRoot: string, planId: string): string {
|
|
13
|
+
return join(repoRoot, '.cloverleaf', 'runs', 'plan', planId, 'walk-state.json');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readWalkState(repoRoot: string, planId: string): WalkState | null {
|
|
17
|
+
const path = walkStatePath(repoRoot, planId);
|
|
18
|
+
if (!existsSync(path)) return null;
|
|
19
|
+
const raw = readFileSync(path, 'utf-8');
|
|
20
|
+
return JSON.parse(raw) as WalkState;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeWalkState(repoRoot: string, state: WalkState): void {
|
|
24
|
+
const path = walkStatePath(repoRoot, state.plan_id);
|
|
25
|
+
const dir = dirname(path);
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
28
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
|
|
29
|
+
try {
|
|
30
|
+
renameSync(tmp, path);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
try {
|
|
33
|
+
unlinkSync(tmp);
|
|
34
|
+
} catch {
|
|
35
|
+
// best effort
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "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",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"test:watch": "vitest",
|
|
45
45
|
"typecheck": "tsc --noEmit",
|
|
46
46
|
"build": "tsc -p tsconfig.build.json && node scripts/rename-to-mjs.mjs",
|
|
47
|
+
"acceptance:walker": "bash scripts/acceptance-walker.sh",
|
|
47
48
|
"prepublishOnly": "npm test && npm run build"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
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
|
|
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:
|
package/prompts/reviewer.md
CHANGED
|
@@ -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
|
|
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.
|
package/prompts/ui-reviewer.md
CHANGED
|
@@ -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 `$
|
|
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 `$
|
|
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
|
-
|
|
75
|
-
git worktree add "$
|
|
76
|
-
npx cloverleaf-cli prep-worktree {{repo_root}} "$
|
|
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 "$
|
|
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 `$
|
|
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 "$
|
|
179
|
+
git worktree remove --force "$WT"
|
|
178
180
|
```
|
|
179
181
|
|
|
180
182
|
## Tool constraints
|
|
@@ -84,8 +84,16 @@ Report:
|
|
|
84
84
|
|
|
85
85
|
## Rules
|
|
86
86
|
|
|
87
|
-
- Only proceed on explicit `y
|
|
87
|
+
- Only proceed on explicit `y/Y/yes/YES`. Anything else is treated as either a decline (`n/N/no/NO`) or a clarifying question (see below).
|
|
88
88
|
- The skill does NOT push the branch or open a PR.
|
|
89
89
|
- Fast lane and full pipeline use distinct gates — the state machine records which path was taken.
|
|
90
90
|
- Full-pipeline merges perform a real `git merge --no-ff` before advancing state — the feature branch's code, baselines, and feedback commits all land on main.
|
|
91
91
|
- If the user declines, no state change and no commit.
|
|
92
|
+
|
|
93
|
+
## Clarifying questions at final-gate
|
|
94
|
+
|
|
95
|
+
If the user's response to the "Confirm merge? (y/N)" prompt is **not** one of `y/Y/yes/YES` or `n/N/no/NO`, treat it as a clarifying question. Answer it from the pipeline context available to you — the Reviewer/UI Reviewer/QA summaries, the diff, the task's ACs, the feedback files — and then **re-prompt** with the same y/N question.
|
|
96
|
+
|
|
97
|
+
Loop this until the user gives a definitive y or n. Do not perform the merge until you see `y/Y/yes/YES`. Do not mark the task declined until you see `n/N/no/NO`.
|
|
98
|
+
|
|
99
|
+
This keeps manual merges and walker-driven merges (`/cloverleaf-run-plan`) consistent: the user gets to interrogate the summary before committing.
|