@cloverleaf/reference-impl 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @cloverleaf/reference-impl
2
2
 
3
- Reference implementation of the Cloverleaf methodology as a set of Claude Code skills. This package implements the **Tight Loop** — Implementer + Reviewer — letting a user drive a Task from `pending` to `merged` with state, events, and feedback recorded in the repo under `.cloverleaf/`.
3
+ Reference implementation of the Cloverleaf methodology as a set of Claude Code skills. Lets a user drive a Task from `pending` to `merged` with state, events, and feedback recorded in the repo under `.cloverleaf/`.
4
4
 
5
5
  Implements [Cloverleaf Standard](../standard/) v0.3.0 at L2 (Exchange) conformance.
6
6
 
@@ -15,15 +15,49 @@ npm install # pulls @cloverleaf/standard + deps
15
15
  ./install.sh --project # local install into ./.claude/plugins/cloverleaf/
16
16
  ```
17
17
 
18
- ## Skills provided
18
+ ## Scope (v0.2)
19
19
 
20
- | Slash command | Role |
21
- |---|---|
22
- | `/cloverleaf-new-task "<brief>"` | Scaffold a new Task JSON from a prose brief. |
23
- | `/cloverleaf-implement <TASK-ID>` | Dispatch Implementer subagent; advances pendingreview. |
24
- | `/cloverleaf-review <TASK-ID>` | Dispatch Reviewer subagent; pass → automated-gates, bounce → implementing. |
25
- | `/cloverleaf-merge <TASK-ID>` | Human gate; confirm and transition automated-gates → merged. |
26
- | `/cloverleaf-run <TASK-ID>` | Orchestrator: loops implement → review with up to 3 bounces, pauses at the human merge gate. |
20
+ v0.2 implements both paths of the Delivery track:
21
+
22
+ - **Fast Lane** (`risk_class: "low"`): Implementer Reviewer Human Merge
23
+ - **Full Pipeline** (`risk_class: "high"`): Implementer Documenter Reviewer (UI Reviewer if `site/**` changed) → QA → Final Approval
24
+
25
+ ### Agents
26
+
27
+ | Agent | Status | Mechanism |
28
+ |---|---|---|
29
+ | Implementer | Real | Subagent, code + tests on feature branch |
30
+ | Documenter | Real (v0.2) | Subagent, doc-only commits per file-path rules |
31
+ | Reviewer | Real | Subagent, read-only review of diff |
32
+ | UI Reviewer | Real (v0.2) | Playwright + axe-core, single viewport, a11y only |
33
+ | QA | Real (v0.2) | Per-package test runner via `git worktree` |
34
+ | Plan | Stub | Deferred to v0.3 |
35
+ | Researcher | Stub | Deferred to v0.3 |
36
+
37
+ ### Skills
38
+
39
+ - `/cloverleaf-new-task` — scaffold a Task (auto-sets `risk_class`)
40
+ - `/cloverleaf-implement` — run Implementer
41
+ - `/cloverleaf-document` — run Documenter *(new in v0.2)*
42
+ - `/cloverleaf-review` — run Reviewer
43
+ - `/cloverleaf-ui-review` — run UI Reviewer *(new in v0.2)*
44
+ - `/cloverleaf-qa` — run QA *(new in v0.2)*
45
+ - `/cloverleaf-merge` — human gate (branches on state)
46
+ - `/cloverleaf-run` — orchestrator (dispatches by `risk_class`)
47
+
48
+ ### Configuration
49
+
50
+ Two JSON config files in `config/` (overridable per consumer project):
51
+
52
+ - `config/ui-paths.json` — glob patterns that trigger UI Reviewer (default: `site/**`)
53
+ - `config/qa-rules.json` — per-package test commands
54
+
55
+ ### Known limitations
56
+
57
+ - Playwright installs ~300MB into each `git worktree` (v0.3 will cache).
58
+ - Concurrent `/cloverleaf-run` on the same repo may race on preview ports.
59
+ - UI Reviewer visual diff + multi-viewport deferred to v0.3.
60
+ - QA does not produce HTML reports (no `report_uri`).
27
61
 
28
62
  ## Quick start — toy repo
29
63
 
@@ -41,21 +75,7 @@ In a Claude Code session in that directory:
41
75
 
42
76
  Watch it walk the state machine, produce a branch `cloverleaf/DEMO-001` with a `multiply` function + tests, and pause at the merge gate.
43
77
 
44
- ## What's in v0.1.0
45
-
46
- - Implementer + Reviewer agents (wired as subagents).
47
- - Full state machine transitions from `pending` to `merged` (fast-lane path), with stub transitions through `documenting` and `automated-gates`.
48
- - Per-project committed audit trail under `.cloverleaf/`.
49
- - Max 3 bounces before `escalated`.
50
-
51
- ## What's NOT in v0.1.0
52
-
53
- - Researcher, Plan, Documenter, UI Reviewer, QA agents (stub transitions only).
54
- - HTTP endpoints for the agent OpenAPI contracts — skills are markdown-driven, not servers. L3 conformance deferred.
55
- - `git push` / PR creation — branch stays local; user handles remote.
56
- - Path-rules or risk-classifier-rules enforcement — fast-lane is hardcoded.
57
-
58
- See [upcoming releases](../CHANGELOG.md) for the roadmap.
78
+ See [CHANGELOG](../CHANGELOG.md) for the full release history and roadmap.
59
79
 
60
80
  ## Branch topology
61
81
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.1
1
+ 0.2.0
@@ -0,0 +1,19 @@
1
+ {
2
+ "rules": [
3
+ {
4
+ "cwd": "standard",
5
+ "match": ["standard/**"],
6
+ "command": "npm ci && npm test"
7
+ },
8
+ {
9
+ "cwd": "reference-impl",
10
+ "match": ["reference-impl/**"],
11
+ "command": "npm ci && npm test"
12
+ },
13
+ {
14
+ "cwd": "site",
15
+ "match": ["site/**"],
16
+ "command": "npm ci && npm run build"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "patterns": ["site/**"]
3
+ }
package/dist/cli.mjs ADDED
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cloverleaf CLI dispatcher.
4
+ *
5
+ * Usage: cli.ts <command> [args...]
6
+ *
7
+ * Commands:
8
+ * load-task <repoRoot> <taskId>
9
+ * infer-project <repoRoot>
10
+ * next-task-id <repoRoot> [--project=<p>]
11
+ * advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]
12
+ * write-feedback <repoRoot> <taskId> <envelopeJsonPath>
13
+ * latest-feedback <repoRoot> <taskId>
14
+ * emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import { execSync } from 'node:child_process';
18
+ import { loadTask } from './state.mjs';
19
+ import { advanceStatus } from './state.mjs';
20
+ import { emitGateDecision } from './events.mjs';
21
+ import { writeFeedback, latestFeedback } from './feedback.mjs';
22
+ import { nextTaskId, inferProject } from './ids.mjs';
23
+ import { matchesUiPaths, loadDefaultPatterns } from './ui-paths.mjs';
24
+ function die(msg, code = 1) {
25
+ process.stderr.write(msg + '\n');
26
+ process.exit(code);
27
+ }
28
+ function usage(msg) {
29
+ if (msg)
30
+ process.stderr.write(msg + '\n');
31
+ process.stderr.write('Usage: cli.ts <command> [args...]\n' +
32
+ 'Commands:\n' +
33
+ ' load-task <repoRoot> <taskId>\n' +
34
+ ' infer-project <repoRoot>\n' +
35
+ ' next-task-id <repoRoot> [--project=<p>]\n' +
36
+ ' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
37
+ ' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
38
+ ' latest-feedback <repoRoot> <taskId>\n' +
39
+ ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n');
40
+ process.exit(2);
41
+ }
42
+ const [, , command, ...rest] = process.argv;
43
+ if (!command) {
44
+ usage('Error: no command given');
45
+ }
46
+ try {
47
+ switch (command) {
48
+ case 'load-task': {
49
+ const [repoRoot, taskId] = rest;
50
+ if (!repoRoot || !taskId)
51
+ usage('load-task requires <repoRoot> <taskId>');
52
+ const task = loadTask(repoRoot, taskId);
53
+ process.stdout.write(JSON.stringify(task, null, 2) + '\n');
54
+ break;
55
+ }
56
+ case 'infer-project': {
57
+ const [repoRoot] = rest;
58
+ if (!repoRoot)
59
+ usage('infer-project requires <repoRoot>');
60
+ const project = inferProject(repoRoot);
61
+ process.stdout.write(project + '\n');
62
+ break;
63
+ }
64
+ case 'next-task-id': {
65
+ // rest may contain --project=<p> flag among positional args
66
+ const positional = rest.filter((a) => !a.startsWith('--'));
67
+ const flags = rest.filter((a) => a.startsWith('--'));
68
+ const [repoRoot] = positional;
69
+ if (!repoRoot)
70
+ usage('next-task-id requires <repoRoot>');
71
+ const projectFlag = flags.find((f) => f.startsWith('--project='));
72
+ const explicitProject = projectFlag ? projectFlag.replace('--project=', '') : undefined;
73
+ const project = inferProject(repoRoot, explicitProject);
74
+ const id = nextTaskId(repoRoot, project);
75
+ process.stdout.write(id + '\n');
76
+ break;
77
+ }
78
+ case 'advance-status': {
79
+ const [repoRoot, taskId, toStatus, actorArg, gate, path] = rest;
80
+ if (!repoRoot || !taskId || !toStatus || !actorArg)
81
+ usage('advance-status requires <repoRoot> <taskId> <toStatus> <actor> [gate] [path]');
82
+ if (actorArg !== 'agent' && actorArg !== 'human') {
83
+ die(`actor must be 'agent' or 'human' (got '${actorArg}')`, 2);
84
+ }
85
+ const actor = actorArg;
86
+ const opts = {};
87
+ if (gate)
88
+ opts.gate = gate;
89
+ if (path === 'fast_lane' || path === 'full_pipeline')
90
+ opts.path = path;
91
+ const updated = advanceStatus(repoRoot, taskId, toStatus, actor, opts);
92
+ process.stdout.write(updated.status + '\n');
93
+ break;
94
+ }
95
+ case 'write-feedback': {
96
+ const positional = rest.filter((a) => !a.startsWith('--'));
97
+ const flags = rest.filter((a) => a.startsWith('--'));
98
+ const [repoRoot, taskId, envelopeJsonPath] = positional;
99
+ if (!repoRoot || !taskId || !envelopeJsonPath)
100
+ usage('write-feedback requires <repoRoot> <taskId> <envelopeJsonPath>');
101
+ const prefixFlag = flags.find((f) => f.startsWith('--prefix='));
102
+ const prefix = prefixFlag ? prefixFlag.split('=')[1] : 'r';
103
+ const envelope = JSON.parse(readFileSync(envelopeJsonPath, 'utf-8'));
104
+ const match = taskId.match(/^(.+)-\d+$/);
105
+ if (!match)
106
+ die(`Invalid taskId format: ${taskId}`);
107
+ const project = match[1];
108
+ const writtenPath = writeFeedback(repoRoot, { project, taskId, envelope, prefix });
109
+ process.stdout.write(writtenPath + '\n');
110
+ break;
111
+ }
112
+ case 'latest-feedback': {
113
+ const [repoRoot, taskId] = rest;
114
+ if (!repoRoot || !taskId)
115
+ usage('latest-feedback requires <repoRoot> <taskId>');
116
+ const envelope = latestFeedback(repoRoot, taskId);
117
+ if (envelope === null) {
118
+ process.stdout.write('');
119
+ }
120
+ else {
121
+ process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
122
+ }
123
+ break;
124
+ }
125
+ case 'emit-gate-decision': {
126
+ const positional = rest.filter((a) => !a.startsWith('--'));
127
+ const flags = rest.filter((a) => a.startsWith('--'));
128
+ const [repoRoot, workItemId, gate, decision, actorArg] = positional;
129
+ if (!repoRoot || !workItemId || !gate || !decision || !actorArg)
130
+ usage('emit-gate-decision requires <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]');
131
+ const validDecisions = ['approve', 'reject', 'revise', 'split', 'abandon', 'escalate'];
132
+ if (!validDecisions.includes(decision)) {
133
+ die(`decision must be one of: ${validDecisions.join(', ')}, got: ${decision}`);
134
+ }
135
+ if (actorArg !== 'agent' && actorArg !== 'human' && actorArg !== 'system') {
136
+ die(`actor must be "agent", "human", or "system", got: ${actorArg}`);
137
+ }
138
+ const commentFlag = flags.find((f) => f.startsWith('--comment='));
139
+ const comment = commentFlag ? commentFlag.replace('--comment=', '') : undefined;
140
+ // Derive project from workItemId (e.g. "DEMO-001" → "DEMO")
141
+ const wiMatch = workItemId.match(/^(.+)-\d+$/);
142
+ if (!wiMatch)
143
+ die(`Cannot derive project from workItemId: ${workItemId}`);
144
+ const project = wiMatch[1];
145
+ const writtenPath = emitGateDecision(repoRoot, {
146
+ project,
147
+ workItemType: 'task',
148
+ workItemId,
149
+ gate,
150
+ decision: decision,
151
+ actor: actorArg,
152
+ reasoning: comment,
153
+ });
154
+ process.stdout.write(writtenPath + '\n');
155
+ break;
156
+ }
157
+ case 'detect-ui-paths': {
158
+ const [repoRoot, taskId] = rest;
159
+ if (!repoRoot || !taskId) {
160
+ console.error('usage: detect-ui-paths <repo_root> <task-id>');
161
+ process.exit(1);
162
+ }
163
+ const branch = `cloverleaf/${taskId}`;
164
+ let changed;
165
+ try {
166
+ const out = execSync(`git diff --name-only main..${branch}`, {
167
+ cwd: repoRoot,
168
+ encoding: 'utf-8',
169
+ stdio: ['pipe', 'pipe', 'pipe'],
170
+ });
171
+ changed = out.split('\n').map((l) => l.trim()).filter(Boolean);
172
+ }
173
+ catch (e) {
174
+ const err = e;
175
+ const stderrStr = typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '';
176
+ console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
177
+ process.exit(2);
178
+ }
179
+ const patterns = loadDefaultPatterns();
180
+ const result = matchesUiPaths(changed, patterns);
181
+ process.stdout.write(`${result}\n`);
182
+ process.exit(0);
183
+ }
184
+ default:
185
+ usage(`Unknown command: ${command}`);
186
+ }
187
+ }
188
+ catch (err) {
189
+ const msg = err instanceof Error ? err.message : String(err);
190
+ // Surface "illegal transition" errors with the right language
191
+ const lower = msg.toLowerCase();
192
+ if (lower.includes('illegal') || lower.includes('not allowed')) {
193
+ die(`Illegal transition: ${msg}`);
194
+ }
195
+ die(`Error: ${msg}`);
196
+ }
@@ -0,0 +1,80 @@
1
+ import { writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { eventsDir } from './paths.mjs';
5
+ import { nextEventId } from './ids.mjs';
6
+ import { validateOrThrow } from './validate.mjs';
7
+ function actorObject(kind) {
8
+ const id = kind === 'agent' ? 'implementer' : kind === 'human' ? 'local-user' : 'system';
9
+ return { kind, id };
10
+ }
11
+ export function formatReason(opts) {
12
+ const parts = [];
13
+ if (opts.gate)
14
+ parts.push(`gate=${opts.gate}`);
15
+ if (opts.path)
16
+ parts.push(`path=${opts.path}`);
17
+ return parts.length > 0 ? parts.join('; ') : undefined;
18
+ }
19
+ /**
20
+ * Emits a status-transition event to `.cloverleaf/events/`.
21
+ * File name: `<PROJECT>-<NNN>-status.json` where NNN is the next per-project
22
+ * sequential number derived from existing event files.
23
+ *
24
+ * Returns the absolute path of the written file.
25
+ */
26
+ export function emitStatusTransition(repoRoot, params) {
27
+ const { project, workItemType, workItemId, from, to, actor } = params;
28
+ const seq = nextEventId(repoRoot, project);
29
+ const seqStr = String(seq).padStart(3, '0');
30
+ const filename = `${project}-${seqStr}-status.json`;
31
+ const filePath = join(eventsDir(repoRoot), filename);
32
+ // Build reason from gate and/or path if provided (schema only allows reason, not gate/path at top level).
33
+ const reason = formatReason({ gate: params.gate, path: params.path });
34
+ const doc = {
35
+ event_id: randomUUID(),
36
+ event_type: 'status_transition',
37
+ occurred_at: new Date().toISOString(),
38
+ work_item_id: { project, id: workItemId },
39
+ work_item_type: workItemType,
40
+ from_status: from,
41
+ to_status: to,
42
+ actor: actorObject(actor),
43
+ };
44
+ if (reason !== undefined) {
45
+ doc.reason = reason;
46
+ }
47
+ mkdirSync(eventsDir(repoRoot), { recursive: true });
48
+ validateOrThrow('https://cloverleaf.example/schemas/status-transition-event.schema.json', doc);
49
+ writeFileSync(filePath, JSON.stringify(doc, null, 2) + '\n');
50
+ return filePath;
51
+ }
52
+ /**
53
+ * Emits a gate-decision event to `.cloverleaf/events/`.
54
+ * File name: `<PROJECT>-<NNN>-gate.json`.
55
+ *
56
+ * Returns the absolute path of the written file.
57
+ */
58
+ export function emitGateDecision(repoRoot, params) {
59
+ const { project, workItemId, gate, decision, actor, reasoning } = params;
60
+ const seq = nextEventId(repoRoot, project);
61
+ const seqStr = String(seq).padStart(3, '0');
62
+ const filename = `${project}-${seqStr}-gate.json`;
63
+ const filePath = join(eventsDir(repoRoot), filename);
64
+ const doc = {
65
+ event_id: randomUUID(),
66
+ event_type: 'gate_decision',
67
+ occurred_at: new Date().toISOString(),
68
+ gate,
69
+ work_item_id: { project, id: workItemId },
70
+ decision,
71
+ approver: actorObject(actor),
72
+ };
73
+ if (reasoning !== undefined) {
74
+ doc.comment = reasoning;
75
+ }
76
+ mkdirSync(eventsDir(repoRoot), { recursive: true });
77
+ validateOrThrow('https://cloverleaf.example/schemas/gate-decision-event.schema.json', doc);
78
+ writeFileSync(filePath, JSON.stringify(doc, null, 2) + '\n');
79
+ return filePath;
80
+ }
@@ -0,0 +1,41 @@
1
+ import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { feedbackDir } from './paths.mjs';
4
+ import { nextFeedbackIteration } from './ids.mjs';
5
+ import { validateOrThrow } from './validate.mjs';
6
+ export function writeFeedback(repoRoot, params) {
7
+ const match = params.taskId.match(/^(.+)-(\d+)$/);
8
+ if (!match)
9
+ throw new Error(`Invalid taskId: ${params.taskId}`);
10
+ const project = match[1];
11
+ const taskNum = parseInt(match[2], 10);
12
+ if (project !== params.project) {
13
+ throw new Error(`project mismatch: taskId=${params.taskId} vs project=${params.project}`);
14
+ }
15
+ const prefix = params.prefix ?? 'r';
16
+ const iteration = nextFeedbackIteration(repoRoot, project, taskNum, prefix);
17
+ const filename = `${params.taskId}-${prefix}${iteration}.json`;
18
+ const path = join(feedbackDir(repoRoot), filename);
19
+ mkdirSync(feedbackDir(repoRoot), { recursive: true });
20
+ validateOrThrow('https://cloverleaf.example/schemas/feedback.schema.json', params.envelope);
21
+ writeFileSync(path, JSON.stringify(params.envelope, null, 2) + '\n');
22
+ return path;
23
+ }
24
+ export function latestFeedback(repoRoot, taskId) {
25
+ const items = allFeedback(repoRoot, taskId);
26
+ return items.length === 0 ? null : items[items.length - 1];
27
+ }
28
+ export function allFeedback(repoRoot, taskId) {
29
+ const dir = feedbackDir(repoRoot);
30
+ if (!existsSync(dir))
31
+ return [];
32
+ const re = new RegExp(`^${escapeRegex(taskId)}-r(\\d+)\\.json$`);
33
+ const entries = readdirSync(dir)
34
+ .map((f) => ({ f, m: f.match(re) }))
35
+ .filter((x) => !!x.m)
36
+ .sort((a, b) => parseInt(a.m[1], 10) - parseInt(b.m[1], 10));
37
+ return entries.map(({ f }) => JSON.parse(readFileSync(join(dir, f), 'utf-8')));
38
+ }
39
+ function escapeRegex(s) {
40
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ }
package/dist/ids.mjs ADDED
@@ -0,0 +1,60 @@
1
+ import { readdirSync, existsSync } from 'node:fs';
2
+ import { tasksDir, eventsDir, feedbackDir, projectsDir } from './paths.mjs';
3
+ export function nextTaskId(repoRoot, project) {
4
+ const dir = tasksDir(repoRoot);
5
+ if (!existsSync(dir))
6
+ return `${project}-001`;
7
+ const re = new RegExp(`^${escapeRegex(project)}-(\\d+)\\.json$`);
8
+ const nums = readdirSync(dir)
9
+ .map((f) => f.match(re))
10
+ .filter((m) => !!m)
11
+ .map((m) => parseInt(m[1], 10));
12
+ const next = nums.length === 0 ? 1 : Math.max(...nums) + 1;
13
+ return `${project}-${String(next).padStart(3, '0')}`;
14
+ }
15
+ export function nextEventId(repoRoot, project) {
16
+ const dir = eventsDir(repoRoot);
17
+ if (!existsSync(dir))
18
+ return 1;
19
+ const re = new RegExp(`^${escapeRegex(project)}-(\\d+)-(status|gate)\\.json$`);
20
+ const nums = readdirSync(dir)
21
+ .map((f) => f.match(re))
22
+ .filter((m) => !!m)
23
+ .map((m) => parseInt(m[1], 10));
24
+ return nums.length === 0 ? 1 : Math.max(...nums) + 1;
25
+ }
26
+ export function nextFeedbackIteration(repoRoot, project, taskNum, prefix = 'r') {
27
+ const dir = feedbackDir(repoRoot);
28
+ if (!existsSync(dir))
29
+ return 1;
30
+ const suffix = String(taskNum).padStart(3, '0');
31
+ const escapedPrefix = escapeRegex(prefix);
32
+ const re = new RegExp(`^${escapeRegex(project)}-${suffix}-${escapedPrefix}(\\d+)\\.json$`);
33
+ const nums = readdirSync(dir)
34
+ .map((f) => f.match(re))
35
+ .filter((m) => !!m)
36
+ .map((m) => parseInt(m[1], 10));
37
+ return nums.length === 0 ? 1 : Math.max(...nums) + 1;
38
+ }
39
+ export function listProjects(repoRoot) {
40
+ const dir = projectsDir(repoRoot);
41
+ if (!existsSync(dir))
42
+ return [];
43
+ return readdirSync(dir)
44
+ .filter((f) => f.endsWith('.json') && !f.endsWith('.meta.json'))
45
+ .map((f) => f.replace(/\.json$/, ''));
46
+ }
47
+ export function inferProject(repoRoot, explicit) {
48
+ if (explicit)
49
+ return explicit;
50
+ const projects = listProjects(repoRoot);
51
+ if (projects.length === 0)
52
+ throw new Error('No projects found under .cloverleaf/projects/');
53
+ if (projects.length > 1) {
54
+ throw new Error(`Multiple projects found (${projects.join(', ')}); specify one explicitly.`);
55
+ }
56
+ return projects[0];
57
+ }
58
+ function escapeRegex(s) {
59
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+ }
package/dist/index.mjs ADDED
@@ -0,0 +1,6 @@
1
+ export * from './paths.mjs';
2
+ export * from './ids.mjs';
3
+ export * from './state.mjs';
4
+ export * from './events.mjs';
5
+ export * from './feedback.mjs';
6
+ export * from './validate.mjs';
package/dist/paths.mjs ADDED
@@ -0,0 +1,26 @@
1
+ import { resolve } from 'node:path';
2
+ const CLOVERLEAF = '.cloverleaf';
3
+ export function cloverleafDir(repoRoot) {
4
+ return resolve(repoRoot, CLOVERLEAF);
5
+ }
6
+ export function projectsDir(repoRoot) {
7
+ return resolve(cloverleafDir(repoRoot), 'projects');
8
+ }
9
+ export function tasksDir(repoRoot) {
10
+ return resolve(cloverleafDir(repoRoot), 'tasks');
11
+ }
12
+ export function eventsDir(repoRoot) {
13
+ return resolve(cloverleafDir(repoRoot), 'events');
14
+ }
15
+ export function feedbackDir(repoRoot) {
16
+ return resolve(cloverleafDir(repoRoot), 'feedback');
17
+ }
18
+ export function rfcsDir(repoRoot) {
19
+ return resolve(cloverleafDir(repoRoot), 'rfcs');
20
+ }
21
+ export function spikesDir(repoRoot) {
22
+ return resolve(cloverleafDir(repoRoot), 'spikes');
23
+ }
24
+ export function plansDir(repoRoot) {
25
+ return resolve(cloverleafDir(repoRoot), 'plans');
26
+ }
package/dist/ports.mjs ADDED
@@ -0,0 +1,19 @@
1
+ import { createServer } from 'node:net';
2
+ export function getFreePort() {
3
+ return new Promise((resolve, reject) => {
4
+ const server = createServer();
5
+ server.unref();
6
+ server.once('error', reject);
7
+ server.listen(0, () => {
8
+ const addr = server.address();
9
+ if (addr && typeof addr === 'object') {
10
+ const port = addr.port;
11
+ server.close(() => resolve(port));
12
+ }
13
+ else {
14
+ server.close();
15
+ reject(new Error('could not determine port'));
16
+ }
17
+ });
18
+ });
19
+ }
@@ -0,0 +1,15 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ import { matchesUiPaths } from './ui-paths.mjs';
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ const DEFAULT_CONFIG = join(here, '..', 'config', 'qa-rules.json');
7
+ export function loadDefaultRules() {
8
+ if (!existsSync(DEFAULT_CONFIG))
9
+ return [];
10
+ const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
11
+ return Array.isArray(doc.rules) ? doc.rules : [];
12
+ }
13
+ export function selectTestCommands(changedFiles, rules) {
14
+ return rules.filter((rule) => matchesUiPaths(changedFiles, rule.match));
15
+ }
package/dist/state.mjs ADDED
@@ -0,0 +1,97 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { tasksDir, projectsDir } from './paths.mjs';
6
+ import { emitStatusTransition, formatReason } from './events.mjs';
7
+ // Import validator from @cloverleaf/standard.
8
+ // The standard package ships TypeScript source only with no exports map.
9
+ // Vitest (via vite-node) resolves .js → .ts for workspace symlinked packages,
10
+ // so the .js convention works here. If it ever fails with "module not found",
11
+ // switch the specifier to '@cloverleaf/standard/validators/index.ts'.
12
+ import { validateStatusTransitionLegality } from '@cloverleaf/standard/validators/index.js';
13
+ import { validateOrThrow } from './validate.mjs';
14
+ const req = createRequire(import.meta.url);
15
+ export function loadTask(repoRoot, taskId) {
16
+ const path = join(tasksDir(repoRoot), `${taskId}.json`);
17
+ if (!existsSync(path))
18
+ throw new Error(`Task ${taskId} not found at ${path}`);
19
+ return JSON.parse(readFileSync(path, 'utf-8'));
20
+ }
21
+ export function saveTask(repoRoot, task) {
22
+ validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
23
+ const path = join(tasksDir(repoRoot), `${task.id}.json`);
24
+ writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
25
+ }
26
+ export function loadProject(repoRoot, projectId) {
27
+ const path = join(projectsDir(repoRoot), `${projectId}.json`);
28
+ if (!existsSync(path))
29
+ throw new Error(`Project ${projectId} not found at ${path}`);
30
+ return JSON.parse(readFileSync(path, 'utf-8'));
31
+ }
32
+ function loadTaskStateMachine() {
33
+ // state-machines/task.json is a static JSON asset. Navigate from standard's
34
+ // package.json — no exports map support needed.
35
+ const pkgPath = req.resolve('@cloverleaf/standard/package.json');
36
+ const pkgDir = pkgPath.replace(/\/package\.json$/, '');
37
+ return JSON.parse(readFileSync(`${pkgDir}/state-machines/task.json`, 'utf-8'));
38
+ }
39
+ export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
40
+ const task = loadTask(repoRoot, taskId);
41
+ const from = task.status;
42
+ const sm = loadTaskStateMachine();
43
+ // Read risk_class directly from the task (defaulting to 'low' if absent).
44
+ // The validator derives itemPath from workItem.risk_class: low → fast_lane, else full_pipeline.
45
+ // If caller passed options.path, translate it back to risk_class for the validator.
46
+ const riskClass = options.path === 'fast_lane' ? 'low'
47
+ : options.path === 'full_pipeline' ? 'high'
48
+ : (task.risk_class ?? 'low');
49
+ // Build a minimal Task-shaped object so the validator can resolve path-tagged transitions.
50
+ const workItemForValidator = {
51
+ type: 'task',
52
+ id: task.id,
53
+ project: task.project,
54
+ status: task.status,
55
+ risk_class: riskClass,
56
+ context: { rfc: { project: task.project, id: task.id } },
57
+ definition_of_done: task.definition_of_done,
58
+ acceptance_criteria: task.acceptance_criteria,
59
+ };
60
+ const reason = formatReason({ gate: options.gate, path: options.path });
61
+ const event = {
62
+ event_id: randomUUID(),
63
+ event_type: 'status_transition',
64
+ occurred_at: new Date().toISOString(),
65
+ work_item_id: { project: task.project, id: task.id },
66
+ work_item_type: 'task',
67
+ from_status: from,
68
+ to_status: toStatus,
69
+ actor: { kind: actor, id: actor },
70
+ ...(reason ? { reason } : {}),
71
+ };
72
+ const result = validateStatusTransitionLegality(event, sm, workItemForValidator);
73
+ if (!result.ok) {
74
+ const msgs = result.violations.map((v) => v.message).join('; ');
75
+ throw new Error(`Illegal transition ${from} → ${toStatus}: ${msgs}`);
76
+ }
77
+ // NEW: emit first, save second. validateStatusTransitionLegality stays above.
78
+ const emittedPath = emitStatusTransition(repoRoot, {
79
+ project: task.project,
80
+ workItemType: 'task',
81
+ workItemId: task.id,
82
+ from,
83
+ to: toStatus,
84
+ actor,
85
+ gate: options.gate,
86
+ path: options.path,
87
+ });
88
+ const proposed = { ...task, status: toStatus };
89
+ try {
90
+ saveTask(repoRoot, proposed);
91
+ }
92
+ catch (err) {
93
+ const inner = err instanceof Error ? err.message : String(err);
94
+ throw new Error(`orphan event written to ${emittedPath} but task save failed: ${inner}`);
95
+ }
96
+ return proposed;
97
+ }