@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cloverleaf",
3
3
  "description": "Cloverleaf reference implementation — Claude Code skills for task scaffolding and the Delivery pipeline (implementer, documenter, reviewer, UI reviewer with multi-viewport visual diff, QA, merge, release).",
4
- "version": "0.8.0",
4
+ "version": "0.10.0",
5
5
  "author": {
6
6
  "name": "Renato D'Arrigo",
7
7
  "email": "renato.darrigo@gmail.com"
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.8.6
1
+ 0.10.0
@@ -0,0 +1,19 @@
1
+ {
2
+ "profiles": {
3
+ "default": {
4
+ "rounds": [
5
+ [ { "member": "reviewer" } ],
6
+ [
7
+ { "member": "security", "when": "security_class:high" },
8
+ { "member": "ui", "when": "ui_changes" },
9
+ { "member": "qa" }
10
+ ]
11
+ ],
12
+ "aggregation": "any-veto",
13
+ "on_round_bounce": "stop"
14
+ }
15
+ },
16
+ "gates": {
17
+ "task.review": "default"
18
+ }
19
+ }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "docContextUri": "",
3
3
  "projectId": "",
4
- "idStart": 1
4
+ "idStart": 1,
5
+ "worktree_setup_command": ""
5
6
  }
@@ -0,0 +1,60 @@
1
+ export function aggregate(members, rule, opts = {}) {
2
+ // Safety invariant: any member that escalates short-circuits, regardless of
3
+ // blocking flag or rule — a hard blocker cannot be out-voted.
4
+ const escalators = members.filter((x) => x.verdict === 'escalate');
5
+ if (escalators.length > 0) {
6
+ return {
7
+ verdict: 'escalate',
8
+ rule,
9
+ members,
10
+ rationale: `escalated by ${escalators.map((x) => x.member).join(', ')}`,
11
+ };
12
+ }
13
+ // Only blocking members gate the pass/bounce decision.
14
+ const blocking = members.filter((x) => x.blocking !== false);
15
+ if (blocking.length === 0) {
16
+ return { verdict: 'pass', rule, members, rationale: 'no blocking members' };
17
+ }
18
+ const passes = blocking.filter((x) => x.verdict === 'pass');
19
+ const passCount = passes.length;
20
+ const total = blocking.length;
21
+ let pass;
22
+ let detail;
23
+ if (rule === 'any-veto') {
24
+ pass = passCount === total;
25
+ detail = `any-veto: ${passCount}/${total} passed`;
26
+ }
27
+ else if (rule === 'unanimous') {
28
+ // Currently identical to any-veto over blocking members. Kept as a distinct
29
+ // named rule for clarity and possible future divergence; do not merge.
30
+ pass = passCount === total;
31
+ detail = `unanimous: ${passCount}/${total} passed`;
32
+ }
33
+ else if (rule === 'majority') {
34
+ pass = passCount * 2 > total; // strict majority; ties → bounce
35
+ detail = `majority: ${passCount}/${total} passed`;
36
+ }
37
+ else if (rule === 'weighted') {
38
+ const totalWeight = blocking.reduce((s, x) => s + (x.weight ?? 1), 0);
39
+ const passWeight = passes.reduce((s, x) => s + (x.weight ?? 1), 0);
40
+ const threshold = opts.weightedThreshold;
41
+ pass = threshold !== undefined ? passWeight >= threshold : passWeight * 2 > totalWeight;
42
+ detail =
43
+ `weighted: passWeight ${passWeight}/${totalWeight}` +
44
+ (threshold !== undefined ? ` (threshold ${threshold})` : '');
45
+ }
46
+ else if (typeof rule === 'object' && rule !== null && 'quorum' in rule) {
47
+ const k = rule.quorum;
48
+ pass = passCount >= k;
49
+ detail = `quorum(${k}): ${passCount}/${total} passed`;
50
+ }
51
+ else {
52
+ throw new Error(`aggregate: unknown aggregation rule ${JSON.stringify(rule)}`);
53
+ }
54
+ return {
55
+ verdict: pass ? 'pass' : 'bounce',
56
+ rule,
57
+ members,
58
+ rationale: `${pass ? 'pass' : 'bounce'} — ${detail}`,
59
+ };
60
+ }
package/dist/cli.mjs 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
  import { readFileSync, mkdirSync, copyFileSync, appendFileSync } from 'node:fs';
