@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
package/lib/cli.ts
CHANGED
|
@@ -13,21 +13,39 @@
|
|
|
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
|
|
|
18
31
|
import { readFileSync } from 'node:fs';
|
|
19
32
|
import { execSync } from 'node:child_process';
|
|
20
|
-
import { loadTask } from './
|
|
21
|
-
import { advanceStatus } from './
|
|
33
|
+
import { loadTask } from './task.js';
|
|
34
|
+
import { advanceStatus } from './task.js';
|
|
22
35
|
import { emitGateDecision } from './events.js';
|
|
23
36
|
import { writeFeedback, latestFeedback } from './feedback.js';
|
|
24
|
-
import { nextTaskId, inferProject } from './ids.js';
|
|
37
|
+
import { nextTaskId, inferProject, nextWorkItemId } from './ids.js';
|
|
25
38
|
import { matchesUiPaths } from './ui-paths.js';
|
|
26
39
|
import { loadUiPathsConfig } from './ui-paths.js';
|
|
27
40
|
import { computeAffectedRoutes } from './affected-routes.js';
|
|
28
41
|
import { loadAffectedRoutesConfig } from './affected-routes.js';
|
|
29
42
|
import { loadUiReviewConfig } from './ui-review-config.js';
|
|
43
|
+
import { getPluginRoot } from './plugin-path.js';
|
|
30
44
|
import type { FeedbackEnvelope } from './feedback.js';
|
|
45
|
+
import { loadRfc, saveRfc, advanceRfcStatus, type RfcDoc } from './rfc.js';
|
|
46
|
+
import { loadSpike, saveSpike, advanceSpikeStatus, type SpikeDoc } from './spike.js';
|
|
47
|
+
import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan, type PlanDoc } from './plan.js';
|
|
48
|
+
import { loadDiscoveryConfig } from './discovery-config.js';
|
|
31
49
|
|
|
32
50
|
function die(msg: string, code = 1): never {
|
|
33
51
|
process.stderr.write(msg + '\n');
|
|
@@ -46,7 +64,20 @@ function usage(msg?: string): never {
|
|
|
46
64
|
' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
|
|
47
65
|
' latest-feedback <repoRoot> <taskId>\n' +
|
|
48
66
|
' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
|
|
49
|
-
' ui-review-config --repo-root <repoRoot>\n'
|
|
67
|
+
' ui-review-config --repo-root <repoRoot>\n' +
|
|
68
|
+
' plugin-root\n' +
|
|
69
|
+
' load-rfc <repoRoot> <id>\n' +
|
|
70
|
+
' save-rfc <repoRoot> <filePath>\n' +
|
|
71
|
+
' advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
|
|
72
|
+
' load-spike <repoRoot> <id>\n' +
|
|
73
|
+
' save-spike <repoRoot> <filePath>\n' +
|
|
74
|
+
' advance-spike <repoRoot> <id> <toStatus> <agent|human>\n' +
|
|
75
|
+
' load-plan <repoRoot> <id>\n' +
|
|
76
|
+
' save-plan <repoRoot> <filePath>\n' +
|
|
77
|
+
' advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
|
|
78
|
+
' materialise-tasks <repoRoot> <planId>\n' +
|
|
79
|
+
' next-work-item-id <repoRoot> <project>\n' +
|
|
80
|
+
' discovery-config --repo-root <repoRoot>\n'
|
|
50
81
|
);
|
|
51
82
|
process.exit(2);
|
|
52
83
|
}
|
|
@@ -244,6 +275,106 @@ try {
|
|
|
244
275
|
process.exit(0);
|
|
245
276
|
}
|
|
246
277
|
|
|
278
|
+
case 'plugin-root': {
|
|
279
|
+
process.stdout.write(getPluginRoot());
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
case 'load-rfc': {
|
|
284
|
+
const [repoRoot, id] = rest;
|
|
285
|
+
if (!repoRoot || !id) usage('load-rfc <repoRoot> <id>');
|
|
286
|
+
process.stdout.write(JSON.stringify(loadRfc(repoRoot, id), null, 2));
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case 'save-rfc': {
|
|
291
|
+
const [repoRoot, filePath] = rest;
|
|
292
|
+
if (!repoRoot || !filePath) usage('save-rfc <repoRoot> <filePath>');
|
|
293
|
+
const rfc = JSON.parse(readFileSync(filePath, 'utf-8')) as RfcDoc;
|
|
294
|
+
saveRfc(repoRoot, rfc);
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case 'advance-rfc': {
|
|
299
|
+
const [repoRoot, id, toStatus, actor, gate] = rest;
|
|
300
|
+
if (!repoRoot || !id || !toStatus || !actor) usage('advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]');
|
|
301
|
+
if (actor !== 'agent' && actor !== 'human') usage('advance-rfc: actor must be agent or human');
|
|
302
|
+
const opts = gate ? { gate } : {};
|
|
303
|
+
advanceRfcStatus(repoRoot, id, toStatus, actor as 'agent' | 'human', opts);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
case 'load-spike': {
|
|
308
|
+
const [repoRoot, id] = rest;
|
|
309
|
+
if (!repoRoot || !id) usage('load-spike <repoRoot> <id>');
|
|
310
|
+
process.stdout.write(JSON.stringify(loadSpike(repoRoot, id), null, 2));
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
case 'save-spike': {
|
|
315
|
+
const [repoRoot, filePath] = rest;
|
|
316
|
+
if (!repoRoot || !filePath) usage('save-spike <repoRoot> <filePath>');
|
|
317
|
+
const spike = JSON.parse(readFileSync(filePath, 'utf-8')) as SpikeDoc;
|
|
318
|
+
saveSpike(repoRoot, spike);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case 'advance-spike': {
|
|
323
|
+
const [repoRoot, id, toStatus, actor] = rest;
|
|
324
|
+
if (!repoRoot || !id || !toStatus || !actor) usage('advance-spike <repoRoot> <id> <toStatus> <agent|human>');
|
|
325
|
+
if (actor !== 'agent' && actor !== 'human') usage('advance-spike: actor must be agent or human');
|
|
326
|
+
advanceSpikeStatus(repoRoot, id, toStatus, actor as 'agent' | 'human');
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
case 'load-plan': {
|
|
331
|
+
const [repoRoot, id] = rest;
|
|
332
|
+
if (!repoRoot || !id) usage('load-plan <repoRoot> <id>');
|
|
333
|
+
process.stdout.write(JSON.stringify(loadPlan(repoRoot, id), null, 2));
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
case 'save-plan': {
|
|
338
|
+
const [repoRoot, filePath] = rest;
|
|
339
|
+
if (!repoRoot || !filePath) usage('save-plan <repoRoot> <filePath>');
|
|
340
|
+
const plan = JSON.parse(readFileSync(filePath, 'utf-8')) as PlanDoc;
|
|
341
|
+
savePlan(repoRoot, plan);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case 'advance-plan': {
|
|
346
|
+
const [repoRoot, id, toStatus, actor, gate] = rest;
|
|
347
|
+
if (!repoRoot || !id || !toStatus || !actor) usage('advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]');
|
|
348
|
+
if (actor !== 'agent' && actor !== 'human') usage('advance-plan: actor must be agent or human');
|
|
349
|
+
const opts = gate ? { gate } : {};
|
|
350
|
+
advancePlanStatus(repoRoot, id, toStatus, actor as 'agent' | 'human', opts);
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case 'materialise-tasks': {
|
|
355
|
+
const [repoRoot, planId] = rest;
|
|
356
|
+
if (!repoRoot || !planId) usage('materialise-tasks <repoRoot> <planId>');
|
|
357
|
+
const plan = loadPlan(repoRoot, planId);
|
|
358
|
+
const ids = materialiseTasksFromPlan(repoRoot, plan);
|
|
359
|
+
process.stdout.write(JSON.stringify({ task_ids: ids }));
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
case 'next-work-item-id': {
|
|
364
|
+
const [repoRoot, project] = rest;
|
|
365
|
+
if (!repoRoot || !project) usage('next-work-item-id <repoRoot> <project>');
|
|
366
|
+
process.stdout.write(nextWorkItemId(repoRoot, project));
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
case 'discovery-config': {
|
|
371
|
+
const idx = rest.indexOf('--repo-root');
|
|
372
|
+
if (idx < 0 || !rest[idx + 1]) usage('discovery-config --repo-root <repoRoot>');
|
|
373
|
+
const c = loadDiscoveryConfig(rest[idx + 1]);
|
|
374
|
+
process.stdout.write(JSON.stringify(c, null, 2));
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
|
|
247
378
|
default:
|
|
248
379
|
usage(`Unknown command: ${command}`);
|
|
249
380
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const PACKAGE_DEFAULT = join(here, '..', 'config', 'discovery.json');
|
|
7
|
+
|
|
8
|
+
export interface DiscoveryConfig {
|
|
9
|
+
docContextUri: string;
|
|
10
|
+
projectId: string;
|
|
11
|
+
idStart: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadDiscoveryConfig(repoRoot: string): DiscoveryConfig {
|
|
15
|
+
const override = join(repoRoot, '.cloverleaf', 'config', 'discovery.json');
|
|
16
|
+
const fallback = JSON.parse(readFileSync(PACKAGE_DEFAULT, 'utf-8')) as DiscoveryConfig;
|
|
17
|
+
|
|
18
|
+
if (existsSync(override)) {
|
|
19
|
+
try {
|
|
20
|
+
const doc = JSON.parse(readFileSync(override, 'utf-8')) as Partial<DiscoveryConfig>;
|
|
21
|
+
return normalise(doc, fallback);
|
|
22
|
+
} catch {
|
|
23
|
+
// Malformed consumer JSON — fall through to package default.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalise(doc: Partial<DiscoveryConfig>, fallback: DiscoveryConfig): DiscoveryConfig {
|
|
30
|
+
return {
|
|
31
|
+
docContextUri: typeof doc.docContextUri === 'string' ? doc.docContextUri : fallback.docContextUri,
|
|
32
|
+
projectId: typeof doc.projectId === 'string' ? doc.projectId : fallback.projectId,
|
|
33
|
+
idStart: typeof doc.idStart === 'number' ? doc.idStart : fallback.idStart,
|
|
34
|
+
};
|
|
35
|
+
}
|
package/lib/feedback.ts
CHANGED
|
@@ -70,7 +70,7 @@ export function latestFeedback(repoRoot: string, taskId: string): FeedbackEnvelo
|
|
|
70
70
|
export function allFeedback(repoRoot: string, taskId: string): FeedbackEnvelope[] {
|
|
71
71
|
const dir = feedbackDir(repoRoot);
|
|
72
72
|
if (!existsSync(dir)) return [];
|
|
73
|
-
const re = new RegExp(`^${escapeRegex(taskId)}-
|
|
73
|
+
const re = new RegExp(`^${escapeRegex(taskId)}-[ruq](\\d+)\\.json$`);
|
|
74
74
|
const entries = readdirSync(dir)
|
|
75
75
|
.map((f) => ({ f, m: f.match(re) }))
|
|
76
76
|
.filter((x): x is { f: string; m: RegExpMatchArray } => !!x.m)
|
package/lib/ids.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdirSync, existsSync } from 'node:fs';
|
|
2
|
-
import { tasksDir, eventsDir, feedbackDir, projectsDir } from './paths.js';
|
|
2
|
+
import { tasksDir, eventsDir, feedbackDir, projectsDir, rfcsDir, spikesDir, plansDir } from './paths.js';
|
|
3
3
|
|
|
4
4
|
export function nextTaskId(repoRoot: string, project: string): string {
|
|
5
5
|
const dir = tasksDir(repoRoot);
|
|
@@ -55,6 +55,30 @@ export function inferProject(repoRoot: string, explicit?: string): string {
|
|
|
55
55
|
return projects[0];
|
|
56
56
|
}
|
|
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: string, project: string): string {
|
|
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)) continue;
|
|
71
|
+
for (const f of readdirSync(d)) {
|
|
72
|
+
const m = pat.exec(f);
|
|
73
|
+
if (m) {
|
|
74
|
+
const n = parseInt(m[1], 10);
|
|
75
|
+
if (n > max) max = n;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return `${project}-${max + 1}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
58
82
|
function escapeRegex(s: string): string {
|
|
59
83
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
84
|
}
|
package/lib/index.ts
CHANGED
package/lib/plan.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { plansDir, tasksDir } from './paths.js';
|
|
4
|
+
import { validateOrThrow } from './validate.js';
|
|
5
|
+
import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
|
|
6
|
+
|
|
7
|
+
export interface WorkItemRef {
|
|
8
|
+
project: string;
|
|
9
|
+
id: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TaskDag {
|
|
13
|
+
nodes: WorkItemRef[];
|
|
14
|
+
edges: Array<{ from: WorkItemRef; to: WorkItemRef }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PlanDoc {
|
|
18
|
+
type: 'plan';
|
|
19
|
+
project: string;
|
|
20
|
+
id: string;
|
|
21
|
+
status: string;
|
|
22
|
+
owner: { kind: 'agent' | 'human' | 'system'; id: string };
|
|
23
|
+
parent_rfc: WorkItemRef;
|
|
24
|
+
task_dag: TaskDag;
|
|
25
|
+
tasks: Array<Record<string, unknown>>;
|
|
26
|
+
path_reviewer_map?: Array<{ pattern: string; role: string }>;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadPlan(repoRoot: string, id: string): PlanDoc {
|
|
31
|
+
const path = join(plansDir(repoRoot), `${id}.json`);
|
|
32
|
+
if (!existsSync(path)) throw new Error(`Plan ${id} not found at ${path}`);
|
|
33
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as PlanDoc;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function savePlan(repoRoot: string, plan: PlanDoc): void {
|
|
37
|
+
validateOrThrow('https://cloverleaf.example/schemas/plan.schema.json', plan);
|
|
38
|
+
const path = join(plansDir(repoRoot), `${plan.id}.json`);
|
|
39
|
+
writeFileSync(path, JSON.stringify(plan, null, 2) + '\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function advancePlanStatus(
|
|
43
|
+
repoRoot: string,
|
|
44
|
+
id: string,
|
|
45
|
+
toStatus: string,
|
|
46
|
+
actor: 'agent' | 'human',
|
|
47
|
+
options: { gate?: string } = {}
|
|
48
|
+
): PlanDoc {
|
|
49
|
+
const plan = loadPlan(repoRoot, id);
|
|
50
|
+
const from = plan.status;
|
|
51
|
+
const sm = loadStateMachine('plan');
|
|
52
|
+
const fixture = { type: 'plan', id: plan.id, project: plan.project, status: plan.status };
|
|
53
|
+
|
|
54
|
+
const proposed = { ...plan, status: toStatus };
|
|
55
|
+
advanceWorkItemStatus({
|
|
56
|
+
repoRoot,
|
|
57
|
+
workItemType: 'plan',
|
|
58
|
+
project: plan.project,
|
|
59
|
+
id: plan.id,
|
|
60
|
+
from,
|
|
61
|
+
to: toStatus,
|
|
62
|
+
actor,
|
|
63
|
+
stateMachine: sm,
|
|
64
|
+
validateFixture: fixture,
|
|
65
|
+
save: (p) => savePlan(repoRoot, p as PlanDoc),
|
|
66
|
+
proposed,
|
|
67
|
+
gate: options.gate,
|
|
68
|
+
});
|
|
69
|
+
return proposed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a directed graph from the DAG's edges and detect any cycle.
|
|
74
|
+
* Returns the first node id involved in a cycle, or null.
|
|
75
|
+
*/
|
|
76
|
+
function detectCycle(dag: TaskDag): string | null {
|
|
77
|
+
// Build adjacency: for each node, list of node ids it points TO.
|
|
78
|
+
const adj = new Map<string, string[]>();
|
|
79
|
+
for (const n of dag.nodes) adj.set(n.id, []);
|
|
80
|
+
for (const e of dag.edges) {
|
|
81
|
+
const from = e.from.id;
|
|
82
|
+
const to = e.to.id;
|
|
83
|
+
if (!adj.has(from)) adj.set(from, []);
|
|
84
|
+
adj.get(from)!.push(to);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const state = new Map<string, 'white' | 'grey' | 'black'>();
|
|
88
|
+
for (const n of dag.nodes) state.set(n.id, 'white');
|
|
89
|
+
|
|
90
|
+
const visit = (id: string): boolean => {
|
|
91
|
+
const s = state.get(id);
|
|
92
|
+
if (s === 'grey') return true; // back-edge → cycle
|
|
93
|
+
if (s === 'black') return false;
|
|
94
|
+
state.set(id, 'grey');
|
|
95
|
+
for (const next of adj.get(id) ?? []) {
|
|
96
|
+
if (visit(next)) return true;
|
|
97
|
+
}
|
|
98
|
+
state.set(id, 'black');
|
|
99
|
+
return false;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const n of dag.nodes) {
|
|
103
|
+
if (visit(n.id)) return n.id;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Materialise all inline tasks from an approved Plan onto disk as
|
|
110
|
+
* .cloverleaf/tasks/<id>.json. Atomic: pre-validates every task before
|
|
111
|
+
* any file write. Throws on cycle in task_dag or AJV failure — no
|
|
112
|
+
* partial materialisation on failure. Returns the ordered list of
|
|
113
|
+
* materialised task IDs.
|
|
114
|
+
*
|
|
115
|
+
* If a task file already exists at the target path, it is OVERWRITTEN.
|
|
116
|
+
* Callers responsible for Delivery state consistency should not invoke
|
|
117
|
+
* this on a Plan whose tasks are already materialised and in-flight.
|
|
118
|
+
*
|
|
119
|
+
* Called by /cloverleaf-discover after a human approves the Plan at
|
|
120
|
+
* task_batch_gate. The gate ensures this function is invoked at most
|
|
121
|
+
* once per Plan in normal operation.
|
|
122
|
+
*/
|
|
123
|
+
export function materialiseTasksFromPlan(repoRoot: string, plan: PlanDoc): string[] {
|
|
124
|
+
// 1. Cycle check on edges.
|
|
125
|
+
const cycleAt = detectCycle(plan.task_dag);
|
|
126
|
+
if (cycleAt) throw new Error(`Plan task_dag contains a cycle involving ${cycleAt}`);
|
|
127
|
+
|
|
128
|
+
// 2. Pre-validate every task before ANY file write.
|
|
129
|
+
for (const task of plan.tasks) {
|
|
130
|
+
validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3. Ensure the tasks directory exists (no-op on fresh repos that haven't
|
|
134
|
+
// initialised .cloverleaf/tasks/ yet). Placed after cycle-check and
|
|
135
|
+
// validation so those fast-path shorts-circuit without any FS side-effect.
|
|
136
|
+
mkdirSync(tasksDir(repoRoot), { recursive: true });
|
|
137
|
+
|
|
138
|
+
// 4. Write all task files.
|
|
139
|
+
const ids: string[] = [];
|
|
140
|
+
for (const task of plan.tasks) {
|
|
141
|
+
const id = String(task['id']);
|
|
142
|
+
const path = join(tasksDir(repoRoot), `${id}.json`);
|
|
143
|
+
writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
|
|
144
|
+
ids.push(id);
|
|
145
|
+
}
|
|
146
|
+
return ids;
|
|
147
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Absolute path to the plugin root.
|
|
8
|
+
*
|
|
9
|
+
* At runtime, this module lives at <plugin-root>/lib/plugin-path.js (or .ts in dev),
|
|
10
|
+
* so the plugin root is the parent directory.
|
|
11
|
+
*
|
|
12
|
+
* Works under:
|
|
13
|
+
* - dev mode (repo source: <repo>/reference-impl/)
|
|
14
|
+
* - npm install (node_modules/@cloverleaf/reference-impl/)
|
|
15
|
+
* - claude plugin install cache (~/.claude/plugins/cache/cloverleaf-local/cloverleaf/0.4.1/)
|
|
16
|
+
* - legacy symlink into ~/.claude/plugins/cloverleaf/
|
|
17
|
+
* - claude --plugin-dir <path>
|
|
18
|
+
*/
|
|
19
|
+
export function getPluginRoot(): string {
|
|
20
|
+
return resolve(here, '..');
|
|
21
|
+
}
|
package/lib/rfc.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { rfcsDir } from './paths.js';
|
|
4
|
+
import { validateOrThrow } from './validate.js';
|
|
5
|
+
import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
|
|
6
|
+
|
|
7
|
+
export interface RfcDoc {
|
|
8
|
+
type: 'rfc';
|
|
9
|
+
project: string;
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
status: string;
|
|
13
|
+
owner: { kind: 'agent' | 'human' | 'system'; id: string };
|
|
14
|
+
problem: string;
|
|
15
|
+
solution: string;
|
|
16
|
+
unknowns: string[];
|
|
17
|
+
acceptance_criteria: string[];
|
|
18
|
+
out_of_scope: string[];
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadRfc(repoRoot: string, id: string): RfcDoc {
|
|
23
|
+
const path = join(rfcsDir(repoRoot), `${id}.json`);
|
|
24
|
+
if (!existsSync(path)) throw new Error(`RFC ${id} not found at ${path}`);
|
|
25
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as RfcDoc;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function saveRfc(repoRoot: string, rfc: RfcDoc): void {
|
|
29
|
+
validateOrThrow('https://cloverleaf.example/schemas/rfc.schema.json', rfc);
|
|
30
|
+
const path = join(rfcsDir(repoRoot), `${rfc.id}.json`);
|
|
31
|
+
writeFileSync(path, JSON.stringify(rfc, null, 2) + '\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function advanceRfcStatus(
|
|
35
|
+
repoRoot: string,
|
|
36
|
+
id: string,
|
|
37
|
+
toStatus: string,
|
|
38
|
+
actor: 'agent' | 'human',
|
|
39
|
+
options: { gate?: string } = {}
|
|
40
|
+
): RfcDoc {
|
|
41
|
+
const rfc = loadRfc(repoRoot, id);
|
|
42
|
+
const from = rfc.status;
|
|
43
|
+
const sm = loadStateMachine('rfc');
|
|
44
|
+
const fixture = { type: 'rfc', id: rfc.id, project: rfc.project, status: rfc.status };
|
|
45
|
+
|
|
46
|
+
const proposed = { ...rfc, status: toStatus };
|
|
47
|
+
advanceWorkItemStatus({
|
|
48
|
+
repoRoot,
|
|
49
|
+
workItemType: 'rfc',
|
|
50
|
+
project: rfc.project,
|
|
51
|
+
id: rfc.id,
|
|
52
|
+
from,
|
|
53
|
+
to: toStatus,
|
|
54
|
+
actor,
|
|
55
|
+
stateMachine: sm,
|
|
56
|
+
validateFixture: fixture,
|
|
57
|
+
save: (p) => saveRfc(repoRoot, p as RfcDoc),
|
|
58
|
+
proposed,
|
|
59
|
+
gate: options.gate,
|
|
60
|
+
});
|
|
61
|
+
return proposed;
|
|
62
|
+
}
|
package/lib/spike.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { spikesDir } from './paths.js';
|
|
4
|
+
import { validateOrThrow } from './validate.js';
|
|
5
|
+
import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
|
|
6
|
+
|
|
7
|
+
export interface SpikeDoc {
|
|
8
|
+
type: 'spike';
|
|
9
|
+
project: string;
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
status: string;
|
|
13
|
+
owner: { kind: 'agent' | 'human' | 'system'; id: string };
|
|
14
|
+
parent_rfc: { project: string; id: string };
|
|
15
|
+
question: string;
|
|
16
|
+
method: 'research' | 'prototype' | 'benchmark';
|
|
17
|
+
findings?: string;
|
|
18
|
+
recommendation?: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadSpike(repoRoot: string, id: string): SpikeDoc {
|
|
23
|
+
const path = join(spikesDir(repoRoot), `${id}.json`);
|
|
24
|
+
if (!existsSync(path)) throw new Error(`Spike ${id} not found at ${path}`);
|
|
25
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as SpikeDoc;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function saveSpike(repoRoot: string, spike: SpikeDoc): void {
|
|
29
|
+
validateOrThrow('https://cloverleaf.example/schemas/spike.schema.json', spike);
|
|
30
|
+
const path = join(spikesDir(repoRoot), `${spike.id}.json`);
|
|
31
|
+
writeFileSync(path, JSON.stringify(spike, null, 2) + '\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function advanceSpikeStatus(
|
|
35
|
+
repoRoot: string,
|
|
36
|
+
id: string,
|
|
37
|
+
toStatus: string,
|
|
38
|
+
actor: 'agent' | 'human'
|
|
39
|
+
): SpikeDoc {
|
|
40
|
+
const spike = loadSpike(repoRoot, id);
|
|
41
|
+
const from = spike.status;
|
|
42
|
+
const sm = loadStateMachine('spike');
|
|
43
|
+
const fixture = { type: 'spike', id: spike.id, project: spike.project, status: spike.status };
|
|
44
|
+
|
|
45
|
+
const proposed = { ...spike, status: toStatus };
|
|
46
|
+
advanceWorkItemStatus({
|
|
47
|
+
repoRoot,
|
|
48
|
+
workItemType: 'spike',
|
|
49
|
+
project: spike.project,
|
|
50
|
+
id: spike.id,
|
|
51
|
+
from,
|
|
52
|
+
to: toStatus,
|
|
53
|
+
actor,
|
|
54
|
+
stateMachine: sm,
|
|
55
|
+
validateFixture: fixture,
|
|
56
|
+
save: (p) => saveSpike(repoRoot, p as SpikeDoc),
|
|
57
|
+
proposed,
|
|
58
|
+
});
|
|
59
|
+
return proposed;
|
|
60
|
+
}
|
package/lib/task.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tasksDir, projectsDir } from './paths.js';
|
|
4
|
+
import type { Task as SMTask } from '@cloverleaf/standard/validators/index.js';
|
|
5
|
+
import { validateOrThrow } from './validate.js';
|
|
6
|
+
import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
|
|
7
|
+
|
|
8
|
+
export interface TaskDoc {
|
|
9
|
+
type: 'task';
|
|
10
|
+
project: string;
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
status: string;
|
|
14
|
+
risk_class: 'low' | 'high';
|
|
15
|
+
owner: { kind: 'agent' | 'human' | 'system'; id: string };
|
|
16
|
+
acceptance_criteria: string[];
|
|
17
|
+
definition_of_done: string[];
|
|
18
|
+
context: Record<string, unknown>;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProjectDoc {
|
|
23
|
+
key: string;
|
|
24
|
+
name: string;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadTask(repoRoot: string, taskId: string): TaskDoc {
|
|
29
|
+
const path = join(tasksDir(repoRoot), `${taskId}.json`);
|
|
30
|
+
if (!existsSync(path)) throw new Error(`Task ${taskId} not found at ${path}`);
|
|
31
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as TaskDoc;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function saveTask(repoRoot: string, task: TaskDoc): void {
|
|
35
|
+
validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
|
|
36
|
+
const path = join(tasksDir(repoRoot), `${task.id}.json`);
|
|
37
|
+
writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadProject(repoRoot: string, projectId: string): ProjectDoc {
|
|
41
|
+
const path = join(projectsDir(repoRoot), `${projectId}.json`);
|
|
42
|
+
if (!existsSync(path)) throw new Error(`Project ${projectId} not found at ${path}`);
|
|
43
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as ProjectDoc;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function advanceStatus(
|
|
47
|
+
repoRoot: string,
|
|
48
|
+
taskId: string,
|
|
49
|
+
toStatus: string,
|
|
50
|
+
actor: 'agent' | 'human',
|
|
51
|
+
options: { gate?: string; path?: 'fast_lane' | 'full_pipeline' } = {}
|
|
52
|
+
): TaskDoc {
|
|
53
|
+
const task = loadTask(repoRoot, taskId);
|
|
54
|
+
const from = task.status;
|
|
55
|
+
const sm = loadStateMachine('task');
|
|
56
|
+
|
|
57
|
+
const riskClass: 'low' | 'high' =
|
|
58
|
+
options.path === 'fast_lane' ? 'low'
|
|
59
|
+
: options.path === 'full_pipeline' ? 'high'
|
|
60
|
+
: (task.risk_class ?? 'low');
|
|
61
|
+
|
|
62
|
+
const workItemForValidator: SMTask = {
|
|
63
|
+
type: 'task',
|
|
64
|
+
id: task.id,
|
|
65
|
+
project: task.project,
|
|
66
|
+
status: task.status,
|
|
67
|
+
risk_class: riskClass,
|
|
68
|
+
context: { rfc: { project: task.project, id: task.id } },
|
|
69
|
+
definition_of_done: task.definition_of_done,
|
|
70
|
+
acceptance_criteria: task.acceptance_criteria,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const proposed = { ...task, status: toStatus };
|
|
74
|
+
advanceWorkItemStatus({
|
|
75
|
+
repoRoot,
|
|
76
|
+
workItemType: 'task',
|
|
77
|
+
project: task.project,
|
|
78
|
+
id: task.id,
|
|
79
|
+
from,
|
|
80
|
+
to: toStatus,
|
|
81
|
+
actor,
|
|
82
|
+
stateMachine: sm,
|
|
83
|
+
validateFixture: workItemForValidator as unknown as Record<string, unknown>,
|
|
84
|
+
save: (p) => saveTask(repoRoot, p as TaskDoc),
|
|
85
|
+
proposed,
|
|
86
|
+
gate: options.gate,
|
|
87
|
+
path: options.path,
|
|
88
|
+
});
|
|
89
|
+
return proposed;
|
|
90
|
+
}
|
package/lib/ui-review-config.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface UiReviewConfig {
|
|
|
21
21
|
axe: {
|
|
22
22
|
viewports: string[];
|
|
23
23
|
dedupeBy: ('ruleId' | 'target')[];
|
|
24
|
+
ignored: Array<{ ruleId: string; target: string }>;
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -31,12 +32,16 @@ const HARDCODED_FALLBACK: UiReviewConfig = {
|
|
|
31
32
|
desktop: { width: 1280, height: 800 },
|
|
32
33
|
},
|
|
33
34
|
visualDiff: { enabled: true, threshold: 0.1, maxDiffRatio: 0.01, mask: [] },
|
|
34
|
-
axe: { viewports: ['desktop'], dedupeBy: ['ruleId', 'target'] },
|
|
35
|
+
axe: { viewports: ['desktop'], dedupeBy: ['ruleId', 'target'], ignored: [] },
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
function readAsConfig(path: string): UiReviewConfig | null {
|
|
38
39
|
try {
|
|
39
40
|
const doc = JSON.parse(readFileSync(path, 'utf-8')) as UiReviewConfig;
|
|
41
|
+
// Back-compat: if ignored is missing from an older override, default it.
|
|
42
|
+
if (doc.axe && !('ignored' in doc.axe)) {
|
|
43
|
+
(doc.axe as UiReviewConfig['axe']).ignored = [];
|
|
44
|
+
}
|
|
40
45
|
return doc;
|
|
41
46
|
} catch {
|
|
42
47
|
return null;
|