@cloverleaf/reference-impl 0.8.6 → 0.10.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.
@@ -0,0 +1,87 @@
1
+ import type { Verdict } from './feedback.js';
2
+
3
+ export type ThresholdRule =
4
+ | 'any-veto'
5
+ | 'unanimous'
6
+ | 'majority'
7
+ | 'weighted'
8
+ | { quorum: number };
9
+
10
+ export interface MemberVerdict {
11
+ member: string;
12
+ verdict: Verdict; // 'pass' | 'bounce' | 'escalate'
13
+ blocking?: boolean; // default true; advisory members (false) don't gate
14
+ weight?: number; // default 1; only used by 'weighted'
15
+ }
16
+
17
+ export interface CouncilVerdict {
18
+ verdict: Verdict;
19
+ rule: ThresholdRule;
20
+ rationale: string;
21
+ members: MemberVerdict[];
22
+ }
23
+
24
+ export function aggregate(
25
+ members: MemberVerdict[],
26
+ rule: ThresholdRule,
27
+ opts: { weightedThreshold?: number } = {},
28
+ ): CouncilVerdict {
29
+ // Safety invariant: any member that escalates short-circuits, regardless of
30
+ // blocking flag or rule — a hard blocker cannot be out-voted.
31
+ const escalators = members.filter((x) => x.verdict === 'escalate');
32
+ if (escalators.length > 0) {
33
+ return {
34
+ verdict: 'escalate',
35
+ rule,
36
+ members,
37
+ rationale: `escalated by ${escalators.map((x) => x.member).join(', ')}`,
38
+ };
39
+ }
40
+
41
+ // Only blocking members gate the pass/bounce decision.
42
+ const blocking = members.filter((x) => x.blocking !== false);
43
+ if (blocking.length === 0) {
44
+ return { verdict: 'pass', rule, members, rationale: 'no blocking members' };
45
+ }
46
+
47
+ const passes = blocking.filter((x) => x.verdict === 'pass');
48
+ const passCount = passes.length;
49
+ const total = blocking.length;
50
+
51
+ let pass: boolean;
52
+ let detail: string;
53
+
54
+ if (rule === 'any-veto') {
55
+ pass = passCount === total;
56
+ detail = `any-veto: ${passCount}/${total} passed`;
57
+ } else if (rule === 'unanimous') {
58
+ // Currently identical to any-veto over blocking members. Kept as a distinct
59
+ // named rule for clarity and possible future divergence; do not merge.
60
+ pass = passCount === total;
61
+ detail = `unanimous: ${passCount}/${total} passed`;
62
+ } else if (rule === 'majority') {
63
+ pass = passCount * 2 > total; // strict majority; ties → bounce
64
+ detail = `majority: ${passCount}/${total} passed`;
65
+ } else if (rule === 'weighted') {
66
+ const totalWeight = blocking.reduce((s, x) => s + (x.weight ?? 1), 0);
67
+ const passWeight = passes.reduce((s, x) => s + (x.weight ?? 1), 0);
68
+ const threshold = opts.weightedThreshold;
69
+ pass = threshold !== undefined ? passWeight >= threshold : passWeight * 2 > totalWeight;
70
+ detail =
71
+ `weighted: passWeight ${passWeight}/${totalWeight}` +
72
+ (threshold !== undefined ? ` (threshold ${threshold})` : '');
73
+ } else if (typeof rule === 'object' && rule !== null && 'quorum' in rule) {
74
+ const k = rule.quorum;
75
+ pass = passCount >= k;
76
+ detail = `quorum(${k}): ${passCount}/${total} passed`;
77
+ } else {
78
+ throw new Error(`aggregate: unknown aggregation rule ${JSON.stringify(rule)}`);
79
+ }
80
+
81
+ return {
82
+ verdict: pass ? 'pass' : 'bounce',
83
+ rule,
84
+ members,
85
+ rationale: `${pass ? 'pass' : 'bounce'} — ${detail}`,
86
+ };
87
+ }
package/lib/cli.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  * next-work-item-id <repoRoot> <project>
32
32
  * discovery-config --repo-root <repoRoot>
