@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.
- package/.claude-plugin/plugin.json +1 -1
- package/VERSION +1 -1
- package/config/council.json +19 -0
- package/config/discovery.json +2 -1
- package/dist/aggregation.mjs +60 -0
- package/dist/cli.mjs +64 -0
- package/dist/council-config.mjs +47 -0
- package/dist/council-result.mjs +19 -0
- package/dist/council.mjs +159 -0
- package/dist/discovery-config.mjs +2 -0
- package/dist/paths.mjs +3 -0
- package/dist/prep-worktree.mjs +51 -36
- package/dist/qa-report.mjs +13 -0
- package/dist/rfc-tasks.mjs +1 -2
- package/dist/security-classify.mjs +0 -0
- package/lib/aggregation.ts +87 -0
- package/lib/cli.ts +67 -0
- package/lib/council-config.ts +79 -0
- package/lib/council-result.ts +45 -0
- package/lib/council.ts +203 -0
- package/lib/discovery-config.ts +5 -0
- package/lib/paths.ts +4 -0
- package/lib/prep-worktree.ts +54 -38
- package/lib/qa-report.ts +15 -0
- package/lib/rfc-tasks.ts +1 -2
- package/lib/security-classify.ts +0 -0
- package/package.json +1 -1
- package/prompts/implementer.md +3 -2
- package/prompts/qa.md +18 -10
- package/prompts/reviewer.md +8 -4
- package/skills/cloverleaf-implement/SKILL.md +11 -0
- package/skills/cloverleaf-review/SKILL.md +11 -1
- package/skills/cloverleaf-run/SKILL.md +34 -3
|
@@ -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
|
+
}
|
package/lib/discovery-config.ts
CHANGED
|
@@ -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
|
+
}
|