@cloverleaf/reference-impl 0.11.1 → 0.13.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/lib/cli.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * load-task <repoRoot> <taskId>
9
9
  * infer-project <repoRoot>
10
10
  * next-task-id <repoRoot> [--project=<p>]
11
- * advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]
11
+ * advance-status <repoRoot> <taskId> <toStatus> <actor> [gate]
12
12
  * write-feedback <repoRoot> <taskId> <envelopeJsonPath>
13
13
  * latest-feedback <repoRoot> <taskId>
14
14
  * emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
@@ -47,6 +47,7 @@
47
47
  * apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>
48
48
  * chair-context <chairMemberInputsJson>
49
49
  * chair-verdict <chairRawJson> <membersJson>
50
+ * validate-council <repoRoot>
50
51
  */
51
52
 
52
53
  import { readFileSync, mkdirSync, copyFileSync, appendFileSync, existsSync } from 'node:fs';
@@ -80,7 +81,9 @@ import type { SiblingScope } from './scope-check.js';
80
81
  import { computeRfcTasksView, type RfcTasksView } from './rfc-tasks.js';
81
82
  import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.js';
82
83
  import { classifyTaskSecurity } from './security-classify.js';
83
- import { resolveCouncilPlan, applyCouncilVerdict } from './council.js';
84
+ import { resolveCouncilPlan, applyCouncilVerdict, GATE_DESCRIPTORS } from './council.js';
85
+ import { loadCouncilConfigWithSource } from './council-config.js';
86
+ import { validateCouncilConfig } from '@cloverleaf/standard/validators/index.js';
84
87
  import { aggregate, type MemberVerdict, type ThresholdRule, type CouncilVerdict } from './aggregation.js';
85
88
  import { buildChairContext, finalizeChairVerdict, type ChairMemberInput, type ChairRawVerdict } from './chair.js';
86
89
 
@@ -97,7 +100,7 @@ function usage(msg?: string): never {
97
100
  ' load-task <repoRoot> <taskId>\n' +
98
101
  ' infer-project <repoRoot>\n' +
99
102
  ' next-task-id <repoRoot> [--project=<p>]\n' +
100
- ' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
103
+ ' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate]\n' +
101
104
  ' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
102
105
  ' latest-feedback <repoRoot> <taskId>\n' +
103
106
  ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
@@ -135,7 +138,8 @@ function usage(msg?: string): never {
135
138
  ' apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>\n' +
136
139
  ' chair-context <chairMemberInputsJson>\n' +
137
140
  ' chair-verdict <chairRawJson> <membersJson>\n' +
138
- ' set-task-field <repoRoot> <taskId> <field> <value>\n'
141
+ ' set-task-field <repoRoot> <taskId> <field> <value>\n' +
142
+ ' validate-council <repoRoot>\n'
139
143
  );
140
144
  process.exit(2);
141
145
  }
@@ -182,16 +186,15 @@ try {
182
186
  }
183
187
 
184
188
  case 'advance-status': {
185
- const [repoRoot, taskId, toStatus, actorArg, gate, path] = rest;
189
+ const [repoRoot, taskId, toStatus, actorArg, gate] = rest;
186
190
  if (!repoRoot || !taskId || !toStatus || !actorArg)
187
- usage('advance-status requires <repoRoot> <taskId> <toStatus> <actor> [gate] [path]');
191
+ usage('advance-status requires <repoRoot> <taskId> <toStatus> <actor> [gate]');
188
192
  if (actorArg !== 'agent' && actorArg !== 'human') {
189
193
  die(`actor must be 'agent' or 'human' (got '${actorArg}')`, 2);
190
194
  }
191
195
  const actor: 'agent' | 'human' = actorArg;
192
- const opts: { gate?: string; path?: 'fast_lane' | 'full_pipeline' } = {};
196
+ const opts: { gate?: string } = {};
193
197
  if (gate) opts.gate = gate;
194
- if (path === 'fast_lane' || path === 'full_pipeline') opts.path = path;
195
198
  const updated = advanceStatus(repoRoot, taskId, toStatus, actor, opts);
196
199
  process.stdout.write(updated.status + '\n');
197
200
  break;
@@ -923,6 +926,23 @@ try {
923
926
  break;
924
927
  }
925
928
 
929
+ case 'validate-council': {
930
+ const [repoRoot] = rest;
931
+ if (!repoRoot) usage('validate-council requires <repoRoot>');
932
+ const { config } = loadCouncilConfigWithSource(repoRoot);
933
+ const gd = Object.fromEntries(
934
+ Object.entries(GATE_DESCRIPTORS).map(([k, d]) => [k, { kind: d.kind ?? 'code' }]),
935
+ );
936
+ const result = validateCouncilConfig(config as never, gd);
937
+ if (result.ok) {
938
+ process.stdout.write('council config OK\n');
939
+ } else {
940
+ for (const v of result.violations) process.stderr.write(`${v.rule}: ${v.message}\n`);
941
+ process.exit(1);
942
+ }
943
+ break;
944
+ }
945
+
926
946
  default:
927
947
  usage(`Unknown command: ${command}`);
928
948
  }
