@mcptoolshop/style-dataset-lab 3.0.0 → 3.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 (99) hide show
  1. package/CHANGELOG.md +108 -15
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +71 -0
  4. package/README.es.md +99 -8
  5. package/README.fr.md +100 -9
  6. package/README.hi.md +102 -9
  7. package/README.it.md +99 -8
  8. package/README.ja.md +100 -9
  9. package/README.md +64 -1
  10. package/README.pt-BR.md +99 -8
  11. package/README.zh.md +100 -9
  12. package/SECURITY.md +6 -5
  13. package/bin/sdlab.js +289 -62
  14. package/lib/adapters/comfyui-runner.js +11 -15
  15. package/lib/adapters/diffusers-lora.js +7 -2
  16. package/lib/adapters/generic-image-caption.js +6 -1
  17. package/lib/args.js +200 -13
  18. package/lib/batch-compiler.js +7 -3
  19. package/lib/batch-runs.js +162 -31
  20. package/lib/batch-sheet-render.js +5 -2
  21. package/lib/brief-compiler.js +38 -11
  22. package/lib/card.js +11 -4
  23. package/lib/comfyui-output.js +70 -0
  24. package/lib/comfyui.js +100 -6
  25. package/lib/config.js +68 -3
  26. package/lib/deprecation.js +1 -1
  27. package/lib/eval-pack.js +94 -4
  28. package/lib/eval-runs.js +42 -3
  29. package/lib/export.js +144 -34
  30. package/lib/implementation-packs.js +15 -1
  31. package/lib/log.js +41 -4
  32. package/lib/paths.js +41 -3
  33. package/lib/refine-briefs.js +16 -9
  34. package/lib/reingest-selected.js +14 -3
  35. package/lib/reingest.js +15 -1
  36. package/lib/run-summary.js +3 -2
  37. package/lib/runtime-runs.js +59 -9
  38. package/lib/selections.js +54 -2
  39. package/lib/snapshot.js +100 -7
  40. package/lib/split.js +175 -17
  41. package/lib/training-adapters.js +79 -0
  42. package/lib/training-manifests.js +17 -3
  43. package/lib/training-packages.js +29 -28
  44. package/lib/training-profiles.js +10 -0
  45. package/package.json +27 -3
  46. package/scripts/batch-generate.js +72 -20
  47. package/scripts/batch-sheet.js +3 -3
  48. package/scripts/batch-show.js +3 -3
  49. package/scripts/brief-compile.js +8 -8
  50. package/scripts/brief-show.js +4 -3
  51. package/scripts/canon-bind.js +7 -9
  52. package/scripts/card.js +22 -25
  53. package/scripts/compare.js +38 -33
  54. package/scripts/critique-show.js +3 -3
  55. package/scripts/critique.js +3 -3
  56. package/scripts/curate.js +91 -46
  57. package/scripts/doctor.js +3 -5
  58. package/scripts/eligibility.js +17 -14
  59. package/scripts/eval-pack.js +13 -11
  60. package/scripts/eval-run.js +30 -29
  61. package/scripts/export.js +27 -31
  62. package/scripts/generate-controlnet.js +31 -17
  63. package/scripts/generate-identity.js +37 -20
  64. package/scripts/generate-ipadapter.js +22 -16
  65. package/scripts/generate.js +96 -29
  66. package/scripts/implementation-pack.js +19 -14
  67. package/scripts/init.js +115 -17
  68. package/scripts/painterly.js +8 -10
  69. package/scripts/refine.js +3 -3
  70. package/scripts/reingest-selected.js +3 -3
  71. package/scripts/reingest.js +33 -20
  72. package/scripts/run-generate.js +4 -4
  73. package/scripts/run-list.js +3 -3
  74. package/scripts/run-show.js +3 -3
  75. package/scripts/select.js +5 -4
  76. package/scripts/selection-show.js +3 -3
  77. package/scripts/snapshot.js +22 -24
  78. package/scripts/split.js +26 -31
  79. package/scripts/training-manifest.js +29 -24
  80. package/scripts/training-package.js +24 -20
  81. package/scripts/training-profile.js +13 -8
  82. package/scripts/workflow-list.js +2 -2
  83. package/scripts/workflow-show.js +4 -3
  84. package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
  85. package/templates/domains/architecture/terminology.json +10 -8
  86. package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
  87. package/templates/domains/character-design/project.json +4 -2
  88. package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
  89. package/templates/domains/creature-design/project.json +4 -2
  90. package/templates/domains/creature-design/terminology.json +1 -0
  91. package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
  92. package/templates/domains/game-art/project.json +4 -2
  93. package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
  94. package/templates/domains/vehicle-mech/project.json +4 -2
  95. package/scripts/bulk-curate-wave2-5.js +0 -138
  96. package/scripts/bulk-curate-waves11-18.js +0 -413
  97. package/scripts/curate-wave25.js +0 -209
  98. package/scripts/migrate-records.js +0 -141
  99. package/templates/inputs/prompts/example-wave.json +0 -27
