@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
@@ -10,11 +10,36 @@
10
10
  * downloads the output PNG, and writes a provenance record to records/.
11
11
  */
12
12
 
13
- import { readFile, writeFile, mkdir } from "node:fs/promises";
13
+ import { writeFile, mkdir } from "node:fs/promises";
14
+ import { existsSync } from "node:fs";
14
15
  import { join } from "node:path";
15
- import { getProjectName } from "../lib/args.js";
16
- import { REPO_ROOT } from "../lib/paths.js";
16
+ import { parseArgs, getProjectName } from "../lib/args.js";
17
+ import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
18
+ import { readJsonFile } from "../lib/config.js";
19
+ import { runtimeError, handleCliError } from "../lib/errors.js";
17
20
  import { comfyHealth, submitAndWait, downloadImage } from "../lib/comfyui.js";
21
+ import { pickOutputImage } from "../lib/comfyui-output.js";
22
+ import { info, result } from "../lib/log.js";
23
+
24
+ /**
25
+ * Format milliseconds as a human ETA string like "6m 12s" or "42s".
26
+ */
27
+ function formatEta(ms) {
28
+ if (!Number.isFinite(ms) || ms < 0) return '—';
29
+ const totalSec = Math.round(ms / 1000);
30
+ if (totalSec < 60) return `${totalSec}s`;
31
+ const m = Math.floor(totalSec / 60);
32
+ const s = totalSec % 60;
33
+ return `${m}m ${s}s`;
34
+ }
35
+
36
+ /**
37
+ * COMFY_URL precedence:
38
+ * 1. process.env.COMFY_URL (explicit user env)
39
+ * 2. project defaults.comfy_url (set by `sdlab project doctor`)
40
+ * 3. built-in default http://127.0.0.1:8188
41
+ * The adapter (lib/adapters/comfyui-runner.js) applies the same precedence.
42
+ */
18
43
 
