@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
@@ -0,0 +1,375 @@
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
+ // ─── Filename validation ─────────────────────────────────────────
18
+
19
+ const SAFE_FILENAME_RE = /^[A-Za-z0-9._-]+\.(png|webp|jpg|jpeg)$/i;
20
+
21
+ function assertSafeFilename(filename, context) {
22
+ if (typeof filename !== 'string' || !SAFE_FILENAME_RE.test(filename)) {
23
+ throw inputError(
24
+ 'UNSAFE_FILENAME',
25
+ `Invalid filename "${filename}" in ${context}`,
26
+ `Filenames must match letters/digits/dot/underscore/hyphen and end in .png/.webp/.jpg/.jpeg`,
27
+ );
28
+ }
29
+ }
30
+
31
+ // ─── Selection ID generation ─────────────────────────────────────
32
+
33
+ export function getSelectionsDir(projectRoot) {
34
+ return join(projectRoot, 'selections');
35
+ }
36
+
37
+ // PB-008: tighten regex so partial matches (e.g. 'selection_<date>_001_wip')
38
+ // don't inflate the sequence. We require exactly 3 digits.
39
+ const SELECTION_SEQ_RE = /^(\d{3})$/;
40
+
41
+ export function generateSelectionId(selectionsDir) {
42
+ const today = new Date().toISOString().slice(0, 10);
43
+ const prefix = `selection_${today}_`;
44
+
45
+ let maxSeq = 0;
46
+ if (existsSync(selectionsDir)) {
47
+ const existing = readdirSync(selectionsDir, { withFileTypes: true })
48
+ .filter(e => e.isDirectory() && e.name.startsWith(prefix));
49
+ for (const e of existing) {
50
+ const seqStr = e.name.slice(prefix.length);
51
+ const match = SELECTION_SEQ_RE.exec(seqStr);
52
+ if (!match) continue;
53
+ const seq = parseInt(match[1], 10);
54
+ if (seq > maxSeq) maxSeq = seq;
55
+ }
56
+ }
57
+
58
+ return `${prefix}${String(maxSeq + 1).padStart(3, '0')}`;
59
+ }
60
+
61
+ /**
62
+ * PB-008: atomically claim the next selection ID by mkdir retry-on-EEXIST.
63
+ * Races safely against a concurrent sdlab process — each one will land on
64
+ * a unique sequence. Returns { selectionId, selectionDir }.
65
+ *
66
+ * Callers that already create the directory themselves can keep using
67
+ * generateSelectionId; the atomic path is opt-in.
68
+ */
69
+ export async function claimSelectionId(selectionsDir) {
70
+ if (!existsSync(selectionsDir)) {
71
+ mkdirSync(selectionsDir, { recursive: true });
72
+ }
73
+ for (let attempt = 0; attempt < 25; attempt++) {
74
+ const candidate = generateSelectionId(selectionsDir);
75
+ const dir = join(selectionsDir, candidate);
76
+ try {
77
+ mkdirSync(dir); // throws EEXIST if a concurrent process already claimed it
78
+ return { selectionId: candidate, selectionDir: dir };
79
+ } catch (err) {
80
+ if (err.code === 'EEXIST') continue;
81
+ throw err;
82
+ }
83
+ }
84
+ throw inputError(
85
+ 'SELECTION_ID_CLAIM_FAILED',
86
+ 'Could not claim a unique selection ID after 25 attempts',
87
+ 'Another sdlab process may be writing selections in a tight loop. Try again.'
88
+ );
89
+ }
90
+
91
+ // ─── Run selection ───────────────────────────────────────────────
92
+
93
+ /**
94
+ * Create a selection from a single run.
95
+ *
96
+ * @param {Object} opts
97
+ * @param {string} opts.projectRoot
98
+ * @param {string} opts.projectId
99
+ * @param {string} opts.runId
100
+ * @param {string[]} opts.approvedFiles — filenames to approve (e.g. ['001.png', '003.png'])
101
+ * @param {string} [opts.reason]
102
+ * @param {string[]} [opts.tags]
103
+ * @returns {Promise<Object>} selection manifest
104
+ */
105
+ export async function createSelectionFromRun({
106
+ projectRoot, projectId, runId, approvedFiles, reason, tags,
107
+ }) {
108
+ const runsDir = getRunsDir(projectRoot);
109
+ const runDir = join(runsDir, runId);
110
+ const manifestPath = join(runDir, 'manifest.json');
111
+
112
+ if (!existsSync(manifestPath)) {
113
+ throw inputError('RUN_NOT_FOUND', `Run "${runId}" not found`, 'Check the run ID.');
114
+ }
115
+
116
+ const runManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
117
+ const outputsDir = join(runDir, 'outputs');
118
+
119
+ // Validate all approved files exist
120
+ for (const file of approvedFiles) {
121
+ assertSafeFilename(file, `approvedFiles for run ${runId}`);
122
+ if (!existsSync(join(outputsDir, file))) {
123
+ throw inputError(
124
+ 'OUTPUT_NOT_FOUND',
125
+ `Output "${file}" not found in ${runId}/outputs/`,
126
+ `Available: ${readdirSync(outputsDir).filter(f => f.endsWith('.png')).join(', ') || '(none)'}`,
127
+ );
128
+ }
129
+ }
130
+
131
+ // Create selection directory
132
+ const selectionsDir = getSelectionsDir(projectRoot);
133
+ const selectionId = generateSelectionId(selectionsDir);
134
+ const selectionDir = join(selectionsDir, selectionId);
135
+ await mkdir(join(selectionDir, 'chosen'), { recursive: true });
136
+
137
+ // Copy chosen files
138
+ const items = [];
139
+ for (let i = 0; i < approvedFiles.length; i++) {
140
+ const file = approvedFiles[i];
141
+ const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
142
+ copyFileSync(join(outputsDir, file), join(selectionDir, 'chosen', destName));
143
+
144
+ // Find seed from run manifest
145
+ const outputEntry = runManifest.outputs?.find(o => o.filename === file);
146
+ items.push({
147
+ slot_or_output: file,
148
+ filename: destName,
149
+ seed: outputEntry?.seed,
150
+ reason: reason || 'selected',
151
+ tags: tags || [],
152
+ });
153
+ }
154
+
155
+ // Build selection manifest
156
+ const manifest = {
157
+ selection_id: selectionId,
158
+ project_id: projectId,
159
+ source_type: 'run',
160
+ source_id: runId,
161
+ workflow_id: runManifest.workflow_template_id,
162
+ created_at: new Date().toISOString(),
163
+ items,
164
+ reingest_ready: true,
165
+ };
166
+
167
+ // Attach subject from brief if available
168
+ const briefPath = join(runDir, 'brief.json');
169
+ if (existsSync(briefPath)) {
170
+ try {
171
+ const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
172
+ if (brief.subject_id) manifest.subject_id = brief.subject_id;
173
+ } catch { /* skip */ }
174
+ }
175
+
176
+ return { selectionId, selectionDir, manifest, runManifest };
177
+ }
178
+
179
+ // ─── Batch selection ─────────────────────────────────────────────
180
+
181
+ /**
182
+ * Create a selection from a batch.
183
+ *
184
+ * @param {Object} opts
185
+ * @param {string} opts.projectRoot
186
+ * @param {string} opts.projectId
187
+ * @param {string} opts.batchId
188
+ * @param {Array<{slotId: string, filename: string}>} opts.approvedSlots
189
+ * @param {string} [opts.reason]
190
+ * @param {string[]} [opts.tags]
191
+ * @returns {Promise<Object>} selection manifest
192
+ */
193
+ export async function createSelectionFromBatch({
194
+ projectRoot, projectId, batchId, approvedSlots, reason, tags,
195
+ }) {
196
+ const batchesDir = getBatchesDir(projectRoot);
197
+ const batchDir = join(batchesDir, batchId);
198
+ const batchManifestPath = join(batchDir, 'manifest.json');
199
+
200
+ if (!existsSync(batchManifestPath)) {
201
+ throw inputError('BATCH_NOT_FOUND', `Batch "${batchId}" not found`, 'Check the batch ID.');
202
+ }
203
+
204
+ const batchManifest = JSON.parse(readFileSync(batchManifestPath, 'utf-8'));
205
+ const runsDir = getRunsDir(projectRoot);
206
+
207
+ // Build slot → run lookup
208
+ const slotRunMap = new Map();
209
+ for (const slot of batchManifest.slots || []) {
210
+ slotRunMap.set(slot.slot_id, slot);
211
+ }
212
+
213
+ // Validate each approved slot
214
+ const runManifests = new Map();
215
+ for (const { slotId, filename } of approvedSlots) {
216
+ assertSafeFilename(filename, `approvedSlots for batch ${batchId}`);
217
+ const slot = slotRunMap.get(slotId);
218
+ if (!slot) {
219
+ throw inputError(
220
+ 'SLOT_NOT_FOUND',
221
+ `Slot "${slotId}" not found in batch ${batchId}`,
222
+ `Available: ${[...slotRunMap.keys()].join(', ')}`,
223
+ );
224
+ }
225
+
226
+ const runDir = join(runsDir, slot.run_id);
227
+ const outputPath = join(runDir, 'outputs', filename);
228
+ if (!existsSync(outputPath)) {
229
+ throw inputError(
230
+ 'OUTPUT_NOT_FOUND',
231
+ `Output "${filename}" not found in ${slot.run_id}/outputs/`,
232
+ `Check the filename.`,
233
+ );
234
+ }
235
+
236
+ // Cache run manifests
237
+ if (!runManifests.has(slot.run_id)) {
238
+ const rmPath = join(runDir, 'manifest.json');
239
+ if (existsSync(rmPath)) {
240
+ runManifests.set(slot.run_id, JSON.parse(readFileSync(rmPath, 'utf-8')));
241
+ }
242
+ }
243
+ }
244
+
245
+ // Create selection directory
246
+ const selectionsDir = getSelectionsDir(projectRoot);
247
+ const selectionId = generateSelectionId(selectionsDir);
248
+ const selectionDir = join(selectionsDir, selectionId);
249
+ await mkdir(join(selectionDir, 'chosen'), { recursive: true });
250
+
251
+ // Copy chosen files and build items
252
+ const items = [];
253
+ for (let i = 0; i < approvedSlots.length; i++) {
254
+ const { slotId, filename } = approvedSlots[i];
255
+ const slot = slotRunMap.get(slotId);
256
+ const runDir = join(runsDir, slot.run_id);
257
+ const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
258
+
259
+ copyFileSync(join(runDir, 'outputs', filename), join(selectionDir, 'chosen', destName));
260
+
261
+ const rm = runManifests.get(slot.run_id);
262
+ const outputEntry = rm?.outputs?.find(o => o.filename === filename);
263
+
264
+ items.push({
265
+ slot_or_output: `${slotId}:${filename}`,
266
+ filename: destName,
267
+ seed: outputEntry?.seed,
268
+ reason: reason || `selected from slot ${slot.label || slotId}`,
269
+ tags: tags || [],
270
+ });
271
+ }
272
+
273
+ const manifest = {
274
+ selection_id: selectionId,
275
+ project_id: projectId,
276
+ source_type: 'batch',
277
+ source_id: batchId,
278
+ workflow_id: batchManifest.mode_id,
279
+ created_at: new Date().toISOString(),
280
+ items,
281
+ reingest_ready: true,
282
+ };
283
+
284
+ if (batchManifest.subject_id) manifest.subject_id = batchManifest.subject_id;
285
+
286
+ return { selectionId, selectionDir, manifest, batchManifest, runManifests };
287
+ }
288
+
289
+ // ─── Save / Load / List ──────────────────────────────────────────
290
+
291
+ /**
292
+ * Save a selection manifest + summary to disk.
293
+ */
294
+ export async function saveSelection(selectionDir, manifest) {
295
+ await writeFile(
296
+ join(selectionDir, 'manifest.json'),
297
+ JSON.stringify(manifest, null, 2) + '\n',
298
+ );
299
+
300
+ // Summary JSON
301
+ await writeFile(
302
+ join(selectionDir, 'summary.json'),
303
+ JSON.stringify({
304
+ selection_id: manifest.selection_id,
305
+ source_type: manifest.source_type,
306
+ source_id: manifest.source_id,
307
+ workflow_id: manifest.workflow_id,
308
+ subject_id: manifest.subject_id,
309
+ items: manifest.items.length,
310
+ reingest_ready: manifest.reingest_ready,
311
+ created_at: manifest.created_at,
312
+ }, null, 2) + '\n',
313
+ );
314
+
315
+ // Summary markdown
316
+ const lines = [
317
+ `# Selection: ${manifest.selection_id}`,
318
+ '',
319
+ `**Source:** ${manifest.source_type} \`${manifest.source_id}\``,
320
+ `**Workflow:** ${manifest.workflow_id || '—'}`,
321
+ ];
322
+ if (manifest.subject_id) lines.push(`**Subject:** ${manifest.subject_id}`);
323
+ lines.push(`**Created:** ${manifest.created_at}`);
324
+ lines.push(`**Re-ingest ready:** ${manifest.reingest_ready ? 'yes' : 'no'}`);
325
+ lines.push('');
326
+ lines.push('## Chosen outputs');
327
+ lines.push('');
328
+ lines.push('| # | Source | File | Seed | Reason |');
329
+ lines.push('|---|--------|------|------|--------|');
330
+ for (let i = 0; i < manifest.items.length; i++) {
331
+ const item = manifest.items[i];
332
+ lines.push(
333
+ `| ${i + 1} | ${item.slot_or_output} | ${item.filename} | ${item.seed ?? '—'} | ${item.reason} |`,
334
+ );
335
+ }
336
+ lines.push('');
337
+ if (manifest.items.some(it => it.tags?.length)) {
338
+ lines.push('## Tags');
339
+ for (const item of manifest.items) {
340
+ if (item.tags?.length) {
341
+ lines.push(`- ${item.filename}: ${item.tags.join(', ')}`);
342
+ }
343
+ }
344
+ lines.push('');
345
+ }
346
+ await writeFile(join(selectionDir, 'summary.md'), lines.join('\n'));
347
+ }
348
+
349
+ /**
350
+ * Load a selection manifest by ID.
351
+ */
352
+ export function loadSelection(projectRoot, selectionId) {
353
+ const selectionDir = join(getSelectionsDir(projectRoot), selectionId);
354
+ const manifestPath = join(selectionDir, 'manifest.json');
355
+ if (!existsSync(manifestPath)) {
356
+ throw inputError(
357
+ 'SELECTION_NOT_FOUND',
358
+ `Selection "${selectionId}" not found`,
359
+ 'Check the selection ID or run: sdlab select --run <id> --approve <files>',
360
+ );
361
+ }
362
+ return JSON.parse(readFileSync(manifestPath, 'utf-8'));
363
+ }
364
+
365
+ /**
366
+ * List all selections in a project.
367
+ */
368
+ export function listSelections(projectRoot) {
369
+ const dir = getSelectionsDir(projectRoot);
370
+ if (!existsSync(dir)) return [];
371
+ return readdirSync(dir, { withFileTypes: true })
372
+ .filter(e => e.isDirectory() && e.name.startsWith('selection_'))
373
+ .map(e => e.name)
374
+ .sort();
375
+ }
package/lib/snapshot.js CHANGED
@@ -11,6 +11,45 @@ import { join } from 'node:path';
11
11
  import { createHash, randomBytes } from 'node:crypto';
