@cloverleaf/reference-impl 0.8.5 → 0.9.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/dist/aggregation.mjs +60 -0
- package/dist/cli.mjs +54 -0
- package/dist/council-config.mjs +47 -0
- package/dist/council-result.mjs +19 -0
- package/dist/council.mjs +159 -0
- package/dist/paths.mjs +3 -0
- package/dist/security-classify.mjs +0 -0
- package/lib/aggregation.ts +87 -0
- package/lib/cli.ts +57 -0
- package/lib/council-config.ts +79 -0
- package/lib/council-result.ts +45 -0
- package/lib/council.ts +203 -0
- package/lib/paths.ts +4 -0
- package/lib/security-classify.ts +0 -0
- package/package.json +1 -1
- package/prompts/ui-reviewer.md +4 -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.9.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.9.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
|
+
}
|
|
@@ -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
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
* secret-scan <repoRoot> --branch <branch>
|
|
42
42
|
* classify-security <repoRoot> <taskId> [--branch <branch>]
|
|
43
43
|
* set-task-field <repoRoot> <taskId> <field> <value>
|
|
44
|
+
* council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]
|
|
45
|
+
* aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]
|
|
46
|
+
* apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>
|
|
44
47
|
*/
|
|
45
48
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync } from 'node:fs';
|
|
46
49
|
import { dirname, join } from 'node:path';
|
|
@@ -70,6 +73,8 @@ import { classifyFiles, normalizePath } from './scope-check.mjs';
|
|
|
70
73
|
import { computeRfcTasksView } from './rfc-tasks.mjs';
|
|
71
74
|
import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.mjs';
|
|
72
75
|
import { classifyTaskSecurity } from './security-classify.mjs';
|
|
76
|
+
import { resolveCouncilPlan, applyCouncilVerdict } from './council.mjs';
|
|
77
|
+
import { aggregate } from './aggregation.mjs';
|
|
73
78
|
function die(msg, code = 1) {
|
|
74
79
|
process.stderr.write(msg + '\n');
|
|
75
80
|
process.exit(code);
|
|
@@ -114,6 +119,9 @@ function usage(msg) {
|
|
|
114
119
|
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
|
|
115
120
|
' secret-scan <repoRoot> --branch <branch>\n' +
|
|
116
121
|
' classify-security <repoRoot> <taskId> [--branch <branch>]\n' +
|
|
122
|
+
' council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]\n' +
|
|
123
|
+
' aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]\n' +
|
|
124
|
+
' apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>\n' +
|
|
117
125
|
' set-task-field <repoRoot> <taskId> <field> <value>\n');
|
|
118
126
|
process.exit(2);
|
|
119
127
|
}
|
|
@@ -796,6 +804,52 @@ try {
|
|
|
796
804
|
process.stdout.write(JSON.stringify(result) + '\n');
|
|
797
805
|
break;
|
|
798
806
|
}
|
|
807
|
+
case 'council-plan': {
|
|
808
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
809
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
810
|
+
const [repoRoot, taskId, gateKey] = positional;
|
|
811
|
+
if (!repoRoot || !taskId)
|
|
812
|
+
usage('council-plan requires <repoRoot> <taskId> [gateKey]');
|
|
813
|
+
const cf = flags.find((f) => f.startsWith('--changed-files='));
|
|
814
|
+
const changedFiles = cf !== undefined
|
|
815
|
+
? cf.replace('--changed-files=', '').split(',').filter(Boolean)
|
|
816
|
+
: undefined;
|
|
817
|
+
const plan = resolveCouncilPlan(repoRoot, taskId, gateKey || 'task.review', changedFiles !== undefined ? { changedFiles } : {});
|
|
818
|
+
process.stdout.write(JSON.stringify(plan) + '\n');
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
case 'aggregate-verdicts': {
|
|
822
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
823
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
824
|
+
const [membersJson, ruleArg] = positional;
|
|
825
|
+
if (!membersJson || !ruleArg)
|
|
826
|
+
usage('aggregate-verdicts requires <membersJson> <rule>');
|
|
827
|
+
const members = JSON.parse(membersJson);
|
|
828
|
+
let rule;
|
|
829
|
+
if (ruleArg.startsWith('quorum:')) {
|
|
830
|
+
const quorumN = parseInt(ruleArg.split(':')[1], 10);
|
|
831
|
+
if (Number.isNaN(quorumN) || quorumN < 1) {
|
|
832
|
+
usage(`aggregate-verdicts: quorum value must be a positive integer, got '${ruleArg}'`);
|
|
833
|
+
}
|
|
834
|
+
rule = { quorum: quorumN };
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
rule = ruleArg;
|
|
838
|
+
}
|
|
839
|
+
const wt = flags.find((f) => f.startsWith('--weighted-threshold='));
|
|
840
|
+
const opts = wt ? { weightedThreshold: parseFloat(wt.replace('--weighted-threshold=', '')) } : {};
|
|
841
|
+
process.stdout.write(JSON.stringify(aggregate(members, rule, opts)) + '\n');
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
case 'apply-council-verdict': {
|
|
845
|
+
const [repoRoot, taskId, gate, verdictJson] = rest;
|
|
846
|
+
if (!repoRoot || !taskId || !gate || !verdictJson)
|
|
847
|
+
usage('apply-council-verdict requires <repoRoot> <taskId> <gate> <councilVerdictJson>');
|
|
848
|
+
const council = JSON.parse(verdictJson);
|
|
849
|
+
const result = applyCouncilVerdict(repoRoot, taskId, gate, council);
|
|
850
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
799
853
|
case 'set-task-field': {
|
|
800
854
|
const [repoRoot, taskId, field, value] = rest;
|
|
801
855
|
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
|
+
}
|
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
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Verdict } from './feedback.js';
|
|
2
|
+
|
|
3
|
+
export type ThresholdRule =
|
|
4
|
+
| 'any-veto'
|
|
5
|
+
| 'unanimous'
|
|
6
|
+
| 'majority'
|
|
7
|
+
| 'weighted'
|
|
8
|
+
| { quorum: number };
|
|
9
|
+
|
|
10
|
+
export interface MemberVerdict {
|
|
11
|
+
member: string;
|
|
12
|
+
verdict: Verdict; // 'pass' | 'bounce' | 'escalate'
|
|
13
|
+
blocking?: boolean; // default true; advisory members (false) don't gate
|
|
14
|
+
weight?: number; // default 1; only used by 'weighted'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CouncilVerdict {
|
|
18
|
+
verdict: Verdict;
|
|
19
|
+
rule: ThresholdRule;
|
|
20
|
+
rationale: string;
|
|
21
|
+
members: MemberVerdict[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function aggregate(
|
|
25
|
+
members: MemberVerdict[],
|
|
26
|
+
rule: ThresholdRule,
|
|
27
|
+
opts: { weightedThreshold?: number } = {},
|
|
28
|
+
): CouncilVerdict {
|
|
29
|
+
// Safety invariant: any member that escalates short-circuits, regardless of
|
|
30
|
+
// blocking flag or rule — a hard blocker cannot be out-voted.
|
|
31
|
+
const escalators = members.filter((x) => x.verdict === 'escalate');
|
|
32
|
+
if (escalators.length > 0) {
|
|
33
|
+
return {
|
|
34
|
+
verdict: 'escalate',
|
|
35
|
+
rule,
|
|
36
|
+
members,
|
|
37
|
+
rationale: `escalated by ${escalators.map((x) => x.member).join(', ')}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Only blocking members gate the pass/bounce decision.
|
|
42
|
+
const blocking = members.filter((x) => x.blocking !== false);
|
|
43
|
+
if (blocking.length === 0) {
|
|
44
|
+
return { verdict: 'pass', rule, members, rationale: 'no blocking members' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const passes = blocking.filter((x) => x.verdict === 'pass');
|
|
48
|
+
const passCount = passes.length;
|
|
49
|
+
const total = blocking.length;
|
|
50
|
+
|
|
51
|
+
let pass: boolean;
|
|
52
|
+
let detail: string;
|
|
53
|
+
|
|
54
|
+
if (rule === 'any-veto') {
|
|
55
|
+
pass = passCount === total;
|
|
56
|
+
detail = `any-veto: ${passCount}/${total} passed`;
|
|
57
|
+
} else if (rule === 'unanimous') {
|
|
58
|
+
// Currently identical to any-veto over blocking members. Kept as a distinct
|
|
59
|
+
// named rule for clarity and possible future divergence; do not merge.
|
|
60
|
+
pass = passCount === total;
|
|
61
|
+
detail = `unanimous: ${passCount}/${total} passed`;
|
|
62
|
+
} else if (rule === 'majority') {
|
|
63
|
+
pass = passCount * 2 > total; // strict majority; ties → bounce
|
|
64
|
+
detail = `majority: ${passCount}/${total} passed`;
|
|
65
|
+
} else if (rule === 'weighted') {
|
|
66
|
+
const totalWeight = blocking.reduce((s, x) => s + (x.weight ?? 1), 0);
|
|
67
|
+
const passWeight = passes.reduce((s, x) => s + (x.weight ?? 1), 0);
|
|
68
|
+
const threshold = opts.weightedThreshold;
|
|
69
|
+
pass = threshold !== undefined ? passWeight >= threshold : passWeight * 2 > totalWeight;
|
|
70
|
+
detail =
|
|
71
|
+
`weighted: passWeight ${passWeight}/${totalWeight}` +
|
|
72
|
+
(threshold !== undefined ? ` (threshold ${threshold})` : '');
|
|
73
|
+
} else if (typeof rule === 'object' && rule !== null && 'quorum' in rule) {
|
|
74
|
+
const k = rule.quorum;
|
|
75
|
+
pass = passCount >= k;
|
|
76
|
+
detail = `quorum(${k}): ${passCount}/${total} passed`;
|
|
77
|
+
} else {
|
|
78
|
+
throw new Error(`aggregate: unknown aggregation rule ${JSON.stringify(rule)}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
verdict: pass ? 'pass' : 'bounce',
|
|
83
|
+
rule,
|
|
84
|
+
members,
|
|
85
|
+
rationale: `${pass ? 'pass' : 'bounce'} — ${detail}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
package/lib/cli.ts
CHANGED
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
* secret-scan <repoRoot> --branch <branch>
|
|
42
42
|
* classify-security <repoRoot> <taskId> [--branch <branch>]
|
|
43
43
|
* set-task-field <repoRoot> <taskId> <field> <value>
|
|
44
|
+
* council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]
|
|
45
|
+
* aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]
|
|
46
|
+
* apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>
|
|
44
47
|
*/
|
|
45
48
|
|
|
46
49
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
@@ -73,6 +76,8 @@ import type { SiblingScope } from './scope-check.js';
|
|
|
73
76
|
import { computeRfcTasksView, type RfcTasksView } from './rfc-tasks.js';
|
|
74
77
|
import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.js';
|
|
75
78
|
import { classifyTaskSecurity } from './security-classify.js';
|
|
79
|
+
import { resolveCouncilPlan, applyCouncilVerdict } from './council.js';
|
|
80
|
+
import { aggregate, type MemberVerdict, type ThresholdRule, type CouncilVerdict } from './aggregation.js';
|
|
76
81
|
|
|
77
82
|
function die(msg: string, code = 1): never {
|
|
78
83
|
process.stderr.write(msg + '\n');
|
|
@@ -119,6 +124,9 @@ function usage(msg?: string): never {
|
|
|
119
124
|
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
|
|
120
125
|
' secret-scan <repoRoot> --branch <branch>\n' +
|
|
121
126
|
' classify-security <repoRoot> <taskId> [--branch <branch>]\n' +
|
|
127
|
+
' council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]\n' +
|
|
128
|
+
' aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]\n' +
|
|
129
|
+
' apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>\n' +
|
|
122
130
|
' set-task-field <repoRoot> <taskId> <field> <value>\n'
|
|
123
131
|
);
|
|
124
132
|
process.exit(2);
|
|
@@ -817,6 +825,55 @@ try {
|
|
|
817
825
|
break;
|
|
818
826
|
}
|
|
819
827
|
|
|
828
|
+
case 'council-plan': {
|
|
829
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
830
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
831
|
+
const [repoRoot, taskId, gateKey] = positional;
|
|
832
|
+
if (!repoRoot || !taskId) usage('council-plan requires <repoRoot> <taskId> [gateKey]');
|
|
833
|
+
const cf = flags.find((f) => f.startsWith('--changed-files='));
|
|
834
|
+
const changedFiles = cf !== undefined
|
|
835
|
+
? cf.replace('--changed-files=', '').split(',').filter(Boolean)
|
|
836
|
+
: undefined;
|
|
837
|
+
const plan = resolveCouncilPlan(
|
|
838
|
+
repoRoot, taskId, gateKey || 'task.review',
|
|
839
|
+
changedFiles !== undefined ? { changedFiles } : {},
|
|
840
|
+
);
|
|
841
|
+
process.stdout.write(JSON.stringify(plan) + '\n');
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
case 'aggregate-verdicts': {
|
|
846
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
847
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
848
|
+
const [membersJson, ruleArg] = positional;
|
|
849
|
+
if (!membersJson || !ruleArg) usage('aggregate-verdicts requires <membersJson> <rule>');
|
|
850
|
+
const members = JSON.parse(membersJson) as MemberVerdict[];
|
|
851
|
+
let rule: ThresholdRule;
|
|
852
|
+
if (ruleArg.startsWith('quorum:')) {
|
|
853
|
+
const quorumN = parseInt(ruleArg.split(':')[1], 10);
|
|
854
|
+
if (Number.isNaN(quorumN) || quorumN < 1) {
|
|
855
|
+
usage(`aggregate-verdicts: quorum value must be a positive integer, got '${ruleArg}'`);
|
|
856
|
+
}
|
|
857
|
+
rule = { quorum: quorumN };
|
|
858
|
+
} else {
|
|
859
|
+
rule = ruleArg as ThresholdRule;
|
|
860
|
+
}
|
|
861
|
+
const wt = flags.find((f) => f.startsWith('--weighted-threshold='));
|
|
862
|
+
const opts = wt ? { weightedThreshold: parseFloat(wt.replace('--weighted-threshold=', '')) } : {};
|
|
863
|
+
process.stdout.write(JSON.stringify(aggregate(members, rule, opts)) + '\n');
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
case 'apply-council-verdict': {
|
|
868
|
+
const [repoRoot, taskId, gate, verdictJson] = rest;
|
|
869
|
+
if (!repoRoot || !taskId || !gate || !verdictJson)
|
|
870
|
+
usage('apply-council-verdict requires <repoRoot> <taskId> <gate> <councilVerdictJson>');
|
|
871
|
+
const council = JSON.parse(verdictJson) as CouncilVerdict;
|
|
872
|
+
const result = applyCouncilVerdict(repoRoot, taskId, gate, council);
|
|
873
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
|
|
820
877
|
case 'set-task-field': {
|
|
821
878
|
const [repoRoot, taskId, field, value] = rest;
|
|
822
879
|
if (!repoRoot || !taskId || !field || value === undefined)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import type { ThresholdRule } from './aggregation.js';
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const DEFAULT_CONFIG = join(here, '..', 'config', 'council.json');
|
|
8
|
+
|
|
9
|
+
export type WhenPredicate = 'always' | 'security_class:high' | 'ui_changes';
|
|
10
|
+
|
|
11
|
+
export interface CouncilMember {
|
|
12
|
+
member: string; // built-in id: 'reviewer' | 'security' | 'ui' | 'qa'
|
|
13
|
+
when?: WhenPredicate; // default 'always'
|
|
14
|
+
blocking?: boolean; // default true
|
|
15
|
+
weight?: number; // default 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CouncilProfile {
|
|
19
|
+
rounds: CouncilMember[][];
|
|
20
|
+
aggregation: ThresholdRule;
|
|
21
|
+
on_round_bounce?: 'stop' | 'continue'; // default 'stop'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type GateBinding =
|
|
25
|
+
| string
|
|
26
|
+
| { profile: string; mode?: 'decisive' | 'advisory' }
|
|
27
|
+
| { by: string; map: Record<string, string | null> };
|
|
28
|
+
|
|
29
|
+
export interface CouncilConfig {
|
|
30
|
+
profiles: Record<string, CouncilProfile>;
|
|
31
|
+
gates: Record<string, GateBinding>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isObject(x: unknown): x is Record<string, unknown> {
|
|
35
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Full-replacement (not merge): a consumer council.json wholly replaces the
|
|
39
|
+
// shipped default — matching the qa-rules / security-paths loaders. A partial
|
|
40
|
+
// consumer file (e.g. only `profiles`) intentionally yields empty `gates`,
|
|
41
|
+
// i.e. no bound gates → today's behavior. Per-profile shape validation is a
|
|
42
|
+
// later (validator) slice; this loader normalizes only the top-level containers.
|
|
43
|
+
function normalize(doc: Record<string, unknown>): CouncilConfig {
|
|
44
|
+
return {
|
|
45
|
+
profiles: isObject(doc.profiles) ? (doc.profiles as Record<string, CouncilProfile>) : {},
|
|
46
|
+
gates: isObject(doc.gates) ? (doc.gates as Record<string, GateBinding>) : {},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function loadDefaultConfig(): CouncilConfig {
|
|
51
|
+
if (!existsSync(DEFAULT_CONFIG)) {
|
|
52
|
+
throw new Error(`council config not found at ${DEFAULT_CONFIG}`);
|
|
53
|
+
}
|
|
54
|
+
const parsed: unknown = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
|
|
55
|
+
if (!isObject(parsed)) {
|
|
56
|
+
throw new Error(`council config malformed (not an object) at ${DEFAULT_CONFIG}`);
|
|
57
|
+
}
|
|
58
|
+
return normalize(parsed);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadCouncilConfigWithSource(
|
|
62
|
+
repoRoot: string,
|
|
63
|
+
): { config: CouncilConfig; source: 'consumer' | 'default' } {
|
|
64
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'council.json');
|
|
65
|
+
if (existsSync(consumerPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(readFileSync(consumerPath, 'utf-8'));
|
|
68
|
+
if (!isObject(parsed)) throw new Error('council.json is not an object');
|
|
69
|
+
return { config: normalize(parsed), source: 'consumer' };
|
|
70
|
+
} catch {
|
|
71
|
+
// malformed / non-object consumer config → fall back to package default
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { config: loadDefaultConfig(), source: 'default' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function loadCouncilConfig(repoRoot: string): CouncilConfig {
|
|
78
|
+
return loadCouncilConfigWithSource(repoRoot).config;
|
|
79
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { councilRunDir } from './paths.js';
|
|
4
|
+
import type { Verdict } from './feedback.js';
|
|
5
|
+
import type { ThresholdRule } from './aggregation.js';
|
|
6
|
+
|
|
7
|
+
export interface CouncilResultMember {
|
|
8
|
+
member: string;
|
|
9
|
+
verdict: Verdict;
|
|
10
|
+
blocking: boolean;
|
|
11
|
+
weight: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CouncilResult {
|
|
15
|
+
gate: string;
|
|
16
|
+
final_verdict: Verdict;
|
|
17
|
+
rule: ThresholdRule;
|
|
18
|
+
rationale: string;
|
|
19
|
+
members: CouncilResultMember[];
|
|
20
|
+
walk: string[]; // states walked, e.g. ["review","automated-gates","qa","final-gate"]
|
|
21
|
+
walk_note?: string; // set when a state was traversed administratively (e.g. qa with no qa member)
|
|
22
|
+
security: {
|
|
23
|
+
member_verdict: Verdict | 'absent';
|
|
24
|
+
gating_verdict_set: 'pass' | null; // security_review_verdict the council set, if any
|
|
25
|
+
basis: string; // human-readable explanation of the security decision
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function councilResultPath(repoRoot: string, taskId: string, gate: string): string {
|
|
30
|
+
return join(councilRunDir(repoRoot, taskId), `${gate}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeCouncilResult(repoRoot: string, taskId: string, result: CouncilResult): string {
|
|
34
|
+
const dir = councilRunDir(repoRoot, taskId);
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
const path = councilResultPath(repoRoot, taskId, result.gate);
|
|
37
|
+
writeFileSync(path, JSON.stringify(result, null, 2) + '\n');
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readCouncilResult(repoRoot: string, taskId: string, gate: string): CouncilResult | null {
|
|
42
|
+
const path = councilResultPath(repoRoot, taskId, gate);
|
|
43
|
+
if (!existsSync(path)) return null;
|
|
44
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as CouncilResult;
|
|
45
|
+
}
|
package/lib/council.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { loadCouncilConfigWithSource, type CouncilConfig, type GateBinding, type WhenPredicate } from './council-config.js';
|
|
3
|
+
import type { ThresholdRule, CouncilVerdict } from './aggregation.js';
|
|
4
|
+
import { loadTask, saveTask, advanceStatus } from './task.js';
|
|
5
|
+
import { writeCouncilResult, type CouncilResult } from './council-result.js';
|
|
6
|
+
import { classifyTaskSecurity } from './security-classify.js';
|
|
7
|
+
import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.js';
|
|
8
|
+
|
|
9
|
+
export interface ResolvedMember {
|
|
10
|
+
member: string;
|
|
11
|
+
blocking: boolean;
|
|
12
|
+
weight: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CouncilPlan {
|
|
16
|
+
gate: string;
|
|
17
|
+
profile: string | null; // null → no council bound (today's behavior)
|
|
18
|
+
mode: 'decisive' | 'advisory';
|
|
19
|
+
rounds: ResolvedMember[][];
|
|
20
|
+
aggregation: ThresholdRule;
|
|
21
|
+
on_round_bounce: 'stop' | 'continue';
|
|
22
|
+
source: 'consumer' | 'default';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface WhenContext {
|
|
26
|
+
securityHigh: boolean;
|
|
27
|
+
uiChanges: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function evaluateWhen(predicate: WhenPredicate | undefined, ctx: WhenContext): boolean {
|
|
31
|
+
switch (predicate) {
|
|
32
|
+
case undefined:
|
|
33
|
+
case 'always':
|
|
34
|
+
return true;
|
|
35
|
+
case 'security_class:high':
|
|
36
|
+
return ctx.securityHigh;
|
|
37
|
+
case 'ui_changes':
|
|
38
|
+
return ctx.uiChanges;
|
|
39
|
+
default:
|
|
40
|
+
return false; // unknown predicate → inactive (fail-closed)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveBinding(
|
|
45
|
+
binding: GateBinding | undefined,
|
|
46
|
+
task: Record<string, unknown>,
|
|
47
|
+
): { profile: string | null; mode: 'decisive' | 'advisory' } {
|
|
48
|
+
if (binding === undefined) return { profile: null, mode: 'decisive' };
|
|
49
|
+
if (typeof binding === 'string') return { profile: binding, mode: 'decisive' };
|
|
50
|
+
if ('profile' in binding) return { profile: binding.profile, mode: binding.mode ?? 'decisive' };
|
|
51
|
+
// conditional selector { by, map }
|
|
52
|
+
const key = String(task[binding.by] ?? '');
|
|
53
|
+
const selected = key in binding.map ? binding.map[key] : (binding.map['*'] ?? null);
|
|
54
|
+
return { profile: selected, mode: 'decisive' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve changed files for predicate evaluation. Callers should pass
|
|
59
|
+
* `opts.changedFiles` (e.g. from a prior `git diff`); when omitted we run
|
|
60
|
+
* `git diff main..cloverleaf/<taskId>` and fall back to [] on any git error.
|
|
61
|
+
* Exported for direct testing of the (now orchestrator-live) git path.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveChangedFiles(repoRoot: string, taskId: string, opts: { changedFiles?: string[] } = {}): string[] {
|
|
64
|
+
if (opts.changedFiles !== undefined) return opts.changedFiles;
|
|
65
|
+
try {
|
|
66
|
+
const out = execFileSync('git', ['-C', repoRoot, 'diff', '--name-only', `main..cloverleaf/${taskId}`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
67
|
+
return out.split('\n').filter(Boolean);
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveCouncilPlan(
|
|
74
|
+
repoRoot: string,
|
|
75
|
+
taskId: string,
|
|
76
|
+
gateKey = 'task.review',
|
|
77
|
+
opts: { changedFiles?: string[] } = {},
|
|
78
|
+
): CouncilPlan {
|
|
79
|
+
const { config, source } = loadCouncilConfigWithSource(repoRoot);
|
|
80
|
+
const task = loadTask(repoRoot, taskId) as unknown as Record<string, unknown>;
|
|
81
|
+
|
|
82
|
+
const { profile: profileName, mode } = resolveBinding(config.gates[gateKey], task);
|
|
83
|
+
const empty: CouncilPlan = {
|
|
84
|
+
gate: gateKey, profile: null, mode, rounds: [],
|
|
85
|
+
aggregation: 'any-veto', on_round_bounce: 'stop', source,
|
|
86
|
+
};
|
|
87
|
+
if (profileName === null) return empty;
|
|
88
|
+
|
|
89
|
+
const profile = config.profiles[profileName];
|
|
90
|
+
if (!profile) {
|
|
91
|
+
if (source === 'consumer') {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
`cloverleaf-cli council-plan: profile '${profileName}' bound to gate '${gateKey}' not found in council.json; falling back to today's behavior.\n`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return empty; // unknown profile → fail toward today's behavior
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const changed = resolveChangedFiles(repoRoot, taskId, opts);
|
|
100
|
+
const securityHigh = classifyTaskSecurity(repoRoot, taskId, { changedFiles: changed }).effective === 'high';
|
|
101
|
+
const affected = computeAffectedRoutes(changed, loadAffectedRoutesConfig(repoRoot));
|
|
102
|
+
const uiChanges = affected === 'all' || affected.length > 0;
|
|
103
|
+
const ctx: WhenContext = { securityHigh, uiChanges };
|
|
104
|
+
|
|
105
|
+
const rounds: ResolvedMember[][] = [];
|
|
106
|
+
for (const round of profile.rounds) {
|
|
107
|
+
const active = round
|
|
108
|
+
.filter((member) => evaluateWhen(member.when, ctx))
|
|
109
|
+
.map((member) => ({ member: member.member, blocking: member.blocking !== false, weight: member.weight ?? 1 }));
|
|
110
|
+
if (active.length > 0) rounds.push(active);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
gate: gateKey,
|
|
115
|
+
profile: profileName,
|
|
116
|
+
mode,
|
|
117
|
+
rounds,
|
|
118
|
+
aggregation: profile.aggregation,
|
|
119
|
+
on_round_bounce: profile.on_round_bounce ?? 'stop',
|
|
120
|
+
source,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Drive the FSM transition implied by a council verdict (the runner's terminal step).
|
|
126
|
+
* Council-authoritative: on a pass it records the council's gating verdict so the
|
|
127
|
+
* v0.8.1 security precondition is satisfied for any high-security gated transition;
|
|
128
|
+
* the per-member basis (incl. an omitted or out-voted `security` member) is written
|
|
129
|
+
* to the result artifact. Walks the minimal legal path to the lane's pre-merge state.
|
|
130
|
+
*/
|
|
131
|
+
export function applyCouncilVerdict(
|
|
132
|
+
repoRoot: string,
|
|
133
|
+
taskId: string,
|
|
134
|
+
gate: string,
|
|
135
|
+
council: CouncilVerdict,
|
|
136
|
+
): CouncilResult {
|
|
137
|
+
if (gate !== 'task.review') {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`apply-council-verdict: gate '${gate}' is not supported yet — the FSM walk is hardcoded for the ` +
|
|
140
|
+
`task.review → merge lane. Binding other gates needs a gate-aware walk (council Slice 3).`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const task = loadTask(repoRoot, taskId);
|
|
144
|
+
if (task.status !== 'review') {
|
|
145
|
+
throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'review'`);
|
|
146
|
+
}
|
|
147
|
+
const lane: 'fast' | 'full' = task.risk_class === 'high' ? 'full' : 'fast';
|
|
148
|
+
const securityMember = council.members.find((m) => m.member === 'security');
|
|
149
|
+
const walk: string[] = ['review'];
|
|
150
|
+
|
|
151
|
+
if (council.verdict === 'escalate') {
|
|
152
|
+
advanceStatus(repoRoot, taskId, 'escalated', 'agent');
|
|
153
|
+
walk.push('escalated');
|
|
154
|
+
} else if (council.verdict === 'bounce') {
|
|
155
|
+
advanceStatus(repoRoot, taskId, 'implementing', 'agent');
|
|
156
|
+
walk.push('implementing');
|
|
157
|
+
} else {
|
|
158
|
+
// pass — minimal legal walk; review→automated-gates resets the security verdict,
|
|
159
|
+
// so set the council's gating verdict AFTER that transition.
|
|
160
|
+
advanceStatus(repoRoot, taskId, 'automated-gates', 'agent');
|
|
161
|
+
walk.push('automated-gates');
|
|
162
|
+
const atGates = loadTask(repoRoot, taskId);
|
|
163
|
+
atGates.security_review_verdict = 'pass';
|
|
164
|
+
saveTask(repoRoot, atGates);
|
|
165
|
+
if (lane === 'full') {
|
|
166
|
+
advanceStatus(repoRoot, taskId, 'qa', 'agent', { path: 'full_pipeline' });
|
|
167
|
+
walk.push('qa');
|
|
168
|
+
advanceStatus(repoRoot, taskId, 'final-gate', 'agent', { path: 'full_pipeline' });
|
|
169
|
+
walk.push('final-gate');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const qaTraversedAdministratively =
|
|
174
|
+
lane === 'full' && walk.includes('qa') && !council.members.some((m) => m.member === 'qa');
|
|
175
|
+
|
|
176
|
+
const result: CouncilResult = {
|
|
177
|
+
gate,
|
|
178
|
+
final_verdict: council.verdict,
|
|
179
|
+
rule: council.rule,
|
|
180
|
+
rationale: council.rationale,
|
|
181
|
+
members: council.members.map((m) => ({
|
|
182
|
+
member: m.member,
|
|
183
|
+
verdict: m.verdict,
|
|
184
|
+
blocking: m.blocking !== false,
|
|
185
|
+
weight: m.weight ?? 1,
|
|
186
|
+
})),
|
|
187
|
+
walk,
|
|
188
|
+
...(qaTraversedAdministratively
|
|
189
|
+
? { walk_note: 'qa state traversed administratively; no qa member ran' }
|
|
190
|
+
: {}),
|
|
191
|
+
security: {
|
|
192
|
+
member_verdict: securityMember ? securityMember.verdict : 'absent',
|
|
193
|
+
gating_verdict_set: council.verdict === 'pass' ? 'pass' : null,
|
|
194
|
+
basis: !securityMember
|
|
195
|
+
? 'no security member configured; advanced under council authority'
|
|
196
|
+
: securityMember.verdict === 'pass'
|
|
197
|
+
? 'security member passed'
|
|
198
|
+
: `security member returned '${securityMember.verdict}'; council ${council.verdict} by rule ${JSON.stringify(council.rule)}`,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
writeCouncilResult(repoRoot, taskId, result);
|
|
202
|
+
return result;
|
|
203
|
+
}
|
package/lib/paths.ts
CHANGED
|
@@ -41,3 +41,7 @@ export function runsDir(repoRoot: string): string {
|
|
|
41
41
|
export function uiReviewRunDir(repoRoot: string, taskId: string): string {
|
|
42
42
|
return resolve(runsDir(repoRoot), taskId, 'ui-review');
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
export function councilRunDir(repoRoot: string, taskId: string): string {
|
|
46
|
+
return resolve(runsDir(repoRoot), taskId, 'council');
|
|
47
|
+
}
|
package/lib/security-classify.ts
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/prompts/ui-reviewer.md
CHANGED
|
@@ -92,7 +92,7 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
|
|
|
92
92
|
SERVER_PID=$!
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
> **Playwright script placement (Bug #3 fix)
|
|
95
|
+
> **Playwright/driver script placement (Bug #3 fix) — applies to EVERY script you write, including retries and ad-hoc fallbacks:** place any standalone `.mjs` driver **inside the worktree** (e.g., `$WT/site/playwright-driver.mjs`) and run it from there (`node "$WT/site/playwright-driver.mjs"`). Node's ESM module resolution walks up from the script's own directory — a script placed anywhere outside the worktree (e.g. `/tmp`) cannot resolve the `playwright` import (or any `node_modules` package) and fails with `ERR_MODULE_NOT_FOUND`. If a driver errors and you retry, fix it in place in `$WT/site/` — never relocate or recreate it under `/tmp`.
|
|
96
96
|
|
|
97
97
|
4. Wait up to 30s for `http://localhost:{{preview_port}}/` to respond 200. If the server fails to start in 30s, kill it and return verdict `escalate`.
|
|
98
98
|
|
|
@@ -117,6 +117,9 @@ Do not attempt to launch a missing engine — fail fast with `verdict: "escalate
|
|
|
117
117
|
a. Launch a Playwright browser context using the `browser` engine.
|
|
118
118
|
|
|
119
119
|
b. **Visual-diff pass (when `visualDiff.enabled` is true):**
|
|
120
|
+
|
|
121
|
+
**Visual diffing is ONLY `compareVisual` (pixelmatch). There is no ImageMagick.** Never shell out to `convert`, `compare`, `magick`, or any external image tool to diff or convert images — they are not installed and not a dependency. Screenshot via Playwright (`page.screenshot`) → pass the buffer to `compareVisual` (`lib/visual-diff.ts`). If `compareVisual` is hard to invoke, your driver script failed to resolve — fix the script (see the placement rule), do **not** substitute an external tool.
|
|
122
|
+
|
|
120
123
|
For each route in the (capped) route list × each viewport in `{{ui_review_config}}.viewports`:
|
|
121
124
|
- Set Playwright viewport to `{ width, height }` from the config.
|
|
122
125
|
- Apply mask CSS — inject a style that sets `visibility: hidden` on any selector in `visualDiff.mask`.
|
|
@@ -57,9 +57,11 @@ Recovery sequence:
|
|
|
57
57
|
|
|
58
58
|
2. Load task: `cloverleaf-cli load-task <repo_root> <TASK-ID>`. Verify `status === "pending"`. If not, report and stop.
|
|
59
59
|
|
|
60
|
-
3.
|
|
61
|
-
- `"low"` →
|
|
62
|
-
- `"high"` →
|
|
60
|
+
3. **Council gate detection (opt-in).** Run `cloverleaf-cli council-plan <repo_root> <TASK-ID> task.review` and parse the JSON plan. If `plan.source === "consumer"` **and** `plan.profile !== null`, the project has opted into a configured review council — drive the review phase via **section 7 (Council review path)** instead of the hardcoded reviewer/security/ui/qa steps in sections 4/5. Otherwise (`source: "default"`, or no `task.review` binding) proceed exactly as today:
|
|
61
|
+
- `task.risk_class === "low"` → section 4 (Fast Lane)
|
|
62
|
+
- `task.risk_class === "high"` → section 5 (Full Pipeline)
|
|
63
|
+
|
|
64
|
+
When the council path is active it still uses the Implementer (and, for the full pipeline, the Documenter) to produce the branch; only the review→merge portion is council-driven.
|
|
63
65
|
|
|
64
66
|
### 4. Fast Lane
|
|
65
67
|
|
|
@@ -122,6 +124,35 @@ Loop:
|
|
|
122
124
|
- Commit: `git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> escalated (bounce budget exhausted)"`.
|
|
123
125
|
- Report: "✗ Escalated `<TASK-ID>`. Review `.cloverleaf/feedback/` and either refine the task or take over manually. Counters: reviewer=<N>, ui_reviewer=<N>, qa=<N>, security=<N>."
|
|
124
126
|
|
|
127
|
+
### 7. Council review path (opt-in; active when council-plan source is "consumer")
|
|
128
|
+
|
|
129
|
+
Initialize `council_bounces = 0`.
|
|
130
|
+
|
|
131
|
+
7.1 **Produce the branch.** Run the Implementer (`/cloverleaf-implement <TASK-ID>` steps); for `risk_class: "high"` also run the Documenter (`/cloverleaf-document <TASK-ID>` steps). The task reaches `review`.
|
|
132
|
+
|
|
133
|
+
7.2 **Run the council members (verdict-only).** Re-run `cloverleaf-cli council-plan <repo_root> <TASK-ID> task.review` to get `plan.rounds`, `plan.aggregation`, `plan.on_round_bounce`. For each round **in order**, for each member in the round, dispatch the member's prompt as a **read-only** subagent and capture its `{verdict, summary, findings}` envelope — do **not** advance state:
|
|
134
|
+
- `reviewer` → `prompts/reviewer.md`, feedback prefix `r`
|
|
135
|
+
- `security` → `prompts/security-reviewer.md`, prefix `s`
|
|
136
|
+
- `ui` → `prompts/ui-reviewer.md`, prefix `u`
|
|
137
|
+
- `qa` → `prompts/qa.md`, prefix `q`
|
|
138
|
+
|
|
139
|
+
**Dispatch conventions:** invoke the Task tool in foreground (default — never `run_in_background`); do not poll with foreground `sleep`. Substitute `{{task}}`, `{{branch}}` (`cloverleaf/<TASK-ID>`), `{{base_branch}}` (`main`), `{{repo_root}}`, `{{diff}}` (`git diff main..cloverleaf/<TASK-ID> -- ':(exclude).cloverleaf/'`).
|
|
140
|
+
|
|
141
|
+
Persist each member's envelope: `echo '<envelope>' > /tmp/clv-council-<member>.json && cloverleaf-cli write-feedback <repo_root> <TASK-ID> /tmp/clv-council-<member>.json --prefix=<r|s|u|q>`. Collect a members array `[{ "member": "<id>", "verdict": "<pass|bounce|escalate>", "blocking": <plan member blocking>, "weight": <plan member weight> }]`.
|
|
142
|
+
|
|
143
|
+
**Short-circuit:** if any member returns `escalate`, stop immediately. Otherwise, after each round, if `plan.on_round_bounce === "stop"` and any **blocking** member in that round returned `bounce`, stop before the next round. Always finish the members already running in the current round (batched).
|
|
144
|
+
|
|
145
|
+
7.3 **Aggregate.** Map `plan.aggregation` to the CLI rule argument: a string passes through; `{ "quorum": k }` → `quorum:k`. Run `cloverleaf-cli aggregate-verdicts '<members-json>' <rule>` and capture the council verdict JSON.
|
|
146
|
+
|
|
147
|
+
7.4 **Apply.** Run `cloverleaf-cli apply-council-verdict <repo_root> <TASK-ID> task.review '<council-verdict-json>'`. The FSM walk may self-commit some transitions (e.g. `security_class → high`, the rework verdict-reset), so the wrap-up commit can find nothing staged — that is expected. Commit the remainder: `git add .cloverleaf/ && (git diff --cached --quiet || git commit -m "cloverleaf: <TASK-ID> council review (<verdict>)")`.
|
|
148
|
+
|
|
149
|
+
7.5 **Branch on the task's new status (reload with `load-task`):**
|
|
150
|
+
- `automated-gates` (fast lane pass) or `final-gate` (full pipeline pass) → proceed to the merge: inline `/cloverleaf-merge <TASK-ID>`.
|
|
151
|
+
- `implementing` (bounce) → `council_bounces += 1`. If `council_bounces >= 3`, escalate (section 6). Else return to 7.1.
|
|
152
|
+
- `escalated` → stop and surface to the user (review `.cloverleaf/feedback/` and `.cloverleaf/runs/<TASK-ID>/council/task.review.json`).
|
|
153
|
+
|
|
154
|
+
The council result artifact at `.cloverleaf/runs/<TASK-ID>/council/task.review.json` records per-member verdicts, the aggregate, and the security basis (incl. an omitted or out-voted `security` member). On any member-dispatch failure or unparseable envelope, stop and report — never treat a failed member as a pass.
|
|
155
|
+
|
|
125
156
|
## Rules
|
|
126
157
|
|
|
127
158
|
- Each agent has its own 3-bounce budget. Bounces from different agents do NOT share counters.
|