@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.
@@ -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.11.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.11.1
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 { profile: profileName, mode } = resolveBinding(config.gates[gateKey], task);
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
- if (gate !== 'task.review') {
138
- throw new Error(`apply-council-verdict: gate '${gate}' is not supported yet — the FSM walk is hardcoded for the ` +
139
- `task.review merge lane. Binding other gates needs a gate-aware walk (council Slice 3).`);
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
+ }
@@ -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 (e.g. qa with no qa member)
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 { profile: profileName, mode } = resolveBinding(config.gates[gateKey], task);
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
- if (gate !== 'task.review') {
203
+ const desc = GATE_DESCRIPTORS[gate];
204
+ if (!desc) {
182
205
  throw new Error(
183
- `apply-council-verdict: gate '${gate}' is not supported yet the FSM walk is hardcoded for the ` +
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.11.1",
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**, 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`.)
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.