@cloverleaf/reference-impl 0.5.5 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cloverleaf",
3
3
  "description": "Cloverleaf reference implementation — Claude Code skills for task scaffolding and the Delivery pipeline (implementer, documenter, reviewer, UI reviewer with multi-viewport visual diff, QA, merge).",
4
- "version": "0.5.5",
4
+ "version": "0.6.0",
5
5
  "author": {
6
6
  "name": "Renato D'Arrigo",
7
7
  "email": "renato.darrigo@gmail.com"
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.5.5
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: `<PROJECT>-<NNN>-status.json` where NNN is the next per-project
21
+ * File name: `<workItemId>-<NNN>-status.json` where NNN is the next per-work-item
22
22
  * sequential number derived from existing event files.
23
23
  *
24
+ * The filename scopes the counter to a single work item (v0.6 change), so
25
+ * parallel Delivery sessions on sibling tasks produce non-colliding event
26
+ * filenames that merge cleanly.
27
+ *
24
28
  * Returns the absolute path of the written file.
25
29
  */
26
30
  export function emitStatusTransition(repoRoot, params) {
27
31
  const { project, workItemType, workItemId, from, to, actor } = params;
28
- const seq = nextEventId(repoRoot, project);
32
+ const seq = nextEventId(repoRoot, workItemId);
29
33
  const seqStr = String(seq).padStart(3, '0');
30
- const filename = `${project}-${seqStr}-status.json`;
34
+ const filename = `${workItemId}-${seqStr}-status.json`;
31
35
  const filePath = join(eventsDir(repoRoot), filename);
32
36
  // Build reason from gate and/or path if provided (schema only allows reason, not gate/path at top level).
33
37
  const reason = formatReason({ gate: params.gate, path: params.path });
@@ -51,15 +55,16 @@ export function emitStatusTransition(repoRoot, params) {
51
55
  }
52
56
  /**
53
57
  * Emits a gate-decision event to `.cloverleaf/events/`.
54
- * File name: `<PROJECT>-<NNN>-gate.json`.
58
+ * File name: `<workItemId>-<NNN>-gate.json`. Counter is scoped to the work item
59
+ * (v0.6 change — see `emitStatusTransition` for rationale).
55
60
  *
56
61
  * Returns the absolute path of the written file.
57
62
  */
58
63
  export function emitGateDecision(repoRoot, params) {
59
64
  const { project, workItemId, gate, decision, actor, reasoning } = params;
60
- const seq = nextEventId(repoRoot, project);
65
+ const seq = nextEventId(repoRoot, workItemId);
61
66
  const seqStr = String(seq).padStart(3, '0');
62
- const filename = `${project}-${seqStr}-gate.json`;
67
+ const filename = `${workItemId}-${seqStr}-gate.json`;
63
68
  const filePath = join(eventsDir(repoRoot), filename);
64
69
  const doc = {
65
70
  event_id: randomUUID(),
package/dist/ids.mjs CHANGED
@@ -12,11 +12,17 @@ export function nextTaskId(repoRoot, project) {
12
12
  const next = nums.length === 0 ? 1 : Math.max(...nums) + 1;
13
13
  return `${project}-${String(next).padStart(3, '0')}`;
14
14
  }
15
- export function nextEventId(repoRoot, project) {
15
+ export function nextEventId(repoRoot, workItemId) {
16
16
  const dir = eventsDir(repoRoot);
17
17
  if (!existsSync(dir))
18
18
  return 1;
19
- const re = new RegExp(`^${escapeRegex(project)}-(\\d+)-(status|gate)\\.json$`);
19
+ // Per-work-item sequence. Filenames are `<workItemId>-<NNN>-<status|gate>.json`,
20
+ // which keeps counters scoped to a single task / RFC / Spike / Plan — this matters
21
+ // for the v0.6 DAG walker's parallel mode, where multiple worktrees emit events
22
+ // simultaneously. A global per-project counter (the pre-v0.6 scheme) produced
23
+ // filename collisions when the walker merged sibling feature branches. Per-work-item
24
+ // scoping means each task's counter is independent; merges union cleanly.
25
+ const re = new RegExp(`^${escapeRegex(workItemId)}-(\\d+)-(status|gate)\\.json$`);
20
26
  const nums = readdirSync(dir)
21
27
  .map((f) => f.match(re))
22
28
  .filter((m) => !!m)
package/dist/index.mjs CHANGED
@@ -5,3 +5,5 @@ export * from './events.mjs';
5
5
  export * from './feedback.mjs';
6
6
  export * from './validate.mjs';
7
7
  export * from './ui-review-state.mjs';
8
+ export * from './dag-walker.mjs';
9
+ export * from './walk-state.mjs';
@@ -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: `<PROJECT>-<NNN>-status.json` where NNN is the next per-project
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, project);
54
+ const seq = nextEventId(repoRoot, workItemId);
51
55
  const seqStr = String(seq).padStart(3, '0');
52
- const filename = `${project}-${seqStr}-status.json`;
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: `<PROJECT>-<NNN>-gate.json`.
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, project);
91
+ const seq = nextEventId(repoRoot, workItemId);
87
92
  const seqStr = String(seq).padStart(3, '0');
88
- const filename = `${project}-${seqStr}-gate.json`;
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, project: string): number {
16
+ export function nextEventId(repoRoot: string, workItemId: string): number {
17
17
  const dir = eventsDir(repoRoot);
18
18
  if (!existsSync(dir)) return 1;
19
- const re = new RegExp(`^${escapeRegex(project)}-(\\d+)-(status|gate)\\.json$`);
19
+ // Per-work-item sequence. Filenames are `<workItemId>-<NNN>-<status|gate>.json`,
20
+ // which keeps counters scoped to a single task / RFC / Spike / Plan — this matters
21
+ // for the v0.6 DAG walker's parallel mode, where multiple worktrees emit events
22
+ // simultaneously. A global per-project counter (the pre-v0.6 scheme) produced
23
+ // filename collisions when the walker merged sibling feature branches. Per-work-item
24
+ // scoping means each task's counter is independent; merges union cleanly.
25
+ const re = new RegExp(`^${escapeRegex(workItemId)}-(\\d+)-(status|gate)\\.json$`);
20
26
  const nums = readdirSync(dir)
21
27
  .map((f) => f.match(re))
22
28
  .filter((m): m is RegExpMatchArray => !!m)
package/lib/index.ts CHANGED
@@ -5,3 +5,5 @@ export * from './events.js';
5
5
  export * from './feedback.js';
6
6
  export * from './validate.js';
7
7
  export * from './ui-review-state.js';
8
+ export * from './dag-walker.js';
9
+ export * from './walk-state.js';
@@ -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.5.5",
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`, `Y`, `yes`, `YES`. Anything else: abort without state change.
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.