@cloverleaf/reference-impl 0.4.1 → 0.5.1

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.4.1",
4
+ "version": "0.5.1",
5
5
  "author": {
6
6
  "name": "Renato D'Arrigo",
7
7
  "email": "renato.darrigo@gmail.com"
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.1
1
+ 0.5.1
@@ -0,0 +1,5 @@
1
+ {
2
+ "docContextUri": "",
3
+ "projectId": "",
4
+ "idStart": 1
5
+ }
package/dist/cli.mjs CHANGED
@@ -14,20 +14,36 @@
14
14
  * emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
15
15
  * ui-review-config --repo-root <repoRoot>
16
16
  * plugin-root
17
+ * load-rfc <repoRoot> <id>
18
+ * save-rfc <repoRoot> <filePath>
19
+ * advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]
20
+ * load-spike <repoRoot> <id>
21
+ * save-spike <repoRoot> <filePath>
22
+ * advance-spike <repoRoot> <id> <toStatus> <agent|human>
23
+ * load-plan <repoRoot> <id>
24
+ * save-plan <repoRoot> <filePath>
25
+ * advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]
26
+ * materialise-tasks <repoRoot> <planId>
27
+ * next-work-item-id <repoRoot> <project>
28
+ * discovery-config --repo-root <repoRoot>
17
29
  */
18
30
  import { readFileSync } from 'node:fs';
19
31
  import { execSync } from 'node:child_process';
20
- import { loadTask } from './state.mjs';
21
- import { advanceStatus } from './state.mjs';
32
+ import { loadTask } from './task.mjs';
33
+ import { advanceStatus } from './task.mjs';
22
34
  import { emitGateDecision } from './events.mjs';
23
35
  import { writeFeedback, latestFeedback } from './feedback.mjs';
24
- import { nextTaskId, inferProject } from './ids.mjs';
36
+ import { nextTaskId, inferProject, nextWorkItemId } from './ids.mjs';
25
37
  import { matchesUiPaths } from './ui-paths.mjs';
26
38
  import { loadUiPathsConfig } from './ui-paths.mjs';
27
39
  import { computeAffectedRoutes } from './affected-routes.mjs';
28
40
  import { loadAffectedRoutesConfig } from './affected-routes.mjs';
29
41
  import { loadUiReviewConfig } from './ui-review-config.mjs';
30
42
  import { getPluginRoot } from './plugin-path.mjs';
43
+ import { loadRfc, saveRfc, advanceRfcStatus } from './rfc.mjs';
44
+ import { loadSpike, saveSpike, advanceSpikeStatus } from './spike.mjs';
45
+ import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan } from './plan.mjs';
46
+ import { loadDiscoveryConfig } from './discovery-config.mjs';
31
47
  function die(msg, code = 1) {
32
48
  process.stderr.write(msg + '\n');
33
49
  process.exit(code);
@@ -45,7 +61,19 @@ function usage(msg) {
45
61
  ' latest-feedback <repoRoot> <taskId>\n' +
46
62
  ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
47
63
  ' ui-review-config --repo-root <repoRoot>\n' +
48
- ' plugin-root\n');
64
+ ' plugin-root\n' +
65
+ ' load-rfc <repoRoot> <id>\n' +
66
+ ' save-rfc <repoRoot> <filePath>\n' +
67
+ ' advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
68
+ ' load-spike <repoRoot> <id>\n' +
69
+ ' save-spike <repoRoot> <filePath>\n' +
70
+ ' advance-spike <repoRoot> <id> <toStatus> <agent|human>\n' +
71
+ ' load-plan <repoRoot> <id>\n' +
72
+ ' save-plan <repoRoot> <filePath>\n' +
73
+ ' advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
74
+ ' materialise-tasks <repoRoot> <planId>\n' +
75
+ ' next-work-item-id <repoRoot> <project>\n' +
76
+ ' discovery-config --repo-root <repoRoot>\n');
49
77
  process.exit(2);
50
78
  }
51
79
  const [, , command, ...rest] = process.argv;
@@ -242,6 +270,104 @@ try {
242
270
  process.stdout.write(getPluginRoot());
243
271
  process.exit(0);
244
272
  }
