@cloverleaf/reference-impl 0.6.6 → 0.7.0

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
@@ -153,7 +153,7 @@ The Reviewer never switches branches. It reads files via `git show` and runs tes
153
153
 
154
154
  ## Package layout
155
155
 
156
- - `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. `lib/walker-config.ts` exports `loadWalkerConfig()` — reads `~/.config/cloverleaf/walker.json` (XDG-aware) for the user-level `max_concurrent` override; exposed via `cloverleaf-cli walker-default-concurrency [--explain]`. `lib/release-preflight.ts` exports `runPreflightChecks(repoRoot)` — runs six blocking checks and two warnings, returning `{ checks, version, tag, notes }`; exposed via `cloverleaf-cli release-preflight <repoRoot> [--json]`.
156
+ - `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. `lib/walker-config.ts` exports `loadWalkerConfig()` — reads `~/.config/cloverleaf/walker.json` (XDG-aware) for the user-level `max_concurrent` override; exposed via `cloverleaf-cli walker-default-concurrency [--explain]`. `lib/release-preflight.ts` exports `runPreflightChecks(repoRoot)` — runs six blocking checks and two warnings, returning `{ checks, version, tag, notes }`; exposed via `cloverleaf-cli release-preflight <repoRoot> [--json]`. `lib/dag-overlap.ts` exports `computeOverlapEdges(tasks)` and `getFirstSharedFile(taskA, taskB)` — infers serialization edges from `scope.files_touched` and is used internally by `savePlan` to augment `task_dag.edges` before writing.
157
157
  - `skills/` — Claude Code skill markdown files.
158
158
  - `prompts/` — Implementer/Reviewer subagent system prompts.
159
159
  - `examples/toy-repo/` — standalone demo repo.
package/dist/cli.mjs CHANGED
@@ -35,12 +35,10 @@
35
35
  * walk-state-read <repoRoot> <planId>
36
36
  * walk-state-write <repoRoot> <walkStateJsonPath>
37
37
  * walker-default-concurrency [--explain]
38
- * release-preflight <repoRoot> [--json]
39
38
  */
40
39
  import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
41
40
  import { dirname } from 'node:path';
42
41
  import { execSync } from 'node:child_process';
43
- import { runPreflightChecks } from './release-preflight.mjs';
44
42
  import { loadTask } from './task.mjs';
45
43
  import { advanceStatus } from './task.mjs';
46
44
  import { emitGateDecision } from './events.mjs';
