@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.
- package/CHANGELOG.md +108 -15
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +71 -0
- package/README.es.md +99 -8
- package/README.fr.md +100 -9
- package/README.hi.md +102 -9
- package/README.it.md +99 -8
- package/README.ja.md +100 -9
- package/README.md +64 -1
- package/README.pt-BR.md +99 -8
- package/README.zh.md +100 -9
- package/SECURITY.md +6 -5
- package/bin/sdlab.js +289 -62
- package/lib/adapters/comfyui-runner.js +11 -15
- package/lib/adapters/diffusers-lora.js +7 -2
- package/lib/adapters/generic-image-caption.js +6 -1
- package/lib/args.js +200 -13
- package/lib/batch-compiler.js +7 -3
- package/lib/batch-runs.js +162 -31
- package/lib/batch-sheet-render.js +5 -2
- package/lib/brief-compiler.js +38 -11
- package/lib/card.js +11 -4
- package/lib/comfyui-output.js +70 -0
- package/lib/comfyui.js +100 -6
- package/lib/config.js +68 -3
- package/lib/deprecation.js +1 -1
- package/lib/eval-pack.js +94 -4
- package/lib/eval-runs.js +42 -3
- package/lib/export.js +144 -34
- package/lib/implementation-packs.js +15 -1
- package/lib/log.js +41 -4
- package/lib/paths.js +41 -3
- package/lib/refine-briefs.js +16 -9
- package/lib/reingest-selected.js +14 -3
- package/lib/reingest.js +15 -1
- package/lib/run-summary.js +3 -2
- package/lib/runtime-runs.js +59 -9
- package/lib/selections.js +54 -2
- package/lib/snapshot.js +100 -7
- package/lib/split.js +175 -17
- package/lib/training-adapters.js +79 -0
- package/lib/training-manifests.js +17 -3
- package/lib/training-packages.js +29 -28
- package/lib/training-profiles.js +10 -0
- package/package.json +27 -3
- package/scripts/batch-generate.js +72 -20
- package/scripts/batch-sheet.js +3 -3
- package/scripts/batch-show.js +3 -3
- package/scripts/brief-compile.js +8 -8
- package/scripts/brief-show.js +4 -3
- package/scripts/canon-bind.js +7 -9
- package/scripts/card.js +22 -25
- package/scripts/compare.js +38 -33
- package/scripts/critique-show.js +3 -3
- package/scripts/critique.js +3 -3
- package/scripts/curate.js +91 -46
- package/scripts/doctor.js +3 -5
- package/scripts/eligibility.js +17 -14
- package/scripts/eval-pack.js +13 -11
- package/scripts/eval-run.js +30 -29
- package/scripts/export.js +27 -31
- package/scripts/generate-controlnet.js +31 -17
- package/scripts/generate-identity.js +37 -20
- package/scripts/generate-ipadapter.js +22 -16
- package/scripts/generate.js +96 -29
- package/scripts/implementation-pack.js +19 -14
- package/scripts/init.js +115 -17
- package/scripts/painterly.js +8 -10
- package/scripts/refine.js +3 -3
- package/scripts/reingest-selected.js +3 -3
- package/scripts/reingest.js +33 -20
- package/scripts/run-generate.js +4 -4
- package/scripts/run-list.js +3 -3
- package/scripts/run-show.js +3 -3
- package/scripts/select.js +5 -4
- package/scripts/selection-show.js +3 -3
- package/scripts/snapshot.js +22 -24
- package/scripts/split.js +26 -31
- package/scripts/training-manifest.js +29 -24
- package/scripts/training-package.js +24 -20
- package/scripts/training-profile.js +13 -8
- package/scripts/workflow-list.js +2 -2
- package/scripts/workflow-show.js +4 -3
- package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/architecture/terminology.json +10 -8
- package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/character-design/project.json +4 -2
- package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/creature-design/project.json +4 -2
- package/templates/domains/creature-design/terminology.json +1 -0
- package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/game-art/project.json +4 -2
- package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/vehicle-mech/project.json +4 -2
- package/scripts/bulk-curate-wave2-5.js +0 -138
- package/scripts/bulk-curate-waves11-18.js +0 -413
- package/scripts/curate-wave25.js +0 -209
- package/scripts/migrate-records.js +0 -141
- 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:
|
|
93
|
-
filename:
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
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:
|
|
253
|
+
config_fingerprint: snapshot.config_fingerprint,
|
|
169
254
|
profile,
|
|
255
|
+
warnings,
|
|
170
256
|
counts: {
|
|
171
257
|
records: metadata.length,
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
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
|
package/lib/refine-briefs.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
88
|
-
return
|
|
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,
|
package/lib/reingest-selected.js
CHANGED
|
@@ -71,10 +71,21 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
|
|
|
71
71
|
const skipped = [];
|
|
72
72
|
const provenanceLines = [];
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
package/lib/run-summary.js
CHANGED
|
@@ -63,10 +63,11 @@ export function renderRunMarkdown(manifest) {
|
|
|
63
63
|
lines.push('');
|
|
64
64
|
|
|
65
65
|
// Errors
|
|
66
|
-
|
|
66
|
+
const errors = manifest.errors || [];
|
|
67
|
+
if (errors.length > 0) {
|
|
67
68
|
lines.push('## Errors');
|
|
68
69
|
lines.push('');
|
|
69
|
-
for (const err of
|
|
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('');
|