@cloverleaf/reference-impl 0.5.4 → 0.6.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/.claude-plugin/plugin.json +1 -1
- package/VERSION +1 -1
- package/dist/cli.mjs +64 -1
- package/dist/dag-walker.mjs +93 -0
- package/dist/events.mjs +11 -6
- package/dist/ids.mjs +8 -2
- package/dist/index.mjs +2 -0
- package/dist/prep-worktree.mjs +16 -3
- package/dist/walk-state.mjs +31 -0
- package/lib/cli.ts +65 -1
- package/lib/dag-walker.ts +133 -0
- package/lib/events.ts +11 -6
- package/lib/ids.ts +8 -2
- package/lib/index.ts +2 -0
- package/lib/prep-worktree.ts +17 -3
- package/lib/walk-state.ts +39 -0
- package/package.json +2 -1
- package/skills/cloverleaf-merge/SKILL.md +9 -1
- package/skills/cloverleaf-run-plan/SKILL.md +203 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloverleaf",
|
|
3
3
|
"description": "Cloverleaf reference implementation — Claude Code skills for task scaffolding and the Delivery pipeline (implementer, documenter, reviewer, UI reviewer with multi-viewport visual diff, QA, merge).",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Renato D'Arrigo",
|
|
7
7
|
"email": "renato.darrigo@gmail.com"
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.6.0
|
package/dist/cli.mjs
CHANGED
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
* next-work-item-id <repoRoot> <project>
|
|
30
30
|
* discovery-config --repo-root <repoRoot>
|
|
31
31
|
* prep-worktree <mainRoot> <worktreePath>
|
|
32
|
+
* dag-ready-tasks <repoRoot> <planId> <maxConcurrent>
|
|
33
|
+
* dag-detect-cycle <repoRoot> <planId>
|
|
34
|
+
* walk-state-read <repoRoot> <planId>
|
|
35
|
+
* walk-state-write <repoRoot> <walkStateJsonPath>
|
|
32
36
|
*/
|
|
33
37
|
import { readFileSync } from 'node:fs';
|
|
34
38
|
import { execSync } from 'node:child_process';
|
|
@@ -49,6 +53,8 @@ import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan } from
|
|
|
49
53
|
import { loadDiscoveryConfig } from './discovery-config.mjs';
|
|
50
54
|
import { prepWorktree } from './prep-worktree.mjs';
|
|
51
55
|
import { readUiReviewState, writeUiReviewState } from './ui-review-state.mjs';
|
|
56
|
+
import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
|
|
57
|
+
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.mjs';
|
|
52
58
|
function die(msg, code = 1) {
|
|
53
59
|
process.stderr.write(msg + '\n');
|
|
54
60
|
process.exit(code);
|
|
@@ -81,7 +87,11 @@ function usage(msg) {
|
|
|
81
87
|
' materialise-tasks <repoRoot> <planId>\n' +
|
|
82
88
|
' next-work-item-id <repoRoot> <project>\n' +
|
|
83
89
|
' discovery-config --repo-root <repoRoot>\n' +
|
|
84
|
-
' prep-worktree <mainRoot> <worktreePath>\n'
|
|
90
|
+
' prep-worktree <mainRoot> <worktreePath>\n' +
|
|
91
|
+
' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
|
|
92
|
+
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
93
|
+
' walk-state-read <repoRoot> <planId>\n' +
|
|
94
|
+
' walk-state-write <repoRoot> <walkStateJsonPath>\n');
|
|
85
95
|
process.exit(2);
|
|
86
96
|
}
|
|
87
97
|
const [, , command, ...rest] = process.argv;
|
|
@@ -399,6 +409,59 @@ try {
|
|
|
399
409
|
prepWorktree(mainRoot, worktreePath);
|
|
400
410
|
break;
|
|
401
411
|
}
|
|
412
|
+
case 'dag-ready-tasks': {
|
|
413
|
+
const [repoRoot, planId, maxConcurrentStr] = rest;
|
|
414
|
+
if (!repoRoot || !planId || !maxConcurrentStr)
|
|
415
|
+
usage('dag-ready-tasks requires <repoRoot> <planId> <maxConcurrent>');
|
|
416
|
+
const maxConcurrent = parseInt(maxConcurrentStr, 10);
|
|
417
|
+
if (Number.isNaN(maxConcurrent) || maxConcurrent < 1)
|
|
418
|
+
die(`maxConcurrent must be a positive integer, got ${maxConcurrentStr}`);
|
|
419
|
+
const plan = loadPlan(repoRoot, planId);
|
|
420
|
+
const state = readWalkState(repoRoot, planId) ?? {
|
|
421
|
+
plan_id: planId,
|
|
422
|
+
started: new Date().toISOString(),
|
|
423
|
+
max_concurrent: maxConcurrent,
|
|
424
|
+
tasks: {},
|
|
425
|
+
};
|
|
426
|
+
const ready = computeReadyTasks(plan, state, maxConcurrent);
|
|
427
|
+
if (ready.length > 0)
|
|
428
|
+
process.stdout.write(ready.join('\n') + '\n');
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
case 'dag-detect-cycle': {
|
|
432
|
+
const [repoRoot, planId] = rest;
|
|
433
|
+
if (!repoRoot || !planId)
|
|
434
|
+
usage('dag-detect-cycle requires <repoRoot> <planId>');
|
|
435
|
+
const plan = loadPlan(repoRoot, planId);
|
|
436
|
+
const cycle = detectCycle(plan);
|
|
437
|
+
if (cycle) {
|
|
438
|
+
process.stdout.write(cycle.cycle.join(' → ') + '\n');
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
case 'walk-state-read': {
|
|
444
|
+
const [repoRoot, planId] = rest;
|
|
445
|
+
if (!repoRoot || !planId)
|
|
446
|
+
usage('walk-state-read requires <repoRoot> <planId>');
|
|
447
|
+
const state = readWalkState(repoRoot, planId);
|
|
448
|
+
if (state === null) {
|
|
449
|
+
die(`walk-state not found for plan ${planId} at ${walkStatePath(repoRoot, planId)}`, 2);
|
|
450
|
+
}
|
|
451
|
+
process.stdout.write(JSON.stringify(state, null, 2) + '\n');
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
case 'walk-state-write': {
|
|
455
|
+
const [repoRoot, walkStateJsonPath] = rest;
|
|
456
|
+
if (!repoRoot || !walkStateJsonPath)
|
|
457
|
+
usage('walk-state-write requires <repoRoot> <walkStateJsonPath>');
|
|
458
|
+
const raw = readFileSync(walkStateJsonPath, 'utf-8');
|
|
459
|
+
const state = JSON.parse(raw);
|
|
460
|
+
if (!state.plan_id || typeof state.plan_id !== 'string')
|
|
461
|
+
die('walk-state JSON must include plan_id (string)');
|
|
462
|
+
writeWalkState(repoRoot, state);
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
402
465
|
default:
|
|
403
466
|
usage(`Unknown command: ${command}`);
|
|
404
467
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler for the DAG walker. Given a Plan and the current walk state, returns the
|
|
3
|
+
* task IDs that are safe to spawn a Session B for right now:
|
|
4
|
+
*
|
|
5
|
+
* 1. Status is effectively `pending` (no state recorded, or recorded as `pending`).
|
|
6
|
+
* 2. Every ancestor in the task_dag has recorded state === 'merged'.
|
|
7
|
+
* 3. The count of returned IDs is capped at `maxConcurrent - currentlyRunning`.
|
|
8
|
+
*
|
|
9
|
+
* Order is deterministic (sorted ascending by task id) so callers can be reproducible.
|
|
10
|
+
*/
|
|
11
|
+
export function computeReadyTasks(plan, walkState, maxConcurrent) {
|
|
12
|
+
const running = Object.values(walkState.tasks).filter((t) => t.state === 'running' || t.state === 'awaiting_final_gate').length;
|
|
13
|
+
const slots = Math.max(0, maxConcurrent - running);
|
|
14
|
+
if (slots === 0)
|
|
15
|
+
return [];
|
|
16
|
+
const parents = {};
|
|
17
|
+
for (const node of plan.task_dag.nodes) {
|
|
18
|
+
parents[node.id] = [];
|
|
19
|
+
}
|
|
20
|
+
for (const edge of plan.task_dag.edges) {
|
|
21
|
+
const to = edge.to.id;
|
|
22
|
+
const from = edge.from.id;
|
|
23
|
+
if (!parents[to])
|
|
24
|
+
parents[to] = [];
|
|
25
|
+
parents[to].push(from);
|
|
26
|
+
}
|
|
27
|
+
const ready = [];
|
|
28
|
+
const allIds = plan.task_dag.nodes.map((n) => n.id).sort();
|
|
29
|
+
for (const id of allIds) {
|
|
30
|
+
const current = walkState.tasks[id];
|
|
31
|
+
const isPending = !current || current.state === 'pending';
|
|
32
|
+
if (!isPending)
|
|
33
|
+
continue;
|
|
34
|
+
const ancestorsMerged = (parents[id] ?? []).every((p) => walkState.tasks[p]?.state === 'merged');
|
|
35
|
+
if (!ancestorsMerged)
|
|
36
|
+
continue;
|
|
37
|
+
ready.push(id);
|
|
38
|
+
if (ready.length >= slots)
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
return ready;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Returns `null` if the Plan's task_dag is acyclic. If a cycle is present, returns
|
|
45
|
+
* `{ cycle: string[] }` naming the task IDs involved in one cycle (order is the
|
|
46
|
+
* traversal order, which is a valid witness of the cycle).
|
|
47
|
+
*
|
|
48
|
+
* Uses Tarjan-style DFS with a white/grey/black colouring.
|
|
49
|
+
*/
|
|
50
|
+
export function detectCycle(plan) {
|
|
51
|
+
const adj = {};
|
|
52
|
+
for (const node of plan.task_dag.nodes)
|
|
53
|
+
adj[node.id] = [];
|
|
54
|
+
for (const edge of plan.task_dag.edges) {
|
|
55
|
+
const from = edge.from.id;
|
|
56
|
+
if (!adj[from])
|
|
57
|
+
adj[from] = [];
|
|
58
|
+
adj[from].push(edge.to.id);
|
|
59
|
+
}
|
|
60
|
+
const WHITE = 0;
|
|
61
|
+
const GREY = 1;
|
|
62
|
+
const BLACK = 2;
|
|
63
|
+
const color = {};
|
|
64
|
+
for (const id of Object.keys(adj))
|
|
65
|
+
color[id] = WHITE;
|
|
66
|
+
const path = [];
|
|
67
|
+
function dfs(id) {
|
|
68
|
+
color[id] = GREY;
|
|
69
|
+
path.push(id);
|
|
70
|
+
for (const next of adj[id] ?? []) {
|
|
71
|
+
if (color[next] === GREY) {
|
|
72
|
+
const start = path.indexOf(next);
|
|
73
|
+
return path.slice(start);
|
|
74
|
+
}
|
|
75
|
+
if (color[next] === WHITE) {
|
|
76
|
+
const cycle = dfs(next);
|
|
77
|
+
if (cycle)
|
|
78
|
+
return cycle;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
color[id] = BLACK;
|
|
82
|
+
path.pop();
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
for (const id of Object.keys(adj)) {
|
|
86
|
+
if (color[id] === WHITE) {
|
|
87
|
+
const cycle = dfs(id);
|
|
88
|
+
if (cycle)
|
|
89
|
+
return { cycle };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
package/dist/events.mjs
CHANGED
|
@@ -18,16 +18,20 @@ export function formatReason(opts) {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* Emits a status-transition event to `.cloverleaf/events/`.
|
|
21
|
-
* File name: `<
|
|
21
|
+
* File name: `<workItemId>-<NNN>-status.json` where NNN is the next per-work-item
|
|
22
22
|
* sequential number derived from existing event files.
|
|
23
23
|
*
|
|
24
|
+
* The filename scopes the counter to a single work item (v0.6 change), so
|
|
25
|
+
* parallel Delivery sessions on sibling tasks produce non-colliding event
|
|
26
|
+
* filenames that merge cleanly.
|
|
27
|
+
*
|
|
24
28
|
* Returns the absolute path of the written file.
|
|
25
29
|
*/
|
|
26
30
|
export function emitStatusTransition(repoRoot, params) {
|
|
27
31
|
const { project, workItemType, workItemId, from, to, actor } = params;
|
|
28
|
-
const seq = nextEventId(repoRoot,
|
|
32
|
+
const seq = nextEventId(repoRoot, workItemId);
|
|
29
33
|
const seqStr = String(seq).padStart(3, '0');
|
|
30
|
-
const filename = `${
|
|
34
|
+
const filename = `${workItemId}-${seqStr}-status.json`;
|
|
31
35
|
const filePath = join(eventsDir(repoRoot), filename);
|
|
32
36
|
// Build reason from gate and/or path if provided (schema only allows reason, not gate/path at top level).
|
|
33
37
|
const reason = formatReason({ gate: params.gate, path: params.path });
|
|
@@ -51,15 +55,16 @@ export function emitStatusTransition(repoRoot, params) {
|
|
|
51
55
|
}
|
|
52
56
|
/**
|
|
53
57
|
* Emits a gate-decision event to `.cloverleaf/events/`.
|
|
54
|
-
* File name: `<
|
|
58
|
+
* File name: `<workItemId>-<NNN>-gate.json`. Counter is scoped to the work item
|
|
59
|
+
* (v0.6 change — see `emitStatusTransition` for rationale).
|
|
55
60
|
*
|
|
56
61
|
* Returns the absolute path of the written file.
|
|
57
62
|
*/
|
|
58
63
|
export function emitGateDecision(repoRoot, params) {
|
|
59
64
|
const { project, workItemId, gate, decision, actor, reasoning } = params;
|
|
60
|
-
const seq = nextEventId(repoRoot,
|
|
65
|
+
const seq = nextEventId(repoRoot, workItemId);
|
|
61
66
|
const seqStr = String(seq).padStart(3, '0');
|
|
62
|
-
const filename = `${
|
|
67
|
+
const filename = `${workItemId}-${seqStr}-gate.json`;
|
|
63
68
|
const filePath = join(eventsDir(repoRoot), filename);
|
|
64
69
|
const doc = {
|
|
65
70
|
event_id: randomUUID(),
|
package/dist/ids.mjs
CHANGED
|
@@ -12,11 +12,17 @@ export function nextTaskId(repoRoot, project) {
|
|
|
12
12
|
const next = nums.length === 0 ? 1 : Math.max(...nums) + 1;
|
|
13
13
|
return `${project}-${String(next).padStart(3, '0')}`;
|
|
14
14
|
}
|
|
15
|
-
export function nextEventId(repoRoot,
|
|
15
|
+
export function nextEventId(repoRoot, workItemId) {
|
|
16
16
|
const dir = eventsDir(repoRoot);
|
|
17
17
|
if (!existsSync(dir))
|
|
18
18
|
return 1;
|
|
19
|
-
|
|
19
|
+
// Per-work-item sequence. Filenames are `<workItemId>-<NNN>-<status|gate>.json`,
|
|
20
|
+
// which keeps counters scoped to a single task / RFC / Spike / Plan — this matters
|
|
21
|
+
// for the v0.6 DAG walker's parallel mode, where multiple worktrees emit events
|
|
22
|
+
// simultaneously. A global per-project counter (the pre-v0.6 scheme) produced
|
|
23
|
+
// filename collisions when the walker merged sibling feature branches. Per-work-item
|
|
24
|
+
// scoping means each task's counter is independent; merges union cleanly.
|
|
25
|
+
const re = new RegExp(`^${escapeRegex(workItemId)}-(\\d+)-(status|gate)\\.json$`);
|
|
20
26
|
const nums = readdirSync(dir)
|
|
21
27
|
.map((f) => f.match(re))
|
|
22
28
|
.filter((m) => !!m)
|
package/dist/index.mjs
CHANGED
package/dist/prep-worktree.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cpSync, existsSync } from 'node:fs';
|
|
1
|
+
import { cpSync, existsSync, rmSync } from 'node:fs';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
/**
|
|
@@ -41,10 +41,23 @@ export function prepWorktree(mainRoot, worktreePath) {
|
|
|
41
41
|
const wtRefImplNm = join(worktreePath, 'reference-impl', 'node_modules');
|
|
42
42
|
// verbatimSymlinks keeps relative symlink targets byte-identical, so the @cloverleaf/standard
|
|
43
43
|
// link in reference-impl/node_modules/ resolves against the worktree after copy.
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
//
|
|
45
|
+
// primeCopy wipes the destination before cpSync. Two reasons:
|
|
46
|
+
// 1. Idempotence: a partial prior run (or a re-invocation after a test failure) may
|
|
47
|
+
// leave partial state; we must not trip on it.
|
|
48
|
+
// 2. cpSync with verbatimSymlinks: true does not reliably overwrite an existing
|
|
49
|
+
// symlink at the destination even with force: true (CLV-20 Reviewer repro was
|
|
50
|
+
// EEXIST on vite/node_modules/.bin on second invocation).
|
|
51
|
+
primeCopy(mainStandardNm, wtStandardNm);
|
|
52
|
+
primeCopy(mainRefImplNm, wtRefImplNm);
|
|
46
53
|
execSync('npm run build', {
|
|
47
54
|
cwd: join(worktreePath, 'standard'),
|
|
48
55
|
stdio: 'pipe',
|
|
49
56
|
});
|
|
50
57
|
}
|
|
58
|
+
function primeCopy(src, dst) {
|
|
59
|
+
if (existsSync(dst)) {
|
|
60
|
+
rmSync(dst, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
cpSync(src, dst, { recursive: true, verbatimSymlinks: true });
|
|
63
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export function walkStatePath(repoRoot, planId) {
|
|
4
|
+
return join(repoRoot, '.cloverleaf', 'runs', 'plan', planId, 'walk-state.json');
|
|
5
|
+
}
|
|
6
|
+
export function readWalkState(repoRoot, planId) {
|
|
7
|
+
const path = walkStatePath(repoRoot, planId);
|
|
8
|
+
if (!existsSync(path))
|
|
9
|
+
return null;
|
|
10
|
+
const raw = readFileSync(path, 'utf-8');
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
}
|
|
13
|
+
export function writeWalkState(repoRoot, state) {
|
|
14
|
+
const path = walkStatePath(repoRoot, state.plan_id);
|
|
15
|
+
const dir = dirname(path);
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
18
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
|
|
19
|
+
try {
|
|
20
|
+
renameSync(tmp, path);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
try {
|
|
24
|
+
unlinkSync(tmp);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// best effort
|
|
28
|
+
}
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/lib/cli.ts
CHANGED
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
* next-work-item-id <repoRoot> <project>
|
|
30
30
|
* discovery-config --repo-root <repoRoot>
|
|
31
31
|
* prep-worktree <mainRoot> <worktreePath>
|
|
32
|
+
* dag-ready-tasks <repoRoot> <planId> <maxConcurrent>
|
|
33
|
+
* dag-detect-cycle <repoRoot> <planId>
|
|
34
|
+
* walk-state-read <repoRoot> <planId>
|
|
35
|
+
* walk-state-write <repoRoot> <walkStateJsonPath>
|
|
32
36
|
*/
|
|
33
37
|
|
|
34
38
|
import { readFileSync } from 'node:fs';
|
|
@@ -51,6 +55,8 @@ import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan, type P
|
|
|
51
55
|
import { loadDiscoveryConfig } from './discovery-config.js';
|
|
52
56
|
import { prepWorktree } from './prep-worktree.js';
|
|
53
57
|
import { readUiReviewState, writeUiReviewState } from './ui-review-state.js';
|
|
58
|
+
import { computeReadyTasks, detectCycle } from './dag-walker.js';
|
|
59
|
+
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.js';
|
|
54
60
|
|
|
55
61
|
function die(msg: string, code = 1): never {
|
|
56
62
|
process.stderr.write(msg + '\n');
|
|
@@ -85,7 +91,11 @@ function usage(msg?: string): never {
|
|
|
85
91
|
' materialise-tasks <repoRoot> <planId>\n' +
|
|
86
92
|
' next-work-item-id <repoRoot> <project>\n' +
|
|
87
93
|
' discovery-config --repo-root <repoRoot>\n' +
|
|
88
|
-
' prep-worktree <mainRoot> <worktreePath>\n'
|
|
94
|
+
' prep-worktree <mainRoot> <worktreePath>\n' +
|
|
95
|
+
' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
|
|
96
|
+
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
97
|
+
' walk-state-read <repoRoot> <planId>\n' +
|
|
98
|
+
' walk-state-write <repoRoot> <walkStateJsonPath>\n'
|
|
89
99
|
);
|
|
90
100
|
process.exit(2);
|
|
91
101
|
}
|
|
@@ -407,6 +417,60 @@ try {
|
|
|
407
417
|
break;
|
|
408
418
|
}
|
|
409
419
|
|
|
420
|
+
case 'dag-ready-tasks': {
|
|
421
|
+
const [repoRoot, planId, maxConcurrentStr] = rest;
|
|
422
|
+
if (!repoRoot || !planId || !maxConcurrentStr)
|
|
423
|
+
usage('dag-ready-tasks requires <repoRoot> <planId> <maxConcurrent>');
|
|
424
|
+
const maxConcurrent = parseInt(maxConcurrentStr, 10);
|
|
425
|
+
if (Number.isNaN(maxConcurrent) || maxConcurrent < 1)
|
|
426
|
+
die(`maxConcurrent must be a positive integer, got ${maxConcurrentStr}`);
|
|
427
|
+
const plan = loadPlan(repoRoot, planId);
|
|
428
|
+
const state = readWalkState(repoRoot, planId) ?? {
|
|
429
|
+
plan_id: planId,
|
|
430
|
+
started: new Date().toISOString(),
|
|
431
|
+
max_concurrent: maxConcurrent,
|
|
432
|
+
tasks: {},
|
|
433
|
+
};
|
|
434
|
+
const ready = computeReadyTasks(plan, state, maxConcurrent);
|
|
435
|
+
if (ready.length > 0) process.stdout.write(ready.join('\n') + '\n');
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
case 'dag-detect-cycle': {
|
|
440
|
+
const [repoRoot, planId] = rest;
|
|
441
|
+
if (!repoRoot || !planId) usage('dag-detect-cycle requires <repoRoot> <planId>');
|
|
442
|
+
const plan = loadPlan(repoRoot, planId);
|
|
443
|
+
const cycle = detectCycle(plan);
|
|
444
|
+
if (cycle) {
|
|
445
|
+
process.stdout.write(cycle.cycle.join(' → ') + '\n');
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
case 'walk-state-read': {
|
|
452
|
+
const [repoRoot, planId] = rest;
|
|
453
|
+
if (!repoRoot || !planId) usage('walk-state-read requires <repoRoot> <planId>');
|
|
454
|
+
const state = readWalkState(repoRoot, planId);
|
|
455
|
+
if (state === null) {
|
|
456
|
+
die(`walk-state not found for plan ${planId} at ${walkStatePath(repoRoot, planId)}`, 2);
|
|
457
|
+
}
|
|
458
|
+
process.stdout.write(JSON.stringify(state, null, 2) + '\n');
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
case 'walk-state-write': {
|
|
463
|
+
const [repoRoot, walkStateJsonPath] = rest;
|
|
464
|
+
if (!repoRoot || !walkStateJsonPath)
|
|
465
|
+
usage('walk-state-write requires <repoRoot> <walkStateJsonPath>');
|
|
466
|
+
const raw = readFileSync(walkStateJsonPath, 'utf-8');
|
|
467
|
+
const state = JSON.parse(raw);
|
|
468
|
+
if (!state.plan_id || typeof state.plan_id !== 'string')
|
|
469
|
+
die('walk-state JSON must include plan_id (string)');
|
|
470
|
+
writeWalkState(repoRoot, state);
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
|
|
410
474
|
default:
|
|
411
475
|
usage(`Unknown command: ${command}`);
|
|
412
476
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { PlanDoc } from './plan.js';
|
|
2
|
+
|
|
3
|
+
export interface WalkState {
|
|
4
|
+
plan_id: string;
|
|
5
|
+
started: string;
|
|
6
|
+
max_concurrent: number;
|
|
7
|
+
tasks: Record<
|
|
8
|
+
string,
|
|
9
|
+
| { state: 'pending' }
|
|
10
|
+
| { state: 'running'; session_id: string; started_at: string; last_seq: number }
|
|
11
|
+
| {
|
|
12
|
+
state: 'awaiting_final_gate';
|
|
13
|
+
session_id: string;
|
|
14
|
+
started_at: string;
|
|
15
|
+
last_seq: number;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
state: 'merged';
|
|
19
|
+
session_id: string;
|
|
20
|
+
merged_at: string;
|
|
21
|
+
merge_commit: string;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
state: 'escalated';
|
|
25
|
+
session_id: string;
|
|
26
|
+
escalated_at: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
}
|
|
29
|
+
>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Scheduler for the DAG walker. Given a Plan and the current walk state, returns the
|
|
34
|
+
* task IDs that are safe to spawn a Session B for right now:
|
|
35
|
+
*
|
|
36
|
+
* 1. Status is effectively `pending` (no state recorded, or recorded as `pending`).
|
|
37
|
+
* 2. Every ancestor in the task_dag has recorded state === 'merged'.
|
|
38
|
+
* 3. The count of returned IDs is capped at `maxConcurrent - currentlyRunning`.
|
|
39
|
+
*
|
|
40
|
+
* Order is deterministic (sorted ascending by task id) so callers can be reproducible.
|
|
41
|
+
*/
|
|
42
|
+
export function computeReadyTasks(
|
|
43
|
+
plan: PlanDoc,
|
|
44
|
+
walkState: WalkState,
|
|
45
|
+
maxConcurrent: number,
|
|
46
|
+
): string[] {
|
|
47
|
+
const running = Object.values(walkState.tasks).filter(
|
|
48
|
+
(t) => t.state === 'running' || t.state === 'awaiting_final_gate',
|
|
49
|
+
).length;
|
|
50
|
+
|
|
51
|
+
const slots = Math.max(0, maxConcurrent - running);
|
|
52
|
+
if (slots === 0) return [];
|
|
53
|
+
|
|
54
|
+
const parents: Record<string, string[]> = {};
|
|
55
|
+
for (const node of plan.task_dag.nodes) {
|
|
56
|
+
parents[node.id] = [];
|
|
57
|
+
}
|
|
58
|
+
for (const edge of plan.task_dag.edges) {
|
|
59
|
+
const to = edge.to.id;
|
|
60
|
+
const from = edge.from.id;
|
|
61
|
+
if (!parents[to]) parents[to] = [];
|
|
62
|
+
parents[to].push(from);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ready: string[] = [];
|
|
66
|
+
const allIds = plan.task_dag.nodes.map((n) => n.id).sort();
|
|
67
|
+
for (const id of allIds) {
|
|
68
|
+
const current = walkState.tasks[id];
|
|
69
|
+
const isPending = !current || current.state === 'pending';
|
|
70
|
+
if (!isPending) continue;
|
|
71
|
+
|
|
72
|
+
const ancestorsMerged = (parents[id] ?? []).every(
|
|
73
|
+
(p) => walkState.tasks[p]?.state === 'merged',
|
|
74
|
+
);
|
|
75
|
+
if (!ancestorsMerged) continue;
|
|
76
|
+
|
|
77
|
+
ready.push(id);
|
|
78
|
+
if (ready.length >= slots) break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return ready;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns `null` if the Plan's task_dag is acyclic. If a cycle is present, returns
|
|
86
|
+
* `{ cycle: string[] }` naming the task IDs involved in one cycle (order is the
|
|
87
|
+
* traversal order, which is a valid witness of the cycle).
|
|
88
|
+
*
|
|
89
|
+
* Uses Tarjan-style DFS with a white/grey/black colouring.
|
|
90
|
+
*/
|
|
91
|
+
export function detectCycle(plan: PlanDoc): { cycle: string[] } | null {
|
|
92
|
+
const adj: Record<string, string[]> = {};
|
|
93
|
+
for (const node of plan.task_dag.nodes) adj[node.id] = [];
|
|
94
|
+
for (const edge of plan.task_dag.edges) {
|
|
95
|
+
const from = edge.from.id;
|
|
96
|
+
if (!adj[from]) adj[from] = [];
|
|
97
|
+
adj[from].push(edge.to.id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const WHITE = 0;
|
|
101
|
+
const GREY = 1;
|
|
102
|
+
const BLACK = 2;
|
|
103
|
+
const color: Record<string, number> = {};
|
|
104
|
+
for (const id of Object.keys(adj)) color[id] = WHITE;
|
|
105
|
+
|
|
106
|
+
const path: string[] = [];
|
|
107
|
+
|
|
108
|
+
function dfs(id: string): string[] | null {
|
|
109
|
+
color[id] = GREY;
|
|
110
|
+
path.push(id);
|
|
111
|
+
for (const next of adj[id] ?? []) {
|
|
112
|
+
if (color[next] === GREY) {
|
|
113
|
+
const start = path.indexOf(next);
|
|
114
|
+
return path.slice(start);
|
|
115
|
+
}
|
|
116
|
+
if (color[next] === WHITE) {
|
|
117
|
+
const cycle = dfs(next);
|
|
118
|
+
if (cycle) return cycle;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
color[id] = BLACK;
|
|
122
|
+
path.pop();
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const id of Object.keys(adj)) {
|
|
127
|
+
if (color[id] === WHITE) {
|
|
128
|
+
const cycle = dfs(id);
|
|
129
|
+
if (cycle) return { cycle };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
package/lib/events.ts
CHANGED
|
@@ -40,16 +40,20 @@ export function formatReason(opts: { gate?: string; path?: string }): string | u
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Emits a status-transition event to `.cloverleaf/events/`.
|
|
43
|
-
* File name: `<
|
|
43
|
+
* File name: `<workItemId>-<NNN>-status.json` where NNN is the next per-work-item
|
|
44
44
|
* sequential number derived from existing event files.
|
|
45
45
|
*
|
|
46
|
+
* The filename scopes the counter to a single work item (v0.6 change), so
|
|
47
|
+
* parallel Delivery sessions on sibling tasks produce non-colliding event
|
|
48
|
+
* filenames that merge cleanly.
|
|
49
|
+
*
|
|
46
50
|
* Returns the absolute path of the written file.
|
|
47
51
|
*/
|
|
48
52
|
export function emitStatusTransition(repoRoot: string, params: StatusTransitionParams): string {
|
|
49
53
|
const { project, workItemType, workItemId, from, to, actor } = params;
|
|
50
|
-
const seq = nextEventId(repoRoot,
|
|
54
|
+
const seq = nextEventId(repoRoot, workItemId);
|
|
51
55
|
const seqStr = String(seq).padStart(3, '0');
|
|
52
|
-
const filename = `${
|
|
56
|
+
const filename = `${workItemId}-${seqStr}-status.json`;
|
|
53
57
|
const filePath = join(eventsDir(repoRoot), filename);
|
|
54
58
|
|
|
55
59
|
// Build reason from gate and/or path if provided (schema only allows reason, not gate/path at top level).
|
|
@@ -77,15 +81,16 @@ export function emitStatusTransition(repoRoot: string, params: StatusTransitionP
|
|
|
77
81
|
|
|
78
82
|
/**
|
|
79
83
|
* Emits a gate-decision event to `.cloverleaf/events/`.
|
|
80
|
-
* File name: `<
|
|
84
|
+
* File name: `<workItemId>-<NNN>-gate.json`. Counter is scoped to the work item
|
|
85
|
+
* (v0.6 change — see `emitStatusTransition` for rationale).
|
|
81
86
|
*
|
|
82
87
|
* Returns the absolute path of the written file.
|
|
83
88
|
*/
|
|
84
89
|
export function emitGateDecision(repoRoot: string, params: GateDecisionParams): string {
|
|
85
90
|
const { project, workItemId, gate, decision, actor, reasoning } = params;
|
|
86
|
-
const seq = nextEventId(repoRoot,
|
|
91
|
+
const seq = nextEventId(repoRoot, workItemId);
|
|
87
92
|
const seqStr = String(seq).padStart(3, '0');
|
|
88
|
-
const filename = `${
|
|
93
|
+
const filename = `${workItemId}-${seqStr}-gate.json`;
|
|
89
94
|
const filePath = join(eventsDir(repoRoot), filename);
|
|
90
95
|
|
|
91
96
|
const doc: Record<string, unknown> = {
|
package/lib/ids.ts
CHANGED
|
@@ -13,10 +13,16 @@ export function nextTaskId(repoRoot: string, project: string): string {
|
|
|
13
13
|
return `${project}-${String(next).padStart(3, '0')}`;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function nextEventId(repoRoot: string,
|
|
16
|
+
export function nextEventId(repoRoot: string, workItemId: string): number {
|
|
17
17
|
const dir = eventsDir(repoRoot);
|
|
18
18
|
if (!existsSync(dir)) return 1;
|
|
19
|
-
|
|
19
|
+
// Per-work-item sequence. Filenames are `<workItemId>-<NNN>-<status|gate>.json`,
|
|
20
|
+
// which keeps counters scoped to a single task / RFC / Spike / Plan — this matters
|
|
21
|
+
// for the v0.6 DAG walker's parallel mode, where multiple worktrees emit events
|
|
22
|
+
// simultaneously. A global per-project counter (the pre-v0.6 scheme) produced
|
|
23
|
+
// filename collisions when the walker merged sibling feature branches. Per-work-item
|
|
24
|
+
// scoping means each task's counter is independent; merges union cleanly.
|
|
25
|
+
const re = new RegExp(`^${escapeRegex(workItemId)}-(\\d+)-(status|gate)\\.json$`);
|
|
20
26
|
const nums = readdirSync(dir)
|
|
21
27
|
.map((f) => f.match(re))
|
|
22
28
|
.filter((m): m is RegExpMatchArray => !!m)
|
package/lib/index.ts
CHANGED
package/lib/prep-worktree.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cpSync, existsSync } from 'node:fs';
|
|
1
|
+
import { cpSync, existsSync, rmSync } from 'node:fs';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
@@ -45,11 +45,25 @@ export function prepWorktree(mainRoot: string, worktreePath: string): void {
|
|
|
45
45
|
|
|
46
46
|
// verbatimSymlinks keeps relative symlink targets byte-identical, so the @cloverleaf/standard
|
|
47
47
|
// link in reference-impl/node_modules/ resolves against the worktree after copy.
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
//
|
|
49
|
+
// primeCopy wipes the destination before cpSync. Two reasons:
|
|
50
|
+
// 1. Idempotence: a partial prior run (or a re-invocation after a test failure) may
|
|
51
|
+
// leave partial state; we must not trip on it.
|
|
52
|
+
// 2. cpSync with verbatimSymlinks: true does not reliably overwrite an existing
|
|
53
|
+
// symlink at the destination even with force: true (CLV-20 Reviewer repro was
|
|
54
|
+
// EEXIST on vite/node_modules/.bin on second invocation).
|
|
55
|
+
primeCopy(mainStandardNm, wtStandardNm);
|
|
56
|
+
primeCopy(mainRefImplNm, wtRefImplNm);
|
|
50
57
|
|
|
51
58
|
execSync('npm run build', {
|
|
52
59
|
cwd: join(worktreePath, 'standard'),
|
|
53
60
|
stdio: 'pipe',
|
|
54
61
|
});
|
|
55
62
|
}
|
|
63
|
+
|
|
64
|
+
function primeCopy(src: string, dst: string): void {
|
|
65
|
+
if (existsSync(dst)) {
|
|
66
|
+
rmSync(dst, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
cpSync(src, dst, { recursive: true, verbatimSymlinks: true });
|
|
69
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import type { WalkState } from './dag-walker.js';
|
|
11
|
+
|
|
12
|
+
export function walkStatePath(repoRoot: string, planId: string): string {
|
|
13
|
+
return join(repoRoot, '.cloverleaf', 'runs', 'plan', planId, 'walk-state.json');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readWalkState(repoRoot: string, planId: string): WalkState | null {
|
|
17
|
+
const path = walkStatePath(repoRoot, planId);
|
|
18
|
+
if (!existsSync(path)) return null;
|
|
19
|
+
const raw = readFileSync(path, 'utf-8');
|
|
20
|
+
return JSON.parse(raw) as WalkState;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeWalkState(repoRoot: string, state: WalkState): void {
|
|
24
|
+
const path = walkStatePath(repoRoot, state.plan_id);
|
|
25
|
+
const dir = dirname(path);
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
28
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
|
|
29
|
+
try {
|
|
30
|
+
renameSync(tmp, path);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
try {
|
|
33
|
+
unlinkSync(tmp);
|
|
34
|
+
} catch {
|
|
35
|
+
// best effort
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"test:watch": "vitest",
|
|
45
45
|
"typecheck": "tsc --noEmit",
|
|
46
46
|
"build": "tsc -p tsconfig.build.json && node scripts/rename-to-mjs.mjs",
|
|
47
|
+
"acceptance:walker": "bash scripts/acceptance-walker.sh",
|
|
47
48
|
"prepublishOnly": "npm test && npm run build"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
@@ -84,8 +84,16 @@ Report:
|
|
|
84
84
|
|
|
85
85
|
## Rules
|
|
86
86
|
|
|
87
|
-
- Only proceed on explicit `y
|
|
87
|
+
- Only proceed on explicit `y/Y/yes/YES`. Anything else is treated as either a decline (`n/N/no/NO`) or a clarifying question (see below).
|
|
88
88
|
- The skill does NOT push the branch or open a PR.
|
|
89
89
|
- Fast lane and full pipeline use distinct gates — the state machine records which path was taken.
|
|
90
90
|
- Full-pipeline merges perform a real `git merge --no-ff` before advancing state — the feature branch's code, baselines, and feedback commits all land on main.
|
|
91
91
|
- If the user declines, no state change and no commit.
|
|
92
|
+
|
|
93
|
+
## Clarifying questions at final-gate
|
|
94
|
+
|
|
95
|
+
If the user's response to the "Confirm merge? (y/N)" prompt is **not** one of `y/Y/yes/YES` or `n/N/no/NO`, treat it as a clarifying question. Answer it from the pipeline context available to you — the Reviewer/UI Reviewer/QA summaries, the diff, the task's ACs, the feedback files — and then **re-prompt** with the same y/N question.
|
|
96
|
+
|
|
97
|
+
Loop this until the user gives a definitive y or n. Do not perform the merge until you see `y/Y/yes/YES`. Do not mark the task declined until you see `n/N/no/NO`.
|
|
98
|
+
|
|
99
|
+
This keeps manual merges and walker-driven merges (`/cloverleaf-run-plan`) consistent: the user gets to interrogate the summary before committing.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cloverleaf-run-plan
|
|
3
|
+
description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in status `gate-approved`, drives each task in the plan's task_dag through Delivery concurrently by spawning one claw-drive Session B per ready task. Default max_concurrent is 3. Surfaces only escalations and per-task final-gate approvals to the human. Resumable across invocations. Usage — /cloverleaf-run-plan <PLAN-ID> [--max-concurrent=N] [--reset].
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cloverleaf — run-plan
|
|
7
|
+
|
|
8
|
+
## Steps
|
|
9
|
+
|
|
10
|
+
0. **Pre-flight.**
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cd <repo_root>
|
|
14
|
+
current=$(git rev-parse --abbrev-ref HEAD)
|
|
15
|
+
if [ "$current" != "main" ]; then git checkout main; fi
|
|
16
|
+
git status --short
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If `main` has uncommitted changes, stop and report — the user must clean up first.
|
|
20
|
+
|
|
21
|
+
1. Capture the `<PLAN-ID>` argument and optional flags:
|
|
22
|
+
|
|
23
|
+
- `--max-concurrent=N` — cap simultaneous sessions. Default `3`. Setting `--max-concurrent=1` yields serial behaviour.
|
|
24
|
+
- `--reset` — wipe `.cloverleaf/runs/plan/<PLAN-ID>/walk-state.json` and start fresh.
|
|
25
|
+
|
|
26
|
+
2. **Guard against cycles.**
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
cloverleaf-cli dag-detect-cycle <repo_root> <PLAN-ID>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
If non-zero exit, stop. The malformed Plan needs to be fixed first.
|
|
33
|
+
|
|
34
|
+
3. **Load or initialise walk-state.**
|
|
35
|
+
|
|
36
|
+
On `--reset`, `rm -f <repo_root>/.cloverleaf/runs/plan/<PLAN-ID>/walk-state.json`. Then:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
if ! cloverleaf-cli walk-state-read <repo_root> <PLAN-ID> > /tmp/walk-state-<PLAN-ID>.json 2>/dev/null; then
|
|
40
|
+
cat > /tmp/walk-state-<PLAN-ID>.json <<EOF
|
|
41
|
+
{
|
|
42
|
+
"plan_id": "<PLAN-ID>",
|
|
43
|
+
"started": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
44
|
+
"max_concurrent": <MAX_CONCURRENT>,
|
|
45
|
+
"tasks": {}
|
|
46
|
+
}
|
|
47
|
+
EOF
|
|
48
|
+
cloverleaf-cli walk-state-write <repo_root> /tmp/walk-state-<PLAN-ID>.json
|
|
49
|
+
fi
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
4. **Resumability: reconcile running sessions.**
|
|
53
|
+
|
|
54
|
+
For each task in the walk-state with `state === "running"`:
|
|
55
|
+
|
|
56
|
+
- Query `claw-drive sessions` — is the session still live?
|
|
57
|
+
- **Still running** → keep it, start the watch monitor from `last_seq`.
|
|
58
|
+
- **Stopped cleanly** → check the task's on-disk status:
|
|
59
|
+
- `merged` → update walk-state `state: "merged"`.
|
|
60
|
+
- Anything else → mark `state: "pending"` for re-scheduling.
|
|
61
|
+
- **Stopped with error** → mark `state: "pending"`.
|
|
62
|
+
|
|
63
|
+
Atomic walk-state writes: every update goes through `cloverleaf-cli walk-state-write`.
|
|
64
|
+
|
|
65
|
+
5. **Schedule loop.** Repeat until no running sessions AND no ready tasks:
|
|
66
|
+
|
|
67
|
+
a. **Compute ready tasks.**
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cloverleaf-cli dag-ready-tasks <repo_root> <PLAN-ID> <MAX_CONCURRENT>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Returns a newline-separated list of task IDs that are `pending`, have all ancestors `merged`, and fit within free concurrency slots.
|
|
74
|
+
|
|
75
|
+
b. **For each ready task**, isolate it in its own git worktree and spawn a claw-drive Session B rooted in that worktree. A shared `cwd` across concurrent sessions is **unsafe** — each Session B's `/cloverleaf-run` mutates HEAD via `git checkout -b cloverleaf/<TASK-ID>`, and parallel Sessions on one working tree irreparably clobber each other's branches and state. Worktrees give each Session its own working directory so code + state commits on the task branch are fully isolated; the walker (in the primary repo, on `main`) handles the final merge serially.
|
|
76
|
+
|
|
77
|
+
Per ready task:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
WT="/tmp/walker-<PLAN-ID>-<TASK-ID>"
|
|
81
|
+
rm -rf "$WT" # idempotent: clean any leftover from a prior run
|
|
82
|
+
git -C <repo_root> worktree add "$WT" -b cloverleaf/<TASK-ID> main
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Then `mcp__claw-drive__start_session` with:
|
|
86
|
+
|
|
87
|
+
- `cwd`: `$WT` (NOT `<repo_root>`)
|
|
88
|
+
- `decision_timeout_seconds`: `3600`
|
|
89
|
+
- `scenario_brief`: constructed for this task (see "Session brief template" below — critically, the brief instructs Session B to stop **before** invoking `/cloverleaf-merge`; the walker merges on main in step 5e).
|
|
90
|
+
- `policy`: the v0.6 walker policy (see "Walker policy" below).
|
|
91
|
+
|
|
92
|
+
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`.
|
|
93
|
+
|
|
94
|
+
c. **Monitor live sessions.** Start `claw-drive watch <session_id> --since <last_seq>` 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).
|
|
95
|
+
|
|
96
|
+
d. **Handle events.**
|
|
97
|
+
|
|
98
|
+
- **tool_decision_required** → let the walker policy decide (auto-approve per rules, defer to user for anything not covered).
|
|
99
|
+
- **turn_completed with final-gate prompt text** → push onto the final-gate queue.
|
|
100
|
+
- **Escalation detected** (assistant text contains `escalated` / Reviewer/QA/UI-Reviewer bounce cap / git merge abort) → **surface to user immediately** with:
|
|
101
|
+
> ⚠️ `<TASK-ID>` escalated at `<agent>` (reason: `<detail>`). Session `<session_id>`. Descendants in this Plan are now blocked until you unstick it.
|
|
102
|
+
> 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.
|
|
103
|
+
Mark the task `state: "escalated"` in walk-state; do NOT queue it behind final-gate approvals; continue other branches.
|
|
104
|
+
- **session_stopped** → reconcile as in step 4.
|
|
105
|
+
- **Per-session idle > 30 min** → surface to user for inspection; do NOT auto-kill.
|
|
106
|
+
|
|
107
|
+
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:
|
|
108
|
+
|
|
109
|
+
1. Print a full summary to the driver:
|
|
110
|
+
```
|
|
111
|
+
⏵ <TASK-ID> ready to merge (<fast lane | full pipeline>)
|
|
112
|
+
Reviewer: <summary>
|
|
113
|
+
UI Reviewer: <summary or "skipped">
|
|
114
|
+
QA: <summary or "n/a for fast lane">
|
|
115
|
+
Session <session_id>, worktree <worktree_path>
|
|
116
|
+
|
|
117
|
+
Confirm merge? (y/N, or ask a question)
|
|
118
|
+
```
|
|
119
|
+
2. Read the user's response.
|
|
120
|
+
3. If it matches `^y(es)?$|^Y(ES)?$` → perform the merge in the primary repo:
|
|
121
|
+
```bash
|
|
122
|
+
cd <repo_root>
|
|
123
|
+
git checkout main
|
|
124
|
+
git merge --no-ff cloverleaf/<TASK-ID> -m "cloverleaf: <TASK-ID> merged (<fast_lane | full_pipeline>)"
|
|
125
|
+
```
|
|
126
|
+
Then advance state and commit:
|
|
127
|
+
```bash
|
|
128
|
+
# Fast lane:
|
|
129
|
+
cloverleaf-cli emit-gate-decision <repo_root> <TASK-ID> human_merge approve human
|
|
130
|
+
cloverleaf-cli advance-status <repo_root> <TASK-ID> merged human human_merge fast_lane
|
|
131
|
+
# Full pipeline (task is already at final-gate):
|
|
132
|
+
cloverleaf-cli emit-gate-decision <repo_root> <TASK-ID> final_approval_gate approve human
|
|
133
|
+
cloverleaf-cli advance-status <repo_root> <TASK-ID> merged human final_approval_gate full_pipeline
|
|
134
|
+
```
|
|
135
|
+
```bash
|
|
136
|
+
git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> merged"
|
|
137
|
+
```
|
|
138
|
+
Capture the merge commit SHA. Mark task `state: "merged"` with `merge_commit` in walk-state. Send `y` (informational) back to Session B so it can record the outcome and exit, but the walker is the authoritative merge-performer.
|
|
139
|
+
**Tear down the worktree**: `git -C <repo_root> worktree remove --force <worktree_path>`. Delete the branch is optional (keep if useful for post-hoc inspection).
|
|
140
|
+
4. If it matches `^n(o)?$|^N(O)?$` → mark task `state: "awaiting_final_gate"`. Send `n` to Session B. **Keep the worktree** so the user can re-run `/cloverleaf-merge <TASK-ID>` manually pointing at it, or fix and retry. Continue with the next queued task.
|
|
141
|
+
5. Otherwise → forward the user's text as a user turn to Session B via `mcp__claw-drive__send_turn` (it's a question). Wait for the session's next `turn_completed`. Print the answer. **Re-surface the same y/N prompt** (with the Q&A appended to shown context). Loop until step 3 or 4 fires.
|
|
142
|
+
|
|
143
|
+
Final-gate drain is strictly serial across tasks — one prompt, one decision, then the next. The merge itself is sequential on main for the same reason: two concurrent `git merge --no-ff` on main would race, even if the feature branches are independent.
|
|
144
|
+
|
|
145
|
+
f. **Exit check.** If no running sessions AND `dag-ready-tasks` returned empty AND the final-gate queue is empty, break the loop.
|
|
146
|
+
|
|
147
|
+
6. **Report.**
|
|
148
|
+
|
|
149
|
+
- `merged: [ ... ]` — with merge-commit SHAs.
|
|
150
|
+
- `escalated: [ ... ]` — with reason per task.
|
|
151
|
+
- `awaiting_final_gate: [ ... ]` — user said `n`; re-invoke `/cloverleaf-merge <TASK-ID>` to retry.
|
|
152
|
+
- `unreachable: [ ... ]` — descendants of escalated tasks.
|
|
153
|
+
|
|
154
|
+
If every task in the plan's `task_dag.nodes` has `state: "merged"`, print: "✓ Plan `<PLAN-ID>` complete."
|
|
155
|
+
|
|
156
|
+
## Session brief template
|
|
157
|
+
|
|
158
|
+
The walker constructs a per-task `scenario_brief` roughly like:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
You are driving <TASK-ID> Delivery via /cloverleaf-run inside a dedicated
|
|
162
|
+
git worktree at <worktree_path>. The worktree is checked out to branch
|
|
163
|
+
cloverleaf/<TASK-ID> (already created from main). Task risk_class: <class>.
|
|
164
|
+
|
|
165
|
+
Plan: invoke `/cloverleaf-run <TASK-ID>`.
|
|
166
|
+
|
|
167
|
+
**DO NOT invoke `/cloverleaf-merge`**. Fast lane stops after `/cloverleaf-review`
|
|
168
|
+
lands the task at `automated-gates`. Full pipeline stops after QA/UI-Review
|
|
169
|
+
lands the task at `final-gate`. Report status + summaries at that point and
|
|
170
|
+
exit cleanly. The walker runs in the primary repo on `main` and performs the
|
|
171
|
+
real `git merge --no-ff` itself after human approval — the worktree's main
|
|
172
|
+
branch can't be checked out concurrently, which is why the walker owns the
|
|
173
|
+
merge. If `/cloverleaf-run` would normally invoke `/cloverleaf-merge`
|
|
174
|
+
internally (fast-lane orchestrator), interrupt before that step and exit.
|
|
175
|
+
|
|
176
|
+
All four v0.5.2+v0.5.3+v0.5.4+v0.5.5 dogfood fixes are in place:
|
|
177
|
+
- /cloverleaf-merge actor: human final_approval_gate full_pipeline.
|
|
178
|
+
- cloverleaf-cli prep-worktree is idempotent.
|
|
179
|
+
- Documenter runs `git status --porcelain` and stages every modified doc.
|
|
180
|
+
- cloverleaf-ui-review uses /cloverleaf-approve-baselines (fully-qualified).
|
|
181
|
+
|
|
182
|
+
Expected: zero interventions until you reach automated-gates / final-gate,
|
|
183
|
+
then exit.
|
|
184
|
+
|
|
185
|
+
Do not push. Do not publish. Report merge + state commit SHAs on completion.
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Walker policy
|
|
189
|
+
|
|
190
|
+
The walker spawns each Session B with a conservative auto-approve policy (Read/Glob/Grep, git-read, cloverleaf-cli, npm/npx/node, common compound scripts, prep-worktree, mkdir -p, etc.) and an auto-reject list covering sudo, `rm -rf /`, git push, npm publish, destructive disk ops. Anything else escalates to the walker for human-in-the-loop handling.
|
|
191
|
+
|
|
192
|
+
The concrete policy JSON is the same one used during the CLV-16..CLV-20 dogfood runs; see `.cloverleaf/claw-drive-policy.json` in the repo for the starting template.
|
|
193
|
+
|
|
194
|
+
## Rules
|
|
195
|
+
|
|
196
|
+
- Never push. Never publish.
|
|
197
|
+
- Always persist walk-state via `cloverleaf-cli walk-state-write` (atomic). Never write the file directly.
|
|
198
|
+
- Always treat the on-disk `.cloverleaf/tasks/<id>.json` status as the source of truth AFTER a task's branch has been merged; before that, the task's state lives on `cloverleaf/<TASK-ID>` in its worktree (walk-state is authoritative for the walker's scheduling decisions during the walk).
|
|
199
|
+
- **Every ready task runs in its own git worktree.** Sharing `cwd` across concurrent sessions is unsafe — parallel `git checkout` / `commit` races corrupt branches and state. The walker creates a worktree per task, passes it to Session B as `cwd`, and owns the final merge serially on main.
|
|
200
|
+
- Session B must NOT invoke `/cloverleaf-merge`. The walker performs the merge in the primary repo, on main, as the authoritative merge-performer.
|
|
201
|
+
- Escalations surface immediately; they do NOT queue behind the final-gate drain.
|
|
202
|
+
- Final-gate drain is serial across tasks — one prompt, one decision.
|
|
203
|
+
- The walker exits after the loop reports the final status; it does not auto-retry escalated tasks.
|