@cloverleaf/reference-impl 0.4.0 → 0.5.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/config/discovery.json +5 -0
- package/config/ui-review.json +2 -1
- package/dist/axe-dedupe.mjs +6 -2
- package/dist/cli.mjs +137 -4
- package/dist/discovery-config.mjs +26 -0
- package/dist/feedback.mjs +1 -1
- package/dist/ids.mjs +26 -1
- package/dist/index.mjs +1 -1
- package/dist/plan.mjs +115 -0
- package/dist/plugin-path.mjs +19 -0
- package/dist/rfc.mjs +38 -0
- package/dist/spike.mjs +37 -0
- package/dist/task.mjs +57 -0
- package/dist/ui-review-config.mjs +5 -1
- package/dist/work-item.mjs +49 -0
- package/lib/axe-dedupe.ts +13 -2
- package/lib/cli.ts +135 -4
- package/lib/discovery-config.ts +35 -0
- package/lib/feedback.ts +1 -1
- package/lib/ids.ts +25 -1
- package/lib/index.ts +1 -1
- package/lib/plan.ts +147 -0
- package/lib/plugin-path.ts +21 -0
- package/lib/rfc.ts +62 -0
- package/lib/spike.ts +60 -0
- package/lib/task.ts +90 -0
- package/lib/ui-review-config.ts +6 -1
- 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/prompts/ui-reviewer.md +19 -6
- package/skills/cloverleaf-breakdown/SKILL.md +74 -0
- package/skills/cloverleaf-discover/SKILL.md +139 -0
- package/skills/cloverleaf-document/SKILL.md +2 -2
- package/skills/cloverleaf-draft-rfc/SKILL.md +99 -0
- package/skills/cloverleaf-gate/SKILL.md +106 -0
- package/skills/cloverleaf-implement/SKILL.md +3 -3
- package/skills/cloverleaf-merge/SKILL.md +26 -5
- package/skills/cloverleaf-new-rfc/SKILL.md +76 -0
- package/skills/cloverleaf-new-task/SKILL.md +2 -2
- package/skills/cloverleaf-qa/SKILL.md +16 -6
- package/skills/cloverleaf-review/SKILL.md +18 -8
- package/skills/cloverleaf-spike/SKILL.md +66 -0
- package/skills/cloverleaf-ui-review/SKILL.md +17 -7
- 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.0",
|
|
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
|
+
0.4.1
|
package/config/ui-review.json
CHANGED
package/dist/axe-dedupe.mjs
CHANGED
|
@@ -7,9 +7,13 @@ const SEVERITY_MAP = {
|
|
|
7
7
|
function dedupeKeyOf(raw, keys) {
|
|
8
8
|
return keys.map((k) => raw[k]).join('||');
|
|
9
9
|
}
|
|
10
|
-
export function dedupeAxeFindings(raws, keys) {
|
|
10
|
+
export function dedupeAxeFindings(raws, keys, ignored = []) {
|
|
11
|
+
// Filter out ignored (ruleId, target) tuples BEFORE grouping.
|
|
12
|
+
const filtered = raws.filter((raw) => {
|
|
13
|
+
return !ignored.some((i) => i.ruleId === raw.ruleId && i.target === raw.target);
|
|
14
|
+
});
|
|
11
15
|
const groups = new Map();
|
|
12
|
-
for (const raw of
|
|
16
|
+
for (const raw of filtered) {
|
|
13
17
|
const key = dedupeKeyOf(raw, keys);
|
|
14
18
|
const existing = groups.get(key);
|
|
15
19
|
if (existing) {
|
package/dist/cli.mjs
CHANGED
|
@@ -13,19 +13,37 @@
|
|
|
13
13
|
* latest-feedback <repoRoot> <taskId>
|
|
14
14
|
* emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
|
|
15
15
|
* ui-review-config --repo-root <repoRoot>
|
|
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>
|
|
16
29
|
*/
|
|
17
30
|
import { readFileSync } from 'node:fs';
|
|
18
31
|
import { execSync } from 'node:child_process';
|
|
19
|
-
import { loadTask } from './
|
|
20
|
-
import { advanceStatus } from './
|
|
32
|
+
import { loadTask } from './task.mjs';
|
|
33
|
+
import { advanceStatus } from './task.mjs';
|
|
21
34
|
import { emitGateDecision } from './events.mjs';
|
|
22
35
|
import { writeFeedback, latestFeedback } from './feedback.mjs';
|
|
23
|
-
import { nextTaskId, inferProject } from './ids.mjs';
|
|
36
|
+
import { nextTaskId, inferProject, nextWorkItemId } from './ids.mjs';
|
|
24
37
|
import { matchesUiPaths } from './ui-paths.mjs';
|
|
25
38
|
import { loadUiPathsConfig } from './ui-paths.mjs';
|
|
26
39
|
import { computeAffectedRoutes } from './affected-routes.mjs';
|
|
27
40
|
import { loadAffectedRoutesConfig } from './affected-routes.mjs';
|
|
28
41
|
import { loadUiReviewConfig } from './ui-review-config.mjs';
|
|
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';
|
|
29
47
|
function die(msg, code = 1) {
|
|
30
48
|
process.stderr.write(msg + '\n');
|
|
31
49
|
process.exit(code);
|
|
@@ -42,7 +60,20 @@ function usage(msg) {
|
|
|
42
60
|
' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
|
|
43
61
|
' latest-feedback <repoRoot> <taskId>\n' +
|
|
44
62
|
' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
|
|
45
|
-
' ui-review-config --repo-root <repoRoot>\n'
|
|
63
|
+
' ui-review-config --repo-root <repoRoot>\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');
|
|
46
77
|
process.exit(2);
|
|
47
78
|
}
|
|
48
79
|
const [, , command, ...rest] = process.argv;
|
|
@@ -235,6 +266,108 @@ try {
|
|
|
235
266
|
process.stdout.write(JSON.stringify(config, null, 2));
|
|
236
267
|
process.exit(0);
|
|
237
268
|
}
|
|
269
|
+
case 'plugin-root': {
|
|
270
|
+
process.stdout.write(getPluginRoot());
|
|
271
|
+
process.exit(0);
|
|
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
|
+
}
|
|
238
371
|
default:
|
|
239
372
|
usage(`Unknown command: ${command}`);
|
|
240
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/feedback.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export function allFeedback(repoRoot, taskId) {
|
|
|
29
29
|
const dir = feedbackDir(repoRoot);
|
|
30
30
|
if (!existsSync(dir))
|
|
31
31
|
return [];
|
|
32
|
-
const re = new RegExp(`^${escapeRegex(taskId)}-
|
|
32
|
+
const re = new RegExp(`^${escapeRegex(taskId)}-[ruq](\\d+)\\.json$`);
|
|
33
33
|
const entries = readdirSync(dir)
|
|
34
34
|
.map((f) => ({ f, m: f.match(re) }))
|
|
35
35
|
.filter((x) => !!x.m)
|
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,115 @@
|
|
|
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
|
+
const path = join(plansDir(repoRoot), `${plan.id}.json`);
|
|
15
|
+
writeFileSync(path, JSON.stringify(plan, null, 2) + '\n');
|
|
16
|
+
}
|
|
17
|
+
export function advancePlanStatus(repoRoot, id, toStatus, actor, options = {}) {
|
|
18
|
+
const plan = loadPlan(repoRoot, id);
|
|
19
|
+
const from = plan.status;
|
|
20
|
+
const sm = loadStateMachine('plan');
|
|
21
|
+
const fixture = { type: 'plan', id: plan.id, project: plan.project, status: plan.status };
|
|
22
|
+
const proposed = { ...plan, status: toStatus };
|
|
23
|
+
advanceWorkItemStatus({
|
|
24
|
+
repoRoot,
|
|
25
|
+
workItemType: 'plan',
|
|
26
|
+
project: plan.project,
|
|
27
|
+
id: plan.id,
|
|
28
|
+
from,
|
|
29
|
+
to: toStatus,
|
|
30
|
+
actor,
|
|
31
|
+
stateMachine: sm,
|
|
32
|
+
validateFixture: fixture,
|
|
33
|
+
save: (p) => savePlan(repoRoot, p),
|
|
34
|
+
proposed,
|
|
35
|
+
gate: options.gate,
|
|
36
|
+
});
|
|
37
|
+
return proposed;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build a directed graph from the DAG's edges and detect any cycle.
|
|
41
|
+
* Returns the first node id involved in a cycle, or null.
|
|
42
|
+
*/
|
|
43
|
+
function detectCycle(dag) {
|
|
44
|
+
// Build adjacency: for each node, list of node ids it points TO.
|
|
45
|
+
const adj = new Map();
|
|
46
|
+
for (const n of dag.nodes)
|
|
47
|
+
adj.set(n.id, []);
|
|
48
|
+
for (const e of dag.edges) {
|
|
49
|
+
const from = e.from.id;
|
|
50
|
+
const to = e.to.id;
|
|
51
|
+
if (!adj.has(from))
|
|
52
|
+
adj.set(from, []);
|
|
53
|
+
adj.get(from).push(to);
|
|
54
|
+
}
|
|
55
|
+
const state = new Map();
|
|
56
|
+
for (const n of dag.nodes)
|
|
57
|
+
state.set(n.id, 'white');
|
|
58
|
+
const visit = (id) => {
|
|
59
|
+
const s = state.get(id);
|
|
60
|
+
if (s === 'grey')
|
|
61
|
+
return true; // back-edge → cycle
|
|
62
|
+
if (s === 'black')
|
|
63
|
+
return false;
|
|
64
|
+
state.set(id, 'grey');
|
|
65
|
+
for (const next of adj.get(id) ?? []) {
|
|
66
|
+
if (visit(next))
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
state.set(id, 'black');
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
for (const n of dag.nodes) {
|
|
73
|
+
if (visit(n.id))
|
|
74
|
+
return n.id;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Materialise all inline tasks from an approved Plan onto disk as
|
|
80
|
+
* .cloverleaf/tasks/<id>.json. Atomic: pre-validates every task before
|
|
81
|
+
* any file write. Throws on cycle in task_dag or AJV failure — no
|
|
82
|
+
* partial materialisation on failure. Returns the ordered list of
|
|
83
|
+
* materialised task IDs.
|
|
84
|
+
*
|
|
85
|
+
* If a task file already exists at the target path, it is OVERWRITTEN.
|
|
86
|
+
* Callers responsible for Delivery state consistency should not invoke
|
|
87
|
+
* this on a Plan whose tasks are already materialised and in-flight.
|
|
88
|
+
*
|
|
89
|
+
* Called by /cloverleaf-discover after a human approves the Plan at
|
|
90
|
+
* task_batch_gate. The gate ensures this function is invoked at most
|
|
91
|
+
* once per Plan in normal operation.
|
|
92
|
+
*/
|
|
93
|
+
export function materialiseTasksFromPlan(repoRoot, plan) {
|
|
94
|
+
// 1. Cycle check on edges.
|
|
95
|
+
const cycleAt = detectCycle(plan.task_dag);
|
|
96
|
+
if (cycleAt)
|
|
97
|
+
throw new Error(`Plan task_dag contains a cycle involving ${cycleAt}`);
|
|
98
|
+
// 2. Pre-validate every task before ANY file write.
|
|
99
|
+
for (const task of plan.tasks) {
|
|
100
|
+
validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
|
|
101
|
+
}
|
|
102
|
+
// 3. Ensure the tasks directory exists (no-op on fresh repos that haven't
|
|
103
|
+
// initialised .cloverleaf/tasks/ yet). Placed after cycle-check and
|
|
104
|
+
// validation so those fast-path shorts-circuit without any FS side-effect.
|
|
105
|
+
mkdirSync(tasksDir(repoRoot), { recursive: true });
|
|
106
|
+
// 4. Write all task files.
|
|
107
|
+
const ids = [];
|
|
108
|
+
for (const task of plan.tasks) {
|
|
109
|
+
const id = String(task['id']);
|
|
110
|
+
const path = join(tasksDir(repoRoot), `${id}.json`);
|
|
111
|
+
writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
|
|
112
|
+
ids.push(id);
|
|
113
|
+
}
|
|
114
|
+
return ids;
|
|
115
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
4
|
+
/**
|
|
5
|
+
* Absolute path to the plugin root.
|
|
6
|
+
*
|
|
7
|
+
* At runtime, this module lives at <plugin-root>/lib/plugin-path.js (or .ts in dev),
|
|
8
|
+
* so the plugin root is the parent directory.
|
|
9
|
+
*
|
|
10
|
+
* Works under:
|
|
11
|
+
* - dev mode (repo source: <repo>/reference-impl/)
|
|
12
|
+
* - npm install (node_modules/@cloverleaf/reference-impl/)
|
|
13
|
+
* - claude plugin install cache (~/.claude/plugins/cache/cloverleaf-local/cloverleaf/0.4.1/)
|
|
14
|
+
* - legacy symlink into ~/.claude/plugins/cloverleaf/
|
|
15
|
+
* - claude --plugin-dir <path>
|
|
16
|
+
*/
|
|
17
|
+
export function getPluginRoot() {
|
|
18
|
+
return resolve(here, '..');
|
|
19
|
+
}
|
package/dist/rfc.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } 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
|
+
const path = join(rfcsDir(repoRoot), `${rfc.id}.json`);
|
|
15
|
+
writeFileSync(path, JSON.stringify(rfc, null, 2) + '\n');
|
|
16
|
+
}
|
|
17
|
+
export function advanceRfcStatus(repoRoot, id, toStatus, actor, options = {}) {
|
|
18
|
+
const rfc = loadRfc(repoRoot, id);
|
|
19
|
+
const from = rfc.status;
|
|
20
|
+
const sm = loadStateMachine('rfc');
|
|
21
|
+
const fixture = { type: 'rfc', id: rfc.id, project: rfc.project, status: rfc.status };
|
|
22
|
+
const proposed = { ...rfc, status: toStatus };
|
|
23
|
+
advanceWorkItemStatus({
|
|
24
|
+
repoRoot,
|
|
25
|
+
workItemType: 'rfc',
|
|
26
|
+
project: rfc.project,
|
|
27
|
+
id: rfc.id,
|
|
28
|
+
from,
|
|
29
|
+
to: toStatus,
|
|
30
|
+
actor,
|
|
31
|
+
stateMachine: sm,
|
|
32
|
+
validateFixture: fixture,
|
|
33
|
+
save: (p) => saveRfc(repoRoot, p),
|
|
34
|
+
proposed,
|
|
35
|
+
gate: options.gate,
|
|
36
|
+
});
|
|
37
|
+
return proposed;
|
|
38
|
+
}
|
package/dist/spike.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } 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
|
+
const path = join(spikesDir(repoRoot), `${spike.id}.json`);
|
|
15
|
+
writeFileSync(path, JSON.stringify(spike, null, 2) + '\n');
|
|
16
|
+
}
|
|
17
|
+
export function advanceSpikeStatus(repoRoot, id, toStatus, actor) {
|
|
18
|
+
const spike = loadSpike(repoRoot, id);
|
|
19
|
+
const from = spike.status;
|
|
20
|
+
const sm = loadStateMachine('spike');
|
|
21
|
+
const fixture = { type: 'spike', id: spike.id, project: spike.project, status: spike.status };
|
|
22
|
+
const proposed = { ...spike, status: toStatus };
|
|
23
|
+
advanceWorkItemStatus({
|
|
24
|
+
repoRoot,
|
|
25
|
+
workItemType: 'spike',
|
|
26
|
+
project: spike.project,
|
|
27
|
+
id: spike.id,
|
|
28
|
+
from,
|
|
29
|
+
to: toStatus,
|
|
30
|
+
actor,
|
|
31
|
+
stateMachine: sm,
|
|
32
|
+
validateFixture: fixture,
|
|
33
|
+
save: (p) => saveSpike(repoRoot, p),
|
|
34
|
+
proposed,
|
|
35
|
+
});
|
|
36
|
+
return proposed;
|
|
37
|
+
}
|
package/dist/task.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } 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
|
+
const path = join(tasksDir(repoRoot), `${task.id}.json`);
|
|
15
|
+
writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
|
|
16
|
+
}
|
|
17
|
+
export function loadProject(repoRoot, projectId) {
|
|
18
|
+
const path = join(projectsDir(repoRoot), `${projectId}.json`);
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
throw new Error(`Project ${projectId} not found at ${path}`);
|
|
21
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
22
|
+
}
|
|
23
|
+
export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
|
|
24
|
+
const task = loadTask(repoRoot, taskId);
|
|
25
|
+
const from = task.status;
|
|
26
|
+
const sm = loadStateMachine('task');
|
|
27
|
+
const riskClass = options.path === 'fast_lane' ? 'low'
|
|
28
|
+
: options.path === 'full_pipeline' ? 'high'
|
|
29
|
+
: (task.risk_class ?? 'low');
|
|
30
|
+
const workItemForValidator = {
|
|
31
|
+
type: 'task',
|
|
32
|
+
id: task.id,
|
|
33
|
+
project: task.project,
|
|
34
|
+
status: task.status,
|
|
35
|
+
risk_class: riskClass,
|
|
36
|
+
context: { rfc: { project: task.project, id: task.id } },
|
|
37
|
+
definition_of_done: task.definition_of_done,
|
|
38
|
+
acceptance_criteria: task.acceptance_criteria,
|
|
39
|
+
};
|
|
40
|
+
const proposed = { ...task, status: toStatus };
|
|
41
|
+
advanceWorkItemStatus({
|
|
42
|
+
repoRoot,
|
|
43
|
+
workItemType: 'task',
|
|
44
|
+
project: task.project,
|
|
45
|
+
id: task.id,
|
|
46
|
+
from,
|
|
47
|
+
to: toStatus,
|
|
48
|
+
actor,
|
|
49
|
+
stateMachine: sm,
|
|
50
|
+
validateFixture: workItemForValidator,
|
|
51
|
+
save: (p) => saveTask(repoRoot, p),
|
|
52
|
+
proposed,
|
|
53
|
+
gate: options.gate,
|
|
54
|
+
path: options.path,
|
|
55
|
+
});
|
|
56
|
+
return proposed;
|
|
57
|
+
}
|
|
@@ -10,11 +10,15 @@ const HARDCODED_FALLBACK = {
|
|
|
10
10
|
desktop: { width: 1280, height: 800 },
|
|
11
11
|
},
|
|
12
12
|
visualDiff: { enabled: true, threshold: 0.1, maxDiffRatio: 0.01, mask: [] },
|
|
13
|
-
axe: { viewports: ['desktop'], dedupeBy: ['ruleId', 'target'] },
|
|
13
|
+
axe: { viewports: ['desktop'], dedupeBy: ['ruleId', 'target'], ignored: [] },
|
|
14
14
|
};
|
|
15
15
|
function readAsConfig(path) {
|
|
16
16
|
try {
|
|
17
17
|
const doc = JSON.parse(readFileSync(path, 'utf-8'));
|
|
18
|
+
// Back-compat: if ignored is missing from an older override, default it.
|
|
19
|
+
if (doc.axe && !('ignored' in doc.axe)) {
|
|
20
|
+
doc.axe.ignored = [];
|
|
21
|
+
}
|
|
18
22
|
return doc;
|
|
19
23
|
}
|
|
20
24
|
catch {
|
|
@@ -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
|
+
}
|
package/lib/axe-dedupe.ts
CHANGED
|
@@ -24,9 +24,20 @@ function dedupeKeyOf(raw: RawAxeFinding, keys: DedupeKey[]): string {
|
|
|
24
24
|
return keys.map((k) => raw[k]).join('||');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function dedupeAxeFindings(
|
|
27
|
+
export function dedupeAxeFindings(
|
|
28
|
+
raws: RawAxeFinding[],
|
|
29
|
+
keys: DedupeKey[],
|
|
30
|
+
ignored: Array<{ ruleId: string; target: string }> = []
|
|
31
|
+
): Finding[] {
|
|
32
|
+
// Filter out ignored (ruleId, target) tuples BEFORE grouping.
|
|
33
|
+
const filtered = raws.filter((raw) => {
|
|
34
|
+
return !ignored.some(
|
|
35
|
+
(i) => i.ruleId === raw.ruleId && i.target === raw.target
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
28
39
|
const groups = new Map<string, { first: RawAxeFinding; viewports: string[] }>();
|
|
29
|
-
for (const raw of
|
|
40
|
+
for (const raw of filtered) {
|
|
30
41
|
const key = dedupeKeyOf(raw, keys);
|
|
31
42
|
const existing = groups.get(key);
|
|
32
43
|
if (existing) {
|