@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloverleaf",
|
|
3
3
|
"description": "Cloverleaf reference implementation — Claude Code skills for task scaffolding and the Delivery pipeline (implementer, documenter, reviewer, UI reviewer with multi-viewport visual diff, QA, merge).",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Renato D'Arrigo",
|
|
7
7
|
"email": "renato.darrigo@gmail.com"
|
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/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.6.0
|
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>
|
|
@@ -29,8 +30,13 @@
|
|
|
29
30
|
* next-work-item-id <repoRoot> <project>
|
|
30
31
|
* discovery-config --repo-root <repoRoot>
|
|
31
32
|
* prep-worktree <mainRoot> <worktreePath>
|
|
33
|
+
* dag-ready-tasks <repoRoot> <planId> <maxConcurrent>
|
|
34
|
+
* dag-detect-cycle <repoRoot> <planId>
|
|
35
|
+
* walk-state-read <repoRoot> <planId>
|
|
36
|
+
* walk-state-write <repoRoot> <walkStateJsonPath>
|
|
32
37
|
*/
|
|
33
|
-
import { readFileSync } from 'node:fs';
|
|
38
|
+
import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
39
|
+
import { dirname } from 'node:path';
|
|
34
40
|
import { execSync } from 'node:child_process';
|
|
35
41
|
import { loadTask } from './task.mjs';
|
|
36
42
|
import { advanceStatus } from './task.mjs';
|
|
@@ -49,6 +55,9 @@ import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan } from
|
|
|
49
55
|
import { loadDiscoveryConfig } from './discovery-config.mjs';
|
|
50
56
|
import { prepWorktree } from './prep-worktree.mjs';
|
|
51
57
|
import { readUiReviewState, writeUiReviewState } from './ui-review-state.mjs';
|
|
58
|
+
import { buildBaselinePath } from './visual-diff.mjs';
|
|
59
|
+
import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
|
|
60
|
+
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.mjs';
|
|
52
61
|
function die(msg, code = 1) {
|
|
53
62
|
process.stderr.write(msg + '\n');
|
|
54
63
|
process.exit(code);
|
|
@@ -68,6 +77,7 @@ function usage(msg) {
|
|
|
68
77
|
' ui-review-config --repo-root <repoRoot>\n' +
|
|
69
78
|
' read-ui-review-state <repoRoot> <taskId>\n' +
|
|
70
79
|
' write-ui-review-state <repoRoot> <taskId> <baselines_pending>\n' +
|
|
80
|
+
' write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>\n' +
|
|
71
81
|
' plugin-root\n' +
|
|
72
82
|
' load-rfc <repoRoot> <id>\n' +
|
|
73
83
|
' save-rfc <repoRoot> <filePath>\n' +
|
|
@@ -81,7 +91,11 @@ function usage(msg) {
|
|
|
81
91
|
' materialise-tasks <repoRoot> <planId>\n' +
|
|
82
92
|
' next-work-item-id <repoRoot> <project>\n' +
|
|
83
93
|
' discovery-config --repo-root <repoRoot>\n' +
|
|
84
|
-
' prep-worktree <mainRoot> <worktreePath>\n'
|
|
94
|
+
' prep-worktree <mainRoot> <worktreePath>\n' +
|
|
95
|
+
' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
|
|
96
|
+
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
97
|
+
' walk-state-read <repoRoot> <planId>\n' +
|
|
98
|
+
' walk-state-write <repoRoot> <walkStateJsonPath>\n');
|
|
85
99
|
process.exit(2);
|
|
86
100
|
}
|
|
87
101
|
const [, , command, ...rest] = process.argv;
|
|
@@ -290,6 +304,25 @@ try {
|
|
|
290
304
|
writeUiReviewState(repoRoot, taskId, { baselines_pending });
|
|
291
305
|
break;
|
|
292
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
|
+
}
|
|
293
326
|
case 'plugin-root': {
|
|
294
327
|
process.stdout.write(getPluginRoot());
|
|
295
328
|
process.exit(0);
|
|
@@ -399,6 +432,59 @@ try {
|
|
|
399
432
|
prepWorktree(mainRoot, worktreePath);
|
|
400
433
|
break;
|
|
401
434
|
}
|
|
435
|
+
case 'dag-ready-tasks': {
|
|
436
|
+
const [repoRoot, planId, maxConcurrentStr] = rest;
|
|
437
|
+
if (!repoRoot || !planId || !maxConcurrentStr)
|
|
438
|
+
usage('dag-ready-tasks requires <repoRoot> <planId> <maxConcurrent>');
|
|
439
|
+
const maxConcurrent = parseInt(maxConcurrentStr, 10);
|
|
440
|
+
if (Number.isNaN(maxConcurrent) || maxConcurrent < 1)
|
|
441
|
+
die(`maxConcurrent must be a positive integer, got ${maxConcurrentStr}`);
|
|
442
|
+
const plan = loadPlan(repoRoot, planId);
|
|
443
|
+
const state = readWalkState(repoRoot, planId) ?? {
|
|
444
|
+
plan_id: planId,
|
|
445
|
+
started: new Date().toISOString(),
|
|
446
|
+
max_concurrent: maxConcurrent,
|
|
447
|
+
tasks: {},
|
|
448
|
+
};
|
|
449
|
+
const ready = computeReadyTasks(plan, state, maxConcurrent);
|
|
450
|
+
if (ready.length > 0)
|
|
451
|
+
process.stdout.write(ready.join('\n') + '\n');
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
case 'dag-detect-cycle': {
|
|
455
|
+
const [repoRoot, planId] = rest;
|
|
456
|
+
if (!repoRoot || !planId)
|
|
457
|
+
usage('dag-detect-cycle requires <repoRoot> <planId>');
|
|
458
|
+
const plan = loadPlan(repoRoot, planId);
|
|
459
|
+
const cycle = detectCycle(plan);
|
|
460
|
+
if (cycle) {
|
|
461
|
+
process.stdout.write(cycle.cycle.join(' → ') + '\n');
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case 'walk-state-read': {
|
|
467
|
+
const [repoRoot, planId] = rest;
|
|
468
|
+
if (!repoRoot || !planId)
|
|
469
|
+
usage('walk-state-read requires <repoRoot> <planId>');
|
|
470
|
+
const state = readWalkState(repoRoot, planId);
|
|
471
|
+
if (state === null) {
|
|
472
|
+
die(`walk-state not found for plan ${planId} at ${walkStatePath(repoRoot, planId)}`, 2);
|
|
473
|
+
}
|
|
474
|
+
process.stdout.write(JSON.stringify(state, null, 2) + '\n');
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case 'walk-state-write': {
|
|
478
|
+
const [repoRoot, walkStateJsonPath] = rest;
|
|
479
|
+
if (!repoRoot || !walkStateJsonPath)
|
|
480
|
+
usage('walk-state-write requires <repoRoot> <walkStateJsonPath>');
|
|
481
|
+
const raw = readFileSync(walkStateJsonPath, 'utf-8');
|
|
482
|
+
const state = JSON.parse(raw);
|
|
483
|
+
if (!state.plan_id || typeof state.plan_id !== 'string')
|
|
484
|
+
die('walk-state JSON must include plan_id (string)');
|
|
485
|
+
writeWalkState(repoRoot, state);
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
402
488
|
default:
|
|
403
489
|
usage(`Unknown command: ${command}`);
|
|
404
490
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler for the DAG walker. Given a Plan and the current walk state, returns the
|
|
3
|
+
* task IDs that are safe to spawn a Session B for right now:
|
|
4
|
+
*
|
|
5
|
+
* 1. Status is effectively `pending` (no state recorded, or recorded as `pending`).
|
|
6
|
+
* 2. Every ancestor in the task_dag has recorded state === 'merged'.
|
|
7
|
+
* 3. The count of returned IDs is capped at `maxConcurrent - currentlyRunning`.
|
|
8
|
+
*
|
|
9
|
+
* Order is deterministic (sorted ascending by task id) so callers can be reproducible.
|
|
10
|
+
*/
|
|
11
|
+
export function computeReadyTasks(plan, walkState, maxConcurrent) {
|
|
12
|
+
const running = Object.values(walkState.tasks).filter((t) => t.state === 'running' || t.state === 'awaiting_final_gate').length;
|
|
13
|
+
const slots = Math.max(0, maxConcurrent - running);
|
|
14
|
+
if (slots === 0)
|
|
15
|
+
return [];
|
|
16
|
+
const parents = {};
|
|
17
|
+
for (const node of plan.task_dag.nodes) {
|
|
18
|
+
parents[node.id] = [];
|
|
19
|
+
}
|
|
20
|
+
for (const edge of plan.task_dag.edges) {
|
|
21
|
+
const to = edge.to.id;
|
|
22
|
+
const from = edge.from.id;
|
|
23
|
+
if (!parents[to])
|
|
24
|
+
parents[to] = [];
|
|
25
|
+
parents[to].push(from);
|
|
26
|
+
}
|
|
27
|
+
const ready = [];
|
|
28
|
+
const allIds = plan.task_dag.nodes.map((n) => n.id).sort();
|
|
29
|
+
for (const id of allIds) {
|
|
30
|
+
const current = walkState.tasks[id];
|
|
31
|
+
const isPending = !current || current.state === 'pending';
|
|
32
|
+
if (!isPending)
|
|
33
|
+
continue;
|
|
34
|
+
const ancestorsMerged = (parents[id] ?? []).every((p) => walkState.tasks[p]?.state === 'merged');
|
|
35
|
+
if (!ancestorsMerged)
|
|
36
|
+
continue;
|
|
37
|
+
ready.push(id);
|
|
38
|
+
if (ready.length >= slots)
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
return ready;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Returns `null` if the Plan's task_dag is acyclic. If a cycle is present, returns
|
|
45
|
+
* `{ cycle: string[] }` naming the task IDs involved in one cycle (order is the
|
|
46
|
+
* traversal order, which is a valid witness of the cycle).
|
|
47
|
+
*
|
|
48
|
+
* Uses Tarjan-style DFS with a white/grey/black colouring.
|
|
49
|
+
*/
|
|
50
|
+
export function detectCycle(plan) {
|
|
51
|
+
const adj = {};
|
|
52
|
+
for (const node of plan.task_dag.nodes)
|
|
53
|
+
adj[node.id] = [];
|
|
54
|
+
for (const edge of plan.task_dag.edges) {
|
|
55
|
+
const from = edge.from.id;
|
|
56
|
+
if (!adj[from])
|
|
57
|
+
adj[from] = [];
|
|
58
|
+
adj[from].push(edge.to.id);
|
|
59
|
+
}
|
|
60
|
+
const WHITE = 0;
|
|
61
|
+
const GREY = 1;
|
|
62
|
+
const BLACK = 2;
|
|
63
|
+
const color = {};
|
|
64
|
+
for (const id of Object.keys(adj))
|
|
65
|
+
color[id] = WHITE;
|
|
66
|
+
const path = [];
|
|
67
|
+
function dfs(id) {
|
|
68
|
+
color[id] = GREY;
|
|
69
|
+
path.push(id);
|
|
70
|
+
for (const next of adj[id] ?? []) {
|
|
71
|
+
if (color[next] === GREY) {
|
|
72
|
+
const start = path.indexOf(next);
|
|
73
|
+
return path.slice(start);
|
|
74
|
+
}
|
|
75
|
+
if (color[next] === WHITE) {
|
|
76
|
+
const cycle = dfs(next);
|
|
77
|
+
if (cycle)
|
|
78
|
+
return cycle;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
color[id] = BLACK;
|
|
82
|
+
path.pop();
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
for (const id of Object.keys(adj)) {
|
|
86
|
+
if (color[id] === WHITE) {
|
|
87
|
+
const cycle = dfs(id);
|
|
88
|
+
if (cycle)
|
|
89
|
+
return { cycle };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
package/dist/events.mjs
CHANGED
|
@@ -18,16 +18,20 @@ export function formatReason(opts) {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* Emits a status-transition event to `.cloverleaf/events/`.
|
|
21
|
-
* File name: `<
|
|
21
|
+
* File name: `<workItemId>-<NNN>-status.json` where NNN is the next per-work-item
|
|
22
22
|
* sequential number derived from existing event files.
|
|
23
23
|
*
|
|
24
|
+
* The filename scopes the counter to a single work item (v0.6 change), so
|
|
25
|
+
* parallel Delivery sessions on sibling tasks produce non-colliding event
|
|
26
|
+
* filenames that merge cleanly.
|
|
27
|
+
*
|
|
24
28
|
* Returns the absolute path of the written file.
|
|
25
29
|
*/
|
|
26
30
|
export function emitStatusTransition(repoRoot, params) {
|
|
27
31
|
const { project, workItemType, workItemId, from, to, actor } = params;
|
|
28
|
-
const seq = nextEventId(repoRoot,
|
|
32
|
+
const seq = nextEventId(repoRoot, workItemId);
|
|
29
33
|
const seqStr = String(seq).padStart(3, '0');
|
|
30
|
-
const filename = `${
|
|
34
|
+
const filename = `${workItemId}-${seqStr}-status.json`;
|
|
31
35
|
const filePath = join(eventsDir(repoRoot), filename);
|
|
32
36
|
// Build reason from gate and/or path if provided (schema only allows reason, not gate/path at top level).
|
|
33
37
|
const reason = formatReason({ gate: params.gate, path: params.path });
|
|
@@ -51,15 +55,16 @@ export function emitStatusTransition(repoRoot, params) {
|
|
|
51
55
|
}
|
|
52
56
|
/**
|
|
53
57
|
* Emits a gate-decision event to `.cloverleaf/events/`.
|
|
54
|
-
* File name: `<
|
|
58
|
+
* File name: `<workItemId>-<NNN>-gate.json`. Counter is scoped to the work item
|
|
59
|
+
* (v0.6 change — see `emitStatusTransition` for rationale).
|
|
55
60
|
*
|
|
56
61
|
* Returns the absolute path of the written file.
|
|
57
62
|
*/
|
|
58
63
|
export function emitGateDecision(repoRoot, params) {
|
|
59
64
|
const { project, workItemId, gate, decision, actor, reasoning } = params;
|
|
60
|
-
const seq = nextEventId(repoRoot,
|
|
65
|
+
const seq = nextEventId(repoRoot, workItemId);
|
|
61
66
|
const seqStr = String(seq).padStart(3, '0');
|
|
62
|
-
const filename = `${
|
|
67
|
+
const filename = `${workItemId}-${seqStr}-gate.json`;
|
|
63
68
|
const filePath = join(eventsDir(repoRoot), filename);
|
|
64
69
|
const doc = {
|
|
65
70
|
event_id: randomUUID(),
|
package/dist/ids.mjs
CHANGED
|
@@ -12,11 +12,17 @@ export function nextTaskId(repoRoot, project) {
|
|
|
12
12
|
const next = nums.length === 0 ? 1 : Math.max(...nums) + 1;
|
|
13
13
|
return `${project}-${String(next).padStart(3, '0')}`;
|
|
14
14
|
}
|
|
15
|
-
export function nextEventId(repoRoot,
|
|
15
|
+
export function nextEventId(repoRoot, workItemId) {
|
|
16
16
|
const dir = eventsDir(repoRoot);
|
|
17
17
|
if (!existsSync(dir))
|
|
18
18
|
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)
|
package/dist/index.mjs
CHANGED
package/dist/prep-worktree.mjs
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
|
* 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
throw
|
|
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
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export function walkStatePath(repoRoot, planId) {
|
|
4
|
+
return join(repoRoot, '.cloverleaf', 'runs', 'plan', planId, 'walk-state.json');
|
|
5
|
+
}
|
|
6
|
+
export function readWalkState(repoRoot, planId) {
|
|
7
|
+
const path = walkStatePath(repoRoot, planId);
|
|
8
|
+
if (!existsSync(path))
|
|
9
|
+
return null;
|
|
10
|
+
const raw = readFileSync(path, 'utf-8');
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
}
|
|
13
|
+
export function writeWalkState(repoRoot, state) {
|
|
14
|
+
const path = walkStatePath(repoRoot, state.plan_id);
|
|
15
|
+
const dir = dirname(path);
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
18
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
|
|
19
|
+
try {
|
|
20
|
+
renameSync(tmp, path);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
try {
|
|
24
|
+
unlinkSync(tmp);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// best effort
|
|
28
|
+
}
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
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>
|
|
@@ -29,9 +30,14 @@
|
|
|
29
30
|
* next-work-item-id <repoRoot> <project>
|
|
30
31
|
* discovery-config --repo-root <repoRoot>
|
|
31
32
|
* prep-worktree <mainRoot> <worktreePath>
|
|
33
|
+
* dag-ready-tasks <repoRoot> <planId> <maxConcurrent>
|
|
34
|
+
* dag-detect-cycle <repoRoot> <planId>
|
|
35
|
+
* walk-state-read <repoRoot> <planId>
|
|
36
|
+
* walk-state-write <repoRoot> <walkStateJsonPath>
|
|
32
37
|
*/
|
|
33
38
|
|
|
34
|
-
import { readFileSync } from 'node:fs';
|
|
39
|
+
import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
40
|
+
import { dirname } from 'node:path';
|
|
35
41
|
import { execSync } from 'node:child_process';
|
|
36
42
|
import { loadTask } from './task.js';
|
|
37
43
|
import { advanceStatus } from './task.js';
|
|
@@ -51,6 +57,9 @@ import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan, type P
|
|
|
51
57
|
import { loadDiscoveryConfig } from './discovery-config.js';
|
|
52
58
|
import { prepWorktree } from './prep-worktree.js';
|
|
53
59
|
import { readUiReviewState, writeUiReviewState } from './ui-review-state.js';
|
|
60
|
+
import { buildBaselinePath } from './visual-diff.js';
|
|
61
|
+
import { computeReadyTasks, detectCycle } from './dag-walker.js';
|
|
62
|
+
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.js';
|
|
54
63
|
|
|
55
64
|
function die(msg: string, code = 1): never {
|
|
56
65
|
process.stderr.write(msg + '\n');
|
|
@@ -72,6 +81,7 @@ function usage(msg?: string): never {
|
|
|
72
81
|
' ui-review-config --repo-root <repoRoot>\n' +
|
|
73
82
|
' read-ui-review-state <repoRoot> <taskId>\n' +
|
|
74
83
|
' write-ui-review-state <repoRoot> <taskId> <baselines_pending>\n' +
|
|
84
|
+
' write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>\n' +
|
|
75
85
|
' plugin-root\n' +
|
|
76
86
|
' load-rfc <repoRoot> <id>\n' +
|
|
77
87
|
' save-rfc <repoRoot> <filePath>\n' +
|
|
@@ -85,7 +95,11 @@ function usage(msg?: string): never {
|
|
|
85
95
|
' materialise-tasks <repoRoot> <planId>\n' +
|
|
86
96
|
' next-work-item-id <repoRoot> <project>\n' +
|
|
87
97
|
' discovery-config --repo-root <repoRoot>\n' +
|
|
88
|
-
' prep-worktree <mainRoot> <worktreePath>\n'
|
|
98
|
+
' prep-worktree <mainRoot> <worktreePath>\n' +
|
|
99
|
+
' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
|
|
100
|
+
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
101
|
+
' walk-state-read <repoRoot> <planId>\n' +
|
|
102
|
+
' walk-state-write <repoRoot> <walkStateJsonPath>\n'
|
|
89
103
|
);
|
|
90
104
|
process.exit(2);
|
|
91
105
|
}
|
|
@@ -300,6 +314,30 @@ try {
|
|
|
300
314
|
break;
|
|
301
315
|
}
|
|
302
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
|
+
|
|
303
341
|
case 'plugin-root': {
|
|
304
342
|
process.stdout.write(getPluginRoot());
|
|
305
343
|
process.exit(0);
|
|
@@ -407,6 +445,60 @@ try {
|
|
|
407
445
|
break;
|
|
408
446
|
}
|
|
409
447
|
|
|
448
|
+
case 'dag-ready-tasks': {
|
|
449
|
+
const [repoRoot, planId, maxConcurrentStr] = rest;
|
|
450
|
+
if (!repoRoot || !planId || !maxConcurrentStr)
|
|
451
|
+
usage('dag-ready-tasks requires <repoRoot> <planId> <maxConcurrent>');
|
|
452
|
+
const maxConcurrent = parseInt(maxConcurrentStr, 10);
|
|
453
|
+
if (Number.isNaN(maxConcurrent) || maxConcurrent < 1)
|
|
454
|
+
die(`maxConcurrent must be a positive integer, got ${maxConcurrentStr}`);
|
|
455
|
+
const plan = loadPlan(repoRoot, planId);
|
|
456
|
+
const state = readWalkState(repoRoot, planId) ?? {
|
|
457
|
+
plan_id: planId,
|
|
458
|
+
started: new Date().toISOString(),
|
|
459
|
+
max_concurrent: maxConcurrent,
|
|
460
|
+
tasks: {},
|
|
461
|
+
};
|
|
462
|
+
const ready = computeReadyTasks(plan, state, maxConcurrent);
|
|
463
|
+
if (ready.length > 0) process.stdout.write(ready.join('\n') + '\n');
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case 'dag-detect-cycle': {
|
|
468
|
+
const [repoRoot, planId] = rest;
|
|
469
|
+
if (!repoRoot || !planId) usage('dag-detect-cycle requires <repoRoot> <planId>');
|
|
470
|
+
const plan = loadPlan(repoRoot, planId);
|
|
471
|
+
const cycle = detectCycle(plan);
|
|
472
|
+
if (cycle) {
|
|
473
|
+
process.stdout.write(cycle.cycle.join(' → ') + '\n');
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
case 'walk-state-read': {
|
|
480
|
+
const [repoRoot, planId] = rest;
|
|
481
|
+
if (!repoRoot || !planId) usage('walk-state-read requires <repoRoot> <planId>');
|
|
482
|
+
const state = readWalkState(repoRoot, planId);
|
|
483
|
+
if (state === null) {
|
|
484
|
+
die(`walk-state not found for plan ${planId} at ${walkStatePath(repoRoot, planId)}`, 2);
|
|
485
|
+
}
|
|
486
|
+
process.stdout.write(JSON.stringify(state, null, 2) + '\n');
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
case 'walk-state-write': {
|
|
491
|
+
const [repoRoot, walkStateJsonPath] = rest;
|
|
492
|
+
if (!repoRoot || !walkStateJsonPath)
|
|
493
|
+
usage('walk-state-write requires <repoRoot> <walkStateJsonPath>');
|
|
494
|
+
const raw = readFileSync(walkStateJsonPath, 'utf-8');
|
|
495
|
+
const state = JSON.parse(raw);
|
|
496
|
+
if (!state.plan_id || typeof state.plan_id !== 'string')
|
|
497
|
+
die('walk-state JSON must include plan_id (string)');
|
|
498
|
+
writeWalkState(repoRoot, state);
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
|
|
410
502
|
default:
|
|
411
503
|
usage(`Unknown command: ${command}`);
|
|
412
504
|
}
|