package/lib/export.js CHANGED
@@ -6,14 +6,17 @@
6
6
  */
7
7
 
8
8
  import { readFile, writeFile, mkdir, readdir, symlink, copyFile, stat } from 'node:fs/promises';
9
- import { join, relative, resolve } from 'node:path';
9
+ import { join, relative, resolve, sep, basename, isAbsolute } from 'node:path';
10
10
  import { existsSync } from 'node:fs';
11
+ import { platform } from 'node:os';
11
12
  import { createHash, randomBytes } from 'node:crypto';
12
13
  import { loadProjectConfig, loadExportProfile, detectLane, detectGroup } from './config.js';
13
- import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint } from './snapshot.js';
14
+ import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
14
15
  import { loadSplit, loadSplitPartition } from './split.js';
15
16
  import { loadRecord } from './records.js';
16
17
  import { generateCard } from './card.js';
18
+ import { inputError } from './errors.js';
19
+ import { warn } from './log.js';
17
20
 
18
21
  /**
19
22
  * Generate an export ID: export-YYYYMMDD-HHMMSS-XXXX
@@ -54,6 +57,29 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
54
57
 
55
58
  const exportId = generateExportId();
56
59
  const exportDir = join(projectRoot, 'exports', exportId);
60
+
61
+ // D-005: existsSync guard prevents silent overwrite on ID collision
62
+ if (existsSync(exportDir)) {
63
+ throw inputError(
64
+ 'EXPORT_ID_COLLISION',
65
+ `Export directory already exists: ${exportDir}`,
66
+ 'Retry to generate a fresh export ID, or delete the existing directory if this was intentional.',
67
+ );
68
+ }
69
+
70
+ // D-001: Verify fingerprint has not drifted since snapshot creation.
71
+ // We inherit the snapshot's fingerprint in the export manifest; if a caller
72
+ // has edited project config mid-flight we surface that loudly rather than
73
+ // silently embedding a new fingerprint that masks reproducibility loss.
74
+ const currentFingerprint = computeConfigFingerprint(projectRoot);
75
+ if (snapshot.config_fingerprint && currentFingerprint !== snapshot.config_fingerprint) {
76
+ throw inputError(
77
+ 'FINGERPRINT_DRIFT',
78
+ `Project config fingerprint has changed since snapshot "${snapshotId}" was created.`,
79
+ `Snapshot fingerprint: ${snapshot.config_fingerprint.slice(0, 16)}..., current: ${currentFingerprint.slice(0, 16)}.... Create a fresh snapshot from the updated config, or revert the config edits.`,
80
+ );
81
+ }
82
+
57
83
  await mkdir(exportDir, { recursive: true });
58
84
 
59
85
  // Build metadata + collect image paths
@@ -61,6 +87,8 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
61
87
  const imagePaths = [];
62
88
  const checksumEntries = [];
63
89
 
90
+ const projectRootResolved = resolve(projectRoot);
91
+
64
92
  for (const entry of included) {
65
93
  const record = await loadRecord(recordsDir, entry.record_id);
66
94
  if (!record) continue;
@@ -87,10 +115,33 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
87
115
 
88
116
  // Track image for linking
89
117
  if (profile.include_images && record.asset_path) {
118
+ // D-007: validate asset_path does not escape projectRoot, use
119
+ // platform-safe basename, and name output files by record.id
120
+ // (D-009) to avoid collisions on shared basenames.
121
+ if (isAbsolute(record.asset_path)) {
122
+ throw inputError(
123
+ 'ASSET_PATH_INVALID',
124
+ `Record "${record.id}" has absolute asset_path: ${record.asset_path}`,
125
+ 'asset_path must be relative to the project root.',
126
+ );
127
+ }
128
+ const resolvedSource = resolve(projectRootResolved, record.asset_path);
129
+ if (resolvedSource !== projectRootResolved &&
130
+ !resolvedSource.startsWith(projectRootResolved + sep)) {
131
+ throw inputError(
132
+ 'ASSET_PATH_TRAVERSAL',
133
+ `Record "${record.id}" asset_path escapes projectRoot: ${record.asset_path}`,
134
+ 'Reject path traversal segments (e.g. "../") in asset_path.',
135
+ );
136
+ }
137
+ const sourceBasename = basename(record.asset_path);
138
+ const extMatch = sourceBasename.match(/\.[^.]+$/);
139
+ const ext = extMatch ? extMatch[0] : '';
140
+ const safeFilename = `${record.id}${ext}`;
90
141
  imagePaths.push({
91
142
  record_id: record.id,
92
- source: join(projectRoot, record.asset_path),
93
- filename: record.asset_path.split('/').pop(),
143
+ source: resolvedSource,
144
+ filename: safeFilename,
94
145
  });
95
146
  }
96
147
  }
@@ -100,27 +151,70 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
100
151
  await writeFile(metadataPath, metadata.map(r => JSON.stringify(r)).join('\n') + '\n');
101
152
 
102
153
  // Images directory
154
+ const warnings = [];
155
+ // DB-002: separate accounting for expected vs. actually placed vs. failed.
156
+ // Previously `counts.images` reported `sources_that_existed`, which hid
157
+ // per-file symlink/copy failures and made partial exports look complete.
158
+ let imagesExpected = 0;
159
+ let imagesActual = 0;
160
+ const imagesFailed = []; // [{record_id, reason}]
103
161
  if (profile.include_images && imagePaths.length > 0) {
162
+ // D-008: Windows symlink semantics differ (require admin or dev mode);
163
+ // refuse symlink mode on Windows and require --copy for portability.
164
+ if (!options.copy && platform() === 'win32') {
165
+ throw inputError(
166
+ 'SYMLINK_UNSUPPORTED_WINDOWS',
167
+ 'Symlink mode is not supported on Windows export (target semantics differ and break on archive).',
168
+ 'Re-run with --copy to produce a portable, self-contained export.',
169
+ );
170
+ }
171
+ if (!options.copy) {
172
+ warnings.push(
173
+ 'Export uses relative symlinks — package is NOT portable across machines. ' +
174
+ 'Use --copy for cross-machine transfer or archival.',
175
+ );
176
+ }
177
+
104
178
  const imagesDir = join(exportDir, 'images');
105
179
  await mkdir(imagesDir, { recursive: true });
106
180
 
107
- let linked = 0;
181
+ imagesExpected = imagePaths.length;
108
182
  for (const img of imagePaths) {
109
183
  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)
184
+ if (!existsSync(img.source)) {
185
+ // DB-002: source-missing is a distinct failure mode from link/copy error.
186
+ imagesFailed.push({ record_id: img.record_id, reason: 'source_missing' });
187
+ continue;
188
+ }
189
+ try {
190
+ if (options.copy) {
191
+ await copyFile(img.source, dest);
192
+ } else {
193
+ const relPath = relative(imagesDir, img.source);
194
+ await symlink(relPath, dest);
121
195
  }
196
+ imagesActual++;
197
+ } catch (err) {
198
+ // DB-002: capture the specific placement failure for operator triage.
199
+ imagesFailed.push({
200
+ record_id: img.record_id,
201
+ reason: options.copy ? 'copy_failed' : 'link_failed',
202
+ detail: err.message,
203
+ });
122
204
  }
123
205
  }
206
+
207
+ // DB-002: surface partial placement failures loudly — silent `catch {}`
208
+ // previously hid this and made exports appear complete when 20% of
209
+ // images had actually been dropped.
210
+ if (imagesFailed.length > 0) {
211
+ const sample = imagesFailed.slice(0, 10).map(f => `${f.record_id} (${f.reason})`);
212
+ const msg =
213
+ `export: ${imagesFailed.length} of ${imagesExpected} images failed to place` +
214
+ (imagesFailed.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`);
215
+ warn(msg);
216
+ warnings.push(msg);
217
+ }
124
218
  }
125
219
 
126
220
  // Splits
@@ -144,34 +238,35 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
144
238
  await writeFile(join(exportDir, 'dataset-card.json'), JSON.stringify(json, null, 2) + '\n');
145
239
  }
146
240
 
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)
241
+ // D-015: Write manifest + summary FIRST so they are covered by checksums.
242
+ // D-001: Inherit snapshot's config_fingerprint (we verified above it matches
243
+ // current state) — never recompute independently at export time.
161
244
  const manifest = {
245
+ // DB-001 / DB-006: stamp manifest schema version
246
+ schema_version: SCHEMA_VERSION,
162
247
  export_id: exportId,
163
248
  created_at: new Date().toISOString(),
164
249
  created_by: 'sdlab-export-v1',
165
250
  project: config.meta.name,
166
251
  snapshot_id: snapshotId,
167
252
  split_id: splitId,
168
- config_fingerprint: computeConfigFingerprint(projectRoot),
253
+ config_fingerprint: snapshot.config_fingerprint,
169
254
  profile,
255
+ warnings,
170
256
  counts: {
171
257
  records: metadata.length,
172
- images: imagePaths.filter(i => existsSync(i.source)).length,
173
- checksums: checksumEntries.length,
258
+ // DB-002: images count is the actual placed count (not source-exists).
259
+ // Keep top-level `images` for backward compat with older manifest readers
260
+ // that indexed it as the summary number, but add structured detail.
261
+ images: imagesActual,
262
+ images_expected: imagesExpected,
263
+ images_actual: imagesActual,
264
+ images_failed: imagesFailed.length,
265
+ checksums: 0, // updated after checksum pass below
174
266
  },
267
+ // DB-002: record-level failure detail so operators can investigate
268
+ // without re-opening the directory to discover what's missing.
269
+ images_failed: imagesFailed,
175
270
  };
176
271
  await writeFile(join(exportDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
177
272
 
@@ -188,9 +283,22 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
188
283
  test: split.counts.test,
189
284
  },
190
285
  config_fingerprint: manifest.config_fingerprint,
286
+ warnings,
191
287
  };
192
288
  await writeFile(join(exportDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n');
193
289
 
290
+ // Checksums (computed AFTER manifest+summary so they are included)
291
+ if (profile.include_checksums) {
292
+ const allFiles = await collectFiles(exportDir);
293
+ for (const filePath of allFiles.sort()) {
294
+ const relPath = relative(exportDir, filePath);
295
+ if (relPath === 'checksums.txt') continue;
296
+ const hash = await sha256File(filePath);
297
+ checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
298
+ }
299
+ await writeFile(join(exportDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
300
+ }
301
+
194
302
  return {
195
303
  exportId,
196
304
  recordCount: metadata.length,
@@ -255,5 +363,7 @@ export async function loadExport(projectRoot, exportId) {
255
363
  if (!existsSync(path)) {
256
364
  throw new Error(`Export "${exportId}" not found at ${path}`);
257
365
  }
258
- return JSON.parse(await readFile(path, 'utf-8'));
366
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
367
+ checkManifestVersion(manifest, 'export');
368
+ return manifest;
259
369
  }
@@ -17,6 +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
22
 
21
23
  function generateImplId() {
22
24
  const now = new Date();
@@ -138,9 +140,19 @@ export async function buildImplementationPack(projectRoot, manifestId) {
138
140
  // Write implementation pack
139
141
  const implId = generateImplId();
140
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
+ }
141
151
  await mkdir(implDir, { recursive: true });
142
152
 
143
153
  const implManifest = {
154
+ // DB-001 / DB-006: stamp manifest schema version
155
+ schema_version: SCHEMA_VERSION,
144
156
  implementation_pack_id: implId,
145
157
  created_at: new Date().toISOString(),
146
158
  created_by: 'sdlab-implementation-v1',
@@ -312,5 +324,7 @@ export async function loadImplementationPack(projectRoot, implId) {
312
324
  if (!existsSync(path)) {
313
325
  throw new Error(`Implementation pack "${implId}" not found at ${path}`);
314
326
  }
315
- return JSON.parse(await readFile(path, 'utf-8'));
327
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
328
+ checkManifestVersion(manifest, 'implementation-pack');
329
+ return manifest;
316
330
  }
package/lib/log.js CHANGED
@@ -3,6 +3,13 @@
3
3
  *
4
4
  * Levels: quiet < normal < verbose
5
5
  * Set via --quiet or --verbose flags (parsed by caller).
6
+ *
7
+ * Context prefix:
8
+ * info('generate', 'starting wave 1') → '[generate] starting wave 1'
9
+ * info('starting wave 1') → 'starting wave 1' (no prefix)
10
+ * The first argument is treated as a command/context label when it is a
11
+ * short single-token string with no whitespace; otherwise the call is
12
+ * prefix-less and backwards-compatible.
6
13
  */
