@mcptoolshop/style-dataset-lab 2.2.1 → 3.0.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.es.md +76 -144
  3. package/README.fr.md +75 -143
  4. package/README.hi.md +75 -143
  5. package/README.it.md +76 -144
  6. package/README.ja.md +75 -143
  7. package/README.md +36 -8
  8. package/README.pt-BR.md +75 -143
  9. package/README.zh.md +76 -144
  10. package/SECURITY.md +3 -1
  11. package/bin/sdlab.js +146 -17
  12. package/lib/adapters/comfyui-runner.js +170 -0
  13. package/lib/adapters/comfyui-workflows.js +163 -0
  14. package/lib/batch-compiler.js +118 -0
  15. package/lib/batch-modes.js +111 -0
  16. package/lib/batch-runs.js +168 -0
  17. package/lib/batch-sheet-render.js +206 -0
  18. package/lib/brief-compiler.js +598 -0
  19. package/lib/brief-render.js +205 -0
  20. package/lib/critique-engine.js +413 -0
  21. package/lib/critique-render.js +171 -0
  22. package/lib/errors.js +82 -0
  23. package/lib/generated-provenance.js +87 -0
  24. package/lib/log.js +36 -0
  25. package/lib/paths.js +31 -0
  26. package/lib/refine-briefs.js +329 -0
  27. package/lib/reingest-selected.js +173 -0
  28. package/lib/run-summary.js +131 -0
  29. package/lib/runtime-runs.js +168 -0
  30. package/lib/selections.js +323 -0
  31. package/lib/snapshot.js +5 -2
  32. package/lib/split.js +5 -2
  33. package/lib/workflow-profiles.js +161 -0
  34. package/package.json +8 -6
  35. package/scripts/batch-generate.js +167 -0
  36. package/scripts/batch-sheet.js +46 -0
  37. package/scripts/batch-show.js +86 -0
  38. package/scripts/brief-compile.js +134 -0
  39. package/scripts/brief-show.js +52 -0
  40. package/scripts/critique-show.js +50 -0
  41. package/scripts/critique.js +70 -0
  42. package/scripts/doctor.js +169 -1
  43. package/scripts/init.js +22 -0
  44. package/scripts/refine.js +119 -0
  45. package/scripts/reingest-selected.js +78 -0
  46. package/scripts/reingest.js +7 -1
  47. package/scripts/run-generate.js +76 -0
  48. package/scripts/run-list.js +58 -0
  49. package/scripts/run-show.js +63 -0
  50. package/scripts/select.js +118 -0
  51. package/scripts/selection-show.js +88 -0
  52. package/scripts/snapshot.js +27 -23
  53. package/scripts/split.js +3 -1
  54. package/scripts/workflow-list.js +61 -0
  55. package/scripts/workflow-show.js +96 -0
  56. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  57. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  58. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  59. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  60. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  61. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  62. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  63. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  64. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  65. package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Brief renderer — converts compiled brief JSON to human-readable formats.
