@cloverleaf/reference-impl 0.6.7 → 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/dag-overlap.mjs +132 -0
- package/dist/plan.mjs +49 -0
- 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/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.
|
|
@@ -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');
|
|
@@ -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.
|