33
33
  * prep-worktree <mainRoot> <worktreePath>
34
+ * qa-report <runs.json> <out.html>
34
35
  * dag-ready-tasks <repoRoot> <planId> <maxConcurrent>
35
36
  * dag-detect-cycle <repoRoot> <planId>
36
37
  * walk-state-read <repoRoot> <planId>
@@ -41,6 +42,9 @@
41
42
  * secret-scan <repoRoot> --branch <branch>
42
43
  * classify-security <repoRoot> <taskId> [--branch <branch>]
43
44
  * set-task-field <repoRoot> <taskId> <field> <value>
45
+ * council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]
46
+ * aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]
47
+ * apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>
44
48
  */
45
49
 
46
50
  import { readFileSync, mkdirSync, copyFileSync, appendFileSync, existsSync } from 'node:fs';
@@ -63,6 +67,7 @@ import { loadSpike, saveSpike, advanceSpikeStatus, type SpikeDoc } from './spike
63
67
  import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan, type PlanDoc } from './plan.js';
64
68
  import { loadDiscoveryConfig } from './discovery-config.js';
65
69
  import { prepWorktree } from './prep-worktree.js';
70
+ import { writeQaReportFromFile } from './qa-report.js';
66
71
  import { readUiReviewState, writeUiReviewState } from './ui-review-state.js';
67
72
  import { buildBaselinePath } from './visual-diff.js';
68
73
  import { computeReadyTasks, detectCycle } from './dag-walker.js';
@@ -73,6 +78,8 @@ import type { SiblingScope } from './scope-check.js';
73
78
  import { computeRfcTasksView, type RfcTasksView } from './rfc-tasks.js';
74
79
  import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.js';
75
80
  import { classifyTaskSecurity } from './security-classify.js';
81
+ import { resolveCouncilPlan, applyCouncilVerdict } from './council.js';
82
+ import { aggregate, type MemberVerdict, type ThresholdRule, type CouncilVerdict } from './aggregation.js';
76
83
 
