@cloverleaf/reference-impl 0.1.1 → 0.3.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 +52 -24
- package/VERSION +1 -1
- package/config/affected-routes.json +12 -0
- package/config/qa-rules.json +19 -0
- package/config/ui-paths.json +3 -0
- package/dist/affected-routes.mjs +66 -0
- package/dist/cli.mjs +224 -0
- package/dist/events.mjs +80 -0
- package/dist/feedback.mjs +41 -0
- package/dist/ids.mjs +60 -0
- package/dist/index.mjs +6 -0
- package/dist/paths.mjs +26 -0
- package/dist/ports.mjs +19 -0
- package/dist/qa-rules.mjs +15 -0
- package/dist/state.mjs +97 -0
- package/dist/ui-paths.mjs +25 -0
- package/dist/validate.mjs +40 -0
- package/install.sh +10 -0
- package/lib/affected-routes.ts +80 -0
- package/lib/cli.ts +63 -2
- package/lib/feedback.ts +6 -2
- package/lib/ids.ts +3 -2
- package/lib/ports.ts +19 -0
- package/lib/qa-rules.ts +23 -0
- package/lib/state.ts +1 -1
- package/lib/ui-paths.ts +27 -0
- package/lib/validate.ts +3 -20
- package/package.json +12 -6
- package/prompts/documenter.md +72 -0
- package/prompts/qa.md +82 -0
- package/prompts/ui-reviewer.md +103 -0
- package/skills/cloverleaf-document.md +73 -0
- package/skills/cloverleaf-implement.md +20 -4
- package/skills/cloverleaf-merge.md +46 -18
- package/skills/cloverleaf-new-task.md +9 -1
- package/skills/cloverleaf-qa.md +64 -0
- package/skills/cloverleaf-run.md +72 -18
- package/skills/cloverleaf-ui-review.md +93 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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 DEFAULT_CONFIG = join(here, '..', 'config', 'ui-paths.json');
|
|
6
|
+
export function loadDefaultPatterns() {
|
|
7
|
+
if (!existsSync(DEFAULT_CONFIG))
|
|
8
|
+
return ['site/**'];
|
|
9
|
+
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
|
|
10
|
+
return Array.isArray(doc.patterns) ? doc.patterns : ['site/**'];
|
|
11
|
+
}
|
|
12
|
+
function globToRegex(pattern) {
|
|
13
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
const regex = escaped
|
|
15
|
+
.replace(/\*\*/g, '\u0000')
|
|
16
|
+
.replace(/\*/g, '[^/]*')
|
|
17
|
+
.replace(/\u0000/g, '.*');
|
|
18
|
+
return new RegExp(`^${regex}$`);
|
|
19
|
+
}
|
|
20
|
+
export function matchesUiPaths(changedFiles, patterns) {
|
|
21
|
+
if (changedFiles.length === 0)
|
|
22
|
+
return false;
|
|
23
|
+
const regexes = patterns.map(globToRegex);
|
|
24
|
+
return changedFiles.some((f) => regexes.some((r) => r.test(f)));
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Ajv from 'ajv/dist/2020.js';
|
|
2
|
+
import addFormats from 'ajv-formats';
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
const req = createRequire(import.meta.url);
|
|
6
|
+
const pkgDir = req.resolve('@cloverleaf/standard/package.json').replace(/\/package\.json$/, '');
|
|
7
|
+
let ajvInstance = null;
|
|
8
|
+
const compiledCache = new Map();
|
|
9
|
+
function getAjv() {
|
|
10
|
+
if (ajvInstance)
|
|
11
|
+
return ajvInstance;
|
|
12
|
+
const ajv = new Ajv({ strict: false, validateFormats: true, allErrors: true });
|
|
13
|
+
addFormats(ajv);
|
|
14
|
+
const schemaFiles = readdirSync(`${pkgDir}/schemas`).filter(f => f.endsWith('.schema.json'));
|
|
15
|
+
for (const file of schemaFiles) {
|
|
16
|
+
const schema = JSON.parse(readFileSync(`${pkgDir}/schemas/${file}`, 'utf-8'));
|
|
17
|
+
ajv.addSchema(schema);
|
|
18
|
+
}
|
|
19
|
+
ajvInstance = ajv;
|
|
20
|
+
return ajv;
|
|
21
|
+
}
|
|
22
|
+
function getValidator(schemaId) {
|
|
23
|
+
const cached = compiledCache.get(schemaId);
|
|
24
|
+
if (cached)
|
|
25
|
+
return cached;
|
|
26
|
+
const ajv = getAjv();
|
|
27
|
+
const validator = ajv.getSchema(schemaId);
|
|
28
|
+
if (!validator)
|
|
29
|
+
throw new Error(`Schema not registered: ${schemaId}`);
|
|
30
|
+
compiledCache.set(schemaId, validator);
|
|
31
|
+
return validator;
|
|
32
|
+
}
|
|
33
|
+
export function validateOrThrow(schemaId, doc) {
|
|
34
|
+
const validate = getValidator(schemaId);
|
|
35
|
+
if (!validate(doc)) {
|
|
36
|
+
const violations = (validate.errors ?? []).length;
|
|
37
|
+
const detail = JSON.stringify(validate.errors, null, 2);
|
|
38
|
+
throw new Error(`Schema validation failed: ${violations} violation(s) against ${schemaId}\n${detail}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/install.sh
CHANGED
|
@@ -28,6 +28,9 @@ fi
|
|
|
28
28
|
|
|
29
29
|
mkdir -p "${INSTALL_ROOT}/skills" "${INSTALL_ROOT}/prompts" "${INSTALL_ROOT}/bin"
|
|
30
30
|
|
|
31
|
+
# Symlink config directory
|
|
32
|
+
ln -sf "${SCRIPT_DIR}/config" "${INSTALL_ROOT}/config"
|
|
33
|
+
|
|
31
34
|
# Symlink skills
|
|
32
35
|
for f in "${SCRIPT_DIR}/skills/"*.md; do
|
|
33
36
|
name="$(basename "$f")"
|
|
@@ -52,3 +55,10 @@ echo "Skills available: $(ls "${INSTALL_ROOT}/skills" | wc -l | tr -d ' ')"
|
|
|
52
55
|
echo ""
|
|
53
56
|
echo "Add ${INSTALL_ROOT}/bin to your PATH if you want to invoke cloverleaf-cli directly,"
|
|
54
57
|
echo "or reference it by absolute path from your skill calls."
|
|
58
|
+
|
|
59
|
+
# Post-install: warn about Playwright chromium if not cached
|
|
60
|
+
if [ ! -d "${HOME}/.cache/ms-playwright" ] || [ -z "$(ls -A "${HOME}/.cache/ms-playwright" 2>/dev/null)" ]; then
|
|
61
|
+
echo ""
|
|
62
|
+
echo "Note: UI Reviewer uses Playwright chromium. If you plan to run /cloverleaf-ui-review, install once with:"
|
|
63
|
+
echo " npx playwright install chromium"
|
|
64
|
+
fi
|
|
@@ -0,0 +1,80 @@
|
|
|
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 DEFAULT_CONFIG = join(here, '..', 'config', 'affected-routes.json');
|
|
7
|
+
|
|
8
|
+
export interface AffectedRoutesConfig {
|
|
9
|
+
pageRoots: string[];
|
|
10
|
+
globalPatterns: string[];
|
|
11
|
+
routeScope: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function globToRegex(pattern: string): RegExp {
|
|
15
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
16
|
+
const regex = escaped
|
|
17
|
+
.replace(/\*\*/g, '\u0000')
|
|
18
|
+
.replace(/\*/g, '[^/]*')
|
|
19
|
+
.replace(/\u0000/g, '.*');
|
|
20
|
+
return new RegExp(`^${regex}$`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function matchesAny(file: string, patterns: string[]): boolean {
|
|
24
|
+
return patterns.some((p) => globToRegex(p).test(file));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function routeForPage(file: string, pageRoot: string): string | null {
|
|
28
|
+
if (!file.startsWith(pageRoot)) return null;
|
|
29
|
+
const rel = file.slice(pageRoot.length);
|
|
30
|
+
const withoutExt = rel.replace(/\.(astro|mdx)$/, '');
|
|
31
|
+
if (!withoutExt || withoutExt === rel) return null;
|
|
32
|
+
if (withoutExt === 'index') return '/';
|
|
33
|
+
return `/${withoutExt}/`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadDefaultConfig(): AffectedRoutesConfig {
|
|
37
|
+
if (!existsSync(DEFAULT_CONFIG)) {
|
|
38
|
+
throw new Error(`affected-routes config not found at ${DEFAULT_CONFIG}`);
|
|
39
|
+
}
|
|
40
|
+
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as Partial<AffectedRoutesConfig>;
|
|
41
|
+
return {
|
|
42
|
+
pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
|
|
43
|
+
globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
|
|
44
|
+
routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function computeAffectedRoutes(
|
|
49
|
+
changedFiles: string[],
|
|
50
|
+
config: AffectedRoutesConfig
|
|
51
|
+
): string[] | 'all' {
|
|
52
|
+
const routes = new Set<string>();
|
|
53
|
+
let inScopeButUnmatched = false;
|
|
54
|
+
|
|
55
|
+
for (const file of changedFiles) {
|
|
56
|
+
if (matchesAny(file, config.globalPatterns)) {
|
|
57
|
+
return 'all';
|
|
58
|
+
}
|
|
59
|
+
let mapped: string | null = null;
|
|
60
|
+
for (const root of config.pageRoots) {
|
|
61
|
+
const r = routeForPage(file, root);
|
|
62
|
+
if (r) {
|
|
63
|
+
mapped = r;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (mapped) {
|
|
68
|
+
routes.add(mapped);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (matchesAny(file, config.routeScope)) {
|
|
72
|
+
inScopeButUnmatched = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (inScopeButUnmatched) {
|
|
77
|
+
return 'all';
|
|
78
|
+
}
|
|
79
|
+
return Array.from(routes).sort();
|
|
80
|
+
}
|
package/lib/cli.ts
CHANGED
|
@@ -15,11 +15,14 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { execSync } from 'node:child_process';
|
|
18
19
|
import { loadTask } from './state.js';
|
|
19
20
|
import { advanceStatus } from './state.js';
|
|
20
21
|
import { emitGateDecision } from './events.js';
|
|
21
22
|
import { writeFeedback, latestFeedback } from './feedback.js';
|
|
22
23
|
import { nextTaskId, inferProject } from './ids.js';
|
|
24
|
+
import { matchesUiPaths, loadDefaultPatterns } from './ui-paths.js';
|
|
25
|
+
import { computeAffectedRoutes, loadDefaultConfig } from './affected-routes.js';
|
|
23
26
|
import type { FeedbackEnvelope } from './feedback.js';
|
|
24
27
|
|
|
25
28
|
function die(msg: string, code = 1): never {
|
|
@@ -98,14 +101,18 @@ try {
|
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
case 'write-feedback': {
|
|
101
|
-
const
|
|
104
|
+
const positional = rest.filter((a: string) => !a.startsWith('--'));
|
|
105
|
+
const flags = rest.filter((a: string) => a.startsWith('--'));
|
|
106
|
+
const [repoRoot, taskId, envelopeJsonPath] = positional;
|
|
102
107
|
if (!repoRoot || !taskId || !envelopeJsonPath)
|
|
103
108
|
usage('write-feedback requires <repoRoot> <taskId> <envelopeJsonPath>');
|
|
109
|
+
const prefixFlag = flags.find((f: string) => f.startsWith('--prefix='));
|
|
110
|
+
const prefix = prefixFlag ? prefixFlag.split('=')[1] : 'r';
|
|
104
111
|
const envelope = JSON.parse(readFileSync(envelopeJsonPath, 'utf-8')) as FeedbackEnvelope;
|
|
105
112
|
const match = taskId.match(/^(.+)-\d+$/);
|
|
106
113
|
if (!match) die(`Invalid taskId format: ${taskId}`);
|
|
107
114
|
const project = match[1];
|
|
108
|
-
const writtenPath = writeFeedback(repoRoot, { project, taskId, envelope });
|
|
115
|
+
const writtenPath = writeFeedback(repoRoot, { project, taskId, envelope, prefix });
|
|
109
116
|
process.stdout.write(writtenPath + '\n');
|
|
110
117
|
break;
|
|
111
118
|
}
|
|
@@ -158,6 +165,60 @@ try {
|
|
|
158
165
|
break;
|
|
159
166
|
}
|
|
160
167
|
|
|
168
|
+
case 'detect-ui-paths': {
|
|
169
|
+
const [repoRoot, taskId] = rest;
|
|
170
|
+
if (!repoRoot || !taskId) {
|
|
171
|
+
console.error('usage: detect-ui-paths <repo_root> <task-id>');
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const branch = `cloverleaf/${taskId}`;
|
|
175
|
+
let changed: string[];
|
|
176
|
+
try {
|
|
177
|
+
const out = execSync(`git diff --name-only main..${branch}`, {
|
|
178
|
+
cwd: repoRoot,
|
|
179
|
+
encoding: 'utf-8',
|
|
180
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
181
|
+
});
|
|
182
|
+
changed = out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
183
|
+
} catch (e: unknown) {
|
|
184
|
+
const err = e as { stderr?: Buffer | string; message?: string };
|
|
185
|
+
const stderrStr = typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '';
|
|
186
|
+
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
187
|
+
process.exit(2);
|
|
188
|
+
}
|
|
189
|
+
const patterns = loadDefaultPatterns();
|
|
190
|
+
const result = matchesUiPaths(changed, patterns);
|
|
191
|
+
process.stdout.write(`${result}\n`);
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'affected-routes': {
|
|
196
|
+
const [repoRoot, taskId] = rest;
|
|
197
|
+
if (!repoRoot || !taskId) {
|
|
198
|
+
console.error('usage: affected-routes <repo_root> <task-id>');
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
const branch = `cloverleaf/${taskId}`;
|
|
202
|
+
let changed: string[];
|
|
203
|
+
try {
|
|
204
|
+
const out = execSync(`git diff --name-only main..${branch}`, {
|
|
205
|
+
cwd: repoRoot,
|
|
206
|
+
encoding: 'utf-8',
|
|
207
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
208
|
+
});
|
|
209
|
+
changed = out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
210
|
+
} catch (e: unknown) {
|
|
211
|
+
const err = e as { stderr?: Buffer | string; message?: string };
|
|
212
|
+
const stderrStr = typeof err.stderr === 'string' ? err.stderr : err.stderr?.toString() ?? '';
|
|
213
|
+
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
214
|
+
process.exit(2);
|
|
215
|
+
}
|
|
216
|
+
const config = loadDefaultConfig();
|
|
217
|
+
const result = computeAffectedRoutes(changed, config);
|
|
218
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
|
|
161
222
|
default:
|
|
162
223
|
usage(`Unknown command: ${command}`);
|
|
163
224
|
}
|
package/lib/feedback.ts
CHANGED
|
@@ -32,6 +32,9 @@ export interface WriteFeedbackParams {
|
|
|
32
32
|
project: string;
|
|
33
33
|
taskId: string; // e.g. "ACME-001"
|
|
34
34
|
envelope: FeedbackEnvelope;
|
|
35
|
+
/** Prefix letter for the feedback file counter. Defaults to 'r' (Reviewer).
|
|
36
|
+
* Use 'u' for UI Reviewer, 'q' for QA. */
|
|
37
|
+
prefix?: string;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function writeFeedback(repoRoot: string, params: WriteFeedbackParams): string {
|
|
@@ -42,8 +45,9 @@ export function writeFeedback(repoRoot: string, params: WriteFeedbackParams): st
|
|
|
42
45
|
if (project !== params.project) {
|
|
43
46
|
throw new Error(`project mismatch: taskId=${params.taskId} vs project=${params.project}`);
|
|
44
47
|
}
|
|
45
|
-
const
|
|
46
|
-
const
|
|
48
|
+
const prefix = params.prefix ?? 'r';
|
|
49
|
+
const iteration = nextFeedbackIteration(repoRoot, project, taskNum, prefix);
|
|
50
|
+
const filename = `${params.taskId}-${prefix}${iteration}.json`;
|
|
47
51
|
const path = join(feedbackDir(repoRoot), filename);
|
|
48
52
|
mkdirSync(feedbackDir(repoRoot), { recursive: true });
|
|
49
53
|
validateOrThrow('https://cloverleaf.example/schemas/feedback.schema.json', params.envelope);
|
package/lib/ids.ts
CHANGED
|
@@ -24,11 +24,12 @@ export function nextEventId(repoRoot: string, project: string): number {
|
|
|
24
24
|
return nums.length === 0 ? 1 : Math.max(...nums) + 1;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function nextFeedbackIteration(repoRoot: string, project: string, taskNum: number): number {
|
|
27
|
+
export function nextFeedbackIteration(repoRoot: string, project: string, taskNum: number, prefix = 'r'): number {
|
|
28
28
|
const dir = feedbackDir(repoRoot);
|
|
29
29
|
if (!existsSync(dir)) return 1;
|
|
30
30
|
const suffix = String(taskNum).padStart(3, '0');
|
|
31
|
-
const
|
|
31
|
+
const escapedPrefix = escapeRegex(prefix);
|
|
32
|
+
const re = new RegExp(`^${escapeRegex(project)}-${suffix}-${escapedPrefix}(\\d+)\\.json$`);
|
|
32
33
|
const nums = readdirSync(dir)
|
|
33
34
|
.map((f) => f.match(re))
|
|
34
35
|
.filter((m): m is RegExpMatchArray => !!m)
|
package/lib/ports.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
|
|
3
|
+
export function getFreePort(): Promise<number> {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const server = createServer();
|
|
6
|
+
server.unref();
|
|
7
|
+
server.once('error', reject);
|
|
8
|
+
server.listen(0, () => {
|
|
9
|
+
const addr = server.address();
|
|
10
|
+
if (addr && typeof addr === 'object') {
|
|
11
|
+
const port = addr.port;
|
|
12
|
+
server.close(() => resolve(port));
|
|
13
|
+
} else {
|
|
14
|
+
server.close();
|
|
15
|
+
reject(new Error('could not determine port'));
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
package/lib/qa-rules.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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.js';
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const DEFAULT_CONFIG = join(here, '..', 'config', 'qa-rules.json');
|
|
8
|
+
|
|
9
|
+
export interface QaRule {
|
|
10
|
+
cwd: string;
|
|
11
|
+
match: string[];
|
|
12
|
+
command: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function loadDefaultRules(): QaRule[] {
|
|
16
|
+
if (!existsSync(DEFAULT_CONFIG)) return [];
|
|
17
|
+
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { rules?: QaRule[] };
|
|
18
|
+
return Array.isArray(doc.rules) ? doc.rules : [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function selectTestCommands(changedFiles: string[], rules: QaRule[]): QaRule[] {
|
|
22
|
+
return rules.filter((rule) => matchesUiPaths(changedFiles, rule.match));
|
|
23
|
+
}
|
package/lib/state.ts
CHANGED
package/lib/ui-paths.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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 DEFAULT_CONFIG = join(here, '..', 'config', 'ui-paths.json');
|
|
7
|
+
|
|
8
|
+
export function loadDefaultPatterns(): string[] {
|
|
9
|
+
if (!existsSync(DEFAULT_CONFIG)) return ['site/**'];
|
|
10
|
+
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { patterns?: string[] };
|
|
11
|
+
return Array.isArray(doc.patterns) ? doc.patterns : ['site/**'];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function globToRegex(pattern: string): RegExp {
|
|
15
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
16
|
+
const regex = escaped
|
|
17
|
+
.replace(/\*\*/g, '\u0000')
|
|
18
|
+
.replace(/\*/g, '[^/]*')
|
|
19
|
+
.replace(/\u0000/g, '.*');
|
|
20
|
+
return new RegExp(`^${regex}$`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function matchesUiPaths(changedFiles: string[], patterns: string[]): boolean {
|
|
24
|
+
if (changedFiles.length === 0) return false;
|
|
25
|
+
const regexes = patterns.map(globToRegex);
|
|
26
|
+
return changedFiles.some((f) => regexes.some((r) => r.test(f)));
|
|
27
|
+
}
|
package/lib/validate.ts
CHANGED
|
@@ -1,29 +1,11 @@
|
|
|
1
1
|
import Ajv, { type ValidateFunction } from 'ajv/dist/2020.js';
|
|
2
2
|
import addFormats from 'ajv-formats';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
|
|
6
6
|
const req = createRequire(import.meta.url);
|
|
7
7
|
const pkgDir = req.resolve('@cloverleaf/standard/package.json').replace(/\/package\.json$/, '');
|
|
8
8
|
|
|
9
|
-
const SCHEMA_FILES = [
|
|
10
|
-
'work-item.schema.json',
|
|
11
|
-
'project.schema.json',
|
|
12
|
-
'task.schema.json',
|
|
13
|
-
'rfc.schema.json',
|
|
14
|
-
'spike.schema.json',
|
|
15
|
-
'plan.schema.json',
|
|
16
|
-
'feedback.schema.json',
|
|
17
|
-
'problem.schema.json',
|
|
18
|
-
'status-transition-event.schema.json',
|
|
19
|
-
'gate-decision-event.schema.json',
|
|
20
|
-
'status-transitions.schema.json',
|
|
21
|
-
'dependency-dag.schema.json',
|
|
22
|
-
'extensions.schema.json',
|
|
23
|
-
'path-rules.schema.json',
|
|
24
|
-
'risk-classifier-rules.schema.json',
|
|
25
|
-
];
|
|
26
|
-
|
|
27
9
|
let ajvInstance: Ajv | null = null;
|
|
28
10
|
const compiledCache = new Map<string, ValidateFunction>();
|
|
29
11
|
|
|
@@ -31,7 +13,8 @@ function getAjv(): Ajv {
|
|
|
31
13
|
if (ajvInstance) return ajvInstance;
|
|
32
14
|
const ajv = new Ajv({ strict: false, validateFormats: true, allErrors: true });
|
|
33
15
|
addFormats(ajv);
|
|
34
|
-
|
|
16
|
+
const schemaFiles = readdirSync(`${pkgDir}/schemas`).filter(f => f.endsWith('.schema.json'));
|
|
17
|
+
for (const file of schemaFiles) {
|
|
35
18
|
const schema = JSON.parse(readFileSync(`${pkgDir}/schemas/${file}`, 'utf-8'));
|
|
36
19
|
ajv.addSchema(schema);
|
|
37
20
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,25 +26,31 @@
|
|
|
26
26
|
"skills",
|
|
27
27
|
"lib",
|
|
28
28
|
"prompts",
|
|
29
|
+
"config",
|
|
29
30
|
"install.sh",
|
|
30
31
|
"README.md",
|
|
31
|
-
"VERSION"
|
|
32
|
+
"VERSION",
|
|
33
|
+
"dist"
|
|
32
34
|
],
|
|
33
35
|
"publishConfig": {
|
|
34
36
|
"access": "public"
|
|
35
37
|
},
|
|
36
38
|
"bin": {
|
|
37
|
-
"cloverleaf-cli": "./
|
|
39
|
+
"cloverleaf-cli": "./dist/cli.mjs"
|
|
38
40
|
},
|
|
39
41
|
"scripts": {
|
|
40
|
-
"test": "vitest run",
|
|
42
|
+
"test": "tsc --noEmit && vitest run",
|
|
41
43
|
"test:watch": "vitest",
|
|
42
|
-
"
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"build": "tsc -p tsconfig.build.json && node scripts/rename-to-mjs.mjs",
|
|
46
|
+
"prepublishOnly": "npm test && npm run build"
|
|
43
47
|
},
|
|
44
48
|
"dependencies": {
|
|
45
49
|
"@cloverleaf/standard": "^0.3.0",
|
|
46
50
|
"ajv": "^8.17.1",
|
|
47
|
-
"ajv-formats": "^3.0.1"
|
|
51
|
+
"ajv-formats": "^3.0.1",
|
|
52
|
+
"playwright": "^1.47.0",
|
|
53
|
+
"axe-core": "^4.10.0"
|
|
48
54
|
},
|
|
49
55
|
"devDependencies": {
|
|
50
56
|
"@types/node": "^22.0.0",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Documenter Agent
|
|
2
|
+
|
|
3
|
+
You are the Cloverleaf Documenter. Your job: produce doc-only commits that update AI-facing docs to reflect the code changes in a task's feature branch. You do NOT touch source code. You do NOT write new prose or new top-level sections. You only edit existing doc structures and append entries to CHANGELOGs.
|
|
4
|
+
|
|
5
|
+
## Input
|
|
6
|
+
|
|
7
|
+
- **Task**: {{task}}
|
|
8
|
+
- **Branch**: {{branch}} (already exists; contains Implementer's commits)
|
|
9
|
+
- **Base branch**: {{base_branch}}
|
|
10
|
+
- **Repo root**: {{repo_root}}
|
|
11
|
+
- **Diff from base**: {{diff}}
|
|
12
|
+
|
|
13
|
+
## Tool constraints
|
|
14
|
+
|
|
15
|
+
- Use `git worktree add <temp> {{branch}}` to work on an isolated checkout. Do NOT `git checkout` in the main working directory.
|
|
16
|
+
- Only edit files under:
|
|
17
|
+
- `<package>/CHANGELOG.md` (create `## [Unreleased]` section if missing)
|
|
18
|
+
- `<package>/README.md`
|
|
19
|
+
- `<package>/docs/*.md`
|
|
20
|
+
- Root `CHANGELOG.md`, root `README.md`
|
|
21
|
+
- Never touch source code (`*.ts`, `*.tsx`, `*.js`, `*.py`, `*.astro` bodies, etc.)
|
|
22
|
+
- Never create new top-level sections. If a change warrants one, return `commits_added: 0` with a summary noting the deferral.
|
|
23
|
+
- Never set release dates or version numbers in CHANGELOGs. Always write under `## [Unreleased]`.
|
|
24
|
+
|
|
25
|
+
## File-path rules
|
|
26
|
+
|
|
27
|
+
Inspect the diff. For each category below that matches, update the listed docs:
|
|
28
|
+
|
|
29
|
+
| Diff touches | Docs to update |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `standard/src/**`, `standard/schemas/**`, `standard/conformance/**` | `standard/CHANGELOG.md` (Unreleased), relevant `standard/docs/*.md` sections if behavior/conformance changed |
|
|
32
|
+
| `reference-impl/lib/**`, `reference-impl/skills/**`, `reference-impl/prompts/**` | `reference-impl/CHANGELOG.md` (Unreleased), `reference-impl/README.md` if public surface changed (new skill, CLI command, exported lib symbol) |
|
|
33
|
+
| `site/src/**`, `site/public/**` | `site/CHANGELOG.md` ONLY if that file already exists; otherwise skip |
|
|
34
|
+
| Root-level package additions, version bumps | Root `README.md`, root `CHANGELOG.md` |
|
|
35
|
+
| Only tests, configs, or `.cloverleaf/**` touched | No doc commits — return `commits_added: 0` with summary |
|
|
36
|
+
|
|
37
|
+
## CHANGELOG format
|
|
38
|
+
|
|
39
|
+
Append a single bullet to an `## [Unreleased]` section under the appropriate `### Added / ### Changed / ### Fixed` subheading. Infer the subheading from commit messages + diff shape:
|
|
40
|
+
|
|
41
|
+
- `feat:` or new files → `### Added`
|
|
42
|
+
- `fix:` → `### Fixed`
|
|
43
|
+
- `refactor:`, `chore:`, other → `### Changed`
|
|
44
|
+
|
|
45
|
+
If `## [Unreleased]` does not exist, create it at the top of the CHANGELOG (right after the title line or any badges).
|
|
46
|
+
|
|
47
|
+
## README/docs surgery
|
|
48
|
+
|
|
49
|
+
- Only edit existing sections. If a change warrants a new section, defer.
|
|
50
|
+
- Keep edits surgical: update a version number in a code block, add a new entry to an existing list, revise a single paragraph to reflect changed behavior.
|
|
51
|
+
- If unsure, prefer a shorter, more conservative edit over rewriting prose.
|
|
52
|
+
|
|
53
|
+
## Commit discipline
|
|
54
|
+
|
|
55
|
+
- One commit per file touched.
|
|
56
|
+
- Commit message: `docs(<scope>): <short>` where `<scope>` is the package name (`standard`, `reference-impl`, `site`, or `repo` for root-level).
|
|
57
|
+
- All commits land on `{{branch}}` (the feature branch).
|
|
58
|
+
- After all commits land, run `git worktree remove --force <temp>` to clean up.
|
|
59
|
+
|
|
60
|
+
## Output
|
|
61
|
+
|
|
62
|
+
Respond with exactly one JSON object and nothing else:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"commits_added": <integer ≥ 0>,
|
|
67
|
+
"files_changed": ["<relative/path1>", "<relative/path2>"],
|
|
68
|
+
"summary": "<one-sentence summary of changes>"
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
If you cannot determine safe edits and no doc update is warranted, return `{"commits_added": 0, "files_changed": [], "summary": "No AI-facing docs required updating."}`.
|