@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.
@@ -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.0",
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",
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`. Setting `--max-concurrent=1` yields serial behaviour.
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>` 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).
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
- - **Per-session idle > 30 min**surface to user for inspection; do NOT auto-kill.
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