@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.1

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 (106) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +71 -0
  4. package/README.md +82 -9
  5. package/SECURITY.md +6 -5
  6. package/bin/sdlab.js +359 -11
  7. package/lib/adapters/comfyui-runner.js +170 -0
  8. package/lib/adapters/comfyui-workflows.js +163 -0
  9. package/lib/adapters/diffusers-lora.js +7 -2
  10. package/lib/adapters/generic-image-caption.js +6 -1
  11. package/lib/args.js +200 -13
  12. package/lib/batch-compiler.js +122 -0
  13. package/lib/batch-modes.js +111 -0
  14. package/lib/batch-runs.js +237 -0
  15. package/lib/batch-sheet-render.js +209 -0
  16. package/lib/brief-compiler.js +625 -0
  17. package/lib/brief-render.js +205 -0
  18. package/lib/card.js +11 -4
  19. package/lib/comfyui.js +100 -6
  20. package/lib/config.js +68 -3
  21. package/lib/critique-engine.js +413 -0
  22. package/lib/critique-render.js +171 -0
  23. package/lib/deprecation.js +1 -1
  24. package/lib/eval-pack.js +94 -4
  25. package/lib/eval-runs.js +42 -3
  26. package/lib/export.js +144 -34
  27. package/lib/generated-provenance.js +87 -0
  28. package/lib/implementation-packs.js +15 -1
  29. package/lib/log.js +41 -4
  30. package/lib/paths.js +72 -3
  31. package/lib/refine-briefs.js +336 -0
  32. package/lib/reingest-selected.js +184 -0
  33. package/lib/reingest.js +15 -1
  34. package/lib/run-summary.js +132 -0
  35. package/lib/runtime-runs.js +218 -0
  36. package/lib/selections.js +375 -0
  37. package/lib/snapshot.js +100 -7
  38. package/lib/split.js +175 -17
  39. package/lib/training-manifests.js +17 -3
  40. package/lib/training-packages.js +28 -14
  41. package/lib/workflow-profiles.js +161 -0
  42. package/package.json +13 -4
  43. package/scripts/batch-generate.js +176 -0
  44. package/scripts/batch-sheet.js +46 -0
  45. package/scripts/batch-show.js +86 -0
  46. package/scripts/brief-compile.js +134 -0
  47. package/scripts/brief-show.js +53 -0
  48. package/scripts/canon-bind.js +7 -9
  49. package/scripts/card.js +22 -25
  50. package/scripts/compare.js +38 -33
  51. package/scripts/critique-show.js +50 -0
  52. package/scripts/critique.js +70 -0
  53. package/scripts/curate.js +91 -46
  54. package/scripts/doctor.js +172 -6
  55. package/scripts/eligibility.js +17 -14
  56. package/scripts/eval-pack.js +13 -11
  57. package/scripts/eval-run.js +30 -29
  58. package/scripts/export.js +27 -31
  59. package/scripts/generate-controlnet.js +31 -17
  60. package/scripts/generate-identity.js +37 -20
  61. package/scripts/generate-ipadapter.js +22 -16
  62. package/scripts/generate.js +68 -13
  63. package/scripts/implementation-pack.js +19 -14
  64. package/scripts/init.js +137 -17
  65. package/scripts/painterly.js +8 -10
  66. package/scripts/refine.js +119 -0
  67. package/scripts/reingest-selected.js +78 -0
  68. package/scripts/reingest.js +37 -18
  69. package/scripts/run-generate.js +76 -0
  70. package/scripts/run-list.js +58 -0
  71. package/scripts/run-show.js +63 -0
  72. package/scripts/select.js +119 -0
  73. package/scripts/selection-show.js +88 -0
  74. package/scripts/snapshot.js +22 -24
  75. package/scripts/split.js +26 -31
  76. package/scripts/training-manifest.js +29 -24
  77. package/scripts/training-package.js +24 -20
  78. package/scripts/training-profile.js +13 -8
  79. package/scripts/workflow-list.js +61 -0
  80. package/scripts/workflow-show.js +97 -0
  81. package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
  82. package/templates/domains/architecture/terminology.json +10 -8
  83. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  84. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  85. package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
  86. package/templates/domains/character-design/project.json +4 -2
  87. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  88. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  89. package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
  90. package/templates/domains/creature-design/project.json +4 -2
  91. package/templates/domains/creature-design/terminology.json +1 -0
  92. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  93. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  94. package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
  95. package/templates/domains/game-art/project.json +4 -2
  96. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  97. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  98. package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
  99. package/templates/domains/vehicle-mech/project.json +4 -2
  100. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  101. package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
  102. package/scripts/bulk-curate-wave2-5.js +0 -138
  103. package/scripts/bulk-curate-waves11-18.js +0 -413
  104. package/scripts/curate-wave25.js +0 -209
  105. package/scripts/migrate-records.js +0 -141
  106. package/templates/inputs/prompts/example-wave.json +0 -27