@@ -110,11 +108,14 @@ if (!command) {
110
108
  try {
111
109
  switch (command) {
112
110
  case 'load-task': {
113
- const [repoRoot, taskId] = rest;
111
+ const positional = rest.filter((a) => !a.startsWith('--'));
112
+ const flags = rest.filter((a) => a.startsWith('--'));
113
+ const [repoRoot, taskId] = positional;
114
114
  if (!repoRoot || !taskId)
115
115
  usage('load-task requires <repoRoot> <taskId>');
116
+ const pretty = flags.includes('--pretty');
116
117
  const task = loadTask(repoRoot, taskId);
117
- process.stdout.write(JSON.stringify(task, null, 2) + '\n');
118
+ process.stdout.write((pretty ? JSON.stringify(task, null, 2) : JSON.stringify(task)) + '\n');
118
119
  break;
119
120
  }
120
121
  case 'infer-project': {
@@ -333,10 +334,14 @@ try {
333
334
  process.exit(0);
334
335
  }
335
336
  case 'load-rfc': {
336
- const [repoRoot, id] = rest;
337
+ const positional = rest.filter((a) => !a.startsWith('--'));
338
+ const flags = rest.filter((a) => a.startsWith('--'));
339
+ const [repoRoot, id] = positional;
337
340
  if (!repoRoot || !id)
338
341
  usage('load-rfc <repoRoot> <id>');
339
- process.stdout.write(JSON.stringify(loadRfc(repoRoot, id), null, 2));
342
+ const pretty = flags.includes('--pretty');
343
+ const doc = loadRfc(repoRoot, id);
344
+ process.stdout.write((pretty ? JSON.stringify(doc, null, 2) : JSON.stringify(doc)) + '\n');
340
345
  break;
341
346
  }
342
347
  case 'save-rfc': {
@@ -358,10 +363,14 @@ try {
358
363
  break;
359
364
  }
360
365
  case 'load-spike': {
361
- const [repoRoot, id] = rest;
366
+ const positional = rest.filter((a) => !a.startsWith('--'));
367
+ const flags = rest.filter((a) => a.startsWith('--'));
368
+ const [repoRoot, id] = positional;
362
369
  if (!repoRoot || !id)
363
370
  usage('load-spike <repoRoot> <id>');
364
- process.stdout.write(JSON.stringify(loadSpike(repoRoot, id), null, 2));
371
+ const pretty = flags.includes('--pretty');
372
+ const doc = loadSpike(repoRoot, id);
373
+ process.stdout.write((pretty ? JSON.stringify(doc, null, 2) : JSON.stringify(doc)) + '\n');
365
374
  break;
366
375
  }
367
376
  case 'save-spike': {
@@ -382,10 +391,14 @@ try {
382
391
  break;
383
392
  }
384
393
  case 'load-plan': {
385
- const [repoRoot, id] = rest;
394
+ const positional = rest.filter((a) => !a.startsWith('--'));
395
+ const flags = rest.filter((a) => a.startsWith('--'));
396
+ const [repoRoot, id] = positional;
386
397
  if (!repoRoot || !id)
387
398
  usage('load-plan <repoRoot> <id>');
388
- process.stdout.write(JSON.stringify(loadPlan(repoRoot, id), null, 2));
399
+ const pretty = flags.includes('--pretty');
400
+ const doc = loadPlan(repoRoot, id);
401
+ process.stdout.write((pretty ? JSON.stringify(doc, null, 2) : JSON.stringify(doc)) + '\n');
389
402
  break;
390
403
  }
391
404
  case 'save-plan': {
@@ -510,37 +523,6 @@ try {
510
523
  }
511
524
  process.exit(0);
512
525
  }
513
- case 'release-preflight': {
514
- const positional = rest.filter((a) => !a.startsWith('--'));
515
- const flags = rest.filter((a) => a.startsWith('--'));
516
- const [repoRoot] = positional;
517
- if (!repoRoot)
518
- usage('release-preflight requires <repoRoot>');
519
- const jsonMode = flags.includes('--json');
520
- const result = runPreflightChecks(repoRoot);
521
- if (jsonMode) {
522
- process.stdout.write(JSON.stringify(result, null, 2) + '\n');
523
- process.exit(0);
524
- }
525
- else {
526
- // Plain human-readable mode
527
- for (const check of result.checks) {
528
- let prefix;
529
- if (check.status === 'pass') {
530
- prefix = '✓';
531
- }
532
- else if (check.level === 'warning') {
533
- prefix = '⚠';
534
- }
535
- else {
536
- prefix = '✗';
537
- }
538
- process.stdout.write(`${prefix} ${check.id}: ${check.message}\n`);
539
- }
540
- const blockingFailed = result.checks.some((c) => c.level === 'blocking' && c.status === 'fail');
541
- process.exit(blockingFailed ? 1 : 0);
542
- }
543
- }
544
526
  default:
545
527
  usage(`Unknown command: ${command}`);
546
528
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * dag-overlap.ts — reference-impl v0.7.0
3
+ *
4
+ * Infers TaskDag serialization edges from shared `scope.files_touched` paths.
5
+ * Two tasks that touch the same file must be serialized to avoid merge conflicts.
6
+ *
7
+ * Algorithm:
8
+ * 1. For each task, extract `scope?.files_touched` (absent / empty → zero contribution).
9
+ * 2. Normalize each path: strip leading `./`, collapse multiple leading `./`, strip
10
+ * trailing `/`, canonicalize separators to forward-slash.
11
+ * 3. Enumerate all (i, j) pairs where i < j (by task index). For each pair, compute
12
+ * the intersection of their normalized file sets.
13
+ * 4. For each intersecting pair, emit one edge: lower-id (lexicographic) → higher-id.
14
+ * 5. Deduplicate edges by (from.id, to.id) — a pair can share multiple files, but
15
+ * only one edge is emitted per unique (from, to) combination.
16
+ * 6. Sort output by (from.id, to.id) for deterministic ordering.
17
+ */
18
+ /**
19
+ * Normalize a raw file path from `scope.files_touched`.
20
+ *
21
+ * Rules (applied in order):
22
+ * - Trim whitespace
23
+ * - Replace backslashes with forward-slashes
24
+ * - Strip any number of leading `./` sequences
25
+ * - Strip trailing `/`
26
+ * - Return the resulting string (may be empty for degenerate inputs; caller filters)
27
+ */
28
+ function normalizePath(p) {
29
+ let s = p.trim().replace(/\\/g, '/');
30
+ // Strip leading `./` repeatedly
31
+ while (s.startsWith('./')) {
32
+ s = s.slice(2);
33
+ }
34
+ // Strip trailing `/`
35
+ while (s.endsWith('/') && s.length > 1) {
36
+ s = s.slice(0, -1);
37
+ }
38
+ return s;
39
+ }
40
+ /**
41
+ * Extract and normalize `scope.files_touched` from a task document.
42
+ * Returns an empty array if the field is absent, null, or empty.
43
+ */
44
+ function getFiles(task) {
45
+ const scope = task['scope'];
46
+ if (!scope)
47
+ return [];
48
+ const files = scope['files_touched'];
49
+ if (!Array.isArray(files))
50
+ return [];
51
+ const normalized = [];
52
+ for (const f of files) {
53
+ if (typeof f !== 'string')
54
+ continue;
55
+ const n = normalizePath(f);
56
+ if (n.length > 0)
57
+ normalized.push(n);
58
+ }
59
+ return normalized;
60
+ }
61
+ /**
62
+ * Compute the canonical overlap edge (from → to) for a pair of tasks.
63
+ * The edge always points from the lexicographically lower id to the higher id.
64
+ */
65
+ function makeEdgeRef(projectA, idA, projectB, idB) {
66
+ if (idA <= idB) {
67
+ return { from: { project: projectA, id: idA }, to: { project: projectB, id: idB } };
68
+ }
69
+ return { from: { project: projectB, id: idB }, to: { project: projectA, id: idA } };
70
+ }
71
+ /**
72
+ * Compute overlap-inferred DAG edges from a list of task documents.
73
+ *
74
+ * Returns a deterministically ordered, deduplicated list of `TaskDagEdge`
75
+ * values — one edge per (from.id, to.id) pair that shares at least one
76
+ * normalized file path.
77
+ *
78
+ * Input order is irrelevant; output order is sorted by (from.id, to.id).
79
+ */
80
+ export function computeOverlapEdges(tasks) {
81
+ if (tasks.length < 2)
82
+ return [];
83
+ // Build a map: taskIndex → normalized file set
84
+ const fileSets = tasks.map(t => new Set(getFiles(t)));
85
+ // Collect edges using a map keyed by "fromId|toId" for deduplication
86
+ const edgeMap = new Map();
87
+ for (let i = 0; i < tasks.length; i++) {
88
+ for (let j = i + 1; j < tasks.length; j++) {
89
+ const setA = fileSets[i];
90
+ const setB = fileSets[j];
91
+ if (setA.size === 0 || setB.size === 0)
92
+ continue;
93
+ // Check intersection
94
+ let hasOverlap = false;
95
+ for (const f of setA) {
96
+ if (setB.has(f)) {
97
+ hasOverlap = true;
98
+ break;
99
+ }
100
+ }
101
+ if (!hasOverlap)
102
+ continue;
103
+ const taskA = tasks[i];
104
+ const taskB = tasks[j];
105
+ const edge = makeEdgeRef(taskA.project, taskA.id, taskB.project, taskB.id);
106
+ const key = `${edge.from.id}|${edge.to.id}`;
107
+ if (!edgeMap.has(key)) {
108
+ edgeMap.set(key, edge);
109
+ }
110
+ }
111
+ }
112
+ // Sort for determinism: by from.id, then to.id
113
+ const edges = Array.from(edgeMap.values());
114
+ edges.sort((a, b) => {
115
+ const c = a.from.id.localeCompare(b.from.id);
116
+ return c !== 0 ? c : a.to.id.localeCompare(b.to.id);
117
+ });
118
+ return edges;
119
+ }
120
+ /**
121
+ * Return true if task A and task B share at least one normalized file path.
122
+ * Used by savePlan to report which file caused the cycle.
123
+ */
124
+ export function getFirstSharedFile(taskA, taskB) {
125
+ const setA = new Set(getFiles(taskA));
126
+ const filesB = getFiles(taskB);
127
+ for (const f of filesB) {
128
+ if (setA.has(f))
129
+ return f;
130
+ }
131
+ return null;
132
+ }
package/dist/plan.mjs CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { plansDir, tasksDir } from './paths.mjs';
4
4
  import { validateOrThrow } from './validate.mjs';
5
5
  import { advanceWorkItemStatus, loadStateMachine } from './work-item.mjs';
6
+ import { computeOverlapEdges, getFirstSharedFile } from './dag-overlap.mjs';
6
7
  export function loadPlan(repoRoot, id) {
7
8
  const path = join(plansDir(repoRoot), `${id}.json`);
8
9
  if (!existsSync(path))
@@ -10,7 +11,55 @@ export function loadPlan(repoRoot, id) {
10
11
  return JSON.parse(readFileSync(path, 'utf-8'));
11
12
  }
12
13
  export function savePlan(repoRoot, plan) {
14
+ // 1. Schema validation.
13
15
  validateOrThrow('https://cloverleaf.example/schemas/plan.schema.json', plan);
16
+ // 2. Compute overlap-inferred edges from task scope.files_touched and
17
+ // merge them into task_dag.edges via set-union (idempotent).
18
+ const tasks = plan.tasks;
19
+ const overlapEdges = computeOverlapEdges(tasks);
20
+ if (overlapEdges.length > 0) {
21
+ const existingKeys = new Set(plan.task_dag.edges.map(e => `${e.from.id}|${e.to.id}`));
22
+ const newEdges = overlapEdges.filter(e => !existingKeys.has(`${e.from.id}|${e.to.id}`));
23
+ if (newEdges.length > 0) {
24
+ plan = {
25
+ ...plan,
26
+ task_dag: {
27
+ ...plan.task_dag,
28
+ edges: [...plan.task_dag.edges, ...newEdges],
29
+ },
30
+ };
31
+ }
32
+ }
33
+ // 3. Cycle detection on the augmented DAG. Only triggered when overlap
34
+ // edges are present — if computeOverlapEdges emitted any edges, we
35
+ // must verify the merged graph is acyclic. Report which pair and which
36
+ // file caused the cycle.
37
+ if (overlapEdges.length > 0) {
38
+ const cycleNodeId = detectCycle(plan.task_dag);
39
+ if (cycleNodeId !== null) {
40
+ // Find the two tasks involved in the cycle that share a file.
41
+ const taskMap = new Map();
42
+ for (const t of tasks)
43
+ taskMap.set(t.id, t);
44
+ let errorMsg = `file overlap creates cycle: ${cycleNodeId} ↔ (unknown) via (unknown)`;
45
+ // Try to find a concrete overlap pair that touches the cycle node.
46
+ outer: for (const edge of overlapEdges) {
47
+ const tFrom = taskMap.get(edge.from.id);
48
+ const tTo = taskMap.get(edge.to.id);
49
+ if (!tFrom || !tTo)
50
+ continue;
51
+ if (edge.from.id !== cycleNodeId && edge.to.id !== cycleNodeId)
52
+ continue;
53
+ const sharedFile = getFirstSharedFile(tFrom, tTo);
54
+ if (sharedFile) {
55
+ errorMsg = `file overlap creates cycle: ${edge.from.id} ↔ ${edge.to.id} via ${sharedFile}`;
56
+ break outer;
57
+ }
58
+ }
59
+ throw new Error(errorMsg);
60
+ }
61
+ }
62
+ // 4. Write to disk.
14
63
  mkdirSync(plansDir(repoRoot), { recursive: true });
15
64
  const path = join(plansDir(repoRoot), `${plan.id}.json`);
16
65
  writeFileSync(path, JSON.stringify(plan, null, 2) + '\n');
package/lib/cli.ts CHANGED
@@ -35,13 +35,11 @@
35
35
  * walk-state-read <repoRoot> <planId>
36
36
  * walk-state-write <repoRoot> <walkStateJsonPath>
37
37
  * walker-default-concurrency [--explain]
38
- * release-preflight <repoRoot> [--json]
39
38
  */
40
39
 
41
40
  import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
42
41
  import { dirname } from 'node:path';
43
42
  import { execSync } from 'node:child_process';
44
- import { runPreflightChecks } from './release-preflight.js';
45
43
  import { loadTask } from './task.js';
46
44
  import { advanceStatus } from './task.js';
47
45
  import { emitGateDecision } from './events.js';
@@ -118,10 +116,13 @@ if (!command) {
118
116
  try {
119
117
  switch (command) {
120
118
  case 'load-task': {
121
- const [repoRoot, taskId] = rest;
119
+ const positional = rest.filter((a) => !a.startsWith('--'));
120
+ const flags = rest.filter((a) => a.startsWith('--'));
121
+ const [repoRoot, taskId] = positional;
122
122
  if (!repoRoot || !taskId) usage('load-task requires <repoRoot> <taskId>');
123
+ const pretty = flags.includes('--pretty');
123
124
  const task = loadTask(repoRoot, taskId);
124
- process.stdout.write(JSON.stringify(task, null, 2) + '\n');
125
+ process.stdout.write((pretty ? JSON.stringify(task, null, 2) : JSON.stringify(task)) + '\n');
125
126
  break;
126
127
  }
127
128
 
@@ -349,9 +350,13 @@ try {
349
350
  }
350
351
 
351
352
  case 'load-rfc': {
352
- const [repoRoot, id] = rest;
353
+ const positional = rest.filter((a) => !a.startsWith('--'));
354
+ const flags = rest.filter((a) => a.startsWith('--'));
355
+ const [repoRoot, id] = positional;
353
356
  if (!repoRoot || !id) usage('load-rfc <repoRoot> <id>');
354
- process.stdout.write(JSON.stringify(loadRfc(repoRoot, id), null, 2));
357
+ const pretty = flags.includes('--pretty');
358
+ const doc = loadRfc(repoRoot, id);
359
+ process.stdout.write((pretty ? JSON.stringify(doc, null, 2) : JSON.stringify(doc)) + '\n');
355
360
  break;
356
361
  }
357
362
 
@@ -373,9 +378,13 @@ try {
373
378
  }
374
379
 
375
380
  case 'load-spike': {
376
- const [repoRoot, id] = rest;
381
+ const positional = rest.filter((a) => !a.startsWith('--'));
382
+ const flags = rest.filter((a) => a.startsWith('--'));
383
+ const [repoRoot, id] = positional;
377
384
  if (!repoRoot || !id) usage('load-spike <repoRoot> <id>');
378
- process.stdout.write(JSON.stringify(loadSpike(repoRoot, id), null, 2));
385
+ const pretty = flags.includes('--pretty');
386
+ const doc = loadSpike(repoRoot, id);
387
+ process.stdout.write((pretty ? JSON.stringify(doc, null, 2) : JSON.stringify(doc)) + '\n');
379
388
  break;
380
389
  }
381
390
 
@@ -396,9 +405,13 @@ try {
396
405
  }
397
406
 
398
407
  case 'load-plan': {
399
- const [repoRoot, id] = rest;
408
+ const positional = rest.filter((a) => !a.startsWith('--'));
409
+ const flags = rest.filter((a) => a.startsWith('--'));
410
+ const [repoRoot, id] = positional;
400
411
  if (!repoRoot || !id) usage('load-plan <repoRoot> <id>');
401
- process.stdout.write(JSON.stringify(loadPlan(repoRoot, id), null, 2));
412
+ const pretty = flags.includes('--pretty');
413
+ const doc = loadPlan(repoRoot, id);
414
+ process.stdout.write((pretty ? JSON.stringify(doc, null, 2) : JSON.stringify(doc)) + '\n');
402
415
  break;
403
416
  }
404
417
 
@@ -524,36 +537,6 @@ try {
524
537
  process.exit(0);
525
538
  }
526
539
 
527
- case 'release-preflight': {
528
- const positional = rest.filter((a) => !a.startsWith('--'));
529
- const flags = rest.filter((a) => a.startsWith('--'));
530
- const [repoRoot] = positional;
531
- if (!repoRoot) usage('release-preflight requires <repoRoot>');
532
- const jsonMode = flags.includes('--json');
533
- const result = runPreflightChecks(repoRoot);
534
- if (jsonMode) {
535
- process.stdout.write(JSON.stringify(result, null, 2) + '\n');
536
- process.exit(0);
537
- } else {
538
- // Plain human-readable mode
539
- for (const check of result.checks) {
540
- let prefix: string;
541
- if (check.status === 'pass') {
542
- prefix = '✓';
543
- } else if (check.level === 'warning') {
544
- prefix = '⚠';
545
- } else {
546
- prefix = '✗';
547
- }
548
- process.stdout.write(`${prefix} ${check.id}: ${check.message}\n`);
549
- }
550
- const blockingFailed = result.checks.some(
551
- (c) => c.level === 'blocking' && c.status === 'fail',
552
- );
553
- process.exit(blockingFailed ? 1 : 0);
554
- }
555
- }
556
-
557
540
  default:
558
541
  usage(`Unknown command: ${command}`);
559
542
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * dag-overlap.ts — reference-impl v0.7.0
3
+ *
4
+ * Infers TaskDag serialization edges from shared `scope.files_touched` paths.
5
+ * Two tasks that touch the same file must be serialized to avoid merge conflicts.
6
+ *
7
+ * Algorithm:
8
+ * 1. For each task, extract `scope?.files_touched` (absent / empty → zero contribution).
9
+ * 2. Normalize each path: strip leading `./`, collapse multiple leading `./`, strip
10
+ * trailing `/`, canonicalize separators to forward-slash.
11
+ * 3. Enumerate all (i, j) pairs where i < j (by task index). For each pair, compute
12
+ * the intersection of their normalized file sets.
13
+ * 4. For each intersecting pair, emit one edge: lower-id (lexicographic) → higher-id.
14
+ * 5. Deduplicate edges by (from.id, to.id) — a pair can share multiple files, but
15
+ * only one edge is emitted per unique (from, to) combination.
16
+ * 6. Sort output by (from.id, to.id) for deterministic ordering.
17
+ */
18
+
19
+ import type { TaskDoc } from './task.js';
20
+
21
+ // Re-export the shape used in plan.ts so callers can use one import.
22
+ export interface TaskDagEdgeRef {
23
+ project: string;
24
+ id: string;
25
+ }
26
+
27
+ export interface TaskDagEdge {
28
+ from: TaskDagEdgeRef;
29
+ to: TaskDagEdgeRef;
30
+ }
31
+
32
+ /**
33
+ * Normalize a raw file path from `scope.files_touched`.
34
+ *
35
+ * Rules (applied in order):
36
+ * - Trim whitespace
37
+ * - Replace backslashes with forward-slashes
38
+ * - Strip any number of leading `./` sequences
39
+ * - Strip trailing `/`
40
+ * - Return the resulting string (may be empty for degenerate inputs; caller filters)
41
+ */
42
+ function normalizePath(p: string): string {
43
+ let s = p.trim().replace(/\\/g, '/');
44
+ // Strip leading `./` repeatedly
45
+ while (s.startsWith('./')) {
46
+ s = s.slice(2);
47
+ }
48
+ // Strip trailing `/`
49
+ while (s.endsWith('/') && s.length > 1) {
50
+ s = s.slice(0, -1);
51
+ }
52
+ return s;
53
+ }
54
+
55
+ /**
56
+ * Extract and normalize `scope.files_touched` from a task document.
57
+ * Returns an empty array if the field is absent, null, or empty.
58
+ */
59
+ function getFiles(task: TaskDoc): string[] {
60
+ const scope = task['scope'] as Record<string, unknown> | undefined;
61
+ if (!scope) return [];
62
+ const files = scope['files_touched'];
63
+ if (!Array.isArray(files)) return [];
64
+ const normalized: string[] = [];
65
+ for (const f of files) {
66
+ if (typeof f !== 'string') continue;
67
+ const n = normalizePath(f);
68
+ if (n.length > 0) normalized.push(n);
69
+ }
70
+ return normalized;
71
+ }
72
+
73
+ /**
74
+ * Compute the canonical overlap edge (from → to) for a pair of tasks.
75
+ * The edge always points from the lexicographically lower id to the higher id.
76
+ */
77
+ function makeEdgeRef(
78
+ projectA: string, idA: string,
79
+ projectB: string, idB: string
80
+ ): { from: TaskDagEdgeRef; to: TaskDagEdgeRef } {
81
+ if (idA <= idB) {
82
+ return { from: { project: projectA, id: idA }, to: { project: projectB, id: idB } };
83
+ }
84
+ return { from: { project: projectB, id: idB }, to: { project: projectA, id: idA } };
85
+ }
86
+
87
+ /**
88
+ * Compute overlap-inferred DAG edges from a list of task documents.
89
+ *
90
+ * Returns a deterministically ordered, deduplicated list of `TaskDagEdge`
91
+ * values — one edge per (from.id, to.id) pair that shares at least one
92
+ * normalized file path.
93
+ *
94
+ * Input order is irrelevant; output order is sorted by (from.id, to.id).
95
+ */
96
+ export function computeOverlapEdges(tasks: TaskDoc[]): TaskDagEdge[] {
97
+ if (tasks.length < 2) return [];
98
+
99
+ // Build a map: taskIndex → normalized file set
100
+ const fileSets: Array<Set<string>> = tasks.map(t => new Set(getFiles(t)));
101
+
102
+ // Collect edges using a map keyed by "fromId|toId" for deduplication
103
+ const edgeMap = new Map<string, TaskDagEdge>();
104
+
105
+ for (let i = 0; i < tasks.length; i++) {
106
+ for (let j = i + 1; j < tasks.length; j++) {
107
+ const setA = fileSets[i];
108
+ const setB = fileSets[j];
109
+ if (setA.size === 0 || setB.size === 0) continue;
110
+
111
+ // Check intersection
112
+ let hasOverlap = false;
113
+ for (const f of setA) {
114
+ if (setB.has(f)) {
115
+ hasOverlap = true;
116
+ break;
117
+ }
118
+ }
119
+ if (!hasOverlap) continue;
120
+
121
+ const taskA = tasks[i];
122
+ const taskB = tasks[j];
123
+ const edge = makeEdgeRef(
124
+ taskA.project, taskA.id,
125
+ taskB.project, taskB.id
126
+ );
127
+ const key = `${edge.from.id}|${edge.to.id}`;
128
+ if (!edgeMap.has(key)) {
129
+ edgeMap.set(key, edge);
130
+ }
131
+ }
132
+ }
133
+
134
+ // Sort for determinism: by from.id, then to.id
135
+ const edges = Array.from(edgeMap.values());
136
+ edges.sort((a, b) => {
137
+ const c = a.from.id.localeCompare(b.from.id);
138
+ return c !== 0 ? c : a.to.id.localeCompare(b.to.id);
139
+ });
140
+
141
+ return edges;
142
+ }
143
+
144
+ /**
145
+ * Return true if task A and task B share at least one normalized file path.
146
+ * Used by savePlan to report which file caused the cycle.
147
+ */
148
+ export function getFirstSharedFile(taskA: TaskDoc, taskB: TaskDoc): string | null {
149
+ const setA = new Set(getFiles(taskA));
150
+ const filesB = getFiles(taskB);
151
+ for (const f of filesB) {
152
+ if (setA.has(f)) return f;
153
+ }
154
+ return null;
155
+ }
package/lib/plan.ts CHANGED
@@ -3,6 +3,8 @@ import { join } from 'node:path';
3
3
  import { plansDir, tasksDir } from './paths.js';
4
4
  import { validateOrThrow } from './validate.js';
5
5
  import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
6
+ import { computeOverlapEdges, getFirstSharedFile } from './dag-overlap.js';
7
+ import type { TaskDoc } from './task.js';
6
8
 
7
9
  export interface WorkItemRef {
8
10
  project: string;
@@ -34,7 +36,59 @@ export function loadPlan(repoRoot: string, id: string): PlanDoc {
34
36
  }
35
37
 
36
38
  export function savePlan(repoRoot: string, plan: PlanDoc): void {
39
+ // 1. Schema validation.
37
40
  validateOrThrow('https://cloverleaf.example/schemas/plan.schema.json', plan);
41
+
42
+ // 2. Compute overlap-inferred edges from task scope.files_touched and
43
+ // merge them into task_dag.edges via set-union (idempotent).
44
+ const tasks = plan.tasks as unknown as TaskDoc[];
45
+ const overlapEdges = computeOverlapEdges(tasks);
46
+ if (overlapEdges.length > 0) {
47
+ const existingKeys = new Set(
48
+ plan.task_dag.edges.map(e => `${e.from.id}|${e.to.id}`)
49
+ );
50
+ const newEdges = overlapEdges.filter(
51
+ e => !existingKeys.has(`${e.from.id}|${e.to.id}`)
52
+ );
53
+ if (newEdges.length > 0) {
54
+ plan = {
55
+ ...plan,
56
+ task_dag: {
57
+ ...plan.task_dag,
58
+ edges: [...plan.task_dag.edges, ...newEdges],
59
+ },
60
+ };
61
+ }
62
+ }
63
+
64
+ // 3. Cycle detection on the augmented DAG. Only triggered when overlap
65
+ // edges are present — if computeOverlapEdges emitted any edges, we
66
+ // must verify the merged graph is acyclic. Report which pair and which
67
+ // file caused the cycle.
68
+ if (overlapEdges.length > 0) {
69
+ const cycleNodeId = detectCycle(plan.task_dag);
70
+ if (cycleNodeId !== null) {
71
+ // Find the two tasks involved in the cycle that share a file.
72
+ const taskMap = new Map<string, TaskDoc>();
73
+ for (const t of tasks) taskMap.set(t.id, t);
74
+ let errorMsg = `file overlap creates cycle: ${cycleNodeId} ↔ (unknown) via (unknown)`;
75
+ // Try to find a concrete overlap pair that touches the cycle node.
76
+ outer: for (const edge of overlapEdges) {
77
+ const tFrom = taskMap.get(edge.from.id);
78
+ const tTo = taskMap.get(edge.to.id);
79
+ if (!tFrom || !tTo) continue;
80
+ if (edge.from.id !== cycleNodeId && edge.to.id !== cycleNodeId) continue;
81
+ const sharedFile = getFirstSharedFile(tFrom, tTo);
82
+ if (sharedFile) {
83
+ errorMsg = `file overlap creates cycle: ${edge.from.id} ↔ ${edge.to.id} via ${sharedFile}`;
84
+ break outer;
85
+ }
86
+ }
87
+ throw new Error(errorMsg);
88
+ }
89
+ }
90
+
91
+ // 4. Write to disk.
38
92
  mkdirSync(plansDir(repoRoot), { recursive: true });
39
93
  const path = join(plansDir(repoRoot), `${plan.id}.json`);
40
94
  writeFileSync(path, JSON.stringify(plan, null, 2) + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloverleaf/reference-impl",
3
- "version": "0.6.6",
3
+ "version": "0.7.0",
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",
@@ -48,7 +48,7 @@
48
48
  "prepublishOnly": "node scripts/check-standard-prepped.mjs && npm test && npm run build"
49
49
  },
50
50
  "dependencies": {
51
- "@cloverleaf/standard": "^0.4.0",
51
+ "@cloverleaf/standard": "^0.5.0",
52
52
  "ajv": "^8.17.1",
53
53
  "ajv-formats": "^3.0.1",
54
54
  "axe-core": "^4.10.0",
package/prompts/plan.md CHANGED
@@ -27,7 +27,9 @@ You are the Plan Agent. Your role is to take an approved RFC and its completed S
27
27
  4. Build a `task_dag` using the edge-based shape from `dependency-dag.schema.json`:
28
28
  - `nodes: Array<{project, id}>` — one workItemRef per task in `tasks[]`.
29
29
  - `edges: Array<{from: {project, id}, to: {project, id}}>` — directed edges from prerequisite to dependent. `to` cannot start until `from` completes. Both endpoints must appear in `nodes`. DAG roots are nodes that appear in no edge's `to` field.
30
- 5. If `path_rules` is non-empty, emit `path_reviewer_map: Array<{pattern, role}>` by mapping the rules' path globs to reviewer roles.
30
+ - Only add edges for **logical** sequencing dependencies (e.g. task B cannot start until task A's output exists). **Do NOT manually add edges for file overlap. The system computes them automatically when the Plan is saved.**
31
+ 5. For each task in `tasks[]`, populate `scope.files_touched` with the repo-root-relative POSIX paths of every file the task is expected to create or modify. Transcribe these paths directly from the brief's Parallel-DAG conflict guidance section; if the brief does not list specific paths for a task, include a best-effort list based on the task's definition of done. Example: `"scope": { "files_touched": ["reference-impl/lib/cli.ts", "reference-impl/lib/auth.ts"] }`.
32
+ 6. If `path_rules` is non-empty, emit `path_reviewer_map: Array<{pattern, role}>` by mapping the rules' path globs to reviewer roles.
31
33
 
32
34
  ## Emit a Plan JSON conforming to `plan.schema.json`
33
35
 
@@ -61,3 +63,29 @@ Write the Plan JSON to stdout, nothing else. No prose, no markdown fences. The o
61
63
  - **Schema compliance is mandatory.** Each task in `tasks[]` must independently pass `task.schema.json` validation.
62
64
  - **No file writes.** Orchestrator persists your output.
63
65
  - Gate for Plan approval: `task_batch_gate` (approved by human after `gate-pending`).
66
+
67
+ ## Gate-pending summary template
68
+
69
+ When the orchestrator transitions the Plan to `gate-pending`, include a human-readable summary of the proposed task graph. Use the following template, computing the edge groupings inline before emitting:
70
+
71
+ ```
72
+ Plan <PLAN-ID> — gate-pending
73
+
74
+ Tasks (<N> total):
75
+ <TASK-ID> <task title> [<risk_class>]
76
+ ...
77
+
78
+ Dependencies:
79
+ Logical:
80
+ <FROM-ID> → <TO-ID> (<reason, e.g. "B needs A's migration output">)
81
+ ...
82
+ Inferred from file overlap:
83
+ <FROM-ID> → <TO-ID> (shared: <comma-separated overlapping file paths>)
84
+ ...
85
+
86
+ (None) — use "(None)" under a subsection when it is empty.
87
+ ```
88
+
89
+ **How to compute the diff inline:**
90
+ - **Logical** edges: edges you explicitly declared in `task_dag.edges` for sequencing reasons.
91
+ - **Inferred from file overlap**: edges the system will auto-compute by comparing `scope.files_touched` across tasks. For the summary, compute these yourself: for each pair of tasks (A, B) where A has lower task-number, if `scope.files_touched` sets overlap, list an inferred edge A → B with the overlapping paths. Do NOT add these to `task_dag.edges` — list them only in this summary so the human can sanity-check before approval.
@@ -111,23 +111,61 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
111
111
 
112
112
  Record the returned `session_id`, `worktree_path`, and `branch_name` in walk-state with `state: "running"`, `started_at: <now>`, `last_seq: 0`. Persist via `walk-state-write`.
113
113
 
114
- c. **Monitor live sessions.** Start `claw-drive watch <session_id> --since <last_seq> --idle-after 600` for each running session. Merge the streams into a single notification feed (e.g., the Monitor tool, or `claw-drive watch` run per-session in the background with a filter). The `--idle-after 600` flag instructs claw-drive to emit a synthetic `idle` event if a session produces no output for 600 seconds (10 minutes), enabling the walker to detect stalled sessions without polling.
114
+ Immediately after `mcp__claw-drive__start_session` returns, attach a Monitor tool stream for the new session:
115
115
 
116
- d. **Handle events.**
116
+ ```
117
+ Monitor(
118
+ watch_command: "claw-drive watch <session_id> --since 0 --idle-after 600",
119
+ persistent: true,
120
+ timeout_ms: 3600000
121
+ )
122
+ ```
123
+
124
+ This ensures the walker receives all child events without requiring Session A nudges. The `persistent: true` flag keeps the stream open across turns; `timeout_ms: 3600000` caps the watch at one hour.
125
+
126
+ c. **Monitor live sessions.** Each child session is already watched via the persistent Monitor stream attached in step 5b. When resuming after a walker restart (step 4), re-attach the Monitor with `--since <last_seq>` for each session still in `state: "running"`:
127
+
128
+ ```
129
+ Monitor(
130
+ watch_command: "claw-drive watch <session_id> --since <last_seq> --idle-after 600",
131
+ persistent: true,
132
+ timeout_ms: 3600000
133
+ )
134
+ ```
135
+
136
+ The `--idle-after 600` flag instructs claw-drive to emit a synthetic `idle` event if a session produces no output for 600 seconds (10 minutes), enabling the walker to detect stalled sessions without polling.
137
+
138
+ d. **Handle events.** Dispatch each incoming Monitor event by type:
139
+
140
+ - **`idle`** (`silent_for_ms >= 600000`) — the session has been silent for 10 minutes. For each child session emitting this event, check terminal state:
141
+ ```bash
142
+ claw-drive status <child_session_id>
143
+ ```
144
+ Read `last_token` from the status response, then branch:
145
+ - `last_token` is `[DONE]` → treat as terminal; proceed with drain (same as `session_stopped` → stopped cleanly).
146
+ - `last_token` is `[NEEDS-INPUT]` → the session is waiting for user input; surface to the user for a decision and send a reply via `mcp__claw-drive__send_turn`.
147
+ - Status output matches the transient-5xx pattern (`5\d\d\b`, `API Error: 5\d\d`, or `temporarily unavailable`) → invoke `mcp__claw-drive__send_turn` with message `'API recovered. Retry the last operation.'` to trigger self-healing.
148
+ - None of the above → the session is still working; continue waiting; do NOT auto-kill.
149
+ - **Per-session idle > 30 min** (no `idle` event received, wall-clock elapsed) → surface to user for inspection; do NOT auto-kill.
150
+
151
+ - **`tool_decision_required`** → let the walker policy decide (auto-approve per rules, defer to user for anything not covered).
152
+
153
+ - **`turn_completed [DONE]`** → the session has finished its current turn with a `[DONE]` terminal token. If the on-disk task status is `final-gate` or `automated-gates`, push onto the final-gate queue. Otherwise continue monitoring.
154
+
155
+ - **`turn_completed [NEEDS-INPUT]`** → the session is paused waiting for a user reply. Surface the assistant's last message to the driver and send the user's response via `mcp__claw-drive__send_turn`.
156
+
157
+ - **`session_stopped`** → reconcile as in step 4.
117
158
 
118
- - **tool_decision_required** → let the walker policy decide (auto-approve per rules, defer to user for anything not covered).
119
- - **turn_completed with final-gate prompt text** → push onto the final-gate queue.
120
159
  - **Escalation detected** (assistant text contains `escalated` / Reviewer/QA/UI-Reviewer bounce cap / git merge abort) → **surface to user immediately** with:
121
160
  > ⚠️ `<TASK-ID>` escalated at `<agent>` (reason: `<detail>`). Session `<session_id>`. Descendants in this Plan are now blocked until you unstick it.
122
161
  > To unstick: read feedback at `.cloverleaf/feedback/<TASK-ID>-*.json`, fix the issue, and run `/cloverleaf-run <TASK-ID>` manually. The walker will re-check on its next tick — when the task reaches `merged`, it'll pick up descendants automatically.
123
162
  Mark the task `state: "escalated"` in walk-state; do NOT queue it behind final-gate approvals; continue other branches.
124
- - **session_stopped** → reconcile as in step 4.
125
- - **idle** (`silent_for_ms >= 600000`) the session has been silent for 10 minutes. For each child session emitting this event, check whether it has reached a terminal state:
126
- ```bash
127
- claw-drive status <child_session_id>
128
- ```
129
- Read `last_token` from the status response. If `last_token` is `[DONE]` **or** the on-disk task status (`.cloverleaf/tasks/<TASK-ID>.json`) is `final-gate` or `automated-gates`, treat the session as terminal and proceed with drain (same as `session_stopped` stopped cleanly). If neither condition is met, surface to the user for inspection; do NOT auto-kill.
130
- - **Per-session idle > 30 min** (no `idle` event received, wall-clock elapsed) → surface to user for inspection; do NOT auto-kill.
163
+
164
+ **Transient-5xx self-healing.** Whenever any event's content (assistant text, error field, or status output) matches the pattern `5\d\d\b|API Error: 5\d\d|temporarily unavailable`, invoke:
165
+ ```
166
+ mcp__claw-drive__send_turn(session_id: <session_id>, text: "API recovered. Retry the last operation.")
167
+ ```
168
+ This covers transient API errors (e.g. HTTP 503, `API Error: 503`) and the `temporarily unavailable` service message without requiring a Session A nudge from the human.
131
169
 
132
170
  e. **Drain the final-gate queue serially and merge on main.** Session B does NOT invoke `/cloverleaf-merge` — it stops at automated-gates (fast lane) or final-gate (full pipeline) and reports. The walker performs the merge on main in the primary repo. For each queued task:
133
171
 
@@ -1,172 +0,0 @@
1
- import { execSync } from 'node:child_process';
2
- import { readFileSync, existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
-
5
- export type CheckLevel = 'blocking' | 'warning';
6
- export type CheckStatus = 'pass' | 'fail';
7
-
8
- export interface PreflightCheck {
9
- id: string;
10
- level: CheckLevel;
11
- status: CheckStatus;
12
- message: string;
13
- }
14
-
15
- export interface PreflightResult {
16
- checks: PreflightCheck[];
17
- version: string;
18
- tag: string;
19
- notes: string;
20
- }
21
-
22
- function shell(cmd: string, cwd: string): { out: string; ok: boolean } {
23
- try {
24
- const out = execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
25
- return { out: out.trim(), ok: true };
26
- } catch (err: unknown) {
27
- const e = err as { stdout?: string; stderr?: string; message?: string };
28
- const msg = (e.stderr ?? e.stdout ?? e.message ?? String(err)).trim();
29
- return { out: msg, ok: false };
30
- }
31
- }
32
-
33
- /**
34
- * Run all pre-flight checks for a release from the given repo root.
35
- * Never throws — all errors are captured into per-check `message` fields.
36
- */
37
- export function runPreflightChecks(repoRoot: string): PreflightResult {
38
- const checks: PreflightCheck[] = [];
39
-
40
- // Helper to add a check result
41
- function addCheck(
42
- id: string,
43
- level: CheckLevel,
44
- pass: boolean,
45
- failMsg: string,
46
- passMsg = 'ok',
47
- ): void {
48
- checks.push({ id, level, status: pass ? 'pass' : 'fail', message: pass ? passMsg : failMsg });
49
- }
50
-
51
- // ── Blocking checks ────────────────────────────────────────────────────────
52
-
53
- // 1. on-main: current branch must be main
54
- const branch = shell('git rev-parse --abbrev-ref HEAD', repoRoot);
55
- const isOnMain = branch.ok && branch.out === 'main';
56
- addCheck('on-main', 'blocking', isOnMain, `not on main (current: ${branch.out})`);
57
-
58
- // 2. clean-tree: no uncommitted changes
59
- const status = shell('git status --porcelain', repoRoot);
60
- const isClean = status.ok && status.out === '';
61
- addCheck('clean-tree', 'blocking', isClean, `working tree is dirty: ${status.out || status.out}`);
62
-
63
- // 3. in-sync-with-origin: no commits behind origin/main
64
- // Fetch quietly first so we can compare
65
- shell('git fetch origin main --quiet', repoRoot);
66
- const behind = shell('git rev-list --count HEAD..origin/main', repoRoot);
67
- const isSynced = behind.ok && behind.out === '0';
68
- addCheck(
69
- 'in-sync-with-origin',
70
- 'blocking',
71
- isSynced,
72
- behind.ok ? `${behind.out} commit(s) behind origin/main` : `could not check sync: ${behind.out}`,
73
- );
74
-
75
- // 4. valid-version: reference-impl/package.json has a valid semver
76
- let version = '';
77
- try {
78
- const pkgPath = join(repoRoot, 'reference-impl', 'package.json');
79
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
80
- version = pkg.version ?? '';
81
- } catch (e: unknown) {
82
- const msg = e instanceof Error ? e.message : String(e);
83
- addCheck('valid-version', 'blocking', false, `could not read package.json: ${msg}`);
84
- version = '';
85
- }
86
- if (version !== '') {
87
- const semverRe = /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/;
88
- const isValidSemver = semverRe.test(version);
89
- addCheck('valid-version', 'blocking', isValidSemver, `invalid semver: '${version}'`, `${version}`);
90
- }
91
-
92
- // 5. changelog-section: CHANGELOG.md has a section for this version
93
- const tag = version ? `reference-impl-v${version}` : 'reference-impl-v<unknown>';
94
- let changelogPass = false;
95
- let changelogMsg = 'no version resolved';
96
- if (version) {
97
- try {
98
- const changelogPath = join(repoRoot, 'reference-impl', 'CHANGELOG.md');
99
- const changelog = readFileSync(changelogPath, 'utf-8');
100
- // Look for a heading like "## 0.6.5" or "## [0.6.5]"
101
- const pattern = new RegExp(`^##\\s+\\[?${version.replace(/\./g, '\\.')}`, 'm');
102
- changelogPass = pattern.test(changelog);
103
- changelogMsg = changelogPass
104
- ? `section for ${version} found`
105
- : `no ## ${version} section found in CHANGELOG.md`;
106
- } catch (e: unknown) {
107
- const msg = e instanceof Error ? e.message : String(e);
108
- changelogMsg = `could not read CHANGELOG.md: ${msg}`;
109
- }
110
- }
111
- addCheck('changelog-section', 'blocking', changelogPass, changelogMsg, changelogMsg);
112
-
113
- // 6. tag-absent: the release tag must not already exist
114
- let tagAbsent = false;
115
- let tagMsg = 'no version resolved';
116
- if (version) {
117
- const localTag = shell(`git tag -l "${tag}"`, repoRoot);
118
- const remoteTag = shell(`git ls-remote --tags origin "${tag}"`, repoRoot);
119
- const existsLocally = localTag.ok && localTag.out !== '';
120
- const existsRemotely = remoteTag.ok && remoteTag.out !== '';
121
- tagAbsent = !existsLocally && !existsRemotely;
122
- if (existsLocally && existsRemotely) {
123
- tagMsg = `tag ${tag} already exists locally and on origin`;
124
- } else if (existsLocally) {
125
- tagMsg = `tag ${tag} already exists locally`;
126
- } else if (existsRemotely) {
127
- tagMsg = `tag ${tag} already exists on origin`;
128
- } else {
129
- tagMsg = `tag ${tag} is absent (ok)`;
130
- }
131
- }
132
- addCheck('tag-absent', 'blocking', tagAbsent, tagMsg, tagMsg);
133
-
134
- // ── Warning checks ─────────────────────────────────────────────────────────
135
-
136
- // 7. npm-authenticated: `npm whoami` must exit 0
137
- const npmAuth = shell('npm whoami', repoRoot);
138
- addCheck(
139
- 'npm-authenticated',
140
- 'warning',
141
- npmAuth.ok,
142
- `npm not authenticated: ${npmAuth.out}`,
143
- `logged in as ${npmAuth.out}`,
144
- );
145
-
146
- // 8. gh-authenticated: `gh auth status` must exit 0
147
- const ghAuth = shell('gh auth status', repoRoot);
148
- addCheck(
149
- 'gh-authenticated',
150
- 'warning',
151
- ghAuth.ok,
152
- `gh not authenticated: ${ghAuth.out}`,
153
- 'gh CLI authenticated',
154
- );
155
-
156
- // Derive release notes from CHANGELOG section
157
- let notes = '';
158
- if (version) {
159
- try {
160
- const changelogPath = join(repoRoot, 'reference-impl', 'CHANGELOG.md');
161
- const changelog = readFileSync(changelogPath, 'utf-8');
162
- const versionRegex = new RegExp(`^##\\s+\\[?${version.replace(/\./g, '\\.')}`, 'm');
163
- const sections = changelog.split(/\n(?=## )/);
164
- const match = sections.find((s) => versionRegex.test(s));
165
- notes = match ? match.replace(/^[^\n]*\n/, '').trim() : '';
166
- } catch {
167
- notes = '';
168
- }
169
- }
170
-
171
- return { checks, version, tag, notes };
172
- }
@@ -1,109 +0,0 @@
1
- ---
2
- name: cloverleaf-release
3
- description: Publish a new @cloverleaf/reference-impl release. Runs pre-flight checks, displays the 5-command release plan, and executes git tag -a / git push origin main / git push origin <tag> / npm publish / gh release create. Accepts [--dry-run] [--yes] flags.
4
- ---
5
-
6
- # Cloverleaf — release
7
-
8
- The user has invoked this skill, optionally with `--dry-run` and/or `--yes`.
9
-
10
- ## Args
11
-
12
- - `--dry-run` — print the pre-flight check list and the 5-command release plan, then exit 0 without executing any release command.
13
- - `--yes` — skip the interactive `y/N` prompt and execute the 5 commands unattended.
14
-
15
- ## Steps
16
-
17
- 1. **Parse flags.** Extract `--dry-run` and `--yes` from the invocation arguments if present.
18
-
19
- 2. **Capture the repo root.**
20
-
21
- ```bash
22
- REPO_ROOT=$(git rev-parse --show-toplevel)
23
- ```
24
-
25
- 3. **Run pre-flight checks.**
26
-
27
- ```bash
28
- PREFLIGHT=$(cloverleaf-cli release-preflight "$REPO_ROOT" --json)
29
- ```
30
-
31
- Parse the JSON. Extract `version`, `tag`, and `notes`.
32
-
33
- 4. **Display pre-flight check list.** For each check in `checks[]`:
34
-
35
- - Status `pass` → prefix `✓`
36
- - Status `fail` and level `warning` → prefix `⚠`
37
- - Status `fail` and level `blocking` → prefix `✗`
38
-
39
- Print one line per check: `<prefix> <id>: <message>`
40
-
41
- 5. **Bail on any blocking failure.**
42
-
43
- If any check has `level === "blocking"` and `status === "fail"`, print:
44
-
45
- ```
46
- ✗ Pre-flight failed — fix the issues above before releasing.
47
- ```
48
-
49
- And exit 1.
50
-
51
- 6. **Display release plan.**
52
-
53
- Print:
54
-
55
- ```
56
- Release plan for <tag>:
57
- 1. git tag -a <tag> -m "Release <tag>"
58
- 2. git push origin main
59
- 3. git push origin <tag>
60
- 4. cd reference-impl && npm publish --access public
61
- 5. gh release create <tag> --notes-file /tmp/release-notes-$VERSION.md
62
-
63
- Version: <version>
64
- Notes preview:
65
- <notes (first 10 lines or "(no notes)" if empty)>
66
- ```
67
-
68
- 7. **If `--dry-run`:** Print `Dry run complete — no release commands executed.` and exit 0.
69
-
70
- 8. **If not `--yes`:** Prompt:
71
-
72
- ```
73
- Proceed with release of <tag>? (y/N)
74
- ```
75
-
76
- Read a single line from the user. If the response is not `y` or `Y`, print `Aborted.` and exit 0.
77
-
78
- 9. **Write the release notes file.**
79
-
80
- ```bash
81
- VERSION=<version>
82
- printf '%s' "$NOTES" > /tmp/release-notes-$VERSION.md
83
- ```
84
-
85
- 10. **Execute the 5 release commands sequentially, bail-fast on first non-zero exit.**
86
-
87
- ```bash
88
- git tag -a "reference-impl-v$VERSION" -m "Release reference-impl-v$VERSION"
89
- git push origin main
90
- git push origin "reference-impl-v$VERSION"
91
- cd reference-impl && npm publish --access public
92
- gh release create "reference-impl-v$VERSION" --notes-file "/tmp/release-notes-$VERSION.md"
93
- ```
94
-
95
- If any command fails, print `✗ Release failed at step N: <command>` and exit 1.
96
-
97
- 11. **Report success.**
98
-
99
- ```
100
- ✓ Released reference-impl-v<version>
101
- ```
102
-
103
- ## Rules
104
-
105
- - Never skip pre-flight checks, even with `--yes`.
106
- - Warning-level check failures (`⚠`) do not block execution — they are informational only.
107
- - Do NOT modify `.cloverleaf/` — this skill only releases, it does not change task state.
108
- - The skill's working directory is the consumer's repo root.
109
- - Do not use hardcoded plugin paths — use `cloverleaf-cli` for all CLI invocations.