46
50
  import { dirname, join } from 'node:path';
@@ -61,6 +65,7 @@ import { loadSpike, saveSpike, advanceSpikeStatus } from './spike.mjs';
61
65
  import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan } from './plan.mjs';
62
66
  import { loadDiscoveryConfig } from './discovery-config.mjs';
63
67
  import { prepWorktree } from './prep-worktree.mjs';
68
+ import { writeQaReportFromFile } from './qa-report.mjs';
64
69
  import { readUiReviewState, writeUiReviewState } from './ui-review-state.mjs';
65
70
  import { buildBaselinePath } from './visual-diff.mjs';
66
71
  import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
@@ -70,6 +75,8 @@ import { classifyFiles, normalizePath } from './scope-check.mjs';
70
75
  import { computeRfcTasksView } from './rfc-tasks.mjs';
71
76
  import { loadSecretPatternsConfig, scanSecrets } from './secret-scan.mjs';
72
77
  import { classifyTaskSecurity } from './security-classify.mjs';
78
+ import { resolveCouncilPlan, applyCouncilVerdict } from './council.mjs';
79
+ import { aggregate } from './aggregation.mjs';
73
80
  function die(msg, code = 1) {
74
81
  process.stderr.write(msg + '\n');
75
82
  process.exit(code);
@@ -105,6 +112,7 @@ function usage(msg) {
105
112
  ' next-work-item-id <repoRoot> <project>\n' +
106
113
  ' discovery-config --repo-root <repoRoot>\n' +
107
114
  ' prep-worktree <mainRoot> <worktreePath>\n' +
115
+ ' qa-report <runs.json> <out.html>\n' +
108
116
  ' dag-ready-tasks <repoRoot> <planId> <maxConcurrent>\n' +
109
117
  ' dag-detect-cycle <repoRoot> <planId>\n' +
110
118
  ' walk-state-read <repoRoot> <planId>\n' +
@@ -114,6 +122,9 @@ function usage(msg) {
114
122
  ' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n' +
115
123
  ' secret-scan <repoRoot> --branch <branch>\n' +
116
124
  ' classify-security <repoRoot> <taskId> [--branch <branch>]\n' +
125
+ ' council-plan <repoRoot> <taskId> [gateKey] [--changed-files=a,b,c]\n' +
126
+ ' aggregate-verdicts <membersJson> <rule> [--weighted-threshold=N]\n' +
127
+ ' apply-council-verdict <repoRoot> <taskId> <gate> <councilVerdictJson>\n' +
117
128
  ' set-task-field <repoRoot> <taskId> <field> <value>\n');
118
129
  process.exit(2);
119
130
  }
@@ -485,6 +496,13 @@ try {
485
496
  prepWorktree(mainRoot, worktreePath);
486
497
  break;
487
498
  }
499
+ case 'qa-report': {
500
+ const [runsJsonPath, outHtmlPath] = rest;
501
+ if (!runsJsonPath || !outHtmlPath)
502
+ usage('qa-report requires <runs.json> <out.html>');
503
+ writeQaReportFromFile(runsJsonPath, outHtmlPath);
504
+ break;
505
+ }
488
506
  case 'dag-ready-tasks': {
489
507
  const [repoRoot, planId, maxConcurrentStr] = rest;
490
508
  if (!repoRoot || !planId || !maxConcurrentStr)
@@ -796,6 +814,52 @@ try {
796
814
  process.stdout.write(JSON.stringify(result) + '\n');
797
815
  break;
798
816
  }
817
+ case 'council-plan': {
818
+ const positional = rest.filter((a) => !a.startsWith('--'));
819
+ const flags = rest.filter((a) => a.startsWith('--'));
820
+ const [repoRoot, taskId, gateKey] = positional;
821
+ if (!repoRoot || !taskId)
822
+ usage('council-plan requires <repoRoot> <taskId> [gateKey]');
823
+ const cf = flags.find((f) => f.startsWith('--changed-files='));
824
+ const changedFiles = cf !== undefined
825
+ ? cf.replace('--changed-files=', '').split(',').filter(Boolean)
826
+ : undefined;
827
+ const plan = resolveCouncilPlan(repoRoot, taskId, gateKey || 'task.review', changedFiles !== undefined ? { changedFiles } : {});
828
+ process.stdout.write(JSON.stringify(plan) + '\n');
829
+ break;
830
+ }
831
+ case 'aggregate-verdicts': {
832
+ const positional = rest.filter((a) => !a.startsWith('--'));
833
+ const flags = rest.filter((a) => a.startsWith('--'));
834
+ const [membersJson, ruleArg] = positional;
835
+ if (!membersJson || !ruleArg)
836
+ usage('aggregate-verdicts requires <membersJson> <rule>');
837
+ const members = JSON.parse(membersJson);
838
+ let rule;
839
+ if (ruleArg.startsWith('quorum:')) {
840
+ const quorumN = parseInt(ruleArg.split(':')[1], 10);
841
+ if (Number.isNaN(quorumN) || quorumN < 1) {
842
+ usage(`aggregate-verdicts: quorum value must be a positive integer, got '${ruleArg}'`);
843
+ }
844
+ rule = { quorum: quorumN };
845
+ }
846
+ else {
847
+ rule = ruleArg;
848
+ }
849
+ const wt = flags.find((f) => f.startsWith('--weighted-threshold='));
850
+ const opts = wt ? { weightedThreshold: parseFloat(wt.replace('--weighted-threshold=', '')) } : {};
851
+ process.stdout.write(JSON.stringify(aggregate(members, rule, opts)) + '\n');
852
+ break;
853
+ }
854
+ case 'apply-council-verdict': {
855
+ const [repoRoot, taskId, gate, verdictJson] = rest;
856
+ if (!repoRoot || !taskId || !gate || !verdictJson)
857
+ usage('apply-council-verdict requires <repoRoot> <taskId> <gate> <councilVerdictJson>');
858
+ const council = JSON.parse(verdictJson);
859
+ const result = applyCouncilVerdict(repoRoot, taskId, gate, council);
860
+ process.stdout.write(JSON.stringify(result) + '\n');
861
+ break;
862
+ }
799
863
  case 'set-task-field': {
800
864
  const [repoRoot, taskId, field, value] = rest;
801
865
  if (!repoRoot || !taskId || !field || value === undefined)
@@ -0,0 +1,47 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ const here = dirname(fileURLToPath(import.meta.url));
5
+ const DEFAULT_CONFIG = join(here, '..', 'config', 'council.json');
6
+ function isObject(x) {
7
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
8
+ }
9
+ // Full-replacement (not merge): a consumer council.json wholly replaces the
10
+ // shipped default — matching the qa-rules / security-paths loaders. A partial
11
+ // consumer file (e.g. only `profiles`) intentionally yields empty `gates`,
12
+ // i.e. no bound gates → today's behavior. Per-profile shape validation is a
13
+ // later (validator) slice; this loader normalizes only the top-level containers.
14
+ function normalize(doc) {
15
+ return {
16
+ profiles: isObject(doc.profiles) ? doc.profiles : {},
17
+ gates: isObject(doc.gates) ? doc.gates : {},
18
+ };
19
+ }
20
+ function loadDefaultConfig() {
21
+ if (!existsSync(DEFAULT_CONFIG)) {
22
+ throw new Error(`council config not found at ${DEFAULT_CONFIG}`);
23
+ }
24
+ const parsed = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
25
+ if (!isObject(parsed)) {
26
+ throw new Error(`council config malformed (not an object) at ${DEFAULT_CONFIG}`);
27
+ }
28
+ return normalize(parsed);
29
+ }
30
+ export function loadCouncilConfigWithSource(repoRoot) {
31
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'council.json');
32
+ if (existsSync(consumerPath)) {
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(consumerPath, 'utf-8'));
35
+ if (!isObject(parsed))
36
+ throw new Error('council.json is not an object');
37
+ return { config: normalize(parsed), source: 'consumer' };
38
+ }
39
+ catch {
40
+ // malformed / non-object consumer config → fall back to package default
41
+ }
42
+ }
43
+ return { config: loadDefaultConfig(), source: 'default' };
44
+ }
45
+ export function loadCouncilConfig(repoRoot) {
46
+ return loadCouncilConfigWithSource(repoRoot).config;
47
+ }
@@ -0,0 +1,19 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { councilRunDir } from './paths.mjs';
4
+ export function councilResultPath(repoRoot, taskId, gate) {
5
+ return join(councilRunDir(repoRoot, taskId), `${gate}.json`);
6
+ }
7
+ export function writeCouncilResult(repoRoot, taskId, result) {
8
+ const dir = councilRunDir(repoRoot, taskId);
9
+ mkdirSync(dir, { recursive: true });
10
+ const path = councilResultPath(repoRoot, taskId, result.gate);
11
+ writeFileSync(path, JSON.stringify(result, null, 2) + '\n');
12
+ return path;
13
+ }
14
+ export function readCouncilResult(repoRoot, taskId, gate) {
15
+ const path = councilResultPath(repoRoot, taskId, gate);
16
+ if (!existsSync(path))
17
+ return null;
18
+ return JSON.parse(readFileSync(path, 'utf-8'));
19
+ }
@@ -0,0 +1,159 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { loadCouncilConfigWithSource } from './council-config.mjs';
3
+ import { loadTask, saveTask, advanceStatus } from './task.mjs';
4
+ import { writeCouncilResult } from './council-result.mjs';
5
+ import { classifyTaskSecurity } from './security-classify.mjs';
6
+ import { loadAffectedRoutesConfig, computeAffectedRoutes } from './affected-routes.mjs';
7
+ export function evaluateWhen(predicate, ctx) {
8
+ switch (predicate) {
9
+ case undefined:
10
+ case 'always':
11
+ return true;
12
+ case 'security_class:high':
13
+ return ctx.securityHigh;
14
+ case 'ui_changes':
15
+ return ctx.uiChanges;
16
+ default:
17
+ return false; // unknown predicate → inactive (fail-closed)
18
+ }
19
+ }
20
+ export function resolveBinding(binding, task) {
21
+ if (binding === undefined)
22
+ return { profile: null, mode: 'decisive' };
23
+ if (typeof binding === 'string')
24
+ return { profile: binding, mode: 'decisive' };
25
+ if ('profile' in binding)
26
+ return { profile: binding.profile, mode: binding.mode ?? 'decisive' };
27
+ // conditional selector { by, map }
28
+ const key = String(task[binding.by] ?? '');
29
+ const selected = key in binding.map ? binding.map[key] : (binding.map['*'] ?? null);
30
+ return { profile: selected, mode: 'decisive' };
31
+ }
32
+ /**
33
+ * Resolve changed files for predicate evaluation. Callers should pass
34
+ * `opts.changedFiles` (e.g. from a prior `git diff`); when omitted we run
35
+ * `git diff main..cloverleaf/<taskId>` and fall back to [] on any git error.
36
+ * Exported for direct testing of the (now orchestrator-live) git path.
37
+ */
38
+ export function resolveChangedFiles(repoRoot, taskId, opts = {}) {
39
+ if (opts.changedFiles !== undefined)
40
+ return opts.changedFiles;
41
+ try {
42
+ const out = execFileSync('git', ['-C', repoRoot, 'diff', '--name-only', `main..cloverleaf/${taskId}`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
43
+ return out.split('\n').filter(Boolean);
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ }
49
+ export function resolveCouncilPlan(repoRoot, taskId, gateKey = 'task.review', opts = {}) {
50
+ const { config, source } = loadCouncilConfigWithSource(repoRoot);
51
+ const task = loadTask(repoRoot, taskId);
52
+ const { profile: profileName, mode } = resolveBinding(config.gates[gateKey], task);
53
+ const empty = {
54
+ gate: gateKey, profile: null, mode, rounds: [],
55
+ aggregation: 'any-veto', on_round_bounce: 'stop', source,
56
+ };
57
+ if (profileName === null)
58
+ return empty;
59
+ const profile = config.profiles[profileName];
60
+ if (!profile) {
61
+ if (source === 'consumer') {
62
+ process.stderr.write(`cloverleaf-cli council-plan: profile '${profileName}' bound to gate '${gateKey}' not found in council.json; falling back to today's behavior.\n`);
63
+ }
64
+ return empty; // unknown profile → fail toward today's behavior
65
+ }
66
+ const changed = resolveChangedFiles(repoRoot, taskId, opts);
67
+ const securityHigh = classifyTaskSecurity(repoRoot, taskId, { changedFiles: changed }).effective === 'high';
68
+ const affected = computeAffectedRoutes(changed, loadAffectedRoutesConfig(repoRoot));
69
+ const uiChanges = affected === 'all' || affected.length > 0;
70
+ const ctx = { securityHigh, uiChanges };
71
+ const rounds = [];
72
+ for (const round of profile.rounds) {
73
+ const active = round
74
+ .filter((member) => evaluateWhen(member.when, ctx))
75
+ .map((member) => ({ member: member.member, blocking: member.blocking !== false, weight: member.weight ?? 1 }));
76
+ if (active.length > 0)
77
+ rounds.push(active);
78
+ }
79
+ return {
80
+ gate: gateKey,
81
+ profile: profileName,
82
+ mode,
83
+ rounds,
84
+ aggregation: profile.aggregation,
85
+ on_round_bounce: profile.on_round_bounce ?? 'stop',
86
+ source,
87
+ };
88
+ }
89
+ /**
90
+ * Drive the FSM transition implied by a council verdict (the runner's terminal step).
91
+ * Council-authoritative: on a pass it records the council's gating verdict so the
92
+ * v0.8.1 security precondition is satisfied for any high-security gated transition;
93
+ * the per-member basis (incl. an omitted or out-voted `security` member) is written
94
+ * to the result artifact. Walks the minimal legal path to the lane's pre-merge state.
95
+ */
96
+ export function applyCouncilVerdict(repoRoot, taskId, gate, council) {
97
+ if (gate !== 'task.review') {
98
+ throw new Error(`apply-council-verdict: gate '${gate}' is not supported yet — the FSM walk is hardcoded for the ` +
99
+ `task.review → merge lane. Binding other gates needs a gate-aware walk (council Slice 3).`);
100
+ }
101
+ const task = loadTask(repoRoot, taskId);
102
+ if (task.status !== 'review') {
103
+ throw new Error(`apply-council-verdict: task ${taskId} is '${task.status}', expected 'review'`);
104
+ }
105
+ const lane = task.risk_class === 'high' ? 'full' : 'fast';
106
+ const securityMember = council.members.find((m) => m.member === 'security');
107
+ const walk = ['review'];
108
+ if (council.verdict === 'escalate') {
109
+ advanceStatus(repoRoot, taskId, 'escalated', 'agent');
110
+ walk.push('escalated');
111
+ }
112
+ else if (council.verdict === 'bounce') {
113
+ advanceStatus(repoRoot, taskId, 'implementing', 'agent');
114
+ walk.push('implementing');
115
+ }
116
+ else {
117
+ // pass — minimal legal walk; review→automated-gates resets the security verdict,
118
+ // so set the council's gating verdict AFTER that transition.
119
+ advanceStatus(repoRoot, taskId, 'automated-gates', 'agent');
120
+ walk.push('automated-gates');
121
+ const atGates = loadTask(repoRoot, taskId);
122
+ atGates.security_review_verdict = 'pass';
123
+ saveTask(repoRoot, atGates);
124
+ if (lane === 'full') {
125
+ advanceStatus(repoRoot, taskId, 'qa', 'agent', { path: 'full_pipeline' });
126
+ walk.push('qa');
127
+ advanceStatus(repoRoot, taskId, 'final-gate', 'agent', { path: 'full_pipeline' });
128
+ walk.push('final-gate');
129
+ }
130
+ }
131
+ const qaTraversedAdministratively = lane === 'full' && walk.includes('qa') && !council.members.some((m) => m.member === 'qa');
132
+ const result = {
133
+ gate,
134
+ final_verdict: council.verdict,
135
+ rule: council.rule,
136
+ rationale: council.rationale,
137
+ members: council.members.map((m) => ({
138
+ member: m.member,
139
+ verdict: m.verdict,
140
+ blocking: m.blocking !== false,
141
+ weight: m.weight ?? 1,
142
+ })),
143
+ walk,
144
+ ...(qaTraversedAdministratively
145
+ ? { walk_note: 'qa state traversed administratively; no qa member ran' }
146
+ : {}),
147
+ security: {
148
+ member_verdict: securityMember ? securityMember.verdict : 'absent',
149
+ gating_verdict_set: council.verdict === 'pass' ? 'pass' : null,
150
+ basis: !securityMember
151
+ ? 'no security member configured; advanced under council authority'
152
+ : securityMember.verdict === 'pass'
153
+ ? 'security member passed'
154
+ : `security member returned '${securityMember.verdict}'; council ${council.verdict} by rule ${JSON.stringify(council.rule)}`,
155
+ },
156
+ };
157
+ writeCouncilResult(repoRoot, taskId, result);
158
+ return result;
159
+ }
@@ -13,6 +13,7 @@ export function loadDiscoveryConfig(repoRoot) {
13
13
  prep_copy_dirs: Array.isArray(rawFallback.prep_copy_dirs)
14
14
  ? rawFallback.prep_copy_dirs.filter((p) => typeof p === 'string')
15
15
  : [],
16
+ worktree_setup_command: typeof rawFallback.worktree_setup_command === 'string' ? rawFallback.worktree_setup_command : '',
16
17
  };
17
18
  if (existsSync(override)) {
18
19
  try {
@@ -33,5 +34,6 @@ function normalise(doc, fallback) {
33
34
  prep_copy_dirs: Array.isArray(doc.prep_copy_dirs)
34
35
  ? doc.prep_copy_dirs.filter((p) => typeof p === 'string')
35
36
  : fallback.prep_copy_dirs,
37
+ worktree_setup_command: typeof doc.worktree_setup_command === 'string' ? doc.worktree_setup_command : fallback.worktree_setup_command,
36
38
  };
37
39
  }
package/dist/paths.mjs CHANGED
@@ -30,3 +30,6 @@ export function runsDir(repoRoot) {
30
30
  export function uiReviewRunDir(repoRoot, taskId) {
31
31
  return resolve(runsDir(repoRoot), taskId, 'ui-review');
32
32
  }
33
+ export function councilRunDir(repoRoot, taskId) {
34
+ return resolve(runsDir(repoRoot), taskId, 'council');
35
+ }
@@ -87,42 +87,61 @@ function buildMissingNodeModulesError(mainRoot) {
87
87
  return new Error(`main missing standard/node_modules at ${mainStandardNm} — run \`npm ci\` in main's standard/ first`);
88
88
  }
89
89
  export function prepWorktree(mainRoot, worktreePath) {
90
- const wtStandardPkg = join(worktreePath, 'standard', 'package.json');
91
- const wtRefImplPkg = join(worktreePath, 'reference-impl', 'package.json');
92
- if (!existsSync(wtStandardPkg)) {
93
- throw new Error(`worktree missing standard/package.json at ${wtStandardPkg}`);
90
+ const embedded = existsSync(join(worktreePath, 'standard', 'package.json')) &&
91
+ existsSync(join(worktreePath, 'reference-impl', 'package.json'));
92
+ // configRoot is where .cloverleaf/config/discovery.json is read from. Embedded mode
93
+ // walks up to the primary repo (which holds node_modules + the config); a non-monorepo
94
+ // consumer uses mainRoot directly.
95
+ let configRoot;
96
+ if (embedded) {
97
+ const resolvedMain = findPrimaryRoot(mainRoot);
98
+ if (resolvedMain === null) {
99
+ throw buildMissingNodeModulesError(mainRoot);
100
+ }
101
+ configRoot = resolvedMain;
102
+ }
103
+ else {
104
+ configRoot = mainRoot;
105
+ }
106
+ const config = loadDiscoveryConfig(configRoot);
107
+ if (embedded) {
108
+ // Embedded / monorepo mode: prime the cloverleaf TS tooling (unchanged behavior).
109
+ copyEmbeddedArtifacts(configRoot, worktreePath);
94
110
  }
95
- if (!existsSync(wtRefImplPkg)) {
96
- throw new Error(`worktree missing reference-impl/package.json at ${wtRefImplPkg}`);
111
+ // Both modes: copy any gitignored dirs the consumer's tests/briefs reference.
112
+ copyPrepDirs(configRoot, config.prep_copy_dirs, worktreePath);
113
+ if (embedded) {
114
+ // Build standard/ fresh from the worktree's own sources.
115
+ execSync('npm run build', {
116
+ cwd: join(worktreePath, 'standard'),
117
+ stdio: 'pipe',
118
+ });
97
119
  }
98
- // Resolve the actual primary repo root: start from mainRoot and walk up until we find a
99
- // directory containing both standard/node_modules and reference-impl/node_modules.
100
- const resolvedMain = findPrimaryRoot(mainRoot);
101
- if (resolvedMain === null) {
102
- throw buildMissingNodeModulesError(mainRoot);
120
+ // Both modes: run the consumer's worktree setup command, if configured.
121
+ if (config.worktree_setup_command.trim() !== '') {
122
+ execSync(config.worktree_setup_command, {
123
+ cwd: worktreePath,
124
+ stdio: 'pipe',
125
+ });
103
126
  }
104
- const mainStandardNm = join(resolvedMain, 'standard', 'node_modules');
105
- const mainRefImplNm = join(resolvedMain, 'reference-impl', 'node_modules');
106
- const wtStandardNm = join(worktreePath, 'standard', 'node_modules');
107
- const wtRefImplNm = join(worktreePath, 'reference-impl', 'node_modules');
108
- // verbatimSymlinks keeps relative symlink targets byte-identical, so the @cloverleaf/standard
109
- // link in reference-impl/node_modules/ resolves against the worktree after copy.
110
- //
111
- // primeCopy wipes the destination before cpSync. Two reasons:
112
- // 1. Idempotence: a partial prior run (or a re-invocation after a test failure) may
113
- // leave partial state; we must not trip on it.
114
- // 2. cpSync with verbatimSymlinks: true does not reliably overwrite an existing
115
- // symlink at the destination even with force: true (CLV-20 Reviewer repro was
116
- // EEXIST on vite/node_modules/.bin on second invocation).
117
- primeCopy(mainStandardNm, wtStandardNm);
118
- primeCopy(mainRefImplNm, wtRefImplNm);
127
+ }
128
+ /**
129
+ * Embedded / monorepo mode: copy the primary repo's installed cloverleaf TS deps + built
130
+ * dist into the worktree, preserving the @cloverleaf/standard relative symlink. (See the
131
+ * file header for the CLV-16/17/37/52 history.)
132
+ */
133
+ function copyEmbeddedArtifacts(resolvedMain, worktreePath) {
134
+ primeCopy(join(resolvedMain, 'standard', 'node_modules'), join(worktreePath, 'standard', 'node_modules'));
135
+ primeCopy(join(resolvedMain, 'reference-impl', 'node_modules'), join(worktreePath, 'reference-impl', 'node_modules'));
119
136
  primeCopy(join(resolvedMain, 'reference-impl', 'dist'), join(worktreePath, 'reference-impl', 'dist'));
120
- // Honor discovery_config.prep_copy_dirs: copy each listed gitignored directory
121
- // (e.g., docs/superpowers) from mainRoot into the worktree. Walker briefs reference
122
- // these paths but git checkouts of main don't carry gitignored content.
123
- const discoveryConfig = loadDiscoveryConfig(resolvedMain);
124
- for (const dir of discoveryConfig.prep_copy_dirs) {
125
- const srcPath = join(resolvedMain, dir);
137
+ }
138
+ /**
139
+ * Honor discovery_config.prep_copy_dirs: copy each listed gitignored directory from
140
+ * configRoot into the worktree. Missing entries warn and are skipped.
141
+ */
142
+ function copyPrepDirs(configRoot, dirs, worktreePath) {
143
+ for (const dir of dirs) {
144
+ const srcPath = join(configRoot, dir);
126
145
  const dstPath = join(worktreePath, dir);
127
146
  if (!existsSync(srcPath)) {
128
147
  process.stderr.write(`prep-worktree: prep_copy_dirs entry '${dir}' not found at ${srcPath} — skipping.\n`);
@@ -130,10 +149,6 @@ export function prepWorktree(mainRoot, worktreePath) {
130
149
  }
131
150
  primeCopy(srcPath, dstPath);
132
151
  }
133
- execSync('npm run build', {
134
- cwd: join(worktreePath, 'standard'),
135
- stdio: 'pipe',
136
- });
137
152
  }
138
153
  function primeCopy(src, dst) {
139
154
  if (existsSync(dst)) {
@@ -1,3 +1,5 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
1
3
  function escape(s) {
2
4
  if (s === undefined || s === null)
3
5
  return '';
@@ -28,6 +30,17 @@ function renderRow(r) {
28
30
  </tr>
29
31
  `;
30
32
  }
33
+ /**
34
+ * Read a QA runs JSON file (array of QaRunResult), render the HTML report, and write it to
35
+ * outHtmlPath, creating the parent directory if needed. Lets QA write its report via the
36
+ * cloverleaf-cli bin instead of importing a monorepo dist path.
37
+ */
38
+ export function writeQaReportFromFile(runsJsonPath, outHtmlPath) {
39
+ const runs = JSON.parse(readFileSync(runsJsonPath, 'utf-8'));
40
+ const html = renderQaReport(runs);
41
+ mkdirSync(dirname(outHtmlPath), { recursive: true });
42
+ writeFileSync(outHtmlPath, html, 'utf-8');
43
+ }
31
44
  export function renderQaReport(runs) {
32
45
  const empty = runs.length === 0
33
46
  ? `<p class="empty">No runs / results.</p>`
@@ -3,8 +3,7 @@ import { join } from 'node:path';
3
3
  import { plansDir, tasksDir, rfcsDir } from './paths.mjs';
4
4
  /**
5
5
  * A task is "standalone" (RFC-direct) iff it has no parent (absent or null)
6
- * AND it has a non-empty context.rfc.id. See
7
- * docs/superpowers/specs/2026-05-12-rfc-direct-tasks-design.md §"Discriminator".
6
+ * AND it has a non-empty context.rfc.id.
8
7
  */
9
8
  export function isStandaloneTask(task) {
10
9
  const parent = task.parent;
Binary file