@cloverleaf/reference-impl 0.12.0 → 0.13.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/dist/council.mjs CHANGED
@@ -3,11 +3,15 @@ import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { loadCouncilConfigWithSource } from './council-config.mjs';
5
5
  import { loadTask, saveTask, advanceStatus } from './task.mjs';
6
+ import { loadPlan } from './plan.mjs';
7
+ import { loadRfc } from './rfc.mjs';
6
8
  import { writeCouncilResult } from './council-result.mjs';
7
9
  import { resolveChairPrompt } from './chair.mjs';
8
10
  import { classifyTaskSecurity } from './security-classify.mjs';
9
11
  import { writeFeedback } from './feedback.mjs';
10
12
  import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.mjs';
13
+ import { loadQaRulesDocument } from './qa-rules.mjs';
14
+ import { loadUiReviewConfig } from './ui-review-config.mjs';
11
15
  import { getPluginRoot } from './plugin-path.mjs';
12
16
  export function evaluateWhen(predicate, ctx) {
13
17
  switch (predicate) {
@@ -51,24 +55,46 @@ export function resolveChangedFiles(repoRoot, taskId, opts = {}) {
51
55
  return [];
52
56
  }
53
57
  }
54
- const BUILTIN_PROMPTS = {
58
+ /**
59
+ * The built-in council members and the shipped prompt each one resolves to.
60
+ * Exported as the single source of truth for "which members are built in":
61
+ * `tests/council.test.ts` walks it to assert every built-in prompt's declared
62
+ * tokens are covered by BASE_TOKENS ∪ MEMBER_TOKENS, so a prompt cannot gain a
63
+ * token that no one resolves.
64
+ */
65
+ export const BUILTIN_PROMPTS = {
55
66
  reviewer: 'reviewer.md',
56
67
  security: 'security-reviewer.md',
57
68
  ui: 'ui-reviewer.md',
58
69
  qa: 'qa.md',
59
70
  };
60
71
  /**
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".
72
+ * Council gate → FSM binding. `task.review` is the collapsed delivery council
73
+ * (decisive); `task.plan_review` is decisive-capable (agent bounce to pending);
74
+ * `task.final_gate` and the two discovery gates are advisory (post-only, human
75
+ * drives the transition). Advisory-only gates route to postAdvisoryVerdict.
66
76
  */
67
77
  export const GATE_DESCRIPTORS = {
68
- 'task.review': { state: 'review', advisoryOnly: false },
69
- 'task.plan_review': { state: 'tactical-plan', advisoryOnly: true },
78
+ 'task.review': { state: 'council', advisoryOnly: false },
79
+ 'task.plan_review': { state: 'tactical-plan', advisoryOnly: false },
70
80
  'task.final_gate': { state: 'final-gate', advisoryOnly: true },
81
+ 'plan.task_batch': { state: 'task_batch_gate', advisoryOnly: true, kind: 'plan' },
82
+ 'rfc.strategy_gate': { state: 'rfc_strategy_gate', advisoryOnly: true, kind: 'rfc' },
71
83
  };
84
+ // Load any work item's status/doc by the gate's "type." prefix (task | plan | rfc).
85
+ function workItemTypeOf(gate) {
86
+ const t = gate.split('.')[0];
87
+ if (t === 'task' || t === 'plan' || t === 'rfc')
88
+ return t;
89
+ throw new Error(`council: gate '${gate}' has no task/plan/rfc type prefix`);
90
+ }
91
+ function loadWorkItemDoc(repoRoot, type, id) {
92
+ if (type === 'plan')
93
+ return loadPlan(repoRoot, id);
94
+ if (type === 'rfc')
95
+ return loadRfc(repoRoot, id);
96
+ return loadTask(repoRoot, id);
97
+ }
72
98
  /**
73
99
  * Resolve a council member to the absolute path of its prompt. A member with a
74
100
  * `prompt` field is a custom role → <repoRoot>/.cloverleaf/prompts/<file> (exist-checked,
@@ -90,10 +116,84 @@ export function resolveMemberPrompt(member, repoRoot) {
90
116
  }
91
117
  return join(getPluginRoot(), 'prompts', builtin);
92
118
  }
93
- export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', opts = {}) {
119
+ /**
120
+ * Extra tokens each built-in member's prompt declares, beyond the five the runner
121
+ * always supplies. Kept adjacent to the resolver so a prompt gaining a token is a
122
+ * one-line change in TS with a test behind it, rather than silent drift in skill prose.
123
+ *
124
+ * `preview_port` is listed because `ui-reviewer.md` genuinely declares it — but it is
125
+ * deliberately not resolved (see `resolveSubstitutions`). This map is the prompts'
126
+ * contract; the resolver is the subset planning can answer honestly.
127
+ *
128
+ * `tests/council.test.ts` pins this map against the prompts themselves: every
129
+ * `{{token}}` a built-in prompt declares must appear here or in BASE_TOKENS. That
130
+ * completeness check is what makes `cloverleaf-run` §7.2's "never dispatch with an
131
+ * unresolved token" rule safe — otherwise a prompt-only token stalls its member.
132
+ */
133
+ export const MEMBER_TOKENS = {
134
+ reviewer: ['test_rules'],
135
+ security: [],
136
+ qa: ['qa_rules'],
137
+ ui: ['affected_routes', 'preview_port', 'ui_review_config', 'taskId'],
138
+ };
139
+ /**
140
+ * Resolve a member's extra prompt tokens. Side-effect free by contract: it reads
141
+ * config and reuses values the plan already computed, and never allocates, writes,
142
+ * or starts anything — `council-plan` is a query, and callers re-run it freely.
143
+ *
144
+ * A token that cannot be answered honestly at planning time is **omitted** rather
145
+ * than filled with a placeholder. An absent key leaves a visible `{{token}}` for
146
+ * whoever dispatches the member; a fabricated value is silently wrong, which is the
147
+ * exact failure mode this map exists to prevent.
148
+ */
149
+ function resolveSubstitutions(memberId, repoRoot, ctx) {
150
+ const out = {};
151
+ for (const token of MEMBER_TOKENS[memberId] ?? []) {
152
+ switch (token) {
153
+ // Both carry the qa-rules *document* (`{ rules: [...] }`) — the shape
154
+ // reviewer.md and qa.md document — not loadQaRulesConfig()'s bare array.
155
+ case 'test_rules':
156
+ case 'qa_rules':
157
+ out[token] = JSON.stringify(loadQaRulesDocument(repoRoot));
158
+ break;
159
+ case 'ui_review_config':
160
+ out[token] = JSON.stringify(loadUiReviewConfig(repoRoot));
161
+ break;
162
+ case 'affected_routes':
163
+ // Diff-dependent, so available only on a code gate; the plan already
164
+ // computed it to evaluate the `ui_changes` predicate.
165
+ if (ctx.affectedRoutes !== undefined)
166
+ out[token] = JSON.stringify(ctx.affectedRoutes);
167
+ break;
168
+ case 'taskId':
169
+ // The run-artifact directory ui-reviewer.md writes its state.json sidecar
170
+ // into. A pure value already in hand, so it is always resolved: leaving it
171
+ // literal makes lib/ui-review-state.ts read a path that cannot exist, and
172
+ // the baselines-hold then fails open.
173
+ out[token] = ctx.workItemId;
174
+ break;
175
+ case 'preview_port':
176
+ // Deliberately unresolved: there is no configured preview port and no
177
+ // side-effect-free way to derive one — lib/ports.ts offers only
178
+ // getFreePort(), which *allocates*. Whoever dispatches the ui member
179
+ // allocates it there, as the standalone ui-review skill does.
180
+ break;
181
+ default: {
182
+ // Exhaustiveness guard. A token added to MEMBER_TOKENS with no case above
183
+ // would otherwise be dropped silently — the exact drift this map exists to
184
+ // prevent — so make it a compile error instead.
185
+ const unhandled = token;
186
+ throw new Error(`council: no resolver for member token '${String(unhandled)}'`);
187
+ }
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ export function resolveCouncilPlan(repoRoot, workItemId, gateKey = 'task.review', opts = {}) {
94
193
  const { config, source } = loadCouncilConfigWithSource(repoRoot);
95
- const task = loadTask(repoRoot, taskId);
96
- const binding = resolveBinding(config.gates[gateKey], task);
194
+ const type = workItemTypeOf(gateKey);
195
+ const doc = loadWorkItemDoc(repoRoot, type, workItemId);
196
+ const binding = resolveBinding(config.gates[gateKey], doc);
97
197
  const profileName = binding.profile;
98
198
  const mode = GATE_DESCRIPTORS[gateKey]?.advisoryOnly ? 'advisory' : binding.mode;
99
199
  const empty = {
@@ -109,11 +209,17 @@ export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', op
109
209
  }
110
210
  return empty; // unknown profile → fail toward today's behavior
111
211
  }
112
- const changed = resolveChangedFiles(repoRoot, taskId, opts);
113
- const securityHigh = classifyTaskSecurity(repoRoot, taskId, { changedFiles: changed }).effective === 'high';
114
- const affected = computeAffectedRoutes(changed, loadAffectedRoutesConfig(repoRoot));
115
- const uiChanges = affected === 'all' || affected.length > 0;
116
- const ctx = { securityHigh, uiChanges };
212
+ // The when-context (security/ui) is a code-kind (task delivery) concern only.
213
+ // `affectedRoutes` outlives the predicate: the ui member's {{affected_routes}}
214
+ // token is the same value, so it is computed once and threaded to both.
215
+ let ctx = { securityHigh: false, uiChanges: false };
216
+ let affectedRoutes;
217
+ if (type === 'task') {
218
+ const changed = resolveChangedFiles(repoRoot, workItemId, opts);
219
+ const securityHigh = classifyTaskSecurity(repoRoot, workItemId, { changedFiles: changed }).effective === 'high';
220
+ affectedRoutes = computeAffectedRoutes(changed, loadAffectedRoutesConfig(repoRoot));
221
+ ctx = { securityHigh, uiChanges: affectedRoutes === 'all' || affectedRoutes.length > 0 };
222
+ }
117
223
  const rounds = [];
118
224
  for (const round of profile.rounds) {
119
225
  const active = round
@@ -123,6 +229,7 @@ export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', op
123
229
  blocking: member.blocking !== false,
124
230
  weight: member.weight ?? 1,
125
231
  promptPath: resolveMemberPrompt(member, repoRoot),
232
+ substitutions: resolveSubstitutions(member.member, repoRoot, { workItemId, affectedRoutes }),
126
233
  }));
127
234
  if (active.length > 0)
128
235
  rounds.push(active);
@@ -143,27 +250,36 @@ export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', op
143
250
  }
144
251
  /**
145
252
  * Drive the FSM transition implied by a council verdict (the runner's terminal step).
146
- * Council-authoritative: on a pass it records the council's gating verdict so the
147
- * v0.8.1 security precondition is satisfied for any high-security gated transition;
148
- * the per-member basis (incl. an omitted or out-voted `security` member) is written
149
- * to the result artifact. Walks the minimal legal path to the lane's pre-merge state.
253
+ * Routes by gate: `task.review` the collapsed decisive delivery council; the
254
+ * decisive `task.plan_review` the plan-review council; advisory-only gates
255
+ * (task.final_gate and the two discovery gates) postAdvisoryVerdict, which
256
+ * records the verdict and drives no transition.
150
257
  */
151
- export function applyCouncilVerdict(repoRoot, taskId, gate, council) {
258
+ export function applyCouncilVerdict(repoRoot, workItemId, gate, council) {
152
259
  const desc = GATE_DESCRIPTORS[gate];
153
260
  if (!desc) {
154
261
  throw new Error(`apply-council-verdict: gate '${gate}' is not supported; supported gates: ${Object.keys(GATE_DESCRIPTORS).join(', ')}.`);
155
262
  }
156
263
  if (desc.advisoryOnly) {
157
- return postAdvisoryVerdict(repoRoot, taskId, gate, desc.state, council);
264
+ return postAdvisoryVerdict(repoRoot, workItemId, gate, desc.state, council);
158
265
  }
159
- // Decisive gate (task.review) — the existing lane logic below is unchanged.
266
+ if (gate === 'task.plan_review') {
267
+ return applyDecisivePlanReview(repoRoot, workItemId, gate, council);
268
+ }
269
+ return applyDeliveryCouncil(repoRoot, workItemId, gate, council); // task.review — collapsed council phase
270
+ }
271
+ /** Decisive delivery council (task.review at the collapsed `council` state). */
272
+ function applyDeliveryCouncil(repoRoot, taskId, gate, council) {
160
273
  const task = loadTask(repoRoot, taskId);
161
- if (task.status !== 'review') {
162
- throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'review'`);
274
+ if (task.status !== 'council') {
275
+ throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'council'`);
163
276
  }
164
- const lane = task.risk_class === 'high' ? 'full' : 'fast';
165
277
  const securityMember = council.members.find((m) => m.member === 'security');
166
- const walk = ['review'];
278
+ const highSecurity = task.security_class === 'high';
279
+ // No security verdict to report? Ask the profile what it intended — before any
280
+ // transition mutates the task the gate binding selects a profile from.
281
+ const plannedSecurity = securityMember === undefined ? resolvePlannedSecurity(repoRoot, taskId, gate) : null;
282
+ const walk = ['council'];
167
283
  if (council.verdict === 'escalate') {
168
284
  advanceStatus(repoRoot, taskId, 'escalated', 'agent');
169
285
  walk.push('escalated');
@@ -173,42 +289,29 @@ export function applyCouncilVerdict(repoRoot, taskId, gate, council) {
173
289
  walk.push('implementing');
174
290
  }
175
291
  else {
176
- // pass minimal legal walk; review→automated-gates resets the security verdict,
177
- // so set the council's gating verdict AFTER that transition.
178
- advanceStatus(repoRoot, taskId, 'automated-gates', 'agent');
179
- walk.push('automated-gates');
180
- const atGates = loadTask(repoRoot, taskId);
181
- atGates.security_review_verdict = 'pass';
182
- saveTask(repoRoot, atGates);
183
- if (lane === 'full') {
184
- advanceStatus(repoRoot, taskId, 'qa', 'agent', { path: 'full_pipeline' });
185
- walk.push('qa');
186
- advanceStatus(repoRoot, taskId, 'final-gate', 'agent', { path: 'full_pipeline' });
187
- walk.push('final-gate');
292
+ // pass council final-gate; record the council's authoritative security verdict for high tasks
293
+ // (the v0.8.1 guarantee's backstop, now that the FSM no longer enforces it mechanically).
294
+ advanceStatus(repoRoot, taskId, 'final-gate', 'agent');
295
+ walk.push('final-gate');
296
+ if (highSecurity) {
297
+ const atGate = loadTask(repoRoot, taskId);
298
+ atGate.security_review_verdict = 'pass';
299
+ saveTask(repoRoot, atGate);
188
300
  }
189
301
  }
190
- const qaTraversedAdministratively = lane === 'full' && walk.includes('qa') && !council.members.some((m) => m.member === 'qa');
191
302
  const result = {
192
303
  gate,
193
304
  final_verdict: council.verdict,
194
305
  rule: council.rule,
195
306
  rationale: council.rationale,
196
- members: council.members.map((m) => ({
197
- member: m.member,
198
- verdict: m.verdict,
199
- blocking: m.blocking !== false,
200
- weight: m.weight ?? 1,
201
- })),
307
+ members: council.members.map((m) => ({ member: m.member, verdict: m.verdict, blocking: m.blocking !== false, weight: m.weight ?? 1 })),
202
308
  walk,
203
- ...(qaTraversedAdministratively
204
- ? { walk_note: 'qa state traversed administratively; no qa member ran' }
205
- : {}),
206
309
  ...(council.forward !== undefined ? { forward: council.forward } : {}),
207
310
  security: {
208
311
  member_verdict: securityMember ? securityMember.verdict : 'absent',
209
- gating_verdict_set: council.verdict === 'pass' ? 'pass' : null,
312
+ gating_verdict_set: council.verdict === 'pass' && highSecurity ? 'pass' : null,
210
313
  basis: !securityMember
211
- ? 'no security member configured; advanced under council authority'
314
+ ? securityBasisWhenAbsent(plannedSecurity, council)
212
315
  : securityMember.verdict === 'pass'
213
316
  ? 'security member passed'
214
317
  : `security member returned '${securityMember.verdict}'; council ${council.verdict} by rule ${JSON.stringify(council.rule)}`,
@@ -218,25 +321,143 @@ export function applyCouncilVerdict(repoRoot, taskId, gate, council) {
218
321
  return result;
219
322
  }
220
323
  /**
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.
324
+ * Resolve what the profile bound to `gate` planned for the `security` member on this task.
325
+ *
326
+ * `council.members` names only the members that actually *ran*, so a missing security
327
+ * verdict is ambiguous on its own. The resolved plan disambiguates it — with one honest
328
+ * limitation: `resolveCouncilPlan` filters `when`-inactive members out, so a member
329
+ * excluded by its `when` predicate and a profile that never listed one both come back
330
+ * `planned: false`. Both are truthfully "no security member for this task", so they share
331
+ * one basis string rather than a distinction this cannot actually make.
332
+ *
333
+ * Two deliberate properties:
334
+ * - Returns null rather than throwing. Plan resolution reads consumer config and can fail
335
+ * on, say, a mistyped member id; an audit string is not worth failing a terminal apply
336
+ * step over, and a run whose plan will not resolve says exactly that.
337
+ * - Called only when there is no security verdict to explain: for a task-kind gate
338
+ * `resolveCouncilPlan` runs a `git diff`, so this adds one git invocation to the apply
339
+ * path, and it should stay at most one.
340
+ */
341
+ function resolvePlannedSecurity(repoRoot, taskId, gate) {
342
+ try {
343
+ const plan = resolveCouncilPlan(repoRoot, taskId, gate);
344
+ // Earliest occurrence wins: if a member is planned twice, the first round is the
345
+ // earliest point the council could have run it.
346
+ const memberRounds = new Map();
347
+ plan.rounds.forEach((round, index) => {
348
+ for (const m of round)
349
+ if (!memberRounds.has(m.member))
350
+ memberRounds.set(m.member, index);
351
+ });
352
+ return {
353
+ securityRound: memberRounds.get('security') ?? null,
354
+ memberRounds,
355
+ stopsOnRoundBounce: plan.on_round_bounce === 'stop',
356
+ };
357
+ }
358
+ catch (err) {
359
+ process.stderr.write(`apply-council-verdict: could not resolve the council plan for gate '${gate}' on ${taskId} (${err instanceof Error ? err.message : String(err)}); the result artifact will record the security basis as unknown.\n`);
360
+ return null;
361
+ }
362
+ }
363
+ /**
364
+ * The `security.basis` for a run that recorded no security verdict. Each string states only
365
+ * what that run can prove, so a cause is named only when the run satisfies one of the
366
+ * council's two stop rules *in a round that could have preceded security's own*:
367
+ *
368
+ * - an `escalate` from any member stops the council immediately, so an escalator in an
369
+ * earlier round — or in security's own round, cutting off a concurrently dispatched
370
+ * member — explains the absence; one in a *later* round cannot, since that round ran only
371
+ * because security's round completed;
372
+ * - a **blocking** `bounce` stops the council "before the next round" under
373
+ * `on_round_bounce: stop`, so it explains the absence only from a *strictly earlier*
374
+ * round. A same-round bounce cannot un-dispatch a member already running alongside it.
375
+ *
376
+ * Anything else — a non-blocking bounce, a stop rule that fired too late, a member the
377
+ * runner never dispatched or whose envelope was lost — leaves the absence unexplained, and
378
+ * the artifact says so rather than naming a cause that would point the auditor away from
379
+ * the real anomaly.
227
380
  */
228
- export function postAdvisoryVerdict(repoRoot, taskId, gate, expectedState, council) {
381
+ function securityBasisWhenAbsent(planned, council) {
382
+ if (planned === null) {
383
+ return 'no security member ran; the council plan could not be resolved, so whether one was configured is unknown';
384
+ }
385
+ const securityRound = planned.securityRound;
386
+ if (securityRound === null) {
387
+ // Covers both a profile with no security member and one excluded by its `when`
388
+ // predicate — indistinguishable here, and the wording claims no more than that.
389
+ const outcome = council.verdict === 'bounce' ? 'council bounced to implementing'
390
+ : council.verdict === 'escalate' ? 'council escalated'
391
+ : 'advanced under council authority';
392
+ return `no security member in this task's resolved council plan; ${outcome}`;
393
+ }
394
+ // Did a stop rule fire in a round at/before `latestRound`? A member the plan does not
395
+ // name cannot be ordered against security's round, so it never explains the absence.
396
+ const stoppedBy = (verdict, latestRound, blockingOnly) => council.members.some((m) => {
397
+ const round = planned.memberRounds.get(m.member);
398
+ return m.verdict === verdict && round !== undefined && round <= latestRound && (!blockingOnly || m.blocking !== false);
399
+ });
400
+ if (stoppedBy('escalate', securityRound, false)) {
401
+ return 'security member configured for this task but not reached: another member escalated and the council short-circuited; no security verdict recorded';
402
+ }
403
+ if (planned.stopsOnRoundBounce && stoppedBy('bounce', securityRound - 1, true)) {
404
+ return "security member configured for this task but not reached: an earlier round's blocking member bounced and the profile stops on a round bounce; no security verdict recorded";
405
+ }
406
+ return 'security member configured for this task but did not run; no security verdict recorded';
407
+ }
408
+ /** Decisive plan-review council (task.plan_review at tactical-plan). */
409
+ function applyDecisivePlanReview(repoRoot, taskId, gate, council) {
229
410
  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}'`);
411
+ if (task.status !== 'tactical-plan') {
412
+ throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'tactical-plan' for gate '${gate}'`);
413
+ }
414
+ const walk = ['tactical-plan'];
415
+ if (council.verdict === 'escalate') {
416
+ advanceStatus(repoRoot, taskId, 'escalated', 'agent');
417
+ walk.push('escalated');
418
+ }
419
+ else if (council.verdict === 'bounce') {
420
+ advanceStatus(repoRoot, taskId, 'pending', 'agent', { gate: 'per_task_plan_review' });
421
+ walk.push('pending');
422
+ }
423
+ else {
424
+ advanceStatus(repoRoot, taskId, 'implementing', 'agent');
425
+ walk.push('implementing');
426
+ }
427
+ const result = {
428
+ gate,
429
+ final_verdict: council.verdict,
430
+ rule: council.rule,
431
+ rationale: council.rationale,
432
+ members: council.members.map((m) => ({ member: m.member, verdict: m.verdict, blocking: m.blocking !== false, weight: m.weight ?? 1 })),
433
+ walk,
434
+ ...(council.forward !== undefined ? { forward: council.forward } : {}),
435
+ };
436
+ writeCouncilResult(repoRoot, taskId, result);
437
+ return result;
438
+ }
439
+ /**
440
+ * Advisory-gate terminal step: record the council verdict + post a feedback
441
+ * envelope, and drive NO transition — the human owns every transition at an
442
+ * advisory gate. The verdict (including an escalate) is recorded verbatim;
443
+ * because nothing is transitioned, the un-lowerable-escalate invariant holds
444
+ * trivially. Generalized over work-item kind: the gate's `type.` prefix selects
445
+ * task | plan | rfc. Used for task.final_gate (final-gate) and the discovery
446
+ * gates plan.task_batch (task_batch_gate) / rfc.strategy_gate (rfc_strategy_gate).
447
+ */
448
+ export function postAdvisoryVerdict(repoRoot, workItemId, gate, expectedState, council) {
449
+ const type = workItemTypeOf(gate);
450
+ const status = String(loadWorkItemDoc(repoRoot, type, workItemId).status ?? '');
451
+ if (status !== expectedState) {
452
+ throw new Error(`apply-council-verdict: ${type} ${workItemId} is '${status}', expected '${expectedState}' for advisory gate '${gate}'`);
232
453
  }
233
- const m = taskId.match(/^(.+)-(\d+)$/);
454
+ const m = workItemId.match(/^(.+)-(\d+)$/);
234
455
  if (!m)
235
- throw new Error(`apply-council-verdict: invalid taskId '${taskId}'`);
456
+ throw new Error(`apply-council-verdict: invalid work-item id '${workItemId}'`);
236
457
  const project = m[1];
237
458
  writeFeedback(repoRoot, {
238
459
  project,
239
- taskId,
460
+ taskId: workItemId,
240
461
  prefix: 'c',
241
462
  envelope: { verdict: council.verdict, summary: council.rationale, findings: [] },
242
463
  });
@@ -256,6 +477,6 @@ export function postAdvisoryVerdict(repoRoot, taskId, gate, expectedState, counc
256
477
  walk_note: 'advisory: verdict posted; human drives the transition',
257
478
  ...(council.forward !== undefined ? { forward: council.forward } : {}),
258
479
  };
259
- writeCouncilResult(repoRoot, taskId, result);
480
+ writeCouncilResult(repoRoot, workItemId, result);
260
481
  return result;
261
482
  }
package/dist/events.mjs CHANGED
@@ -9,12 +9,7 @@ function actorObject(kind) {
9
9
  return { kind, id };
10
10
  }
11
11
  export function formatReason(opts) {
12
- const parts = [];
13
- if (opts.gate)
14
- parts.push(`gate=${opts.gate}`);
15
- if (opts.path)
16
- parts.push(`path=${opts.path}`);
17
- return parts.length > 0 ? parts.join('; ') : undefined;
12
+ return opts.gate ? `gate=${opts.gate}` : undefined;
18
13
  }
19
14
  /**
20
15
  * Emits a status-transition event to `.cloverleaf/events/`.
@@ -33,8 +28,8 @@ export function emitStatusTransition(repoRoot, params) {
33
28
  const seqStr = String(seq).padStart(3, '0');
34
29
  const filename = `${workItemId}-${seqStr}-status.json`;
35
30
  const filePath = join(eventsDir(repoRoot), filename);
36
- // Build reason from gate and/or path if provided (schema only allows reason, not gate/path at top level).
37
- const reason = formatReason({ gate: params.gate, path: params.path });
31
+ // Build reason from the gate if provided (the schema allows only `reason` at top level).
32
+ const reason = formatReason({ gate: params.gate });
38
33
  const doc = {
39
34
  event_id: randomUUID(),
40
35
  event_type: 'status_transition',
package/dist/ids.mjs CHANGED
@@ -22,6 +22,13 @@ export function nextEventId(repoRoot, workItemId) {
22
22
  // simultaneously. A global per-project counter (the pre-v0.6 scheme) produced
23
23
  // filename collisions when the walker merged sibling feature branches. Per-work-item
24
24
  // scoping means each task's counter is independent; merges union cleanly.
25
+ //
26
+ // The `(\d+)` segment is load-bearing, not cosmetic: `.cloverleaf/events/` in
27
+ // long-lived repos also contains pre-v0.6 files named `<PROJECT>-<NNN>-status.json`
28
+ // (a global counter), whose names collide with the task-id namespace — e.g.
29
+ // `CLV-109-status.json` is the 109th project event, not an event for task CLV-109.
30
+ // Loosening this regex to a bare prefix match would fold unrelated tasks' history
31
+ // into a task's counter, including transitions through states retired in 0.8.0.
25
32
  const re = new RegExp(`^${escapeRegex(workItemId)}-(\\d+)-(status|gate)\\.json$`);
26
33
  const nums = readdirSync(dir)
27
34
  .map((f) => f.match(re))
package/dist/qa-rules.mjs CHANGED
@@ -10,20 +10,36 @@ function loadDefaultRules() {
10
10
  const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
11
11
  return Array.isArray(doc.rules) ? doc.rules : [];
12
12
  }
13
- export function loadQaRulesConfig(repoRoot) {
13
+ /**
14
+ * The qa-rules document in the shape the prompts consume it: the `{ rules: [...] }`
15
+ * object, not a bare array. `reviewer.md` ({{test_rules}}), `implementer.md`
16
+ * ({{test_rules}}) and `qa.md` ({{qa_rules}}) all document the token as an object —
17
+ * 0.10.1 shipped a fix precisely because `qa.md` had described it as an array, and an
18
+ * agent iterating a non-existent top-level array is the bug that fix closed. Callers
19
+ * substituting one of those tokens must stringify THIS, never `loadQaRulesConfig()`.
20
+ *
21
+ * Precedence matches what the standalone skills `cat`: the consumer's
22
+ * `.cloverleaf/config/qa-rules.json` when it exists and parses to a `rules` array,
23
+ * otherwise the packaged default.
24
+ */
25
+ export function loadQaRulesDocument(repoRoot) {
14
26
  const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'qa-rules.json');
15
27
  if (existsSync(consumerPath)) {
16
28
  try {
17
29
  const doc = JSON.parse(readFileSync(consumerPath, 'utf-8'));
18
30
  if (Array.isArray(doc.rules)) {
19
- return doc.rules;
31
+ return { rules: doc.rules };
20
32
  }
21
33
  }
22
34
  catch {
23
35
  // fall through
24
36
  }
25
37
  }
26
- return loadDefaultRules();
38
+ return { rules: loadDefaultRules() };
39
+ }
40
+ /** The rules array alone, for callers that select/execute commands rather than prompt with them. */
41
+ export function loadQaRulesConfig(repoRoot) {
42
+ return loadQaRulesDocument(repoRoot).rules;
27
43
  }
28
44
  export function selectTestCommands(changedFiles, rules) {
29
45
  return rules.filter((rule) => matchesUiPaths(changedFiles, rule.match));
package/dist/task.mjs CHANGED
@@ -27,9 +27,10 @@ export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
27
27
  let task = loadTask(repoRoot, taskId);
28
28
  const from = task.status;
29
29
  const sm = loadStateMachine('task');
30
- const targetTransition = sm.transitions.find((t) => t.from === from && t.to === toStatus);
31
- // Security-gate writeback: classify the task's security and upgrade if needed.
32
- if (targetTransition?.security_gate) {
30
+ // Security classification at council entry: a declared-low task whose diff touches a
31
+ // sensitive path is upgraded to security_class:high so the delivery council runs its
32
+ // blocking security member. (v0.8.0: replaces the retired security_gate FSM annotation.)
33
+ if (from === 'documenting' && toStatus === 'council') {
33
34
  let classification;
34
35
  try {
35
36
  classification = classifyTaskSecurity(repoRoot, taskId);
@@ -58,27 +59,19 @@ export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
58
59
  task = { ...upgraded };
59
60
  }
60
61
  }
61
- const riskClass = options.path === 'fast_lane' ? 'low'
62
- : options.path === 'full_pipeline' ? 'high'
63
- : (task.risk_class ?? 'low');
64
62
  const workItemForValidator = {
65
63
  type: 'task',
66
64
  id: task.id,
67
65
  project: task.project,
68
66
  status: task.status,
69
- risk_class: riskClass,
67
+ risk_class: task.risk_class ?? 'low',
70
68
  security_class: task.security_class,
71
69
  security_review_verdict: task.security_review_verdict,
72
70
  context: { rfc: { project: task.project, id: task.id } },
73
71
  definition_of_done: task.definition_of_done,
74
72
  acceptance_criteria: task.acceptance_criteria,
75
73
  };
76
- const resetsVerdict = targetTransition?.resets_security_verdict === true;
77
- const proposed = {
78
- ...task,
79
- status: toStatus,
80
- ...(resetsVerdict ? { security_review_verdict: null } : {}),
81
- };
74
+ const proposed = { ...task, status: toStatus };
82
75
  advanceWorkItemStatus({
83
76
  repoRoot,
84
77
  workItemType: 'task',
@@ -92,18 +85,6 @@ export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
92
85
  save: (p) => saveTask(repoRoot, p),
93
86
  proposed,
94
87
  gate: options.gate,
95
- path: options.path,
96
88
  });
97
- // After a successful status change + verdict reset, emit a single commit covering both.
98
- if (resetsVerdict) {
99
- const taskFilePath = join(tasksDir(repoRoot), `${taskId}.json`);
100
- try {
101
- execFileSync('git', ['-C', repoRoot, 'add', taskFilePath], { stdio: 'pipe' });
102
- execFileSync('git', ['-C', repoRoot, 'commit', '-m', `cloverleaf: ${taskId} status ${from} → ${toStatus}; security_review_verdict → null (rework)`], { stdio: 'pipe' });
103
- }
104
- catch {
105
- // No-op: commit is best-effort when running outside a git repo (e.g., test environments).
106
- }
107
- }
108
89
  return proposed;
109
90
  }
@@ -10,8 +10,8 @@ export function loadStateMachine(type) {
10
10
  return JSON.parse(readFileSync(`${pkgDir}/state-machines/${type}.json`, 'utf-8'));
11
11
  }
12
12
  export function advanceWorkItemStatus(params) {
13
- const { repoRoot, workItemType, project, id, from, to, actor, stateMachine, validateFixture, save, gate, path } = params;
14
- const reason = formatReason({ gate, path });
13
+ const { repoRoot, workItemType, project, id, from, to, actor, stateMachine, validateFixture, save, gate } = params;
14
+ const reason = formatReason({ gate });
15
15
  const event = {
16
16
  event_id: randomUUID(),
17
17
  event_type: 'status_transition',
@@ -45,7 +45,6 @@ export function advanceWorkItemStatus(params) {
45
45
  to,
46
46
  actor,
47
47
  gate,
48
- path,
49
48
  });
50
49
  try {
51
50
  save(params.proposed);