@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.
- package/.claude-plugin/plugin.json +1 -1
- package/VERSION +1 -1
- package/config/discovery.json +5 -0
- package/dist/cli.mjs +130 -4
- package/dist/discovery-config.mjs +26 -0
- package/dist/ids.mjs +26 -1
- package/dist/index.mjs +1 -1
- package/dist/plan.mjs +116 -0
- package/dist/rfc.mjs +39 -0
- package/dist/spike.mjs +38 -0
- package/dist/task.mjs +58 -0
- package/dist/work-item.mjs +49 -0
- package/lib/cli.ts +127 -4
- package/lib/discovery-config.ts +35 -0
- package/lib/ids.ts +25 -1
- package/lib/index.ts +1 -1
- package/lib/plan.ts +148 -0
- package/lib/rfc.ts +63 -0
- package/lib/spike.ts +61 -0
- package/lib/task.ts +91 -0
- package/lib/work-item.ts +78 -0
- package/package.json +1 -1
- package/prompts/plan.md +63 -0
- package/prompts/researcher.md +74 -0
- package/skills/cloverleaf-breakdown/SKILL.md +74 -0
- package/skills/cloverleaf-discover/SKILL.md +140 -0
- package/skills/cloverleaf-draft-rfc/SKILL.md +99 -0
- package/skills/cloverleaf-gate/SKILL.md +106 -0
- package/skills/cloverleaf-new-rfc/SKILL.md +76 -0
- package/skills/cloverleaf-spike/SKILL.md +66 -0
- package/lib/state.ts +0 -137
|
@@ -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.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.
|
|
1
|
+
0.5.1
|
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 './
|
|
21
|
-
import { advanceStatus } from './
|
|
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
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
|
+
}
|