@@ -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
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Critique renderer — converts critique report to human-readable formats.
3
+ *
4
+ * Reads like a working note, not a report card.
5
+ */
6
+
7
+ // ─── Fit badge ───────────────────────────────────────────────────
8
+
9
+ const FIT_BADGES = {
10
+ strong: '✓ strong',
11
+ usable: '○ usable',
12
+ 'off-model': '✗ off-model',
13
+ };
14
+
15
+ const FIT_EMOJI = {
16
+ strong: '🟢',
17
+ usable: '🟡',
18
+ 'off-model': '🔴',
19
+ };
20
+
21
+ // ─── Markdown renderer ──────────────────────────────────────────
22
+
23
+ /**
24
+ * Render a critique report as Markdown.
25
+ */
26
+ export function renderCritiqueMarkdown(report) {
27
+ const lines = [];
28
+
29
+ lines.push(`# Critique: ${report.run_id}`);
30
+ lines.push('');
31
+ lines.push(`**Brief:** ${report.brief_id}`);
32
+ lines.push(`**Workflow:** ${report.workflow_id}`);
33
+ lines.push(`**Project:** ${report.project_id}`);
34
+ if (report.subject_id) lines.push(`**Subject:** ${report.subject_id}`);
35
+ lines.push(`**Created:** ${report.created_at}`);
36
+ lines.push('');
37
+
38
+ // Recommended action
39
+ const action = report.recommended_action;
40
+ const modeLabel = {
41
+ accept_one: '✓ Accept one candidate',
42
+ refine_from_one: '→ Refine from one candidate',
43
+ rerun_broader: '↻ Rerun with broader parameters',
44
+ discard_run: '✗ Discard this run',
45
+ };
46
+ lines.push('## Recommended Action');
47
+ lines.push('');
48
+ lines.push(`**${modeLabel[action.mode] || action.mode}**`);
49
+ if (action.preferred_candidate) {
50
+ lines.push(`Preferred: **${action.preferred_candidate}**`);
51
+ }
52
+ if (action.reason) lines.push(`Reason: ${action.reason}`);
53
+ lines.push('');
54
+
55
+ // Active dimensions
56
+ lines.push('## Active Dimensions');
57
+ lines.push('');
58
+ for (const dim of report.active_dimensions) {
59
+ lines.push(`- ${dim.replace(/_/g, ' ')}`);
60
+ }
61
+ lines.push('');
62
+
63
+ // Candidates
64
+ lines.push('## Candidates');
65
+ lines.push('');
66
+
67
+ for (const c of report.candidates) {
68
+ const badge = FIT_EMOJI[c.overall_fit] || '?';
69
+ lines.push(`### ${badge} ${c.filename} (seed: ${c.seed ?? '?'})`);
70
+ lines.push('');
71
+ lines.push(`**Fit:** ${c.overall_fit}`);
72
+ lines.push('');
73
+
74
+ if (c.strengths.length > 0) {
75
+ lines.push('**Strengths:**');
76
+ for (const s of c.strengths) lines.push(`- ${s}`);
77
+ lines.push('');
78
+ }
79
+
80
+ if (c.drift_issues.length > 0) {
81
+ lines.push('**Drift Issues:**');
82
+ for (const d of c.drift_issues) lines.push(`- ⚠ ${d}`);
83
+ lines.push('');
84
+ }
85
+
86
+ if (c.preserve_next_pass.length > 0) {
87
+ lines.push('**Preserve Next Pass:**');
88
+ for (const p of c.preserve_next_pass) lines.push(`- ✓ ${p}`);
89
+ lines.push('');
90
+ }
91
+
92
+ if (c.correct_next_pass.length > 0) {
93
+ lines.push('**Correct Next Pass:**');
94
+ for (const x of c.correct_next_pass) lines.push(`- → ${x}`);
95
+ lines.push('');
96
+ }
97
+ }
98
+
99
+ // Next-pass direction summary
100
+ lines.push('## Next-Pass Direction');
101
+ lines.push('');
102
+
103
+ // Aggregate preserve/correct across all candidates
104
+ const allPreserve = new Set();
105
+ const allCorrect = new Set();
106
+ for (const c of report.candidates) {
107
+ for (const p of c.preserve_next_pass) allPreserve.add(p);
108
+ for (const x of c.correct_next_pass) allCorrect.add(x);
109
+ }
110
+
111
+ if (allPreserve.size > 0) {
112
+ lines.push('**Preserve:**');
113
+ for (const p of allPreserve) lines.push(`- ${p}`);
114
+ lines.push('');
115
+ }
116
+
117
+ if (allCorrect.size > 0) {
118
+ lines.push('**Correct:**');
119
+ for (const x of allCorrect) lines.push(`- ${x}`);
120
+ lines.push('');
121
+ }
122
+
123
+ return lines.join('\n');
124
+ }
125
+
126
+ // ─── Terminal text renderer ──────────────────────────────────────
127
+
128
+ /**
129
+ * Render a critique report as plain text for terminal display.
130
+ */
131
+ export function renderCritiqueText(report) {
132
+ const lines = [];
133
+
134
+ lines.push(`Critique: ${report.run_id}`);
135
+ lines.push(`Brief: ${report.brief_id} Workflow: ${report.workflow_id}`);
136
+ if (report.subject_id) lines.push(`Subject: ${report.subject_id}`);
137
+ lines.push('');
138
+
139
+ // Action
140
+ const action = report.recommended_action;
141
+ const actionIcon = {
142
+ accept_one: '✓',
143
+ refine_from_one: '→',
144
+ rerun_broader: '↻',
145
+ discard_run: '✗',
146
+ };
147
+ lines.push(`${actionIcon[action.mode] || '?'} ${action.mode.replace(/_/g, ' ')}`);
148
+ if (action.preferred_candidate) lines.push(` Preferred: ${action.preferred_candidate}`);
149
+ if (action.reason) lines.push(` ${action.reason}`);
150
+ lines.push('');
151
+
152
+ // Dimensions
153
+ lines.push(`Dimensions: ${report.active_dimensions.map(d => d.replace(/_/g, ' ')).join(', ')}`);
154
+ lines.push('');
155
+
156
+ // Candidates
157
+ for (const c of report.candidates) {
158
+ const badge = FIT_BADGES[c.overall_fit] || '? unknown';
159
+ lines.push(` ${badge} ${c.filename} (seed: ${c.seed ?? '?'})`);
160
+
161
+ if (c.strengths.length > 0) {
162
+ lines.push(` strengths: ${c.strengths.slice(0, 3).join('; ')}`);
163
+ }
164
+ if (c.drift_issues.length > 0) {
165
+ lines.push(` drift: ${c.drift_issues.slice(0, 3).join('; ')}`);
166
+ }
167
+ }
168
+ lines.push('');
169
+
170
+ return lines.join('\n');
171
+ }
@@ -9,6 +9,6 @@ export function warnDeprecated(oldFlag, newFlag) {
9
9
  if (warned.has(key)) return;
10
10
  warned.add(key);
11
11
  process.stderr.write(
12
- `\x1b[33m⚠ --${oldFlag} is deprecated and will be removed in v3.0. Use --${newFlag} instead.\x1b[0m\n`
12
+ `\x1b[33m⚠ --${oldFlag} is deprecated and will be removed in v4.0. Use --${newFlag} instead.\x1b[0m\n`
13
13
  );
14
14
  }