@@ -11,6 +11,7 @@ export type WhenPredicate = 'always' | 'security_class:high' | 'ui_changes';
11
11
  export interface CouncilMember {
12
12
  member: string; // built-in id ('reviewer' | 'security' | 'ui' | 'qa') or a custom role id
13
13
  prompt?: string; // custom-role prompt filename, resolved under .cloverleaf/prompts/
14
+ kind?: 'code' | 'rfc' | 'plan'; // artifact kind this member reviews; default 'code'. A profile must be kind-homogeneous.
14
15
  when?: WhenPredicate; // default 'always'
15
16
  blocking?: boolean; // default true
16
17
  weight?: number; // default 1
@@ -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
@@ -4,10 +4,15 @@ import { join } from 'node:path';
4
4
  import { loadCouncilConfigWithSource, type CouncilConfig, type GateBinding, type WhenPredicate, type CouncilMember } from './council-config.js';
5
5
  import type { ThresholdRule, CouncilVerdict } from './aggregation.js';
6
6
  import { loadTask, saveTask, advanceStatus } from './task.js';
7
+ import { loadPlan } from './plan.js';
8
+ import { loadRfc } from './rfc.js';
7
9
  import { writeCouncilResult, type CouncilResult } from './council-result.js';
8
10
  import { resolveChairPrompt } from './chair.js';
9
11
  import { classifyTaskSecurity } from './security-classify.js';
12
+ import { writeFeedback } from './feedback.js';
10
13
  import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.js';
14
+ import { loadQaRulesDocument } from './qa-rules.js';
15
+ import { loadUiReviewConfig } from './ui-review-config.js';
11
16
  import { getPluginRoot } from './plugin-path.js';
12
17
 
13
18
  export interface ResolvedMember {
@@ -15,6 +20,14 @@ export interface ResolvedMember {
15
20
  blocking: boolean;
16
21
  weight: number;
17
22
  promptPath: string;
23
+ /**
24
+ * Tokens this member's prompt declares beyond the five the runner always supplies
25
+ * (task, branch, base_branch, repo_root, diff), resolved here rather than described
26
+ * in skill prose. Values are the literal strings to substitute. Empty for members
27
+ * whose prompt needs nothing extra, and for any token planning cannot resolve
28
+ * without side effects (see MEMBER_TOKENS).
29
+ */
30
+ substitutions: Record<string, string>;
18
31
  }
19
32
 
20
33
  export interface CouncilPlan {
@@ -76,13 +89,52 @@ export function resolveChangedFiles(repoRoot: string, taskId: string, opts: { ch
76
89
  }
77
90
  }
78
91
 
79
- const BUILTIN_PROMPTS: Record<string, string> = {
92
+ /**
93
+ * The built-in council members and the shipped prompt each one resolves to.
94
+ * Exported as the single source of truth for "which members are built in":
95
+ * `tests/council.test.ts` walks it to assert every built-in prompt's declared
96
+ * tokens are covered by BASE_TOKENS ∪ MEMBER_TOKENS, so a prompt cannot gain a
97
+ * token that no one resolves.
98
+ */
99
+ export const BUILTIN_PROMPTS: Record<string, string> = {
80
100
  reviewer: 'reviewer.md',
81
101
  security: 'security-reviewer.md',
82
102
  ui: 'ui-reviewer.md',
83
103
  qa: 'qa.md',
84
104
  };
85
105
 
106
+ interface GateDescriptor {
107
+ state: string; // the work-item status a gate's council runs at
108
+ advisoryOnly: boolean; // true when the gate's only legal transitions are human-driven
109
+ kind?: 'code' | 'rfc' | 'plan'; // artifact kind the gate reviews; default 'code'
110
+ }
111
+
112
+ /**
113
+ * Council gate → FSM binding. `task.review` is the collapsed delivery council
114
+ * (decisive); `task.plan_review` is decisive-capable (agent bounce to pending);
115
+ * `task.final_gate` and the two discovery gates are advisory (post-only, human
116
+ * drives the transition). Advisory-only gates route to postAdvisoryVerdict.
117
+ */
118
+ export const GATE_DESCRIPTORS: Record<string, GateDescriptor> = {
119
+ 'task.review': { state: 'council', advisoryOnly: false },
120
+ 'task.plan_review': { state: 'tactical-plan', advisoryOnly: false },
121
+ 'task.final_gate': { state: 'final-gate', advisoryOnly: true },
122
+ 'plan.task_batch': { state: 'task_batch_gate', advisoryOnly: true, kind: 'plan' },
123
+ 'rfc.strategy_gate': { state: 'rfc_strategy_gate', advisoryOnly: true, kind: 'rfc' },
124
+ };
125
+
126
+ // Load any work item's status/doc by the gate's "type." prefix (task | plan | rfc).
127
+ function workItemTypeOf(gate: string): 'task' | 'plan' | 'rfc' {
128
+ const t = gate.split('.')[0];
129
+ if (t === 'task' || t === 'plan' || t === 'rfc') return t;
130
+ throw new Error(`council: gate '${gate}' has no task/plan/rfc type prefix`);
131
+ }
132
+ function loadWorkItemDoc(repoRoot: string, type: 'task' | 'plan' | 'rfc', id: string): Record<string, unknown> {
133
+ if (type === 'plan') return loadPlan(repoRoot, id) as unknown as Record<string, unknown>;
134
+ if (type === 'rfc') return loadRfc(repoRoot, id) as unknown as Record<string, unknown>;
135
+ return loadTask(repoRoot, id) as unknown as Record<string, unknown>;
136
+ }
137
+
86
138
  /**
87
139
  * Resolve a council member to the absolute path of its prompt. A member with a
88
140
  * `prompt` field is a custom role → <repoRoot>/.cloverleaf/prompts/<file> (exist-checked,
@@ -105,16 +157,127 @@ export function resolveMemberPrompt(member: CouncilMember, repoRoot: string): st
105
157
  return join(getPluginRoot(), 'prompts', builtin);
106
158
  }
107
159
 
160
+ /**
161
+ * Every extra token a built-in member's prompt may declare, beyond the five the
162
+ * runner always supplies. A union rather than `string` so `resolveSubstitutions`'s
163
+ * `default:` can be an exhaustiveness check: adding a token to MEMBER_TOKENS
164
+ * without a matching `case` is a typecheck failure, not a silently dropped key.
165
+ */
166
+ export type MemberToken =
167
+ | 'test_rules'
168
+ | 'qa_rules'
169
+ | 'affected_routes'
170
+ | 'preview_port'
171
+ | 'ui_review_config'
172
+ | 'taskId';
173
+
174
+ /**
175
+ * Extra tokens each built-in member's prompt declares, beyond the five the runner
176
+ * always supplies. Kept adjacent to the resolver so a prompt gaining a token is a
177
+ * one-line change in TS with a test behind it, rather than silent drift in skill prose.
178
+ *
179
+ * `preview_port` is listed because `ui-reviewer.md` genuinely declares it — but it is
180
+ * deliberately not resolved (see `resolveSubstitutions`). This map is the prompts'
181
+ * contract; the resolver is the subset planning can answer honestly.
182
+ *
183
+ * `tests/council.test.ts` pins this map against the prompts themselves: every
184
+ * `{{token}}` a built-in prompt declares must appear here or in BASE_TOKENS. That
185
+ * completeness check is what makes `cloverleaf-run` §7.2's "never dispatch with an
186
+ * unresolved token" rule safe — otherwise a prompt-only token stalls its member.
187
+ */
188
+ export const MEMBER_TOKENS: Record<string, readonly MemberToken[]> = {
189
+ reviewer: ['test_rules'],
190
+ security: [],
191
+ qa: ['qa_rules'],
192
+ ui: ['affected_routes', 'preview_port', 'ui_review_config', 'taskId'],
193
+ };
194
+
195
+ interface SubstitutionContext {
196
+ /**
197
+ * Id of the work item under review. Always known at planning time, so
198
+ * `{{taskId}}` — `ui-reviewer.md`'s run-artifact directory
199
+ * (`.cloverleaf/runs/<id>/ui-review/`) — is always resolvable.
200
+ */
201
+ workItemId: string;
202
+ /**
203
+ * Routes this work item's diff affects, in the same `string[] | 'all'` encoding
204
+ * `cloverleaf-cli affected-routes` prints. Undefined for the discovery gates
205
+ * (plan/rfc), where no code diff is in scope.
206
+ */
207
+ affectedRoutes?: string[] | 'all';
208
+ }
209
+
210
+ /**
211
+ * Resolve a member's extra prompt tokens. Side-effect free by contract: it reads
212
+ * config and reuses values the plan already computed, and never allocates, writes,
213
+ * or starts anything — `council-plan` is a query, and callers re-run it freely.
214
+ *
215
+ * A token that cannot be answered honestly at planning time is **omitted** rather
216
+ * than filled with a placeholder. An absent key leaves a visible `{{token}}` for
217
+ * whoever dispatches the member; a fabricated value is silently wrong, which is the
218
+ * exact failure mode this map exists to prevent.
219
+ */
220
+ function resolveSubstitutions(
221
+ memberId: string,
222
+ repoRoot: string,
223
+ ctx: SubstitutionContext,
224
+ ): Record<string, string> {
225
+ const out: Record<string, string> = {};
226
+ for (const token of MEMBER_TOKENS[memberId] ?? []) {
227
+ switch (token) {
228
+ // Both carry the qa-rules *document* (`{ rules: [...] }`) — the shape
229
+ // reviewer.md and qa.md document — not loadQaRulesConfig()'s bare array.
230
+ case 'test_rules':
231
+ case 'qa_rules':
232
+ out[token] = JSON.stringify(loadQaRulesDocument(repoRoot));
233
+ break;
234
+ case 'ui_review_config':
235
+ out[token] = JSON.stringify(loadUiReviewConfig(repoRoot));
236
+ break;
237
+ case 'affected_routes':
238
+ // Diff-dependent, so available only on a code gate; the plan already
239
+ // computed it to evaluate the `ui_changes` predicate.
240
+ if (ctx.affectedRoutes !== undefined) out[token] = JSON.stringify(ctx.affectedRoutes);
241
+ break;
242
+ case 'taskId':
243
+ // The run-artifact directory ui-reviewer.md writes its state.json sidecar
244
+ // into. A pure value already in hand, so it is always resolved: leaving it
245
+ // literal makes lib/ui-review-state.ts read a path that cannot exist, and
246
+ // the baselines-hold then fails open.
247
+ out[token] = ctx.workItemId;
248
+ break;
249
+ case 'preview_port':
250
+ // Deliberately unresolved: there is no configured preview port and no
251
+ // side-effect-free way to derive one — lib/ports.ts offers only
252
+ // getFreePort(), which *allocates*. Whoever dispatches the ui member
253
+ // allocates it there, as the standalone ui-review skill does.
254
+ break;
255
+ default: {
256
+ // Exhaustiveness guard. A token added to MEMBER_TOKENS with no case above
257
+ // would otherwise be dropped silently — the exact drift this map exists to
258
+ // prevent — so make it a compile error instead.
259
+ const unhandled: never = token;
260
+ throw new Error(`council: no resolver for member token '${String(unhandled)}'`);
261
+ }
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+
108
267
  export function resolveCouncilPlan(
109
268
  repoRoot: string,
110
- taskId: string,
269
+ workItemId: string,
111
270
  gateKey = 'task.review',
112
271
  opts: { changedFiles?: string[] } = {},
113
272
  ): CouncilPlan {
114
273
  const { config, source } = loadCouncilConfigWithSource(repoRoot);
115
- const task = loadTask(repoRoot, taskId) as unknown as Record<string, unknown>;
274
+ const type = workItemTypeOf(gateKey);
275
+ const doc = loadWorkItemDoc(repoRoot, type, workItemId);
116
276
 
117
- const { profile: profileName, mode } = resolveBinding(config.gates[gateKey], task);
277
+ const binding = resolveBinding(config.gates[gateKey], doc);
278
+ const profileName = binding.profile;
279
+ const mode: 'decisive' | 'advisory' =
280
+ GATE_DESCRIPTORS[gateKey]?.advisoryOnly ? 'advisory' : binding.mode;
118
281
  const empty: CouncilPlan = {
119
282
  gate: gateKey, profile: null, mode, rounds: [],
120
283
  aggregation: 'any-veto', on_round_bounce: 'stop', source,
@@ -131,11 +294,17 @@ export function resolveCouncilPlan(
131
294
  return empty; // unknown profile → fail toward today's behavior
132
295
  }
133
296
 
134
- const changed = resolveChangedFiles(repoRoot, taskId, opts);
135
- const securityHigh = classifyTaskSecurity(repoRoot, taskId, { changedFiles: changed }).effective === 'high';
136
- const affected = computeAffectedRoutes(changed, loadAffectedRoutesConfig(repoRoot));
137
- const uiChanges = affected === 'all' || affected.length > 0;
138
- const ctx: WhenContext = { securityHigh, uiChanges };
297
+ // The when-context (security/ui) is a code-kind (task delivery) concern only.
298
+ // `affectedRoutes` outlives the predicate: the ui member's {{affected_routes}}
299
+ // token is the same value, so it is computed once and threaded to both.
300
+ let ctx: WhenContext = { securityHigh: false, uiChanges: false };
301
+ let affectedRoutes: string[] | 'all' | undefined;
302
+ if (type === 'task') {
303
+ const changed = resolveChangedFiles(repoRoot, workItemId, opts);
304
+ const securityHigh = classifyTaskSecurity(repoRoot, workItemId, { changedFiles: changed }).effective === 'high';
305
+ affectedRoutes = computeAffectedRoutes(changed, loadAffectedRoutesConfig(repoRoot));
306
+ ctx = { securityHigh, uiChanges: affectedRoutes === 'all' || affectedRoutes.length > 0 };
307
+ }
139
308
 
140
309
  const rounds: ResolvedMember[][] = [];
141
310
  for (const round of profile.rounds) {
@@ -146,6 +315,7 @@ export function resolveCouncilPlan(
146
315
  blocking: member.blocking !== false,
147
316
  weight: member.weight ?? 1,
148
317
  promptPath: resolveMemberPrompt(member, repoRoot),
318
+ substitutions: resolveSubstitutions(member.member, repoRoot, { workItemId, affectedRoutes }),
149
319
  }));
150
320
  if (active.length > 0) rounds.push(active);
151
321
  }
@@ -167,30 +337,44 @@ export function resolveCouncilPlan(
167
337
 
168
338
  /**
169
339
  * Drive the FSM transition implied by a council verdict (the runner's terminal step).
170
- * Council-authoritative: on a pass it records the council's gating verdict so the
171
- * v0.8.1 security precondition is satisfied for any high-security gated transition;
172
- * the per-member basis (incl. an omitted or out-voted `security` member) is written
173
- * to the result artifact. Walks the minimal legal path to the lane's pre-merge state.
340
+ * Routes by gate: `task.review` the collapsed decisive delivery council; the
341
+ * decisive `task.plan_review` the plan-review council; advisory-only gates
342
+ * (task.final_gate and the two discovery gates) postAdvisoryVerdict, which
343
+ * records the verdict and drives no transition.
174
344
  */
175
345
  export function applyCouncilVerdict(
176
346
  repoRoot: string,
177
- taskId: string,
347
+ workItemId: string,
178
348
  gate: string,
179
349
  council: CouncilVerdict,
180
350
  ): CouncilResult {
181
- if (gate !== 'task.review') {
351
+ const desc = GATE_DESCRIPTORS[gate];
352
+ if (!desc) {
182
353
  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).`,
354
+ `apply-council-verdict: gate '${gate}' is not supported; supported gates: ${Object.keys(GATE_DESCRIPTORS).join(', ')}.`,
185
355
  );
186
356
  }
357
+ if (desc.advisoryOnly) {
358
+ return postAdvisoryVerdict(repoRoot, workItemId, gate, desc.state, council);
359
+ }
360
+ if (gate === 'task.plan_review') {
361
+ return applyDecisivePlanReview(repoRoot, workItemId, gate, council);
362
+ }
363
+ return applyDeliveryCouncil(repoRoot, workItemId, gate, council); // task.review — collapsed council phase
364
+ }
365
+
366
+ /** Decisive delivery council (task.review at the collapsed `council` state). */
367
+ function applyDeliveryCouncil(repoRoot: string, taskId: string, gate: string, council: CouncilVerdict): CouncilResult {
187
368
  const task = loadTask(repoRoot, taskId);
188
- if (task.status !== 'review') {
189
- throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'review'`);
369
+ if (task.status !== 'council') {
370
+ throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'council'`);
190
371
  }
191
- const lane: 'fast' | 'full' = task.risk_class === 'high' ? 'full' : 'fast';
192
372
  const securityMember = council.members.find((m) => m.member === 'security');
193
- const walk: string[] = ['review'];
373
+ const highSecurity = task.security_class === 'high';
374
+ // No security verdict to report? Ask the profile what it intended — before any
375
+ // transition mutates the task the gate binding selects a profile from.
376
+ const plannedSecurity = securityMember === undefined ? resolvePlannedSecurity(repoRoot, taskId, gate) : null;
377
+ const walk: string[] = ['council'];
194
378
 
195
379
  if (council.verdict === 'escalate') {
196
380
  advanceStatus(repoRoot, taskId, 'escalated', 'agent');
@@ -199,45 +383,30 @@ export function applyCouncilVerdict(
199
383
  advanceStatus(repoRoot, taskId, 'implementing', 'agent');
200
384
  walk.push('implementing');
201
385
  } else {
202
- // pass minimal legal walk; review→automated-gates resets the security verdict,
203
- // so set the council's gating verdict AFTER that transition.
204
- advanceStatus(repoRoot, taskId, 'automated-gates', 'agent');
205
- walk.push('automated-gates');
206
- const atGates = loadTask(repoRoot, taskId);
207
- atGates.security_review_verdict = 'pass';
208
- saveTask(repoRoot, atGates);
209
- if (lane === 'full') {
210
- advanceStatus(repoRoot, taskId, 'qa', 'agent', { path: 'full_pipeline' });
211
- walk.push('qa');
212
- advanceStatus(repoRoot, taskId, 'final-gate', 'agent', { path: 'full_pipeline' });
213
- walk.push('final-gate');
386
+ // pass council final-gate; record the council's authoritative security verdict for high tasks
387
+ // (the v0.8.1 guarantee's backstop, now that the FSM no longer enforces it mechanically).
388
+ advanceStatus(repoRoot, taskId, 'final-gate', 'agent');
389
+ walk.push('final-gate');
390
+ if (highSecurity) {
391
+ const atGate = loadTask(repoRoot, taskId);
392
+ atGate.security_review_verdict = 'pass';
393
+ saveTask(repoRoot, atGate);
214
394
  }
215
395
  }
216
396
 
217
- const qaTraversedAdministratively =
218
- lane === 'full' && walk.includes('qa') && !council.members.some((m) => m.member === 'qa');
219
-
220
397
  const result: CouncilResult = {
221
398
  gate,
222
399
  final_verdict: council.verdict,
223
400
  rule: council.rule,
224
401
  rationale: council.rationale,
225
- members: council.members.map((m) => ({
226
- member: m.member,
227
- verdict: m.verdict,
228
- blocking: m.blocking !== false,
229
- weight: m.weight ?? 1,
230
- })),
402
+ members: council.members.map((m) => ({ member: m.member, verdict: m.verdict, blocking: m.blocking !== false, weight: m.weight ?? 1 })),
231
403
  walk,
232
- ...(qaTraversedAdministratively
233
- ? { walk_note: 'qa state traversed administratively; no qa member ran' }
234
- : {}),
235
404
  ...(council.forward !== undefined ? { forward: council.forward } : {}),
236
405
  security: {
237
406
  member_verdict: securityMember ? securityMember.verdict : 'absent',
238
- gating_verdict_set: council.verdict === 'pass' ? 'pass' : null,
407
+ gating_verdict_set: council.verdict === 'pass' && highSecurity ? 'pass' : null,
239
408
  basis: !securityMember
240
- ? 'no security member configured; advanced under council authority'
409
+ ? securityBasisWhenAbsent(plannedSecurity, council)
241
410
  : securityMember.verdict === 'pass'
242
411
  ? 'security member passed'
243
412
  : `security member returned '${securityMember.verdict}'; council ${council.verdict} by rule ${JSON.stringify(council.rule)}`,
@@ -246,3 +415,186 @@ export function applyCouncilVerdict(
246
415
  writeCouncilResult(repoRoot, taskId, result);
247
416
  return result;
248
417
  }
418
+
419
+ /** What this gate's profile planned for the `security` member, for basis classification. */
420
+ interface PlannedSecurity {
421
+ /** Round the `security` member is planned in, or null when it is not in the plan at all. */
422
+ securityRound: number | null;
423
+ /**
424
+ * Earliest planned round per member id. A stop rule explains security's absence only if
425
+ * it fired in a round that could actually have preceded security's own, so the round
426
+ * ordering — not merely the presence of a bouncing member — is what the basis checks.
427
+ */
428
+ memberRounds: Map<string, number>;
429
+ stopsOnRoundBounce: boolean;
430
+ }
431
+
432
+ /**
433
+ * Resolve what the profile bound to `gate` planned for the `security` member on this task.
434
+ *
435
+ * `council.members` names only the members that actually *ran*, so a missing security
436
+ * verdict is ambiguous on its own. The resolved plan disambiguates it — with one honest
437
+ * limitation: `resolveCouncilPlan` filters `when`-inactive members out, so a member
438
+ * excluded by its `when` predicate and a profile that never listed one both come back
439
+ * `planned: false`. Both are truthfully "no security member for this task", so they share
440
+ * one basis string rather than a distinction this cannot actually make.
441
+ *
442
+ * Two deliberate properties:
443
+ * - Returns null rather than throwing. Plan resolution reads consumer config and can fail
444
+ * on, say, a mistyped member id; an audit string is not worth failing a terminal apply
445
+ * step over, and a run whose plan will not resolve says exactly that.
446
+ * - Called only when there is no security verdict to explain: for a task-kind gate
447
+ * `resolveCouncilPlan` runs a `git diff`, so this adds one git invocation to the apply
448
+ * path, and it should stay at most one.
449
+ */
450
+ function resolvePlannedSecurity(repoRoot: string, taskId: string, gate: string): PlannedSecurity | null {
451
+ try {
452
+ const plan = resolveCouncilPlan(repoRoot, taskId, gate);
453
+ // Earliest occurrence wins: if a member is planned twice, the first round is the
454
+ // earliest point the council could have run it.
455
+ const memberRounds = new Map<string, number>();
456
+ plan.rounds.forEach((round, index) => {
457
+ for (const m of round) if (!memberRounds.has(m.member)) memberRounds.set(m.member, index);
458
+ });
459
+ return {
460
+ securityRound: memberRounds.get('security') ?? null,
461
+ memberRounds,
462
+ stopsOnRoundBounce: plan.on_round_bounce === 'stop',
463
+ };
464
+ } catch (err) {
465
+ process.stderr.write(
466
+ `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`,
467
+ );
468
+ return null;
469
+ }
470
+ }
471
+
472
+ /**
473
+ * The `security.basis` for a run that recorded no security verdict. Each string states only
474
+ * what that run can prove, so a cause is named only when the run satisfies one of the
475
+ * council's two stop rules *in a round that could have preceded security's own*:
476
+ *
477
+ * - an `escalate` from any member stops the council immediately, so an escalator in an
478
+ * earlier round — or in security's own round, cutting off a concurrently dispatched
479
+ * member — explains the absence; one in a *later* round cannot, since that round ran only
480
+ * because security's round completed;
481
+ * - a **blocking** `bounce` stops the council "before the next round" under
482
+ * `on_round_bounce: stop`, so it explains the absence only from a *strictly earlier*
483
+ * round. A same-round bounce cannot un-dispatch a member already running alongside it.
484
+ *
485
+ * Anything else — a non-blocking bounce, a stop rule that fired too late, a member the
486
+ * runner never dispatched or whose envelope was lost — leaves the absence unexplained, and
487
+ * the artifact says so rather than naming a cause that would point the auditor away from
488
+ * the real anomaly.
489
+ */
490
+ function securityBasisWhenAbsent(planned: PlannedSecurity | null, council: CouncilVerdict): string {
491
+ if (planned === null) {
492
+ return 'no security member ran; the council plan could not be resolved, so whether one was configured is unknown';
493
+ }
494
+ const securityRound = planned.securityRound;
495
+ if (securityRound === null) {
496
+ // Covers both a profile with no security member and one excluded by its `when`
497
+ // predicate — indistinguishable here, and the wording claims no more than that.
498
+ const outcome =
499
+ council.verdict === 'bounce' ? 'council bounced to implementing'
500
+ : council.verdict === 'escalate' ? 'council escalated'
501
+ : 'advanced under council authority';
502
+ return `no security member in this task's resolved council plan; ${outcome}`;
503
+ }
504
+ // Did a stop rule fire in a round at/before `latestRound`? A member the plan does not
505
+ // name cannot be ordered against security's round, so it never explains the absence.
506
+ const stoppedBy = (verdict: CouncilVerdict['verdict'], latestRound: number, blockingOnly: boolean): boolean =>
507
+ council.members.some((m) => {
508
+ const round = planned.memberRounds.get(m.member);
509
+ return m.verdict === verdict && round !== undefined && round <= latestRound && (!blockingOnly || m.blocking !== false);
510
+ });
511
+ if (stoppedBy('escalate', securityRound, false)) {
512
+ return 'security member configured for this task but not reached: another member escalated and the council short-circuited; no security verdict recorded';
513
+ }
514
+ if (planned.stopsOnRoundBounce && stoppedBy('bounce', securityRound - 1, true)) {
515
+ 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";
516
+ }
517
+ return 'security member configured for this task but did not run; no security verdict recorded';
518
+ }
519
+
520
+ /** Decisive plan-review council (task.plan_review at tactical-plan). */
521
+ function applyDecisivePlanReview(repoRoot: string, taskId: string, gate: string, council: CouncilVerdict): CouncilResult {
522
+ const task = loadTask(repoRoot, taskId);
523
+ if (task.status !== 'tactical-plan') {
524
+ throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'tactical-plan' for gate '${gate}'`);
525
+ }
526
+ const walk: string[] = ['tactical-plan'];
527
+ if (council.verdict === 'escalate') {
528
+ advanceStatus(repoRoot, taskId, 'escalated', 'agent');
529
+ walk.push('escalated');
530
+ } else if (council.verdict === 'bounce') {
531
+ advanceStatus(repoRoot, taskId, 'pending', 'agent', { gate: 'per_task_plan_review' });
532
+ walk.push('pending');
533
+ } else {
534
+ advanceStatus(repoRoot, taskId, 'implementing', 'agent');
535
+ walk.push('implementing');
536
+ }
537
+ const result: CouncilResult = {
538
+ gate,
539
+ final_verdict: council.verdict,
540
+ rule: council.rule,
541
+ rationale: council.rationale,
542
+ members: council.members.map((m) => ({ member: m.member, verdict: m.verdict, blocking: m.blocking !== false, weight: m.weight ?? 1 })),
543
+ walk,
544
+ ...(council.forward !== undefined ? { forward: council.forward } : {}),
545
+ };
546
+ writeCouncilResult(repoRoot, taskId, result);
547
+ return result;
548
+ }
549
+
550
+ /**
551
+ * Advisory-gate terminal step: record the council verdict + post a feedback
552
+ * envelope, and drive NO transition — the human owns every transition at an
553
+ * advisory gate. The verdict (including an escalate) is recorded verbatim;
554
+ * because nothing is transitioned, the un-lowerable-escalate invariant holds
555
+ * trivially. Generalized over work-item kind: the gate's `type.` prefix selects
556
+ * task | plan | rfc. Used for task.final_gate (final-gate) and the discovery
557
+ * gates plan.task_batch (task_batch_gate) / rfc.strategy_gate (rfc_strategy_gate).
558
+ */
559
+ export function postAdvisoryVerdict(
560
+ repoRoot: string,
561
+ workItemId: string,
562
+ gate: string,
563
+ expectedState: string,
564
+ council: CouncilVerdict,
565
+ ): CouncilResult {
566
+ const type = workItemTypeOf(gate);
567
+ const status = String(loadWorkItemDoc(repoRoot, type, workItemId).status ?? '');
568
+ if (status !== expectedState) {
569
+ throw new Error(
570
+ `apply-council-verdict: ${type} ${workItemId} is '${status}', expected '${expectedState}' for advisory gate '${gate}'`,
571
+ );
572
+ }
573
+ const m = workItemId.match(/^(.+)-(\d+)$/);
574
+ if (!m) throw new Error(`apply-council-verdict: invalid work-item id '${workItemId}'`);
575
+ const project = m[1];
576
+ writeFeedback(repoRoot, {
577
+ project,
578
+ taskId: workItemId,
579
+ prefix: 'c',
580
+ envelope: { verdict: council.verdict, summary: council.rationale, findings: [] },
581
+ });
582
+ const result: CouncilResult = {
583
+ gate,
584
+ mode: 'advisory',
585
+ final_verdict: council.verdict,
586
+ rule: council.rule,
587
+ rationale: council.rationale,
588
+ members: council.members.map((mm) => ({
589
+ member: mm.member,
590
+ verdict: mm.verdict,
591
+ blocking: mm.blocking !== false,
592
+ weight: mm.weight ?? 1,
593
+ })),
594
+ walk: [expectedState],
595
+ walk_note: 'advisory: verdict posted; human drives the transition',
596
+ ...(council.forward !== undefined ? { forward: council.forward } : {}),
597
+ };
598
+ writeCouncilResult(repoRoot, workItemId, result);
599
+ return result;
600
+ }