@mcptoolshop/style-dataset-lab 1.2.1 → 2.1.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 (62) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +138 -113
  3. package/bin/sdlab.js +126 -0
  4. package/lib/args.js +106 -0
  5. package/lib/card.js +145 -0
  6. package/lib/comfyui.js +109 -0
  7. package/lib/config.js +248 -0
  8. package/lib/deprecation.js +14 -0
  9. package/lib/eligibility.js +110 -0
  10. package/lib/eval-pack.js +283 -0
  11. package/lib/export.js +259 -0
  12. package/lib/paths.js +38 -0
  13. package/lib/records.js +69 -0
  14. package/lib/snapshot.js +211 -0
  15. package/lib/split.js +409 -0
  16. package/package.json +20 -9
  17. package/scripts/bulk-curate-wave2-5.js +1 -1
  18. package/scripts/bulk-curate-waves11-18.js +1 -1
  19. package/scripts/canon-bind.js +188 -338
  20. package/scripts/card.js +73 -0
  21. package/scripts/compare.js +40 -30
  22. package/scripts/curate-wave25.js +1 -1
  23. package/scripts/curate.js +34 -28
  24. package/scripts/doctor.js +324 -0
  25. package/scripts/eligibility.js +91 -0
  26. package/scripts/eval-pack.js +92 -0
  27. package/scripts/export.js +85 -0
  28. package/scripts/generate-controlnet.js +36 -121
  29. package/scripts/generate-identity.js +38 -106
  30. package/scripts/generate-ipadapter.js +42 -119
  31. package/scripts/generate.js +22 -81
  32. package/scripts/init.js +229 -0
  33. package/scripts/migrate-records.js +15 -21
  34. package/scripts/painterly-test.js +20 -47
  35. package/scripts/painterly.js +27 -106
  36. package/scripts/snapshot.js +132 -0
  37. package/scripts/split.js +154 -0
  38. package/templates/domains/architecture/constitution.json +97 -0
  39. package/templates/domains/architecture/lanes.json +41 -0
  40. package/templates/domains/architecture/project.json +20 -0
  41. package/templates/domains/architecture/rubric.json +40 -0
  42. package/templates/domains/architecture/terminology.json +19 -0
  43. package/templates/domains/character-design/constitution.json +88 -0
  44. package/templates/domains/character-design/lanes.json +35 -0
  45. package/templates/domains/character-design/project.json +17 -0
  46. package/templates/domains/character-design/rubric.json +38 -0
  47. package/templates/domains/character-design/terminology.json +21 -0
  48. package/templates/domains/creature-design/constitution.json +87 -0
  49. package/templates/domains/creature-design/lanes.json +41 -0
  50. package/templates/domains/creature-design/project.json +18 -0
  51. package/templates/domains/creature-design/rubric.json +39 -0
  52. package/templates/domains/creature-design/terminology.json +20 -0
  53. package/templates/domains/game-art/constitution.json +113 -0
  54. package/templates/domains/game-art/lanes.json +47 -0
  55. package/templates/domains/game-art/project.json +17 -0
  56. package/templates/domains/game-art/rubric.json +36 -0
  57. package/templates/domains/game-art/terminology.json +30 -0
  58. package/templates/domains/vehicle-mech/constitution.json +87 -0
  59. package/templates/domains/vehicle-mech/lanes.json +41 -0
  60. package/templates/domains/vehicle-mech/project.json +17 -0
  61. package/templates/domains/vehicle-mech/rubric.json +40 -0
  62. package/templates/domains/vehicle-mech/terminology.json +21 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Canon-aware eval pack builder.
