@mcptoolshop/style-dataset-lab 2.3.0 → 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 (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +36 -8
  3. package/bin/sdlab.js +129 -0
  4. package/lib/adapters/comfyui-runner.js +170 -0
  5. package/lib/adapters/comfyui-workflows.js +163 -0
  6. package/lib/batch-compiler.js +118 -0
  7. package/lib/batch-modes.js +111 -0
  8. package/lib/batch-runs.js +168 -0
  9. package/lib/batch-sheet-render.js +206 -0
  10. package/lib/brief-compiler.js +598 -0
  11. package/lib/brief-render.js +205 -0
  12. package/lib/critique-engine.js +413 -0
  13. package/lib/critique-render.js +171 -0
  14. package/lib/generated-provenance.js +87 -0
  15. package/lib/paths.js +31 -0
  16. package/lib/refine-briefs.js +329 -0
  17. package/lib/reingest-selected.js +173 -0
  18. package/lib/run-summary.js +131 -0
  19. package/lib/runtime-runs.js +168 -0
  20. package/lib/selections.js +323 -0
  21. package/lib/workflow-profiles.js +161 -0
  22. package/package.json +6 -3
  23. package/scripts/batch-generate.js +167 -0
  24. package/scripts/batch-sheet.js +46 -0
  25. package/scripts/batch-show.js +86 -0
  26. package/scripts/brief-compile.js +134 -0
  27. package/scripts/brief-show.js +52 -0
  28. package/scripts/critique-show.js +50 -0
  29. package/scripts/critique.js +70 -0
  30. package/scripts/doctor.js +169 -1
  31. package/scripts/init.js +22 -0
  32. package/scripts/refine.js +119 -0
  33. package/scripts/reingest-selected.js +78 -0
  34. package/scripts/reingest.js +7 -1
  35. package/scripts/run-generate.js +76 -0
  36. package/scripts/run-list.js +58 -0
  37. package/scripts/run-show.js +63 -0
  38. package/scripts/select.js +118 -0
  39. package/scripts/selection-show.js +88 -0
  40. package/scripts/workflow-list.js +61 -0
  41. package/scripts/workflow-show.js +96 -0
  42. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  43. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  44. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  45. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  46. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  47. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  48. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  49. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  50. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  51. package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
@@ -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
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Generated provenance builder — constructs the provenance block
3
+ * attached to records created from selected run/batch outputs.
4
+ *
5
+ * Normalizes source context (run vs batch) into a single shape
6
+ * that traces back through brief → workflow → seed → config.
7
+ */
8
+
9
+ import { readFileSync, existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { createHash } from 'node:crypto';
12
+
13
+ /**
14
+ * Build a generation provenance payload for a selected output.
15
+ *
16
+ * @param {Object} opts
17
+ * @param {'run'|'batch'} opts.sourceType
18
+ * @param {string} opts.sourceId — run_id or batch_id
19
+ * @param {Object} opts.runManifest — the run manifest containing this output
20
+ * @param {Object} [opts.batchManifest] — batch manifest if source is batch
21
+ * @param {Object} opts.item — the selection item (slot_or_output, filename, seed)
22
+ * @param {string} opts.projectRoot
23
+ * @returns {Object} generation_provenance block
24
+ */
25
+ export function buildGeneratedProvenance({
26
+ sourceType,
27
+ sourceId,
28
+ runManifest,
29
+ batchManifest,
30
+ item,
31
+ projectRoot,
32
+ }) {
33
+ const prov = {
34
+ source_type: sourceType,
35
+ source_id: sourceId,
36
+ run_id: runManifest.run_id,
37
+ brief_id: runManifest.brief_id,
38
+ workflow_id: runManifest.workflow_template_id,
39
+ selected_filename: item.filename,
40
+ generated_at: runManifest.created_at,
41
+ };
42
+
43
+ if (sourceType === 'batch' && batchManifest) {
44
+ prov.batch_id = batchManifest.batch_id;
45
+ }
46
+
47
+ // Seed from the item or from the run manifest outputs
48
+ if (item.seed !== undefined) {
49
+ prov.seed = item.seed;
50
+ } else if (runManifest.outputs) {
51
+ const match = runManifest.outputs.find(o => o.filename === item.filename);
52
+ if (match?.seed !== undefined) prov.seed = match.seed;
53
+ }
54
+
55
+ // Adapter target from run manifest
56
+ if (runManifest.adapter_target) {
57
+ prov.adapter_target = runManifest.adapter_target;
58
+ }
59
+
60
+ // Subject from run brief or batch manifest
61
+ if (batchManifest?.subject_id) {
62
+ prov.subject_id = batchManifest.subject_id;
63
+ } else {
64
+ // Try to read subject from brief
65
+ const briefPath = join(projectRoot, 'briefs', `${runManifest.brief_id}.json`);
66
+ if (existsSync(briefPath)) {
67
+ try {
68
+ const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
69
+ if (brief.subject_id) prov.subject_id = brief.subject_id;
70
+ if (brief.parent_brief_id) prov.parent_brief_id = brief.parent_brief_id;
71
+ } catch { /* skip */ }
72
+ }
73
+ }
74
+
75
+ // Config fingerprint — hash the run manifest for reproducibility tracing
76
+ prov.config_fingerprint = createHash('sha256')
77
+ .update(JSON.stringify({
78
+ brief_id: runManifest.brief_id,
79
+ workflow_template_id: runManifest.workflow_template_id,
80
+ adapter_target: runManifest.adapter_target,
81
+ seed_plan: runManifest.seed_plan,
82
+ }))
83
+ .digest('hex')
84
+ .slice(0, 16);
85
+
86
+ return prov;
87
+ }
package/lib/paths.js CHANGED
@@ -36,3 +36,34 @@ export function getProjectRoot(name) {
36
36
  export function resolveProjectPath(name, ...segments) {
37
37
  return join(getProjectRoot(name), ...segments);
38
38
  }
39
+
40
+ /**
41
+ * Return the workflow profiles directory for a project root.
42
+ * @param {string} projectRoot — absolute path to project
43
+ */
44
+ export function getWorkflowProfilesDir(projectRoot) {
45
+ return join(projectRoot, 'workflows', 'profiles');
46
+ }
47
+
48
+ /**
49
+ * Return the briefs directory for a project root.
50
+ * @param {string} projectRoot — absolute path to project
51
+ */
52
+ export function getBriefsDir(projectRoot) {
53
+ return join(projectRoot, 'briefs');
54
+ }
55
+
56
+ /**
57
+ * Return the runs directory for a project root.
58
+ * @param {string} projectRoot — absolute path to project
59
+ */
60
+ export function getRunsDir(projectRoot) {
61
+ return join(projectRoot, 'runs');
62
+ }
63
+
64
+ /**
65
+ * Return the runtime templates directory (repo-level).
66
+ */
67
+ export function getRuntimeDir() {
68
+ return join(REPO_ROOT, 'runtime');
69
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Refine briefs — derive a next-pass brief from critique of a previous run.
3
+ *
4
+ * A refined brief is NOT a brand-new brief. It is a delta layer:
5
+ * - Preserves: original workflow, subject, lane, canon constraints, runtime plan
6
+ * - Adds: preserve/push/suppress instructions derived from critique
7
+ * - Updates: prompt and negative prompt with targeted refinements
8
+ *
9
+ * This keeps the loop stable — each pass improves, never scrambles.
10
+ */
11
+
12
+ import { writeFile, mkdir } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { existsSync, readdirSync } from 'node:fs';
15
+ import { getRunsDir } from './paths.js';
16
+ import { inputError } from './errors.js';
17
+
18
+ // ─── Refined brief ID generation ─────────────────────────────────
19
+
20
+ /**
21
+ * Generate refined brief ID: <parent_brief_id>_refine_NN
22
+ */
23
+ function generateRefinedBriefId(refineDir, parentBriefId) {
24
+ const prefix = `${parentBriefId}_refine_`;
25
+
26
+ let maxSeq = 0;
27
+ if (existsSync(refineDir)) {
28
+ const existing = readdirSync(refineDir)
29
+ .filter(f => f.startsWith(prefix) && f.endsWith('.json'));
30
+ for (const f of existing) {
31
+ const seqStr = f.slice(prefix.length, -5);
32
+ const seq = parseInt(seqStr, 10);
33
+ if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
34
+ }
35
+ }
36
+
37
+ const seq = String(maxSeq + 1).padStart(2, '0');
38
+ return `${prefix}${seq}`;
39
+ }
40
+
41
+ // ─── Prompt refinement ───────────────────────────────────────────
42
+
43
+ /**
44
+ * Render a refined prompt from parent prompt + delta instructions.
45
+ *
46
+ * Strategy:
47
+ * - Start with the original prompt
48
+ * - Append preserve notes as reinforcement
49
+ * - Append push notes as emphasis
50
+ * - Do NOT remove anything from the original — only add emphasis
51
+ */
52
+ export function renderRefinedPrompt(parentPrompt, delta) {
53
+ const parts = [parentPrompt];
54
+
55
+ // Preserve instructions — reinforce what worked
56
+ if (delta.preserve?.length > 0) {
57
+ parts.push(`[preserve: ${delta.preserve.join(', ')}]`);
58
+ }
59
+
60
+ // Push instructions — emphasize what needs to be stronger
61
+ if (delta.push?.length > 0) {
62
+ parts.push(`[emphasize: ${delta.push.join(', ')}]`);
63
+ }
64
+
65
+ return parts.join(', ');
66
+ }
67
+
68
+ /**
69
+ * Render a refined negative prompt from parent negative + suppress instructions.
70
+ *
71
+ * Strategy:
72
+ * - Keep the entire original negative prompt
73
+ * - Append suppress items that aren't already present
74
+ */
75
+ export function renderRefinedNegative(parentNegative, delta) {
76
+ const existing = parentNegative.split(',').map(s => s.trim().toLowerCase());
77
+ const additions = [];
78
+
79
+ for (const item of delta.suppress || []) {
80
+ const lower = item.toLowerCase();
81
+ // Don't duplicate if already in negative
82
+ if (!existing.some(e => e.includes(lower) || lower.includes(e))) {
83
+ additions.push(item);
84
+ }
85
+ }
86
+
87
+ if (additions.length === 0) return parentNegative;
88
+ return `${parentNegative}, ${additions.join(', ')}`;
89
+ }
90
+
91
+ // ─── Delta extraction from critique ──────────────────────────────
92
+
93
+ /**
94
+ * Extract a refine delta from a critique report + picked candidate.
95
+ *
96
+ * @param {Object} critique — critique report
97
+ * @param {string} pickedFilename — filename of the picked candidate
98
+ * @param {Object} cliOverrides — { preserve: string[], push: string[], suppress: string[] }
99
+ * @returns {{ preserve: string[], push: string[], suppress: string[] }}
100
+ */
101
+ export function extractDelta(critique, pickedFilename, cliOverrides = {}) {
102
+ // Find the picked candidate in the critique
103
+ const picked = critique.candidates.find(c => c.filename === pickedFilename);
104
+ if (!picked) {
105
+ throw inputError(
106
+ 'REFINE_CANDIDATE_NOT_FOUND',
107
+ `Candidate "${pickedFilename}" not found in critique for run "${critique.run_id}"`,
108
+ `Available candidates: ${critique.candidates.map(c => c.filename).join(', ')}`
109
+ );
110
+ }
111
+
112
+ // Start with critique-derived instructions
113
+ const preserve = [...(picked.preserve_next_pass || [])];
114
+ const push = [];
115
+ const suppress = [];
116
+
117
+ // Derive push from drift issues that need correction
118
+ for (const issue of picked.correct_next_pass || []) {
119
+ push.push(issue);
120
+ }
121
+
122
+ // Derive suppress from drift issues
123
+ for (const drift of picked.drift_issues || []) {
124
+ // Strip "check:" and "negative check:" prefixes for cleaner suppress
125
+ const cleaned = drift
126
+ .replace(/^\[.*?\]\s*check:\s*/i, '')
127
+ .replace(/^negative check:\s*/i, '')
128
+ .trim();
129
+ if (cleaned) suppress.push(cleaned);
130
+ }
131
+
132
+ // CLI overrides — append without duplicating
133
+ if (cliOverrides.preserve) {
134
+ for (const p of cliOverrides.preserve) {
135
+ if (!preserve.includes(p)) preserve.push(p);
136
+ }
137
+ }
138
+ if (cliOverrides.push) {
139
+ for (const p of cliOverrides.push) {
140
+ if (!push.includes(p)) push.push(p);
141
+ }
142
+ }
143
+ if (cliOverrides.suppress) {
144
+ for (const s of cliOverrides.suppress) {
145
+ if (!suppress.includes(s)) suppress.push(s);
146
+ }
147
+ }
148
+
149
+ return { preserve, push, suppress };
150
+ }
151
+
152
+ // ─── Build refined brief ─────────────────────────────────────────
153
+
154
+ /**
155
+ * Build a refined brief from parent brief, critique, and delta.
156
+ *
157
+ * @param {Object} opts
158
+ * @param {Object} opts.parentBrief — original compiled brief
159
+ * @param {Object} opts.critique — critique report
160
+ * @param {string} opts.pickedCandidate — filename of selected candidate
161
+ * @param {Object} opts.delta — { preserve, push, suppress }
162
+ * @param {string} opts.runId — source run ID
163
+ * @param {string} opts.refineDir — path to write refined brief
164
+ * @returns {Object} refined brief
165
+ */
166
+ export function buildRefinedBrief({ parentBrief, critique, pickedCandidate, delta, runId, refineDir }) {
167
+ const briefId = generateRefinedBriefId(refineDir, parentBrief.brief_id);
168
+
169
+ // Render updated prompts
170
+ const prompt = renderRefinedPrompt(parentBrief.prompt, delta);
171
+ const negativePrompt = renderRefinedNegative(parentBrief.negative_prompt, delta);
172
+
173
+ const refined = {
174
+ brief_id: briefId,
175
+ parent_brief_id: parentBrief.brief_id,
176
+ source_run_id: runId,
177
+ source_candidate: pickedCandidate,
178
+
179
+ project_id: parentBrief.project_id,
180
+ workflow_id: parentBrief.workflow_id,
181
+ lane_id: parentBrief.lane_id,
182
+ compiled_at: new Date().toISOString(),
183
+ config_fingerprint: parentBrief.config_fingerprint,
184
+
185
+ prompt,
186
+ negative_prompt: negativePrompt,
187
+
188
+ refine_delta: delta,
189
+
190
+ // Carry forward from parent
191
+ canon_constraints: parentBrief.canon_constraints || [],
192
+ drift_warnings: parentBrief.drift_warnings || [],
193
+ runtime_plan: { ...parentBrief.runtime_plan },
194
+ expected_outputs: { ...parentBrief.expected_outputs },
195
+ };
196
+
197
+ // Optional fields
198
+ if (parentBrief.subject_id) refined.subject_id = parentBrief.subject_id;
199
+ if (parentBrief.subject_constraints) refined.subject_constraints = parentBrief.subject_constraints;
200
+ if (parentBrief.reference_selection) refined.reference_selection = parentBrief.reference_selection;
201
+
202
+ return refined;
203
+ }
204
+
205
+ // ─── Markdown renderer ──────────────────────────────────────────
206
+
207
+ /**
208
+ * Render a refined brief as Markdown.
209
+ */
210
+ export function renderRefinedBriefMarkdown(brief) {
211
+ const lines = [];
212
+
213
+ lines.push(`# Refined Brief: ${brief.brief_id}`);
214
+ lines.push('');
215
+ lines.push(`**Parent Brief:** ${brief.parent_brief_id}`);
216
+ lines.push(`**Source Run:** ${brief.source_run_id}`);
217
+ lines.push(`**Picked Candidate:** ${brief.source_candidate}`);
218
+ lines.push('');
219
+ lines.push(`**Workflow:** ${brief.workflow_id}`);
220
+ lines.push(`**Project:** ${brief.project_id}`);
221
+ lines.push(`**Lane:** ${brief.lane_id}`);
222
+ if (brief.subject_id) lines.push(`**Subject:** ${brief.subject_id}`);
223
+ lines.push('');
224
+
225
+ // Refine delta — the key section
226
+ lines.push('## Refine Delta');
227
+ lines.push('');
228
+
229
+ if (brief.refine_delta?.preserve?.length > 0) {
230
+ lines.push('### Preserve');
231
+ for (const p of brief.refine_delta.preserve) lines.push(`- ✓ ${p}`);
232
+ lines.push('');
233
+ }
234
+
235
+ if (brief.refine_delta?.push?.length > 0) {
236
+ lines.push('### Push');
237
+ for (const p of brief.refine_delta.push) lines.push(`- → ${p}`);
238
+ lines.push('');
239
+ }
240
+
241
+ if (brief.refine_delta?.suppress?.length > 0) {
242
+ lines.push('### Suppress');
243
+ for (const s of brief.refine_delta.suppress) lines.push(`- ✗ ${s}`);
244
+ lines.push('');
245
+ }
246
+
247
+ // Prompt
248
+ lines.push('## Prompt');
249
+ lines.push('');
250
+ lines.push('```');
251
+ lines.push(brief.prompt);
252
+ lines.push('```');
253
+ lines.push('');
254
+
255
+ // Negative prompt
256
+ lines.push('## Negative Prompt');
257
+ lines.push('');
258
+ lines.push('```');
259
+ lines.push(brief.negative_prompt);
260
+ lines.push('```');
261
+ lines.push('');
262
+
263
+ // Canon constraints
264
+ if (brief.canon_constraints?.length > 0) {
265
+ lines.push('## Canon Constraints');
266
+ lines.push('');
267
+ for (const c of brief.canon_constraints) lines.push(`- ${c}`);
268
+ lines.push('');
269
+ }
270
+
271
+ // Drift warnings
272
+ if (brief.drift_warnings?.length > 0) {
273
+ lines.push('## Drift Warnings');
274
+ lines.push('');
275
+ for (const w of brief.drift_warnings) lines.push(`- ⚠ ${w}`);
276
+ lines.push('');
277
+ }
278
+
279
+ // Runtime plan
280
+ lines.push('## Runtime Plan');
281
+ lines.push('');
282
+ lines.push('| Parameter | Value |');
283
+ lines.push('|-----------|-------|');
284
+ const rp = brief.runtime_plan || {};
285
+ lines.push(`| Adapter | ${rp.adapter_target || '?'} |`);
286
+ lines.push(`| Width | ${rp.width || '?'} |`);
287
+ lines.push(`| Height | ${rp.height || '?'} |`);
288
+ lines.push(`| Steps | ${rp.steps || '?'} |`);
289
+ lines.push(`| CFG | ${rp.cfg || '?'} |`);
290
+ lines.push(`| Sampler | ${rp.sampler || '?'} |`);
291
+ lines.push(`| Seed Mode | ${rp.seed_mode || '?'} |`);
292
+ lines.push('');
293
+
294
+ // Expected outputs
295
+ lines.push('## Expected Outputs');
296
+ lines.push('');
297
+ const eo = brief.expected_outputs || {};
298
+ lines.push(`- **Mode:** ${eo.output_mode || '?'}`);
299
+ lines.push(`- **Count:** ${eo.output_count || '?'}`);
300
+ lines.push('');
301
+
302
+ // Metadata
303
+ lines.push('---');
304
+ lines.push('');
305
+ lines.push(`Compiled: ${brief.compiled_at}`);
306
+ lines.push(`Config fingerprint: \`${brief.config_fingerprint || '—'}\``);
307
+ lines.push('');
308
+
309
+ return lines.join('\n');
310
+ }
311
+
312
+ // ─── Save ────────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * Save a refined brief to the run's refine directory.
316
+ */
317
+ export async function saveRefinedBrief(projectRoot, runId, refinedBrief) {
318
+ const refineDir = join(getRunsDir(projectRoot), runId, 'refine');
319
+ await mkdir(refineDir, { recursive: true });
320
+
321
+ const jsonPath = join(refineDir, `${refinedBrief.brief_id}.json`);
322
+ await writeFile(jsonPath, JSON.stringify(refinedBrief, null, 2) + '\n');
323
+
324
+ const md = renderRefinedBriefMarkdown(refinedBrief);
325
+ const mdPath = join(refineDir, `${refinedBrief.brief_id}.md`);
326
+ await writeFile(mdPath, md);
327
+
328
+ return { jsonPath, mdPath, refineDir };
329
+ }