@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +71 -0
  4. package/README.md +82 -9
  5. package/SECURITY.md +6 -5
  6. package/bin/sdlab.js +359 -11
  7. package/lib/adapters/comfyui-runner.js +170 -0
  8. package/lib/adapters/comfyui-workflows.js +163 -0
  9. package/lib/adapters/diffusers-lora.js +7 -2
  10. package/lib/adapters/generic-image-caption.js +6 -1
  11. package/lib/args.js +200 -13
  12. package/lib/batch-compiler.js +122 -0
  13. package/lib/batch-modes.js +111 -0
  14. package/lib/batch-runs.js +237 -0
  15. package/lib/batch-sheet-render.js +209 -0
  16. package/lib/brief-compiler.js +625 -0
  17. package/lib/brief-render.js +205 -0
  18. package/lib/card.js +11 -4
  19. package/lib/comfyui.js +100 -6
  20. package/lib/config.js +68 -3
  21. package/lib/critique-engine.js +413 -0
  22. package/lib/critique-render.js +171 -0
  23. package/lib/deprecation.js +1 -1
  24. package/lib/eval-pack.js +94 -4
  25. package/lib/eval-runs.js +42 -3
  26. package/lib/export.js +144 -34
  27. package/lib/generated-provenance.js +87 -0
  28. package/lib/implementation-packs.js +15 -1
  29. package/lib/log.js +41 -4
  30. package/lib/paths.js +72 -3
  31. package/lib/refine-briefs.js +336 -0
  32. package/lib/reingest-selected.js +184 -0
  33. package/lib/reingest.js +15 -1
  34. package/lib/run-summary.js +132 -0
  35. package/lib/runtime-runs.js +218 -0
  36. package/lib/selections.js +375 -0
  37. package/lib/snapshot.js +100 -7
  38. package/lib/split.js +175 -17
  39. package/lib/training-manifests.js +17 -3
  40. package/lib/training-packages.js +28 -14
  41. package/lib/workflow-profiles.js +161 -0
  42. package/package.json +13 -4
  43. package/scripts/batch-generate.js +176 -0
  44. package/scripts/batch-sheet.js +46 -0
  45. package/scripts/batch-show.js +86 -0
  46. package/scripts/brief-compile.js +134 -0
  47. package/scripts/brief-show.js +53 -0
  48. package/scripts/canon-bind.js +7 -9
  49. package/scripts/card.js +22 -25
  50. package/scripts/compare.js +38 -33
  51. package/scripts/critique-show.js +50 -0
  52. package/scripts/critique.js +70 -0
  53. package/scripts/curate.js +91 -46
  54. package/scripts/doctor.js +172 -6
  55. package/scripts/eligibility.js +17 -14
  56. package/scripts/eval-pack.js +13 -11
  57. package/scripts/eval-run.js +30 -29
  58. package/scripts/export.js +27 -31
  59. package/scripts/generate-controlnet.js +31 -17
  60. package/scripts/generate-identity.js +37 -20
  61. package/scripts/generate-ipadapter.js +22 -16
  62. package/scripts/generate.js +68 -13
  63. package/scripts/implementation-pack.js +19 -14
  64. package/scripts/init.js +137 -17
  65. package/scripts/painterly.js +8 -10
  66. package/scripts/refine.js +119 -0
  67. package/scripts/reingest-selected.js +78 -0
  68. package/scripts/reingest.js +37 -18
  69. package/scripts/run-generate.js +76 -0
  70. package/scripts/run-list.js +58 -0
  71. package/scripts/run-show.js +63 -0
  72. package/scripts/select.js +119 -0
  73. package/scripts/selection-show.js +88 -0
  74. package/scripts/snapshot.js +22 -24
  75. package/scripts/split.js +26 -31
  76. package/scripts/training-manifest.js +29 -24
  77. package/scripts/training-package.js +24 -20
  78. package/scripts/training-profile.js +13 -8
  79. package/scripts/workflow-list.js +61 -0
  80. package/scripts/workflow-show.js +97 -0
  81. package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
  82. package/templates/domains/architecture/terminology.json +10 -8
  83. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  84. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  85. package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
  86. package/templates/domains/character-design/project.json +4 -2
  87. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  88. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  89. package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
  90. package/templates/domains/creature-design/project.json +4 -2
  91. package/templates/domains/creature-design/terminology.json +1 -0
  92. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  93. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  94. package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
  95. package/templates/domains/game-art/project.json +4 -2
  96. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  97. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  98. package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
  99. package/templates/domains/vehicle-mech/project.json +4 -2
  100. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  101. package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
  102. package/scripts/bulk-curate-wave2-5.js +0 -138
  103. package/scripts/bulk-curate-waves11-18.js +0 -413
  104. package/scripts/curate-wave25.js +0 -209
  105. package/scripts/migrate-records.js +0 -141
  106. package/templates/inputs/prompts/example-wave.json +0 -27
