@cloverleaf/reference-impl 0.6.3 → 0.6.5
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 +8 -2
- package/README.md +2 -1
- package/dist/cli.mjs +57 -1
- package/dist/release-preflight.mjs +123 -0
- package/dist/walker-config.mjs +63 -0
- package/lib/cli.ts +56 -1
- package/lib/release-preflight.ts +171 -0
- package/lib/walker-config.ts +90 -0
- package/package.json +1 -1
- package/prompts/qa.md +1 -1
- package/skills/cloverleaf-release/SKILL.md +109 -0
- package/skills/cloverleaf-run-plan/SKILL.md +27 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloverleaf",
|
|
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.6.
|
|
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, release).",
|
|
4
|
+
"version": "0.6.5",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Renato D'Arrigo",
|
|
7
7
|
"email": "renato.darrigo@gmail.com"
|
|
@@ -14,5 +14,11 @@
|
|
|
14
14
|
"methodology",
|
|
15
15
|
"ai-first",
|
|
16
16
|
"reference-implementation"
|
|
17
|
+
],
|
|
18
|
+
"skills": [
|
|
19
|
+
{
|
|
20
|
+
"name": "cloverleaf-release",
|
|
21
|
+
"description": "Publish a new @cloverleaf/reference-impl release. Runs pre-flight checks then executes git tag, git push, npm publish, gh release create. Accepts [--dry-run] [--yes] flags."
|
|
22
|
+
}
|
|
17
23
|
]
|
|
18
24
|
}
|
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ v0.2 implements both paths of the Delivery track:
|
|
|
45
45
|
- `/cloverleaf-qa` — run QA *(new in v0.2)*
|
|
46
46
|
- `/cloverleaf-merge` — human gate (branches on state)
|
|
47
47
|
- `/cloverleaf-run` — orchestrator (dispatches by `risk_class`)
|
|
48
|
+
- `/cloverleaf-release` — publish a new `@cloverleaf/reference-impl` release; runs pre-flight checks then executes `git tag -a` / `git push origin main` / `git push origin <tag>` / `npm publish` / `gh release create`; accepts `[--dry-run] [--yes]` *(new in CLV-63)*
|
|
48
49
|
|
|
49
50
|
### Configuration
|
|
50
51
|
|
|
@@ -152,7 +153,7 @@ The Reviewer never switches branches. It reads files via `git show` and runs tes
|
|
|
152
153
|
|
|
153
154
|
## Package layout
|
|
154
155
|
|
|
155
|
-
- `lib/` — TypeScript library used by the CLI. State, events, feedback, IDs, paths. Includes `buildBaselinePath(repoRoot, browser, slug, viewport)` (`lib/visual-diff.ts`) for constructing canonical baseline paths under `.cloverleaf/baselines/{browser}/`. `lib/ui-browser.ts` exports `buildBrowserEscalationFinding` and `applyMaxCombinationsCap` (used by the UI Reviewer prompt for per-engine escalation and combination-count capping). `lib/ui-review-state.ts` exports `readUiReviewState`, `writeUiReviewState`, and `uiReviewStatePath` — the baseline-approval sidecar API for `.cloverleaf/runs/{taskId}/ui-review/state.json`. The CLI exposes `write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>` as the safe write path for baselines; it enforces the `baselines_pending` guard and uses `buildBaselinePath` internally.
|
|
156
|
+
- `lib/` — TypeScript library used by the CLI. State, events, feedback, IDs, paths. Includes `buildBaselinePath(repoRoot, browser, slug, viewport)` (`lib/visual-diff.ts`) for constructing canonical baseline paths under `.cloverleaf/baselines/{browser}/`. `lib/ui-browser.ts` exports `buildBrowserEscalationFinding` and `applyMaxCombinationsCap` (used by the UI Reviewer prompt for per-engine escalation and combination-count capping). `lib/ui-review-state.ts` exports `readUiReviewState`, `writeUiReviewState`, and `uiReviewStatePath` — the baseline-approval sidecar API for `.cloverleaf/runs/{taskId}/ui-review/state.json`. The CLI exposes `write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>` as the safe write path for baselines; it enforces the `baselines_pending` guard and uses `buildBaselinePath` internally. `lib/walker-config.ts` exports `loadWalkerConfig()` — reads `~/.config/cloverleaf/walker.json` (XDG-aware) for the user-level `max_concurrent` override; exposed via `cloverleaf-cli walker-default-concurrency [--explain]`. `lib/release-preflight.ts` exports `runPreflightChecks(repoRoot)` — runs six blocking checks and two warnings, returning `{ checks, version, tag, notes }`; exposed via `cloverleaf-cli release-preflight <repoRoot> [--json]`.
|
|
156
157
|
- `skills/` — Claude Code skill markdown files.
|
|
157
158
|
- `prompts/` — Implementer/Reviewer subagent system prompts.
|
|
158
159
|
- `examples/toy-repo/` — standalone demo repo.
|
package/dist/cli.mjs
CHANGED
|
@@ -34,10 +34,13 @@
|
|
|
34
34
|
* dag-detect-cycle <repoRoot> <planId>
|
|
35
35
|
* walk-state-read <repoRoot> <planId>
|
|
36
36
|
* walk-state-write <repoRoot> <walkStateJsonPath>
|
|
37
|
+
* walker-default-concurrency [--explain]
|
|
38
|
+
* release-preflight <repoRoot> [--json]
|
|
37
39
|
*/
|
|
38
40
|
import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
39
41
|
import { dirname } from 'node:path';
|
|
40
42
|
import { execSync } from 'node:child_process';
|
|
43
|
+
import { runPreflightChecks } from './release-preflight.mjs';
|
|
41
44
|
import { loadTask } from './task.mjs';
|
|
42
45
|
import { advanceStatus } from './task.mjs';
|
|
43
46
|
import { emitGateDecision } from './events.mjs';
|
|
@@ -58,6 +61,7 @@ import { readUiReviewState, writeUiReviewState } from './ui-review-state.mjs';
|
|
|
58
61
|
import { buildBaselinePath } from './visual-diff.mjs';
|
|
59
62
|
import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
|
|
60
63
|
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.mjs';
|
|
64
|
+
import { loadWalkerConfig } from './walker-config.mjs';
|
|
61
65
|
function die(msg, code = 1) {
|
|
62
66
|
process.stderr.write(msg + '\n');
|
|
63
67
|
process.exit(code);
|
|
@@ -95,7 +99,8 @@ function usage(msg) {
|
|
|
95
99
|
' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
|
|
96
100
|
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
97
101
|
' walk-state-read <repoRoot> <planId>\n' +
|
|
98
|
-
' walk-state-write <repoRoot> <walkStateJsonPath>\n'
|
|
102
|
+
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
103
|
+
' walker-default-concurrency [--explain]\n');
|
|
99
104
|
process.exit(2);
|
|
100
105
|
}
|
|
101
106
|
const [, , command, ...rest] = process.argv;
|
|
@@ -485,6 +490,57 @@ try {
|
|
|
485
490
|
writeWalkState(repoRoot, state);
|
|
486
491
|
break;
|
|
487
492
|
}
|
|
493
|
+
case 'walker-default-concurrency': {
|
|
494
|
+
const explain = rest.includes('--explain');
|
|
495
|
+
let cfg;
|
|
496
|
+
try {
|
|
497
|
+
cfg = loadWalkerConfig();
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
501
|
+
process.stderr.write(msg + '\n');
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
if (explain) {
|
|
505
|
+
const location = cfg.source === 'user' ? `from ${cfg.path}` : 'default';
|
|
506
|
+
process.stdout.write(`max_concurrent=${cfg.maxConcurrent} (${location})\n`);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
process.stdout.write(`${cfg.maxConcurrent}\n`);
|
|
510
|
+
}
|
|
511
|
+
process.exit(0);
|
|
512
|
+
}
|
|
513
|
+
case 'release-preflight': {
|
|
514
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
515
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
516
|
+
const [repoRoot] = positional;
|
|
517
|
+
if (!repoRoot)
|
|
518
|
+
usage('release-preflight requires <repoRoot>');
|
|
519
|
+
const jsonMode = flags.includes('--json');
|
|
520
|
+
const result = runPreflightChecks(repoRoot);
|
|
521
|
+
if (jsonMode) {
|
|
522
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
523
|
+
process.exit(0);
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
// Plain human-readable mode
|
|
527
|
+
for (const check of result.checks) {
|
|
528
|
+
let prefix;
|
|
529
|
+
if (check.status === 'pass') {
|
|
530
|
+
prefix = '✓';
|
|
531
|
+
}
|
|
532
|
+
else if (check.level === 'warning') {
|
|
533
|
+
prefix = '⚠';
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
prefix = '✗';
|
|
537
|
+
}
|
|
538
|
+
process.stdout.write(`${prefix} ${check.id}: ${check.message}\n`);
|
|
539
|
+
}
|
|
540
|
+
const blockingFailed = result.checks.some((c) => c.level === 'blocking' && c.status === 'fail');
|
|
541
|
+
process.exit(blockingFailed ? 1 : 0);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
488
544
|
default:
|
|
489
545
|
usage(`Unknown command: ${command}`);
|
|
490
546
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
function shell(cmd, cwd) {
|
|
5
|
+
try {
|
|
6
|
+
const out = execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
7
|
+
return { out: out.trim(), ok: true };
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
const e = err;
|
|
11
|
+
const msg = (e.stderr ?? e.stdout ?? e.message ?? String(err)).trim();
|
|
12
|
+
return { out: msg, ok: false };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Run all pre-flight checks for a release from the given repo root.
|
|
17
|
+
* Never throws — all errors are captured into per-check `message` fields.
|
|
18
|
+
*/
|
|
19
|
+
export function runPreflightChecks(repoRoot) {
|
|
20
|
+
const checks = [];
|
|
21
|
+
// Helper to add a check result
|
|
22
|
+
function addCheck(id, level, pass, failMsg, passMsg = 'ok') {
|
|
23
|
+
checks.push({ id, level, status: pass ? 'pass' : 'fail', message: pass ? passMsg : failMsg });
|
|
24
|
+
}
|
|
25
|
+
// ── Blocking checks ────────────────────────────────────────────────────────
|
|
26
|
+
// 1. on-main: current branch must be main
|
|
27
|
+
const branch = shell('git rev-parse --abbrev-ref HEAD', repoRoot);
|
|
28
|
+
const isOnMain = branch.ok && branch.out === 'main';
|
|
29
|
+
addCheck('on-main', 'blocking', isOnMain, `not on main (current: ${branch.out})`);
|
|
30
|
+
// 2. clean-tree: no uncommitted changes
|
|
31
|
+
const status = shell('git status --porcelain', repoRoot);
|
|
32
|
+
const isClean = status.ok && status.out === '';
|
|
33
|
+
addCheck('clean-tree', 'blocking', isClean, `working tree is dirty: ${status.out || status.out}`);
|
|
34
|
+
// 3. in-sync-with-origin: no commits behind origin/main
|
|
35
|
+
// Fetch quietly first so we can compare
|
|
36
|
+
shell('git fetch origin main --quiet', repoRoot);
|
|
37
|
+
const behind = shell('git rev-list --count HEAD..origin/main', repoRoot);
|
|
38
|
+
const isSynced = behind.ok && behind.out === '0';
|
|
39
|
+
addCheck('in-sync-with-origin', 'blocking', isSynced, behind.ok ? `${behind.out} commit(s) behind origin/main` : `could not check sync: ${behind.out}`);
|
|
40
|
+
// 4. valid-version: reference-impl/package.json has a valid semver
|
|
41
|
+
let version = '';
|
|
42
|
+
try {
|
|
43
|
+
const pkgPath = join(repoRoot, 'reference-impl', 'package.json');
|
|
44
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
45
|
+
version = pkg.version ?? '';
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
49
|
+
addCheck('valid-version', 'blocking', false, `could not read package.json: ${msg}`);
|
|
50
|
+
version = '';
|
|
51
|
+
}
|
|
52
|
+
if (version !== '') {
|
|
53
|
+
const semverRe = /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/;
|
|
54
|
+
const isValidSemver = semverRe.test(version);
|
|
55
|
+
addCheck('valid-version', 'blocking', isValidSemver, `invalid semver: '${version}'`, `${version}`);
|
|
56
|
+
}
|
|
57
|
+
// 5. changelog-section: CHANGELOG.md has a section for this version
|
|
58
|
+
const tag = version ? `reference-impl-v${version}` : 'reference-impl-v<unknown>';
|
|
59
|
+
let changelogPass = false;
|
|
60
|
+
let changelogMsg = 'no version resolved';
|
|
61
|
+
if (version) {
|
|
62
|
+
try {
|
|
63
|
+
const changelogPath = join(repoRoot, 'reference-impl', 'CHANGELOG.md');
|
|
64
|
+
const changelog = readFileSync(changelogPath, 'utf-8');
|
|
65
|
+
// Look for a heading like "## 0.6.5" or "## [0.6.5]"
|
|
66
|
+
const pattern = new RegExp(`^##\\s+\\[?${version.replace(/\./g, '\\.')}`, 'm');
|
|
67
|
+
changelogPass = pattern.test(changelog);
|
|
68
|
+
changelogMsg = changelogPass
|
|
69
|
+
? `section for ${version} found`
|
|
70
|
+
: `no ## ${version} section found in CHANGELOG.md`;
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
74
|
+
changelogMsg = `could not read CHANGELOG.md: ${msg}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
addCheck('changelog-section', 'blocking', changelogPass, changelogMsg, changelogMsg);
|
|
78
|
+
// 6. tag-absent: the release tag must not already exist
|
|
79
|
+
let tagAbsent = false;
|
|
80
|
+
let tagMsg = 'no version resolved';
|
|
81
|
+
if (version) {
|
|
82
|
+
const localTag = shell(`git tag -l "${tag}"`, repoRoot);
|
|
83
|
+
const remoteTag = shell(`git ls-remote --tags origin "${tag}"`, repoRoot);
|
|
84
|
+
const existsLocally = localTag.ok && localTag.out !== '';
|
|
85
|
+
const existsRemotely = remoteTag.ok && remoteTag.out !== '';
|
|
86
|
+
tagAbsent = !existsLocally && !existsRemotely;
|
|
87
|
+
if (existsLocally && existsRemotely) {
|
|
88
|
+
tagMsg = `tag ${tag} already exists locally and on origin`;
|
|
89
|
+
}
|
|
90
|
+
else if (existsLocally) {
|
|
91
|
+
tagMsg = `tag ${tag} already exists locally`;
|
|
92
|
+
}
|
|
93
|
+
else if (existsRemotely) {
|
|
94
|
+
tagMsg = `tag ${tag} already exists on origin`;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
tagMsg = `tag ${tag} is absent (ok)`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
addCheck('tag-absent', 'blocking', tagAbsent, tagMsg, tagMsg);
|
|
101
|
+
// ── Warning checks ─────────────────────────────────────────────────────────
|
|
102
|
+
// 7. npm-authenticated: `npm whoami` must exit 0
|
|
103
|
+
const npmAuth = shell('npm whoami', repoRoot);
|
|
104
|
+
addCheck('npm-authenticated', 'warning', npmAuth.ok, `npm not authenticated: ${npmAuth.out}`, `logged in as ${npmAuth.out}`);
|
|
105
|
+
// 8. gh-authenticated: `gh auth status` must exit 0
|
|
106
|
+
const ghAuth = shell('gh auth status', repoRoot);
|
|
107
|
+
addCheck('gh-authenticated', 'warning', ghAuth.ok, `gh not authenticated: ${ghAuth.out}`, 'gh CLI authenticated');
|
|
108
|
+
// Derive release notes from CHANGELOG section
|
|
109
|
+
let notes = '';
|
|
110
|
+
if (version) {
|
|
111
|
+
try {
|
|
112
|
+
const changelogPath = join(repoRoot, 'reference-impl', 'CHANGELOG.md');
|
|
113
|
+
const changelog = readFileSync(changelogPath, 'utf-8');
|
|
114
|
+
const pattern = new RegExp(`^##\\s+\\[?${version.replace(/\./g, '\\.')}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s|$)`, 'm');
|
|
115
|
+
const m = changelog.match(pattern);
|
|
116
|
+
notes = m ? m[1].trim() : '';
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
notes = '';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { checks, version, tag, notes };
|
|
123
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the absolute path to the walker config file.
|
|
6
|
+
* Respects XDG_CONFIG_HOME if set; otherwise falls back to $HOME/.config.
|
|
7
|
+
*/
|
|
8
|
+
export function walkerConfigPath() {
|
|
9
|
+
const xdgConfigHome = process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config');
|
|
10
|
+
return join(xdgConfigHome, 'cloverleaf', 'walker.json');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Load and validate the walker configuration.
|
|
14
|
+
*
|
|
15
|
+
* Resolution rules:
|
|
16
|
+
* - File absent (ENOENT) → { maxConcurrent: 3, source: 'default' }
|
|
17
|
+
* - File present, no max_concurrent field → { maxConcurrent: 3, source: 'default' }
|
|
18
|
+
* - File present, valid positive integer → { maxConcurrent: N, source: 'user' }
|
|
19
|
+
* - Malformed JSON → throws with file path and parser detail
|
|
20
|
+
* - Invalid max_concurrent (0, negative, float, string, null) → throws with field path and actual value
|
|
21
|
+
*
|
|
22
|
+
* No AJV dependency — hand-rolled validation.
|
|
23
|
+
*/
|
|
24
|
+
export function loadWalkerConfig() {
|
|
25
|
+
const configPath = walkerConfigPath();
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = readFileSync(configPath, 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const e = err;
|
|
32
|
+
if (e.code === 'ENOENT') {
|
|
33
|
+
return { maxConcurrent: 3, source: 'default', path: configPath };
|
|
34
|
+
}
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(raw);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
43
|
+
throw new Error(`walker config at ${configPath} contains malformed JSON: ${detail}`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
46
|
+
throw new Error(`walker config at ${configPath}: expected a JSON object, got ${JSON.stringify(parsed)}`);
|
|
47
|
+
}
|
|
48
|
+
const doc = parsed;
|
|
49
|
+
if (!('max_concurrent' in doc)) {
|
|
50
|
+
return { maxConcurrent: 3, source: 'default', path: configPath };
|
|
51
|
+
}
|
|
52
|
+
const value = doc['max_concurrent'];
|
|
53
|
+
if (typeof value !== 'number') {
|
|
54
|
+
throw new Error(`walker config at ${configPath}: max_concurrent must be a positive integer, got ${JSON.stringify(value)}`);
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isInteger(value)) {
|
|
57
|
+
throw new Error(`walker config at ${configPath}: max_concurrent must be a positive integer (no floats), got ${value}`);
|
|
58
|
+
}
|
|
59
|
+
if (value <= 0) {
|
|
60
|
+
throw new Error(`walker config at ${configPath}: max_concurrent must be a positive integer (> 0), got ${value}`);
|
|
61
|
+
}
|
|
62
|
+
return { maxConcurrent: value, source: 'user', path: configPath };
|
|
63
|
+
}
|
package/lib/cli.ts
CHANGED
|
@@ -34,11 +34,14 @@
|
|
|
34
34
|
* dag-detect-cycle <repoRoot> <planId>
|
|
35
35
|
* walk-state-read <repoRoot> <planId>
|
|
36
36
|
* walk-state-write <repoRoot> <walkStateJsonPath>
|
|
37
|
+
* walker-default-concurrency [--explain]
|
|
38
|
+
* release-preflight <repoRoot> [--json]
|
|
37
39
|
*/
|
|
38
40
|
|
|
39
41
|
import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
40
42
|
import { dirname } from 'node:path';
|
|
41
43
|
import { execSync } from 'node:child_process';
|
|
44
|
+
import { runPreflightChecks } from './release-preflight.js';
|
|
42
45
|
import { loadTask } from './task.js';
|
|
43
46
|
import { advanceStatus } from './task.js';
|
|
44
47
|
import { emitGateDecision } from './events.js';
|
|
@@ -60,6 +63,7 @@ import { readUiReviewState, writeUiReviewState } from './ui-review-state.js';
|
|
|
60
63
|
import { buildBaselinePath } from './visual-diff.js';
|
|
61
64
|
import { computeReadyTasks, detectCycle } from './dag-walker.js';
|
|
62
65
|
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.js';
|
|
66
|
+
import { loadWalkerConfig } from './walker-config.js';
|
|
63
67
|
|
|
64
68
|
function die(msg: string, code = 1): never {
|
|
65
69
|
process.stderr.write(msg + '\n');
|
|
@@ -99,7 +103,8 @@ function usage(msg?: string): never {
|
|
|
99
103
|
' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
|
|
100
104
|
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
101
105
|
' walk-state-read <repoRoot> <planId>\n' +
|
|
102
|
-
' walk-state-write <repoRoot> <walkStateJsonPath>\n'
|
|
106
|
+
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
107
|
+
' walker-default-concurrency [--explain]\n'
|
|
103
108
|
);
|
|
104
109
|
process.exit(2);
|
|
105
110
|
}
|
|
@@ -499,6 +504,56 @@ try {
|
|
|
499
504
|
break;
|
|
500
505
|
}
|
|
501
506
|
|
|
507
|
+
case 'walker-default-concurrency': {
|
|
508
|
+
const explain = rest.includes('--explain');
|
|
509
|
+
let cfg: { maxConcurrent: number; source: 'user' | 'default'; path: string };
|
|
510
|
+
try {
|
|
511
|
+
cfg = loadWalkerConfig();
|
|
512
|
+
} catch (err: unknown) {
|
|
513
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
514
|
+
process.stderr.write(msg + '\n');
|
|
515
|
+
process.exit(1);
|
|
516
|
+
}
|
|
517
|
+
if (explain) {
|
|
518
|
+
const location =
|
|
519
|
+
cfg.source === 'user' ? `from ${cfg.path}` : 'default';
|
|
520
|
+
process.stdout.write(`max_concurrent=${cfg.maxConcurrent} (${location})\n`);
|
|
521
|
+
} else {
|
|
522
|
+
process.stdout.write(`${cfg.maxConcurrent}\n`);
|
|
523
|
+
}
|
|
524
|
+
process.exit(0);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
case 'release-preflight': {
|
|
528
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
529
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
530
|
+
const [repoRoot] = positional;
|
|
531
|
+
if (!repoRoot) usage('release-preflight requires <repoRoot>');
|
|
532
|
+
const jsonMode = flags.includes('--json');
|
|
533
|
+
const result = runPreflightChecks(repoRoot);
|
|
534
|
+
if (jsonMode) {
|
|
535
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
536
|
+
process.exit(0);
|
|
537
|
+
} else {
|
|
538
|
+
// Plain human-readable mode
|
|
539
|
+
for (const check of result.checks) {
|
|
540
|
+
let prefix: string;
|
|
541
|
+
if (check.status === 'pass') {
|
|
542
|
+
prefix = '✓';
|
|
543
|
+
} else if (check.level === 'warning') {
|
|
544
|
+
prefix = '⚠';
|
|
545
|
+
} else {
|
|
546
|
+
prefix = '✗';
|
|
547
|
+
}
|
|
548
|
+
process.stdout.write(`${prefix} ${check.id}: ${check.message}\n`);
|
|
549
|
+
}
|
|
550
|
+
const blockingFailed = result.checks.some(
|
|
551
|
+
(c) => c.level === 'blocking' && c.status === 'fail',
|
|
552
|
+
);
|
|
553
|
+
process.exit(blockingFailed ? 1 : 0);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
502
557
|
default:
|
|
503
558
|
usage(`Unknown command: ${command}`);
|
|
504
559
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export type CheckLevel = 'blocking' | 'warning';
|
|
6
|
+
export type CheckStatus = 'pass' | 'fail';
|
|
7
|
+
|
|
8
|
+
export interface PreflightCheck {
|
|
9
|
+
id: string;
|
|
10
|
+
level: CheckLevel;
|
|
11
|
+
status: CheckStatus;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PreflightResult {
|
|
16
|
+
checks: PreflightCheck[];
|
|
17
|
+
version: string;
|
|
18
|
+
tag: string;
|
|
19
|
+
notes: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function shell(cmd: string, cwd: string): { out: string; ok: boolean } {
|
|
23
|
+
try {
|
|
24
|
+
const out = execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
25
|
+
return { out: out.trim(), ok: true };
|
|
26
|
+
} catch (err: unknown) {
|
|
27
|
+
const e = err as { stdout?: string; stderr?: string; message?: string };
|
|
28
|
+
const msg = (e.stderr ?? e.stdout ?? e.message ?? String(err)).trim();
|
|
29
|
+
return { out: msg, ok: false };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run all pre-flight checks for a release from the given repo root.
|
|
35
|
+
* Never throws — all errors are captured into per-check `message` fields.
|
|
36
|
+
*/
|
|
37
|
+
export function runPreflightChecks(repoRoot: string): PreflightResult {
|
|
38
|
+
const checks: PreflightCheck[] = [];
|
|
39
|
+
|
|
40
|
+
// Helper to add a check result
|
|
41
|
+
function addCheck(
|
|
42
|
+
id: string,
|
|
43
|
+
level: CheckLevel,
|
|
44
|
+
pass: boolean,
|
|
45
|
+
failMsg: string,
|
|
46
|
+
passMsg = 'ok',
|
|
47
|
+
): void {
|
|
48
|
+
checks.push({ id, level, status: pass ? 'pass' : 'fail', message: pass ? passMsg : failMsg });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Blocking checks ────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
// 1. on-main: current branch must be main
|
|
54
|
+
const branch = shell('git rev-parse --abbrev-ref HEAD', repoRoot);
|
|
55
|
+
const isOnMain = branch.ok && branch.out === 'main';
|
|
56
|
+
addCheck('on-main', 'blocking', isOnMain, `not on main (current: ${branch.out})`);
|
|
57
|
+
|
|
58
|
+
// 2. clean-tree: no uncommitted changes
|
|
59
|
+
const status = shell('git status --porcelain', repoRoot);
|
|
60
|
+
const isClean = status.ok && status.out === '';
|
|
61
|
+
addCheck('clean-tree', 'blocking', isClean, `working tree is dirty: ${status.out || status.out}`);
|
|
62
|
+
|
|
63
|
+
// 3. in-sync-with-origin: no commits behind origin/main
|
|
64
|
+
// Fetch quietly first so we can compare
|
|
65
|
+
shell('git fetch origin main --quiet', repoRoot);
|
|
66
|
+
const behind = shell('git rev-list --count HEAD..origin/main', repoRoot);
|
|
67
|
+
const isSynced = behind.ok && behind.out === '0';
|
|
68
|
+
addCheck(
|
|
69
|
+
'in-sync-with-origin',
|
|
70
|
+
'blocking',
|
|
71
|
+
isSynced,
|
|
72
|
+
behind.ok ? `${behind.out} commit(s) behind origin/main` : `could not check sync: ${behind.out}`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// 4. valid-version: reference-impl/package.json has a valid semver
|
|
76
|
+
let version = '';
|
|
77
|
+
try {
|
|
78
|
+
const pkgPath = join(repoRoot, 'reference-impl', 'package.json');
|
|
79
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
|
|
80
|
+
version = pkg.version ?? '';
|
|
81
|
+
} catch (e: unknown) {
|
|
82
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
83
|
+
addCheck('valid-version', 'blocking', false, `could not read package.json: ${msg}`);
|
|
84
|
+
version = '';
|
|
85
|
+
}
|
|
86
|
+
if (version !== '') {
|
|
87
|
+
const semverRe = /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/;
|
|
88
|
+
const isValidSemver = semverRe.test(version);
|
|
89
|
+
addCheck('valid-version', 'blocking', isValidSemver, `invalid semver: '${version}'`, `${version}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 5. changelog-section: CHANGELOG.md has a section for this version
|
|
93
|
+
const tag = version ? `reference-impl-v${version}` : 'reference-impl-v<unknown>';
|
|
94
|
+
let changelogPass = false;
|
|
95
|
+
let changelogMsg = 'no version resolved';
|
|
96
|
+
if (version) {
|
|
97
|
+
try {
|
|
98
|
+
const changelogPath = join(repoRoot, 'reference-impl', 'CHANGELOG.md');
|
|
99
|
+
const changelog = readFileSync(changelogPath, 'utf-8');
|
|
100
|
+
// Look for a heading like "## 0.6.5" or "## [0.6.5]"
|
|
101
|
+
const pattern = new RegExp(`^##\\s+\\[?${version.replace(/\./g, '\\.')}`, 'm');
|
|
102
|
+
changelogPass = pattern.test(changelog);
|
|
103
|
+
changelogMsg = changelogPass
|
|
104
|
+
? `section for ${version} found`
|
|
105
|
+
: `no ## ${version} section found in CHANGELOG.md`;
|
|
106
|
+
} catch (e: unknown) {
|
|
107
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
108
|
+
changelogMsg = `could not read CHANGELOG.md: ${msg}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
addCheck('changelog-section', 'blocking', changelogPass, changelogMsg, changelogMsg);
|
|
112
|
+
|
|
113
|
+
// 6. tag-absent: the release tag must not already exist
|
|
114
|
+
let tagAbsent = false;
|
|
115
|
+
let tagMsg = 'no version resolved';
|
|
116
|
+
if (version) {
|
|
117
|
+
const localTag = shell(`git tag -l "${tag}"`, repoRoot);
|
|
118
|
+
const remoteTag = shell(`git ls-remote --tags origin "${tag}"`, repoRoot);
|
|
119
|
+
const existsLocally = localTag.ok && localTag.out !== '';
|
|
120
|
+
const existsRemotely = remoteTag.ok && remoteTag.out !== '';
|
|
121
|
+
tagAbsent = !existsLocally && !existsRemotely;
|
|
122
|
+
if (existsLocally && existsRemotely) {
|
|
123
|
+
tagMsg = `tag ${tag} already exists locally and on origin`;
|
|
124
|
+
} else if (existsLocally) {
|
|
125
|
+
tagMsg = `tag ${tag} already exists locally`;
|
|
126
|
+
} else if (existsRemotely) {
|
|
127
|
+
tagMsg = `tag ${tag} already exists on origin`;
|
|
128
|
+
} else {
|
|
129
|
+
tagMsg = `tag ${tag} is absent (ok)`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
addCheck('tag-absent', 'blocking', tagAbsent, tagMsg, tagMsg);
|
|
133
|
+
|
|
134
|
+
// ── Warning checks ─────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
// 7. npm-authenticated: `npm whoami` must exit 0
|
|
137
|
+
const npmAuth = shell('npm whoami', repoRoot);
|
|
138
|
+
addCheck(
|
|
139
|
+
'npm-authenticated',
|
|
140
|
+
'warning',
|
|
141
|
+
npmAuth.ok,
|
|
142
|
+
`npm not authenticated: ${npmAuth.out}`,
|
|
143
|
+
`logged in as ${npmAuth.out}`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// 8. gh-authenticated: `gh auth status` must exit 0
|
|
147
|
+
const ghAuth = shell('gh auth status', repoRoot);
|
|
148
|
+
addCheck(
|
|
149
|
+
'gh-authenticated',
|
|
150
|
+
'warning',
|
|
151
|
+
ghAuth.ok,
|
|
152
|
+
`gh not authenticated: ${ghAuth.out}`,
|
|
153
|
+
'gh CLI authenticated',
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Derive release notes from CHANGELOG section
|
|
157
|
+
let notes = '';
|
|
158
|
+
if (version) {
|
|
159
|
+
try {
|
|
160
|
+
const changelogPath = join(repoRoot, 'reference-impl', 'CHANGELOG.md');
|
|
161
|
+
const changelog = readFileSync(changelogPath, 'utf-8');
|
|
162
|
+
const pattern = new RegExp(`^##\\s+\\[?${version.replace(/\./g, '\\.')}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s|$)`, 'm');
|
|
163
|
+
const m = changelog.match(pattern);
|
|
164
|
+
notes = m ? m[1].trim() : '';
|
|
165
|
+
} catch {
|
|
166
|
+
notes = '';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { checks, version, tag, notes };
|
|
171
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface WalkerConfig {
|
|
6
|
+
maxConcurrent: number;
|
|
7
|
+
source: 'user' | 'default';
|
|
8
|
+
/** Absolute resolved path to the walker.json file (whether it exists or not). */
|
|
9
|
+
path: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the absolute path to the walker config file.
|
|
14
|
+
* Respects XDG_CONFIG_HOME if set; otherwise falls back to $HOME/.config.
|
|
15
|
+
*/
|
|
16
|
+
export function walkerConfigPath(): string {
|
|
17
|
+
const xdgConfigHome = process.env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config');
|
|
18
|
+
return join(xdgConfigHome, 'cloverleaf', 'walker.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Load and validate the walker configuration.
|
|
23
|
+
*
|
|
24
|
+
* Resolution rules:
|
|
25
|
+
* - File absent (ENOENT) → { maxConcurrent: 3, source: 'default' }
|
|
26
|
+
* - File present, no max_concurrent field → { maxConcurrent: 3, source: 'default' }
|
|
27
|
+
* - File present, valid positive integer → { maxConcurrent: N, source: 'user' }
|
|
28
|
+
* - Malformed JSON → throws with file path and parser detail
|
|
29
|
+
* - Invalid max_concurrent (0, negative, float, string, null) → throws with field path and actual value
|
|
30
|
+
*
|
|
31
|
+
* No AJV dependency — hand-rolled validation.
|
|
32
|
+
*/
|
|
33
|
+
export function loadWalkerConfig(): WalkerConfig {
|
|
34
|
+
const configPath = walkerConfigPath();
|
|
35
|
+
|
|
36
|
+
let raw: string;
|
|
37
|
+
try {
|
|
38
|
+
raw = readFileSync(configPath, 'utf-8');
|
|
39
|
+
} catch (err: unknown) {
|
|
40
|
+
const e = err as NodeJS.ErrnoException;
|
|
41
|
+
if (e.code === 'ENOENT') {
|
|
42
|
+
return { maxConcurrent: 3, source: 'default', path: configPath };
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let parsed: unknown;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(raw);
|
|
50
|
+
} catch (err: unknown) {
|
|
51
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
52
|
+
throw new Error(
|
|
53
|
+
`walker config at ${configPath} contains malformed JSON: ${detail}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`walker config at ${configPath}: expected a JSON object, got ${JSON.stringify(parsed)}`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const doc = parsed as Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
if (!('max_concurrent' in doc)) {
|
|
66
|
+
return { maxConcurrent: 3, source: 'default', path: configPath };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const value = doc['max_concurrent'];
|
|
70
|
+
|
|
71
|
+
if (typeof value !== 'number') {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`walker config at ${configPath}: max_concurrent must be a positive integer, got ${JSON.stringify(value)}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!Number.isInteger(value)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`walker config at ${configPath}: max_concurrent must be a positive integer (no floats), got ${value}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (value <= 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`walker config at ${configPath}: max_concurrent must be a positive integer (> 0), got ${value}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { maxConcurrent: value, source: 'user', path: configPath };
|
|
90
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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",
|
package/prompts/qa.md
CHANGED
|
@@ -77,7 +77,7 @@ The Standard's QA contract requires a `preview_uri`. You were passed the sentine
|
|
|
77
77
|
|
|
78
78
|
After executing all matched QA rules, write an HTML report summarizing each run to `<repoRoot>/.cloverleaf/runs/{taskId}/qa/report.html` (substitute `{taskId}` with the `id` field from the task input, e.g., `{{task.id}}`).
|
|
79
79
|
|
|
80
|
-
Use `renderQaReport(runs)` from `lib/qa-report.ts` to produce the HTML. Ensure the directory exists first (`mkdir -p`).
|
|
80
|
+
Use `renderQaReport(runs)` from `lib/qa-report.ts` to produce the HTML. The compiled artifact is at `<repoRoot>/reference-impl/dist/qa-report.mjs` — invoke via `node --input-type=module` or import from there. Ensure the output directory exists first (`mkdir -p`).
|
|
81
81
|
|
|
82
82
|
In the feedback you emit, include the report as an attachment on a single info-level finding (or on whichever summary finding you already emit):
|
|
83
83
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cloverleaf-release
|
|
3
|
+
description: Publish a new @cloverleaf/reference-impl release. Runs pre-flight checks, displays the 5-command release plan, and executes git tag -a / git push origin main / git push origin <tag> / npm publish / gh release create. Accepts [--dry-run] [--yes] flags.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cloverleaf — release
|
|
7
|
+
|
|
8
|
+
The user has invoked this skill, optionally with `--dry-run` and/or `--yes`.
|
|
9
|
+
|
|
10
|
+
## Args
|
|
11
|
+
|
|
12
|
+
- `--dry-run` — print the pre-flight check list and the 5-command release plan, then exit 0 without executing any release command.
|
|
13
|
+
- `--yes` — skip the interactive `y/N` prompt and execute the 5 commands unattended.
|
|
14
|
+
|
|
15
|
+
## Steps
|
|
16
|
+
|
|
17
|
+
1. **Parse flags.** Extract `--dry-run` and `--yes` from the invocation arguments if present.
|
|
18
|
+
|
|
19
|
+
2. **Capture the repo root.**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
3. **Run pre-flight checks.**
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
PREFLIGHT=$(cloverleaf-cli release-preflight "$REPO_ROOT" --json)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Parse the JSON. Extract `version`, `tag`, and `notes`.
|
|
32
|
+
|
|
33
|
+
4. **Display pre-flight check list.** For each check in `checks[]`:
|
|
34
|
+
|
|
35
|
+
- Status `pass` → prefix `✓`
|
|
36
|
+
- Status `fail` and level `warning` → prefix `⚠`
|
|
37
|
+
- Status `fail` and level `blocking` → prefix `✗`
|
|
38
|
+
|
|
39
|
+
Print one line per check: `<prefix> <id>: <message>`
|
|
40
|
+
|
|
41
|
+
5. **Bail on any blocking failure.**
|
|
42
|
+
|
|
43
|
+
If any check has `level === "blocking"` and `status === "fail"`, print:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
✗ Pre-flight failed — fix the issues above before releasing.
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
And exit 1.
|
|
50
|
+
|
|
51
|
+
6. **Display release plan.**
|
|
52
|
+
|
|
53
|
+
Print:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
Release plan for <tag>:
|
|
57
|
+
1. git tag -a <tag> -m "Release <tag>"
|
|
58
|
+
2. git push origin main
|
|
59
|
+
3. git push origin <tag>
|
|
60
|
+
4. cd reference-impl && npm publish --access public
|
|
61
|
+
5. gh release create <tag> --notes-file /tmp/release-notes-$VERSION.md
|
|
62
|
+
|
|
63
|
+
Version: <version>
|
|
64
|
+
Notes preview:
|
|
65
|
+
<notes (first 10 lines or "(no notes)" if empty)>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
7. **If `--dry-run`:** Print `Dry run complete — no release commands executed.` and exit 0.
|
|
69
|
+
|
|
70
|
+
8. **If not `--yes`:** Prompt:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
Proceed with release of <tag>? (y/N)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Read a single line from the user. If the response is not `y` or `Y`, print `Aborted.` and exit 0.
|
|
77
|
+
|
|
78
|
+
9. **Write the release notes file.**
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
VERSION=<version>
|
|
82
|
+
printf '%s' "$NOTES" > /tmp/release-notes-$VERSION.md
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
10. **Execute the 5 release commands sequentially, bail-fast on first non-zero exit.**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
git tag -a "reference-impl-v$VERSION" -m "Release reference-impl-v$VERSION"
|
|
89
|
+
git push origin main
|
|
90
|
+
git push origin "reference-impl-v$VERSION"
|
|
91
|
+
cd reference-impl && npm publish --access public
|
|
92
|
+
gh release create "reference-impl-v$VERSION" --notes-file "/tmp/release-notes-$VERSION.md"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
If any command fails, print `✗ Release failed at step N: <command>` and exit 1.
|
|
96
|
+
|
|
97
|
+
11. **Report success.**
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
✓ Released reference-impl-v<version>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Rules
|
|
104
|
+
|
|
105
|
+
- Never skip pre-flight checks, even with `--yes`.
|
|
106
|
+
- Warning-level check failures (`⚠`) do not block execution — they are informational only.
|
|
107
|
+
- Do NOT modify `.cloverleaf/` — this skill only releases, it does not change task state.
|
|
108
|
+
- The skill's working directory is the consumer's repo root.
|
|
109
|
+
- Do not use hardcoded plugin paths — use `cloverleaf-cli` for all CLI invocations.
|
|
@@ -20,9 +20,27 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
|
|
|
20
20
|
|
|
21
21
|
1. Capture the `<PLAN-ID>` argument and optional flags:
|
|
22
22
|
|
|
23
|
-
- `--max-concurrent=N` — cap simultaneous sessions. Default `3
|
|
23
|
+
- `--max-concurrent=N` — cap simultaneous sessions. Default `3` (resolved via `cloverleaf-cli walker-default-concurrency`). Setting `--max-concurrent=1` yields serial behaviour.
|
|
24
24
|
- `--reset` — wipe `.cloverleaf/runs/plan/<PLAN-ID>/walk-state.json` and start fresh.
|
|
25
25
|
|
|
26
|
+
Resolve `MAX` and print exactly one startup info line:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
if [ -n "$MAX_FLAG" ]; then
|
|
30
|
+
MAX="$MAX_FLAG"
|
|
31
|
+
echo "max_concurrent=$MAX (from --max-concurrent flag)"
|
|
32
|
+
else
|
|
33
|
+
if ! MAX=$(cloverleaf-cli walker-default-concurrency); then
|
|
34
|
+
echo "ERROR: cloverleaf-cli walker-default-concurrency failed."
|
|
35
|
+
echo "Fix or remove \`~/.config/cloverleaf/walker.json\` and retry."
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
cloverleaf-cli walker-default-concurrency --explain
|
|
39
|
+
fi
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If `cloverleaf-cli walker-default-concurrency` exits non-zero (e.g. malformed `${XDG_CONFIG_HOME:-$HOME/.config}/cloverleaf/walker.json`), the walker stops and reports the error: **Fix or remove `~/.config/cloverleaf/walker.json` and retry.** This mirrors the step-0 uncommitted-changes stop-and-report pattern.
|
|
43
|
+
|
|
26
44
|
2. **Guard against cycles.**
|
|
27
45
|
|
|
28
46
|
```bash
|
|
@@ -81,6 +99,7 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
|
|
|
81
99
|
mkdir -p "$(dirname "$WT")"
|
|
82
100
|
rm -rf "$WT" # idempotent: clean any leftover from a prior run
|
|
83
101
|
git -C <repo_root> worktree add "$WT" -b cloverleaf/<TASK-ID> main
|
|
102
|
+
cloverleaf-cli prep-worktree <repo_root> "$WT"
|
|
84
103
|
```
|
|
85
104
|
|
|
86
105
|
Then `mcp__claw-drive__start_session` with:
|
|
@@ -92,7 +111,7 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
|
|
|
92
111
|
|
|
93
112
|
Record the returned `session_id`, `worktree_path`, and `branch_name` in walk-state with `state: "running"`, `started_at: <now>`, `last_seq: 0`. Persist via `walk-state-write`.
|
|
94
113
|
|
|
95
|
-
c. **Monitor live sessions.** Start `claw-drive watch <session_id> --since <last_seq
|
|
114
|
+
c. **Monitor live sessions.** Start `claw-drive watch <session_id> --since <last_seq> --idle-after 600` for each running session. Merge the streams into a single notification feed (e.g., the Monitor tool, or `claw-drive watch` run per-session in the background with a filter). The `--idle-after 600` flag instructs claw-drive to emit a synthetic `idle` event if a session produces no output for 600 seconds (10 minutes), enabling the walker to detect stalled sessions without polling.
|
|
96
115
|
|
|
97
116
|
d. **Handle events.**
|
|
98
117
|
|
|
@@ -103,7 +122,12 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
|
|
|
103
122
|
> To unstick: read feedback at `.cloverleaf/feedback/<TASK-ID>-*.json`, fix the issue, and run `/cloverleaf-run <TASK-ID>` manually. The walker will re-check on its next tick — when the task reaches `merged`, it'll pick up descendants automatically.
|
|
104
123
|
Mark the task `state: "escalated"` in walk-state; do NOT queue it behind final-gate approvals; continue other branches.
|
|
105
124
|
- **session_stopped** → reconcile as in step 4.
|
|
106
|
-
- **
|
|
125
|
+
- **idle** (`silent_for_ms >= 600000`) → the session has been silent for 10 minutes. For each child session emitting this event, check whether it has reached a terminal state:
|
|
126
|
+
```bash
|
|
127
|
+
claw-drive status <child_session_id>
|
|
128
|
+
```
|
|
129
|
+
Read `last_token` from the status response. If `last_token` is `[DONE]` **or** the on-disk task status (`.cloverleaf/tasks/<TASK-ID>.json`) is `final-gate` or `automated-gates`, treat the session as terminal and proceed with drain (same as `session_stopped` → stopped cleanly). If neither condition is met, surface to the user for inspection; do NOT auto-kill.
|
|
130
|
+
- **Per-session idle > 30 min** (no `idle` event received, wall-clock elapsed) → surface to user for inspection; do NOT auto-kill.
|
|
107
131
|
|
|
108
132
|
e. **Drain the final-gate queue serially and merge on main.** Session B does NOT invoke `/cloverleaf-merge` — it stops at automated-gates (fast lane) or final-gate (full pipeline) and reports. The walker performs the merge on main in the primary repo. For each queued task:
|
|
109
133
|
|