77
84
  function die(msg: string, code = 1): never {
78
85
  process.stderr.write(msg + '\n');
@@ -110,6 +117,7 @@ function usage(msg?: string): never {
110
117
  ' next-work-item-id <repoRoot> <project>\n' +
111
118
  ' discovery-config --repo-root <repoRoot>\n' +
112
119
  ' prep-worktree <mainRoot> <worktreePath>\n' +
120
+ ' qa-report <runs.json> <out.html>\n' +
113
121
  ' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
114
122
  ' dag-detect-cycle <repoRoot> <planId>\n' +
115
123
  ' walk-state-read <repoRoot> <planId>\n' +
@@ -119,6 +127,9 @@ function usage(msg?: string): never {
119
127
  ' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
120
128
  ' secret-scan <repoRoot> --branch <branch>\n' +
121
129
  ' classify-security <repoRoot> <taskId> [--branch <branch>]\n' +
130
+ ' council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]\n' +
131
+ ' aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]\n' +
132
+ ' apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>\n' +
122
133
  ' set-task-field <repoRoot> <taskId> <field> <value>\n'
123
134
  );
124
135
  process.exit(2);
@@ -498,6 +509,13 @@ try {
498
509
  break;
499
510
  }
500
511
 
512
+ case 'qa-report': {
513
+ const [runsJsonPath, outHtmlPath] = rest;
514
+ if (!runsJsonPath || !outHtmlPath) usage('qa-report requires <runs.json> <out.html>');
515
+ writeQaReportFromFile(runsJsonPath, outHtmlPath);
516
+ break;
517
+ }
518
+
501
519
  case 'dag-ready-tasks': {
502
520
  const [repoRoot, planId, maxConcurrentStr] = rest;
503
521
  if (!repoRoot || !planId || !maxConcurrentStr)
@@ -817,6 +835,55 @@ try {
817
835
  break;
818
836
  }
819
837
 
838
+ case 'council-plan': {
839
+ const positional = rest.filter((a) => !a.startsWith('--'));
840
+ const flags = rest.filter((a) => a.startsWith('--'));
841
+ const [repoRoot, taskId, gateKey] = positional;
842
+ if (!repoRoot || !taskId) usage('council-plan requires <repoRoot> <taskId> [gateKey]');
843
+ const cf = flags.find((f) => f.startsWith('--changed-files='));
844
+ const changedFiles = cf !== undefined
845
+ ? cf.replace('--changed-files=', '').split(',').filter(Boolean)
846
+ : undefined;
847
+ const plan = resolveCouncilPlan(
848
+ repoRoot, taskId, gateKey || 'task.review',
849
+ changedFiles !== undefined ? { changedFiles } : {},
850
+ );
851
+ process.stdout.write(JSON.stringify(plan) + '\n');
852
+ break;
853
+ }
854
+
855
+ case 'aggregate-verdicts': {
856
+ const positional = rest.filter((a) => !a.startsWith('--'));
857
+ const flags = rest.filter((a) => a.startsWith('--'));
858
+ const [membersJson, ruleArg] = positional;
859
+ if (!membersJson || !ruleArg) usage('aggregate-verdicts requires <membersJson> <rule>');
860
+ const members = JSON.parse(membersJson) as MemberVerdict[];
861
+ let rule: ThresholdRule;
862
+ if (ruleArg.startsWith('quorum:')) {
863
+ const quorumN = parseInt(ruleArg.split(':')[1], 10);
864
+ if (Number.isNaN(quorumN) || quorumN < 1) {
865
+ usage(`aggregate-verdicts: quorum value must be a positive integer, got '${ruleArg}'`);
866
+ }
867
+ rule = { quorum: quorumN };
868
+ } else {
869
+ rule = ruleArg as ThresholdRule;
870
+ }
871
+ const wt = flags.find((f) => f.startsWith('--weighted-threshold='));
872
+ const opts = wt ? { weightedThreshold: parseFloat(wt.replace('--weighted-threshold=', '')) } : {};
873
+ process.stdout.write(JSON.stringify(aggregate(members, rule, opts)) + '\n');
874
+ break;
875
+ }
876
+
877
+ case 'apply-council-verdict': {
878
+ const [repoRoot, taskId, gate, verdictJson] = rest;
879
+ if (!repoRoot || !taskId || !gate || !verdictJson)
880
+ usage('apply-council-verdict requires <repoRoot> <taskId> <gate> <councilVerdictJson>');
881
+ const council = JSON.parse(verdictJson) as CouncilVerdict;
882
+ const result = applyCouncilVerdict(repoRoot, taskId, gate, council);
883
+ process.stdout.write(JSON.stringify(result) + '\n');
884
+ break;
885
+ }
886
+
820
887
  case 'set-task-field': {
821
888
  const [repoRoot, taskId, field, value] = rest;
822
889
  if (!repoRoot || !taskId || !field || value === undefined)
@@ -0,0 +1,79 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ import type { ThresholdRule } from './aggregation.js';
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const DEFAULT_CONFIG = join(here, '..', 'config', 'council.json');
8
+
9
+ export type WhenPredicate = 'always' | 'security_class:high' | 'ui_changes';
10
+
11
+ export interface CouncilMember {
12
+ member: string; // built-in id: 'reviewer' | 'security' | 'ui' | 'qa'
13
+ when?: WhenPredicate; // default 'always'
14
+ blocking?: boolean; // default true
15
+ weight?: number; // default 1
16
+ }
17
+
18
+ export interface CouncilProfile {
19
+ rounds: CouncilMember[][];
20
+ aggregation: ThresholdRule;
21
+ on_round_bounce?: 'stop' | 'continue'; // default 'stop'
22
+ }
23
+
24
+ export type GateBinding =
25
+ | string
26
+ | { profile: string; mode?: 'decisive' | 'advisory' }
27
+ | { by: string; map: Record<string, string | null> };
28
+
29
+ export interface CouncilConfig {
30
+ profiles: Record<string, CouncilProfile>;
31
+ gates: Record<string, GateBinding>;
32
+ }
33
+
34
+ function isObject(x: unknown): x is Record<string, unknown> {
35
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
36
+ }
37
+
38
+ // Full-replacement (not merge): a consumer council.json wholly replaces the
39
+ // shipped default — matching the qa-rules / security-paths loaders. A partial
40
+ // consumer file (e.g. only `profiles`) intentionally yields empty `gates`,
41
+ // i.e. no bound gates → today's behavior. Per-profile shape validation is a
42
+ // later (validator) slice; this loader normalizes only the top-level containers.
43
+ function normalize(doc: Record<string, unknown>): CouncilConfig {
44
+ return {
45
+ profiles: isObject(doc.profiles) ? (doc.profiles as Record<string, CouncilProfile>) : {},
46
+ gates: isObject(doc.gates) ? (doc.gates as Record<string, GateBinding>) : {},
47
+ };
48
+ }
49
+
50
+ function loadDefaultConfig(): CouncilConfig {
51
+ if (!existsSync(DEFAULT_CONFIG)) {
52
+ throw new Error(`council config not found at ${DEFAULT_CONFIG}`);
53
+ }
54
+ const parsed: unknown = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
55
+ if (!isObject(parsed)) {
56
+ throw new Error(`council config malformed (not an object) at ${DEFAULT_CONFIG}`);
57
+ }
58
+ return normalize(parsed);
59
+ }
60
+
61
+ export function loadCouncilConfigWithSource(
62
+ repoRoot: string,
63
+ ): { config: CouncilConfig; source: 'consumer' | 'default' } {
64
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'council.json');
65
+ if (existsSync(consumerPath)) {
66
+ try {
67
+ const parsed: unknown = JSON.parse(readFileSync(consumerPath, 'utf-8'));
68
+ if (!isObject(parsed)) throw new Error('council.json is not an object');
69
+ return { config: normalize(parsed), source: 'consumer' };
70
+ } catch {
71
+ // malformed / non-object consumer config → fall back to package default
72
+ }
73
+ }
74
+ return { config: loadDefaultConfig(), source: 'default' };
75
+ }
76
+
77
+ export function loadCouncilConfig(repoRoot: string): CouncilConfig {
78
+ return loadCouncilConfigWithSource(repoRoot).config;
79
+ }
@@ -0,0 +1,45 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { councilRunDir } from './paths.js';
4
+ import type { Verdict } from './feedback.js';
5
+ import type { ThresholdRule } from './aggregation.js';
6
+
7
+ export interface CouncilResultMember {
8
+ member: string;
9
+ verdict: Verdict;
10
+ blocking: boolean;
11
+ weight: number;
12
+ }
13
+
14
+ export interface CouncilResult {
15
+ gate: string;
16
+ final_verdict: Verdict;
17
+ rule: ThresholdRule;
18
+ rationale: string;
19
+ 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)
22
+ security: {
23
+ member_verdict: Verdict | 'absent';
24
+ gating_verdict_set: 'pass' | null; // security_review_verdict the council set, if any
25
+ basis: string; // human-readable explanation of the security decision
26
+ };
27
+ }
28
+
29
+ export function councilResultPath(repoRoot: string, taskId: string, gate: string): string {
30
+ return join(councilRunDir(repoRoot, taskId), `${gate}.json`);
31
+ }
32
+
33
+ export function writeCouncilResult(repoRoot: string, taskId: string, result: CouncilResult): string {
34
+ const dir = councilRunDir(repoRoot, taskId);
35
+ mkdirSync(dir, { recursive: true });
36
+ const path = councilResultPath(repoRoot, taskId, result.gate);
37
+ writeFileSync(path, JSON.stringify(result, null, 2) + '\n');
38
+ return path;
39
+ }
40
+
41
+ export function readCouncilResult(repoRoot: string, taskId: string, gate: string): CouncilResult | null {
42
+ const path = councilResultPath(repoRoot, taskId, gate);
43
+ if (!existsSync(path)) return null;
44
+ return JSON.parse(readFileSync(path, 'utf-8')) as CouncilResult;
45
+ }
package/lib/council.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { loadCouncilConfigWithSource, type CouncilConfig, type GateBinding, type WhenPredicate } from './council-config.js';
3
+ import type { ThresholdRule, CouncilVerdict } from './aggregation.js';
4
+ import { loadTask, saveTask, advanceStatus } from './task.js';
5
+ import { writeCouncilResult, type CouncilResult } from './council-result.js';
6
+ import { classifyTaskSecurity } from './security-classify.js';
7
+ import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.js';
8
+
9
+ export interface ResolvedMember {
10
+ member: string;
11
+ blocking: boolean;
12
+ weight: number;
13
+ }
14
+
15
+ export interface CouncilPlan {
16
+ gate: string;
17
+ profile: string | null; // null → no council bound (today's behavior)
18
+ mode: 'decisive' | 'advisory';
19
+ rounds: ResolvedMember[][];
20
+ aggregation: ThresholdRule;
21
+ on_round_bounce: 'stop' | 'continue';
22
+ source: 'consumer' | 'default';
23
+ }
24
+
25
+ interface WhenContext {
26
+ securityHigh: boolean;
27
+ uiChanges: boolean;
28
+ }
29
+
30
+ export function evaluateWhen(predicate: WhenPredicate | undefined, ctx: WhenContext): boolean {
31
+ switch (predicate) {
32
+ case undefined:
33
+ case 'always':
34
+ return true;
35
+ case 'security_class:high':
36
+ return ctx.securityHigh;
37
+ case 'ui_changes':
38
+ return ctx.uiChanges;
39
+ default:
40
+ return false; // unknown predicate → inactive (fail-closed)
41
+ }
42
+ }
43
+
44
+ export function resolveBinding(
45
+ binding: GateBinding | undefined,
46
+ task: Record<string, unknown>,
47
+ ): { profile: string | null; mode: 'decisive' | 'advisory' } {
48
+ if (binding === undefined) return { profile: null, mode: 'decisive' };
49
+ if (typeof binding === 'string') return { profile: binding, mode: 'decisive' };
50
+ if ('profile' in binding) return { profile: binding.profile, mode: binding.mode ?? 'decisive' };
51
+ // conditional selector { by, map }
52
+ const key = String(task[binding.by] ?? '');
53
+ const selected = key in binding.map ? binding.map[key] : (binding.map['*'] ?? null);
54
+ return { profile: selected, mode: 'decisive' };
55
+ }
56
+
57
+ /**
58
+ * Resolve changed files for predicate evaluation. Callers should pass
59
+ * `opts.changedFiles` (e.g. from a prior `git diff`); when omitted we run
60
+ * `git diff main..cloverleaf/<taskId>` and fall back to [] on any git error.
61
+ * Exported for direct testing of the (now orchestrator-live) git path.
62
+ */
63
+ export function resolveChangedFiles(repoRoot: string, taskId: string, opts: { changedFiles?: string[] } = {}): string[] {
64
+ if (opts.changedFiles !== undefined) return opts.changedFiles;
65
+ try {
66
+ const out = execFileSync('git', ['-C', repoRoot, 'diff', '--name-only', `main..cloverleaf/${taskId}`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
67
+ return out.split('\n').filter(Boolean);
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
73
+ export function resolveCouncilPlan(
74
+ repoRoot: string,
75
+ taskId: string,
76
+ gateKey = 'task.review',
77
+ opts: { changedFiles?: string[] } = {},
78
+ ): CouncilPlan {
79
+ const { config, source } = loadCouncilConfigWithSource(repoRoot);
80
+ const task = loadTask(repoRoot, taskId) as unknown as Record<string, unknown>;
81
+
82
+ const { profile: profileName, mode } = resolveBinding(config.gates[gateKey], task);
83
+ const empty: CouncilPlan = {
84
+ gate: gateKey, profile: null, mode, rounds: [],
85
+ aggregation: 'any-veto', on_round_bounce: 'stop', source,
86
+ };
87
+ if (profileName === null) return empty;
88
+
89
+ const profile = config.profiles[profileName];
90
+ if (!profile) {
91
+ if (source === 'consumer') {
92
+ process.stderr.write(
93
+ `cloverleaf-cli council-plan: profile '${profileName}' bound to gate '${gateKey}' not found in council.json; falling back to today's behavior.\n`,
94
+ );
95
+ }
96
+ return empty; // unknown profile → fail toward today's behavior
97
+ }
98
+
99
+ const changed = resolveChangedFiles(repoRoot, taskId, opts);
100
+ const securityHigh = classifyTaskSecurity(repoRoot, taskId, { changedFiles: changed }).effective === 'high';
101
+ const affected = computeAffectedRoutes(changed, loadAffectedRoutesConfig(repoRoot));
102
+ const uiChanges = affected === 'all' || affected.length > 0;
103
+ const ctx: WhenContext = { securityHigh, uiChanges };
104
+
105
+ const rounds: ResolvedMember[][] = [];
106
+ for (const round of profile.rounds) {
107
+ const active = round
108
+ .filter((member) => evaluateWhen(member.when, ctx))
109
+ .map((member) => ({ member: member.member, blocking: member.blocking !== false, weight: member.weight ?? 1 }));
110
+ if (active.length > 0) rounds.push(active);
111
+ }
112
+
113
+ return {
114
+ gate: gateKey,
115
+ profile: profileName,
116
+ mode,
117
+ rounds,
118
+ aggregation: profile.aggregation,
119
+ on_round_bounce: profile.on_round_bounce ?? 'stop',
120
+ source,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Drive the FSM transition implied by a council verdict (the runner's terminal step).
126
+ * Council-authoritative: on a pass it records the council's gating verdict so the
127
+ * v0.8.1 security precondition is satisfied for any high-security gated transition;
128
+ * the per-member basis (incl. an omitted or out-voted `security` member) is written
129
+ * to the result artifact. Walks the minimal legal path to the lane's pre-merge state.
130
+ */
131
+ export function applyCouncilVerdict(
132
+ repoRoot: string,
133
+ taskId: string,
134
+ gate: string,
135
+ council: CouncilVerdict,
136
+ ): CouncilResult {
137
+ if (gate !== 'task.review') {
138
+ throw new Error(
139
+ `apply-council-verdict: gate '${gate}' is not supported yet — the FSM walk is hardcoded for the ` +
140
+ `task.review → merge lane. Binding other gates needs a gate-aware walk (council Slice 3).`,
141
+ );
142
+ }
143
+ const task = loadTask(repoRoot, taskId);
144
+ if (task.status !== 'review') {
145
+ throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'review'`);
146
+ }
147
+ const lane: 'fast' | 'full' = task.risk_class === 'high' ? 'full' : 'fast';
148
+ const securityMember = council.members.find((m) => m.member === 'security');
149
+ const walk: string[] = ['review'];
150
+
151
+ if (council.verdict === 'escalate') {
152
+ advanceStatus(repoRoot, taskId, 'escalated', 'agent');
153
+ walk.push('escalated');
154
+ } else if (council.verdict === 'bounce') {
155
+ advanceStatus(repoRoot, taskId, 'implementing', 'agent');
156
+ walk.push('implementing');
157
+ } else {
158
+ // pass — minimal legal walk; review→automated-gates resets the security verdict,
159
+ // so set the council's gating verdict AFTER that transition.
160
+ advanceStatus(repoRoot, taskId, 'automated-gates', 'agent');
161
+ walk.push('automated-gates');
162
+ const atGates = loadTask(repoRoot, taskId);
163
+ atGates.security_review_verdict = 'pass';
164
+ saveTask(repoRoot, atGates);
165
+ if (lane === 'full') {
166
+ advanceStatus(repoRoot, taskId, 'qa', 'agent', { path: 'full_pipeline' });
167
+ walk.push('qa');
168
+ advanceStatus(repoRoot, taskId, 'final-gate', 'agent', { path: 'full_pipeline' });
169
+ walk.push('final-gate');
170
+ }
171
+ }
172
+
173
+ const qaTraversedAdministratively =
174
+ lane === 'full' && walk.includes('qa') && !council.members.some((m) => m.member === 'qa');
175
+
176
+ const result: CouncilResult = {
177
+ gate,
178
+ final_verdict: council.verdict,
179
+ rule: council.rule,
180
+ rationale: council.rationale,
181
+ members: council.members.map((m) => ({
182
+ member: m.member,
183
+ verdict: m.verdict,
184
+ blocking: m.blocking !== false,
185
+ weight: m.weight ?? 1,
186
+ })),
187
+ walk,
188
+ ...(qaTraversedAdministratively
189
+ ? { walk_note: 'qa state traversed administratively; no qa member ran' }
190
+ : {}),
191
+ security: {
192
+ member_verdict: securityMember ? securityMember.verdict : 'absent',
193
+ gating_verdict_set: council.verdict === 'pass' ? 'pass' : null,
194
+ basis: !securityMember
195
+ ? 'no security member configured; advanced under council authority'
196
+ : securityMember.verdict === 'pass'
197
+ ? 'security member passed'
198
+ : `security member returned '${securityMember.verdict}'; council ${council.verdict} by rule ${JSON.stringify(council.rule)}`,
199
+ },
200
+ };
201
+ writeCouncilResult(repoRoot, taskId, result);
202
+ return result;
203
+ }
@@ -10,6 +10,7 @@ export interface DiscoveryConfig {
10
10
  projectId: string;
11
11
  idStart: number;
12
12
  prep_copy_dirs: string[];
13
+ worktree_setup_command: string;
13
14
  }
14
15
 
15
16
  export function loadDiscoveryConfig(repoRoot: string): DiscoveryConfig {
@@ -22,6 +23,8 @@ export function loadDiscoveryConfig(repoRoot: string): DiscoveryConfig {
22
23
  prep_copy_dirs: Array.isArray(rawFallback.prep_copy_dirs)
23
24
  ? (rawFallback.prep_copy_dirs as unknown[]).filter((p): p is string => typeof p === 'string')
24
25
  : [],
26
+ worktree_setup_command:
27
+ typeof rawFallback.worktree_setup_command === 'string' ? rawFallback.worktree_setup_command : '',
25
28
  };
26
29
 
27
30
  if (existsSync(override)) {
@@ -43,5 +46,7 @@ function normalise(doc: Partial<DiscoveryConfig>, fallback: DiscoveryConfig): Di
43
46
  prep_copy_dirs: Array.isArray(doc.prep_copy_dirs)
44
47
  ? (doc.prep_copy_dirs as unknown[]).filter((p): p is string => typeof p === 'string')
45
48
  : fallback.prep_copy_dirs,
49
+ worktree_setup_command:
50
+ typeof doc.worktree_setup_command === 'string' ? doc.worktree_setup_command : fallback.worktree_setup_command,
46
51
  };
47
52
  }
package/lib/paths.ts CHANGED
@@ -41,3 +41,7 @@ export function runsDir(repoRoot: string): string {
41
41
  export function uiReviewRunDir(repoRoot: string, taskId: string): string {
42
42
  return resolve(runsDir(repoRoot), taskId, 'ui-review');
43
43
  }
44
+
45
+ export function councilRunDir(repoRoot: string, taskId: string): string {
46
+ return resolve(runsDir(repoRoot), taskId, 'council');
47
+ }