3
+ */
4
+
5
+ /**
6
+ * Render a compiled brief as Markdown.
7
+ */
8
+ export function renderBriefMarkdown(brief) {
9
+ const lines = [];
10
+
11
+ lines.push(`# Brief: ${brief.brief_id}`);
12
+ lines.push('');
13
+ lines.push(`**Workflow:** ${brief.workflow_id}`);
14
+ lines.push(`**Project:** ${brief.project_id}`);
15
+ lines.push(`**Lane:** ${brief.lane_id}`);
16
+ if (brief.subject_id) {
17
+ lines.push(`**Subject:** ${brief.subject_id}`);
18
+ }
19
+ if (brief.training_asset_ref) {
20
+ lines.push(`**Training Asset:** ${brief.training_asset_ref}`);
21
+ }
22
+ if (brief.implementation_pack_ref) {
23
+ lines.push(`**Implementation Pack:** ${brief.implementation_pack_ref}`);
24
+ }
25
+ lines.push('');
26
+
27
+ // Prompt
28
+ lines.push('## Prompt');
29
+ lines.push('');
30
+ lines.push('```');
31
+ lines.push(brief.prompt);
32
+ lines.push('```');
33
+ lines.push('');
34
+
35
+ // Negative prompt
36
+ lines.push('## Negative Prompt');
37
+ lines.push('');
38
+ lines.push('```');
39
+ lines.push(brief.negative_prompt);
40
+ lines.push('```');
41
+ lines.push('');
42
+
43
+ // Canon constraints
44
+ if (brief.canon_constraints?.length > 0) {
45
+ lines.push('## Canon Constraints');
46
+ lines.push('');
47
+ for (const c of brief.canon_constraints) {
48
+ lines.push(`- ${c}`);
49
+ }
50
+ lines.push('');
51
+ }
52
+
53
+ // Subject constraints
54
+ if (brief.subject_constraints?.length > 0) {
55
+ lines.push('## Subject Constraints');
56
+ lines.push('');
57
+ for (const c of brief.subject_constraints) {
58
+ lines.push(`- ${c}`);
59
+ }
60
+ lines.push('');
61
+ }
62
+
63
+ // Drift warnings
64
+ if (brief.drift_warnings?.length > 0) {
65
+ lines.push('## Drift Warnings');
66
+ lines.push('');
67
+ for (const w of brief.drift_warnings) {
68
+ lines.push(`- ⚠ ${w}`);
69
+ }
70
+ lines.push('');
71
+ }
72
+
73
+ // Runtime plan
74
+ lines.push('## Runtime Plan');
75
+ lines.push('');
76
+ lines.push('| Parameter | Value |');
77
+ lines.push('|-----------|-------|');
78
+ const rp = brief.runtime_plan;
79
+ if (rp) {
80
+ lines.push(`| Adapter | ${rp.adapter_target} |`);
81
+ lines.push(`| Width | ${rp.width} |`);
82
+ lines.push(`| Height | ${rp.height} |`);
83
+ lines.push(`| Steps | ${rp.steps} |`);
84
+ lines.push(`| CFG | ${rp.cfg} |`);
85
+ lines.push(`| Sampler | ${rp.sampler} |`);
86
+ lines.push(`| Seed Mode | ${rp.seed_mode} |`);
87
+ }
88
+ lines.push('');
89
+
90
+ // Expected outputs
91
+ lines.push('## Expected Outputs');
92
+ lines.push('');
93
+ const eo = brief.expected_outputs;
94
+ if (eo) {
95
+ lines.push(`- **Mode:** ${eo.output_mode}`);
96
+ lines.push(`- **Count:** ${eo.output_count}`);
97
+ if (eo.review_surface) {
98
+ lines.push(`- **Review Surface:** ${eo.review_surface}`);
99
+ }
100
+ }
101
+ lines.push('');
102
+
103
+ // Selected references
104
+ if (brief.reference_selection?.length > 0) {
105
+ lines.push('## Selected References');
106
+ lines.push('');
107
+ for (const ref of brief.reference_selection) {
108
+ lines.push(`- **${ref.record_id}** (${ref.role}): ${ref.reason}`);
109
+ }
110
+ lines.push('');
111
+ }
112
+
113
+ // Metadata
114
+ lines.push('---');
115
+ lines.push('');
116
+ lines.push(`Compiled: ${brief.compiled_at}`);
117
+ lines.push(`Config fingerprint: \`${brief.config_fingerprint}\``);
118
+ lines.push('');
119
+
120
+ return lines.join('\n');
121
+ }
122
+
123
+ /**
124
+ * Render a compiled brief as plain text for terminal output.
125
+ */
126
+ export function renderBriefText(brief) {
127
+ const lines = [];
128
+
129
+ lines.push(`\x1b[1mBrief: ${brief.brief_id}\x1b[0m`);
130
+ lines.push(` Workflow: ${brief.workflow_id}`);
131
+ lines.push(` Project: ${brief.project_id}`);
132
+ lines.push(` Lane: ${brief.lane_id}`);
133
+ if (brief.subject_id) {
134
+ lines.push(` Subject: ${brief.subject_id}`);
135
+ }
136
+ if (brief.training_asset_ref) {
137
+ lines.push(` Asset: ${brief.training_asset_ref}`);
138
+ }
139
+ lines.push('');
140
+
141
+ lines.push('\x1b[1mPrompt:\x1b[0m');
142
+ lines.push(` ${brief.prompt}`);
143
+ lines.push('');
144
+
145
+ lines.push('\x1b[1mNegative:\x1b[0m');
146
+ lines.push(` ${brief.negative_prompt}`);
147
+ lines.push('');
148
+
149
+ if (brief.canon_constraints?.length > 0) {
150
+ lines.push('\x1b[1mCanon Constraints:\x1b[0m');
151
+ for (const c of brief.canon_constraints) {
152
+ lines.push(` • ${c}`);
153
+ }
154
+ lines.push('');
155
+ }
156
+
157
+ if (brief.subject_constraints?.length > 0) {
158
+ lines.push('\x1b[1mSubject Constraints:\x1b[0m');
159
+ for (const c of brief.subject_constraints) {
160
+ lines.push(` • ${c}`);
161
+ }
162
+ lines.push('');
163
+ }
164
+
165
+ if (brief.drift_warnings?.length > 0) {
166
+ lines.push('\x1b[1mDrift Warnings:\x1b[0m');
167
+ for (const w of brief.drift_warnings) {
168
+ lines.push(` \x1b[33m⚠\x1b[0m ${w}`);
169
+ }
170
+ lines.push('');
171
+ }
172
+
173
+ lines.push('\x1b[1mRuntime Plan:\x1b[0m');
174
+ const rp = brief.runtime_plan;
175
+ if (rp) {
176
+ lines.push(` Adapter: ${rp.adapter_target}`);
177
+ lines.push(` Size: ${rp.width}×${rp.height}`);
178
+ lines.push(` Steps: ${rp.steps}`);
179
+ lines.push(` CFG: ${rp.cfg}`);
180
+ lines.push(` Sampler: ${rp.sampler}`);
181
+ lines.push(` Seed mode: ${rp.seed_mode}`);
182
+ }
183
+ lines.push('');
184
+
185
+ lines.push('\x1b[1mExpected Outputs:\x1b[0m');
186
+ const eo = brief.expected_outputs;
187
+ if (eo) {
188
+ lines.push(` Mode: ${eo.output_mode}`);
189
+ lines.push(` Count: ${eo.output_count}`);
190
+ }
191
+ lines.push('');
192
+
193
+ if (brief.reference_selection?.length > 0) {
194
+ lines.push('\x1b[1mSelected References:\x1b[0m');
195
+ for (const ref of brief.reference_selection) {
196
+ lines.push(` ${ref.record_id} (${ref.role}) — ${ref.reason}`);
197
+ }
198
+ lines.push('');
199
+ }
200
+
201
+ lines.push(` Compiled: ${brief.compiled_at}`);
202
+ lines.push(` Fingerprint: ${brief.config_fingerprint}`);
203
+
204
+ return lines.join('\n');
205
+ }
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Critique engine — inspect run outputs and produce structured feedback.
3
+ *
4
+ * A critique is NOT a scorecard. It answers:
5
+ * - Which candidates are closest to the brief?
6
+ * - What each candidate did well.
7
+ * - What drifted.
8
+ * - What to preserve into the next pass.
9
+ * - What to explicitly correct.
10
+ *
11
+ * The engine is rule-based and brief-driven — the brief determines the lens.
12
+ */
13
+
14
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
17
+ import { getProjectRoot, getRunsDir } from './paths.js';
18
+ import { loadRun } from './runtime-runs.js';
19
+ import { getWorkflowProfile } from './workflow-profiles.js';
20
+ import { loadProjectConfig } from './config.js';
21
+ import { inputError } from './errors.js';
22
+
23
+ // ─── Dimension catalogue ─────────────────────────────────────────
24
+
25
+ /**
26
+ * All known critique dimensions.
27
+ * Each dimension maps to brief/workflow features that activate it.
28
+ */
29
+ const DIMENSION_CATALOGUE = {
30
+ subject_continuity: {
31
+ label: 'Subject Continuity',
32
+ activatedBy: { hasSubject: true },
33
+ driftGuardKeywords: ['face structure', 'identity', 'subject'],
34
+ description: 'Does the candidate preserve the subject identity across outputs?',
35
+ },
36
+ lane_fit: {
37
+ label: 'Lane Fit',
38
+ activatedBy: { always: true },
39
+ driftGuardKeywords: ['lane', 'role'],
40
+ description: 'Does the candidate fit the lane purpose?',
41
+ },
42
+ silhouette_read: {
43
+ label: 'Silhouette Read',
44
+ activatedBy: { canonFocus: ['silhouette'] },
45
+ driftGuardKeywords: ['silhouette'],
46
+ description: 'Is the silhouette clean and recognizable?',
47
+ },
48
+ material_costume_logic: {
49
+ label: 'Material / Costume Logic',
50
+ activatedBy: { canonFocus: ['costume_logic', 'material_language'] },
51
+ driftGuardKeywords: ['material', 'costume', 'cloth', 'metal', 'fabric'],
52
+ description: 'Are materials and costume elements canon-correct?',
53
+ },
54
+ palette_mood: {
55
+ label: 'Palette / Mood Fidelity',
56
+ activatedBy: { canonFocus: ['palette', 'lighting'] },
57
+ driftGuardKeywords: ['palette', 'mood', 'lighting', 'color'],
58
+ description: 'Does the palette and mood match the brief?',
59
+ },
60
+ composition_framing: {
61
+ label: 'Composition / Framing',
62
+ activatedBy: { canonFocus: ['composition'] },
63
+ driftGuardKeywords: ['composition', 'framing', 'crop'],
64
+ description: 'Is the composition appropriate for the output mode?',
65
+ },
66
+ ornamental_drift: {
67
+ label: 'Ornamental Drift',
68
+ activatedBy: { canonFocus: ['costume_logic', 'faction_read'] },
69
+ driftGuardKeywords: ['ornament', 'accessori', 'extra'],
70
+ description: 'Has the candidate added non-canon decorative elements?',
71
+ },
72
+ era_faction_drift: {
73
+ label: 'Era / Faction Drift',
74
+ activatedBy: { canonFocus: ['era_logic', 'faction_read'] },
75
+ driftGuardKeywords: ['era', 'faction', 'wrong'],
76
+ description: 'Are there wrong-era or wrong-faction elements?',
77
+ },
78
+ identity_stability: {
79
+ label: 'Identity Stability',
80
+ activatedBy: { hasSubject: true },
81
+ driftGuardKeywords: ['identity', 'face', 'hairstyle', 'age'],
82
+ description: 'Has the candidate maintained stable identity features?',
83
+ },
84
+ readability: {
85
+ label: 'Readability',
86
+ activatedBy: { always: true },
87
+ driftGuardKeywords: ['read', 'clarity', 'muddy', 'clean'],
88
+ description: 'Is the output clearly readable at a glance?',
89
+ },
90
+ };
91
+
92
+ // ─── Workflow-specific emphasis maps ─────────────────────────────
93
+
94
+ /**
95
+ * Output mode → dimensions to emphasize.
96
+ * These get boosted in critique output ordering.
97
+ */
98
+ const MODE_EMPHASIS = {
99
+ portrait_set: [
100
+ 'subject_continuity', 'identity_stability', 'silhouette_read',
101
+ 'material_costume_logic', 'ornamental_drift',
102
+ ],
103
+ expression_sheet: [
104
+ 'identity_stability', 'subject_continuity',
105
+ 'material_costume_logic', 'readability',
106
+ ],
107
+ moodboard: [
108
+ 'palette_mood', 'material_costume_logic', 'era_faction_drift',
109
+ 'composition_framing', 'readability',
110
+ ],
111
+ variant_pack: [
112
+ 'silhouette_read', 'material_costume_logic', 'palette_mood',
113
+ 'ornamental_drift',
114
+ ],
115
+ silhouette_sheet: [
116
+ 'silhouette_read', 'composition_framing', 'readability',
117
+ ],
118
+ turnaround: [
119
+ 'subject_continuity', 'identity_stability', 'silhouette_read',
120
+ 'material_costume_logic',
121
+ ],
122
+ };
123
+
124
+ // ─── Active dimension resolution ─────────────────────────────────
125
+
126
+ /**
127
+ * Determine which critique dimensions are active for this brief + workflow.
128
+ */
129
+ export function getActiveDimensions({ brief, workflow }) {
130
+ const active = [];
131
+ const hasSubject = !!brief.subject_id;
132
+ const canonFocus = workflow.canon_focus || [];
133
+ const driftGuards = workflow.drift_guards || [];
134
+ const driftText = driftGuards.join(' ').toLowerCase();
135
+
136
+ for (const [dimId, dim] of Object.entries(DIMENSION_CATALOGUE)) {
137
+ const cond = dim.activatedBy;
138
+
139
+ // Always-on dimensions
140
+ if (cond.always) {
141
+ active.push(dimId);
142
+ continue;
143
+ }
144
+
145
+ // Subject-dependent
146
+ if (cond.hasSubject && hasSubject) {
147
+ active.push(dimId);
148
+ continue;
149
+ }
150
+
151
+ // Canon focus match
152
+ if (cond.canonFocus) {
153
+ const match = cond.canonFocus.some(cf => canonFocus.includes(cf));
154
+ if (match) {
155
+ active.push(dimId);
156
+ continue;
157
+ }
158
+ }
159
+
160
+ // Drift guard keyword match (catch dimensions invoked by guard language)
161
+ if (dim.driftGuardKeywords) {
162
+ const kwMatch = dim.driftGuardKeywords.some(kw => driftText.includes(kw));
163
+ if (kwMatch) {
164
+ active.push(dimId);
165
+ continue;
166
+ }
167
+ }
168
+ }
169
+
170
+ // Deduplicate and order by mode emphasis
171
+ const outputMode = workflow.output_mode || brief.expected_outputs?.output_mode;
172
+ const emphasis = MODE_EMPHASIS[outputMode] || [];
173
+ const ordered = [
174
+ ...emphasis.filter(d => active.includes(d)),
175
+ ...active.filter(d => !emphasis.includes(d)),
176
+ ];
177
+
178
+ return [...new Set(ordered)];
179
+ }
180
+
181
+ // ─── Candidate critique ──────────────────────────────────────────
182
+
183
+ /**
184
+ * Generate structured notes for a single candidate.
185
+ *
186
+ * In this first version, the engine uses the brief's drift guards,
187
+ * canon constraints, and negative prompt to produce rule-based notes.
188
+ * A human or LLM fills in the actual per-image observations via
189
+ * `sdlab critique --run <id>` which creates a template to complete.
190
+ */
191
+ export function critiqueCandidate({ brief, workflow, candidate, activeDimensions }) {
192
+ const strengths = [];
193
+ const driftIssues = [];
194
+ const preserveNextPass = [];
195
+ const correctNextPass = [];
196
+
197
+ const driftGuards = workflow.drift_guards || [];
198
+ const canonFocus = workflow.canon_focus || [];
199
+ const negatives = (brief.negative_prompt || '').split(',').map(s => s.trim()).filter(Boolean);
200
+
201
+ // Map active dimensions to structured notes
202
+ for (const dimId of activeDimensions) {
203
+ const dim = DIMENSION_CATALOGUE[dimId];
204
+ if (!dim) continue;
205
+
206
+ // For each dimension, generate a review prompt based on what the brief requires.
207
+ // Strengths: what should be achieved for this dimension
208
+ // Drift: what the drift guards warn against
209
+
210
+ // Find drift guards relevant to this dimension
211
+ const relevantGuards = driftGuards.filter(g => {
212
+ const gl = g.toLowerCase();
213
+ return dim.driftGuardKeywords?.some(kw => gl.includes(kw));
214
+ });
215
+
216
+ if (relevantGuards.length > 0) {
217
+ // Add as structured review items
218
+ for (const guard of relevantGuards) {
219
+ driftIssues.push(`[${dim.label}] check: ${guard}`);
220
+ correctNextPass.push(`enforce: ${guard}`);
221
+ }
222
+ }
223
+
224
+ // Canon focus strengths — what we want to see
225
+ const focusMatch = canonFocus.filter(cf => {
226
+ const dimMap = {
227
+ subject_continuity: ['anatomy'],
228
+ silhouette_read: ['silhouette'],
229
+ material_costume_logic: ['costume_logic', 'material_language'],
230
+ palette_mood: ['palette', 'lighting'],
231
+ composition_framing: ['composition'],
232
+ ornamental_drift: ['costume_logic'],
233
+ era_faction_drift: ['era_logic', 'faction_read'],
234
+ identity_stability: ['anatomy'],
235
+ readability: [],
236
+ lane_fit: [],
237
+ };
238
+ return (dimMap[dimId] || []).includes(cf);
239
+ });
240
+
241
+ if (focusMatch.length > 0) {
242
+ strengths.push(`[${dim.label}] brief targets: ${focusMatch.join(', ')}`);
243
+ preserveNextPass.push(`maintain ${dim.label.toLowerCase()} from this candidate`);
244
+ }
245
+ }
246
+
247
+ // Negative prompt items become potential drift issues
248
+ const topNegatives = negatives.slice(0, 5);
249
+ for (const neg of topNegatives) {
250
+ driftIssues.push(`negative check: ${neg}`);
251
+ }
252
+
253
+ return {
254
+ filename: candidate.filename,
255
+ seed: candidate.seed,
256
+ overall_fit: 'usable', // Default — human or LLM overrides
257
+ strengths,
258
+ drift_issues: driftIssues,
259
+ preserve_next_pass: preserveNextPass,
260
+ correct_next_pass: correctNextPass,
261
+ };
262
+ }
263
+
264
+ // ─── Run-level recommendation ────────────────────────────────────
265
+
266
+ /**
267
+ * Derive a run-level recommended action from candidate notes.
268
+ */
269
+ export function recommendAction({ candidates }) {
270
+ if (candidates.length === 0) {
271
+ return { mode: 'discard_run', reason: 'No candidates in run' };
272
+ }
273
+
274
+ const strong = candidates.filter(c => c.overall_fit === 'strong');
275
+ const usable = candidates.filter(c => c.overall_fit === 'usable');
276
+ const offModel = candidates.filter(c => c.overall_fit === 'off-model');
277
+
278
+ // If any are strong, accept the best
279
+ if (strong.length > 0) {
280
+ return {
281
+ mode: 'accept_one',
282
+ preferred_candidate: strong[0].filename,
283
+ reason: `${strong.length} strong candidate(s) — ${strong[0].filename} is the best fit`,
284
+ };
285
+ }
286
+
287
+ // If mostly usable, refine from the best one
288
+ if (usable.length > 0) {
289
+ // Pick the one with fewest drift issues
290
+ const sorted = [...usable].sort((a, b) => a.drift_issues.length - b.drift_issues.length);
291
+ return {
292
+ mode: 'refine_from_one',
293
+ preferred_candidate: sorted[0].filename,
294
+ reason: `${usable.length} usable candidate(s) — ${sorted[0].filename} has fewest drift issues`,
295
+ };
296
+ }
297
+
298
+ // All off-model
299
+ if (offModel.length === candidates.length) {
300
+ return {
301
+ mode: 'discard_run',
302
+ reason: 'All candidates are off-model — brief may need fundamental revision',
303
+ };
304
+ }
305
+
306
+ // Mixed bag — rerun
307
+ return {
308
+ mode: 'rerun_broader',
309
+ reason: 'No clear winner — try broader seed range or adjusted parameters',
310
+ };
311
+ }
312
+
313
+ // ─── Main critique function ──────────────────────────────────────
314
+
315
+ /**
316
+ * Generate a full critique report for a completed run.
317
+ *
318
+ * @param {Object} opts
319
+ * @param {string} opts.projectRoot — absolute project path
320
+ * @param {string} opts.projectId — project name
321
+ * @param {string} opts.runId — run ID to critique
322
+ * @returns {Promise<Object>} critique report
323
+ */
324
+ export async function critiqueRun({ projectRoot, projectId, runId }) {
325
+ // Load run manifest
326
+ const manifest = await loadRun(projectRoot, runId);
327
+ const runDir = join(getRunsDir(projectRoot), runId);
328
+
329
+ // Load the brief from the run directory
330
+ const briefPath = join(runDir, 'brief.json');
331
+ if (!existsSync(briefPath)) {
332
+ throw inputError(
333
+ 'CRITIQUE_NO_BRIEF',
334
+ `Run "${runId}" has no brief.json`,
335
+ 'The run must have a brief artifact to critique against.'
336
+ );
337
+ }
338
+ const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
339
+
340
+ // Load workflow profile
341
+ const workflow = await getWorkflowProfile(projectRoot, brief.workflow_id);
342
+
343
+ // Determine active dimensions
344
+ const activeDimensions = getActiveDimensions({ brief, workflow });
345
+
346
+ // Build candidate list from manifest outputs
347
+ const successfulOutputs = (manifest.outputs || []).filter(
348
+ o => o.status === 'ok' || o.status === 'dry_run'
349
+ );
350
+
351
+ if (successfulOutputs.length === 0) {
352
+ throw inputError(
353
+ 'CRITIQUE_NO_OUTPUTS',
354
+ `Run "${runId}" has no successful outputs to critique`,
355
+ 'Run must have at least one successful output.'
356
+ );
357
+ }
358
+
359
+ // Critique each candidate
360
+ const candidates = successfulOutputs.map(output =>
361
+ critiqueCandidate({
362
+ brief,
363
+ workflow,
364
+ candidate: { filename: output.filename, seed: output.seed },
365
+ activeDimensions,
366
+ })
367
+ );
368
+
369
+ // Derive recommendation
370
+ const recommended = recommendAction({ candidates });
371
+
372
+ // Build report
373
+ const report = {
374
+ run_id: runId,
375
+ project_id: projectId,
376
+ brief_id: brief.brief_id,
377
+ workflow_id: brief.workflow_id,
378
+ created_at: new Date().toISOString(),
379
+ active_dimensions: activeDimensions,
380
+ candidates,
381
+ recommended_action: recommended,
382
+ };
383
+
384
+ if (brief.subject_id) {
385
+ report.subject_id = brief.subject_id;
386
+ }
387
+
388
+ return report;
389
+ }
390
+
391
+ /**
392
+ * Save a critique report to the run directory.
393
+ */
394
+ export async function saveCritique(projectRoot, runId, report, markdown) {
395
+ const runDir = join(getRunsDir(projectRoot), runId);
396
+ await writeFile(join(runDir, 'critique.json'), JSON.stringify(report, null, 2) + '\n');
397
+ await writeFile(join(runDir, 'critique.md'), markdown);
398
+ }
399
+
400
+ /**
401
+ * Load a saved critique from a run directory.
402
+ */
403
+ export function loadCritique(projectRoot, runId) {
404
+ const critiquePath = join(getRunsDir(projectRoot), runId, 'critique.json');
405
+ if (!existsSync(critiquePath)) {
406
+ throw inputError(
407
+ 'CRITIQUE_NOT_FOUND',
408
+ `No critique found for run "${runId}"`,
409
+ `Run: sdlab critique --run ${runId}`
410
+ );
411
+ }
412
+ return JSON.parse(readFileSync(critiquePath, 'utf-8'));
413
+ }