7
14
 
8
15
  let level = 'normal'; // quiet | normal | verbose
@@ -15,22 +22,52 @@ export function getLogLevel() {
15
22
  return level;
16
23
  }
17
24
 
25
+ // Accept a leading short-token context label as the first arg.
26
+ // Heuristic: string, no whitespace, ≤ 32 chars, and there is at least
27
+ // one additional argument after it. This keeps existing call sites
28
+ // (where args is typically a full message) unchanged.
29
+ function applyContextPrefix(args) {
30
+ if (args.length < 2) return args;
31
+ const first = args[0];
32
+ if (typeof first !== 'string') return args;
33
+ if (first.length > 32) return args;
34
+ if (/\s/.test(first)) return args;
35
+ // Avoid treating a message that happens to start with a format token as
36
+ // a context: require it to look like an identifier (word chars, dots, dashes).
37
+ if (!/^[A-Za-z][\w.:-]*$/.test(first)) return args;
38
+ return [`[${first}]`, ...args.slice(1)];
39
+ }
40
+
18
41
  /** Always shown (errors, final results). */
19
42
  export function log(...args) {
20
- console.log(...args);
43
+ console.log(...applyContextPrefix(args));
44
+ }
45
+
46
+ /**
47
+ * Artifact / final-output path helper.
48
+ * Always prints regardless of verbosity (survives --quiet) so scripts
49
+ * that produce files have a machine-parseable path on stdout.
50
+ */
51
+ export function result(...args) {
52
+ console.log(...applyContextPrefix(args));
53
+ }
54
+
55
+ /** Always shown (used for final success lines). */
56
+ export function success(...args) {
57
+ console.log(...applyContextPrefix(args));
21
58
  }
