@mcptoolshop/style-dataset-lab 3.2.0 → 3.4.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 (95) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/README.es.md +40 -19
  3. package/README.fr.md +74 -53
  4. package/README.hi.md +67 -46
  5. package/README.it.md +42 -21
  6. package/README.ja.md +44 -23
  7. package/README.md +25 -4
  8. package/README.pt-BR.md +42 -21
  9. package/README.zh.md +66 -45
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +114 -27
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +82 -3
  28. package/lib/critique-render.js +45 -0
  29. package/lib/eligibility.js +121 -9
  30. package/lib/eval-pack.js +14 -14
  31. package/lib/eval-runs.js +33 -0
  32. package/lib/export.js +122 -34
  33. package/lib/freeze-gate.js +69 -16
  34. package/lib/freeze-stamp.js +28 -3
  35. package/lib/generated-provenance.js +16 -1
  36. package/lib/implementation-packs.js +95 -17
  37. package/lib/ingest.js +396 -0
  38. package/lib/judgment-provenance.js +220 -0
  39. package/lib/measure.js +422 -0
  40. package/lib/paths.js +196 -7
  41. package/lib/reingest-selected.js +98 -16
  42. package/lib/run-manifest.js +394 -0
  43. package/lib/run-summary.js +12 -6
  44. package/lib/runtime-runs.js +45 -4
  45. package/lib/snapshot.js +121 -16
  46. package/lib/split.js +364 -48
  47. package/lib/training-manifests.js +114 -21
  48. package/lib/training-packages.js +147 -15
  49. package/lib/verifier.js +67 -0
  50. package/lib/workflow-profiles.js +24 -3
  51. package/package.json +3 -2
  52. package/runtime/comfyui/txt2img-qwen.json +38 -0
  53. package/runtime/comfyui/txt2img-standard.json +21 -0
  54. package/scripts/_resume.js +62 -0
  55. package/scripts/_shared-args.js +44 -0
  56. package/scripts/brief-compile.js +2 -1
  57. package/scripts/brief-show.js +2 -1
  58. package/scripts/canon-bind.js +2 -2
  59. package/scripts/canon-build.js +27 -20
  60. package/scripts/canon-drift.js +16 -7
  61. package/scripts/canon-freeze-status.js +14 -6
  62. package/scripts/canon-freeze.js +27 -11
  63. package/scripts/canon-unfreeze.js +17 -9
  64. package/scripts/card.js +2 -2
  65. package/scripts/compare.js +2 -2
  66. package/scripts/critique.js +48 -8
  67. package/scripts/curate.js +23 -2
  68. package/scripts/doctor.js +181 -55
  69. package/scripts/eligibility.js +42 -2
  70. package/scripts/eval-pack.js +2 -2
  71. package/scripts/eval-run.js +2 -2
  72. package/scripts/export.js +2 -2
  73. package/scripts/generate-controlnet.js +45 -25
  74. package/scripts/generate-identity.js +53 -23
  75. package/scripts/generate-ipadapter.js +44 -23
  76. package/scripts/generate.js +72 -5
  77. package/scripts/implementation-pack.js +2 -2
  78. package/scripts/ingest.js +145 -0
  79. package/scripts/init.js +73 -13
  80. package/scripts/measure.js +170 -0
  81. package/scripts/measure_image.py +458 -0
  82. package/scripts/migrate-records.js +142 -0
  83. package/scripts/painterly-test.js +4 -3
  84. package/scripts/painterly.js +7 -3
  85. package/scripts/qwen_generate.py +334 -13
  86. package/scripts/reingest.js +2 -2
  87. package/scripts/run-generate.js +3 -0
  88. package/scripts/sheet.js +142 -0
  89. package/scripts/snapshot.js +2 -2
  90. package/scripts/split.js +2 -2
  91. package/scripts/training-manifest.js +2 -2
  92. package/scripts/training-package.js +2 -2
  93. package/scripts/training-profile.js +2 -2
  94. package/scripts/workflow-list.js +2 -1
  95. package/scripts/workflow-show.js +2 -1
@@ -8,7 +8,7 @@
8
8
  * Every implementation pack is tied to a training manifest.
