@cloverleaf/reference-impl 0.8.6 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/VERSION +1 -1
- package/config/council.json +19 -0
- package/config/discovery.json +2 -1
- package/dist/aggregation.mjs +60 -0
- package/dist/cli.mjs +64 -0
- package/dist/council-config.mjs +47 -0
- package/dist/council-result.mjs +19 -0
- package/dist/council.mjs +159 -0
- package/dist/discovery-config.mjs +2 -0
- package/dist/paths.mjs +3 -0
- package/dist/prep-worktree.mjs +51 -36
- package/dist/qa-report.mjs +13 -0
- package/dist/rfc-tasks.mjs +1 -2
- package/dist/security-classify.mjs +0 -0
- package/lib/aggregation.ts +87 -0
- package/lib/cli.ts +67 -0
- package/lib/council-config.ts +79 -0
- package/lib/council-result.ts +45 -0
- package/lib/council.ts +203 -0
- package/lib/discovery-config.ts +5 -0
- package/lib/paths.ts +4 -0
- package/lib/prep-worktree.ts +54 -38
- package/lib/qa-report.ts +15 -0
- package/lib/rfc-tasks.ts +1 -2
- package/lib/security-classify.ts +0 -0
- package/package.json +1 -1
- package/prompts/implementer.md +3 -2
- package/prompts/qa.md +18 -10
- package/prompts/reviewer.md +8 -4
- package/skills/cloverleaf-implement/SKILL.md +11 -0
- package/skills/cloverleaf-review/SKILL.md +11 -1
- package/skills/cloverleaf-run/SKILL.md +34 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloverleaf",
|
|
3
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.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Renato D'Arrigo",
|
|
7
7
|
"email": "renato.darrigo@gmail.com"
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.10.0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"profiles": {
|
|
3
|
+
"default": {
|
|
4
|
+
"rounds": [
|
|
5
|
+
[ { "member": "reviewer" } ],
|
|
6
|
+
[
|
|
7
|
+
{ "member": "security", "when": "security_class:high" },
|
|
8
|
+
{ "member": "ui", "when": "ui_changes" },
|
|
9
|
+
{ "member": "qa" }
|
|
10
|
+
]
|
|
11
|
+
],
|
|
12
|
+
"aggregation": "any-veto",
|
|
13
|
+
"on_round_bounce": "stop"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"gates": {
|
|
17
|
+
"task.review": "default"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/config/discovery.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function aggregate(members, rule, opts = {}) {
|
|
2
|
+
// Safety invariant: any member that escalates short-circuits, regardless of
|
|
3
|
+
// blocking flag or rule — a hard blocker cannot be out-voted.
|
|
4
|
+
const escalators = members.filter((x) => x.verdict === 'escalate');
|
|
5
|
+
if (escalators.length > 0) {
|
|
6
|
+
return {
|
|
7
|
+
verdict: 'escalate',
|
|
8
|
+
rule,
|
|
9
|
+
members,
|
|
10
|
+
rationale: `escalated by ${escalators.map((x) => x.member).join(', ')}`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
// Only blocking members gate the pass/bounce decision.
|
|
14
|
+
const blocking = members.filter((x) => x.blocking !== false);
|
|
15
|
+
if (blocking.length === 0) {
|
|
16
|
+
return { verdict: 'pass', rule, members, rationale: 'no blocking members' };
|
|
17
|
+
}
|
|
18
|
+
const passes = blocking.filter((x) => x.verdict === 'pass');
|
|
19
|
+
const passCount = passes.length;
|
|
20
|
+
const total = blocking.length;
|
|
21
|
+
let pass;
|
|
22
|
+
let detail;
|
|
23
|
+
if (rule === 'any-veto') {
|
|
24
|
+
pass = passCount === total;
|
|
25
|
+
detail = `any-veto: ${passCount}/${total} passed`;
|
|
26
|
+
}
|
|
27
|
+
else if (rule === 'unanimous') {
|
|
28
|
+
// Currently identical to any-veto over blocking members. Kept as a distinct
|
|
29
|
+
// named rule for clarity and possible future divergence; do not merge.
|
|
30
|
+
pass = passCount === total;
|
|
31
|
+
detail = `unanimous: ${passCount}/${total} passed`;
|
|
32
|
+
}
|
|
33
|
+
else if (rule === 'majority') {
|
|
34
|
+
pass = passCount * 2 > total; // strict majority; ties → bounce
|
|
35
|
+
detail = `majority: ${passCount}/${total} passed`;
|
|
36
|
+
}
|
|
37
|
+
else if (rule === 'weighted') {
|
|
38
|
+
const totalWeight = blocking.reduce((s, x) => s + (x.weight ?? 1), 0);
|
|
39
|
+
const passWeight = passes.reduce((s, x) => s + (x.weight ?? 1), 0);
|
|
40
|
+
const threshold = opts.weightedThreshold;
|
|
41
|
+
pass = threshold !== undefined ? passWeight >= threshold : passWeight * 2 > totalWeight;
|
|
42
|
+
detail =
|
|
43
|
+
`weighted: passWeight ${passWeight}/${totalWeight}` +
|
|
44
|
+
(threshold !== undefined ? ` (threshold ${threshold})` : '');
|
|
45
|
+
}
|
|
46
|
+
else if (typeof rule === 'object' && rule !== null && 'quorum' in rule) {
|
|
47
|
+
const k = rule.quorum;
|
|
48
|
+
pass = passCount >= k;
|
|
49
|
+
detail = `quorum(${k}): ${passCount}/${total} passed`;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
throw new Error(`aggregate: unknown aggregation rule ${JSON.stringify(rule)}`);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
verdict: pass ? 'pass' : 'bounce',
|
|
56
|
+
rule,
|
|
57
|
+
members,
|
|
58
|
+
rationale: `${pass ? 'pass' : 'bounce'} — ${detail}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/dist/cli.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* next-work-item-id <repoRoot> <project>
|
|
32
32
|
* discovery-config --repo-root <repoRoot>
|
|
33
33
|
* prep-worktree <mainRoot> <worktreePath>
|
|
34
|
+
* qa-report <runs.json> <out.html>
|
|
34
35
|
* dag-ready-tasks <repoRoot> <planId> <maxConcurrent>
|
|
35
36
|
* dag-detect-cycle <repoRoot> <planId>
|
|
36
37
|
* walk-state-read <repoRoot> <planId>
|
|
@@ -41,6 +42,9 @@
|
|
|
41
42
|
* secret-scan <repoRoot> --branch <branch>
|
|
42
43
|
* classify-security <repoRoot> <taskId> [--branch <branch>]
|
|
43
44
|
* set-task-field <repoRoot> <taskId> <field> <value>
|
|
45
|
+
* council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]
|
|
46
|
+
* aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]
|
|
47
|
+
* apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>
|
|
44
48
|
*/
|
|
45
49
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync } from 'node:fs';
|
|
46
50
|
import { dirname, join } from 'node:path';
|
|
@@ -61,6 +65,7 @@ import { loadSpike, saveSpike, advanceSpikeStatus } from './spike.mjs';
|
|
|
61
65
|
import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan } from './plan.mjs';
|
|
62
66
|
import { loadDiscoveryConfig } from './discovery-config.mjs';
|
|
63
67
|
import { prepWorktree } from './prep-worktree.mjs';
|
|
68
|
+
import { writeQaReportFromFile } from './qa-report.mjs';
|
|
64
69
|
import { readUiReviewState, writeUiReviewState } from './ui-review-state.mjs';
|
|
65
70
|
import { buildBaselinePath } from './visual-diff.mjs';
|
|
66
71
|
import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
|
|
@@ -70,6 +75,8 @@ import { classifyFiles, normalizePath } from './scope-check.mjs';
|
|
|
70
75
|
import { computeRfcTasksView } from './rfc-tasks.mjs';
|
|
71
76
|
import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.mjs';
|
|
72
77
|
import { classifyTaskSecurity } from './security-classify.mjs';
|
|
78
|
+
import { resolveCouncilPlan, applyCouncilVerdict } from './council.mjs';
|
|
79
|
+
import { aggregate } from './aggregation.mjs';
|
|
73
80
|
function die(msg, code = 1) {
|
|
74
81
|
process.stderr.write(msg + '\n');
|
|
75
82
|
process.exit(code);
|
|
@@ -105,6 +112,7 @@ function usage(msg) {
|
|
|
105
112
|
' next-work-item-id <repoRoot> <project>\n' +
|
|
106
113
|
' discovery-config --repo-root <repoRoot>\n' +
|
|
107
114
|
' prep-worktree <mainRoot> <worktreePath>\n' +
|
|
115
|
+
' qa-report <runs.json> <out.html>\n' +
|
|
108
116
|
' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
|
|
109
117
|
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
110
118
|
' walk-state-read <repoRoot> <planId>\n' +
|
|
@@ -114,6 +122,9 @@ function usage(msg) {
|
|
|
114
122
|
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
|
|
115
123
|
' secret-scan <repoRoot> --branch <branch>\n' +
|
|
116
124
|
' classify-security <repoRoot> <taskId> [--branch <branch>]\n' +
|
|
125
|
+
' council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]\n' +
|
|
126
|
+
' aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]\n' +
|
|
127
|
+
' apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>\n' +
|
|
117
128
|
' set-task-field <repoRoot> <taskId> <field> <value>\n');
|
|
118
129
|
process.exit(2);
|
|
119
130
|
}
|
|
@@ -485,6 +496,13 @@ try {
|
|
|
485
496
|
prepWorktree(mainRoot, worktreePath);
|
|
486
497
|
break;
|
|
487
498
|
}
|
|
499
|
+
case 'qa-report': {
|
|
500
|
+
const [runsJsonPath, outHtmlPath] = rest;
|
|
501
|
+
if (!runsJsonPath || !outHtmlPath)
|
|
502
|
+
usage('qa-report requires <runs.json> <out.html>');
|
|
503
|
+
writeQaReportFromFile(runsJsonPath, outHtmlPath);
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
488
506
|
case 'dag-ready-tasks': {
|
|
489
507
|
const [repoRoot, planId, maxConcurrentStr] = rest;
|
|
490
508
|
if (!repoRoot || !planId || !maxConcurrentStr)
|
|
@@ -796,6 +814,52 @@ try {
|
|
|
796
814
|
process.stdout.write(JSON.stringify(result) + '\n');
|
|
797
815
|
break;
|
|
798
816
|
}
|
|
817
|
+
case 'council-plan': {
|
|
818
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
819
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
820
|
+
const [repoRoot, taskId, gateKey] = positional;
|
|
821
|
+
if (!repoRoot || !taskId)
|
|
822
|
+
usage('council-plan requires <repoRoot> <taskId> [gateKey]');
|
|
823
|
+
const cf = flags.find((f) => f.startsWith('--changed-files='));
|
|
824
|
+
const changedFiles = cf !== undefined
|
|
825
|
+
? cf.replace('--changed-files=', '').split(',').filter(Boolean)
|
|
826
|
+
: undefined;
|
|
827
|
+
const plan = resolveCouncilPlan(repoRoot, taskId, gateKey || 'task.review', changedFiles !== undefined ? { changedFiles } : {});
|
|
828
|
+
process.stdout.write(JSON.stringify(plan) + '\n');
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
case 'aggregate-verdicts': {
|
|
832
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
833
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
834
|
+
const [membersJson, ruleArg] = positional;
|
|
835
|
+
if (!membersJson || !ruleArg)
|
|
836
|
+
usage('aggregate-verdicts requires <membersJson> <rule>');
|
|
837
|
+
const members = JSON.parse(membersJson);
|
|
838
|
+
let rule;
|
|
839
|
+
if (ruleArg.startsWith('quorum:')) {
|
|
840
|
+
const quorumN = parseInt(ruleArg.split(':')[1], 10);
|
|
841
|
+
if (Number.isNaN(quorumN) || quorumN < 1) {
|
|
842
|
+
usage(`aggregate-verdicts: quorum value must be a positive integer, got '${ruleArg}'`);
|
|
843
|
+
}
|
|
844
|
+
rule = { quorum: quorumN };
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
rule = ruleArg;
|
|
848
|
+
}
|
|
849
|
+
const wt = flags.find((f) => f.startsWith('--weighted-threshold='));
|
|
850
|
+
const opts = wt ? { weightedThreshold: parseFloat(wt.replace('--weighted-threshold=', '')) } : {};
|
|
851
|
+
process.stdout.write(JSON.stringify(aggregate(members, rule, opts)) + '\n');
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
case 'apply-council-verdict': {
|
|
855
|
+
const [repoRoot, taskId, gate, verdictJson] = rest;
|
|
856
|
+
if (!repoRoot || !taskId || !gate || !verdictJson)
|
|
857
|
+
usage('apply-council-verdict requires <repoRoot> <taskId> <gate> <councilVerdictJson>');
|
|
858
|
+
const council = JSON.parse(verdictJson);
|
|
859
|
+
const result = applyCouncilVerdict(repoRoot, taskId, gate, council);
|
|
860
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
799
863
|
case 'set-task-field': {
|
|
800
864
|
const [repoRoot, taskId, field, value] = rest;
|
|
801
865
|
if (!repoRoot || !taskId || !field || value === undefined)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const DEFAULT_CONFIG = join(here, '..', 'config', 'council.json');
|
|
6
|
+
function isObject(x) {
|
|
7
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
8
|
+
}
|
|
9
|
+
// Full-replacement (not merge): a consumer council.json wholly replaces the
|
|
10
|
+
// shipped default — matching the qa-rules / security-paths loaders. A partial
|
|
11
|
+
// consumer file (e.g. only `profiles`) intentionally yields empty `gates`,
|
|
12
|
+
// i.e. no bound gates → today's behavior. Per-profile shape validation is a
|
|
13
|
+
// later (validator) slice; this loader normalizes only the top-level containers.
|
|
14
|
+
function normalize(doc) {
|
|
15
|
+
return {
|
|
16
|
+
profiles: isObject(doc.profiles) ? doc.profiles : {},
|
|
17
|
+
gates: isObject(doc.gates) ? doc.gates : {},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function loadDefaultConfig() {
|
|
21
|
+
if (!existsSync(DEFAULT_CONFIG)) {
|
|
22
|
+
throw new Error(`council config not found at ${DEFAULT_CONFIG}`);
|
|
23
|
+
}
|
|
24
|
+
const parsed = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
|
|
25
|
+
if (!isObject(parsed)) {
|
|
26
|
+
throw new Error(`council config malformed (not an object) at ${DEFAULT_CONFIG}`);
|
|
27
|
+
}
|
|
28
|
+
return normalize(parsed);
|
|
29
|
+
}
|
|
30
|
+
export function loadCouncilConfigWithSource(repoRoot) {
|
|
31
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'council.json');
|
|
32
|
+
if (existsSync(consumerPath)) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(readFileSync(consumerPath, 'utf-8'));
|
|
35
|
+
if (!isObject(parsed))
|
|
36
|
+
throw new Error('council.json is not an object');
|
|
37
|
+
return { config: normalize(parsed), source: 'consumer' };
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// malformed / non-object consumer config → fall back to package default
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { config: loadDefaultConfig(), source: 'default' };
|
|
44
|
+
}
|
|
45
|
+
export function loadCouncilConfig(repoRoot) {
|
|
46
|
+
return loadCouncilConfigWithSource(repoRoot).config;
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { councilRunDir } from './paths.mjs';
|
|
4
|
+
export function councilResultPath(repoRoot, taskId, gate) {
|
|
5
|
+
return join(councilRunDir(repoRoot, taskId), `${gate}.json`);
|
|
6
|
+
}
|
|
7
|
+
export function writeCouncilResult(repoRoot, taskId, result) {
|
|
8
|
+
const dir = councilRunDir(repoRoot, taskId);
|
|
9
|
+
mkdirSync(dir, { recursive: true });
|
|
10
|
+
const path = councilResultPath(repoRoot, taskId, result.gate);
|
|
11
|
+
writeFileSync(path, JSON.stringify(result, null, 2) + '\n');
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
export function readCouncilResult(repoRoot, taskId, gate) {
|
|
15
|
+
const path = councilResultPath(repoRoot, taskId, gate);
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
return null;
|
|
18
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
19
|
+
}
|
package/dist/council.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { loadCouncilConfigWithSource } from './council-config.mjs';
|
|
3
|
+
import { loadTask, saveTask, advanceStatus } from './task.mjs';
|
|
4
|
+
import { writeCouncilResult } from './council-result.mjs';
|
|
5
|
+
import { classifyTaskSecurity } from './security-classify.mjs';
|
|
6
|
+
import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.mjs';
|
|
7
|
+
export function evaluateWhen(predicate, ctx) {
|
|
8
|
+
switch (predicate) {
|
|
9
|
+
case undefined:
|
|
10
|
+
case 'always':
|
|
11
|
+
return true;
|
|
12
|
+
case 'security_class:high':
|
|
13
|
+
return ctx.securityHigh;
|
|
14
|
+
case 'ui_changes':
|
|
15
|
+
return ctx.uiChanges;
|
|
16
|
+
default:
|
|
17
|
+
return false; // unknown predicate → inactive (fail-closed)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function resolveBinding(binding, task) {
|
|
21
|
+
if (binding === undefined)
|
|
22
|
+
return { profile: null, mode: 'decisive' };
|
|
23
|
+
if (typeof binding === 'string')
|
|
24
|
+
return { profile: binding, mode: 'decisive' };
|
|
25
|
+
if ('profile' in binding)
|
|
26
|
+
return { profile: binding.profile, mode: binding.mode ?? 'decisive' };
|
|
27
|
+
// conditional selector { by, map }
|
|
28
|
+
const key = String(task[binding.by] ?? '');
|
|
29
|
+
const selected = key in binding.map ? binding.map[key] : (binding.map['*'] ?? null);
|
|
30
|
+
return { profile: selected, mode: 'decisive' };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve changed files for predicate evaluation. Callers should pass
|
|
34
|
+
* `opts.changedFiles` (e.g. from a prior `git diff`); when omitted we run
|
|
35
|
+
* `git diff main..cloverleaf/<taskId>` and fall back to [] on any git error.
|
|
36
|
+
* Exported for direct testing of the (now orchestrator-live) git path.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveChangedFiles(repoRoot, taskId, opts = {}) {
|
|
39
|
+
if (opts.changedFiles !== undefined)
|
|
40
|
+
return opts.changedFiles;
|
|
41
|
+
try {
|
|
42
|
+
const out = execFileSync('git', ['-C', repoRoot, 'diff', '--name-only', `main..cloverleaf/${taskId}`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
43
|
+
return out.split('\n').filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', opts = {}) {
|
|
50
|
+
const { config, source } = loadCouncilConfigWithSource(repoRoot);
|
|
51
|
+
const task = loadTask(repoRoot, taskId);
|
|
52
|
+
const { profile: profileName, mode } = resolveBinding(config.gates[gateKey], task);
|
|
53
|
+
const empty = {
|
|
54
|
+
gate: gateKey, profile: null, mode, rounds: [],
|
|
55
|
+
aggregation: 'any-veto', on_round_bounce: 'stop', source,
|
|
56
|
+
};
|
|
57
|
+
if (profileName === null)
|
|
58
|
+
return empty;
|
|
59
|
+
const profile = config.profiles[profileName];
|
|
60
|
+
if (!profile) {
|
|
61
|
+
if (source === 'consumer') {
|
|
62
|
+
process.stderr.write(`cloverleaf-cli council-plan: profile '${profileName}' bound to gate '${gateKey}' not found in council.json; falling back to today's behavior.\n`);
|
|
63
|
+
}
|
|
64
|
+
return empty; // unknown profile → fail toward today's behavior
|
|
65
|
+
}
|
|
66
|
+
const changed = resolveChangedFiles(repoRoot, taskId, opts);
|
|
67
|
+
const securityHigh = classifyTaskSecurity(repoRoot, taskId, { changedFiles: changed }).effective === 'high';
|
|
68
|
+
const affected = computeAffectedRoutes(changed, loadAffectedRoutesConfig(repoRoot));
|
|
69
|
+
const uiChanges = affected === 'all' || affected.length > 0;
|
|
70
|
+
const ctx = { securityHigh, uiChanges };
|
|
71
|
+
const rounds = [];
|
|
72
|
+
for (const round of profile.rounds) {
|
|
73
|
+
const active = round
|
|
74
|
+
.filter((member) => evaluateWhen(member.when, ctx))
|
|
75
|
+
.map((member) => ({ member: member.member, blocking: member.blocking !== false, weight: member.weight ?? 1 }));
|
|
76
|
+
if (active.length > 0)
|
|
77
|
+
rounds.push(active);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
gate: gateKey,
|
|
81
|
+
profile: profileName,
|
|
82
|
+
mode,
|
|
83
|
+
rounds,
|
|
84
|
+
aggregation: profile.aggregation,
|
|
85
|
+
on_round_bounce: profile.on_round_bounce ?? 'stop',
|
|
86
|
+
source,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Drive the FSM transition implied by a council verdict (the runner's terminal step).
|
|
91
|
+
* Council-authoritative: on a pass it records the council's gating verdict so the
|
|
92
|
+
* v0.8.1 security precondition is satisfied for any high-security gated transition;
|
|
93
|
+
* the per-member basis (incl. an omitted or out-voted `security` member) is written
|
|
94
|
+
* to the result artifact. Walks the minimal legal path to the lane's pre-merge state.
|
|
95
|
+
*/
|
|
96
|
+
export function applyCouncilVerdict(repoRoot, taskId, gate, council) {
|
|
97
|
+
if (gate !== 'task.review') {
|
|
98
|
+
throw new Error(`apply-council-verdict: gate '${gate}' is not supported yet — the FSM walk is hardcoded for the ` +
|
|
99
|
+
`task.review → merge lane. Binding other gates needs a gate-aware walk (council Slice 3).`);
|
|
100
|
+
}
|
|
101
|
+
const task = loadTask(repoRoot, taskId);
|
|
102
|
+
if (task.status !== 'review') {
|
|
103
|
+
throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'review'`);
|
|
104
|
+
}
|
|
105
|
+
const lane = task.risk_class === 'high' ? 'full' : 'fast';
|
|
106
|
+
const securityMember = council.members.find((m) => m.member === 'security');
|
|
107
|
+
const walk = ['review'];
|
|
108
|
+
if (council.verdict === 'escalate') {
|
|
109
|
+
advanceStatus(repoRoot, taskId, 'escalated', 'agent');
|
|
110
|
+
walk.push('escalated');
|
|
111
|
+
}
|
|
112
|
+
else if (council.verdict === 'bounce') {
|
|
113
|
+
advanceStatus(repoRoot, taskId, 'implementing', 'agent');
|
|
114
|
+
walk.push('implementing');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// pass — minimal legal walk; review→automated-gates resets the security verdict,
|
|
118
|
+
// so set the council's gating verdict AFTER that transition.
|
|
119
|
+
advanceStatus(repoRoot, taskId, 'automated-gates', 'agent');
|
|
120
|
+
walk.push('automated-gates');
|
|
121
|
+
const atGates = loadTask(repoRoot, taskId);
|
|
122
|
+
atGates.security_review_verdict = 'pass';
|
|
123
|
+
saveTask(repoRoot, atGates);
|
|
124
|
+
if (lane === 'full') {
|
|
125
|
+
advanceStatus(repoRoot, taskId, 'qa', 'agent', { path: 'full_pipeline' });
|
|
126
|
+
walk.push('qa');
|
|
127
|
+
advanceStatus(repoRoot, taskId, 'final-gate', 'agent', { path: 'full_pipeline' });
|
|
128
|
+
walk.push('final-gate');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const qaTraversedAdministratively = lane === 'full' && walk.includes('qa') && !council.members.some((m) => m.member === 'qa');
|
|
132
|
+
const result = {
|
|
133
|
+
gate,
|
|
134
|
+
final_verdict: council.verdict,
|
|
135
|
+
rule: council.rule,
|
|
136
|
+
rationale: council.rationale,
|
|
137
|
+
members: council.members.map((m) => ({
|
|
138
|
+
member: m.member,
|
|
139
|
+
verdict: m.verdict,
|
|
140
|
+
blocking: m.blocking !== false,
|
|
141
|
+
weight: m.weight ?? 1,
|
|
142
|
+
})),
|
|
143
|
+
walk,
|
|
144
|
+
...(qaTraversedAdministratively
|
|
145
|
+
? { walk_note: 'qa state traversed administratively; no qa member ran' }
|
|
146
|
+
: {}),
|
|
147
|
+
security: {
|
|
148
|
+
member_verdict: securityMember ? securityMember.verdict : 'absent',
|
|
149
|
+
gating_verdict_set: council.verdict === 'pass' ? 'pass' : null,
|
|
150
|
+
basis: !securityMember
|
|
151
|
+
? 'no security member configured; advanced under council authority'
|
|
152
|
+
: securityMember.verdict === 'pass'
|
|
153
|
+
? 'security member passed'
|
|
154
|
+
: `security member returned '${securityMember.verdict}'; council ${council.verdict} by rule ${JSON.stringify(council.rule)}`,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
writeCouncilResult(repoRoot, taskId, result);
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
@@ -13,6 +13,7 @@ export function loadDiscoveryConfig(repoRoot) {
|
|
|
13
13
|
prep_copy_dirs: Array.isArray(rawFallback.prep_copy_dirs)
|
|
14
14
|
? rawFallback.prep_copy_dirs.filter((p) => typeof p === 'string')
|
|
15
15
|
: [],
|
|
16
|
+
worktree_setup_command: typeof rawFallback.worktree_setup_command === 'string' ? rawFallback.worktree_setup_command : '',
|
|
16
17
|
};
|
|
17
18
|
if (existsSync(override)) {
|
|
18
19
|
try {
|
|
@@ -33,5 +34,6 @@ function normalise(doc, fallback) {
|
|
|
33
34
|
prep_copy_dirs: Array.isArray(doc.prep_copy_dirs)
|
|
34
35
|
? doc.prep_copy_dirs.filter((p) => typeof p === 'string')
|
|
35
36
|
: fallback.prep_copy_dirs,
|
|
37
|
+
worktree_setup_command: typeof doc.worktree_setup_command === 'string' ? doc.worktree_setup_command : fallback.worktree_setup_command,
|
|
36
38
|
};
|
|
37
39
|
}
|
package/dist/paths.mjs
CHANGED
|
@@ -30,3 +30,6 @@ export function runsDir(repoRoot) {
|
|
|
30
30
|
export function uiReviewRunDir(repoRoot, taskId) {
|
|
31
31
|
return resolve(runsDir(repoRoot), taskId, 'ui-review');
|
|
32
32
|
}
|
|
33
|
+
export function councilRunDir(repoRoot, taskId) {
|
|
34
|
+
return resolve(runsDir(repoRoot), taskId, 'council');
|
|
35
|
+
}
|
package/dist/prep-worktree.mjs
CHANGED
|
@@ -87,42 +87,61 @@ function buildMissingNodeModulesError(mainRoot) {
|
|
|
87
87
|
return new Error(`main missing standard/node_modules at ${mainStandardNm} — run \`npm ci\` in main's standard/ first`);
|
|
88
88
|
}
|
|
89
89
|
export function prepWorktree(mainRoot, worktreePath) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
const embedded = existsSync(join(worktreePath, 'standard', 'package.json')) &&
|
|
91
|
+
existsSync(join(worktreePath, 'reference-impl', 'package.json'));
|
|
92
|
+
// configRoot is where .cloverleaf/config/discovery.json is read from. Embedded mode
|
|
93
|
+
// walks up to the primary repo (which holds node_modules + the config); a non-monorepo
|
|
94
|
+
// consumer uses mainRoot directly.
|
|
95
|
+
let configRoot;
|
|
96
|
+
if (embedded) {
|
|
97
|
+
const resolvedMain = findPrimaryRoot(mainRoot);
|
|
98
|
+
if (resolvedMain === null) {
|
|
99
|
+
throw buildMissingNodeModulesError(mainRoot);
|
|
100
|
+
}
|
|
101
|
+
configRoot = resolvedMain;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
configRoot = mainRoot;
|
|
105
|
+
}
|
|
106
|
+
const config = loadDiscoveryConfig(configRoot);
|
|
107
|
+
if (embedded) {
|
|
108
|
+
// Embedded / monorepo mode: prime the cloverleaf TS tooling (unchanged behavior).
|
|
109
|
+
copyEmbeddedArtifacts(configRoot, worktreePath);
|
|
94
110
|
}
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
// Both modes: copy any gitignored dirs the consumer's tests/briefs reference.
|
|
112
|
+
copyPrepDirs(configRoot, config.prep_copy_dirs, worktreePath);
|
|
113
|
+
if (embedded) {
|
|
114
|
+
// Build standard/ fresh from the worktree's own sources.
|
|
115
|
+
execSync('npm run build', {
|
|
116
|
+
cwd: join(worktreePath, 'standard'),
|
|
117
|
+
stdio: 'pipe',
|
|
118
|
+
});
|
|
97
119
|
}
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
// Both modes: run the consumer's worktree setup command, if configured.
|
|
121
|
+
if (config.worktree_setup_command.trim() !== '') {
|
|
122
|
+
execSync(config.worktree_setup_command, {
|
|
123
|
+
cwd: worktreePath,
|
|
124
|
+
stdio: 'pipe',
|
|
125
|
+
});
|
|
103
126
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// leave partial state; we must not trip on it.
|
|
114
|
-
// 2. cpSync with verbatimSymlinks: true does not reliably overwrite an existing
|
|
115
|
-
// symlink at the destination even with force: true (CLV-20 Reviewer repro was
|
|
116
|
-
// EEXIST on vite/node_modules/.bin on second invocation).
|
|
117
|
-
primeCopy(mainStandardNm, wtStandardNm);
|
|
118
|
-
primeCopy(mainRefImplNm, wtRefImplNm);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Embedded / monorepo mode: copy the primary repo's installed cloverleaf TS deps + built
|
|
130
|
+
* dist into the worktree, preserving the @cloverleaf/standard relative symlink. (See the
|
|
131
|
+
* file header for the CLV-16/17/37/52 history.)
|
|
132
|
+
*/
|
|
133
|
+
function copyEmbeddedArtifacts(resolvedMain, worktreePath) {
|
|
134
|
+
primeCopy(join(resolvedMain, 'standard', 'node_modules'), join(worktreePath, 'standard', 'node_modules'));
|
|
135
|
+
primeCopy(join(resolvedMain, 'reference-impl', 'node_modules'), join(worktreePath, 'reference-impl', 'node_modules'));
|
|
119
136
|
primeCopy(join(resolvedMain, 'reference-impl', 'dist'), join(worktreePath, 'reference-impl', 'dist'));
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Honor discovery_config.prep_copy_dirs: copy each listed gitignored directory from
|
|
140
|
+
* configRoot into the worktree. Missing entries warn and are skipped.
|
|
141
|
+
*/
|
|
142
|
+
function copyPrepDirs(configRoot, dirs, worktreePath) {
|
|
143
|
+
for (const dir of dirs) {
|
|
144
|
+
const srcPath = join(configRoot, dir);
|
|
126
145
|
const dstPath = join(worktreePath, dir);
|
|
127
146
|
if (!existsSync(srcPath)) {
|
|
128
147
|
process.stderr.write(`prep-worktree: prep_copy_dirs entry '${dir}' not found at ${srcPath} — skipping.\n`);
|
|
@@ -130,10 +149,6 @@ export function prepWorktree(mainRoot, worktreePath) {
|
|
|
130
149
|
}
|
|
131
150
|
primeCopy(srcPath, dstPath);
|
|
132
151
|
}
|
|
133
|
-
execSync('npm run build', {
|
|
134
|
-
cwd: join(worktreePath, 'standard'),
|
|
135
|
-
stdio: 'pipe',
|
|
136
|
-
});
|
|
137
152
|
}
|
|
138
153
|
function primeCopy(src, dst) {
|
|
139
154
|
if (existsSync(dst)) {
|
package/dist/qa-report.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
1
3
|
function escape(s) {
|
|
2
4
|
if (s === undefined || s === null)
|
|
3
5
|
return '';
|
|
@@ -28,6 +30,17 @@ function renderRow(r) {
|
|
|
28
30
|
</tr>
|
|
29
31
|
`;
|
|
30
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Read a QA runs JSON file (array of QaRunResult), render the HTML report, and write it to
|
|
35
|
+
* outHtmlPath, creating the parent directory if needed. Lets QA write its report via the
|
|
36
|
+
* cloverleaf-cli bin instead of importing a monorepo dist path.
|
|
37
|
+
*/
|
|
38
|
+
export function writeQaReportFromFile(runsJsonPath, outHtmlPath) {
|
|
39
|
+
const runs = JSON.parse(readFileSync(runsJsonPath, 'utf-8'));
|
|
40
|
+
const html = renderQaReport(runs);
|
|
41
|
+
mkdirSync(dirname(outHtmlPath), { recursive: true });
|
|
42
|
+
writeFileSync(outHtmlPath, html, 'utf-8');
|
|
43
|
+
}
|
|
31
44
|
export function renderQaReport(runs) {
|
|
32
45
|
const empty = runs.length === 0
|
|
33
46
|
? `<p class="empty">No runs / results.</p>`
|
package/dist/rfc-tasks.mjs
CHANGED
|
@@ -3,8 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { plansDir, tasksDir, rfcsDir } from './paths.mjs';
|
|
4
4
|
/**
|
|
5
5
|
* A task is "standalone" (RFC-direct) iff it has no parent (absent or null)
|
|
6
|
-
* AND it has a non-empty context.rfc.id.
|
|
7
|
-
* docs/superpowers/specs/2026-05-12-rfc-direct-tasks-design.md §"Discriminator".
|
|
6
|
+
* AND it has a non-empty context.rfc.id.
|
|
8
7
|
*/
|
|
9
8
|
export function isStandaloneTask(task) {
|
|
10
9
|
const parent = task.parent;
|
|
Binary file
|