19
44
  function buildWorkflow(prompt, negativePrompt, checkpoint, loras, seed, steps, cfg, sampler, scheduler, width, height, ipAdapterConfig) {
20
45
  const nodes = {};
@@ -157,11 +182,21 @@ export async function run(argv = process.argv.slice(2)) {
157
182
  const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
158
183
  const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
159
184
 
160
- const packPath = argv.find((a) => !a.startsWith("--")) || "inputs/prompts/rpg-icons-lane1.json";
161
- const dryRun = argv.includes("--dry-run");
185
+ const parsed = parseArgs(argv, {
186
+ flags: {
187
+ project: { type: 'string' },
188
+ game: { type: 'string' },
189
+ 'dry-run': { type: 'boolean' },
190
+ resume: { type: 'boolean' },
191
+ },
192
+ deprecated: { game: 'project' },
193
+ });
194
+ const packPath = parsed.positionals[0] || "inputs/prompts/rpg-icons-lane1.json";
195
+ const dryRun = parsed.flags['dry-run'] === true;
196
+ const resume = parsed.flags['resume'] === true;
162
197
 
163
- const fullPackPath = join(GAME_ROOT, packPath);
164
- const pack = JSON.parse(await readFile(fullPackPath, "utf-8"));
198
+ const fullPackPath = resolveSafeProjectPath(GAME_ROOT, packPath, { flagName: 'pack-path' });
199
+ const pack = await readJsonFile(fullPackPath, { requiredFields: ['lane', 'subjects', 'variations', 'defaults'] });
165
200
 
166
201
  console.log(`\x1b[1mstyle-dataset-lab\x1b[0m generate`);
167
202
  console.log(` Lane: ${pack.lane}`);
@@ -173,7 +208,7 @@ export async function run(argv = process.argv.slice(2)) {
173
208
  if (!dryRun) {
174
209
  const online = await comfyHealth(COMFY_URL);
175
210
  if (!online) {
176
- throw new Error("ComfyUI not reachable at " + COMFY_URL);
211
+ throw runtimeError('RUNTIME_COMFY_UNREACHABLE', "ComfyUI not reachable at " + COMFY_URL, 'Start ComfyUI or set COMFY_URL to the right host.');
177
212
  }
178
213
  console.log("\x1b[32m✓\x1b[0m ComfyUI online");
179
214
  }
@@ -183,7 +218,10 @@ export async function run(argv = process.argv.slice(2)) {
183
218
 
184
219
  const d = pack.defaults;
185
220
  let generated = 0;
221
+ let errors = 0;
222
+ let skipped = 0;
186
223
  const totalExpected = pack.subjects.length * pack.variations.length;
224
+ const runStartMs = Date.now();
187
225
 
188
226
  for (const subject of pack.subjects) {
189
227
  for (const variation of pack.variations) {
@@ -192,6 +230,20 @@ export async function run(argv = process.argv.slice(2)) {
192
230
  const fullPrompt = `${pack.style_prefix}, ${subject.prompt}`;
193
231
  const loras = variation.loras || [];
194
232
 
233
+ // --resume: skip subjects whose record + image are both already on disk.
234
+ // Seeds are deterministic per (base_seed, generated, variation), so
235
+ // increment `generated` for skipped slots to keep downstream seeds stable.
236
+ if (resume && !dryRun) {
237
+ const recordPath = join(GAME_ROOT, `records/${assetId}.json`);
238
+ const imagePath = join(GAME_ROOT, `outputs/candidates/${assetId}.png`);
239
+ if (existsSync(recordPath) && existsSync(imagePath)) {
240
+ console.log(` [${generated + 1}/${totalExpected}] ${assetId} \x1b[2m(resumed — skipped)\x1b[0m`);
241
+ generated++;
242
+ skipped++;
243
+ continue;
244
+ }
245
+ }
246
+
195
247
  console.log(` [${generated + 1}/${totalExpected}] ${assetId} (seed: ${seed}, loras: ${loras.length})`);
196
248
 
197
249
  if (dryRun) {
@@ -200,7 +252,7 @@ export async function run(argv = process.argv.slice(2)) {
200
252
  }
201
253
 
202
254
  const startMs = Date.now();
203
- const { nodes } = buildWorkflow(
255
+ const { nodes, saveNodeId } = buildWorkflow(
204
256
  fullPrompt, pack.negative,
205
257
  d.checkpoint, loras, seed,
206
258
  d.steps, d.cfg, d.sampler, d.scheduler,
@@ -208,29 +260,20 @@ export async function run(argv = process.argv.slice(2)) {
208
260
  );
209
261
 
210
262
  try {
211
- const result = await submitAndWait(nodes, COMFY_URL);
263
+ const submitResult = await submitAndWait(nodes, COMFY_URL);
212
264
  const elapsed = Date.now() - startMs;
213
265
 
214
- // Find output image
215
- const outputs = result.outputs || {};
216
- let imageFile = null;
217
- let imageSubfolder = "";
218
- for (const nodeOut of Object.values(outputs)) {
219
- if (nodeOut.images && nodeOut.images.length > 0) {
220
- imageFile = nodeOut.images[0].filename;
221
- imageSubfolder = nodeOut.images[0].subfolder || "";
222
- break;
223
- }
224
- }
225
-
226
- if (!imageFile) {
266
+ // Prefer the explicit SaveImage node from buildWorkflow; fall back
267
+ // to highest numeric node id (see lib/comfyui-output.js).
268
+ const picked = pickOutputImage(submitResult.outputs, { preferNodeId: saveNodeId });
269
+ if (!picked) {
227
270
  console.log(` \x1b[33m⚠\x1b[0m No output image found`);
228
271
  generated++;
229
272
  continue;
230
273
  }
231
274
 
232
275
  // Download and save
233
- const imgData = await downloadImage(imageFile, imageSubfolder, COMFY_URL);
276
+ const imgData = await downloadImage(picked.filename, picked.subfolder, COMFY_URL);
234
277
  const destPath = `outputs/candidates/${assetId}.png`;
235
278
  await writeFile(join(GAME_ROOT, destPath), imgData);
236
279
 
@@ -272,21 +315,45 @@ export async function run(argv = process.argv.slice(2)) {
272
315
  console.log(` \x1b[32m✓\x1b[0m ${destPath} (${elapsed}ms, ${imgData.length} bytes)`);
273
316
  } catch (err) {
274
317
  console.log(` \x1b[31m✗\x1b[0m ${err.message}`);
318
+ errors++;
275
319
  }
276
320
 
277
321
  generated++;
322
+
323
+ // ETA every 5 items (not every item — too noisy for long runs).
324
+ if (!dryRun && generated > 0 && generated % 5 === 0 && generated < totalExpected) {
325
+ const elapsedTotal = Date.now() - runStartMs;
326
+ const avgMs = elapsedTotal / generated;
327
+ const remaining = totalExpected - generated;
328
+ const etaMs = avgMs * remaining;
329
+ info('generate', `progress ${generated}/${totalExpected} — avg ${formatEta(avgMs)}/item, ETA ~${formatEta(etaMs)}`);
330
+ }
278
331
  }
279
332
  }
280
333
 
281
334
  console.log("");
282
- console.log(`\x1b[32m✓\x1b[0m Generated ${generated} candidates`);
335
+ const succeeded = generated - errors - skipped;
336
+ const totalElapsedMs = Date.now() - runStartMs;
337
+ const skippedSuffix = skipped > 0 ? `, ${skipped} resumed` : '';
338
+ console.log(`\x1b[32m✓\x1b[0m Generated ${succeeded} candidates (${errors} errors${skippedSuffix})`);
339
+ if (!dryRun && generated > skipped) {
340
+ const ran = generated - skipped;
341
+ const avgMs = totalElapsedMs / ran;
342
+ console.log(` Total: ${formatEta(totalElapsedMs)} — avg ${formatEta(avgMs)}/item over ${ran} new`);
343
+ }
283
344
  if (dryRun) console.log(" (dry run — no images produced)");
345
+ // Always print the output directory so scripted pipelines can parse it,
346
+ // even under --quiet.
347
+ if (!dryRun && succeeded > 0) {
348
+ result(`Candidates: ${join(GAME_ROOT, 'outputs/candidates')}`);
349
+ result(`Records: ${join(GAME_ROOT, 'records')}`);
350
+ }
351
+ if (!dryRun && errors > 0 && succeeded === 0) {
352
+ throw runtimeError('RUNTIME_ALL_FAILED', `All ${totalExpected} generation attempts failed.`);
353
+ }
284
354
  }
285
355
 
286
356
  // Direct execution guard
287
357
  if (process.argv[1] && (process.argv[1].endsWith('generate.js') || process.argv[1].endsWith('generate'))) {
288
- run().catch((err) => {
289
- console.error(err.message || err);
290
- process.exit(1);
291
- });
358
+ run().catch(handleCliError);
292
359
  }
@@ -10,25 +10,30 @@
10
10
  */
11
11
 
12
12
  import { join } from 'node:path';
13
- import { getProjectName } from '../lib/args.js';
13
+ import { parseArgs, getProjectName } from '../lib/args.js';
14
14
  import { REPO_ROOT } from '../lib/paths.js';
15
+ import { inputError, handleCliError } from '../lib/errors.js';
15
16
  import { listTrainingManifests } from '../lib/training-manifests.js';
16
17
  import { buildImplementationPack, listImplementationPacks, loadImplementationPack } from '../lib/implementation-packs.js';
17
18
 
18
19
  export async function run(argv = process.argv.slice(2)) {
19
- const projectName = getProjectName(argv);
20
+ const { flags, positionals } = parseArgs(argv, {
21
+ flags: {
22
+ project: { type: 'string' },
23
+ manifest: { type: 'string' },
24
+ },
25
+ deprecated: { game: 'project' },
26
+ });
27
+
28
+ const projectName = flags.project || getProjectName(argv);
20
29
  const projectRoot = join(REPO_ROOT, 'projects', projectName);
21
- const subcommand = argv.find(a => !a.startsWith('--') && !a.startsWith('impl-') && !a.startsWith('tm-')) || 'list';
22
- const args = argv.filter(a => a !== subcommand);
30
+ const subcommand = positionals.find(a => !a.startsWith('impl-') && !a.startsWith('tm-')) || 'list';
23
31
 
24
32
  if (subcommand === 'build') {
25
- let manifestId;
26
- const manifestIdx = args.indexOf('--manifest');
27
- if (manifestIdx >= 0) {
28
- manifestId = args[manifestIdx + 1];
29
- } else {
33
+ let manifestId = flags.manifest;
34
+ if (!manifestId) {
30
35
  const manifests = await listTrainingManifests(projectRoot);
31
- if (manifests.length === 0) throw new Error('No training manifests. Run: sdlab training-manifest create');
36
+ if (manifests.length === 0) throw inputError('INPUT_NO_MANIFEST', 'No training manifests. Run: sdlab training-manifest create');
32
37
  manifestId = manifests[manifests.length - 1].id;
33
38
  }
34
39
 
@@ -44,8 +49,8 @@ export async function run(argv = process.argv.slice(2)) {
44
49
  console.log(` Subject continuity: ${result.subjects}`);
45
50
 
46
51
  } else if (subcommand === 'show') {
47
- const implId = args.find(a => a.startsWith('impl-'));
48
- if (!implId) throw new Error('Usage: sdlab implementation-pack show <impl-id>');
52
+ const implId = positionals.find(a => a.startsWith('impl-'));
53
+ if (!implId) throw inputError('INPUT_MISSING_ARGS', 'Usage: sdlab implementation-pack show <impl-id>');
49
54
 
50
55
  const pack = await loadImplementationPack(projectRoot, implId);
51
56
  console.log(`\x1b[1mImplementation pack\x1b[0m — ${implId}\n`);
@@ -69,10 +74,10 @@ export async function run(argv = process.argv.slice(2)) {
69
74
  }
70
75
 
71
76
  } else {
72
- throw new Error(`Unknown subcommand: ${subcommand}. Use: build, show, list`);
77
+ throw inputError('INPUT_BAD_SUBCOMMAND', `Unknown subcommand: ${subcommand}. Use: build, show, list`);
73
78
  }
74
79
  }
75
80
 
76
81
  if (process.argv[1] && (process.argv[1].endsWith('implementation-pack.js') || process.argv[1].endsWith('implementation-pack'))) {
77
- run().catch((err) => { console.error(err.message || err); process.exit(1); });
82
+ run().catch(handleCliError);
78
83
  }
package/scripts/init.js CHANGED
@@ -13,6 +13,7 @@ import { mkdir, readdir, readFile, writeFile, copyFile } from 'node:fs/promises'
13
13
  import { join, basename } from 'node:path';
14
14
  import { existsSync } from 'node:fs';
15
15
  import { REPO_ROOT } from '../lib/paths.js';
16
+ import { inputError, handleCliError } from '../lib/errors.js';
16
17
 
17
18
  const TEMPLATES_DIR = join(REPO_ROOT, 'templates');
18
19
  const PROJECTS_DIR = join(REPO_ROOT, 'projects');
@@ -82,15 +83,28 @@ export async function run(argv = process.argv.slice(2)) {
82
83
  }
83
84
 
84
85
  if (!isValidName(projectName)) {
85
- throw new Error(
86
- `Invalid project name "${projectName}".\n` +
86
+ throw inputError(
87
+ 'INPUT_BAD_NAME',
88
+ `Invalid project name "${projectName}".`,
87
89
  `Use lowercase letters, numbers, and hyphens (e.g. "my-project").`
88
90
  );
89
91
  }
90
92
 
93
+ // Validate --domain to prevent path traversal via template lookup.
94
+ if (!isValidName(domain)) {
95
+ throw inputError(
96
+ 'INPUT_BAD_DOMAIN',
97
+ `Invalid --domain "${domain}".`,
98
+ `Use lowercase letters, numbers, and hyphens (e.g. "character-design").`
99
+ );
100
+ }
101
+
91
102
  const projectDir = join(PROJECTS_DIR, projectName);
92
103
  if (existsSync(projectDir)) {
93
- throw new Error(`Project "${projectName}" already exists at ${projectDir}`);
104
+ throw inputError(
105
+ 'INPUT_PROJECT_EXISTS',
106
+ `Project "${projectName}" already exists at ${projectDir}`
107
+ );
94
108
  }
95
109
 
96
110
  // Resolve domain template source
@@ -99,8 +113,9 @@ export async function run(argv = process.argv.slice(2)) {
99
113
  domainDir = join(TEMPLATES_DIR, 'domains', domain);
100
114
  if (!existsSync(domainDir)) {
101
115
  const domains = await listDomains();
102
- throw new Error(
103
- `Domain "${domain}" not found.\n` +
116
+ throw inputError(
117
+ 'INPUT_UNKNOWN_DOMAIN',
118
+ `Domain "${domain}" not found.`,
104
119
  `Available: generic, ${domains.join(', ')}`
105
120
  );
106
121
  }
@@ -206,18 +221,30 @@ export async function run(argv = process.argv.slice(2)) {
206
221
 
207
222
  // Copy canon markdown templates
208
223
  const canonTemplates = ['constitution.md', 'review-rubric.md'];
224
+ const wroteCanon = {};
209
225
  for (const file of canonTemplates) {
210
226
  const src = join(TEMPLATES_DIR, 'canon', file);
211
227
  if (existsSync(src)) {
212
228
  await copyFile(src, join(projectDir, 'canon', file));
229
+ wroteCanon[file] = true;
213
230
  console.log(` \x1b[32m✓\x1b[0m canon/${file}`);
214
231
  }
215
232
  }
216
233
 
217
- // Copy example prompt pack
218
- const examplePack = join(TEMPLATES_DIR, 'inputs', 'prompts', 'example-wave.json');
219
- if (existsSync(examplePack)) {
234
+ // Copy example prompt pack — prefer per-domain example (subjects match domain lane id_patterns),
235
+ // fall back to legacy global template if it exists (pre-v3.0.1 installs may still ship it).
236
+ let wroteExamplePack = false;
237
+ const domainExamplePack = domainDir
238
+ ? join(domainDir, 'inputs', 'prompts', 'example-wave.json')
239
+ : null;
240
+ const globalExamplePack = join(TEMPLATES_DIR, 'inputs', 'prompts', 'example-wave.json');
241
+ const examplePack =
242
+ domainExamplePack && existsSync(domainExamplePack) ? domainExamplePack
243
+ : existsSync(globalExamplePack) ? globalExamplePack
244
+ : null;
245
+ if (examplePack) {
220
246
  await copyFile(examplePack, join(projectDir, 'inputs', 'prompts', 'example-wave.json'));
247
+ wroteExamplePack = true;
221
248
  console.log(` \x1b[32m✓\x1b[0m inputs/prompts/example-wave.json`);
222
249
  }
223
250
 
@@ -236,21 +263,92 @@ export async function run(argv = process.argv.slice(2)) {
236
263
  }
237
264
  }
238
265
 
266
+ // Write a starter README.md inside the project so users have a home base
267
+ // with copy-pasteable next commands and a place for their notes.
268
+ const readmeLines = [
269
+ `# ${projectName}`,
270
+ '',
271
+ `A \`style-dataset-lab\` project — domain: **${domain}**.`,
272
+ '',
273
+ '## Quick commands',
274
+ '',
275
+ '```bash',
276
+ `# Validate that the scaffold is healthy`,
277
+ `sdlab project doctor --project ${projectName}`,
278
+ '',
279
+ ];
280
+ if (wroteExamplePack) {
281
+ readmeLines.push(
282
+ `# Generate candidates from the example prompt pack`,
283
+ `sdlab generate inputs/prompts/example-wave.json --project ${projectName}`,
284
+ ''
285
+ );
286
+ } else {
287
+ readmeLines.push(
288
+ `# Generate candidates (add a prompt pack under inputs/prompts/ first)`,
289
+ `sdlab generate <pack-path> --project ${projectName}`,
290
+ ''
291
+ );
292
+ }
293
+ readmeLines.push(
294
+ `# Curate a candidate (approved | rejected | borderline)`,
295
+ `sdlab curate <asset_id> approved "reads clean" --project ${projectName}`,
296
+ '',
297
+ `# Bind approved records to constitution rules`,
298
+ `sdlab canon-bind --project ${projectName}`,
299
+ '',
300
+ `# Create a frozen dataset snapshot`,
301
+ `sdlab snapshot create --project ${projectName}`,
302
+ '```',
303
+ '',
304
+ '## Project layout',
305
+ '',
306
+ '- `project.json` — defaults (checkpoint, width, height, sampler)',
307
+ '- `constitution.json` — machine-readable style rules',
308
+ '- `canon/` — human-authored style reference (constitution.md, review-rubric.md)',
309
+ '- `inputs/prompts/` — generation prompt packs',
310
+ '- `outputs/candidates/` — raw generated PNGs',
311
+ '- `outputs/approved/` · `outputs/rejected/` · `outputs/borderline/` — curated outputs',
312
+ '- `records/` — provenance + judgment records (one JSON per candidate)',
313
+ '- `snapshots/` · `splits/` · `exports/` — frozen dataset artifacts',
314
+ '',
315
+ '## Docs',
316
+ '',
317
+ '- Handbook: https://mcp-tool-shop-org.github.io/style-dataset-lab/',
318
+ '- CLI reference: `sdlab --help` (or `sdlab <cmd> --help` for any command)',
319
+ '',
320
+ '## TODO',
321
+ '',
322
+ '- [ ] Fill in `canon/constitution.md` with your style rules',
323
+ '- [ ] Encode those rules in `constitution.json` for canon-bind',
324
+ '- [ ] Add at least one prompt pack under `inputs/prompts/`',
325
+ '- [ ] Run `sdlab project doctor` and fix any warnings',
326
+ ''
327
+ );
328
+ await writeFile(join(projectDir, 'README.md'), readmeLines.join('\n'));
329
+ console.log(` \x1b[32m✓\x1b[0m README.md`);
330
+
239
331
  console.log('');
240
332
  console.log(`\x1b[32m✓\x1b[0m Project "${projectName}" initialized`);
241
333
  console.log('');
242
334
  console.log('Next steps:');
243
- console.log(` 1. Edit ${projectName}/canon/constitution.md — define your visual rules`);
244
- console.log(` 2. Edit ${projectName}/constitution.json — encode rules for canon-bind`);
245
- console.log(` 3. Create prompt packs in ${projectName}/inputs/prompts/`);
246
- console.log(` 4. Run: sdlab generate <pack> --project ${projectName}`);
247
- console.log(` 5. Run: sdlab project doctor --project ${projectName}`);
335
+ let step = 1;
336
+ if (wroteCanon['constitution.md']) {
337
+ console.log(` ${step++}. Edit ${projectName}/canon/constitution.md — define your visual rules`);
338
+ }
339
+ console.log(` ${step++}. Edit ${projectName}/constitution.json — encode rules for canon-bind`);
340
+ if (wroteExamplePack) {
341
+ console.log(` ${step++}. Run: sdlab generate inputs/prompts/example-wave.json --project ${projectName}`);
342
+ } else {
343
+ console.log(` ${step++}. Create prompt packs under ${projectName}/inputs/prompts/`);
344
+ console.log(` ${step++}. Run: sdlab generate <pack> --project ${projectName}`);
345
+ }
346
+ console.log(` ${step++}. Run: sdlab project doctor --project ${projectName}`);
347
+ console.log('');
348
+ console.log(`See ${projectName}/README.md for the full quick-start.`);
248
349
  }
249
350
 
250
351
  // Direct execution guard
251
352
  if (process.argv[1] && (process.argv[1].endsWith('init.js') || process.argv[1].endsWith('init'))) {
252
- run().catch((err) => {
253
- console.error(err.message || err);
254
- process.exit(1);
255
- });
353
+ run().catch(handleCliError);
256
354
  }
@@ -18,8 +18,9 @@
18
18
  import { writeFile, mkdir, readdir } from "node:fs/promises";
19
19
  import { join } from "node:path";
20
20
  import { existsSync } from "node:fs";
21
- import { getProjectName } from "../lib/args.js";
22
- import { REPO_ROOT } from "../lib/paths.js";
21
+ import { getProjectName, parseNumberFlag } from "../lib/args.js";
22
+ import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
23
+ import { runtimeError, handleCliError } from "../lib/errors.js";
23
24
  import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
24
25
 
25
26
  // ── Config ──
@@ -156,11 +157,11 @@ export async function run(argv = process.argv.slice(2)) {
156
157
 
157
158
  for (let i = 0; i < argv.length; i++) {
158
159
  if (argv[i] === "--source" && argv[i + 1]) sourceDir = argv[++i];
159
- if (argv[i] === "--limit" && argv[i + 1]) limit = parseInt(argv[++i]);
160
- if (argv[i] === "--offset" && argv[i + 1]) offset = parseInt(argv[++i]);
160
+ else if (argv[i] === "--limit" && argv[i + 1]) limit = parseNumberFlag('limit', argv[++i], { int: true, min: 1 });
161
+ else if (argv[i] === "--offset" && argv[i + 1]) offset = parseNumberFlag('offset', argv[++i], { int: true, min: 0 });
161
162
  }
162
163
 
163
- const fullSourceDir = join(GAME_ROOT, sourceDir);
164
+ const fullSourceDir = resolveSafeProjectPath(GAME_ROOT, sourceDir, { flagName: 'source' });
164
165
  const outDir = join(GAME_ROOT, "outputs/painterly");
165
166
  await mkdir(outDir, { recursive: true });
166
167
 
@@ -195,7 +196,7 @@ export async function run(argv = process.argv.slice(2)) {
195
196
  if (!dryRun) {
196
197
  const online = await comfyHealth(COMFY_URL);
197
198
  if (!online) {
198
- throw new Error("ComfyUI not reachable at " + COMFY_URL);
199
+ throw runtimeError('RUNTIME_COMFY_UNREACHABLE', "ComfyUI not reachable at " + COMFY_URL);
199
200
  }
200
201
  console.log("\x1b[32m✓\x1b[0m ComfyUI online");
201
202
  }
@@ -263,8 +264,5 @@ export async function run(argv = process.argv.slice(2)) {
263
264
 
264
265
  // Direct execution guard
265
266
  if (process.argv[1] && (process.argv[1].endsWith('painterly.js') || process.argv[1].endsWith('painterly'))) {
266
- run().catch((err) => {
267
- console.error(err.message || err);
268
- process.exit(1);
269
- });
267
+ run().catch(handleCliError);
270
268
  }
package/scripts/refine.js CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
- import { parseArgs } from '../lib/args.js';
14
+ import { parseArgs, getProjectName } from '../lib/args.js';
15
15
  import { getProjectRoot, getRunsDir } from '../lib/paths.js';
16
16
  import { loadCritique } from '../lib/critique-engine.js';
17
17
  import {
@@ -25,7 +25,7 @@ import { info } from '../lib/log.js';
25
25
  export async function run(argv = process.argv.slice(2)) {
26
26
  const { flags } = parseArgs(argv, {
27
27
  flags: {
28
- project: { type: 'string', default: 'star-freight' },
28
+ project: { type: 'string' },
29
29
  run: { type: 'string' },
30
30
  pick: { type: 'string' },
31
31
  preserve: { type: 'string' },
@@ -49,7 +49,7 @@ export async function run(argv = process.argv.slice(2)) {
49
49
  return;
50
50
  }
51
51
 
52
- const projectName = flags.project;
52
+ const projectName = flags.project || getProjectName(argv);
53
53
  const projectRoot = getProjectRoot(projectName);
54
54
  const runId = flags.run;
55
55
  const pickedFilename = flags.pick;
@@ -8,7 +8,7 @@
8
8
  * sdlab reingest selected --selection selection_2026-04-16_001 --dry-run
9
9
  */
10
10
 
11
- import { parseArgs } from '../lib/args.js';
11
+ import { parseArgs, getProjectName } from '../lib/args.js';
12
12
  import { getProjectRoot } from '../lib/paths.js';
13
13
  import { reingestSelection } from '../lib/reingest-selected.js';
14
14
  import { info } from '../lib/log.js';
@@ -16,7 +16,7 @@ import { info } from '../lib/log.js';
16
16
  export async function run(argv = process.argv.slice(2)) {
17
17
  const { flags } = parseArgs(argv, {
18
18
  flags: {
19
- project: { type: 'string', default: 'star-freight' },
19
+ project: { type: 'string' },
20
20
  selection: { type: 'string' },
21
21
  'dry-run': { type: 'boolean' },
22
22
  json: { type: 'boolean' },
@@ -34,7 +34,7 @@ export async function run(argv = process.argv.slice(2)) {
34
34
  return;
35
35
  }
36
36
 
37
- const projectName = flags.project;
37
+ const projectName = flags.project || getProjectName(argv);
38
38
  const projectRoot = getProjectRoot(projectName);
39
39
  const dryRun = flags['dry-run'] || false;
40
40
 
@@ -5,38 +5,51 @@
5
5
  *
6
6
  * Usage:
7
7
  * sdlab reingest generated --source <dir> --manifest <id> [--dry-run] [--project <name>]
8
+ * sdlab reingest selected --selection <id> [--project <name>]
8
9
  * sdlab reingest audit [--project <name>]
9
10
  */
10
11
 
11
12
  import { join } from 'node:path';
12
- import { getProjectName } from '../lib/args.js';
13
- import { REPO_ROOT } from '../lib/paths.js';
13
+ import { parseArgs, getProjectName } from '../lib/args.js';
14
+ import { REPO_ROOT, resolveSafeProjectPath } from '../lib/paths.js';
15
+ import { inputError, handleCliError } from '../lib/errors.js';
14
16
  import { reingestGenerated, auditReingest } from '../lib/reingest.js';
15
17
 
16
18
  export async function run(argv = process.argv.slice(2)) {
17
- const projectName = getProjectName(argv);
19
+ const { flags, positionals } = parseArgs(argv, {
20
+ flags: {
21
+ project: { type: 'string' },
22
+ source: { type: 'string' },
23
+ manifest: { type: 'string' },
24
+ selection: { type: 'string' },
25
+ 'dry-run': { type: 'boolean', default: false },
26
+ },
27
+ deprecated: { game: 'project' },
28
+ allowUnknown: true, // forward unknowns to delegated 'selected' path
29
+ });
30
+
31
+ const projectName = flags.project || getProjectName(argv);
18
32
  const projectRoot = join(REPO_ROOT, 'projects', projectName);
19
- const subcommand = argv.find(a => !a.startsWith('--')) || 'audit';
20
- const args = argv.filter(a => a !== subcommand);
33
+ const subcommand = positionals[0] || 'audit';
21
34
 
22
35
  if (subcommand === 'generated') {
23
- const sourceIdx = args.indexOf('--source');
24
- if (sourceIdx < 0) throw new Error('--source <dir> is required (directory of generated images)');
25
- const sourcePath = args[sourceIdx + 1];
26
-
27
- const manifestIdx = args.indexOf('--manifest');
28
- if (manifestIdx < 0) throw new Error('--manifest <id> is required (training manifest that produced the outputs)');
29
- const manifestId = args[manifestIdx + 1];
36
+ if (!flags.source) {
37
+ throw inputError('INPUT_MISSING_FLAG', '--source <dir> is required (directory of generated images)');
38
+ }
39
+ if (!flags.manifest) {
40
+ throw inputError('INPUT_MISSING_FLAG', '--manifest <id> is required (training manifest that produced the outputs)');
41
+ }
30
42
 
31
- const dryRun = args.includes('--dry-run');
43
+ const safeSource = resolveSafeProjectPath(projectRoot, flags.source, { baseRoot: REPO_ROOT, flagName: 'source' });
44
+ const dryRun = flags['dry-run'] || flags.dryRun;
32
45
 
33
46
  console.log(`\x1b[1msdlab reingest generated\x1b[0m — ${projectName}`);
34
- console.log(` Source: ${sourcePath}`);
35
- console.log(` Manifest: ${manifestId}`);
47
+ console.log(` Source: ${flags.source}`);
48
+ console.log(` Manifest: ${flags.manifest}`);
36
49
  if (dryRun) console.log(` Mode: DRY RUN (no records written)`);
37
50
  console.log('');
38
51
 
39
- const result = await reingestGenerated(projectRoot, sourcePath, manifestId, { dryRun });
52
+ const result = await reingestGenerated(projectRoot, safeSource, flags.manifest, { dryRun });
40
53
 
41
54
  console.log(` \x1b[32m✓\x1b[0m Created: ${result.created} records`);
42
55
  if (result.skipped > 0) {
@@ -51,9 +64,9 @@ export async function run(argv = process.argv.slice(2)) {
51
64
  }
52
65
 
53
66
  } else if (subcommand === 'selected') {
54
- // Delegate to reingest-selected.js
67
+ // Delegate to reingest-selected.js — pass the full argv so it can re-parse
55
68
  const { run: runSelected } = await import('./reingest-selected.js');
56
- await runSelected(args);
69
+ await runSelected(argv.slice(argv.indexOf(subcommand) + 1));
57
70
  return;
58
71
 
59
72
  } else if (subcommand === 'audit') {
@@ -83,10 +96,10 @@ export async function run(argv = process.argv.slice(2)) {
83
96
  }
84
97
 
85
98
  } else {
86
- throw new Error(`Unknown subcommand: ${subcommand}. Use: generated, selected, audit`);
99
+ throw inputError('INPUT_BAD_SUBCOMMAND', `Unknown subcommand: ${subcommand}. Use: generated, selected, audit`);
87
100
  }
88
101
  }
89
102
 
90
103
  if (process.argv[1] && (process.argv[1].endsWith('reingest.js') || process.argv[1].endsWith('reingest'))) {
91
- run().catch((err) => { console.error(err.message || err); process.exit(1); });
104
+ run().catch(handleCliError);
92
105
  }