273
+ case 'load-rfc': {
274
+ const [repoRoot, id] = rest;
275
+ if (!repoRoot || !id)
276
+ usage('load-rfc <repoRoot> <id>');
277
+ process.stdout.write(JSON.stringify(loadRfc(repoRoot, id), null, 2));
278
+ break;
279
+ }
280
+ case 'save-rfc': {
281
+ const [repoRoot, filePath] = rest;
282
+ if (!repoRoot || !filePath)
283
+ usage('save-rfc <repoRoot> <filePath>');
284
+ const rfc = JSON.parse(readFileSync(filePath, 'utf-8'));
285
+ saveRfc(repoRoot, rfc);
286
+ break;
287
+ }
288
+ case 'advance-rfc': {
289
+ const [repoRoot, id, toStatus, actor, gate] = rest;
290
+ if (!repoRoot || !id || !toStatus || !actor)
291
+ usage('advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]');
292
+ if (actor !== 'agent' && actor !== 'human')
293
+ usage('advance-rfc: actor must be agent or human');
294
+ const opts = gate ? { gate } : {};
295
+ advanceRfcStatus(repoRoot, id, toStatus, actor, opts);
296
+ break;
297
+ }
298
+ case 'load-spike': {
299
+ const [repoRoot, id] = rest;
300
+ if (!repoRoot || !id)
301
+ usage('load-spike <repoRoot> <id>');
302
+ process.stdout.write(JSON.stringify(loadSpike(repoRoot, id), null, 2));
303
+ break;
304
+ }
305
+ case 'save-spike': {
306
+ const [repoRoot, filePath] = rest;
307
+ if (!repoRoot || !filePath)
308
+ usage('save-spike <repoRoot> <filePath>');
309
+ const spike = JSON.parse(readFileSync(filePath, 'utf-8'));
310
+ saveSpike(repoRoot, spike);
311
+ break;
312
+ }
313
+ case 'advance-spike': {
314
+ const [repoRoot, id, toStatus, actor] = rest;
315
+ if (!repoRoot || !id || !toStatus || !actor)
316
+ usage('advance-spike <repoRoot> <id> <toStatus> <agent|human>');
317
+ if (actor !== 'agent' && actor !== 'human')
318
+ usage('advance-spike: actor must be agent or human');
319
+ advanceSpikeStatus(repoRoot, id, toStatus, actor);
320
+ break;
321
+ }
322
+ case 'load-plan': {
323
+ const [repoRoot, id] = rest;
324
+ if (!repoRoot || !id)
325
+ usage('load-plan <repoRoot> <id>');
326
+ process.stdout.write(JSON.stringify(loadPlan(repoRoot, id), null, 2));
327
+ break;
328
+ }
329
+ case 'save-plan': {
330
+ const [repoRoot, filePath] = rest;
331
+ if (!repoRoot || !filePath)
332
+ usage('save-plan <repoRoot> <filePath>');
333
+ const plan = JSON.parse(readFileSync(filePath, 'utf-8'));
334
+ savePlan(repoRoot, plan);
335
+ break;
336
+ }
337
+ case 'advance-plan': {
338
+ const [repoRoot, id, toStatus, actor, gate] = rest;
339
+ if (!repoRoot || !id || !toStatus || !actor)
340
+ usage('advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]');
341
+ if (actor !== 'agent' && actor !== 'human')
342
+ usage('advance-plan: actor must be agent or human');
343
+ const opts = gate ? { gate } : {};
344
+ advancePlanStatus(repoRoot, id, toStatus, actor, opts);
345
+ break;
346
+ }
347
+ case 'materialise-tasks': {
348
+ const [repoRoot, planId] = rest;
349
+ if (!repoRoot || !planId)
350
+ usage('materialise-tasks <repoRoot> <planId>');
351
+ const plan = loadPlan(repoRoot, planId);
352
+ const ids = materialiseTasksFromPlan(repoRoot, plan);
353
+ process.stdout.write(JSON.stringify({ task_ids: ids }));
354
+ break;
355
+ }
356
+ case 'next-work-item-id': {
357
+ const [repoRoot, project] = rest;
358
+ if (!repoRoot || !project)
359
+ usage('next-work-item-id <repoRoot> <project>');
360
+ process.stdout.write(nextWorkItemId(repoRoot, project));
361
+ break;
362
+ }
363
+ case 'discovery-config': {
364
+ const idx = rest.indexOf('--repo-root');
365
+ if (idx < 0 || !rest[idx + 1])
366
+ usage('discovery-config --repo-root <repoRoot>');
367
+ const c = loadDiscoveryConfig(rest[idx + 1]);
368
+ process.stdout.write(JSON.stringify(c, null, 2));
369
+ break;
370
+ }
245
371
  default:
