@cloverleaf/reference-impl 0.10.1 → 0.11.1
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/dist/chair.mjs +74 -0
- package/dist/cli.mjs +22 -0
- package/dist/council.mjs +43 -2
- package/lib/aggregation.ts +2 -1
- package/lib/chair.ts +94 -0
- package/lib/cli.ts +22 -0
- package/lib/council-config.ts +4 -2
- package/lib/council-result.ts +2 -1
- package/lib/council.ts +49 -4
- package/package.json +1 -1
- package/prompts/chair.md +43 -0
- package/prompts/implementer.md +1 -1
- package/skills/cloverleaf-run/SKILL.md +9 -8
|
@@ -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.11.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.11.1
|
package/dist/chair.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getPluginRoot } from './plugin-path.mjs';
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the chair prompt to an absolute path. A profile `chair.prompt` points at
|
|
6
|
+
* a custom prompt under <repoRoot>/.cloverleaf/prompts/ (exist-checked); omitted →
|
|
7
|
+
* the shipped built-in prompts/chair.md.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveChairPrompt(chair, repoRoot) {
|
|
10
|
+
if (chair?.prompt !== undefined) {
|
|
11
|
+
const p = join(repoRoot, '.cloverleaf', 'prompts', chair.prompt);
|
|
12
|
+
if (!existsSync(p)) {
|
|
13
|
+
throw new Error(`council: chair prompt not found at ${p}`);
|
|
14
|
+
}
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
return join(getPluginRoot(), 'prompts', 'chair.md');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Render a readable deliberation packet from the member verdicts + their feedback
|
|
21
|
+
* envelopes (supplied inline by the orchestrator) for the chair prompt's
|
|
22
|
+
* {{member_verdicts}} placeholder. Pure — no disk read.
|
|
23
|
+
*/
|
|
24
|
+
export function buildChairContext(members) {
|
|
25
|
+
return members
|
|
26
|
+
.map((m) => {
|
|
27
|
+
const tags = [m.blocking === false ? 'advisory' : 'blocking', `weight ${m.weight ?? 1}`].join(', ');
|
|
28
|
+
const lines = [`### ${m.member} — ${m.verdict} (${tags})`];
|
|
29
|
+
if (m.envelope?.summary)
|
|
30
|
+
lines.push(m.envelope.summary);
|
|
31
|
+
for (const f of m.envelope?.findings ?? []) {
|
|
32
|
+
const loc = f.location?.file ? ` [${f.location.file}${f.location.line ? `:${f.location.line}` : ''}]` : '';
|
|
33
|
+
lines.push(`- (${f.severity ?? 'info'}) ${f.message ?? ''}${loc}`);
|
|
34
|
+
}
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
})
|
|
37
|
+
.join('\n\n');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Normalize the chair agent's raw output into a CouncilVerdict. Fail-closed on a
|
|
41
|
+
* malformed shape. Re-asserts the escalate invariant: a member `escalate` forces the
|
|
42
|
+
* council verdict to `escalate` regardless of the chair's output (the chair may raise
|
|
43
|
+
* a bounce to escalate but can never lower an escalate).
|
|
44
|
+
*/
|
|
45
|
+
export function finalizeChairVerdict(raw, members) {
|
|
46
|
+
const escalators = members.filter((m) => m.verdict === 'escalate');
|
|
47
|
+
if (escalators.length > 0) {
|
|
48
|
+
return {
|
|
49
|
+
verdict: 'escalate',
|
|
50
|
+
rule: 'chair',
|
|
51
|
+
members,
|
|
52
|
+
rationale: `escalated by ${escalators.map((m) => m.member).join(', ')} (chair cannot lower an escalate)`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (raw === null || typeof raw !== 'object') {
|
|
56
|
+
throw new Error('chair-verdict: chair output is not an object');
|
|
57
|
+
}
|
|
58
|
+
if (raw.verdict !== 'pass' && raw.verdict !== 'bounce' && raw.verdict !== 'escalate') {
|
|
59
|
+
throw new Error(`chair-verdict: invalid verdict '${String(raw.verdict)}'`);
|
|
60
|
+
}
|
|
61
|
+
const ids = new Set(members.map((m) => m.member));
|
|
62
|
+
const forward = (raw.forward ?? []).filter((f) => typeof f === 'string');
|
|
63
|
+
for (const f of forward) {
|
|
64
|
+
if (!ids.has(f))
|
|
65
|
+
throw new Error(`chair-verdict: forward names unknown member '${f}'`);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
verdict: raw.verdict,
|
|
69
|
+
rule: 'chair',
|
|
70
|
+
members,
|
|
71
|
+
rationale: typeof raw.rationale === 'string' ? raw.rationale : '',
|
|
72
|
+
...(raw.verdict === 'bounce' ? { forward } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/dist/cli.mjs
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
* council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]
|
|
46
46
|
* aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]
|
|
47
47
|
* apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>
|
|
48
|
+
* chair-context <chairMemberInputsJson>
|
|
49
|
+
* chair-verdict <chairRawJson> <membersJson>
|
|
48
50
|
*/
|
|
49
51
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync } from 'node:fs';
|
|
50
52
|
import { dirname, join } from 'node:path';
|
|
@@ -77,6 +79,7 @@ import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.mjs';
|
|
|
77
79
|
import { classifyTaskSecurity } from './security-classify.mjs';
|
|
78
80
|
import { resolveCouncilPlan, applyCouncilVerdict } from './council.mjs';
|
|
79
81
|
import { aggregate } from './aggregation.mjs';
|
|
82
|
+
import { buildChairContext, finalizeChairVerdict } from './chair.mjs';
|
|
80
83
|
function die(msg, code = 1) {
|
|
81
84
|
process.stderr.write(msg + '\n');
|
|
82
85
|
process.exit(code);
|
|
@@ -125,6 +128,8 @@ function usage(msg) {
|
|
|
125
128
|
' council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]\n' +
|
|
126
129
|
' aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]\n' +
|
|
127
130
|
' apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>\n' +
|
|
131
|
+
' chair-context <chairMemberInputsJson>\n' +
|
|
132
|
+
' chair-verdict <chairRawJson> <membersJson>\n' +
|
|
128
133
|
' set-task-field <repoRoot> <taskId> <field> <value>\n');
|
|
129
134
|
process.exit(2);
|
|
130
135
|
}
|
|
@@ -860,6 +865,23 @@ try {
|
|
|
860
865
|
process.stdout.write(JSON.stringify(result) + '\n');
|
|
861
866
|
break;
|
|
862
867
|
}
|
|
868
|
+
case 'chair-context': {
|
|
869
|
+
const [inputsJson] = rest;
|
|
870
|
+
if (!inputsJson)
|
|
871
|
+
usage('chair-context requires <chairMemberInputsJson>');
|
|
872
|
+
const inputs = JSON.parse(inputsJson);
|
|
873
|
+
process.stdout.write(buildChairContext(inputs) + '\n');
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
case 'chair-verdict': {
|
|
877
|
+
const [rawJson, membersJson] = rest;
|
|
878
|
+
if (!rawJson || !membersJson)
|
|
879
|
+
usage('chair-verdict requires <chairRawJson> <membersJson>');
|
|
880
|
+
const raw = JSON.parse(rawJson);
|
|
881
|
+
const members = JSON.parse(membersJson);
|
|
882
|
+
process.stdout.write(JSON.stringify(finalizeChairVerdict(raw, members)) + '\n');
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
863
885
|
case 'set-task-field': {
|
|
864
886
|
const [repoRoot, taskId, field, value] = rest;
|
|
865
887
|
if (!repoRoot || !taskId || !field || value === undefined)
|
package/dist/council.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { loadCouncilConfigWithSource } from './council-config.mjs';
|
|
3
5
|
import { loadTask, saveTask, advanceStatus } from './task.mjs';
|
|
4
6
|
import { writeCouncilResult } from './council-result.mjs';
|
|
7
|
+
import { resolveChairPrompt } from './chair.mjs';
|
|
5
8
|
import { classifyTaskSecurity } from './security-classify.mjs';
|
|
6
9
|
import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.mjs';
|
|
10
|
+
import { getPluginRoot } from './plugin-path.mjs';
|
|
7
11
|
export function evaluateWhen(predicate, ctx) {
|
|
8
12
|
switch (predicate) {
|
|
9
13
|
case undefined:
|
|
@@ -46,6 +50,33 @@ export function resolveChangedFiles(repoRoot, taskId, opts = {}) {
|
|
|
46
50
|
return [];
|
|
47
51
|
}
|
|
48
52
|
}
|
|
53
|
+
const BUILTIN_PROMPTS = {
|
|
54
|
+
reviewer: 'reviewer.md',
|
|
55
|
+
security: 'security-reviewer.md',
|
|
56
|
+
ui: 'ui-reviewer.md',
|
|
57
|
+
qa: 'qa.md',
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a council member to the absolute path of its prompt. A member with a
|
|
61
|
+
* `prompt` field is a custom role → <repoRoot>/.cloverleaf/prompts/<file> (exist-checked,
|
|
62
|
+
* since it is user-authored and easily mistyped); a bare built-in id → the shipped prompt
|
|
63
|
+
* under the plugin root, deliberately NOT exist-checked (built-ins ship with the plugin, so
|
|
64
|
+
* a missing one is a broken install that surfaces at prompt read-time, not user error).
|
|
65
|
+
*/
|
|
66
|
+
export function resolveMemberPrompt(member, repoRoot) {
|
|
67
|
+
if (member.prompt !== undefined) {
|
|
68
|
+
const p = join(repoRoot, '.cloverleaf', 'prompts', member.prompt);
|
|
69
|
+
if (!existsSync(p)) {
|
|
70
|
+
throw new Error(`council: custom member '${member.member}' prompt not found at ${p}`);
|
|
71
|
+
}
|
|
72
|
+
return p;
|
|
73
|
+
}
|
|
74
|
+
const builtin = BUILTIN_PROMPTS[member.member];
|
|
75
|
+
if (builtin === undefined) {
|
|
76
|
+
throw new Error(`council: unknown member '${member.member}' (no built-in prompt and no 'prompt' field)`);
|
|
77
|
+
}
|
|
78
|
+
return join(getPluginRoot(), 'prompts', builtin);
|
|
79
|
+
}
|
|
49
80
|
export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', opts = {}) {
|
|
50
81
|
const { config, source } = loadCouncilConfigWithSource(repoRoot);
|
|
51
82
|
const task = loadTask(repoRoot, taskId);
|
|
@@ -72,11 +103,16 @@ export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', op
|
|
|
72
103
|
for (const round of profile.rounds) {
|
|
73
104
|
const active = round
|
|
74
105
|
.filter((member) => evaluateWhen(member.when, ctx))
|
|
75
|
-
.map((member) => ({
|
|
106
|
+
.map((member) => ({
|
|
107
|
+
member: member.member,
|
|
108
|
+
blocking: member.blocking !== false,
|
|
109
|
+
weight: member.weight ?? 1,
|
|
110
|
+
promptPath: resolveMemberPrompt(member, repoRoot),
|
|
111
|
+
}));
|
|
76
112
|
if (active.length > 0)
|
|
77
113
|
rounds.push(active);
|
|
78
114
|
}
|
|
79
|
-
|
|
115
|
+
const plan = {
|
|
80
116
|
gate: gateKey,
|
|
81
117
|
profile: profileName,
|
|
82
118
|
mode,
|
|
@@ -85,6 +121,10 @@ export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', op
|
|
|
85
121
|
on_round_bounce: profile.on_round_bounce ?? 'stop',
|
|
86
122
|
source,
|
|
87
123
|
};
|
|
124
|
+
if (profile.aggregation === 'chair') {
|
|
125
|
+
plan.chair = { promptPath: resolveChairPrompt(profile.chair, repoRoot) };
|
|
126
|
+
}
|
|
127
|
+
return plan;
|
|
88
128
|
}
|
|
89
129
|
/**
|
|
90
130
|
* Drive the FSM transition implied by a council verdict (the runner's terminal step).
|
|
@@ -144,6 +184,7 @@ export function applyCouncilVerdict(repoRoot, taskId, gate, council) {
|
|
|
144
184
|
...(qaTraversedAdministratively
|
|
145
185
|
? { walk_note: 'qa state traversed administratively; no qa member ran' }
|
|
146
186
|
: {}),
|
|
187
|
+
...(council.forward !== undefined ? { forward: council.forward } : {}),
|
|
147
188
|
security: {
|
|
148
189
|
member_verdict: securityMember ? securityMember.verdict : 'absent',
|
|
149
190
|
gating_verdict_set: council.verdict === 'pass' ? 'pass' : null,
|
package/lib/aggregation.ts
CHANGED
|
@@ -16,9 +16,10 @@ export interface MemberVerdict {
|
|
|
16
16
|
|
|
17
17
|
export interface CouncilVerdict {
|
|
18
18
|
verdict: Verdict;
|
|
19
|
-
rule: ThresholdRule;
|
|
19
|
+
rule: ThresholdRule | 'chair';
|
|
20
20
|
rationale: string;
|
|
21
21
|
members: MemberVerdict[];
|
|
22
|
+
forward?: string[]; // chair-curated member ids to forward to the Implementer (bounce only)
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function aggregate(
|
package/lib/chair.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getPluginRoot } from './plugin-path.js';
|
|
4
|
+
import type { Verdict } from './feedback.js';
|
|
5
|
+
import type { MemberVerdict, CouncilVerdict } from './aggregation.js';
|
|
6
|
+
|
|
7
|
+
export interface ChairRawVerdict {
|
|
8
|
+
verdict: Verdict;
|
|
9
|
+
rationale: string;
|
|
10
|
+
forward?: string[]; // member ids whose feedback to forward (bounce only)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ChairMemberInput {
|
|
14
|
+
member: string;
|
|
15
|
+
verdict: Verdict;
|
|
16
|
+
blocking?: boolean;
|
|
17
|
+
weight?: number;
|
|
18
|
+
envelope?: {
|
|
19
|
+
summary?: string;
|
|
20
|
+
findings?: Array<{ severity?: string; message?: string; location?: { file?: string; line?: number } }>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the chair prompt to an absolute path. A profile `chair.prompt` points at
|
|
26
|
+
* a custom prompt under <repoRoot>/.cloverleaf/prompts/ (exist-checked); omitted →
|
|
27
|
+
* the shipped built-in prompts/chair.md.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveChairPrompt(chair: { prompt?: string } | undefined, repoRoot: string): string {
|
|
30
|
+
if (chair?.prompt !== undefined) {
|
|
31
|
+
const p = join(repoRoot, '.cloverleaf', 'prompts', chair.prompt);
|
|
32
|
+
if (!existsSync(p)) {
|
|
33
|
+
throw new Error(`council: chair prompt not found at ${p}`);
|
|
34
|
+
}
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
return join(getPluginRoot(), 'prompts', 'chair.md');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Render a readable deliberation packet from the member verdicts + their feedback
|
|
42
|
+
* envelopes (supplied inline by the orchestrator) for the chair prompt's
|
|
43
|
+
* {{member_verdicts}} placeholder. Pure — no disk read.
|
|
44
|
+
*/
|
|
45
|
+
export function buildChairContext(members: ChairMemberInput[]): string {
|
|
46
|
+
return members
|
|
47
|
+
.map((m) => {
|
|
48
|
+
const tags = [m.blocking === false ? 'advisory' : 'blocking', `weight ${m.weight ?? 1}`].join(', ');
|
|
49
|
+
const lines: string[] = [`### ${m.member} — ${m.verdict} (${tags})`];
|
|
50
|
+
if (m.envelope?.summary) lines.push(m.envelope.summary);
|
|
51
|
+
for (const f of m.envelope?.findings ?? []) {
|
|
52
|
+
const loc = f.location?.file ? ` [${f.location.file}${f.location.line ? `:${f.location.line}` : ''}]` : '';
|
|
53
|
+
lines.push(`- (${f.severity ?? 'info'}) ${f.message ?? ''}${loc}`);
|
|
54
|
+
}
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
})
|
|
57
|
+
.join('\n\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalize the chair agent's raw output into a CouncilVerdict. Fail-closed on a
|
|
62
|
+
* malformed shape. Re-asserts the escalate invariant: a member `escalate` forces the
|
|
63
|
+
* council verdict to `escalate` regardless of the chair's output (the chair may raise
|
|
64
|
+
* a bounce to escalate but can never lower an escalate).
|
|
65
|
+
*/
|
|
66
|
+
export function finalizeChairVerdict(raw: ChairRawVerdict, members: MemberVerdict[]): CouncilVerdict {
|
|
67
|
+
const escalators = members.filter((m) => m.verdict === 'escalate');
|
|
68
|
+
if (escalators.length > 0) {
|
|
69
|
+
return {
|
|
70
|
+
verdict: 'escalate',
|
|
71
|
+
rule: 'chair',
|
|
72
|
+
members,
|
|
73
|
+
rationale: `escalated by ${escalators.map((m) => m.member).join(', ')} (chair cannot lower an escalate)`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (raw === null || typeof raw !== 'object') {
|
|
77
|
+
throw new Error('chair-verdict: chair output is not an object');
|
|
78
|
+
}
|
|
79
|
+
if (raw.verdict !== 'pass' && raw.verdict !== 'bounce' && raw.verdict !== 'escalate') {
|
|
80
|
+
throw new Error(`chair-verdict: invalid verdict '${String(raw.verdict)}'`);
|
|
81
|
+
}
|
|
82
|
+
const ids = new Set(members.map((m) => m.member));
|
|
83
|
+
const forward = (raw.forward ?? []).filter((f): f is string => typeof f === 'string');
|
|
84
|
+
for (const f of forward) {
|
|
85
|
+
if (!ids.has(f)) throw new Error(`chair-verdict: forward names unknown member '${f}'`);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
verdict: raw.verdict,
|
|
89
|
+
rule: 'chair',
|
|
90
|
+
members,
|
|
91
|
+
rationale: typeof raw.rationale === 'string' ? raw.rationale : '',
|
|
92
|
+
...(raw.verdict === 'bounce' ? { forward } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
package/lib/cli.ts
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
* council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]
|
|
46
46
|
* aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]
|
|
47
47
|
* apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>
|
|
48
|
+
* chair-context <chairMemberInputsJson>
|
|
49
|
+
* chair-verdict <chairRawJson> <membersJson>
|
|
48
50
|
*/
|
|
49
51
|
|
|
50
52
|
import { readFileSync, mkdirSync, copyFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
@@ -80,6 +82,7 @@ import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.js';
|
|
|
80
82
|
import { classifyTaskSecurity } from './security-classify.js';
|
|
81
83
|
import { resolveCouncilPlan, applyCouncilVerdict } from './council.js';
|
|
82
84
|
import { aggregate, type MemberVerdict, type ThresholdRule, type CouncilVerdict } from './aggregation.js';
|
|
85
|
+
import { buildChairContext, finalizeChairVerdict, type ChairMemberInput, type ChairRawVerdict } from './chair.js';
|
|
83
86
|
|
|
84
87
|
function die(msg: string, code = 1): never {
|
|
85
88
|
process.stderr.write(msg + '\n');
|
|
@@ -130,6 +133,8 @@ function usage(msg?: string): never {
|
|
|
130
133
|
' council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]\n' +
|
|
131
134
|
' aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]\n' +
|
|
132
135
|
' apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>\n' +
|
|
136
|
+
' chair-context <chairMemberInputsJson>\n' +
|
|
137
|
+
' chair-verdict <chairRawJson> <membersJson>\n' +
|
|
133
138
|
' set-task-field <repoRoot> <taskId> <field> <value>\n'
|
|
134
139
|
);
|
|
135
140
|
process.exit(2);
|
|
@@ -884,6 +889,23 @@ try {
|
|
|
884
889
|
break;
|
|
885
890
|
}
|
|
886
891
|
|
|
892
|
+
case 'chair-context': {
|
|
893
|
+
const [inputsJson] = rest;
|
|
894
|
+
if (!inputsJson) usage('chair-context requires <chairMemberInputsJson>');
|
|
895
|
+
const inputs = JSON.parse(inputsJson) as ChairMemberInput[];
|
|
896
|
+
process.stdout.write(buildChairContext(inputs) + '\n');
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
case 'chair-verdict': {
|
|
901
|
+
const [rawJson, membersJson] = rest;
|
|
902
|
+
if (!rawJson || !membersJson) usage('chair-verdict requires <chairRawJson> <membersJson>');
|
|
903
|
+
const raw = JSON.parse(rawJson) as ChairRawVerdict;
|
|
904
|
+
const members = JSON.parse(membersJson) as MemberVerdict[];
|
|
905
|
+
process.stdout.write(JSON.stringify(finalizeChairVerdict(raw, members)) + '\n');
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
|
|
887
909
|
case 'set-task-field': {
|
|
888
910
|
const [repoRoot, taskId, field, value] = rest;
|
|
889
911
|
if (!repoRoot || !taskId || !field || value === undefined)
|
package/lib/council-config.ts
CHANGED
|
@@ -9,7 +9,8 @@ const DEFAULT_CONFIG = join(here, '..', 'config', 'council.json');
|
|
|
9
9
|
export type WhenPredicate = 'always' | 'security_class:high' | 'ui_changes';
|
|
10
10
|
|
|
11
11
|
export interface CouncilMember {
|
|
12
|
-
member: string; // built-in id
|
|
12
|
+
member: string; // built-in id ('reviewer' | 'security' | 'ui' | 'qa') or a custom role id
|
|
13
|
+
prompt?: string; // custom-role prompt filename, resolved under .cloverleaf/prompts/
|
|
13
14
|
when?: WhenPredicate; // default 'always'
|
|
14
15
|
blocking?: boolean; // default true
|
|
15
16
|
weight?: number; // default 1
|
|
@@ -17,7 +18,8 @@ export interface CouncilMember {
|
|
|
17
18
|
|
|
18
19
|
export interface CouncilProfile {
|
|
19
20
|
rounds: CouncilMember[][];
|
|
20
|
-
aggregation: ThresholdRule;
|
|
21
|
+
aggregation: ThresholdRule | 'chair';
|
|
22
|
+
chair?: { prompt?: string }; // only when aggregation === 'chair'; omit prompt → built-in chair.md
|
|
21
23
|
on_round_bounce?: 'stop' | 'continue'; // default 'stop'
|
|
22
24
|
}
|
|
23
25
|
|
package/lib/council-result.ts
CHANGED
|
@@ -14,11 +14,12 @@ export interface CouncilResultMember {
|
|
|
14
14
|
export interface CouncilResult {
|
|
15
15
|
gate: string;
|
|
16
16
|
final_verdict: Verdict;
|
|
17
|
-
rule: ThresholdRule;
|
|
17
|
+
rule: ThresholdRule | 'chair';
|
|
18
18
|
rationale: string;
|
|
19
19
|
members: CouncilResultMember[];
|
|
20
20
|
walk: string[]; // states walked, e.g. ["review","automated-gates","qa","final-gate"]
|
|
21
21
|
walk_note?: string; // set when a state was traversed administratively (e.g. qa with no qa member)
|
|
22
|
+
forward?: string[]; // chair-curated forwarded member ids (bounce only)
|
|
22
23
|
security: {
|
|
23
24
|
member_verdict: Verdict | 'absent';
|
|
24
25
|
gating_verdict_set: 'pass' | null; // security_review_verdict the council set, if any
|
package/lib/council.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadCouncilConfigWithSource, type CouncilConfig, type GateBinding, type WhenPredicate, type CouncilMember } from './council-config.js';
|
|
3
5
|
import type { ThresholdRule, CouncilVerdict } from './aggregation.js';
|
|
4
6
|
import { loadTask, saveTask, advanceStatus } from './task.js';
|
|
5
7
|
import { writeCouncilResult, type CouncilResult } from './council-result.js';
|
|
8
|
+
import { resolveChairPrompt } from './chair.js';
|
|
6
9
|
import { classifyTaskSecurity } from './security-classify.js';
|
|
7
10
|
import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.js';
|
|
11
|
+
import { getPluginRoot } from './plugin-path.js';
|
|
8
12
|
|
|
9
13
|
export interface ResolvedMember {
|
|
10
14
|
member: string;
|
|
11
15
|
blocking: boolean;
|
|
12
16
|
weight: number;
|
|
17
|
+
promptPath: string;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
export interface CouncilPlan {
|
|
@@ -17,7 +22,8 @@ export interface CouncilPlan {
|
|
|
17
22
|
profile: string | null; // null → no council bound (today's behavior)
|
|
18
23
|
mode: 'decisive' | 'advisory';
|
|
19
24
|
rounds: ResolvedMember[][];
|
|
20
|
-
aggregation: ThresholdRule;
|
|
25
|
+
aggregation: ThresholdRule | 'chair';
|
|
26
|
+
chair?: { promptPath: string }; // resolved iff aggregation === 'chair'
|
|
21
27
|
on_round_bounce: 'stop' | 'continue';
|
|
22
28
|
source: 'consumer' | 'default';
|
|
23
29
|
}
|
|
@@ -70,6 +76,35 @@ export function resolveChangedFiles(repoRoot: string, taskId: string, opts: { ch
|
|
|
70
76
|
}
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
const BUILTIN_PROMPTS: Record<string, string> = {
|
|
80
|
+
reviewer: 'reviewer.md',
|
|
81
|
+
security: 'security-reviewer.md',
|
|
82
|
+
ui: 'ui-reviewer.md',
|
|
83
|
+
qa: 'qa.md',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a council member to the absolute path of its prompt. A member with a
|
|
88
|
+
* `prompt` field is a custom role → <repoRoot>/.cloverleaf/prompts/<file> (exist-checked,
|
|
89
|
+
* since it is user-authored and easily mistyped); a bare built-in id → the shipped prompt
|
|
90
|
+
* under the plugin root, deliberately NOT exist-checked (built-ins ship with the plugin, so
|
|
91
|
+
* a missing one is a broken install that surfaces at prompt read-time, not user error).
|
|
92
|
+
*/
|
|
93
|
+
export function resolveMemberPrompt(member: CouncilMember, repoRoot: string): string {
|
|
94
|
+
if (member.prompt !== undefined) {
|
|
95
|
+
const p = join(repoRoot, '.cloverleaf', 'prompts', member.prompt);
|
|
96
|
+
if (!existsSync(p)) {
|
|
97
|
+
throw new Error(`council: custom member '${member.member}' prompt not found at ${p}`);
|
|
98
|
+
}
|
|
99
|
+
return p;
|
|
100
|
+
}
|
|
101
|
+
const builtin = BUILTIN_PROMPTS[member.member];
|
|
102
|
+
if (builtin === undefined) {
|
|
103
|
+
throw new Error(`council: unknown member '${member.member}' (no built-in prompt and no 'prompt' field)`);
|
|
104
|
+
}
|
|
105
|
+
return join(getPluginRoot(), 'prompts', builtin);
|
|
106
|
+
}
|
|
107
|
+
|
|
73
108
|
export function resolveCouncilPlan(
|
|
74
109
|
repoRoot: string,
|
|
75
110
|
taskId: string,
|
|
@@ -106,11 +141,16 @@ export function resolveCouncilPlan(
|
|
|
106
141
|
for (const round of profile.rounds) {
|
|
107
142
|
const active = round
|
|
108
143
|
.filter((member) => evaluateWhen(member.when, ctx))
|
|
109
|
-
.map((member) => ({
|
|
144
|
+
.map((member) => ({
|
|
145
|
+
member: member.member,
|
|
146
|
+
blocking: member.blocking !== false,
|
|
147
|
+
weight: member.weight ?? 1,
|
|
148
|
+
promptPath: resolveMemberPrompt(member, repoRoot),
|
|
149
|
+
}));
|
|
110
150
|
if (active.length > 0) rounds.push(active);
|
|
111
151
|
}
|
|
112
152
|
|
|
113
|
-
|
|
153
|
+
const plan: CouncilPlan = {
|
|
114
154
|
gate: gateKey,
|
|
115
155
|
profile: profileName,
|
|
116
156
|
mode,
|
|
@@ -119,6 +159,10 @@ export function resolveCouncilPlan(
|
|
|
119
159
|
on_round_bounce: profile.on_round_bounce ?? 'stop',
|
|
120
160
|
source,
|
|
121
161
|
};
|
|
162
|
+
if (profile.aggregation === 'chair') {
|
|
163
|
+
plan.chair = { promptPath: resolveChairPrompt(profile.chair, repoRoot) };
|
|
164
|
+
}
|
|
165
|
+
return plan;
|
|
122
166
|
}
|
|
123
167
|
|
|
124
168
|
/**
|
|
@@ -188,6 +232,7 @@ export function applyCouncilVerdict(
|
|
|
188
232
|
...(qaTraversedAdministratively
|
|
189
233
|
? { walk_note: 'qa state traversed administratively; no qa member ran' }
|
|
190
234
|
: {}),
|
|
235
|
+
...(council.forward !== undefined ? { forward: council.forward } : {}),
|
|
191
236
|
security: {
|
|
192
237
|
member_verdict: securityMember ? securityMember.verdict : 'absent',
|
|
193
238
|
gating_verdict_set: council.verdict === 'pass' ? 'pass' : null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
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/chair.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Chair Subagent
|
|
2
|
+
|
|
3
|
+
You are the Cloverleaf Council Chair. Your job: read every council member's verdict and feedback and render the **council's** verdict on this task's review gate. You judge the members' verdicts — you do NOT review the code yourself.
|
|
4
|
+
|
|
5
|
+
## Inputs
|
|
6
|
+
|
|
7
|
+
- `task`: the Cloverleaf Task document (JSON): {{task}}
|
|
8
|
+
- `repo_root`: absolute path to the consumer repo: {{repo_root}}
|
|
9
|
+
- `member_verdicts`: each council member's verdict, severity, and feedback (summary + findings). This is your evidence:
|
|
10
|
+
|
|
11
|
+
{{member_verdicts}}
|
|
12
|
+
|
|
13
|
+
## Your process
|
|
14
|
+
|
|
15
|
+
1. Read the task's `acceptance_criteria` and `definition_of_done` for context.
|
|
16
|
+
2. Weigh each member's verdict and findings. A member may have bounced on a non-substantive issue, or several members may point at the same underlying defect.
|
|
17
|
+
3. Decide the council verdict:
|
|
18
|
+
- `pass` — the members' concerns, taken together, do not warrant rework (e.g. a lone stylistic bounce).
|
|
19
|
+
- `bounce` — the branch needs rework. Choose which members' feedback the Implementer should act on.
|
|
20
|
+
- `escalate` — a hard blocker needs a human. You may **raise a bounce to escalate**; you can **never lower an escalate** — a member escalation is already final and never reaches you.
|
|
21
|
+
4. On a `bounce`, set `forward` to the ids of the members whose feedback the Implementer should prioritize. Forwarding fewer, higher-signal members beats forwarding all of them. Your `rationale` frames what to fix.
|
|
22
|
+
|
|
23
|
+
## Output
|
|
24
|
+
|
|
25
|
+
Return a single JSON object to stdout:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"verdict": "pass" | "bounce" | "escalate",
|
|
30
|
+
"rationale": "Why the council reached this verdict, and (on a bounce) what the Implementer should focus on.",
|
|
31
|
+
"forward": ["security", "qa"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- `forward` is only meaningful on a `bounce`; use `[]` (or omit) on `pass` / `escalate`.
|
|
36
|
+
- Every `forward` id MUST be a member present in the input above.
|
|
37
|
+
|
|
38
|
+
## Rules
|
|
39
|
+
|
|
40
|
+
- You review **verdicts, not code**. Do not open a diff or run tests; judge the members' reports.
|
|
41
|
+
- Do NOT modify any files. You are read-only.
|
|
42
|
+
- Each council member emits a `{verdict, summary, findings}` feedback envelope; an unparseable envelope has already been rejected by the orchestrator, so you can trust the envelopes you receive.
|
|
43
|
+
- Prefer a smaller, higher-signal `forward` set — the Implementer acts on what you forward.
|
package/prompts/implementer.md
CHANGED
|
@@ -24,7 +24,7 @@ You are the Cloverleaf Implementer agent. Your job: take a Task and produce work
|
|
|
24
24
|
|
|
25
25
|
**Scope nudge.** Your declared scope is `task.scope.files_touched`. You may freely modify any file listed there. If you discover during implementation that you need to touch a file outside that list, you may do so only if no sibling task in the same Plan declares that file — the walker auto-extends your scope on merge. If a file you need is already declared by a sibling task, that is a contested modification: stop, surface the conflict to the human, and do not merge. The walker enforces this at merge time and will refuse contested merges; auto-resolution is never attempted.
|
|
26
26
|
|
|
27
|
-
2. If `feedback` is present, re-read each finding; plan how to address them.
|
|
27
|
+
2. If `feedback` is present, re-read each finding; plan how to address them. If the prior bounce came from a chair council (`.cloverleaf/runs/<task.id>/council/task.review.json` has `rule: "chair"`), prioritize the members listed in its `forward` array and the chair's `rationale`.
|
|
28
28
|
3. Create a new branch named `cloverleaf/<task.id>` from `base_branch` using `git checkout -b cloverleaf/<task.id>`.
|
|
29
29
|
4. Implement the code + tests needed to satisfy every acceptance criterion.
|
|
30
30
|
5. Run the project's tests. Your test rules are provided as `{{test_rules}}` — a JSON object `{ rules: [...] }` whose `rules` is a list of `{cwd, match, command}` entries; each `match` is a list of glob patterns. For each rule whose `match` covers a file you changed, run its `command` in its `cwd`. All must pass. (If no rule matches your changes, there is nothing to run.)
|
|
@@ -130,19 +130,20 @@ Initialize `council_bounces = 0`.
|
|
|
130
130
|
|
|
131
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
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
|
|
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`
|
|
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`, and (for a chair profile) `plan.chair`. For each round **in order**, for each active member in the round, dispatch its prompt at `plan.rounds[<round>][<member>].promptPath` as a **read-only** subagent and capture its `{verdict, summary, findings}` envelope — do **not** advance state. (Built-in members resolve to the shipped `reviewer`/`security-reviewer`/`ui-reviewer`/`qa` prompts; a custom role resolves to `.cloverleaf/prompts/<file>.md`.)
|
|
138
134
|
|
|
139
135
|
**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
136
|
|
|
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
|
|
137
|
+
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=<prefix>`, where `<prefix>` is `r`/`s`/`u`/`q` for the built-ins and the **member id** for a custom role. Collect a members array `[{ "member": "<id>", "verdict": "<pass|bounce|escalate>", "blocking": <plan member blocking>, "weight": <plan member weight> }]`.
|
|
142
138
|
|
|
143
139
|
**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
140
|
|
|
145
|
-
7.3 **
|
|
141
|
+
7.3 **Reach the council verdict.**
|
|
142
|
+
- **If `plan.aggregation === "chair"`:**
|
|
143
|
+
- If **any member returned `escalate`**, the council verdict is `{"verdict":"escalate","rule":"chair","rationale":"escalated by <escalating member ids>","members":[<members array>]}` — do **not** dispatch the chair (a member escalate is final; the chair may raise a bounce to escalate but can never lower one).
|
|
144
|
+
- Else if **no blocking member bounced and none escalated** (all blocking members passed), the council verdict is `{"verdict":"pass","rule":"chair","rationale":"all blocking members passed; chair not convened","members":[<members array>],"forward":[]}` — do **not** dispatch the chair.
|
|
145
|
+
- Otherwise (a blocking member bounced; no member escalated) **dispatch the chair.** Build enriched inputs `[{ "member", "verdict", "blocking", "weight", "envelope": <the member's /tmp/clv-council-<member>.json object> }]`; run `context=$(cloverleaf-cli chair-context '<enriched-inputs-json>')`. Dispatch the chair prompt at `plan.chair.promptPath` as a **read-only** foreground subagent, substituting `{{task}}`, `{{repo_root}}`, and `{{member_verdicts}}` = `$context`; capture its `{verdict, rationale, forward}` output. Then run `cloverleaf-cli chair-verdict '<chair-raw-json>' '<members-json>'` and capture the council verdict JSON.
|
|
146
|
+
- **Else (deterministic):** map `plan.aggregation` to the CLI rule (a string passes through; `{ "quorum": k }` → `quorum:k`) and run `cloverleaf-cli aggregate-verdicts '<members-json>' <rule>`; capture the council verdict JSON.
|
|
146
147
|
|
|
147
148
|
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
|
|
|
@@ -151,7 +152,7 @@ Initialize `council_bounces = 0`.
|
|
|
151
152
|
- `implementing` (bounce) → `council_bounces += 1`. If `council_bounces >= 3`, escalate (section 6). Else return to 7.1.
|
|
152
153
|
- `escalated` → stop and surface to the user (review `.cloverleaf/feedback/` and `.cloverleaf/runs/<TASK-ID>/council/task.review.json`).
|
|
153
154
|
|
|
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
|
+
On a chair **bounce**, the result artifact's `forward` array names the members whose feedback the Implementer should prioritize; the chair `rationale` frames them. The council result artifact at `.cloverleaf/runs/<TASK-ID>/council/task.review.json` records per-member verdicts, the aggregate (or chair) verdict, `forward` (for a chair bounce), 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
156
|
|
|
156
157
|
## Rules
|
|
157
158
|
|