@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.
@@ -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.10.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.10.1
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) => ({ member: member.member, blocking: member.blocking !== false, weight: member.weight ?? 1 }));
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
- return {
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,
@@ -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)
@@ -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: 'reviewer' | 'security' | 'ui' | 'qa'
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
 
@@ -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 { loadCouncilConfigWithSource, type CouncilConfig, type GateBinding, type WhenPredicate } from './council-config.js';
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) => ({ member: member.member, blocking: member.blocking !== false, weight: member.weight ?? 1 }));
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
- return {
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.10.1",
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",
@@ -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.
@@ -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 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`
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|s|u|q>`. Collect a members array `[{ "member": "<id>", "verdict": "<pass|bounce|escalate>", "blocking": <plan member blocking>, "weight": <plan member weight> }]`.
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 **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.
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