22
59
 
23
60
  /** Shown at normal and verbose levels (progress, summaries). */
24
61
  export function info(...args) {
25
- if (level !== 'quiet') console.log(...args);
62
+ if (level !== 'quiet') console.log(...applyContextPrefix(args));
26
63
  }
27
64
 
28
65
  /** Shown only at verbose level (per-record details, debug context). */
29
66
  export function verbose(...args) {
30
- if (level === 'verbose') console.log(...args);
67
+ if (level === 'verbose') console.log(...applyContextPrefix(args));
31
68
  }
32
69
 
33
70
  /** Always shown on stderr. */
34
71
  export function warn(...args) {
35
- console.error('\x1b[33m⚠\x1b[0m', ...args);
72
+ console.error('\x1b[33m⚠\x1b[0m', ...applyContextPrefix(args));
36
73
  }
package/lib/paths.js CHANGED
@@ -3,8 +3,9 @@
3
3
  */
4
4
 
5
5
  import { fileURLToPath } from 'node:url';
6
- import { dirname, join } from 'node:path';
6
+ import { dirname, join, resolve, relative, isAbsolute } from 'node:path';
7
7
  import { existsSync } from 'node:fs';
8
+ import { inputError } from './errors.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = dirname(__filename);
@@ -18,10 +19,18 @@ export const REPO_ROOT = join(__dirname, '..');
18
19
  * @returns {string} absolute path to projects/<name>/
