@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,168 @@
1
+ /**
2
+ * Runtime run management — create, load, list run artifacts.
3
+ *
4
+ * A run is a frozen execution artifact: brief + seeds + outputs + manifest.
5
+ * The run folder IS the review surface — humans open it directly.
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir, readdir, copyFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { getRunsDir, getBriefsDir } from './paths.js';
12
+ import { inputError } from './errors.js';
13
+
14
+ // ─── Run ID generation ───────────────────────────────────────────
15
+
16
+ /**
17
+ * Generate a run ID: run_YYYY-MM-DD_NNN
18
+ */
19
+ export function generateRunId(runsDir) {
20
+ const today = new Date().toISOString().slice(0, 10);
21
+ const prefix = `run_${today}_`;
22
+
23
+ let maxSeq = 0;
24
+ if (existsSync(runsDir)) {
25
+ const existing = readdirSync(runsDir, { withFileTypes: true })
26
+ .filter(e => e.isDirectory() && e.name.startsWith(prefix));
27
+ for (const e of existing) {
28
+ const seqStr = e.name.slice(prefix.length);
29
+ const seq = parseInt(seqStr, 10);
30
+ if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
31
+ }
32
+ }
33
+
34
+ const seq = String(maxSeq + 1).padStart(3, '0');
35
+ return `${prefix}${seq}`;
36
+ }
37
+
38
+ // ─── Seed planning ───────────────────────────────────────────────
39
+
40
+ /**
41
+ * Build a deterministic seed plan.
42
+ * @param {Object} opts
43
+ * @param {'fixed'|'increment'|'random'} opts.seedMode
44
+ * @param {number} opts.outputCount
45
+ * @param {number} [opts.baseSeed] — explicit base seed (default: random)
46
+ * @returns {{ base_seed: number, seeds: number[] }}
47
+ */
48
+ export function buildSeedPlan({ seedMode, outputCount, baseSeed }) {
49
+ const base = baseSeed ?? Math.floor(Math.random() * 2_147_483_647);
50
+ const seeds = [];
51
+
52
+ switch (seedMode) {
53
+ case 'fixed':
54
+ for (let i = 0; i < outputCount; i++) seeds.push(base);
55
+ break;
56
+ case 'increment':
57
+ for (let i = 0; i < outputCount; i++) seeds.push(base + i);
58
+ break;
59
+ case 'random':
60
+ seeds.push(base);
61
+ for (let i = 1; i < outputCount; i++) {
62
+ seeds.push(Math.floor(Math.random() * 2_147_483_647));
63
+ }
64
+ break;
65
+ default:
66
+ // Default to increment
67
+ for (let i = 0; i < outputCount; i++) seeds.push(base + i);
68
+ }
69
+
70
+ return { base_seed: base, seeds };
71
+ }
72
+
73
+ // ─── Run directory setup ─────────────────────────────────────────
74
+
75
+ /**
76
+ * Prepare a run directory and copy brief artifacts into it.
77
+ * @returns {{ runId: string, runDir: string }}
78
+ */
79
+ export async function prepareRunDir(projectRoot, brief) {
80
+ const runsDir = getRunsDir(projectRoot);
81
+ const runId = generateRunId(runsDir);
82
+ const runDir = join(runsDir, runId);
83
+
84
+ await mkdir(join(runDir, 'outputs'), { recursive: true });
85
+
86
+ // Copy brief artifacts
87
+ const briefsDir = getBriefsDir(projectRoot);
88
+ const briefJsonSrc = join(briefsDir, `${brief.brief_id}.json`);
89
+ const briefMdSrc = join(briefsDir, `${brief.brief_id}.md`);
90
+
91
+ await writeFile(join(runDir, 'brief.json'), JSON.stringify(brief, null, 2) + '\n');
92
+
93
+ if (existsSync(briefMdSrc)) {
94
+ await copyFile(briefMdSrc, join(runDir, 'brief.md'));
95
+ }
96
+
97
+ // Write standalone prompt/negative files for easy inspection
98
+ await writeFile(join(runDir, 'prompt.txt'), brief.prompt || '');
99
+ await writeFile(join(runDir, 'negative-prompt.txt'), brief.negative_prompt || '');
100
+
101
+ return { runId, runDir };
102
+ }
103
+
104
+ // ─── Manifest ────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Build and save a run manifest.
108
+ */
109
+ export async function saveRunManifest(runDir, manifest) {
110
+ await writeFile(
111
+ join(runDir, 'manifest.json'),
112
+ JSON.stringify(manifest, null, 2) + '\n'
113
+ );
114
+ }
115
+
116
+ // ─── Load / List ─────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Load a run manifest by ID.
120
+ */
121
+ export async function loadRun(projectRoot, runId) {
122
+ const runDir = join(getRunsDir(projectRoot), runId);
123
+ const manifestPath = join(runDir, 'manifest.json');
124
+
125
+ if (!existsSync(manifestPath)) {
126
+ throw inputError(
127
+ 'RUN_NOT_FOUND',
128
+ `Run "${runId}" not found`,
129
+ `Check the run ID or run: sdlab run generate --brief <id>`
130
+ );
131
+ }
132
+
133
+ return JSON.parse(await readFile(manifestPath, 'utf-8'));
134
+ }
135
+
136
+ /**
137
+ * List all runs in a project.
138
+ */
139
+ export function listRuns(projectRoot) {
140
+ const runsDir = getRunsDir(projectRoot);
141
+ if (!existsSync(runsDir)) return [];
142
+
143
+ const entries = readdirSync(runsDir, { withFileTypes: true })
144
+ .filter(e => e.isDirectory() && e.name.startsWith('run_'))
145
+ .sort((a, b) => a.name.localeCompare(b.name));
146
+
147
+ const runs = [];
148
+ for (const entry of entries) {
149
+ const manifestPath = join(runsDir, entry.name, 'manifest.json');
150
+ if (!existsSync(manifestPath)) continue;
151
+
152
+ try {
153
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
154
+ runs.push({
155
+ run_id: manifest.run_id,
156
+ brief_id: manifest.brief_id,
157
+ workflow_template_id: manifest.workflow_template_id,
158
+ output_count: manifest.output_count,
159
+ created_at: manifest.created_at,
160
+ adapter_target: manifest.adapter_target,
161
+ });
162
+ } catch {
163
+ // Skip malformed
164
+ }
165
+ }
166
+
167
+ return runs;
168
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Selection engine — approve outputs from runs or batches into
3
+ * durable selection artifacts.
4
+ *
5
+ * A selection is a creative decision: what was chosen, why, and
6
+ * where it came from. Re-ingest consumes selections; this module
7
+ * only builds and stores the artifact.
8
+ */
9
+
10
+ import { readFileSync, existsSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs';
11
+ import { writeFile, mkdir, readFile } from 'node:fs/promises';
12
+ import { join, basename } from 'node:path';
13
+ import { getRunsDir } from './paths.js';
14
+ import { getBatchesDir } from './batch-runs.js';
15
+ import { inputError } from './errors.js';
16
+
17
+ // ─── Selection ID generation ─────────────────────────────────────
18
+
19
+ export function getSelectionsDir(projectRoot) {
20
+ return join(projectRoot, 'selections');
21
+ }
22
+
23
+ export function generateSelectionId(selectionsDir) {
24
+ const today = new Date().toISOString().slice(0, 10);
25
+ const prefix = `selection_${today}_`;
26
+
27
+ let maxSeq = 0;
28
+ if (existsSync(selectionsDir)) {
29
+ const existing = readdirSync(selectionsDir, { withFileTypes: true })
30
+ .filter(e => e.isDirectory() && e.name.startsWith(prefix));
31
+ for (const e of existing) {
32
+ const seqStr = e.name.slice(prefix.length);
33
+ const seq = parseInt(seqStr, 10);
34
+ if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
35
+ }
36
+ }
37
+
38
+ return `${prefix}${String(maxSeq + 1).padStart(3, '0')}`;
39
+ }
40
+
41
+ // ─── Run selection ───────────────────────────────────────────────
42
+
43
+ /**
44
+ * Create a selection from a single run.
45
+ *
46
+ * @param {Object} opts
47
+ * @param {string} opts.projectRoot
48
+ * @param {string} opts.projectId
49
+ * @param {string} opts.runId
50
+ * @param {string[]} opts.approvedFiles — filenames to approve (e.g. ['001.png', '003.png'])
51
+ * @param {string} [opts.reason]
52
+ * @param {string[]} [opts.tags]
53
+ * @returns {Promise<Object>} selection manifest
54
+ */
55
+ export async function createSelectionFromRun({
56
+ projectRoot, projectId, runId, approvedFiles, reason, tags,
57
+ }) {
58
+ const runsDir = getRunsDir(projectRoot);
59
+ const runDir = join(runsDir, runId);
60
+ const manifestPath = join(runDir, 'manifest.json');
61
+
62
+ if (!existsSync(manifestPath)) {
63
+ throw inputError('RUN_NOT_FOUND', `Run "${runId}" not found`, 'Check the run ID.');
64
+ }
65
+
66
+ const runManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
67
+ const outputsDir = join(runDir, 'outputs');
68
+
69
+ // Validate all approved files exist
70
+ for (const file of approvedFiles) {
71
+ if (!existsSync(join(outputsDir, file))) {
72
+ throw inputError(
73
+ 'OUTPUT_NOT_FOUND',
74
+ `Output "${file}" not found in ${runId}/outputs/`,
75
+ `Available: ${readdirSync(outputsDir).filter(f => f.endsWith('.png')).join(', ') || '(none)'}`,
76
+ );
77
+ }
78
+ }
79
+
80
+ // Create selection directory
81
+ const selectionsDir = getSelectionsDir(projectRoot);
82
+ const selectionId = generateSelectionId(selectionsDir);
83
+ const selectionDir = join(selectionsDir, selectionId);
84
+ await mkdir(join(selectionDir, 'chosen'), { recursive: true });
85
+
86
+ // Copy chosen files
87
+ const items = [];
88
+ for (let i = 0; i < approvedFiles.length; i++) {
89
+ const file = approvedFiles[i];
90
+ const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
91
+ copyFileSync(join(outputsDir, file), join(selectionDir, 'chosen', destName));
92
+
93
+ // Find seed from run manifest
94
+ const outputEntry = runManifest.outputs?.find(o => o.filename === file);
95
+ items.push({
96
+ slot_or_output: file,
97
+ filename: destName,
98
+ seed: outputEntry?.seed,
99
+ reason: reason || 'selected',
100
+ tags: tags || [],
101
+ });
102
+ }
103
+
104
+ // Build selection manifest
105
+ const manifest = {
106
+ selection_id: selectionId,
107
+ project_id: projectId,
108
+ source_type: 'run',
109
+ source_id: runId,
110
+ workflow_id: runManifest.workflow_template_id,
111
+ created_at: new Date().toISOString(),
112
+ items,
113
+ reingest_ready: true,
114
+ };
115
+
116
+ // Attach subject from brief if available
117
+ const briefPath = join(runDir, 'brief.json');
118
+ if (existsSync(briefPath)) {
119
+ try {
120
+ const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
121
+ if (brief.subject_id) manifest.subject_id = brief.subject_id;
122
+ } catch { /* skip */ }
123
+ }
124
+
125
+ return { selectionId, selectionDir, manifest, runManifest };
126
+ }
127
+
128
+ // ─── Batch selection ─────────────────────────────────────────────
129
+
130
+ /**
131
+ * Create a selection from a batch.
132
+ *
133
+ * @param {Object} opts
134
+ * @param {string} opts.projectRoot
135
+ * @param {string} opts.projectId
136
+ * @param {string} opts.batchId
137
+ * @param {Array<{slotId: string, filename: string}>} opts.approvedSlots
138
+ * @param {string} [opts.reason]
139
+ * @param {string[]} [opts.tags]
140
+ * @returns {Promise<Object>} selection manifest
141
+ */
142
+ export async function createSelectionFromBatch({
143
+ projectRoot, projectId, batchId, approvedSlots, reason, tags,
144
+ }) {
145
+ const batchesDir = getBatchesDir(projectRoot);
146
+ const batchDir = join(batchesDir, batchId);
147
+ const batchManifestPath = join(batchDir, 'manifest.json');
148
+
149
+ if (!existsSync(batchManifestPath)) {
150
+ throw inputError('BATCH_NOT_FOUND', `Batch "${batchId}" not found`, 'Check the batch ID.');
151
+ }
152
+
153
+ const batchManifest = JSON.parse(readFileSync(batchManifestPath, 'utf-8'));
154
+ const runsDir = getRunsDir(projectRoot);
155
+
156
+ // Build slot → run lookup
157
+ const slotRunMap = new Map();
158
+ for (const slot of batchManifest.slots || []) {
159
+ slotRunMap.set(slot.slot_id, slot);
160
+ }
161
+
162
+ // Validate each approved slot
163
+ const runManifests = new Map();
164
+ for (const { slotId, filename } of approvedSlots) {
165
+ const slot = slotRunMap.get(slotId);
166
+ if (!slot) {
167
+ throw inputError(
168
+ 'SLOT_NOT_FOUND',
169
+ `Slot "${slotId}" not found in batch ${batchId}`,
170
+ `Available: ${[...slotRunMap.keys()].join(', ')}`,
171
+ );
172
+ }
173
+
174
+ const runDir = join(runsDir, slot.run_id);
175
+ const outputPath = join(runDir, 'outputs', filename);
176
+ if (!existsSync(outputPath)) {
177
+ throw inputError(
178
+ 'OUTPUT_NOT_FOUND',
179
+ `Output "${filename}" not found in ${slot.run_id}/outputs/`,
180
+ `Check the filename.`,
181
+ );
182
+ }
183
+
184
+ // Cache run manifests
185
+ if (!runManifests.has(slot.run_id)) {
186
+ const rmPath = join(runDir, 'manifest.json');
187
+ if (existsSync(rmPath)) {
188
+ runManifests.set(slot.run_id, JSON.parse(readFileSync(rmPath, 'utf-8')));
189
+ }
190
+ }
191
+ }
192
+
193
+ // Create selection directory
194
+ const selectionsDir = getSelectionsDir(projectRoot);
195
+ const selectionId = generateSelectionId(selectionsDir);
196
+ const selectionDir = join(selectionsDir, selectionId);
197
+ await mkdir(join(selectionDir, 'chosen'), { recursive: true });
198
+
199
+ // Copy chosen files and build items
200
+ const items = [];
201
+ for (let i = 0; i < approvedSlots.length; i++) {
202
+ const { slotId, filename } = approvedSlots[i];
203
+ const slot = slotRunMap.get(slotId);
204
+ const runDir = join(runsDir, slot.run_id);
205
+ const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
206
+
207
+ copyFileSync(join(runDir, 'outputs', filename), join(selectionDir, 'chosen', destName));
208
+
209
+ const rm = runManifests.get(slot.run_id);
210
+ const outputEntry = rm?.outputs?.find(o => o.filename === filename);
211
+
212
+ items.push({
213
+ slot_or_output: `${slotId}:${filename}`,
214
+ filename: destName,
215
+ seed: outputEntry?.seed,
216
+ reason: reason || `selected from slot ${slot.label || slotId}`,
217
+ tags: tags || [],
218
+ });
219
+ }
220
+
221
+ const manifest = {
222
+ selection_id: selectionId,
223
+ project_id: projectId,
224
+ source_type: 'batch',
225
+ source_id: batchId,
226
+ workflow_id: batchManifest.mode_id,
227
+ created_at: new Date().toISOString(),
228
+ items,
229
+ reingest_ready: true,
230
+ };
231
+
232
+ if (batchManifest.subject_id) manifest.subject_id = batchManifest.subject_id;
233
+
234
+ return { selectionId, selectionDir, manifest, batchManifest, runManifests };
235
+ }
236
+
237
+ // ─── Save / Load / List ──────────────────────────────────────────
238
+
239
+ /**
240
+ * Save a selection manifest + summary to disk.
241
+ */
242
+ export async function saveSelection(selectionDir, manifest) {
243
+ await writeFile(
244
+ join(selectionDir, 'manifest.json'),
245
+ JSON.stringify(manifest, null, 2) + '\n',
246
+ );
247
+
248
+ // Summary JSON
249
+ await writeFile(
250
+ join(selectionDir, 'summary.json'),
251
+ JSON.stringify({
252
+ selection_id: manifest.selection_id,
253
+ source_type: manifest.source_type,
254
+ source_id: manifest.source_id,
255
+ workflow_id: manifest.workflow_id,
256
+ subject_id: manifest.subject_id,
257
+ items: manifest.items.length,
258
+ reingest_ready: manifest.reingest_ready,
259
+ created_at: manifest.created_at,
260
+ }, null, 2) + '\n',
261
+ );
262
+
263
+ // Summary markdown
264
+ const lines = [
265
+ `# Selection: ${manifest.selection_id}`,
266
+ '',
267
+ `**Source:** ${manifest.source_type} \`${manifest.source_id}\``,
268
+ `**Workflow:** ${manifest.workflow_id || '—'}`,
269
+ ];
270
+ if (manifest.subject_id) lines.push(`**Subject:** ${manifest.subject_id}`);
271
+ lines.push(`**Created:** ${manifest.created_at}`);
272
+ lines.push(`**Re-ingest ready:** ${manifest.reingest_ready ? 'yes' : 'no'}`);
273
+ lines.push('');
274
+ lines.push('## Chosen outputs');
275
+ lines.push('');
276
+ lines.push('| # | Source | File | Seed | Reason |');
277
+ lines.push('|---|--------|------|------|--------|');
278
+ for (let i = 0; i < manifest.items.length; i++) {
279
+ const item = manifest.items[i];
280
+ lines.push(
281
+ `| ${i + 1} | ${item.slot_or_output} | ${item.filename} | ${item.seed ?? '—'} | ${item.reason} |`,
282
+ );
283
+ }
284
+ lines.push('');
285
+ if (manifest.items.some(it => it.tags?.length)) {
286
+ lines.push('## Tags');
287
+ for (const item of manifest.items) {
288
+ if (item.tags?.length) {
289
+ lines.push(`- ${item.filename}: ${item.tags.join(', ')}`);
290
+ }
291
+ }
292
+ lines.push('');
293
+ }
294
+ await writeFile(join(selectionDir, 'summary.md'), lines.join('\n'));
295
+ }
296
+
297
+ /**
298
+ * Load a selection manifest by ID.
299
+ */
300
+ export function loadSelection(projectRoot, selectionId) {
301
+ const selectionDir = join(getSelectionsDir(projectRoot), selectionId);
302
+ const manifestPath = join(selectionDir, 'manifest.json');
303
+ if (!existsSync(manifestPath)) {
304
+ throw inputError(
305
+ 'SELECTION_NOT_FOUND',
306
+ `Selection "${selectionId}" not found`,
307
+ 'Check the selection ID or run: sdlab select --run <id> --approve <files>',
308
+ );
309
+ }
310
+ return JSON.parse(readFileSync(manifestPath, 'utf-8'));
311
+ }
312
+
313
+ /**
314
+ * List all selections in a project.
315
+ */
316
+ export function listSelections(projectRoot) {
317
+ const dir = getSelectionsDir(projectRoot);
318
+ if (!existsSync(dir)) return [];
319
+ return readdirSync(dir, { withFileTypes: true })
320
+ .filter(e => e.isDirectory() && e.name.startsWith('selection_'))
321
+ .map(e => e.name)
322
+ .sort();
323
+ }
package/lib/snapshot.js CHANGED
@@ -53,7 +53,7 @@ export function computeConfigFingerprint(projectRoot) {
53
53
  * @param {Object} profile — selection profile
54
54
  * @returns {Promise<{snapshotId: string, included: number, excluded: number}>}
55
55
  */
56
- export async function createSnapshot(projectRoot, profile) {
56
+ export async function createSnapshot(projectRoot, profile, options = {}) {
57
57
  const config = loadProjectConfig(projectRoot);
58
58
  const recordsDir = join(projectRoot, 'records');
59
59
  const snapshotId = generateSnapshotId();
@@ -104,7 +104,10 @@ export async function createSnapshot(projectRoot, profile) {
104
104
  }
105
105
  }
106
106
 
107
- // Write snapshot files
107
+ // Write snapshot files (skip if dry run)
108
+ if (options.dryRun) {
109
+ return { snapshotId, included: included.length, excluded: excluded.length };
110
+ }
108
111
  await mkdir(snapshotDir, { recursive: true });
109
112
 
110
113
  const manifest = {
package/lib/split.js CHANGED
@@ -127,7 +127,7 @@ function buildAncestorMap(records) {
127
127
  * @param {Object} profile — split profile (from loadSplitProfile)
128
128
  * @returns {Promise<{splitId: string, train: number, val: number, test: number}>}
129
129
  */
130
- export async function createSplit(projectRoot, snapshotId, profile) {
130
+ export async function createSplit(projectRoot, snapshotId, profile, options = {}) {
131
131
  const config = loadProjectConfig(projectRoot);
132
132
  const snapshot = await loadSnapshot(projectRoot, snapshotId);
133
133
  const includedEntries = await loadSnapshotIncluded(projectRoot, snapshotId);
@@ -242,8 +242,11 @@ export async function createSplit(projectRoot, snapshotId, profile) {
242
242
  records, trainRecords, valRecords, testRecords, config.lanes
243
243
  );
244
244
 
245
- // Write output
245
+ // Write output (skip if dry run)
246
246
  const splitId = generateSplitId();
247
+ if (options.dryRun) {
248
+ return { splitId, train: trainRecords.length, val: valRecords.length, test: testRecords.length };
249
+ }
247
250
  const splitDir = join(projectRoot, 'splits', splitId);
248
251
  await mkdir(splitDir, { recursive: true });
249
252
 
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Workflow profile management.
3
+ *
4
+ * A workflow profile is a production recipe — it defines how to generate
5
+ * a specific kind of output set (portrait pack, expression sheet, moodboard, etc.)
6
+ * using project canon and trained assets.
7
+ *
8
+ * Workflow profiles are NOT training profiles. They drive generation, not training.
9
+ */
10
+
11
+ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { existsSync } from 'node:fs';
14
+ import { getWorkflowProfilesDir } from './paths.js';
15
+
16
+ const REQUIRED_FIELDS = [
17
+ 'workflow_id', 'label', 'lane_id', 'output_mode', 'output_count',
18
+ 'prompt_strategy', 'negative_strategy', 'canon_focus', 'drift_guards',
19
+ 'runtime_defaults',
20
+ ];
21
+
22
+ const VALID_SUBJECT_MODES = ['required', 'optional', 'forbidden'];
23
+
24
+ const VALID_OUTPUT_MODES = [
25
+ 'portrait_set', 'expression_sheet', 'variant_pack',
26
+ 'moodboard', 'silhouette_sheet', 'turnaround',
27
+ ];
28
+
29
+ const VALID_CANON_FOCUS = [
30
+ 'silhouette', 'material_language', 'palette', 'anatomy',
31
+ 'costume_logic', 'era_logic', 'scale_logic', 'lighting',
32
+ 'surface_wear', 'gesture', 'composition', 'faction_read',
33
+ ];
34
+
35
+ /**
36
+ * Validate a workflow profile object.
37
+ * @returns {{ valid: boolean, errors: string[] }}
38
+ */
39
+ export function validateWorkflowProfile(profile) {
40
+ const errors = [];
41
+
42
+ for (const field of REQUIRED_FIELDS) {
43
+ if (profile[field] === undefined || profile[field] === null) {
44
+ errors.push(`missing required field: ${field}`);
45
+ }
46
+ }
47
+
48
+ if (profile.subject_mode && !VALID_SUBJECT_MODES.includes(profile.subject_mode)) {
49
+ errors.push(`invalid subject_mode "${profile.subject_mode}" — must be one of: ${VALID_SUBJECT_MODES.join(', ')}`);
50
+ }
51
+
52
+ if (profile.output_mode && !VALID_OUTPUT_MODES.includes(profile.output_mode)) {
53
+ errors.push(`invalid output_mode "${profile.output_mode}" — must be one of: ${VALID_OUTPUT_MODES.join(', ')}`);
54
+ }
55
+
56
+ if (!Number.isInteger(profile.output_count) || profile.output_count < 1) {
57
+ errors.push('output_count must be a positive integer');
58
+ }
59
+
60
+ // Prompt strategy structure
61
+ const ps = profile.prompt_strategy;
62
+ if (ps) {
63
+ if (!Array.isArray(ps.style_prefix)) errors.push('prompt_strategy.style_prefix must be an array');
64
+ if (typeof ps.structure !== 'string') errors.push('prompt_strategy.structure must be a string');
65
+ if (!Array.isArray(ps.must_include)) errors.push('prompt_strategy.must_include must be an array');
66
+ }
67
+
68
+ // Negative strategy structure
69
+ const ns = profile.negative_strategy;
70
+ if (ns) {
71
+ if (!Array.isArray(ns.must_avoid)) errors.push('negative_strategy.must_avoid must be an array');
72
+ }
73
+
74
+ // Canon focus items
75
+ if (Array.isArray(profile.canon_focus)) {
76
+ for (const item of profile.canon_focus) {
77
+ if (!VALID_CANON_FOCUS.includes(item)) {
78
+ errors.push(`invalid canon_focus item "${item}" — must be one of: ${VALID_CANON_FOCUS.join(', ')}`);
79
+ }
80
+ }
81
+ } else if (profile.canon_focus !== undefined) {
82
+ errors.push('canon_focus must be an array');
83
+ }
84
+
85
+ if (profile.drift_guards && !Array.isArray(profile.drift_guards)) {
86
+ errors.push('drift_guards must be an array');
87
+ }
88
+
89
+ // Runtime defaults
90
+ const rd = profile.runtime_defaults;
91
+ if (rd) {
92
+ if (!rd.adapter_target) errors.push('runtime_defaults.adapter_target is required');
93
+ if (rd.seed_mode && !['fixed', 'increment', 'random'].includes(rd.seed_mode)) {
94
+ errors.push(`invalid runtime_defaults.seed_mode "${rd.seed_mode}" — must be fixed, increment, or random`);
95
+ }
96
+ }
97
+
98
+ return { valid: errors.length === 0, errors };
99
+ }
100
+
101
+ /**
102
+ * Load a single workflow profile by ID.
103
+ */
104
+ export async function getWorkflowProfile(projectRoot, workflowId) {
105
+ const dir = getWorkflowProfilesDir(projectRoot);
106
+ const path = join(dir, `${workflowId}.json`);
107
+ if (!existsSync(path)) {
108
+ throw new Error(`Workflow profile "${workflowId}" not found at ${path}`);
109
+ }
110
+ const data = JSON.parse(await readFile(path, 'utf-8'));
111
+ const { valid, errors } = validateWorkflowProfile(data);
112
+ if (!valid) {
113
+ throw new Error(`Workflow profile "${workflowId}" is invalid:\n ${errors.join('\n ')}`);
114
+ }
115
+ return data;
116
+ }
117
+
118
+ /**
119
+ * List all workflow profiles in a project.
120
+ */
121
+ export async function listWorkflowProfiles(projectRoot) {
122
+ const dir = getWorkflowProfilesDir(projectRoot);
123
+ if (!existsSync(dir)) return [];
124
+
125
+ const files = (await readdir(dir)).filter(f => f.endsWith('.json')).sort();
126
+ const profiles = [];
127
+
128
+ for (const file of files) {
129
+ try {
130
+ const data = JSON.parse(await readFile(join(dir, file), 'utf-8'));
131
+ profiles.push({
132
+ workflow_id: data.workflow_id,
133
+ label: data.label,
134
+ lane_id: data.lane_id,
135
+ subject_mode: data.subject_mode || 'optional',
136
+ output_mode: data.output_mode,
137
+ output_count: data.output_count,
138
+ });
139
+ } catch {
140
+ // Skip malformed
141
+ }
142
+ }
143
+
144
+ return profiles;
145
+ }
146
+
147
+ /**
148
+ * Save a workflow profile to a project.
149
+ */
150
+ export async function saveWorkflowProfile(projectRoot, profile) {
151
+ const { valid, errors } = validateWorkflowProfile(profile);
152
+ if (!valid) {
153
+ throw new Error(`Invalid workflow profile:\n ${errors.join('\n ')}`);
154
+ }
155
+ const dir = getWorkflowProfilesDir(projectRoot);
156
+ await mkdir(dir, { recursive: true });
157
+ await writeFile(
158
+ join(dir, `${profile.workflow_id}.json`),
159
+ JSON.stringify(profile, null, 2) + '\n'
160
+ );
161
+ }