246
372
  usage(`Unknown command: ${command}`);
247
373
  }
@@ -0,0 +1,26 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ const here = dirname(fileURLToPath(import.meta.url));
5
+ const PACKAGE_DEFAULT = join(here, '..', 'config', 'discovery.json');
6
+ export function loadDiscoveryConfig(repoRoot) {
7
+ const override = join(repoRoot, '.cloverleaf', 'config', 'discovery.json');
8
+ const fallback = JSON.parse(readFileSync(PACKAGE_DEFAULT, 'utf-8'));
9
+ if (existsSync(override)) {
10
+ try {
11
+ const doc = JSON.parse(readFileSync(override, 'utf-8'));
12
+ return normalise(doc, fallback);
13
+ }
14
+ catch {
15
+ // Malformed consumer JSON — fall through to package default.
16
+ }
17
+ }
18
+ return fallback;
19
+ }
20
+ function normalise(doc, fallback) {
21
+ return {
22
+ docContextUri: typeof doc.docContextUri === 'string' ? doc.docContextUri : fallback.docContextUri,
23
+ projectId: typeof doc.projectId === 'string' ? doc.projectId : fallback.projectId,
24
+ idStart: typeof doc.idStart === 'number' ? doc.idStart : fallback.idStart,
25
+ };
26
+ }
package/dist/ids.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readdirSync, existsSync } from 'node:fs';
2
- import { tasksDir, eventsDir, feedbackDir, projectsDir } from './paths.mjs';
2
+ import { tasksDir, eventsDir, feedbackDir, projectsDir, rfcsDir, spikesDir, plansDir } from './paths.mjs';
3
3
  export function nextTaskId(repoRoot, project) {
4
4
  const dir = tasksDir(repoRoot);
5
5
  if (!existsSync(dir))
@@ -55,6 +55,31 @@ export function inferProject(repoRoot, explicit) {
55
55
  }
56
56
  return projects[0];
57
57
  }
58
+ /**
59
+ * Returns unpadded per-project IDs (e.g., `CLV-13`) by scanning all four
60
+ * work-item directories and taking max+1. Matches the canonical convention
61
+ * used in @cloverleaf/standard example scenarios (oauth-rollout: ACME-100,
62
+ * ACME-200). Legacy `nextTaskId` retains three-digit padding (e.g., CLV-001)
63
+ * for back-compat with existing task files.
64
+ */
65
+ export function nextWorkItemId(repoRoot, project) {
66
+ const dirs = [rfcsDir(repoRoot), spikesDir(repoRoot), plansDir(repoRoot), tasksDir(repoRoot)];
67
+ const pat = new RegExp(`^${escapeRegex(project)}-(\\d+)\\.json$`);
68
+ let max = 0;
69
+ for (const d of dirs) {
70
+ if (!existsSync(d))
71
+ continue;
72
+ for (const f of readdirSync(d)) {
73
+ const m = pat.exec(f);
74
+ if (m) {
75
+ const n = parseInt(m[1], 10);
76
+ if (n > max)
77
+ max = n;
78
+ }
79
+ }
80
+ }
81
+ return `${project}-${max + 1}`;
82
+ }
58
83
  function escapeRegex(s) {
59
84
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
85
  }
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  export * from './paths.mjs';
2
2
  export * from './ids.mjs';
3
- export * from './state.mjs';
3
+ export * from './task.mjs';
4
4
  export * from './events.mjs';
5
5
  export * from './feedback.mjs';
6
6
  export * from './validate.mjs';