@@ -11,10 +11,11 @@
11
11
  * sdlab generate:ipadapter --subject corrigan --ref <image> --seeds 4 --project star-freight
12
12
  */
13
13
 
14
- import { readFile, writeFile, mkdir } from "node:fs/promises";
14
+ import { writeFile, mkdir } from "node:fs/promises";
15
15
  import { join } from "node:path";
16
- import { getProjectName } from "../lib/args.js";
17
- import { REPO_ROOT } from "../lib/paths.js";
16
+ import { getProjectName, parseNumberFlag } from "../lib/args.js";
17
+ import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
18
+ import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
18
19
  import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
19
20
 
20
21
  const DEFAULTS = {
@@ -34,17 +35,22 @@ const DEFAULTS = {
34
35
  function parseLocalArgs(argv) {
35
36
  const get = (flag, def) => {
36
37
  const i = argv.indexOf(flag);
37
- return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
38
+ if (i < 0 || i + 1 >= argv.length) return def;
39
+ const v = argv[i + 1];
40
+ if (typeof v === 'string' && v.startsWith('--')) {
41
+ throw inputError('INPUT_MISSING_VALUE', `Flag ${flag} is missing its value (got another flag: ${v}).`);
42
+ }
43
+ return v;
38
44
  };
39
45
  return {
40
46
  subject: get("--subject", "unknown"),
41
47
  refPath: get("--ref", null),
42
48
  prompt: get("--prompt", null),
43
49
  negative: get("--negative", null),
44
- seeds: parseInt(get("--seeds", "4"), 10),
45
- weight: parseFloat(get("--weight", "0.55")),
46
- start: parseFloat(get("--start", "0.0")),
47
- end: parseFloat(get("--end", "0.8")),
50
+ seeds: parseNumberFlag('seeds', get("--seeds", "4"), { int: true, min: 1 }),
51
+ weight: parseNumberFlag('weight', get("--weight", "0.55"), { min: 0, max: 2 }),
52
+ start: parseNumberFlag('start', get("--start", "0.0"), { min: 0, max: 1 }),
53
+ end: parseNumberFlag('end', get("--end", "0.8"), { min: 0, max: 1 }),
48
54
  dryRun: argv.includes("--dry-run"),
49
55
  };
50
56
  }
@@ -174,12 +180,15 @@ export async function run(argv = process.argv.slice(2)) {
174
180
  const opts = parseLocalArgs(argv);
175
181
 
176
182
  if (!opts.refPath) {
177
- throw new Error("--ref <path> is required");
183
+ throw inputError('INPUT_MISSING_FLAG', "--ref <path> is required");
178
184
  }
179
185
  if (!opts.prompt) {
180
- throw new Error("--prompt is required");
186
+ throw inputError('INPUT_MISSING_FLAG', "--prompt is required");
181
187
  }
182
188
 
189
+ // Validate --ref path stays inside project tree.
190
+ const safeRefPath = resolveSafeProjectPath(GAME_ROOT, opts.refPath, { flagName: 'ref' });
191
+
183
192
  const negative = opts.negative ||
184
193
  "photorealistic, photograph, 3d render, anime, cartoon, text, watermark, blurry, low quality";
185
194
 
@@ -196,11 +205,11 @@ export async function run(argv = process.argv.slice(2)) {
196
205
  if (!opts.dryRun) {
197
206
  const online = await comfyHealth(COMFY_URL);
198
207
  if (!online) {
199
- throw new Error("ComfyUI not reachable");
208
+ throw runtimeError('RUNTIME_COMFY_UNREACHABLE', "ComfyUI not reachable at " + COMFY_URL);
200
209
  }
201
210
  console.log("\x1b[32m+\x1b[0m ComfyUI online");
202
211
 
203
- await uploadImage(join(GAME_ROOT, opts.refPath), refFilename, COMFY_URL);
212
+ await uploadImage(safeRefPath, refFilename, COMFY_URL);
204
213
  console.log(`\x1b[32m+\x1b[0m Reference uploaded: ${refFilename}`);
205
214
  }
206
215
 
@@ -255,8 +264,5 @@ export async function run(argv = process.argv.slice(2)) {
255
264
 
256
265
  // Direct execution guard
257
266
  if (process.argv[1] && (process.argv[1].endsWith('generate-ipadapter.js') || process.argv[1].endsWith('generate-ipadapter'))) {
258
- run().catch((err) => {
259
- console.error(err.message || err);
260
- process.exit(1);
261
- });
267
+ run().catch(handleCliError);
262
268
  }
@@ -10,11 +10,34 @@
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
14
  import { join } from "node:path";
15
- import { getProjectName } from "../lib/args.js";
16
- import { REPO_ROOT } from "../lib/paths.js";
15
+ import { parseArgs, getProjectName } from "../lib/args.js";
16
+ import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
17
+ import { readJsonFile } from "../lib/config.js";
18
+ import { runtimeError, handleCliError } from "../lib/errors.js";
17
19
  import { comfyHealth, submitAndWait, downloadImage } from "../lib/comfyui.js";
20
+ import { info, result } from "../lib/log.js";
21
+
22
+ /**
23
+ * Format milliseconds as a human ETA string like "6m 12s" or "42s".
24
+ */
25
+ function formatEta(ms) {
26
+ if (!Number.isFinite(ms) || ms < 0) return '—';
27
+ const totalSec = Math.round(ms / 1000);
28
+ if (totalSec < 60) return `${totalSec}s`;
29
+ const m = Math.floor(totalSec / 60);
30
+ const s = totalSec % 60;
31
+ return `${m}m ${s}s`;
32
+ }
33
+
34
+ /**
35
+ * COMFY_URL precedence:
36
+ * 1. process.env.COMFY_URL (explicit user env)
37
+ * 2. project defaults.comfy_url (set by `sdlab project doctor`)
38
+ * 3. built-in default http://127.0.0.1:8188
39
+ * The adapter (lib/adapters/comfyui-runner.js) applies the same precedence.
40
+ */
18
41
 
19
42
  function buildWorkflow(prompt, negativePrompt, checkpoint, loras, seed, steps, cfg, sampler, scheduler, width, height, ipAdapterConfig) {
20
43
  const nodes = {};
@@ -157,11 +180,19 @@ export async function run(argv = process.argv.slice(2)) {
157
180
  const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
158
181
  const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
159
182
 
160
- const packPath = argv.find((a) => !a.startsWith("--")) || "inputs/prompts/rpg-icons-lane1.json";
161
- const dryRun = argv.includes("--dry-run");
183
+ const parsed = parseArgs(argv, {
184
+ flags: {
185
+ project: { type: 'string' },
186
+ game: { type: 'string' },
187
+ 'dry-run': { type: 'boolean' },
188
+ },
189
+ deprecated: { game: 'project' },
190
+ });
191
+ const packPath = parsed.positionals[0] || "inputs/prompts/rpg-icons-lane1.json";
192
+ const dryRun = parsed.flags['dry-run'] === true;
162
193
 
163
- const fullPackPath = join(GAME_ROOT, packPath);
164
- const pack = JSON.parse(await readFile(fullPackPath, "utf-8"));
194
+ const fullPackPath = resolveSafeProjectPath(GAME_ROOT, packPath, { flagName: 'pack-path' });
195
+ const pack = await readJsonFile(fullPackPath, { requiredFields: ['lane', 'subjects', 'variations', 'defaults'] });
165
196
 
166
197
  console.log(`\x1b[1mstyle-dataset-lab\x1b[0m generate`);
167
198
  console.log(` Lane: ${pack.lane}`);
@@ -173,7 +204,7 @@ export async function run(argv = process.argv.slice(2)) {
173
204
  if (!dryRun) {
174
205
  const online = await comfyHealth(COMFY_URL);
175
206
  if (!online) {
176
- throw new Error("ComfyUI not reachable at " + COMFY_URL);
207
+ throw runtimeError('RUNTIME_COMFY_UNREACHABLE', "ComfyUI not reachable at " + COMFY_URL, 'Start ComfyUI or set COMFY_URL to the right host.');
177
208
  }
178
209
  console.log("\x1b[32m✓\x1b[0m ComfyUI online");
179
210
  }
@@ -183,7 +214,9 @@ export async function run(argv = process.argv.slice(2)) {
183
214
 
184
215
  const d = pack.defaults;
185
216
  let generated = 0;
217
+ let errors = 0;
186
218
  const totalExpected = pack.subjects.length * pack.variations.length;
219
+ const runStartMs = Date.now();
187
220
 
188
221
  for (const subject of pack.subjects) {
189
222
  for (const variation of pack.variations) {
@@ -272,21 +305,43 @@ export async function run(argv = process.argv.slice(2)) {
272
305
  console.log(` \x1b[32m✓\x1b[0m ${destPath} (${elapsed}ms, ${imgData.length} bytes)`);
273
306
  } catch (err) {
274
307
  console.log(` \x1b[31m✗\x1b[0m ${err.message}`);
308
+ errors++;
275
309
  }
276
310
 
277
311
  generated++;
312
+
313
+ // ETA every 5 items (not every item — too noisy for long runs).
314
+ if (!dryRun && generated > 0 && generated % 5 === 0 && generated < totalExpected) {
315
+ const elapsedTotal = Date.now() - runStartMs;
316
+ const avgMs = elapsedTotal / generated;
317
+ const remaining = totalExpected - generated;
318
+ const etaMs = avgMs * remaining;
319
+ info('generate', `progress ${generated}/${totalExpected} — avg ${formatEta(avgMs)}/item, ETA ~${formatEta(etaMs)}`);
320
+ }
278
321
  }
279
322
  }
280
323
 
281
324
  console.log("");
282
- console.log(`\x1b[32m✓\x1b[0m Generated ${generated} candidates`);
325
+ const succeeded = generated - errors;
326
+ const totalElapsedMs = Date.now() - runStartMs;
327
+ console.log(`\x1b[32m✓\x1b[0m Generated ${succeeded} candidates (${errors} errors)`);
328
+ if (!dryRun && generated > 0) {
329
+ const avgMs = totalElapsedMs / generated;
330
+ console.log(` Total: ${formatEta(totalElapsedMs)} — avg ${formatEta(avgMs)}/item`);
331
+ }
283
332
  if (dryRun) console.log(" (dry run — no images produced)");
333
+ // Always print the output directory so scripted pipelines can parse it,
334
+ // even under --quiet.
335
+ if (!dryRun && succeeded > 0) {
336
+ result(`Candidates: ${join(GAME_ROOT, 'outputs/candidates')}`);
337
+ result(`Records: ${join(GAME_ROOT, 'records')}`);
338
+ }
339
+ if (!dryRun && errors > 0 && succeeded === 0) {
340
+ throw runtimeError('RUNTIME_ALL_FAILED', `All ${totalExpected} generation attempts failed.`);
341
+ }
284
342
  }
285
343
 
286
344
  // Direct execution guard
287
345
  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
- });
346
+ run().catch(handleCliError);
292
347
  }
@@ -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');
@@ -40,6 +41,13 @@ const PROJECT_DIRS = [
40
41
  'training/packages',
41
42
  'training/eval-runs',
42
43
  'training/implementations',
44
+ 'workflows/profiles',
45
+ 'workflows/batch-modes',
46
+ 'briefs',
47
+ 'runs',
48
+ 'batches',
49
+ 'selections',
50
+ 'inbox/generated',
43
51
  ];
44
52
 
45
53
  function isValidName(name) {
@@ -75,15 +83,28 @@ export async function run(argv = process.argv.slice(2)) {
75
83
  }
76
84
 
77
85
  if (!isValidName(projectName)) {
78
- throw new Error(
79
- `Invalid project name "${projectName}".\n` +
86
+ throw inputError(
87
+ 'INPUT_BAD_NAME',
88
+ `Invalid project name "${projectName}".`,
80
89
  `Use lowercase letters, numbers, and hyphens (e.g. "my-project").`
81
90
  );
82
91
  }
83
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
+
84
102
  const projectDir = join(PROJECTS_DIR, projectName);
85
103
  if (existsSync(projectDir)) {
86
- throw new Error(`Project "${projectName}" already exists at ${projectDir}`);
104
+ throw inputError(
105
+ 'INPUT_PROJECT_EXISTS',
106
+ `Project "${projectName}" already exists at ${projectDir}`
107
+ );
87
108
  }
88
109
 
89
110
  // Resolve domain template source
@@ -92,8 +113,9 @@ export async function run(argv = process.argv.slice(2)) {
92
113
  domainDir = join(TEMPLATES_DIR, 'domains', domain);
93
114
  if (!existsSync(domainDir)) {
94
115
  const domains = await listDomains();
95
- throw new Error(
96
- `Domain "${domain}" not found.\n` +
116
+ throw inputError(
117
+ 'INPUT_UNKNOWN_DOMAIN',
118
+ `Domain "${domain}" not found.`,
97
119
  `Available: generic, ${domains.join(', ')}`
98
120
  );
99
121
  }
@@ -199,36 +221,134 @@ export async function run(argv = process.argv.slice(2)) {
199
221
 
200
222
  // Copy canon markdown templates
201
223
  const canonTemplates = ['constitution.md', 'review-rubric.md'];
224
+ const wroteCanon = {};
202
225
  for (const file of canonTemplates) {
203
226
  const src = join(TEMPLATES_DIR, 'canon', file);
204
227
  if (existsSync(src)) {
205
228
  await copyFile(src, join(projectDir, 'canon', file));
229
+ wroteCanon[file] = true;
206
230
  console.log(` \x1b[32m✓\x1b[0m canon/${file}`);
207
231
  }
208
232
  }
209
233
 
210
- // Copy example prompt pack
211
- const examplePack = join(TEMPLATES_DIR, 'inputs', 'prompts', 'example-wave.json');
212
- 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) {
213
246
  await copyFile(examplePack, join(projectDir, 'inputs', 'prompts', 'example-wave.json'));
247
+ wroteExamplePack = true;
214
248
  console.log(` \x1b[32m✓\x1b[0m inputs/prompts/example-wave.json`);
215
249
  }
216
250
 
251
+ // Copy domain workflow profiles if available
252
+ if (domainDir) {
253
+ const domainWorkflowsDir = join(domainDir, 'workflows', 'profiles');
254
+ if (existsSync(domainWorkflowsDir)) {
255
+ const wfFiles = (await readdir(domainWorkflowsDir)).filter(f => f.endsWith('.json'));
256
+ for (const file of wfFiles) {
257
+ await copyFile(
258
+ join(domainWorkflowsDir, file),
259
+ join(projectDir, 'workflows', 'profiles', file)
260
+ );
261
+ console.log(` \x1b[32m✓\x1b[0m workflows/profiles/${file}`);
262
+ }
263
+ }
264
+ }
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
+
217
331
  console.log('');
218
332
  console.log(`\x1b[32m✓\x1b[0m Project "${projectName}" initialized`);
219
333
  console.log('');
220
334
  console.log('Next steps:');
221
- console.log(` 1. Edit ${projectName}/canon/constitution.md — define your visual rules`);
222
- console.log(` 2. Edit ${projectName}/constitution.json — encode rules for canon-bind`);
223
- console.log(` 3. Create prompt packs in ${projectName}/inputs/prompts/`);
224
- console.log(` 4. Run: sdlab generate <pack> --project ${projectName}`);
225
- 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.`);
226
349
  }
227
350
 
228
351
  // Direct execution guard
229
352
  if (process.argv[1] && (process.argv[1].endsWith('init.js') || process.argv[1].endsWith('init'))) {
230
- run().catch((err) => {
231
- console.error(err.message || err);
232
- process.exit(1);
233
- });
353
+ run().catch(handleCliError);
234
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
  }
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * refine.js — Generate a refined next-pass brief from critique.
5
+ *
6
+ * Usage:
7
+ * sdlab refine --project star-freight --run run_2026-04-15_001 --pick 003.png
8
+ * sdlab refine --run run_2026-04-15_001 --pick 003.png --push "stronger faction read" --suppress "extra ornament"
9
+ * sdlab refine --run run_2026-04-15_001 --pick 003.png --json
10
+ */
11
+
12
+ import { readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { parseArgs, getProjectName } from '../lib/args.js';
15
+ import { getProjectRoot, getRunsDir } from '../lib/paths.js';
16
+ import { loadCritique } from '../lib/critique-engine.js';
17
+ import {
18
+ extractDelta,
19
+ buildRefinedBrief,
20
+ saveRefinedBrief,
21
+ renderRefinedBriefMarkdown,
22
+ } from '../lib/refine-briefs.js';
23
+ import { info } from '../lib/log.js';
24
+
25
+ export async function run(argv = process.argv.slice(2)) {
26
+ const { flags } = parseArgs(argv, {
27
+ flags: {
28
+ project: { type: 'string' },
29
+ run: { type: 'string' },
30
+ pick: { type: 'string' },
31
+ preserve: { type: 'string' },
32
+ push: { type: 'string' },
33
+ suppress: { type: 'string' },
34
+ json: { type: 'boolean' },
35
+ },
36
+ deprecated: { game: 'project' },
37
+ });
38
+
39
+ if (!flags.run || !flags.pick) {
40
+ console.log('Usage: sdlab refine --run <id> --pick <filename> [--project <name>]');
41
+ console.log('');
42
+ console.log('Options:');
43
+ console.log(' --run <id> Run to refine from (required)');
44
+ console.log(' --pick <filename> Candidate filename to refine from (required)');
45
+ console.log(' --preserve <notes> Comma-separated preserve instructions');
46
+ console.log(' --push <notes> Comma-separated push instructions');
47
+ console.log(' --suppress <notes> Comma-separated suppress instructions');
48
+ console.log(' --json Output raw JSON');
49
+ return;
50
+ }
51
+
52
+ const projectName = flags.project || getProjectName(argv);
53
+ const projectRoot = getProjectRoot(projectName);
54
+ const runId = flags.run;
55
+ const pickedFilename = flags.pick;
56
+
57
+ console.log(`\x1b[1mstyle-dataset-lab\x1b[0m refine`);
58
+ console.log(` Project: ${projectName}`);
59
+ console.log(` Run: ${runId}`);
60
+ console.log(` Picked: ${pickedFilename}`);
61
+ console.log('');
62
+
63
+ // Load critique
64
+ const critique = loadCritique(projectRoot, runId);
65
+
66
+ // Load parent brief from the run
67
+ const runDir = join(getRunsDir(projectRoot), runId);
68
+ const parentBrief = JSON.parse(readFileSync(join(runDir, 'brief.json'), 'utf-8'));
69
+
70
+ // Parse CLI delta overrides
71
+ const cliOverrides = {
72
+ preserve: flags.preserve ? flags.preserve.split(',').map(s => s.trim()).filter(Boolean) : [],
73
+ push: flags.push ? flags.push.split(',').map(s => s.trim()).filter(Boolean) : [],
74
+ suppress: flags.suppress ? flags.suppress.split(',').map(s => s.trim()).filter(Boolean) : [],
75
+ };
76
+
77
+ // Extract delta from critique + overrides
78
+ const delta = extractDelta(critique, pickedFilename, cliOverrides);
79
+
80
+ // Build refined brief
81
+ const refineDir = join(runDir, 'refine');
82
+ const refinedBrief = buildRefinedBrief({
83
+ parentBrief,
84
+ critique,
85
+ pickedCandidate: pickedFilename,
86
+ delta,
87
+ runId,
88
+ refineDir,
89
+ });
90
+
91
+ // Save
92
+ const { jsonPath, mdPath } = await saveRefinedBrief(projectRoot, runId, refinedBrief);
93
+
94
+ if (flags.json) {
95
+ console.log(JSON.stringify(refinedBrief, null, 2));
96
+ } else {
97
+ console.log(`Refined brief: ${refinedBrief.brief_id}`);
98
+ console.log(`Parent: ${refinedBrief.parent_brief_id}`);
99
+ console.log(`Source: ${runId} → ${pickedFilename}`);
100
+ console.log('');
101
+
102
+ if (delta.preserve.length > 0) {
103
+ console.log('Preserve:');
104
+ for (const p of delta.preserve) console.log(` ✓ ${p}`);
105
+ }
106
+ if (delta.push.length > 0) {
107
+ console.log('Push:');
108
+ for (const p of delta.push) console.log(` → ${p}`);
109
+ }
110
+ if (delta.suppress.length > 0) {
111
+ console.log('Suppress:');
112
+ for (const s of delta.suppress) console.log(` ✗ ${s}`);
113
+ }
114
+
115
+ console.log('');
116
+ info(`Saved: runs/${runId}/refine/${refinedBrief.brief_id}.json`);
117
+ info(`Next: sdlab run generate --brief ${refinedBrief.brief_id} (from refine dir)`);
118
+ }
119
+ }