12
12
  import { loadProjectConfig, loadSelectionProfile, detectLane, detectGroup } from './config.js';
13
13
  import { evaluateEligibility } from './eligibility.js';
14
+ import { inputError } from './errors.js';
15
+ import { warn } from './log.js';
16
+
17
+ /**
18
+ * DB-001 / DB-006: Manifest schema version. Every manifest.json written by
19
+ * snapshot/split/export/eval-pack/eval-run/training-manifest/training-package/
20
+ * impl-pack stamps this version; readers log a WARNING on version mismatch
21
+ * (best-effort compatibility).
22
+ */
23
+ export const SCHEMA_VERSION = '2.2.0';
24
+
25
+ /**
26
+ * Warn if a loaded manifest's schema_version does not match the reader.
27
+ * Non-fatal — continues with best-effort compatibility. No warning when
28
+ * manifest is missing the field (legacy unstamped artifacts).
29
+ */
30
+ export function checkManifestVersion(manifest, kind) {
31
+ if (manifest?.schema_version && manifest.schema_version !== SCHEMA_VERSION) {
32
+ warn(
33
+ `${kind} schema v${manifest.schema_version} loaded by v${SCHEMA_VERSION} reader — continuing with best-effort compatibility`,
34
+ );
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Derive which rule names were evaluated against a record for a given profile.
40
+ * Mirrors the order of checks in evaluateEligibility() so the trace is stable.
41
+ */
42
+ function rulesCheckedForProfile(profile) {
43
+ const rules = [];
44
+ if (profile.require_judgment) rules.push('require_judgment');
45
+ if (profile.require_status?.length > 0) rules.push('require_status');
46
+ if (profile.require_canon_bound) rules.push('require_canon_bound');
47
+ if (profile.minimum_pass_ratio != null) rules.push('minimum_pass_ratio');
48
+ if (profile.exclude_lanes?.length > 0) rules.push('exclude_lanes');
49
+ if (profile.rights_filter != null) rules.push('rights_filter');
50
+ if (profile.exclude_tags?.length > 0) rules.push('exclude_tags');
51
+ return rules;
52
+ }
14
53
 
15
54
  /**
16
55
  * Generate a snapshot ID: snap-YYYYMMDD-HHMMSS-XXXX
@@ -59,6 +98,20 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
59
98
  const snapshotId = generateSnapshotId();
60
99
  const snapshotDir = join(projectRoot, 'snapshots', snapshotId);
61
100
 
101
+ // D-005: refuse to proceed if the generated ID has collided with an existing
102
+ // snapshot dir — silent overwrite would break Law 1 (snapshots are frozen).
103
+ if (!options.dryRun && existsSync(snapshotDir)) {
104
+ throw inputError(
105
+ 'SNAPSHOT_ID_COLLISION',
106
+ `Snapshot directory already exists: ${snapshotDir}`,
107
+ 'Retry to generate a fresh snapshot ID.',
108
+ );
109
+ }
110
+
111
+ const configFingerprint = computeConfigFingerprint(projectRoot);
112
+ const profileId = profile.profile_id || profile.id || 'unknown';
113
+ const rulesChecked = rulesCheckedForProfile(profile);
114
+
62
115
  // Load all records (sorted for deterministic output)
63
116
  const files = (await readdir(recordsDir))
64
117
  .filter(f => f.endsWith('.json'))
@@ -66,13 +119,27 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
66
119
 
67
120
  const included = [];
68
121
  const excluded = [];
122
+ const unreadable = []; // DB-003: collect per-record parse errors, continue
69
123
  const laneDistribution = {};
70
124
  const factionDistribution = {};
71
125
  const verdictDistribution = { pass: 0, fail: 0, partial: 0 };
72
126
 
73
127
  for (const file of files) {
74
- const raw = await readFile(join(recordsDir, file), 'utf-8');
75
- const record = JSON.parse(raw);
128
+ // DB-003: wrap per-record JSON.parse in try/catch so one bad file does
129
+ // not abort the entire snapshot. The operator sees an `unreadable` list
130
+ // in the manifest + a WARNING count on stderr.
131
+ let record;
132
+ try {
133
+ const raw = await readFile(join(recordsDir, file), 'utf-8');
134
+ record = JSON.parse(raw);
135
+ } catch (err) {
136
+ unreadable.push({
137
+ record_id: file.replace(/\.json$/, ''),
138
+ file,
139
+ error: err.message,
140
+ });
141
+ continue;
142
+ }
76
143
 
77
144
  const { eligible, reasons } = evaluateEligibility(
78
145
  record, profile, config.lanes, config.terminology
@@ -89,7 +156,15 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
89
156
  : 'n/a';
90
157
  const reason = `${record.judgment.status}, canon-bound, pass_ratio=${passRatio}, lane=${lane}`;
91
158
 
92
- included.push({ record_id: record.id, reason });
159
+ // D-006: record the rule-trace + profile context so inclusion is
160
+ // replayable without re-running evaluateEligibility.
161
+ included.push({
162
+ record_id: record.id,
163
+ reason,
164
+ rules_checked: rulesChecked,
165
+ profile_id: profileId,
166
+ config_fingerprint: configFingerprint,
167
+ });
93
168
 
94
169
  // Track distributions
95
170
  laneDistribution[lane] = (laneDistribution[lane] || 0) + 1;
@@ -104,30 +179,46 @@ export async function createSnapshot(projectRoot, profile, options = {}) {
104
179
  }
105
180
  }
106
181
 
182
+ // DB-003: surface unreadable records loudly so partial parse failures
183
+ // are not silent. Manifest also carries the full list for drill-down.
184
+ if (unreadable.length > 0) {
185
+ const sample = unreadable.slice(0, 5).map(u => u.file).join(', ');
186
+ warn(
187
+ `snapshot: ${unreadable.length} record file(s) were unreadable and skipped` +
188
+ (unreadable.length > 5 ? ` (first 5: ${sample}, ...)` : ` (${sample})`),
189
+ );
190
+ }
191
+
107
192
  // Write snapshot files (skip if dry run)
108
193
  if (options.dryRun) {
109
- return { snapshotId, included: included.length, excluded: excluded.length };
194
+ return { snapshotId, included: included.length, excluded: excluded.length, unreadable: unreadable.length };
110
195
  }
111
196
  await mkdir(snapshotDir, { recursive: true });
112
197
 
113
198
  const manifest = {
199
+ // DB-001 / DB-006: stamp manifest schema version (reader checks on load)
200
+ schema_version: SCHEMA_VERSION,
114
201
  snapshot_id: snapshotId,
115
202
  created_at: new Date().toISOString(),
116
203
  created_by: 'sdlab-snapshot-v1',
117
204
  project_name: config.meta.name,
118
205
  frozen: true,
119
206
  selection_profile: profile,
120
- config_fingerprint: computeConfigFingerprint(projectRoot),
207
+ config_fingerprint: configFingerprint,
121
208
  config_versions: {
122
209
  constitution: config.constitution.version || '1.0.0',
123
210
  project: config.meta.version || '0.0.0',
124
211
  },
125
212
  counts: {
126
213
  total_records: files.length,
127
- evaluated: files.length,
214
+ // DB-003: evaluated reflects records actually parsed (excludes unreadable)
215
+ evaluated: files.length - unreadable.length,
128
216
  included: included.length,
129
217
  excluded: excluded.length,
218
+ unreadable: unreadable.length,
130
219
  },
220
+ // DB-003: preserve the unreadable list so operators can investigate
221
+ errors: unreadable,
131
222
  };
132
223
 
133
224
  const summary = {
@@ -153,7 +244,9 @@ export async function loadSnapshot(projectRoot, snapshotId) {
153
244
  if (!existsSync(path)) {
154
245
  throw new Error(`Snapshot "${snapshotId}" not found at ${path}`);
155
246
  }
156
- return JSON.parse(await readFile(path, 'utf-8'));
247
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
248
+ checkManifestVersion(manifest, 'snapshot');
249
+ return manifest;
157
250
  }
158
251
 
159
252
  /**