@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.
@@ -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.5.5",
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.5.5
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: `<PROJECT>-<NNN>-status.json` where NNN is the next per-project
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, project);
32
+ const seq = nextEventId(repoRoot, workItemId);
29
33
  const seqStr = String(seq).padStart(3, '0');
30
- const filename = `${project}-${seqStr}-status.json`;
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: `<PROJECT>-<NNN>-gate.json`.
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, project);
65
+ const seq = nextEventId(repoRoot, workItemId);
61
66
  const seqStr = String(seq).padStart(3, '0');
62
- const filename = `${project}-${seqStr}-gate.json`;
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, project) {
15
+ export function nextEventId(repoRoot, workItemId) {
16
16
  const dir = eventsDir(repoRoot);
17
17
  if (!existsSync(dir))
18
18
  return 1;
19
- const re = new RegExp(`^${escapeRegex(project)}-(\\d+)-(status|gate)\\.json$`);
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
@@ -5,3 +5,5 @@ export * from './events.mjs';
5
5
  export * from './feedback.mjs';
6
6
  export * from './validate.mjs';
7
7
  export * from './ui-review-state.mjs';
8
+ export * from './dag-walker.mjs';
9
+ export * from './walk-state.mjs';
@@ -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
@@ -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
  }