@cloverleaf/reference-impl 0.11.1 → 0.12.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/dist/council.mjs +65 -4
- package/lib/council-result.ts +4 -3
- package/lib/council.ts +80 -4
- package/package.json +1 -1
- package/skills/cloverleaf-run/SKILL.md +13 -2
|
@@ -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.12.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.12.0
|
package/dist/council.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { loadTask, saveTask, advanceStatus } from './task.mjs';
|
|
|
6
6
|
import { writeCouncilResult } from './council-result.mjs';
|
|
7
7
|
import { resolveChairPrompt } from './chair.mjs';
|
|
8
8
|
import { classifyTaskSecurity } from './security-classify.mjs';
|
|
9
|
+
import { writeFeedback } from './feedback.mjs';
|
|
9
10
|
import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.mjs';
|
|
10
11
|
import { getPluginRoot } from './plugin-path.mjs';
|
|
11
12
|
export function evaluateWhen(predicate, ctx) {
|
|
@@ -56,6 +57,18 @@ const BUILTIN_PROMPTS = {
|
|
|
56
57
|
ui: 'ui-reviewer.md',
|
|
57
58
|
qa: 'qa.md',
|
|
58
59
|
};
|
|
60
|
+
/**
|
|
61
|
+
* Council gate → FSM binding (parent-spec §8). The declarative binding layer,
|
|
62
|
+
* NOT an FSM interpreter: the one decisive gate's transitions remain the lane
|
|
63
|
+
* logic in applyCouncilVerdict. `advisoryOnly` gates (plan_review's reject,
|
|
64
|
+
* final_gate's merge/reject are human-only) are forced to advisory regardless
|
|
65
|
+
* of the binding — a fail-safe honoring "human gates are always advisory".
|
|
66
|
+
*/
|
|
67
|
+
export const GATE_DESCRIPTORS = {
|
|
68
|
+
'task.review': { state: 'review', advisoryOnly: false },
|
|
69
|
+
'task.plan_review': { state: 'tactical-plan', advisoryOnly: true },
|
|
70
|
+
'task.final_gate': { state: 'final-gate', advisoryOnly: true },
|
|
71
|
+
};
|
|
59
72
|
/**
|
|
60
73
|
* Resolve a council member to the absolute path of its prompt. A member with a
|
|
61
74
|
* `prompt` field is a custom role → <repoRoot>/.cloverleaf/prompts/<file> (exist-checked,
|
|
@@ -80,7 +93,9 @@ export function resolveMemberPrompt(member, repoRoot) {
|
|
|
80
93
|
export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', opts = {}) {
|
|
81
94
|
const { config, source } = loadCouncilConfigWithSource(repoRoot);
|
|
82
95
|
const task = loadTask(repoRoot, taskId);
|
|
83
|
-
const
|
|
96
|
+
const binding = resolveBinding(config.gates[gateKey], task);
|
|
97
|
+
const profileName = binding.profile;
|
|
98
|
+
const mode = GATE_DESCRIPTORS[gateKey]?.advisoryOnly ? 'advisory' : binding.mode;
|
|
84
99
|
const empty = {
|
|
85
100
|
gate: gateKey, profile: null, mode, rounds: [],
|
|
86
101
|
aggregation: 'any-veto', on_round_bounce: 'stop', source,
|
|
@@ -134,10 +149,14 @@ export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', op
|
|
|
134
149
|
* to the result artifact. Walks the minimal legal path to the lane's pre-merge state.
|
|
135
150
|
*/
|
|
136
151
|
export function applyCouncilVerdict(repoRoot, taskId, gate, council) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
152
|
+
const desc = GATE_DESCRIPTORS[gate];
|
|
153
|
+
if (!desc) {
|
|
154
|
+
throw new Error(`apply-council-verdict: gate '${gate}' is not supported; supported gates: ${Object.keys(GATE_DESCRIPTORS).join(', ')}.`);
|
|
140
155
|
}
|
|
156
|
+
if (desc.advisoryOnly) {
|
|
157
|
+
return postAdvisoryVerdict(repoRoot, taskId, gate, desc.state, council);
|
|
158
|
+
}
|
|
159
|
+
// Decisive gate (task.review) — the existing lane logic below is unchanged.
|
|
141
160
|
const task = loadTask(repoRoot, taskId);
|
|
142
161
|
if (task.status !== 'review') {
|
|
143
162
|
throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'review'`);
|
|
@@ -198,3 +217,45 @@ export function applyCouncilVerdict(repoRoot, taskId, gate, council) {
|
|
|
198
217
|
writeCouncilResult(repoRoot, taskId, result);
|
|
199
218
|
return result;
|
|
200
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Advisory-gate terminal step (Slice 3): record the council verdict + post a
|
|
222
|
+
* feedback envelope, and drive NO transition — the human owns every transition
|
|
223
|
+
* at an advisory gate. The verdict (including an escalate) is recorded verbatim;
|
|
224
|
+
* because nothing is transitioned, the un-lowerable-escalate invariant holds
|
|
225
|
+
* trivially. Used for task.plan_review (at tactical-plan) and task.final_gate
|
|
226
|
+
* (at final-gate) — both advisory-only in the current FSM.
|
|
227
|
+
*/
|
|
228
|
+
export function postAdvisoryVerdict(repoRoot, taskId, gate, expectedState, council) {
|
|
229
|
+
const task = loadTask(repoRoot, taskId);
|
|
230
|
+
if (task.status !== expectedState) {
|
|
231
|
+
throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected '${expectedState}' for advisory gate '${gate}'`);
|
|
232
|
+
}
|
|
233
|
+
const m = taskId.match(/^(.+)-(\d+)$/);
|
|
234
|
+
if (!m)
|
|
235
|
+
throw new Error(`apply-council-verdict: invalid taskId '${taskId}'`);
|
|
236
|
+
const project = m[1];
|
|
237
|
+
writeFeedback(repoRoot, {
|
|
238
|
+
project,
|
|
239
|
+
taskId,
|
|
240
|
+
prefix: 'c',
|
|
241
|
+
envelope: { verdict: council.verdict, summary: council.rationale, findings: [] },
|
|
242
|
+
});
|
|
243
|
+
const result = {
|
|
244
|
+
gate,
|
|
245
|
+
mode: 'advisory',
|
|
246
|
+
final_verdict: council.verdict,
|
|
247
|
+
rule: council.rule,
|
|
248
|
+
rationale: council.rationale,
|
|
249
|
+
members: council.members.map((mm) => ({
|
|
250
|
+
member: mm.member,
|
|
251
|
+
verdict: mm.verdict,
|
|
252
|
+
blocking: mm.blocking !== false,
|
|
253
|
+
weight: mm.weight ?? 1,
|
|
254
|
+
})),
|
|
255
|
+
walk: [expectedState],
|
|
256
|
+
walk_note: 'advisory: verdict posted; human drives the transition',
|
|
257
|
+
...(council.forward !== undefined ? { forward: council.forward } : {}),
|
|
258
|
+
};
|
|
259
|
+
writeCouncilResult(repoRoot, taskId, result);
|
|
260
|
+
return result;
|
|
261
|
+
}
|
package/lib/council-result.ts
CHANGED
|
@@ -13,14 +13,15 @@ export interface CouncilResultMember {
|
|
|
13
13
|
|
|
14
14
|
export interface CouncilResult {
|
|
15
15
|
gate: string;
|
|
16
|
+
mode?: 'decisive' | 'advisory'; // omitted ⇒ decisive (back-compat); 'advisory' for posted-only gates
|
|
16
17
|
final_verdict: Verdict;
|
|
17
18
|
rule: ThresholdRule | 'chair';
|
|
18
19
|
rationale: string;
|
|
19
20
|
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
|
|
21
|
+
walk: string[]; // states walked, e.g. ["review","automated-gates","qa","final-gate"]; [state] for advisory
|
|
22
|
+
walk_note?: string; // set when a state was traversed administratively, or the advisory note
|
|
22
23
|
forward?: string[]; // chair-curated forwarded member ids (bounce only)
|
|
23
|
-
security
|
|
24
|
+
security?: { // present for the decisive task.review lane; omitted for advisory gates
|
|
24
25
|
member_verdict: Verdict | 'absent';
|
|
25
26
|
gating_verdict_set: 'pass' | null; // security_review_verdict the council set, if any
|
|
26
27
|
basis: string; // human-readable explanation of the security decision
|
package/lib/council.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { loadTask, saveTask, advanceStatus } from './task.js';
|
|
|
7
7
|
import { writeCouncilResult, type CouncilResult } from './council-result.js';
|
|
8
8
|
import { resolveChairPrompt } from './chair.js';
|
|
9
9
|
import { classifyTaskSecurity } from './security-classify.js';
|
|
10
|
+
import { writeFeedback } from './feedback.js';
|
|
10
11
|
import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.js';
|
|
11
12
|
import { getPluginRoot } from './plugin-path.js';
|
|
12
13
|
|
|
@@ -83,6 +84,24 @@ const BUILTIN_PROMPTS: Record<string, string> = {
|
|
|
83
84
|
qa: 'qa.md',
|
|
84
85
|
};
|
|
85
86
|
|
|
87
|
+
interface GateDescriptor {
|
|
88
|
+
state: string; // the task status a gate's council runs at
|
|
89
|
+
advisoryOnly: boolean; // true when the gate's only legal transitions are human-driven
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Council gate → FSM binding (parent-spec §8). The declarative binding layer,
|
|
94
|
+
* NOT an FSM interpreter: the one decisive gate's transitions remain the lane
|
|
95
|
+
* logic in applyCouncilVerdict. `advisoryOnly` gates (plan_review's reject,
|
|
96
|
+
* final_gate's merge/reject are human-only) are forced to advisory regardless
|
|
97
|
+
* of the binding — a fail-safe honoring "human gates are always advisory".
|
|
98
|
+
*/
|
|
99
|
+
export const GATE_DESCRIPTORS: Record<string, GateDescriptor> = {
|
|
100
|
+
'task.review': { state: 'review', advisoryOnly: false },
|
|
101
|
+
'task.plan_review': { state: 'tactical-plan', advisoryOnly: true },
|
|
102
|
+
'task.final_gate': { state: 'final-gate', advisoryOnly: true },
|
|
103
|
+
};
|
|
104
|
+
|
|
86
105
|
/**
|
|
87
106
|
* Resolve a council member to the absolute path of its prompt. A member with a
|
|
88
107
|
* `prompt` field is a custom role → <repoRoot>/.cloverleaf/prompts/<file> (exist-checked,
|
|
@@ -114,7 +133,10 @@ export function resolveCouncilPlan(
|
|
|
114
133
|
const { config, source } = loadCouncilConfigWithSource(repoRoot);
|
|
115
134
|
const task = loadTask(repoRoot, taskId) as unknown as Record<string, unknown>;
|
|
116
135
|
|
|
117
|
-
const
|
|
136
|
+
const binding = resolveBinding(config.gates[gateKey], task);
|
|
137
|
+
const profileName = binding.profile;
|
|
138
|
+
const mode: 'decisive' | 'advisory' =
|
|
139
|
+
GATE_DESCRIPTORS[gateKey]?.advisoryOnly ? 'advisory' : binding.mode;
|
|
118
140
|
const empty: CouncilPlan = {
|
|
119
141
|
gate: gateKey, profile: null, mode, rounds: [],
|
|
120
142
|
aggregation: 'any-veto', on_round_bounce: 'stop', source,
|
|
@@ -178,12 +200,16 @@ export function applyCouncilVerdict(
|
|
|
178
200
|
gate: string,
|
|
179
201
|
council: CouncilVerdict,
|
|
180
202
|
): CouncilResult {
|
|
181
|
-
|
|
203
|
+
const desc = GATE_DESCRIPTORS[gate];
|
|
204
|
+
if (!desc) {
|
|
182
205
|
throw new Error(
|
|
183
|
-
`apply-council-verdict: gate '${gate}' is not supported
|
|
184
|
-
`task.review → merge lane. Binding other gates needs a gate-aware walk (council Slice 3).`,
|
|
206
|
+
`apply-council-verdict: gate '${gate}' is not supported; supported gates: ${Object.keys(GATE_DESCRIPTORS).join(', ')}.`,
|
|
185
207
|
);
|
|
186
208
|
}
|
|
209
|
+
if (desc.advisoryOnly) {
|
|
210
|
+
return postAdvisoryVerdict(repoRoot, taskId, gate, desc.state, council);
|
|
211
|
+
}
|
|
212
|
+
// Decisive gate (task.review) — the existing lane logic below is unchanged.
|
|
187
213
|
const task = loadTask(repoRoot, taskId);
|
|
188
214
|
if (task.status !== 'review') {
|
|
189
215
|
throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'review'`);
|
|
@@ -246,3 +272,53 @@ export function applyCouncilVerdict(
|
|
|
246
272
|
writeCouncilResult(repoRoot, taskId, result);
|
|
247
273
|
return result;
|
|
248
274
|
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Advisory-gate terminal step (Slice 3): record the council verdict + post a
|
|
278
|
+
* feedback envelope, and drive NO transition — the human owns every transition
|
|
279
|
+
* at an advisory gate. The verdict (including an escalate) is recorded verbatim;
|
|
280
|
+
* because nothing is transitioned, the un-lowerable-escalate invariant holds
|
|
281
|
+
* trivially. Used for task.plan_review (at tactical-plan) and task.final_gate
|
|
282
|
+
* (at final-gate) — both advisory-only in the current FSM.
|
|
283
|
+
*/
|
|
284
|
+
export function postAdvisoryVerdict(
|
|
285
|
+
repoRoot: string,
|
|
286
|
+
taskId: string,
|
|
287
|
+
gate: string,
|
|
288
|
+
expectedState: string,
|
|
289
|
+
council: CouncilVerdict,
|
|
290
|
+
): CouncilResult {
|
|
291
|
+
const task = loadTask(repoRoot, taskId);
|
|
292
|
+
if (task.status !== expectedState) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`apply-council-verdict: task ${taskId} is '${task.status}', expected '${expectedState}' for advisory gate '${gate}'`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
const m = taskId.match(/^(.+)-(\d+)$/);
|
|
298
|
+
if (!m) throw new Error(`apply-council-verdict: invalid taskId '${taskId}'`);
|
|
299
|
+
const project = m[1];
|
|
300
|
+
writeFeedback(repoRoot, {
|
|
301
|
+
project,
|
|
302
|
+
taskId,
|
|
303
|
+
prefix: 'c',
|
|
304
|
+
envelope: { verdict: council.verdict, summary: council.rationale, findings: [] },
|
|
305
|
+
});
|
|
306
|
+
const result: CouncilResult = {
|
|
307
|
+
gate,
|
|
308
|
+
mode: 'advisory',
|
|
309
|
+
final_verdict: council.verdict,
|
|
310
|
+
rule: council.rule,
|
|
311
|
+
rationale: council.rationale,
|
|
312
|
+
members: council.members.map((mm) => ({
|
|
313
|
+
member: mm.member,
|
|
314
|
+
verdict: mm.verdict,
|
|
315
|
+
blocking: mm.blocking !== false,
|
|
316
|
+
weight: mm.weight ?? 1,
|
|
317
|
+
})),
|
|
318
|
+
walk: [expectedState],
|
|
319
|
+
walk_note: 'advisory: verdict posted; human drives the transition',
|
|
320
|
+
...(council.forward !== undefined ? { forward: council.forward } : {}),
|
|
321
|
+
};
|
|
322
|
+
writeCouncilResult(repoRoot, taskId, result);
|
|
323
|
+
return result;
|
|
324
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -116,7 +116,7 @@ Loop:
|
|
|
116
116
|
c. If `status === "implementing"`: QA bounced. `qa_bounces += 1`. If `qa_bounces >= MAX_QA_BOUNCES`, escalate. Else return to section 5.1.
|
|
117
117
|
d. Else: unexpected. Report and stop.
|
|
118
118
|
|
|
119
|
-
5.4. **Final merge:** Inline `/cloverleaf-merge <TASK-ID>` steps (branches to full-pipeline gate per state).
|
|
119
|
+
5.4. **Final merge:** First run the **Advisory `final_gate` council** (§7.6) if a consumer has bound `task.final_gate`. Inline `/cloverleaf-merge <TASK-ID>` steps (branches to full-pipeline gate per state).
|
|
120
120
|
|
|
121
121
|
### 6. Escalation
|
|
122
122
|
|
|
@@ -130,7 +130,7 @@ 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`, and (for a chair profile) `plan.chair`. For each round **in order
|
|
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**: dispatch **all active members in the round concurrently** — issue their Task-tool calls **in a single message** so the harness runs them in parallel — and capture each member's `{verdict, summary, findings}` envelope. Do **not** advance state. Rounds still run in sequence; only members *within* a round are concurrent. (Built-in members resolve to the shipped `reviewer`/`security-reviewer`/`ui-reviewer`/`qa` prompts; a custom role resolves to `.cloverleaf/prompts/<file>.md`.)
|
|
134
134
|
|
|
135
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/'`).
|
|
136
136
|
|
|
@@ -154,6 +154,17 @@ Initialize `council_bounces = 0`.
|
|
|
154
154
|
|
|
155
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.
|
|
156
156
|
|
|
157
|
+
### 7.6 Advisory `final_gate` council (opt-in; full pipeline only)
|
|
158
|
+
|
|
159
|
+
`final-gate` is reached only in the full pipeline and is already the human merge pause. Before inlining `/cloverleaf-merge <TASK-ID>` at a full-pipeline final gate (both here at 7.5 and at §5.4), check for an advisory council:
|
|
160
|
+
|
|
161
|
+
1. `cloverleaf-cli council-plan <repo_root> <TASK-ID> task.final_gate`.
|
|
162
|
+
2. If `plan.source !== "consumer"` or `plan.profile === null`, skip — proceed to the plain human merge (today's behavior).
|
|
163
|
+
3. Otherwise dispatch `plan.rounds` per §7.2 (parallel within a round), reviewing `{{diff}}` = `git diff main..cloverleaf/<TASK-ID> -- ':(exclude).cloverleaf/'`, and reach a verdict per §7.3 (chair) or §7.4-style `aggregate-verdicts` (deterministic). Then `cloverleaf-cli apply-council-verdict <repo_root> <TASK-ID> task.final_gate '<council-verdict-json>'`. This **posts** the advisory result to `.cloverleaf/runs/<TASK-ID>/council/task.final_gate.json` + a feedback envelope and **drives no transition** (the task stays at `final-gate`). Commit: `git add .cloverleaf/ && (git diff --cached --quiet || git commit -m "cloverleaf: <TASK-ID> advisory final_gate council (<verdict>)")`.
|
|
164
|
+
4. Surface the council verdict + rationale to the human at the merge confirmation. The human still drives `/cloverleaf-merge` (merge) or reject; the advisory council never merges.
|
|
165
|
+
|
|
166
|
+
The **fast lane's** `human_merge` (`automated-gates → merged`) is not a council gate. `task.plan_review` (advisory, at `tactical-plan`) is supported at the CLI/library level (`council-plan task.plan_review`, `apply-council-verdict task.plan_review`) for a consumer with a human checkpoint at `tactical-plan`; it is not auto-inserted into this autonomous runner.
|
|
167
|
+
|
|
157
168
|
## Rules
|
|
158
169
|
|
|
159
170
|
- Each agent has its own 3-bounce budget. Bounces from different agents do NOT share counters.
|