package/dist/plan.mjs ADDED
@@ -0,0 +1,116 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { plansDir, tasksDir } from './paths.mjs';
4
+ import { validateOrThrow } from './validate.mjs';
5
+ import { advanceWorkItemStatus, loadStateMachine } from './work-item.mjs';
6
+ export function loadPlan(repoRoot, id) {
7
+ const path = join(plansDir(repoRoot), `${id}.json`);
8
+ if (!existsSync(path))
9
+ throw new Error(`Plan ${id} not found at ${path}`);
10
+ return JSON.parse(readFileSync(path, 'utf-8'));
11
+ }
12
+ export function savePlan(repoRoot, plan) {
13
+ validateOrThrow('https://cloverleaf.example/schemas/plan.schema.json', plan);
14
+ mkdirSync(plansDir(repoRoot), { recursive: true });
15
+ const path = join(plansDir(repoRoot), `${plan.id}.json`);
16
+ writeFileSync(path, JSON.stringify(plan, null, 2) + '\n');
17
+ }
18
+ export function advancePlanStatus(repoRoot, id, toStatus, actor, options = {}) {
19
+ const plan = loadPlan(repoRoot, id);
20
+ const from = plan.status;
21
+ const sm = loadStateMachine('plan');
22
+ const fixture = { type: 'plan', id: plan.id, project: plan.project, status: plan.status };
23
+ const proposed = { ...plan, status: toStatus };
24
+ advanceWorkItemStatus({
25
+ repoRoot,
26
+ workItemType: 'plan',
27
+ project: plan.project,
28
+ id: plan.id,
29
+ from,
30
+ to: toStatus,
31
+ actor,
32
+ stateMachine: sm,
33
+ validateFixture: fixture,
34
+ save: (p) => savePlan(repoRoot, p),
35
+ proposed,
36
+ gate: options.gate,
37
+ });
38
+ return proposed;
39
+ }
40
+ /**
41
+ * Build a directed graph from the DAG's edges and detect any cycle.
42
+ * Returns the first node id involved in a cycle, or null.
43
+ */
44
+ function detectCycle(dag) {
45
+ // Build adjacency: for each node, list of node ids it points TO.
46
+ const adj = new Map();
47
+ for (const n of dag.nodes)
48
+ adj.set(n.id, []);
49
+ for (const e of dag.edges) {
50
+ const from = e.from.id;
51
+ const to = e.to.id;
52
+ if (!adj.has(from))
53
+ adj.set(from, []);
54
+ adj.get(from).push(to);
55
+ }
56
+ const state = new Map();
57
+ for (const n of dag.nodes)
58
+ state.set(n.id, 'white');
59
+ const visit = (id) => {
60
+ const s = state.get(id);
61
+ if (s === 'grey')
62
+ return true; // back-edge → cycle
63
+ if (s === 'black')
64
+ return false;
65
+ state.set(id, 'grey');
66
+ for (const next of adj.get(id) ?? []) {
67
+ if (visit(next))
68
+ return true;
69
+ }
70
+ state.set(id, 'black');
71
+ return false;
72
+ };
73
+ for (const n of dag.nodes) {
74
+ if (visit(n.id))
75
+ return n.id;
76
+ }
77
+ return null;
78
+ }
79
+ /**
80
+ * Materialise all inline tasks from an approved Plan onto disk as
81
+ * .cloverleaf/tasks/<id>.json. Atomic: pre-validates every task before
82
+ * any file write. Throws on cycle in task_dag or AJV failure — no
83
+ * partial materialisation on failure. Returns the ordered list of
84
+ * materialised task IDs.
85
+ *
86
+ * If a task file already exists at the target path, it is OVERWRITTEN.
87
+ * Callers responsible for Delivery state consistency should not invoke
88
+ * this on a Plan whose tasks are already materialised and in-flight.
89
+ *
90
+ * Called by /cloverleaf-discover after a human approves the Plan at
91
+ * task_batch_gate. The gate ensures this function is invoked at most
92
+ * once per Plan in normal operation.
93
+ */
94
+ export function materialiseTasksFromPlan(repoRoot, plan) {
95
+ // 1. Cycle check on edges.
96
+ const cycleAt = detectCycle(plan.task_dag);
97
+ if (cycleAt)
98
+ throw new Error(`Plan task_dag contains a cycle involving ${cycleAt}`);
99
+ // 2. Pre-validate every task before ANY file write.
100
+ for (const task of plan.tasks) {
101
+ validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
102
+ }
103
+ // 3. Ensure the tasks directory exists (no-op on fresh repos that haven't
104
+ // initialised .cloverleaf/tasks/ yet). Placed after cycle-check and
105
+ // validation so those fast-path shorts-circuit without any FS side-effect.
106
+ mkdirSync(tasksDir(repoRoot), { recursive: true });
107
+ // 4. Write all task files.
108
+ const ids = [];
109
+ for (const task of plan.tasks) {
110
+ const id = String(task['id']);
111
+ const path = join(tasksDir(repoRoot), `${id}.json`);
112
+ writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
113
+ ids.push(id);
114
+ }
115
+ return ids;
116
+ }
package/dist/rfc.mjs ADDED
@@ -0,0 +1,39 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { rfcsDir } from './paths.mjs';
4
+ import { validateOrThrow } from './validate.mjs';
5
+ import { advanceWorkItemStatus, loadStateMachine } from './work-item.mjs';
6
+ export function loadRfc(repoRoot, id) {
7
+ const path = join(rfcsDir(repoRoot), `${id}.json`);
8
+ if (!existsSync(path))
9
+ throw new Error(`RFC ${id} not found at ${path}`);
10
+ return JSON.parse(readFileSync(path, 'utf-8'));
11
+ }
12
+ export function saveRfc(repoRoot, rfc) {
13
+ validateOrThrow('https://cloverleaf.example/schemas/rfc.schema.json', rfc);
14
+ mkdirSync(rfcsDir(repoRoot), { recursive: true });
15
+ const path = join(rfcsDir(repoRoot), `${rfc.id}.json`);
16
+ writeFileSync(path, JSON.stringify(rfc, null, 2) + '\n');
17
+ }
18
+ export function advanceRfcStatus(repoRoot, id, toStatus, actor, options = {}) {
19
+ const rfc = loadRfc(repoRoot, id);
20
+ const from = rfc.status;
21
+ const sm = loadStateMachine('rfc');
22
+ const fixture = { type: 'rfc', id: rfc.id, project: rfc.project, status: rfc.status };
23
+ const proposed = { ...rfc, status: toStatus };
24
+ advanceWorkItemStatus({
25
+ repoRoot,
26
+ workItemType: 'rfc',
27
+ project: rfc.project,
28
+ id: rfc.id,
29
+ from,
30
+ to: toStatus,
31
+ actor,
32
+ stateMachine: sm,
33
+ validateFixture: fixture,
34
+ save: (p) => saveRfc(repoRoot, p),
35
+ proposed,
36
+ gate: options.gate,
37
+ });
38
+ return proposed;
39
+ }
package/dist/spike.mjs ADDED
@@ -0,0 +1,38 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { spikesDir } from './paths.mjs';
4
+ import { validateOrThrow } from './validate.mjs';
5
+ import { advanceWorkItemStatus, loadStateMachine } from './work-item.mjs';
6
+ export function loadSpike(repoRoot, id) {
7
+ const path = join(spikesDir(repoRoot), `${id}.json`);
8
+ if (!existsSync(path))
9
+ throw new Error(`Spike ${id} not found at ${path}`);
10
+ return JSON.parse(readFileSync(path, 'utf-8'));
11
+ }
12
+ export function saveSpike(repoRoot, spike) {
13
+ validateOrThrow('https://cloverleaf.example/schemas/spike.schema.json', spike);
14
+ mkdirSync(spikesDir(repoRoot), { recursive: true });
15
+ const path = join(spikesDir(repoRoot), `${spike.id}.json`);
16
+ writeFileSync(path, JSON.stringify(spike, null, 2) + '\n');
17
+ }
18
+ export function advanceSpikeStatus(repoRoot, id, toStatus, actor) {
19
+ const spike = loadSpike(repoRoot, id);
20
+ const from = spike.status;
21
+ const sm = loadStateMachine('spike');
22
+ const fixture = { type: 'spike', id: spike.id, project: spike.project, status: spike.status };
23
+ const proposed = { ...spike, status: toStatus };
24
+ advanceWorkItemStatus({
25
+ repoRoot,
26
+ workItemType: 'spike',
27
+ project: spike.project,
28
+ id: spike.id,
29
+ from,
30
+ to: toStatus,
31
+ actor,
32
+ stateMachine: sm,
33
+ validateFixture: fixture,
34
+ save: (p) => saveSpike(repoRoot, p),
35
+ proposed,
36
+ });
37
+ return proposed;
38
+ }
package/dist/task.mjs ADDED
@@ -0,0 +1,58 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { tasksDir, projectsDir } from './paths.mjs';
4
+ import { validateOrThrow } from './validate.mjs';
5
+ import { advanceWorkItemStatus, loadStateMachine } from './work-item.mjs';
6
+ export function loadTask(repoRoot, taskId) {
7
+ const path = join(tasksDir(repoRoot), `${taskId}.json`);
8
+ if (!existsSync(path))
9
+ throw new Error(`Task ${taskId} not found at ${path}`);
10
+ return JSON.parse(readFileSync(path, 'utf-8'));
11
+ }
12
+ export function saveTask(repoRoot, task) {
13
+ validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
14
+ mkdirSync(tasksDir(repoRoot), { recursive: true });
15
+ const path = join(tasksDir(repoRoot), `${task.id}.json`);
16
+ writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
17
+ }
18
+ export function loadProject(repoRoot, projectId) {
19
+ const path = join(projectsDir(repoRoot), `${projectId}.json`);
20
+ if (!existsSync(path))
21
+ throw new Error(`Project ${projectId} not found at ${path}`);
22
+ return JSON.parse(readFileSync(path, 'utf-8'));
23
+ }
24
+ export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
25
+ const task = loadTask(repoRoot, taskId);
26
+ const from = task.status;
27
+ const sm = loadStateMachine('task');
28
+ const riskClass = options.path === 'fast_lane' ? 'low'
29
+ : options.path === 'full_pipeline' ? 'high'
30
+ : (task.risk_class ?? 'low');
31
+ const workItemForValidator = {
32
+ type: 'task',
33
+ id: task.id,
34
+ project: task.project,
35
+ status: task.status,
36
+ risk_class: riskClass,
37
+ context: { rfc: { project: task.project, id: task.id } },
38
+ definition_of_done: task.definition_of_done,
39
+ acceptance_criteria: task.acceptance_criteria,
40
+ };
41
+ const proposed = { ...task, status: toStatus };
42
+ advanceWorkItemStatus({
43
+ repoRoot,
44
+ workItemType: 'task',
45
+ project: task.project,
46
+ id: task.id,
47
+ from,
48
+ to: toStatus,
49
+ actor,
50
+ stateMachine: sm,
51
+ validateFixture: workItemForValidator,
52
+ save: (p) => saveTask(repoRoot, p),
53
+ proposed,
54
+ gate: options.gate,
55
+ path: options.path,
56
+ });
57
+ return proposed;
58
+ }
@@ -0,0 +1,49 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { emitStatusTransition, formatReason } from './events.mjs';
5
+ import { validateStatusTransitionLegality } from '@cloverleaf/standard/validators/index.js';
6
+ const req = createRequire(import.meta.url);
7
+ export function loadStateMachine(type) {
8
+ const pkgPath = req.resolve('@cloverleaf/standard/package.json');
9
+ const pkgDir = pkgPath.replace(/\/package\.json$/, '');
10
+ return JSON.parse(readFileSync(`${pkgDir}/state-machines/${type}.json`, 'utf-8'));
11
+ }
12
+ export function advanceWorkItemStatus(params) {
13
+ const { repoRoot, workItemType, project, id, from, to, actor, stateMachine, validateFixture, save, gate, path } = params;
14
+ const reason = formatReason({ gate, path });
15
+ const event = {
16
+ event_id: randomUUID(),
17
+ event_type: 'status_transition',
18
+ occurred_at: new Date().toISOString(),
19
+ work_item_id: { project, id },
20
+ work_item_type: workItemType,
21
+ from_status: from,
22
+ to_status: to,
23
+ actor: { kind: actor, id: actor },
24
+ ...(reason ? { reason } : {}),
25
+ };
26
+ const result = validateStatusTransitionLegality(event, stateMachine, validateFixture);
27
+ if (!result.ok) {
28
+ const msgs = result.violations.map((v) => v.message).join('; ');
29
+ throw new Error(`Illegal transition ${from} → ${to}: ${msgs}`);
30
+ }
31
+ const emittedPath = emitStatusTransition(repoRoot, {
32
+ project,
33
+ workItemType,
34
+ workItemId: id,
35
+ from,
36
+ to,
37
+ actor,
38
+ gate,
39
+ path,
40
+ });
41
+ try {
42
+ save(params.proposed);
43
+ }
44
+ catch (err) {
45
+ const inner = err instanceof Error ? err.message : String(err);
46
+ throw new Error(`orphan event written to ${emittedPath} but ${workItemType} save failed: ${inner}`);
47
+ }
48
+ return { from, to };
49
+ }