@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
package/lib/split.js CHANGED
@@ -13,8 +13,9 @@ import { join } from 'node:path';
13
13
  import { existsSync } from 'node:fs';
14
14
  import { createHash, randomBytes } from 'node:crypto';
15
15
  import { loadProjectConfig, loadSplitProfile, detectLane } from './config.js';
16
- import { loadSnapshot, loadSnapshotIncluded } from './snapshot.js';
16
+ import { loadSnapshot, loadSnapshotIncluded, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
17
17
  import { loadRecord } from './records.js';
18
+ import { inputError } from './errors.js';
18
19
 
19
20
  /**
20
21
  * Mulberry32 — deterministic 32-bit PRNG.
@@ -119,6 +120,74 @@ function buildAncestorMap(records) {
119
120
  return ancestorMap;
120
121
  }
121
122
 
123
+ /**
124
+ * Allocate `total` items across train/val/test using largest-remainder rounding,
125
+ * guaranteeing each partition receives at least 1 when total >= 3.
126
+ */
127
+ function allocateLargestRemainder(total, profile) {
128
+ if (total <= 0) return { train: 0, val: 0, test: 0 };
129
+
130
+ const ratios = {
131
+ train: profile.train_ratio,
132
+ val: profile.val_ratio,
133
+ test: profile.test_ratio,
134
+ };
135
+
136
+ // Ideal fractional counts
137
+ const ideal = {
138
+ train: total * ratios.train,
139
+ val: total * ratios.val,
140
+ test: total * ratios.test,
141
+ };
142
+
143
+ // Floor + sort by fractional remainder desc (tie-break by partition name)
144
+ const base = {
145
+ train: Math.floor(ideal.train),
146
+ val: Math.floor(ideal.val),
147
+ test: Math.floor(ideal.test),
148
+ };
149
+ let assigned = base.train + base.val + base.test;
150
+
151
+ const remainders = [
152
+ ['train', ideal.train - base.train],
153
+ ['val', ideal.val - base.val],
154
+ ['test', ideal.test - base.test],
155
+ ].sort((a, b) => {
156
+ if (b[1] !== a[1]) return b[1] - a[1];
157
+ return a[0].localeCompare(b[0]);
158
+ });
159
+
160
+ let ri = 0;
161
+ while (assigned < total) {
162
+ base[remainders[ri % remainders.length][0]]++;
163
+ assigned++;
164
+ ri++;
165
+ }
166
+
167
+ // Min-1 rebalance: if total >= 3 and any partition is 0, take from the
168
+ // largest partition. Deterministic — always steal from 'train' first when tied.
169
+ if (total >= 3) {
170
+ const partitionOrder = ['train', 'val', 'test'];
171
+ for (const part of partitionOrder) {
172
+ if (base[part] === 0) {
173
+ // find largest (ties broken by name asc to stay deterministic)
174
+ const donor = partitionOrder
175
+ .filter(p => p !== part && base[p] > 1)
176
+ .sort((a, b) => {
177
+ if (base[b] !== base[a]) return base[b] - base[a];
178
+ return a.localeCompare(b);
179
+ })[0];
180
+ if (donor) {
181
+ base[donor]--;
182
+ base[part]++;
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ return base;
189
+ }
190
+
122
191
  /**
123
192
  * Create a split from a snapshot.
124
193
  *
@@ -156,14 +225,34 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
156
225
  familyMap.get(family).push(record);
157
226
  }
158
227
 
159
- // Detect primary lane per family (first record's lane)
228
+ // D-002: Deterministic primary lane per family = majority vote; ties
229
+ // broken by lexicographic lane name. Record the per-family decision
230
+ // for the audit so the chosen lane is explainable.
160
231
  const familyLane = new Map();
232
+ const familyLaneDecisions = [];
161
233
  for (const [family, recs] of familyMap) {
162
- const first = recs[0];
163
- const prompt = first.provenance?.prompt || '';
164
- const lane = detectLane(first.id, prompt, config.lanes);
165
- familyLane.set(family, lane);
234
+ const laneCounts = new Map();
235
+ for (const rec of recs) {
236
+ const prompt = rec.provenance?.prompt || '';
237
+ const recLane = detectLane(rec.id, prompt, config.lanes);
238
+ laneCounts.set(recLane, (laneCounts.get(recLane) || 0) + 1);
239
+ }
240
+ // Sort: count desc, then lane name asc. Deterministic winner.
241
+ const ranked = [...laneCounts.entries()].sort((a, b) => {
242
+ if (b[1] !== a[1]) return b[1] - a[1];
243
+ return a[0].localeCompare(b[0]);
244
+ });
245
+ const [chosenLane, chosenCount] = ranked[0];
246
+ familyLane.set(family, chosenLane);
247
+ familyLaneDecisions.push({
248
+ family,
249
+ chosen_lane: chosenLane,
250
+ chosen_count: chosenCount,
251
+ record_count: recs.length,
252
+ lane_counts: Object.fromEntries(laneCounts),
253
+ });
166
254
  }
255
+ familyLaneDecisions.sort((a, b) => a.family.localeCompare(b.family));
167
256
 
168
257
  // Group families by lane
169
258
  const laneGroups = new Map(); // lane → [family]
@@ -173,7 +262,8 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
173
262
  }
174
263
 
175
264
  // Split families per lane using seeded PRNG
176
- const rng = mulberry32(profile.seed || 42);
265
+ // D-011: seed:0 is a valid seed; use nullish coalescing so 0 is honored.
266
+ const rng = mulberry32(profile.seed ?? 42);
177
267
  const trainFamilies = new Set();
178
268
  const valFamilies = new Set();
179
269
  const testFamilies = new Set();
@@ -191,11 +281,17 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
191
281
  continue;
192
282
  }
193
283
 
194
- // Assign by ratio
284
+ // D-004: Largest-remainder allocation guarantees min-1 per partition
285
+ // when total >= 3, and emits a warning if any partition ends up at 0.
195
286
  const total = shuffled.length;
196
- const trainCount = Math.round(total * profile.train_ratio);
197
- const valCount = Math.round(total * profile.val_ratio);
198
- // test gets the rest
287
+ const { train: trainCount, val: valCount, test: testCount } =
288
+ allocateLargestRemainder(total, profile);
289
+
290
+ if (total >= 3 && (trainCount === 0 || valCount === 0 || testCount === 0)) {
291
+ warnings.push(
292
+ `lane "${lane}": partition size hit 0 after allocation (train=${trainCount}, val=${valCount}, test=${testCount}) — ratios may be extreme`,
293
+ );
294
+ }
199
295
 
200
296
  let idx = 0;
201
297
  for (let i = 0; i < trainCount && idx < total; i++, idx++) {
@@ -226,7 +322,14 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
226
322
  if (trainFamilies.has(family)) trainRecords.push(record.id);
227
323
  else if (valFamilies.has(family)) valRecords.push(record.id);
228
324
  else if (testFamilies.has(family)) testRecords.push(record.id);
229
- else trainRecords.push(record.id); // fallback: unassigned → train
325
+ else {
326
+ // D-003: unassigned family means the familyMap build pass disagreed
327
+ // with the assignment pass — never silently rescue; surface the bug.
328
+ throw new Error(
329
+ `unassigned family for record ${record.id} (family=${family}) — ` +
330
+ `this indicates a bug in family resolution; the record was not placed in train/val/test`,
331
+ );
332
+ }
230
333
  }
231
334
 
232
335
  // Sort for deterministic output
@@ -237,9 +340,9 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
237
340
  // Leakage audit
238
341
  const leakageIssues = auditLeakage(familyMap, trainRecords, valRecords, testRecords);
239
342
 
240
- // Lane balance audit
343
+ // Lane balance audit (DB-005: includes deviation from profile ratios)
241
344
  const laneBalance = auditLaneBalance(
242
- records, trainRecords, valRecords, testRecords, config.lanes
345
+ records, trainRecords, valRecords, testRecords, config.lanes, profile
243
346
  );
244
347
 
245
348
  // Write output (skip if dry run)
@@ -248,9 +351,19 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
248
351
  return { splitId, train: trainRecords.length, val: valRecords.length, test: testRecords.length };
249
352
  }
250
353
  const splitDir = join(projectRoot, 'splits', splitId);
354
+ // D-005: refuse to silently overwrite on ID collision.
355
+ if (existsSync(splitDir)) {
356
+ throw inputError(
357
+ 'SPLIT_ID_COLLISION',
358
+ `Split directory already exists: ${splitDir}`,
359
+ 'Retry to generate a fresh split ID.',
360
+ );
361
+ }
251
362
  await mkdir(splitDir, { recursive: true });
252
363
 
253
364
  const manifest = {
365
+ // DB-001 / DB-006: stamp manifest schema version
366
+ schema_version: SCHEMA_VERSION,
254
367
  split_id: splitId,
255
368
  created_at: new Date().toISOString(),
256
369
  created_by: 'sdlab-split-v1',
@@ -280,6 +393,9 @@ export async function createSplit(projectRoot, snapshotId, profile, options = {}
280
393
  val: valFamilies.size,
281
394
  test: testFamilies.size,
282
395
  },
396
+ // D-002: record the deterministic per-family lane decision for replay.
397
+ family_lane_decisions: familyLaneDecisions,
398
+ warnings,
283
399
  };
284
400
 
285
401
  await writeFile(join(splitDir, 'split.json'), JSON.stringify(manifest, null, 2) + '\n');
@@ -322,8 +438,13 @@ function auditLeakage(familyMap, trainIds, valIds, testIds) {
322
438
 
323
439
  /**
324
440
  * Audit lane balance across splits.
441
+ *
442
+ * DB-005: includes deviation-from-profile metric per lane + overall score.
443
+ * A lane that shows train=100/val=0/test=0 and another at 33/33/33 look
444
+ * equally "balanced" by percentage alone — deviation makes the real gap
445
+ * between intent and reality visible at a glance.
325
446
  */
326
- function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig) {
447
+ function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig, profile) {
327
448
  const trainSet = new Set(trainIds);
328
449
  const valSet = new Set(valIds);
329
450
  const testSet = new Set(testIds);
@@ -339,14 +460,49 @@ function auditLaneBalance(records, trainIds, valIds, testIds, lanesConfig) {
339
460
  else if (testSet.has(record.id)) balance[lane].test++;
340
461
  }
341
462
 
342
- // Compute percentages
463
+ // Compute percentages + deviation from profile ratios (DB-005)
464
+ const targetTrain = (profile?.train_ratio ?? 0) * 100;
465
+ const targetVal = (profile?.val_ratio ?? 0) * 100;
466
+ const targetTest = (profile?.test_ratio ?? 0) * 100;
467
+
468
+ let overallDeviation = 0;
469
+ const deviations = {};
470
+
343
471
  for (const lane of Object.keys(balance)) {
344
472
  const b = balance[lane];
345
473
  b.train_pct = b.total > 0 ? +(b.train / b.total * 100).toFixed(1) : 0;
346
474
  b.val_pct = b.total > 0 ? +(b.val / b.total * 100).toFixed(1) : 0;
347
475
  b.test_pct = b.total > 0 ? +(b.test / b.total * 100).toFixed(1) : 0;
476
+
477
+ // DB-005: absolute deviation from profile target per partition
478
+ const devTrain = Math.abs(b.train_pct - targetTrain);
479
+ const devVal = Math.abs(b.val_pct - targetVal);
480
+ const devTest = Math.abs(b.test_pct - targetTest);
481
+ // Lane deviation: sum of per-partition absolute deviations (max ~200)
482
+ const laneDev = +(devTrain + devVal + devTest).toFixed(1);
483
+ b.deviation_from_target = {
484
+ train: +devTrain.toFixed(1),
485
+ val: +devVal.toFixed(1),
486
+ test: +devTest.toFixed(1),
487
+ total: laneDev,
488
+ };
489
+ deviations[lane] = laneDev;
490
+ overallDeviation += laneDev;
348
491
  }
349
492
 
493
+ // Attach a summary so the operator sees the signal without scanning per-lane rows.
494
+ balance._summary = {
495
+ target_ratios_pct: {
496
+ train: +targetTrain.toFixed(1),
497
+ val: +targetVal.toFixed(1),
498
+ test: +targetTest.toFixed(1),
499
+ },
500
+ overall_deviation_score: +overallDeviation.toFixed(1),
501
+ worst_lane: Object.keys(deviations).length > 0
502
+ ? Object.entries(deviations).sort((a, b) => b[1] - a[1])[0][0]
503
+ : null,
504
+ };
505
+
350
506
  return balance;
351
507
  }
352
508
 
@@ -358,7 +514,9 @@ export async function loadSplit(projectRoot, splitId) {
358
514
  if (!existsSync(path)) {
359
515
  throw new Error(`Split "${splitId}" not found at ${path}`);
360
516
  }
361
- return JSON.parse(await readFile(path, 'utf-8'));
517
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
518
+ checkManifestVersion(manifest, 'split');
519
+ return manifest;
362
520
  }
363
521
 
364
522
  /**
@@ -12,8 +12,9 @@ import { existsSync } from 'node:fs';
12
12
  import { createHash, randomBytes } from 'node:crypto';
13
13
  import { loadExport } from './export.js';
14
14
  import { loadTrainingProfile } from './training-profiles.js';
15
- import { computeConfigFingerprint } from './snapshot.js';
15
+ import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
16
16
  import { listEvalPacks } from './eval-pack.js';
17
+ import { inputError } from './errors.js';
17
18
 
18
19
  /**
19
20
  * Generate a training manifest ID: tm-YYYYMMDD-HHMMSS-XXXX
@@ -64,6 +65,8 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
64
65
 
65
66
  const manifestId = generateManifestId();
66
67
  const manifest = {
68
+ // DB-001 / DB-006: stamp manifest schema version
69
+ schema_version: SCHEMA_VERSION,
67
70
  training_manifest_id: manifestId,
68
71
  created_at: new Date().toISOString(),
69
72
  created_by: 'sdlab-training-manifest-v1',
@@ -89,7 +92,16 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
89
92
  // Write manifest
90
93
  const dir = join(projectRoot, 'training', 'manifests');
91
94
  await mkdir(dir, { recursive: true });
92
- await writeFile(join(dir, `${manifestId}.json`), JSON.stringify(manifest, null, 2) + '\n');
95
+ const manifestPath = join(dir, `${manifestId}.json`);
96
+ // D-005: refuse to silently overwrite on ID collision.
97
+ if (existsSync(manifestPath)) {
98
+ throw inputError(
99
+ 'TRAINING_MANIFEST_ID_COLLISION',
100
+ `Training manifest file already exists: ${manifestPath}`,
101
+ 'Retry to generate a fresh manifest ID.',
102
+ );
103
+ }
104
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
93
105
 
94
106
  return { manifestId, exportId, profileId };
95
107
  }
@@ -139,7 +151,9 @@ export async function loadTrainingManifest(projectRoot, manifestId) {
139
151
  if (!existsSync(path)) {
140
152
  throw new Error(`Training manifest "${manifestId}" not found at ${path}`);
141
153
  }
142
- return JSON.parse(await readFile(path, 'utf-8'));
154
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
155
+ checkManifestVersion(manifest, 'training-manifest');
156
+ return manifest;
143
157
  }
144
158
 
145
159
  /**
@@ -15,6 +15,8 @@ import { loadTrainingProfile } from './training-profiles.js';
15
15
  import { loadSplitPartition } from './split.js';
16
16
  import { loadRecord } from './records.js';
17
17
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
+ import { inputError } from './errors.js';
19
+ import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
18
20
 
19
21
  /**
20
22
  * Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
@@ -63,6 +65,14 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
63
65
  const recordsDir = join(projectRoot, 'records');
64
66
  const packageId = generatePackageId();
65
67
  const packageDir = join(projectRoot, 'training', 'packages', packageId);
68
+ // D-005: refuse to silently overwrite on ID collision.
69
+ if (existsSync(packageDir)) {
70
+ throw inputError(
71
+ 'TRAINING_PACKAGE_ID_COLLISION',
72
+ `Training package directory already exists: ${packageDir}`,
73
+ 'Retry to generate a fresh package ID.',
74
+ );
75
+ }
66
76
 
67
77
  // Load split partitions
68
78
  const partitions = {};
@@ -105,19 +115,10 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
105
115
 
106
116
  totalImages = adapterResult.imageCount || 0;
107
117
 
108
- // Generate checksums for all files in the package
109
- const checksumEntries = [];
110
- const allFiles = await collectFiles(packageDir);
111
- for (const filePath of allFiles.sort()) {
112
- const relPath = relative(packageDir, filePath);
113
- if (relPath === 'checksums.txt') continue;
114
- const hash = await sha256File(filePath);
115
- checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
116
- }
117
- await writeFile(join(packageDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
118
-
119
- // Write package manifest (last — references everything)
118
+ // D-015: Write manifest + README FIRST so they are covered by checksums.
120
119
  const packageManifest = {
120
+ // DB-001 / DB-006: stamp manifest schema version
121
+ schema_version: SCHEMA_VERSION,
121
122
  training_package_id: packageId,
122
123
  created_at: new Date().toISOString(),
123
124
  created_by: 'sdlab-training-package-v1',
@@ -134,7 +135,7 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
134
135
  val: partitions.val.length,
135
136
  test: partitions.test.length,
136
137
  images: totalImages,
137
- checksums: checksumEntries.length,
138
+ checksums: 0, // updated after checksum pass below
138
139
  },
139
140
  };
140
141
  await writeFile(join(packageDir, 'manifest.json'), JSON.stringify(packageManifest, null, 2) + '\n');
@@ -143,6 +144,17 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
143
144
  const readme = generatePackageReadme(packageManifest, profile, manifest, adapterTarget);
144
145
  await writeFile(join(packageDir, 'README.md'), readme);
145
146
 
147
+ // Generate checksums AFTER manifest+README are on disk so they are included.
148
+ const checksumEntries = [];
149
+ const allFiles = await collectFiles(packageDir);
150
+ for (const filePath of allFiles.sort()) {
151
+ const relPath = relative(packageDir, filePath);
152
+ if (relPath === 'checksums.txt') continue;
153
+ const hash = await sha256File(filePath);
154
+ checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
155
+ }
156
+ await writeFile(join(packageDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
157
+
146
158
  return { packageId, records: totalRecords, images: totalImages };
147
159
  }
148
160
 
@@ -237,5 +249,7 @@ export async function loadTrainingPackage(projectRoot, packageId) {
237
249
  if (!existsSync(path)) {
238
250
  throw new Error(`Training package "${packageId}" not found at ${path}`);
239
251
  }
240
- return JSON.parse(await readFile(path, 'utf-8'));
252
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
253
+ checkManifestVersion(manifest, 'training-package');
254
+ return manifest;
241
255
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@mcptoolshop/style-dataset-lab",
3
- "version": "2.3.0",
4
- "description": "Visual canon and dataset production pipeline — snapshots, splits, versioned exports, eval packs, and canon-bound curation for any image production domain",
3
+ "version": "3.0.1",
4
+ "description": "Canon-aligned dataset production and generation workbench — define visual rules, build versioned training data, compile production briefs, run local workflows, batch-produce, select winners, and re-ingest into your corpus",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sdlab": "./bin/sdlab.js"
8
8
  },
9
9
  "scripts": {
10
+ "test": "node --test tests/**/*.test.js",
10
11
  "verify": "node bin/sdlab.js --help && node bin/sdlab.js project doctor --project star-freight",
11
12
  "generate": "node scripts/generate.js",
12
13
  "curate": "node scripts/curate.js",
@@ -19,11 +20,16 @@
19
20
  "bin/",
20
21
  "lib/",
21
22
  "scripts/",
23
+ "!scripts/bulk-curate-*.js",
24
+ "!scripts/curate-wave*.js",
25
+ "!scripts/migrate-records.js",
22
26
  "templates/",
23
27
  "README.md",
24
28
  "LICENSE",
25
29
  "SECURITY.md",
26
- "CHANGELOG.md"
30
+ "CHANGELOG.md",
31
+ "CONTRIBUTING.md",
32
+ "CODE_OF_CONDUCT.md"
27
33
  ],
28
34
  "keywords": [
29
35
  "dataset",
@@ -38,7 +44,10 @@
38
44
  "style-guide",
39
45
  "lora",
40
46
  "multimodal",
41
- "comfyui"
47
+ "comfyui",
48
+ "generation-workflow",
49
+ "batch-production",
50
+ "reintegration"
42
51
  ],
43
52
  "homepage": "https://mcp-tool-shop-org.github.io/style-dataset-lab/",
44
53
  "repository": {