@cloverleaf/reference-impl 0.6.3 → 0.6.4
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 +1 -1
- package/dist/cli.mjs +24 -1
- package/dist/walker-config.mjs +63 -0
- package/lib/cli.ts +24 -1
- package/lib/walker-config.ts +90 -0
- package/package.json +1 -1
- package/skills/cloverleaf-run-plan/SKILL.md +19 -1
package/README.md
CHANGED
|
@@ -152,7 +152,7 @@ The Reviewer never switches branches. It reads files via `git show` and runs tes
|
|
|
152
152
|
|
|
153
153
|
## Package layout
|
|
154
154
|
|
|
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.
|
|
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. `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]`.
|
|
156
156
|
- `skills/` — Claude Code skill markdown files.
|
|
157
157
|
- `prompts/` — Implementer/Reviewer subagent system prompts.
|
|
158
158
|
- `examples/toy-repo/` — standalone demo repo.
|
package/dist/cli.mjs
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
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]
|
|
37
38
|
*/
|
|
38
39
|
import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
39
40
|
import { dirname } from 'node:path';
|
|
@@ -58,6 +59,7 @@ import { readUiReviewState, writeUiReviewState } from './ui-review-state.mjs';
|
|
|
58
59
|
import { buildBaselinePath } from './visual-diff.mjs';
|
|
59
60
|
import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
|
|
60
61
|
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.mjs';
|
|
62
|
+
import { loadWalkerConfig } from './walker-config.mjs';
|
|
61
63
|
function die(msg, code = 1) {
|
|
62
64
|
process.stderr.write(msg + '\n');
|
|
63
65
|
process.exit(code);
|
|
@@ -95,7 +97,8 @@ function usage(msg) {
|
|
|
95
97
|
' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
|
|
96
98
|
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
97
99
|
' walk-state-read <repoRoot> <planId>\n' +
|
|
98
|
-
' walk-state-write <repoRoot> <walkStateJsonPath>\n'
|
|
100
|
+
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
101
|
+
' walker-default-concurrency [--explain]\n');
|
|
99
102
|
process.exit(2);
|
|
100
103
|
}
|
|
101
104
|
const [, , command, ...rest] = process.argv;
|
|
@@ -485,6 +488,26 @@ try {
|
|
|
485
488
|
writeWalkState(repoRoot, state);
|
|
486
489
|
break;
|
|
487
490
|
}
|
|
491
|
+
case 'walker-default-concurrency': {
|
|
492
|
+
const explain = rest.includes('--explain');
|
|
493
|
+
let cfg;
|
|
494
|
+
try {
|
|
495
|
+
cfg = loadWalkerConfig();
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
499
|
+
process.stderr.write(msg + '\n');
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
if (explain) {
|
|
503
|
+
const location = cfg.source === 'user' ? `from ${cfg.path}` : 'default';
|
|
504
|
+
process.stdout.write(`max_concurrent=${cfg.maxConcurrent} (${location})\n`);
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
process.stdout.write(`${cfg.maxConcurrent}\n`);
|
|
508
|
+
}
|
|
509
|
+
process.exit(0);
|
|
510
|
+
}
|
|
488
511
|
default:
|
|
489
512
|
usage(`Unknown command: ${command}`);
|
|
490
513
|
}
|
|
@@ -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,6 +34,7 @@
|
|
|
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]
|
|
37
38
|
*/
|
|
38
39
|
|
|
39
40
|
import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
@@ -60,6 +61,7 @@ import { readUiReviewState, writeUiReviewState } from './ui-review-state.js';
|
|
|
60
61
|
import { buildBaselinePath } from './visual-diff.js';
|
|
61
62
|
import { computeReadyTasks, detectCycle } from './dag-walker.js';
|
|
62
63
|
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.js';
|
|
64
|
+
import { loadWalkerConfig } from './walker-config.js';
|
|
63
65
|
|
|
64
66
|
function die(msg: string, code = 1): never {
|
|
65
67
|
process.stderr.write(msg + '\n');
|
|
@@ -99,7 +101,8 @@ function usage(msg?: string): never {
|
|
|
99
101
|
' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
|
|
100
102
|
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
101
103
|
' walk-state-read <repoRoot> <planId>\n' +
|
|
102
|
-
' walk-state-write <repoRoot> <walkStateJsonPath>\n'
|
|
104
|
+
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
105
|
+
' walker-default-concurrency [--explain]\n'
|
|
103
106
|
);
|
|
104
107
|
process.exit(2);
|
|
105
108
|
}
|
|
@@ -499,6 +502,26 @@ try {
|
|
|
499
502
|
break;
|
|
500
503
|
}
|
|
501
504
|
|
|
505
|
+
case 'walker-default-concurrency': {
|
|
506
|
+
const explain = rest.includes('--explain');
|
|
507
|
+
let cfg: { maxConcurrent: number; source: 'user' | 'default'; path: string };
|
|
508
|
+
try {
|
|
509
|
+
cfg = loadWalkerConfig();
|
|
510
|
+
} catch (err: unknown) {
|
|
511
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
512
|
+
process.stderr.write(msg + '\n');
|
|
513
|
+
process.exit(1);
|
|
514
|
+
}
|
|
515
|
+
if (explain) {
|
|
516
|
+
const location =
|
|
517
|
+
cfg.source === 'user' ? `from ${cfg.path}` : 'default';
|
|
518
|
+
process.stdout.write(`max_concurrent=${cfg.maxConcurrent} (${location})\n`);
|
|
519
|
+
} else {
|
|
520
|
+
process.stdout.write(`${cfg.maxConcurrent}\n`);
|
|
521
|
+
}
|
|
522
|
+
process.exit(0);
|
|
523
|
+
}
|
|
524
|
+
|
|
502
525
|
default:
|
|
503
526
|
usage(`Unknown command: ${command}`);
|
|
504
527
|
}
|
|
@@ -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.4",
|
|
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",
|
|
@@ -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
|