19
20
  */
20
21
  export function getProjectRoot(name) {
22
+ if (!name || typeof name !== 'string') {
23
+ throw inputError(
24
+ 'INPUT_MISSING_PROJECT',
25
+ 'Project name is required.',
26
+ 'Pass --project <name>.'
27
+ );
28
+ }
21
29
  const root = join(REPO_ROOT, 'projects', name);
22
30
  if (!existsSync(root)) {
23
- throw new Error(
24
- `Project "${name}" not found at ${root}.\n` +
31
+ throw inputError(
32
+ 'INPUT_UNKNOWN_PROJECT',
33
+ `Project "${name}" not found at ${root}.`,
25
34
  `Run: sdlab init ${name} --domain <domain>`
26
35
  );
27
36
  }
@@ -37,6 +46,35 @@ export function resolveProjectPath(name, ...segments) {
37
46
  return join(getProjectRoot(name), ...segments);
38
47
  }
39
48
 
49
+ /**
50
+ * Resolve a user-supplied path relative to a project root and verify
51
+ * it does not escape the root (no `..` traversal, no absolute-path escapes
52
+ * out of root). Returns the absolute resolved path.
53
+ *
54
+ * @param {string} projectRoot — absolute project root (trusted)
55
+ * @param {string} userPath — user-supplied, possibly malicious path
56
+ * @param {{baseRoot?:string, flagName?:string}} [opts]
57
+ * @returns {string} absolute path guaranteed to be within baseRoot
58
+ */
59
+ export function resolveSafeProjectPath(projectRoot, userPath, opts = {}) {
60
+ const flagName = opts.flagName || 'path';
61
+ if (userPath == null || userPath === '') {
62
+ throw inputError('INPUT_MISSING_VALUE', `Flag --${flagName} requires a path.`);
63
+ }
64
+ const baseRoot = opts.baseRoot || projectRoot;
65
+ const absBase = resolve(baseRoot);
66
+ const resolved = isAbsolute(userPath) ? resolve(userPath) : resolve(absBase, userPath);
67
+ const rel = relative(absBase, resolved);
68
+ if (rel.startsWith('..') || (isAbsolute(rel) && rel !== '')) {
69
+ throw inputError(
70
+ 'INPUT_PATH_TRAVERSAL',
71
+ `Path "${userPath}" resolves outside of ${absBase}.`,
72
+ `Paths passed via --${flagName} must stay within the project tree.`
73
+ );
74
+ }
75
+ return resolved;
76
+ }
77
+
40
78
  /**
41
79
  * Return the workflow profiles directory for a project root.
42
80
  * @param {string} projectRoot — absolute path to project
@@ -73,19 +73,26 @@ export function renderRefinedPrompt(parentPrompt, delta) {
73
73
  * - Append suppress items that aren't already present
74
74
  */
75
75
  export function renderRefinedNegative(parentNegative, delta) {
76
- const existing = parentNegative.split(',').map(s => s.trim().toLowerCase());
76
+ // PB-007: coerce missing/null/non-string negatives to '' so we don't
77
+ // throw on legacy briefs or hand-edited JSON that dropped the field.
78
+ const parent = typeof parentNegative === 'string' ? parentNegative : '';
79
+ const existing = parent
80
+ .split(',')
81
+ .map(s => s.trim().toLowerCase())
82
+ .filter(s => s.length > 0);
77
83
  const additions = [];
78
84
 
79
85
  for (const item of delta.suppress || []) {
80
- const lower = item.toLowerCase();
81
- // Don't duplicate if already in negative
82
- if (!existing.some(e => e.includes(lower) || lower.includes(e))) {
86
+ const lower = item.toLowerCase().trim();
87
+ // Don't duplicate if already in negative (token-exact match)
88
+ if (lower.length > 0 && !existing.includes(lower)) {
83
89
  additions.push(item);
84
90
  }
85
91
  }
86
92
 
87
- if (additions.length === 0) return parentNegative;
88
- return `${parentNegative}, ${additions.join(', ')}`;
93
+ if (additions.length === 0) return parent;
94
+ if (parent.length === 0) return additions.join(', ');
95
+ return `${parent}, ${additions.join(', ')}`;
89
96
  }
90
97
 
91
98
  // ─── Delta extraction from critique ──────────────────────────────
@@ -166,9 +173,9 @@ export function extractDelta(critique, pickedFilename, cliOverrides = {}) {
166
173
  export function buildRefinedBrief({ parentBrief, critique, pickedCandidate, delta, runId, refineDir }) {
167
174
  const briefId = generateRefinedBriefId(refineDir, parentBrief.brief_id);
168
175
 
169
- // Render updated prompts
170
- const prompt = renderRefinedPrompt(parentBrief.prompt, delta);
171
- const negativePrompt = renderRefinedNegative(parentBrief.negative_prompt, delta);
176
+ // Render updated prompts — PB-007 guards missing fields upstream.
177
+ const prompt = renderRefinedPrompt(parentBrief.prompt ?? '', delta);
178
+ const negativePrompt = renderRefinedNegative(parentBrief.negative_prompt ?? '', delta);
172
179
 
173
180
  const refined = {
174
181
  brief_id: briefId,
@@ -71,10 +71,21 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
71
71
  const skipped = [];
72
72
  const provenanceLines = [];
73
73
 
74
- for (const item of manifest.items) {
75
- // Determine record ID from selection + item index
76
- const idx = String(manifest.items.indexOf(item) + 1).padStart(3, '0');
74
+ // D-010: use entries() — not indexOf() — to avoid O(n^2) behavior and
75
+ // silent duplicate-ID bugs when manifest.items contains structurally
76
+ // equal or repeated references. Assert uniqueness of generated IDs.
77
+ const seenIds = new Set();
78
+ for (const [i, item] of manifest.items.entries()) {
79
+ const idx = String(i + 1).padStart(3, '0');
77
80
  const recordId = `gen_${selectionId}_${idx}`;
81
+ if (seenIds.has(recordId)) {
82
+ throw inputError(
83
+ 'REINGEST_DUPLICATE_RECORD_ID',
84
+ `Duplicate record ID generated during re-ingest: ${recordId}`,
85
+ 'This indicates a bug in record ID derivation; report this along with the selection manifest.',
86
+ );
87
+ }
88
+ seenIds.add(recordId);
78
89
  const recordPath = join(recordsDir, `${recordId}.json`);
79
90
 
80
91
  if (existsSync(recordPath)) {
package/lib/reingest.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * canon-bound like everything else.
8
8
  */
9
9
 
10
- import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
10
+ import { readFile, writeFile, readdir, stat, mkdir, copyFile } from 'node:fs/promises';
11
11
  import { join, basename, extname } from 'node:path';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { loadTrainingManifest } from './training-manifests.js';
@@ -33,6 +33,12 @@ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
33
33
  export async function reingestGenerated(projectRoot, sourcePath, manifestId, options = {}) {
34
34
  const manifest = await loadTrainingManifest(projectRoot, manifestId);
35
35
  const recordsDir = join(projectRoot, 'records');
36
+ // D-020: ensure outputs/candidates/ exists so the record's asset_path
37
+ // contract ("outputs/candidates/<file>") actually points at the image.
38
+ const candidatesDir = join(projectRoot, 'outputs', 'candidates');
39
+ if (!options.dryRun) {
40
+ await mkdir(candidatesDir, { recursive: true });
41
+ }
36
42
 
37
43
  // Find image files in source directory
38
44
  const entries = await readdir(sourcePath);
@@ -80,6 +86,14 @@ export async function reingestGenerated(projectRoot, sourcePath, manifestId, opt
80
86
  record.schema_version = '2.1.0';
81
87
 
82
88
  if (!options.dryRun) {
89
+ // D-020: copy the image into outputs/candidates/ so the record's
90
+ // project-relative asset_path contract is satisfied. Without this,
91
+ // downstream readers (export, adapters) would silently skip the
92
+ // record because the file does not exist at the declared path.
93
+ const destPath = join(candidatesDir, file);
94
+ if (!existsSync(destPath)) {
95
+ await copyFile(filePath, destPath);
96
+ }
83
97
  await writeFile(recordPath, JSON.stringify(record, null, 2) + '\n');
84
98
  }
85
99
 
@@ -63,10 +63,11 @@ export function renderRunMarkdown(manifest) {
63
63
  lines.push('');
64
64
 
65
65
  // Errors
66
- if (manifest.errors.length > 0) {
66
+ const errors = manifest.errors || [];
67
+ if (errors.length > 0) {
67
68
  lines.push('## Errors');
68
69
  lines.push('');
69
- for (const err of manifest.errors) {
70
+ for (const err of errors) {
70
71
  lines.push(`- **Image ${err.index + 1}** (seed ${err.seed}): ${err.error}`);
71
72
  }
72
73
  lines.push('');