3
+ *
4
+ * Eval packs are curated test instruments that verify a model's
5
+ * relationship with project canon. Four task types:
6
+ *
7
+ * 1. Lane coverage — representative approved records per lane
8
+ * 2. Forbidden drift — rejected/borderline records paired with violated rules
9
+ * 3. Anchor/gold — highest pass-ratio records per faction
10
+ * 4. Subject continuity — same-subject records for identity testing
11
+ */
12
+
13
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { existsSync } from 'node:fs';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
+ import { loadRecord } from './records.js';
19
+
20
+ /**
21
+ * Generate an eval-pack ID: eval-YYYYMMDD-HHMMSS-XXXX
22
+ */
23
+ function generateEvalId() {
24
+ const now = new Date();
25
+ const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
26
+ const rand = randomBytes(2).toString('hex');
27
+ return `eval-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
28
+ }
29
+
30
+ /**
31
+ * Build an eval pack from project records.
32
+ *
33
+ * @param {string} projectRoot
34
+ * @param {Object} options — { maxPerLane?, maxPerFaction?, maxDrift?, maxContinuity? }
35
+ * @returns {Promise<{evalId: string, tasks: Object}>}
36
+ */
37
+ export async function buildEvalPack(projectRoot, options = {}) {
38
+ const config = loadProjectConfig(projectRoot);
39
+ const recordsDir = join(projectRoot, 'records');
40
+ const files = (await readdir(recordsDir)).filter(f => f.endsWith('.json')).sort();
41
+
42
+ const maxPerLane = options.maxPerLane || 5;
43
+ const maxPerFaction = options.maxPerFaction || 3;
44
+ const maxDrift = options.maxDrift || 20;
45
+ const maxContinuity = options.maxContinuity || 10;
46
+
47
+ // Load all records
48
+ const records = [];
49
+ for (const file of files) {
50
+ const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
51
+ records.push(record);
52
+ }
53
+
54
+ // Task 1: Lane coverage
55
+ const laneCoverage = buildLaneCoverage(records, config, maxPerLane);
56
+
57
+ // Task 2: Forbidden drift
58
+ const forbiddenDrift = buildForbiddenDrift(records, config, maxDrift);
59
+
60
+ // Task 3: Anchor/gold
61
+ const anchorGold = buildAnchorGold(records, config, maxPerFaction);
62
+
63
+ // Task 4: Subject continuity
64
+ const subjectContinuity = buildSubjectContinuity(records, maxContinuity);
65
+
66
+ // Write eval pack
67
+ const evalId = generateEvalId();
68
+ const evalDir = join(projectRoot, 'eval-packs', evalId);
69
+ await mkdir(evalDir, { recursive: true });
70
+
71
+ const manifest = {
72
+ eval_id: evalId,
73
+ created_at: new Date().toISOString(),
74
+ created_by: 'sdlab-eval-v1',
75
+ project: config.meta.name,
76
+ task_counts: {
77
+ lane_coverage: laneCoverage.length,
78
+ forbidden_drift: forbiddenDrift.length,
79
+ anchor_gold: anchorGold.length,
80
+ subject_continuity: subjectContinuity.length,
81
+ },
82
+ total_records: laneCoverage.length + forbiddenDrift.length +
83
+ anchorGold.length + subjectContinuity.length,
84
+ };
85
+
86
+ await writeFile(join(evalDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
87
+ await writeFile(join(evalDir, 'lane-coverage.jsonl'), laneCoverage.map(r => JSON.stringify(r)).join('\n') + '\n');
88
+ await writeFile(join(evalDir, 'forbidden-drift.jsonl'), forbiddenDrift.map(r => JSON.stringify(r)).join('\n') + '\n');
89
+ await writeFile(join(evalDir, 'anchor-gold.jsonl'), anchorGold.map(r => JSON.stringify(r)).join('\n') + '\n');
90
+ await writeFile(join(evalDir, 'subject-continuity.jsonl'), subjectContinuity.map(r => JSON.stringify(r)).join('\n') + '\n');
91
+
92
+ return {
93
+ evalId,
94
+ tasks: manifest.task_counts,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Task 1: Lane coverage — best approved record per lane.
100
+ */
101
+ function buildLaneCoverage(records, config, maxPerLane) {
102
+ const byLane = new Map();
103
+
104
+ for (const record of records) {
105
+ if (record.judgment?.status !== 'approved') continue;
106
+ if (!record.canon?.assertions?.length) continue;
107
+
108
+ const prompt = record.provenance?.prompt || '';
109
+ const lane = detectLane(record.id, prompt, config.lanes);
110
+ if (!byLane.has(lane)) byLane.set(lane, []);
111
+ byLane.get(lane).push(record);
112
+ }
113
+
114
+ const result = [];
115
+ for (const [lane, recs] of [...byLane].sort((a, b) => a[0].localeCompare(b[0]))) {
116
+ // Sort by pass ratio descending
117
+ const sorted = recs.sort((a, b) => {
118
+ const ratioA = a.canon.assertion_count > 0 ? a.canon.pass_count / a.canon.assertion_count : 0;
119
+ const ratioB = b.canon.assertion_count > 0 ? b.canon.pass_count / b.canon.assertion_count : 0;
120
+ return ratioB - ratioA;
121
+ });
122
+
123
+ for (const record of sorted.slice(0, maxPerLane)) {
124
+ result.push({
125
+ task: 'lane_coverage',
126
+ record_id: record.id,
127
+ lane,
128
+ pass_ratio: record.canon.assertion_count > 0
129
+ ? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3) : 0,
130
+ asset_path: record.asset_path,
131
+ });
132
+ }
133
+ }
134
+
135
+ return result;
136
+ }
137
+
138
+ /**
139
+ * Task 2: Forbidden drift — rejected/borderline records with violated rules.
140
+ */
141
+ function buildForbiddenDrift(records, config, maxDrift) {
142
+ const candidates = [];
143
+
144
+ for (const record of records) {
145
+ const status = record.judgment?.status;
146
+ if (status !== 'rejected' && status !== 'borderline') continue;
147
+
148
+ // Collect failure modes from judgment
149
+ const failureModes = record.judgment?.failure_modes || [];
150
+ // Collect failed canon assertions
151
+ const failedAssertions = (record.canon?.assertions || [])
152
+ .filter(a => a.verdict === 'fail')
153
+ .map(a => a.rule_id || a.rule);
154
+
155
+ const violations = [...new Set([...failureModes, ...failedAssertions])];
156
+ if (violations.length === 0 && !record.judgment?.explanation) continue;
157
+
158
+ candidates.push({
159
+ task: 'forbidden_drift',
160
+ record_id: record.id,
161
+ status,
162
+ violations,
163
+ explanation: record.judgment?.explanation || null,
164
+ asset_path: record.asset_path,
165
+ });
166
+ }
167
+
168
+ // Sort by number of violations descending (most instructive first)
169
+ candidates.sort((a, b) => b.violations.length - a.violations.length);
170
+ return candidates.slice(0, maxDrift);
171
+ }
172
+
173
+ /**
174
+ * Task 3: Anchor/gold — highest pass-ratio records per faction.
175
+ */
176
+ function buildAnchorGold(records, config, maxPerFaction) {
177
+ const byFaction = new Map();
178
+
179
+ for (const record of records) {
180
+ if (record.judgment?.status !== 'approved') continue;
181
+ if (!record.canon?.assertions?.length) continue;
182
+
183
+ const prompt = record.provenance?.prompt || '';
184
+ const faction = detectGroup(record.id, prompt, config.terminology) || 'unknown';
185
+ if (!byFaction.has(faction)) byFaction.set(faction, []);
186
+ byFaction.get(faction).push(record);
187
+ }
188
+
189
+ const result = [];
190
+ for (const [faction, recs] of [...byFaction].sort((a, b) => a[0].localeCompare(b[0]))) {
191
+ const sorted = recs.sort((a, b) => {
192
+ const ratioA = a.canon.assertion_count > 0 ? a.canon.pass_count / a.canon.assertion_count : 0;
193
+ const ratioB = b.canon.assertion_count > 0 ? b.canon.pass_count / b.canon.assertion_count : 0;
194
+ return ratioB - ratioA;
195
+ });
196
+
197
+ for (const record of sorted.slice(0, maxPerFaction)) {
198
+ result.push({
199
+ task: 'anchor_gold',
200
+ record_id: record.id,
201
+ faction,
202
+ pass_ratio: record.canon.assertion_count > 0
203
+ ? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3) : 0,
204
+ assertion_count: record.canon.assertion_count,
205
+ asset_path: record.asset_path,
206
+ });
207
+ }
208
+ }
209
+
210
+ return result;
211
+ }
212
+
213
+ /**
214
+ * Task 4: Subject continuity — groups of same-subject records for identity testing.
215
+ */
216
+ function buildSubjectContinuity(records, maxGroups) {
217
+ const bySubject = new Map();
218
+
219
+ for (const record of records) {
220
+ const subject = record.identity?.subject_name;
221
+ if (!subject) continue;
222
+ if (!bySubject.has(subject)) bySubject.set(subject, []);
223
+ bySubject.get(subject).push(record);
224
+ }
225
+
226
+ const result = [];
227
+ // Sort subjects by record count descending (richest clusters first)
228
+ const sorted = [...bySubject].sort((a, b) => b[1].length - a[1].length);
229
+
230
+ for (const [subject, recs] of sorted.slice(0, maxGroups)) {
231
+ result.push({
232
+ task: 'subject_continuity',
233
+ subject_name: subject,
234
+ record_count: recs.length,
235
+ record_ids: recs.map(r => r.id).sort(),
236
+ subject_type: recs[0].identity?.subject_type || 'unknown',
237
+ faction: recs[0].identity?.faction || null,
238
+ });
239
+ }
240
+
241
+ return result;
242
+ }
243
+
244
+ /**
245
+ * List all eval packs in a project.
246
+ */
247
+ export async function listEvalPacks(projectRoot) {
248
+ const evalDir = join(projectRoot, 'eval-packs');
249
+ if (!existsSync(evalDir)) return [];
250
+
251
+ const entries = await readdir(evalDir, { withFileTypes: true });
252
+ const packs = [];
253
+
254
+ for (const entry of entries) {
255
+ if (!entry.isDirectory() || !entry.name.startsWith('eval-')) continue;
256
+ const manifestPath = join(evalDir, entry.name, 'manifest.json');
257
+ if (!existsSync(manifestPath)) continue;
258
+ try {
259
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
260
+ packs.push({
261
+ id: manifest.eval_id,
262
+ created_at: manifest.created_at,
263
+ total: manifest.total_records,
264
+ tasks: manifest.task_counts,
265
+ });
266
+ } catch {
267
+ // Skip malformed
268
+ }
269
+ }
270
+
271
+ return packs.sort((a, b) => a.created_at.localeCompare(b.created_at));
272
+ }
273
+
274
+ /**
275
+ * Load an eval pack manifest.
276
+ */
277
+ export async function loadEvalPack(projectRoot, evalId) {
278
+ const path = join(projectRoot, 'eval-packs', evalId, 'manifest.json');
279
+ if (!existsSync(path)) {
280
+ throw new Error(`Eval pack "${evalId}" not found at ${path}`);
281
+ }
282
+ return JSON.parse(await readFile(path, 'utf-8'));
283
+ }
package/lib/export.js ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Export package builder — self-contained, reproducible dataset exports.
3
+ *
4
+ * An export package contains everything needed to use or rebuild the dataset:
5
+ * manifest, metadata, images (symlinked or copied), splits, card, and checksums.
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir, readdir, symlink, copyFile, stat } from 'node:fs/promises';
9
+ import { join, relative, resolve } from 'node:path';
10
+ import { existsSync } from 'node:fs';
11
+ import { createHash, randomBytes } from 'node:crypto';
12
+ import { loadProjectConfig, loadExportProfile, detectLane, detectGroup } from './config.js';
13
+ import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint } from './snapshot.js';
14
+ import { loadSplit, loadSplitPartition } from './split.js';
15
+ import { loadRecord } from './records.js';
16
+ import { generateCard } from './card.js';
17
+
18
+ /**
19
+ * Generate an export ID: export-YYYYMMDD-HHMMSS-XXXX
20
+ */
21
+ function generateExportId() {
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 `export-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
26
+ }
27
+
28
+ /**
29
+ * Compute SHA-256 of a file.
30
+ */
31
+ async function sha256File(filePath) {
32
+ const data = await readFile(filePath);
33
+ return createHash('sha256').update(data).digest('hex');
34
+ }
35
+
36
+ /**
37
+ * Build an export package.
38
+ *
39
+ * @param {string} projectRoot
40
+ * @param {string} snapshotId
41
+ * @param {string} splitId
42
+ * @param {Object} options — { profileId?, copy? }
43
+ * @returns {Promise<{exportId: string, recordCount: number, imageCount: number}>}
44
+ */
45
+ export async function buildExport(projectRoot, snapshotId, splitId, options = {}) {
46
+ const config = loadProjectConfig(projectRoot);
47
+ const profile = loadExportProfile(projectRoot, options.profileId || null);
48
+ const snapshot = await loadSnapshot(projectRoot, snapshotId);
49
+ const split = await loadSplit(projectRoot, splitId);
50
+
51
+ // Load all included records
52
+ const included = await loadSnapshotIncluded(projectRoot, snapshotId);
53
+ const recordsDir = join(projectRoot, 'records');
54
+
55
+ const exportId = generateExportId();
56
+ const exportDir = join(projectRoot, 'exports', exportId);
57
+ await mkdir(exportDir, { recursive: true });
58
+
59
+ // Build metadata + collect image paths
60
+ const metadata = [];
61
+ const imagePaths = [];
62
+ const checksumEntries = [];
63
+
64
+ for (const entry of included) {
65
+ const record = await loadRecord(recordsDir, entry.record_id);
66
+ if (!record) continue;
67
+
68
+ const prompt = record.provenance?.prompt || '';
69
+ const lane = detectLane(record.id, prompt, config.lanes);
70
+ const group = detectGroup(record.id, prompt, config.terminology);
71
+
72
+ // Build metadata row with requested fields
73
+ const row = {};
74
+ for (const field of profile.metadata_fields) {
75
+ if (field === 'id') row.id = record.id;
76
+ else if (field === 'asset_path') row.asset_path = record.asset_path;
77
+ else if (field === 'provenance') row.provenance = record.provenance;
78
+ else if (field === 'judgment') row.judgment = record.judgment;
79
+ else if (field === 'canon') row.canon = record.canon;
80
+ else if (field === 'identity') row.identity = record.identity || null;
81
+ else if (field === 'lineage') row.lineage = record.lineage || null;
82
+ else if (record[field] !== undefined) row[field] = record[field];
83
+ }
84
+ row.detected_lane = lane;
85
+ row.detected_group = group;
86
+ metadata.push(row);
87
+
88
+ // Track image for linking
89
+ if (profile.include_images && record.asset_path) {
90
+ imagePaths.push({
91
+ record_id: record.id,
92
+ source: join(projectRoot, record.asset_path),
93
+ filename: record.asset_path.split('/').pop(),
94
+ });
95
+ }
96
+ }
97
+
98
+ // Write metadata.jsonl
99
+ const metadataPath = join(exportDir, 'metadata.jsonl');
100
+ await writeFile(metadataPath, metadata.map(r => JSON.stringify(r)).join('\n') + '\n');
101
+
102
+ // Images directory
103
+ if (profile.include_images && imagePaths.length > 0) {
104
+ const imagesDir = join(exportDir, 'images');
105
+ await mkdir(imagesDir, { recursive: true });
106
+
107
+ let linked = 0;
108
+ for (const img of imagePaths) {
109
+ const dest = join(imagesDir, img.filename);
110
+ if (existsSync(img.source)) {
111
+ try {
112
+ if (options.copy) {
113
+ await copyFile(img.source, dest);
114
+ } else {
115
+ const relPath = relative(imagesDir, img.source);
116
+ await symlink(relPath, dest);
117
+ }
118
+ linked++;
119
+ } catch {
120
+ // Skip individual failures (e.g. duplicate filenames)
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // Splits
127
+ if (profile.include_splits) {
128
+ const splitsDir = join(exportDir, 'splits');
129
+ await mkdir(splitsDir, { recursive: true });
130
+
131
+ for (const partition of ['train', 'val', 'test']) {
132
+ const entries = await loadSplitPartition(projectRoot, splitId, partition);
133
+ await writeFile(
134
+ join(splitsDir, `${partition}.jsonl`),
135
+ entries.map(e => JSON.stringify(e)).join('\n') + '\n'
136
+ );
137
+ }
138
+ }
139
+
140
+ // Dataset card
141
+ if (profile.include_card) {
142
+ const { markdown, json } = await generateCard(projectRoot, snapshotId, splitId);
143
+ await writeFile(join(exportDir, 'dataset-card.md'), markdown);
144
+ await writeFile(join(exportDir, 'dataset-card.json'), JSON.stringify(json, null, 2) + '\n');
145
+ }
146
+
147
+ // Checksums
148
+ if (profile.include_checksums) {
149
+ // Checksum all files in the export dir (except checksums.txt itself)
150
+ const allFiles = await collectFiles(exportDir);
151
+ for (const filePath of allFiles.sort()) {
152
+ const relPath = relative(exportDir, filePath);
153
+ if (relPath === 'checksums.txt') continue;
154
+ const hash = await sha256File(filePath);
155
+ checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
156
+ }
157
+ await writeFile(join(exportDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
158
+ }
159
+
160
+ // Manifest (written last — references everything)
161
+ const manifest = {
162
+ export_id: exportId,
163
+ created_at: new Date().toISOString(),
164
+ created_by: 'sdlab-export-v1',
165
+ project: config.meta.name,
166
+ snapshot_id: snapshotId,
167
+ split_id: splitId,
168
+ config_fingerprint: computeConfigFingerprint(projectRoot),
169
+ profile,
170
+ counts: {
171
+ records: metadata.length,
172
+ images: imagePaths.filter(i => existsSync(i.source)).length,
173
+ checksums: checksumEntries.length,
174
+ },
175
+ };
176
+ await writeFile(join(exportDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
177
+
178
+ // Summary
179
+ const summary = {
180
+ export_id: exportId,
181
+ snapshot_id: snapshotId,
182
+ split_id: splitId,
183
+ record_count: metadata.length,
184
+ image_count: manifest.counts.images,
185
+ partitions: {
186
+ train: split.counts.train,
187
+ val: split.counts.val,
188
+ test: split.counts.test,
189
+ },
190
+ config_fingerprint: manifest.config_fingerprint,
191
+ };
192
+ await writeFile(join(exportDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n');
193
+
194
+ return {
195
+ exportId,
196
+ recordCount: metadata.length,
197
+ imageCount: manifest.counts.images,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Recursively collect all files in a directory.
203
+ */
204
+ async function collectFiles(dir, base = dir) {
205
+ const entries = await readdir(dir, { withFileTypes: true });
206
+ const files = [];
207
+ for (const entry of entries) {
208
+ const full = join(dir, entry.name);
209
+ if (entry.isDirectory()) {
210
+ files.push(...await collectFiles(full, base));
211
+ } else {
212
+ files.push(full);
213
+ }
214
+ }
215
+ return files;
216
+ }
217
+
218
+ /**
219
+ * List all exports in a project.
220
+ */
221
+ export async function listExports(projectRoot) {
222
+ const exportsDir = join(projectRoot, 'exports');
223
+ if (!existsSync(exportsDir)) return [];
224
+
225
+ const entries = await readdir(exportsDir, { withFileTypes: true });
226
+ const exports = [];
227
+
228
+ for (const entry of entries) {
229
+ if (!entry.isDirectory() || !entry.name.startsWith('export-')) continue;
230
+ const manifestPath = join(exportsDir, entry.name, 'manifest.json');
231
+ if (!existsSync(manifestPath)) continue;
232
+ try {
233
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
234
+ exports.push({
235
+ id: manifest.export_id,
236
+ created_at: manifest.created_at,
237
+ snapshot_id: manifest.snapshot_id,
238
+ split_id: manifest.split_id,
239
+ records: manifest.counts.records,
240
+ images: manifest.counts.images,
241
+ });
242
+ } catch {
243
+ // Skip malformed
244
+ }
245
+ }
246
+
247
+ return exports.sort((a, b) => a.created_at.localeCompare(b.created_at));
248
+ }
249
+
250
+ /**
251
+ * Load an export manifest.
252
+ */
253
+ export async function loadExport(projectRoot, exportId) {
254
+ const path = join(projectRoot, 'exports', exportId, 'manifest.json');
255
+ if (!existsSync(path)) {
256
+ throw new Error(`Export "${exportId}" not found at ${path}`);
257
+ }
258
+ return JSON.parse(await readFile(path, 'utf-8'));
259
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Path resolution — single source of truth for repo root and project paths.
3
+ */
4
+
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, join } from 'node:path';
7
+ import { existsSync } from 'node:fs';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+
12
+ /** Absolute path to the repository root. */
13
+ export const REPO_ROOT = join(__dirname, '..');
14
+
15
+ /**
16
+ * Resolve the root directory for a named project.
17
+ * @param {string} name — project name (e.g. 'star-freight')
18
+ * @returns {string} absolute path to projects/<name>/
19
+ */
20
+ export function getProjectRoot(name) {
21
+ const root = join(REPO_ROOT, 'projects', name);
22
+ if (!existsSync(root)) {
23
+ throw new Error(
24
+ `Project "${name}" not found at ${root}.\n` +
25
+ `Run: sdlab init ${name} --domain <domain>`
26
+ );
27
+ }
28
+ return root;
29
+ }
30
+
31
+ /**
32
+ * Join segments onto a project root.
33
+ * @param {string} name — project name
34
+ * @param {...string} segments — path segments
35
+ */
36
+ export function resolveProjectPath(name, ...segments) {
37
+ return join(getProjectRoot(name), ...segments);
38
+ }
package/lib/records.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Record builder and I/O utilities.
3
+ *
4
+ * Standardizes the JSON record format used across all scripts.
5
+ */
6
+
7
+ import { readFile, writeFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+
10
+ /**
11
+ * Build a base provenance record (generated by txt2img scripts).
12
+ */
13
+ export function buildBaseRecord(id, assetPath, imageData, provenance) {
14
+ return {
15
+ id,
16
+ schema_version: '2.0.0',
17
+ created_at: new Date().toISOString(),
18
+ asset_path: assetPath,
19
+ image: {
20
+ format: 'png',
21
+ width: imageData.width || 1024,
22
+ height: imageData.height || 1024,
23
+ bytes: imageData.bytes || 0,
24
+ },
25
+ provenance,
26
+ judgment: null,
27
+ canon: null,
28
+ iteration: null,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Build an identity record (extends base with identity + lineage blocks).
34
+ */
35
+ export function buildIdentityRecord(baseRecord, identity, lineage) {
36
+ return {
37
+ ...baseRecord,
38
+ identity,
39
+ lineage,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Load a record by asset ID from a records directory.
45
+ * @param {string} recordsDir — absolute path to records/
46
+ * @param {string} assetId — record filename (without .json)
47
+ * @returns {Promise<Object|null>}
48
+ */
49
+ export async function loadRecord(recordsDir, assetId) {
50
+ const path = join(recordsDir, `${assetId}.json`);
51
+ try {
52
+ const raw = await readFile(path, 'utf-8');
53
+ return JSON.parse(raw);
54
+ } catch (err) {
55
+ if (err.code === 'ENOENT') return null;
56
+ throw new Error(`Failed to load record ${assetId}: ${err.message}`);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Save a record to disk.
62
+ * @param {string} recordsDir — absolute path to records/
63
+ * @param {string} assetId — record filename (without .json)
64
+ * @param {Object} record — the record object
65
+ */
66
+ export async function saveRecord(recordsDir, assetId, record) {
67
+ const path = join(recordsDir, `${assetId}.json`);
68
+ await writeFile(path, JSON.stringify(record, null, 2) + '\n');
69
+ }