@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 +1 -1
- package/dist/cli.mjs +23 -41
- package/dist/dag-overlap.mjs +132 -0
- package/dist/plan.mjs +49 -0
- package/lib/cli.ts +23 -40
- package/lib/dag-overlap.ts +155 -0
- package/lib/plan.ts +54 -0
- package/package.json +2 -2
- package/prompts/plan.md +29 -1
- package/skills/cloverleaf-run-plan/SKILL.md +49 -11
- package/lib/release-preflight.ts +0 -172
- package/skills/cloverleaf-release/SKILL.md +0 -109
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
Immediately after `mcp__claw-drive__start_session` returns, attach a Monitor tool stream for the new session:
|
|
115
115
|
|
|
116
|
-
|
|
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
|
-
|
|
125
|
-
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
package/lib/release-preflight.ts
DELETED
|
@@ -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.
|