9
9
  */
10
10
 
11
- import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
11
+ import { readFile, writeFile, readdir } from 'node:fs/promises';
12
12
  import { join } from 'node:path';
13
13
  import { existsSync } from 'node:fs';
14
14
  import { randomBytes } from 'node:crypto';
@@ -17,8 +17,8 @@ import { loadTrainingProfile } from './training-profiles.js';
17
17
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
18
  import { loadSplitPartition } from './split.js';
19
19
  import { loadRecord } from './records.js';
20
- import { inputError } from './errors.js';
21
- import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
20
+ import { SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
21
+ import { warn } from './log.js';
22
22
 
23
23
  function generateImplId() {
24
24
  const now = new Date();
@@ -46,13 +46,27 @@ export async function buildImplementationPack(projectRoot, manifestId) {
46
46
  // Also load some train records for broader coverage
47
47
  const trainEntries = await loadSplitPartition(projectRoot, manifest.source_split_id, 'train');
48
48
 
49
+ // F3: a record the split promised (via source_split_id's train/test
50
+ // partitions) whose file was later deleted from records/ must not
51
+ // silently drop out of the implementation pack with no counter, no
52
+ // warning, no id recorded. loadRecord() returns null on ENOENT
53
+ // (lib/records.js); previously `if (!record) continue` was the entire
54
+ // handling at both loops below. WARN + ledger (not hard-fail): an
55
+ // implementation pack is an illustrative/example artifact, not the
56
+ // Law-4 reproducibility-guaranteeing one (export.js and
57
+ // training-packages.js are, and hard-fail on the identical situation).
58
+ const missingRecords = [];
59
+
49
60
  // Build prompt examples — per-lane best records
50
61
  const promptExamples = [];
51
62
  const laneExamples = new Map();
52
63
 
53
64
  for (const entry of [...testEntries, ...trainEntries.slice(0, 50)]) {
54
65
  const record = await loadRecord(recordsDir, entry.record_id);
55
- if (!record) continue;
66
+ if (!record) {
67
+ missingRecords.push({ record_id: entry.record_id, stage: 'implementation-pack', source: 'prompt-examples' });
68
+ continue;
69
+ }
56
70
  if (record.judgment?.status !== 'approved') continue;
57
71
 
58
72
  const prompt = record.provenance?.prompt || '';
@@ -85,10 +99,26 @@ export async function buildImplementationPack(projectRoot, manifestId) {
85
99
  const knownFailures = [];
86
100
  const allFiles = (await readdir(recordsDir)).filter(f => f.endsWith('.json')).sort();
87
101
  let failCount = 0;
102
+ // F4: adopt the DB-003 pattern already proven in snapshot.js's
103
+ // createSnapshot (and mirrored in eval-pack.js's buildEvalPack) — this
104
+ // loop was the one place left in the codebase still doing a bare
105
+ // JSON.parse per record file with no try/catch. One truncated/corrupt
106
+ // record file threw a filename-free SyntaxError here and discarded the
107
+ // prompt-examples work (the loop above) and the subject-continuity work
108
+ // (the loop below) this function had already computed, since every
109
+ // artifact is written to disk only at the very end of
110
+ // buildImplementationPack — skip, count, warn, continue instead.
111
+ const unreadable = [];
88
112
 
89
113
  for (const file of allFiles) {
90
114
  if (failCount >= 10) break;
91
- const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
115
+ let record;
116
+ try {
117
+ record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
118
+ } catch (err) {
119
+ unreadable.push({ record_id: file.replace(/\.json$/, ''), file, error: err.message });
120
+ continue;
121
+ }
92
122
  if (record.judgment?.status !== 'rejected') continue;
93
123
 
94
124
  const prompt = record.provenance?.prompt || '';
@@ -107,6 +137,15 @@ export async function buildImplementationPack(projectRoot, manifestId) {
107
137
  });
108
138
  failCount++;
109
139
  }
140
+ // F4: surface unreadable records loudly (DB-003 parity) — same shape,
141
+ // same warn() visibility as snapshot.js's createSnapshot.
142
+ if (unreadable.length > 0) {
143
+ const sample = unreadable.slice(0, 5).map(u => u.file).join(', ');
144
+ warn(
145
+ `implementation-pack: ${unreadable.length} record file(s) were unreadable and skipped` +
146
+ (unreadable.length > 5 ? ` (first 5: ${sample}, ...)` : ` (${sample})`),
147
+ );
148
+ }
110
149
 
111
150
  // Build subject continuity examples
112
151
  const subjectExamples = [];
@@ -114,12 +153,33 @@ export async function buildImplementationPack(projectRoot, manifestId) {
114
153
 
115
154
  for (const entry of [...testEntries, ...trainEntries]) {
116
155
  const record = await loadRecord(recordsDir, entry.record_id);
117
- if (!record?.identity?.subject_name) continue;
156
+ // F3: keep "record file is missing" (a data-integrity problem worth a
157
+ // warning) distinct from "record loaded fine but simply has no
158
+ // identity.subject_name" (the normal, expected case for most records —
159
+ // not a missing-record situation). The original combined
160
+ // `!record?.identity?.subject_name` check could not tell these apart.
161
+ if (!record) {
162
+ missingRecords.push({ record_id: entry.record_id, stage: 'implementation-pack', source: 'subject-continuity' });
163
+ continue;
164
+ }
165
+ if (!record.identity?.subject_name) continue;
118
166
  const name = record.identity.subject_name;
119
167
  if (!bySubject.has(name)) bySubject.set(name, []);
120
168
  bySubject.get(name).push(record);
121
169
  }
122
170
 
171
+ // F3: warn once for both loops combined — the ledger records which
172
+ // loop(s) actually hit the miss via `source`, so the summary line stays
173
+ // useful even though it's a single warning for the whole pack build.
174
+ if (missingRecords.length > 0) {
175
+ const sample = missingRecords.slice(0, 10).map(m => `${m.record_id} (${m.source})`);
176
+ warn(
177
+ `implementation-pack: ${missingRecords.length} record(s) referenced by split "${manifest.source_split_id}" ` +
178
+ `could not be loaded from ${recordsDir}` +
179
+ (missingRecords.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`),
180
+ );
181
+ }
182
+
123
183
  for (const [name, records] of [...bySubject].sort((a, b) => b[1].length - a[1].length).slice(0, 5)) {
124
184
  subjectExamples.push({
125
185
  subject_name: name,
@@ -138,17 +198,18 @@ export async function buildImplementationPack(projectRoot, manifestId) {
138
198
  };
139
199
 
140
200
  // Write implementation pack
141
- const implId = generateImplId();
142
- const implDir = join(projectRoot, 'training', 'implementations', implId);
143
- // D-005: refuse to silently overwrite on ID collision.
144
- if (existsSync(implDir)) {
145
- throw inputError(
146
- 'IMPL_PACK_ID_COLLISION',
147
- `Implementation pack directory already exists: ${implDir}`,
148
- 'Retry to generate a fresh implementation pack ID.',
149
- );
150
- }
151
- await mkdir(implDir, { recursive: true });
201
+ // SDL-M10: atomically claim the implementation-pack id/dir — closes the
202
+ // check-then-act race the old existsSync+recursive-mkdir pattern had
203
+ // (D-005's intent, now actually race-proof; see claimIdDir in snapshot.js).
204
+ const { id: implId, dir: implDir } = await claimIdDir(
205
+ join(projectRoot, 'training', 'implementations'),
206
+ generateImplId,
207
+ {
208
+ code: 'IMPL_PACK_ID_COLLISION',
209
+ message: 'Could not claim a unique implementation-pack ID — every candidate collided with an existing implementation-pack directory.',
210
+ hint: 'Retry; if this persists, another sdlab process may be creating implementation packs in a tight loop.',
211
+ },
212
+ );
152
213
 
153
214
  const implManifest = {
154
215
  // DB-001 / DB-006: stamp manifest schema version
@@ -163,7 +224,24 @@ export async function buildImplementationPack(projectRoot, manifestId) {
163
224
  known_failures: knownFailures.length,
164
225
  subject_examples: subjectExamples.length,
165
226
  lanes_covered: laneExamples.size,
227
+ // F3: how many split-referenced records could not be loaded while
228
+ // building this pack. 0 in the common case; see missing_records
229
+ // below for the actual ids.
230
+ missing_records: missingRecords.length,
231
+ // F4: how many record files (from the known-failures scan over ALL
232
+ // of records/) existed but threw on JSON.parse — distinct from
233
+ // missing_records above, which is "no file at all". 0 in the common
234
+ // case; see unreadable_records below for the actual files.
235
+ unreadable_records: unreadable.length,
166
236
  },
237
+ // F3: ledger of every record_id that failed to load while building
238
+ // this pack (mirrors the images_failed pattern export.js established
239
+ // at export time) — record_id + stage + which loop hit it, so an
240
+ // operator can trace it without re-diffing the split against records/.
241
+ missing_records: missingRecords,
242
+ // F4: ledger of record files that existed but failed to JSON.parse
243
+ // (DB-003 parity with snapshot.js's `errors` field).
244
+ unreadable_records: unreadable,
167
245
  };
168
246
 
169
247
  await writeFile(join(implDir, 'manifest.json'), JSON.stringify(implManifest, null, 2) + '\n');
package/lib/ingest.js ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Ingest engine — bring pre-existing images into the pipeline as bare,
3
+ * uncurated candidate records.
4
+ *
5
+ * The gap this closes: there was no CLI path to get an image that was NOT
6
+ * produced by `sdlab generate`/`run generate`/`batch generate` into the
7
+ * project. `sdlab reingest generated` requires a trained LoRA's training
8
+ * manifest (lib/reingest.js loadTrainingManifest() unconditionally) — it
9
+ * exists to bring TRAINED-MODEL outputs back in, not pre-training
10
+ * candidates. `sdlab reingest selected` requires a selection id, which only
11
+ * the production loop can mint. Neither can take "a folder of images from
12
+ * somewhere else" (hand-picked references, a batch rendered in external
13
+ * Python against Comfy Cloud, salvage from a lost pipeline run) and turn it
14
+ * into records ready for `sdlab curate`. This module is that third path.
15
+ *
16
+ * Non-negotiables this module holds itself to:
17
+ * - NEVER invents a judgment, a score, or a caption. Every record ships
18
+ * with judgment: null, canon: null (via buildBaseRecord, the same
19
+ * shared helper lib/reingest.js already uses) — uncurated, exactly like
20
+ * everything else that hasn't been through `sdlab curate` yet.
21
+ * - provenance.source is always 'external' — honestly distinguishable
22
+ * from provenance.source === 'generated' (lib/reingest.js) and from the
23
+ * flat generation_provenance shape lib/generated-provenance.js builds
24
+ * for selected run/batch outputs. An ingested record's generation
25
+ * details (prompt, seed, ...) are attached ONLY when a --wave file or a
26
+ * receipts.jsonl actually supplies them for that specific id — never
27
+ * guessed, never interpolated from a sibling.
28
+ * - id derivation is validated, not silently rewritten: a filename stem
29
+ * that fails the SAME allowlist lib/export.js already uses for every
30
+ * other record-id-becomes-a-filename boundary (SAFE_ID_RE / assertSafeId
31
+ * — SDL-H5) is rejected per-file with a reason, not coerced into
32
+ * something "close enough". A directory scan is bulk, externally-named
33
+ * input by nature (unlike an already-pipeline-produced record id), so
34
+ * one unsafe filename is reported and skipped rather than aborting the
35
+ * whole ingest — the vector is closed either way, since no path is ever
36
+ * built from a name that fails the check.
37
+ * - --dry-run performs zero filesystem writes: no mkdir, no image copy, no
38
+ * record write. (SDL-M11 fixed five scripts that violated exactly this.)
39
+ * - idempotent: an id whose record already exists is skipped, never
40
+ * duplicated or clobbered, and every skip is reported back to the
41
+ * caller (not just counted).
42
+ * - crash-safe: both the copied image and the written record land via
43
+ * temp-file-then-rename, mirroring the atomic pattern lib/runtime-runs.js
44
+ * (saveRunManifest/checkpointRunManifest) and lib/batch-runs.js already
45
+ * use. A process killed mid-write must never leave a truncated file at
46
+ * the FINAL path that a later run's existsSync() mistakes for "done".
47
+ */
48
+
49
+ import { readFile, writeFile, readdir, stat, mkdir, copyFile, rename, unlink } from 'node:fs/promises';
50
+ import { join, basename, extname, relative, sep } from 'node:path';
51
+ import { existsSync } from 'node:fs';
52
+ import { buildBaseRecord } from './records.js';
53
+ import { assertSafeId } from './export.js';
54
+ import { inputError } from './errors.js';
55
+ import { warn, verbose } from './log.js';
56
+
57
+ // Mirrors lib/reingest.js's IMAGE_EXTENSIONS. Not imported from there —
58
+ // that module doesn't export the constant, and this file's ownership
59
+ // boundary for this change doesn't include editing lib/reingest.js.
60
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
61
+
62
+ const RECEIPTS_FILENAME = 'receipts.jsonl';
63
+
64
+ /** Normalize a path to forward slashes for storage (matches asset_path style used throughout this codebase). */
65
+ function toPortablePath(p) {
66
+ return p.split(sep).join('/');
67
+ }
68
+
69
+ // ─── Atomic write helpers ──────────────────────────────────────────
70
+ //
71
+ // Same temp-file-then-rename shape as lib/runtime-runs.js's
72
+ // atomicWriteJson / lib/batch-runs.js's atomicWriteJson / lib/reingest-
73
+ // selected.js's atomicWriteText. None of those three are exported (each
74
+ // module keeps its own copy — that is the established convention in this
75
+ // codebase for this exact ~10-line helper, not an oversight to "fix" by
76
+ // centralizing here), so this is a fourth copy of the same pattern rather
77
+ // than a new invention.
78
+
79
+ async function atomicWriteFile(targetPath, body) {
80
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
81
+ try {
82
+ await writeFile(tmpPath, body);
83
+ await rename(tmpPath, targetPath);
84
+ } catch (err) {
85
+ try { await unlink(tmpPath); } catch { /* best-effort cleanup */ }
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Copy a file atomically: copy to a temp path in the SAME directory as
92
+ * destPath, then rename. A process killed mid-copy leaves only the
93
+ * uniquely-named temp file behind — never a partial file sitting at
94
+ * destPath that a later run's `existsSync(destPath)` would mistake for a
95
+ * complete, already-ingested image and skip re-copying forever.
96
+ */
97
+ async function atomicCopyFile(srcPath, destPath) {
98
+ const tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}`;
99
+ try {
100
+ await copyFile(srcPath, tmpPath);
101
+ await rename(tmpPath, destPath);
102
+ } catch (err) {
103
+ try { await unlink(tmpPath); } catch { /* best-effort cleanup */ }
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ // ─── Wave / receipts loaders ────────────────────────────────────────
109
+
110
+ /**
111
+ * Load a wave pack (the shape of projects/salt-road/inputs/prompts/
112
+ * wave-v2.json: `{ formula_ref?, wave?, items: [{ id, seed, subject, ... }] }`)
113
+ * into a Map<id, item>. Falls back to a `subjects` array (the
114
+ * `sdlab generate` prompt-pack shape) if `items` is absent, so a wave file
115
+ * authored either way is still usable.
116
+ *
117
+ * Returns an empty index when wavePath is falsy — --wave is opt-in
118
+ * enrichment, not a requirement. A wavePath that IS given but cannot be
119
+ * read/parsed is a user error worth stopping for (they asked for that
120
+ * file specifically), not a silent "provenance unknown" fallback.
121
+ *
122
+ * @param {string|null|undefined} wavePath — absolute, already-validated path
123
+ * @returns {Promise<{index: Map<string,Object>, formulaRef: string|null, waveName: string|null}>}
124
+ */
125
+ export async function loadWaveIndex(wavePath) {
126
+ const index = new Map();
127
+ if (!wavePath) return { index, formulaRef: null, waveName: null };
128
+
129
+ if (!existsSync(wavePath)) {
130
+ throw inputError(
131
+ 'INPUT_WAVE_NOT_FOUND',
132
+ `--wave file not found: ${wavePath}`,
133
+ 'Check the path. Wave files live under inputs/prompts/*.json — see projects/salt-road/inputs/prompts/wave-v2.json for the shape.'
134
+ );
135
+ }
136
+
137
+ let raw;
138
+ try {
139
+ raw = JSON.parse(await readFile(wavePath, 'utf-8'));
140
+ } catch (err) {
141
+ throw inputError(
142
+ 'INPUT_INVALID_JSON',
143
+ `Failed to parse --wave file ${wavePath}: ${err.message}`,
144
+ 'Check the file for JSON syntax errors.'
145
+ );
146
+ }
147
+
148
+ const items = Array.isArray(raw.items) ? raw.items
149
+ : Array.isArray(raw.subjects) ? raw.subjects
150
+ : null;
151
+
152
+ if (!items) {
153
+ warn(`ingest: --wave file ${wavePath} has no "items" or "subjects" array — no entries will be attached.`);
154
+ return { index, formulaRef: raw.formula_ref || null, waveName: raw.wave || raw.name || null };
155
+ }
156
+
157
+ for (const item of items) {
158
+ if (item && typeof item.id === 'string') index.set(item.id, item);
159
+ }
160
+
161
+ return { index, formulaRef: raw.formula_ref || null, waveName: raw.wave || raw.name || null };
162
+ }
163
+
164
+ /**
165
+ * Load a receipts.jsonl (the shape of projects/salt-road/inbox/generated/
166
+ * set-v2-2026-07-30/receipts.jsonl: one `{id, status, prompt_id, seed, secs}`
167
+ * object per line) into a Map<id, receipt>.
168
+ *
169
+ * Best-effort by design: a missing file yields an empty Map (this is
170
+ * opportunistic enrichment co-located with the images, not a requirement),
171
+ * and a malformed INDIVIDUAL line is skipped with a warning rather than
172
+ * failing the whole load — one corrupt trailing line from an interrupted
173
+ * write must not blank out every other real receipt in the file.
174
+ *
175
+ * @param {string} receiptsPath — absolute path
176
+ * @returns {Promise<Map<string,Object>>}
177
+ */
178
+ export async function loadReceiptsIndex(receiptsPath) {
179
+ const index = new Map();
180
+ if (!receiptsPath || !existsSync(receiptsPath)) return index;
181
+
182
+ const raw = await readFile(receiptsPath, 'utf-8');
183
+ const lines = raw.split('\n');
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const trimmed = lines[i].trim();
186
+ if (!trimmed) continue;
187
+ try {
188
+ const entry = JSON.parse(trimmed);
189
+ if (entry && typeof entry.id === 'string') index.set(entry.id, entry);
190
+ } catch (err) {
191
+ warn(`ingest: ${receiptsPath}:${i + 1} is not valid JSON — line skipped (${err.message})`);
192
+ }
193
+ }
194
+ return index;
195
+ }
196
+
197
+ // ─── Provenance builder ─────────────────────────────────────────────
198
+
199
+ /**
200
+ * Build the provenance block for one ingested image. Additive-only: a
201
+ * field is set exactly when a real source (the --wave entry or the
202
+ * receipts.jsonl entry for this id) supplied it. Nothing here is ever
203
+ * synthesized, interpolated, or defaulted from another record.
204
+ */
205
+ function buildExternalProvenance({ file, sourcePath, waveEntry, waveMeta, receiptEntry, receiptsRelPath }) {
206
+ const prov = {
207
+ source: 'external',
208
+ ingested_at: new Date().toISOString(),
209
+ original_filename: file,
210
+ original_path: sourcePath,
211
+ };
212
+
213
+ const notes = [];
214
+
215
+ if (waveEntry) {
216
+ prov.wave_file = waveMeta.waveRelPath;
217
+ if (waveMeta.waveName) prov.wave_name = waveMeta.waveName;
218
+ if (waveMeta.formulaRef) {
219
+ prov.formula_ref = waveMeta.formulaRef;
220
+ notes.push(
221
+ 'preamble_ref/negative_ref are keys into the wave\'s formula_ref file, not literal prompt/negative text.'
222
+ );
223
+ }
224
+ if (waveEntry.seed !== undefined) prov.seed = waveEntry.seed;
225
+ const promptText = waveEntry.subject ?? waveEntry.prompt;
226
+ if (promptText !== undefined) prov.prompt = promptText;
227
+ if (waveEntry.preamble !== undefined) prov.preamble_ref = waveEntry.preamble;
228
+ if (waveEntry.neg !== undefined) prov.negative_ref = waveEntry.neg;
229
+ if (waveEntry.negExtra !== undefined) prov.negative_extra = waveEntry.negExtra;
230
+ if (waveEntry.cn !== undefined) prov.controlnet = waveEntry.cn;
231
+ if (waveEntry.enriched_by !== undefined) prov.enriched_by = waveEntry.enriched_by;
232
+ } else if (waveMeta.wavePath) {
233
+ notes.push(`No entry for this id was found in --wave file ${waveMeta.waveRelPath}.`);
234
+ }
235
+
236
+ if (receiptEntry) {
237
+ prov.receipts_file = receiptsRelPath;
238
+ if (receiptEntry.prompt_id !== undefined) prov.prompt_id = receiptEntry.prompt_id;
239
+ // Wave seed wins if both are present and disagree; a receipt seed is
240
+ // still useful when the wave lacked one — never overwritten if already set.
241
+ if (receiptEntry.seed !== undefined && prov.seed === undefined) prov.seed = receiptEntry.seed;
242
+ if (receiptEntry.secs !== undefined) prov.generation_seconds = receiptEntry.secs;
243
+ if (receiptEntry.status !== undefined) prov.receipt_status = receiptEntry.status;
244
+ }
245
+
246
+ prov.generation_provenance_known = Boolean(waveEntry || receiptEntry);
247
+ if (!waveEntry && !receiptEntry) {
248
+ notes.push(
249
+ waveMeta.wavePath
250
+ ? 'No matching wave or receipts.jsonl entry for this id — generation details are unknown.'
251
+ : 'No --wave file supplied and no receipts.jsonl found alongside the source images — generation details are unknown.'
252
+ );
253
+ }
254
+ if (notes.length) prov.provenance_note = notes.join(' ');
255
+
256
+ return prov;
257
+ }
258
+
259
+ // ─── Main entry point ────────────────────────────────────────────────
260
+
261
+ /**
262
+ * Ingest a directory of pre-existing images into a project as bare,
263
+ * uncurated candidate records ready for `sdlab curate`.
264
+ *
265
+ * @param {string} projectRoot — absolute project root (already resolved by the caller)
266
+ * @param {string} sourceDir — absolute path to a directory of images (already validated by the caller)
267
+ * @param {Object} [options]
268
+ * @param {string} [options.wavePath] — absolute, already-validated path to a wave JSON file
269
+ * @param {boolean} [options.dryRun]
270
+ * @returns {Promise<{created: string[], skipped: string[], rejected: {file:string, reason:string}[], receiptsFile: string|null}>}
271
+ */
272
+ export async function ingestDirectory(projectRoot, sourceDir, options = {}) {
273
+ const dryRun = Boolean(options.dryRun);
274
+
275
+ if (!existsSync(sourceDir)) {
276
+ throw inputError(
277
+ 'INPUT_SOURCE_NOT_FOUND',
278
+ `Source directory not found: ${sourceDir}`,
279
+ 'Check the path passed to "sdlab ingest <dir>".'
280
+ );
281
+ }
282
+
283
+ const recordsDir = join(projectRoot, 'records');
284
+ const candidatesDir = join(projectRoot, 'outputs', 'candidates');
285
+
286
+ // Wave + receipts are loaded up front, once, before touching any output
287
+ // directory — reading is safe under --dry-run (no mutation).
288
+ const wavePath = options.wavePath || null;
289
+ const { index: waveIndex, formulaRef, waveName } = await loadWaveIndex(wavePath);
290
+ const waveMeta = {
291
+ wavePath,
292
+ waveRelPath: wavePath ? toPortablePath(relative(projectRoot, wavePath)) : null,
293
+ formulaRef,
294
+ waveName,
295
+ };
296
+
297
+ const receiptsPath = join(sourceDir, RECEIPTS_FILENAME);
298
+ const receiptsIndex = await loadReceiptsIndex(receiptsPath);
299
+ const receiptsFound = receiptsIndex.size > 0;
300
+ const receiptsRelPath = receiptsFound ? toPortablePath(relative(projectRoot, receiptsPath)) : null;
301
+ if (receiptsFound) {
302
+ verbose(`ingest: loaded ${receiptsIndex.size} receipt(s) from ${receiptsPath}`);
303
+ }
304
+
305
+ // SDL-M11 discipline: a --dry-run preview must be side-effect-free. Only
306
+ // create output directories on a real run.
307
+ if (!dryRun) {
308
+ await mkdir(recordsDir, { recursive: true });
309
+ await mkdir(candidatesDir, { recursive: true });
310
+ }
311
+
312
+ const entries = (await readdir(sourceDir)).sort();
313
+ const imageFiles = entries.filter((f) => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()));
314
+
315
+ const created = [];
316
+ const skipped = [];
317
+ const rejected = [];
318
+
319
+ for (const file of imageFiles) {
320
+ const ext = extname(file);
321
+ const stem = basename(file, ext);
322
+
323
+ // SDL-H5-style boundary: a filename becoming a record id (and later a
324
+ // path segment for the copied image / record file) is a path-traversal
325
+ // vector. Reuses the exact allowlist lib/export.js already applies at
326
+ // every other record-id-becomes-a-filename boundary in this codebase.
327
+ // Unlike that adapter-layer call site (which hard-throws because an
328
+ // unsafe id THERE means a pipeline-internal invariant already broke),
329
+ // this is a first-touch scan of an arbitrary, externally-named
330
+ // directory — one badly-named file (spaces, unicode, an accidental
331
+ // "..") is the normal case, not an attack. It is rejected and reported
332
+ // so the other 99 files in the same directory still get ingested; the
333
+ // vector is closed identically either way, since no path is ever built
334
+ // from a name that fails this check.
335
+ try {
336
+ assertSafeId(stem, `filename "${file}" in ${sourceDir}`);
337
+ } catch (err) {
338
+ rejected.push({ file, reason: err.message });
339
+ continue;
340
+ }
341
+
342
+ const id = stem;
343
+ const recordPath = join(recordsDir, `${id}.json`);
344
+
345
+ if (existsSync(recordPath)) {
346
+ skipped.push(id);
347
+ continue;
348
+ }
349
+
350
+ if (dryRun) {
351
+ // Preview only — no stat(), no copy, no write. Report what WOULD be
352
+ // created without touching the filesystem beyond the read-only
353
+ // listing/existence checks already done above.
354
+ created.push(id);
355
+ continue;
356
+ }
357
+
358
+ const sourcePath = join(sourceDir, file);
359
+ const destPath = join(candidatesDir, file);
360
+ const assetPath = toPortablePath(join('outputs', 'candidates', file));
361
+
362
+ const fileStat = await stat(sourcePath);
363
+
364
+ if (!existsSync(destPath)) {
365
+ await atomicCopyFile(sourcePath, destPath);
366
+ }
367
+
368
+ const waveEntry = waveIndex.get(id) || null;
369
+ const receiptEntry = receiptsIndex.get(id) || null;
370
+ const provenance = buildExternalProvenance({
371
+ file,
372
+ sourcePath,
373
+ waveEntry,
374
+ waveMeta,
375
+ receiptEntry,
376
+ receiptsRelPath,
377
+ });
378
+
379
+ const record = buildBaseRecord(
380
+ id,
381
+ assetPath,
382
+ { width: null, height: null, bytes: fileStat.size },
383
+ provenance
384
+ );
385
+
386
+ await atomicWriteFile(recordPath, JSON.stringify(record, null, 2) + '\n');
387
+ created.push(id);
388
+ }
389
+
390
+ return {
391
+ created,
392
+ skipped,
393
+ rejected,
394
+ receiptsFile: receiptsFound ? receiptsRelPath : null,
395
+ };
396
+ }