@mcptoolshop/style-dataset-lab 2.1.0 → 2.2.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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Re-ingest engine for generated outputs.
3
+ *
4
+ * Accepted generated outputs re-enter the project as new records
5
+ * with provenance fields linking back to the training manifest.
6
+ * No bypass around review — generated work must be curated and
7
+ * canon-bound like everything else.
8
+ */
9
+
10
+ import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
11
+ import { join, basename, extname } from 'node:path';
12
+ import { existsSync } from 'node:fs';
13
+ import { loadTrainingManifest } from './training-manifests.js';
14
+ import { buildBaseRecord } from './records.js';
15
+
16
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
17
+
18
+ /**
19
+ * Re-ingest generated outputs from a directory into the project.
20
+ *
21
+ * Each image in the source directory becomes a new record with:
22
+ * - provenance.source = "generated"
23
+ * - provenance.training_manifest_id = <manifest-id>
24
+ * - judgment = null (must be reviewed)
25
+ * - canon = null (must be bound)
26
+ *
27
+ * @param {string} projectRoot
28
+ * @param {string} sourcePath — directory containing generated images
29
+ * @param {string} manifestId — training manifest that produced them
30
+ * @param {Object} options — { dryRun? }
31
+ * @returns {Promise<{created: number, skipped: number, records: string[]}>}
32
+ */
33
+ export async function reingestGenerated(projectRoot, sourcePath, manifestId, options = {}) {
34
+ const manifest = await loadTrainingManifest(projectRoot, manifestId);
35
+ const recordsDir = join(projectRoot, 'records');
36
+
37
+ // Find image files in source directory
38
+ const entries = await readdir(sourcePath);
39
+ const imageFiles = entries
40
+ .filter(f => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
41
+ .sort();
42
+
43
+ const created = [];
44
+ const skipped = [];
45
+
46
+ for (const file of imageFiles) {
47
+ const stem = basename(file, extname(file));
48
+ const recordId = `gen_${stem}`;
49
+ const recordPath = join(recordsDir, `${recordId}.json`);
50
+
51
+ // Skip if record already exists
52
+ if (existsSync(recordPath)) {
53
+ skipped.push(recordId);
54
+ continue;
55
+ }
56
+
57
+ const filePath = join(sourcePath, file);
58
+ const fileStat = await stat(filePath);
59
+
60
+ // Relative asset path from project root
61
+ const assetPath = `outputs/candidates/${file}`;
62
+
63
+ const provenance = {
64
+ source: 'generated',
65
+ training_manifest_id: manifestId,
66
+ training_profile_id: manifest.training_profile_id,
67
+ adapter_target: manifest.adapter_target,
68
+ base_model: manifest.base_model || null,
69
+ source_export_id: manifest.source_export_id,
70
+ generated_at: new Date().toISOString(),
71
+ original_path: filePath,
72
+ };
73
+
74
+ const record = buildBaseRecord(recordId, assetPath, {
75
+ width: null,
76
+ height: null,
77
+ bytes: fileStat.size,
78
+ }, provenance);
79
+
80
+ record.schema_version = '2.1.0';
81
+
82
+ if (!options.dryRun) {
83
+ await writeFile(recordPath, JSON.stringify(record, null, 2) + '\n');
84
+ }
85
+
86
+ created.push(recordId);
87
+ }
88
+
89
+ return { created: created.length, skipped: skipped.length, records: created };
90
+ }
91
+
92
+ /**
93
+ * Audit re-ingested records — check that they've been properly reviewed.
94
+ *
95
+ * @param {string} projectRoot
96
+ * @returns {Promise<{total: number, reviewed: number, unreviewed: number, bound: number, unbound: number}>}
97
+ */
98
+ export async function auditReingest(projectRoot) {
99
+ const recordsDir = join(projectRoot, 'records');
100
+ const files = (await readdir(recordsDir))
101
+ .filter(f => f.startsWith('gen_') && f.endsWith('.json'))
102
+ .sort();
103
+
104
+ let reviewed = 0;
105
+ let unreviewed = 0;
106
+ let bound = 0;
107
+ let unbound = 0;
108
+
109
+ for (const file of files) {
110
+ const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
111
+
112
+ if (record.judgment) {
113
+ reviewed++;
114
+ if (record.canon?.assertions?.length > 0) {
115
+ bound++;
116
+ } else {
117
+ unbound++;
118
+ }
119
+ } else {
120
+ unreviewed++;
121
+ }
122
+ }
123
+
124
+ return {
125
+ total: files.length,
126
+ reviewed,
127
+ unreviewed,
128
+ bound,
129
+ unbound,
130
+ };
131
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Training manifest management.
3
+ *
4
+ * A training manifest is a frozen, versioned contract for one training attempt.
5
+ * It captures the exact export package, split, profile, and config fingerprint
6
+ * so the training run is fully reproducible.
7
+ */
8
+
9
+ import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import { createHash, randomBytes } from 'node:crypto';
13
+ import { loadExport } from './export.js';
14
+ import { loadTrainingProfile } from './training-profiles.js';
15
+ import { computeConfigFingerprint } from './snapshot.js';
16
+ import { listEvalPacks } from './eval-pack.js';
17
+
18
+ /**
19
+ * Generate a training manifest ID: tm-YYYYMMDD-HHMMSS-XXXX
20
+ */
21
+ function generateManifestId() {
22
+ const now = new Date();
23
+ const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
24
+ const rand = randomBytes(2).toString('hex');
25
+ return `tm-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
26
+ }
27
+
28
+ /**
29
+ * Compute SHA-256 of a file's contents.
30
+ */
31
+ async function hashFile(filePath) {
32
+ const data = await readFile(filePath);
33
+ return createHash('sha256').update(data).digest('hex');
34
+ }
35
+
36
+ /**
37
+ * Create a training manifest from an export package and training profile.
38
+ *
39
+ * @param {string} projectRoot
40
+ * @param {string} exportId
41
+ * @param {string} profileId
42
+ * @param {Object} options — { baseModel?, adapterTarget?, hyperparameters?, captionMode? }
43
+ * @returns {Promise<{manifestId: string, exportId: string, profileId: string}>}
44
+ */
45
+ export async function createTrainingManifest(projectRoot, exportId, profileId, options = {}) {
46
+ const profile = await loadTrainingProfile(projectRoot, profileId);
47
+ const exportManifest = await loadExport(projectRoot, exportId);
48
+
49
+ // Hash the export manifest for integrity
50
+ const exportManifestPath = join(projectRoot, 'exports', exportId, 'manifest.json');
51
+ const exportManifestHash = await hashFile(exportManifestPath);
52
+
53
+ // Determine adapter target
54
+ const adapterTarget = options.adapterTarget || profile.adapter_targets[0];
55
+ if (!profile.adapter_targets.includes(adapterTarget)) {
56
+ throw new Error(
57
+ `Adapter target "${adapterTarget}" not in profile's adapter_targets: [${profile.adapter_targets.join(', ')}]`
58
+ );
59
+ }
60
+
61
+ // Find linked eval packs
62
+ const evalPacks = await listEvalPacks(projectRoot);
63
+ const linkedEvalPackIds = evalPacks.map(p => p.id);
64
+
65
+ const manifestId = generateManifestId();
66
+ const manifest = {
67
+ training_manifest_id: manifestId,
68
+ created_at: new Date().toISOString(),
69
+ created_by: 'sdlab-training-manifest-v1',
70
+ project_id: exportManifest.project,
71
+ training_profile_id: profileId,
72
+ source_export_id: exportId,
73
+ source_snapshot_id: exportManifest.snapshot_id,
74
+ source_split_id: exportManifest.split_id,
75
+ config_fingerprint: computeConfigFingerprint(projectRoot),
76
+ export_manifest_hash: exportManifestHash,
77
+ dataset_counts: exportManifest.counts,
78
+ base_model: options.baseModel || profile.base_model_recommendations?.[0] || null,
79
+ adapter_target: adapterTarget,
80
+ hyperparameters: options.hyperparameters || {},
81
+ caption_mode: options.captionMode || profile.caption_strategy || 'filename',
82
+ expected_outputs: {
83
+ asset_type: profile.asset_type,
84
+ target_family: profile.target_family,
85
+ },
86
+ linked_eval_pack_ids: linkedEvalPackIds,
87
+ };
88
+
89
+ // Write manifest
90
+ const dir = join(projectRoot, 'training', 'manifests');
91
+ await mkdir(dir, { recursive: true });
92
+ await writeFile(join(dir, `${manifestId}.json`), JSON.stringify(manifest, null, 2) + '\n');
93
+
94
+ return { manifestId, exportId, profileId };
95
+ }
96
+
97
+ /**
98
+ * Validate a training manifest against current project state.
99
+ * Checks that referenced artifacts still exist and fingerprints match.
100
+ *
101
+ * @returns {{ valid: boolean, issues: string[] }}
102
+ */
103
+ export async function validateTrainingManifest(projectRoot, manifestId) {
104
+ const manifest = await loadTrainingManifest(projectRoot, manifestId);
105
+ const issues = [];
106
+
107
+ // Check export exists
108
+ const exportPath = join(projectRoot, 'exports', manifest.source_export_id, 'manifest.json');
109
+ if (!existsSync(exportPath)) {
110
+ issues.push(`source export "${manifest.source_export_id}" not found`);
111
+ } else {
112
+ // Check export hash
113
+ const currentHash = await hashFile(exportPath);
114
+ if (currentHash !== manifest.export_manifest_hash) {
115
+ issues.push(`export manifest hash mismatch — export may have been modified`);
116
+ }
117
+ }
118
+
119
+ // Check config fingerprint
120
+ const currentFingerprint = computeConfigFingerprint(projectRoot);
121
+ if (currentFingerprint !== manifest.config_fingerprint) {
122
+ issues.push(`config fingerprint mismatch — project config has changed since manifest creation`);
123
+ }
124
+
125
+ // Check training profile exists
126
+ const profilePath = join(projectRoot, 'training', 'profiles', `${manifest.training_profile_id}.json`);
127
+ if (!existsSync(profilePath)) {
128
+ issues.push(`training profile "${manifest.training_profile_id}" not found`);
129
+ }
130
+
131
+ return { valid: issues.length === 0, issues };
132
+ }
133
+
134
+ /**
135
+ * Load a training manifest.
136
+ */
137
+ export async function loadTrainingManifest(projectRoot, manifestId) {
138
+ const path = join(projectRoot, 'training', 'manifests', `${manifestId}.json`);
139
+ if (!existsSync(path)) {
140
+ throw new Error(`Training manifest "${manifestId}" not found at ${path}`);
141
+ }
142
+ return JSON.parse(await readFile(path, 'utf-8'));
143
+ }
144
+
145
+ /**
146
+ * List all training manifests in a project.
147
+ */
148
+ export async function listTrainingManifests(projectRoot) {
149
+ const dir = join(projectRoot, 'training', 'manifests');
150
+ if (!existsSync(dir)) return [];
151
+
152
+ const files = (await readdir(dir)).filter(f => f.endsWith('.json')).sort();
153
+ const manifests = [];
154
+
155
+ for (const file of files) {
156
+ try {
157
+ const data = JSON.parse(await readFile(join(dir, file), 'utf-8'));
158
+ manifests.push({
159
+ id: data.training_manifest_id,
160
+ created_at: data.created_at,
161
+ profile: data.training_profile_id,
162
+ export_id: data.source_export_id,
163
+ adapter: data.adapter_target,
164
+ records: data.dataset_counts?.records || 0,
165
+ });
166
+ } catch {
167
+ // Skip malformed
168
+ }
169
+ }
170
+
171
+ return manifests.sort((a, b) => a.created_at.localeCompare(b.created_at));
172
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Training package builder.
3
+ *
4
+ * Builds trainer-ready dataset packages from training manifests.
5
+ * Adapters transform the canonical layout for specific training targets
6
+ * but never mutate inclusion or split truth.
7
+ */
8
+
9
+ import { readFile, writeFile, mkdir, readdir, symlink, copyFile } from 'node:fs/promises';
10
+ import { join, relative } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import { createHash, randomBytes } from 'node:crypto';
13
+ import { loadTrainingManifest } from './training-manifests.js';
14
+ import { loadTrainingProfile } from './training-profiles.js';
15
+ import { loadSplitPartition } from './split.js';
16
+ import { loadRecord } from './records.js';
17
+ import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
+
19
+ /**
20
+ * Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
21
+ */
22
+ function generatePackageId() {
23
+ const now = new Date();
24
+ const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
25
+ const rand = randomBytes(2).toString('hex');
26
+ return `tp-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
27
+ }
28
+
29
+ async function sha256File(filePath) {
30
+ const data = await readFile(filePath);
31
+ return createHash('sha256').update(data).digest('hex');
32
+ }
33
+
34
+ /**
35
+ * Load an adapter module by name.
36
+ */
37
+ async function loadAdapter(adapterTarget) {
38
+ const adapterPath = `./adapters/${adapterTarget}.js`;
39
+ try {
40
+ return await import(adapterPath);
41
+ } catch {
42
+ throw new Error(
43
+ `Adapter "${adapterTarget}" not found. Available: generic-image-caption, diffusers-lora`
44
+ );
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Build a training package from a manifest.
50
+ *
51
+ * @param {string} projectRoot
52
+ * @param {string} manifestId
53
+ * @param {Object} options — { copy?, adapterOverride? }
54
+ * @returns {Promise<{packageId: string, records: number, images: number}>}
55
+ */
56
+ export async function buildTrainingPackage(projectRoot, manifestId, options = {}) {
57
+ const manifest = await loadTrainingManifest(projectRoot, manifestId);
58
+ const profile = await loadTrainingProfile(projectRoot, manifest.training_profile_id);
59
+ const config = loadProjectConfig(projectRoot);
60
+ const adapterTarget = options.adapterOverride || manifest.adapter_target;
61
+ const adapter = await loadAdapter(adapterTarget);
62
+
63
+ const recordsDir = join(projectRoot, 'records');
64
+ const packageId = generatePackageId();
65
+ const packageDir = join(projectRoot, 'training', 'packages', packageId);
66
+
67
+ // Load split partitions
68
+ const partitions = {};
69
+ let totalRecords = 0;
70
+ let totalImages = 0;
71
+
72
+ for (const partition of ['train', 'val', 'test']) {
73
+ const entries = await loadSplitPartition(projectRoot, manifest.source_split_id, partition);
74
+ const records = [];
75
+
76
+ for (const entry of entries) {
77
+ const record = await loadRecord(recordsDir, entry.record_id);
78
+ if (!record) continue;
79
+
80
+ // Filter by profile's eligible lanes
81
+ const prompt = record.provenance?.prompt || '';
82
+ const lane = detectLane(record.id, prompt, config.lanes);
83
+ if (profile.eligible_lanes.length > 0 && !profile.eligible_lanes.includes(lane)) {
84
+ continue;
85
+ }
86
+
87
+ const group = detectGroup(record.id, prompt, config.terminology);
88
+ records.push({ record, lane, group });
89
+ }
90
+
91
+ partitions[partition] = records;
92
+ totalRecords += records.length;
93
+ }
94
+
95
+ // Let the adapter build the package layout
96
+ const adapterResult = await adapter.buildPackage({
97
+ packageDir,
98
+ partitions,
99
+ profile,
100
+ manifest,
101
+ config,
102
+ projectRoot,
103
+ copy: options.copy || false,
104
+ });
105
+
106
+ totalImages = adapterResult.imageCount || 0;
107
+
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)
120
+ const packageManifest = {
121
+ training_package_id: packageId,
122
+ created_at: new Date().toISOString(),
123
+ created_by: 'sdlab-training-package-v1',
124
+ training_manifest_id: manifestId,
125
+ training_profile_id: manifest.training_profile_id,
126
+ adapter_target: adapterTarget,
127
+ source_export_id: manifest.source_export_id,
128
+ source_snapshot_id: manifest.source_snapshot_id,
129
+ source_split_id: manifest.source_split_id,
130
+ config_fingerprint: manifest.config_fingerprint,
131
+ counts: {
132
+ total_records: totalRecords,
133
+ train: partitions.train.length,
134
+ val: partitions.val.length,
135
+ test: partitions.test.length,
136
+ images: totalImages,
137
+ checksums: checksumEntries.length,
138
+ },
139
+ };
140
+ await writeFile(join(packageDir, 'manifest.json'), JSON.stringify(packageManifest, null, 2) + '\n');
141
+
142
+ // Write README
143
+ const readme = generatePackageReadme(packageManifest, profile, manifest, adapterTarget);
144
+ await writeFile(join(packageDir, 'README.md'), readme);
145
+
146
+ return { packageId, records: totalRecords, images: totalImages };
147
+ }
148
+
149
+ function generatePackageReadme(pkg, profile, manifest, adapter) {
150
+ return `# Training Package: ${pkg.training_package_id}
151
+
152
+ **Profile:** ${profile.label} (${profile.profile_id})
153
+ **Adapter:** ${adapter}
154
+ **Target:** ${profile.target_family} / ${profile.asset_type}
155
+ **Created:** ${pkg.created_at}
156
+
157
+ ## Source chain
158
+
159
+ - Training manifest: \`${manifest.training_manifest_id}\`
160
+ - Export: \`${manifest.source_export_id}\`
161
+ - Snapshot: \`${manifest.source_snapshot_id}\`
162
+ - Split: \`${manifest.source_split_id}\`
163
+ - Config fingerprint: \`${manifest.config_fingerprint.slice(0, 16)}...\`
164
+
165
+ ## Counts
166
+
167
+ | Partition | Records |
168
+ |-----------|---------|
169
+ | Train | ${pkg.counts.train} |
170
+ | Val | ${pkg.counts.val} |
171
+ | Test | ${pkg.counts.test} |
172
+ | **Total** | **${pkg.counts.total_records}** |
173
+
174
+ Images: ${pkg.counts.images}
175
+
176
+ ## Checksums
177
+
178
+ All files in this package are checksummed in \`checksums.txt\` (BSD format).
179
+
180
+ ## Provenance
181
+
182
+ This package was produced by [Style Dataset Lab](https://github.com/mcp-tool-shop-org/style-dataset-lab).
183
+ Every included record has been human-reviewed, canon-bound, and verified against project constitution rules.
184
+ The dataset split ensures no subject family appears in multiple partitions.
185
+ `;
186
+ }
187
+
188
+ async function collectFiles(dir) {
189
+ const entries = await readdir(dir, { withFileTypes: true });
190
+ const files = [];
191
+ for (const entry of entries) {
192
+ const full = join(dir, entry.name);
193
+ if (entry.isDirectory()) {
194
+ files.push(...await collectFiles(full));
195
+ } else {
196
+ files.push(full);
197
+ }
198
+ }
199
+ return files;
200
+ }
201
+
202
+ /**
203
+ * List all training packages.
204
+ */
205
+ export async function listTrainingPackages(projectRoot) {
206
+ const dir = join(projectRoot, 'training', 'packages');
207
+ if (!existsSync(dir)) return [];
208
+
209
+ const entries = await readdir(dir, { withFileTypes: true });
210
+ const packages = [];
211
+
212
+ for (const entry of entries) {
213
+ if (!entry.isDirectory() || !entry.name.startsWith('tp-')) continue;
214
+ const manifestPath = join(dir, entry.name, 'manifest.json');
215
+ if (!existsSync(manifestPath)) continue;
216
+ try {
217
+ const data = JSON.parse(await readFile(manifestPath, 'utf-8'));
218
+ packages.push({
219
+ id: data.training_package_id,
220
+ created_at: data.created_at,
221
+ profile: data.training_profile_id,
222
+ adapter: data.adapter_target,
223
+ records: data.counts.total_records,
224
+ images: data.counts.images,
225
+ });
226
+ } catch { /* skip */ }
227
+ }
228
+
229
+ return packages.sort((a, b) => a.created_at.localeCompare(b.created_at));
230
+ }
231
+
232
+ /**
233
+ * Load a training package manifest.
234
+ */
235
+ export async function loadTrainingPackage(projectRoot, packageId) {
236
+ const path = join(projectRoot, 'training', 'packages', packageId, 'manifest.json');
237
+ if (!existsSync(path)) {
238
+ throw new Error(`Training package "${packageId}" not found at ${path}`);
239
+ }
240
+ return JSON.parse(await readFile(path, 'utf-8'));
241
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Training profile management.
3
+ *
4
+ * A training profile defines how a project wants to turn an export package
5
+ * into a trainable model asset. Profiles never select data directly — they
6
+ * define eligibility rules and packaging expectations.
7
+ */
8
+
9
+ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+
13
+ const REQUIRED_FIELDS = ['profile_id', 'label', 'target_family', 'asset_type', 'eligible_lanes', 'adapter_targets'];
14
+
15
+ /**
16
+ * Validate a training profile object.
17
+ * @returns {{ valid: boolean, errors: string[] }}
18
+ */
19
+ export function validateProfile(profile) {
20
+ const errors = [];
21
+ for (const field of REQUIRED_FIELDS) {
22
+ if (profile[field] === undefined || profile[field] === null) {
23
+ errors.push(`missing required field: ${field}`);
24
+ }
25
+ }
26
+ if (profile.eligible_lanes && !Array.isArray(profile.eligible_lanes)) {
27
+ errors.push('eligible_lanes must be an array');
28
+ }
29
+ if (profile.adapter_targets && !Array.isArray(profile.adapter_targets)) {
30
+ errors.push('adapter_targets must be an array');
31
+ }
32
+ if (profile.adapter_targets?.length === 0) {
33
+ errors.push('adapter_targets must have at least one target');
34
+ }
35
+ return { valid: errors.length === 0, errors };
36
+ }
37
+
38
+ /**
39
+ * Load a training profile from a project.
40
+ */
41
+ export async function loadTrainingProfile(projectRoot, profileId) {
42
+ const path = join(projectRoot, 'training', 'profiles', `${profileId}.json`);
43
+ if (!existsSync(path)) {
44
+ throw new Error(`Training profile "${profileId}" not found at ${path}`);
45
+ }
46
+ const data = JSON.parse(await readFile(path, 'utf-8'));
47
+ const { valid, errors } = validateProfile(data);
48
+ if (!valid) {
49
+ throw new Error(`Training profile "${profileId}" is invalid:\n ${errors.join('\n ')}`);
50
+ }
51
+ return data;
52
+ }
53
+
54
+ /**
55
+ * List all training profiles in a project.
56
+ */
57
+ export async function listTrainingProfiles(projectRoot) {
58
+ const dir = join(projectRoot, 'training', 'profiles');
59
+ if (!existsSync(dir)) return [];
60
+
61
+ const files = (await readdir(dir)).filter(f => f.endsWith('.json')).sort();
62
+ const profiles = [];
63
+
64
+ for (const file of files) {
65
+ try {
66
+ const data = JSON.parse(await readFile(join(dir, file), 'utf-8'));
67
+ profiles.push({
68
+ profile_id: data.profile_id,
69
+ label: data.label,
70
+ target_family: data.target_family,
71
+ asset_type: data.asset_type,
72
+ lanes: data.eligible_lanes?.length || 0,
73
+ adapters: data.adapter_targets || [],
74
+ });
75
+ } catch {
76
+ // Skip malformed
77
+ }
78
+ }
79
+
80
+ return profiles;
81
+ }
82
+
83
+ /**
84
+ * Save a training profile to a project.
85
+ */
86
+ export async function saveTrainingProfile(projectRoot, profile) {
87
+ const { valid, errors } = validateProfile(profile);
88
+ if (!valid) {
89
+ throw new Error(`Invalid training profile:\n ${errors.join('\n ')}`);
90
+ }
91
+ const dir = join(projectRoot, 'training', 'profiles');
92
+ await mkdir(dir, { recursive: true });
93
+ await writeFile(
94
+ join(dir, `${profile.profile_id}.json`),
95
+ JSON.stringify(profile, null, 2) + '\n'
96
+ );
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/style-dataset-lab",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Visual canon and dataset production pipeline — snapshots, splits, versioned exports, eval packs, and canon-bound curation for any image production domain",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -36,6 +36,11 @@ const REQUIRED_DIRS = [
36
36
  'snapshots',
37
37
  'splits',
38
38
  'eval-packs',
39
+ 'training/profiles',
40
+ 'training/manifests',
41
+ 'training/packages',
42
+ 'training/eval-runs',
43
+ 'training/implementations',
39
44
  ];
40
45
 
41
46
  function pass(msg) { console.log(` \x1b[32m✓\x1b[0m ${msg}`); }