@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
@@ -8,62 +8,70 @@
8
8
  * sdlab compare iron_sword_base iron_sword_icon_mid a "Better silhouette" --project star-freight
9
9
  */
10
10
 
11
- import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
11
+ import { writeFile, readdir, mkdir, readFile } from "node:fs/promises";
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
-
16
- function getFlagValue(args, flag) {
17
- const prefix = `--${flag}=`;
18
- for (const a of args) {
19
- if (a.startsWith(prefix)) return a.slice(prefix.length);
20
- }
21
- const idx = args.indexOf(`--${flag}`);
22
- if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
23
- return undefined;
24
- }
15
+ import { readJsonFile } from "../lib/config.js";
16
+ import { inputError, handleCliError } from "../lib/errors.js";
25
17
 
26
18
  export async function run(argv = process.argv.slice(2)) {
27
- const projectName = getProjectName(argv);
28
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
29
-
30
- // Strip --project/--game and their values from positional parsing
31
- const args = argv.filter((a, i) => {
32
- if (a === '--project' || a === '--game') return false;
33
- if (i > 0 && (argv[i - 1] === '--project' || argv[i - 1] === '--game')) return false;
34
- return true;
19
+ const { flags, positionals } = parseArgs(argv, {
20
+ flags: {
21
+ project: { type: 'string' },
22
+ scores: { type: 'string' },
23
+ },
24
+ deprecated: { game: 'project' },
35
25
  });
36
26
 
37
- const [assetAId, assetBId, winner, ...reasonParts] = args;
27
+ const projectName = flags.project || getProjectName(argv);
28
+ const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
29
+
30
+ const [assetAId, assetBId, winner, ...reasonParts] = positionals;
38
31
 
39
32
  if (!assetAId || !assetBId || !winner) {
40
- throw new Error("Usage: sdlab compare <asset_a_id> <asset_b_id> <a|b|tie> <reasoning>");
33
+ throw inputError(
34
+ 'INPUT_MISSING_ARGS',
35
+ 'Usage: sdlab compare <asset_a_id> <asset_b_id> <a|b|tie> <reasoning>'
36
+ );
41
37
  }
42
38
 
43
39
  if (!["a", "b", "tie"].includes(winner)) {
44
- throw new Error(`Invalid winner: ${winner}. Must be a, b, or tie.`);
40
+ throw inputError(
41
+ 'INPUT_BAD_WINNER',
42
+ `Invalid winner: ${winner}. Must be a, b, or tie.`
43
+ );
45
44
  }
46
45
 
47
- const reasoning = reasonParts.filter(r => !r.startsWith('--')).join(" ") || null;
46
+ const reasoning = reasonParts.join(" ") || null;
48
47
 
49
48
  // Load records to get paths
50
49
  const recordA = await loadRecord(GAME_ROOT, assetAId);
51
50
  const recordB = await loadRecord(GAME_ROOT, assetBId);
52
51
 
53
52
  if (!recordA || !recordB) {
54
- throw new Error("Could not load one or both records.");
53
+ throw inputError('INPUT_UNKNOWN_RECORD', 'Could not load one or both records.');
55
54
  }
56
55
 
57
56
  // Parse optional scores
58
- const scoresStr = getFlagValue(args, "scores");
57
+ const scoresStr = flags.scores;
59
58
  const criteria_scores = {};
60
59
  if (scoresStr) {
61
60
  for (const pair of scoresStr.split(",")) {
62
61
  const [k, v] = pair.split(":");
63
62
  // Format: "silhouette:0.9/0.6" → { silhouette: { a: 0.9, b: 0.6 } }
64
63
  if (k && v) {
65
- const [aScore, bScore] = v.split("/");
66
- criteria_scores[k.trim()] = { a: parseFloat(aScore), b: parseFloat(bScore) };
64
+ const [aStr, bStr] = v.split("/");
65
+ const aScore = parseFloat(aStr);
66
+ const bScore = parseFloat(bStr);
67
+ if (!Number.isFinite(aScore) || !Number.isFinite(bScore)) {
68
+ throw inputError(
69
+ 'INPUT_BAD_SCORE',
70
+ `Score for "${k.trim()}" is not numeric: "${v}"`,
71
+ 'Use the form --scores silhouette:0.9/0.6,palette:0.8/0.7'
72
+ );
73
+ }
74
+ criteria_scores[k.trim()] = { a: aScore, b: bScore };
67
75
  }
68
76
  }
69
77
  }
@@ -101,7 +109,7 @@ export async function run(argv = process.argv.slice(2)) {
101
109
  async function loadRecord(GAME_ROOT, id) {
102
110
  try {
103
111
  const path = join(GAME_ROOT, `records/${id}.json`);
104
- return JSON.parse(await readFile(path, "utf-8"));
112
+ return await readJsonFile(path);
105
113
  } catch {
106
114
  console.error(`Record not found: records/${id}.json`);
107
115
  return null;
@@ -119,8 +127,5 @@ async function countComparisons(GAME_ROOT) {
119
127
 
120
128
  // Direct execution guard
121
129
  if (process.argv[1] && (process.argv[1].endsWith('compare.js') || process.argv[1].endsWith('compare'))) {
122
- run().catch((err) => {
123
- console.error(err.message || err);
124
- process.exit(1);
125
- });
130
+ run().catch(handleCliError);
126
131
  }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * critique-show.js — Display a saved critique report.
5
+ *
6
+ * Usage:
7
+ * sdlab critique show --project star-freight --run run_2026-04-15_001
8
+ * sdlab critique show --project star-freight --run run_2026-04-15_001 --json
9
+ * sdlab critique show --project star-freight --run run_2026-04-15_001 --md
10
+ */
11
+
12
+ import { parseArgs, getProjectName } from '../lib/args.js';
13
+ import { getProjectRoot } from '../lib/paths.js';
14
+ import { loadCritique } from '../lib/critique-engine.js';
15
+ import { renderCritiqueMarkdown, renderCritiqueText } from '../lib/critique-render.js';
16
+
17
+ export async function run(argv = process.argv.slice(2)) {
18
+ const { flags } = parseArgs(argv, {
19
+ flags: {
20
+ project: { type: 'string' },
21
+ run: { type: 'string' },
22
+ json: { type: 'boolean' },
23
+ md: { type: 'boolean' },
24
+ },
25
+ deprecated: { game: 'project' },
26
+ });
27
+
28
+ if (!flags.run) {
29
+ console.log('Usage: sdlab critique show --run <id> [--project <name>]');
30
+ console.log('');
31
+ console.log('Options:');
32
+ console.log(' --run <id> Run ID (required)');
33
+ console.log(' --json Output raw JSON');
34
+ console.log(' --md Output full Markdown');
35
+ return;
36
+ }
37
+
38
+ const projectName = flags.project || getProjectName(argv);
39
+ const projectRoot = getProjectRoot(projectName);
40
+
41
+ const report = loadCritique(projectRoot, flags.run);
42
+
43
+ if (flags.json) {
44
+ console.log(JSON.stringify(report, null, 2));
45
+ } else if (flags.md) {
46
+ console.log(renderCritiqueMarkdown(report));
47
+ } else {
48
+ console.log(renderCritiqueText(report));
49
+ }
50
+ }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * critique.js — Generate a critique report for a completed run.
5
+ *
6
+ * Usage:
7
+ * sdlab critique --project star-freight --run run_2026-04-15_001
8
+ * sdlab critique --project star-freight --run run_2026-04-15_001 --json
9
+ */
10
+
11
+ import { parseArgs, getProjectName } from '../lib/args.js';
12
+ import { getProjectRoot } from '../lib/paths.js';
13
+ import { critiqueRun, saveCritique } from '../lib/critique-engine.js';
14
+ import { renderCritiqueMarkdown, renderCritiqueText } from '../lib/critique-render.js';
15
+ import { info } from '../lib/log.js';
16
+
17
+ export async function run(argv = process.argv.slice(2)) {
18
+ const { flags } = parseArgs(argv, {
19
+ flags: {
20
+ project: { type: 'string' },
21
+ run: { type: 'string' },
22
+ json: { type: 'boolean' },
23
+ },
24
+ deprecated: { game: 'project' },
25
+ });
26
+
27
+ if (!flags.run) {
28
+ console.log('Usage: sdlab critique --run <id> [--project <name>]');
29
+ console.log('');
30
+ console.log('Options:');
31
+ console.log(' --run <id> Run to critique (required)');
32
+ console.log(' --json Output raw JSON');
33
+ return;
34
+ }
35
+
36
+ const projectName = flags.project || getProjectName(argv);
37
+ const projectRoot = getProjectRoot(projectName);
38
+
39
+ console.log(`\x1b[1mstyle-dataset-lab\x1b[0m critique`);
40
+ console.log(` Project: ${projectName}`);
41
+ console.log(` Run: ${flags.run}`);
42
+ console.log('');
43
+
44
+ // Generate critique
45
+ const report = await critiqueRun({
46
+ projectRoot,
47
+ projectId: projectName,
48
+ runId: flags.run,
49
+ });
50
+
51
+ // Save
52
+ const md = renderCritiqueMarkdown(report);
53
+ await saveCritique(projectRoot, flags.run, report, md);
54
+
55
+ if (flags.json) {
56
+ console.log(JSON.stringify(report, null, 2));
57
+ } else {
58
+ console.log(renderCritiqueText(report));
59
+ const action = report.recommended_action;
60
+ console.log(`Critique saved to: runs/${flags.run}/critique.json`);
61
+
62
+ if (action.mode === 'refine_from_one' && action.preferred_candidate) {
63
+ console.log('');
64
+ info(`Next: sdlab refine --run ${flags.run} --pick ${action.preferred_candidate}`);
65
+ } else if (action.mode === 'accept_one' && action.preferred_candidate) {
66
+ console.log('');
67
+ info(`Preferred candidate: ${action.preferred_candidate}`);
68
+ }
69
+ }
70
+ }
package/scripts/curate.js CHANGED
@@ -7,59 +7,73 @@
7
7
  * node scripts/curate.js <asset_id> approved "Clean silhouette, correct palette" --scores silhouette:0.9,palette:0.8
8
8
  * node scripts/curate.js <asset_id> rejected "3D render look, alpha halo" --failures alpha_halo,3d_render_look
9
9
  * node scripts/curate.js --list (show uncurated candidates)
10
- * sdlab curate <asset_id> approved "explanation" --project star-freight
10
+ * sdlab curate <asset_id> approved "explanation" --project star-freight [--dry-run]
11
11
  */
12
12
 
13
- import { readFile, writeFile, rename, readdir, mkdir } from "node:fs/promises";
13
+ import { writeFile, rename, readdir, mkdir, readFile } from "node:fs/promises";
14
14
  import { join } from "node:path";
15
- import { getProjectName } from "../lib/args.js";
15
+ import { parseArgs, getProjectName } from "../lib/args.js";
16
16
  import { REPO_ROOT } from "../lib/paths.js";
17
-
18
- function getFlagValue(args, flag) {
19
- const prefix = `--${flag}=`;
20
- for (const a of args) {
21
- if (a.startsWith(prefix)) return a.slice(prefix.length);
22
- }
23
- const idx = args.indexOf(`--${flag}`);
24
- if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
25
- return undefined;
26
- }
17
+ import { readJsonFile } from "../lib/config.js";
18
+ import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
19
+ import { result } from "../lib/log.js";
27
20
 
28
21
  export async function run(argv = process.argv.slice(2)) {
29
- const projectName = getProjectName(argv);
30
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
31
-
32
- // Strip --project/--game and their values from positional parsing
33
- const args = argv.filter((a, i) => {
34
- if (a === '--project' || a === '--game') return false;
35
- if (i > 0 && (argv[i - 1] === '--project' || argv[i - 1] === '--game')) return false;
36
- return true;
22
+ const { flags, positionals } = parseArgs(argv, {
23
+ flags: {
24
+ project: { type: 'string' },
25
+ scores: { type: 'string' },
26
+ failures: { type: 'string' },
27
+ notes: { type: 'string' },
28
+ list: { type: 'boolean', default: false },
29
+ 'dry-run': { type: 'boolean', default: false },
30
+ },
31
+ deprecated: { game: 'project' },
37
32
  });
38
33
 
39
- if (args.includes("--list")) {
34
+ const projectName = flags.project || getProjectName(argv);
35
+ const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
36
+ const dryRun = flags['dry-run'] || flags.dryRun;
37
+
38
+ if (flags.list) {
40
39
  return listUncurated(GAME_ROOT);
41
40
  }
42
41
 
43
- const [assetId, status, explanation, ...rest] = args;
42
+ const [assetId, status, explanation] = positionals;
44
43
 
45
44
  if (!assetId || !status || !explanation) {
46
- throw new Error("Usage: sdlab curate <asset_id> <approved|rejected|borderline> <explanation> [--scores k:v,...] [--failures f1,f2]");
45
+ throw inputError(
46
+ 'INPUT_MISSING_ARGS',
47
+ 'Usage: sdlab curate <asset_id> <approved|rejected|borderline> <explanation> [--scores k:v,...] [--failures f1,f2] [--dry-run]'
48
+ );
47
49
  }
48
50
 
49
51
  if (!["approved", "rejected", "borderline"].includes(status)) {
50
- throw new Error(`Invalid status: ${status}. Must be approved, rejected, or borderline.`);
52
+ throw inputError(
53
+ 'INPUT_BAD_STATUS',
54
+ `Invalid status: ${status}. Must be approved, rejected, or borderline.`
55
+ );
51
56
  }
52
57
 
53
- // Parse optional flags
54
- const scoresStr = getFlagValue(rest, "scores");
55
- const failuresStr = getFlagValue(rest, "failures");
56
- const notesStr = getFlagValue(rest, "notes");
58
+ const scoresStr = flags.scores;
59
+ const failuresStr = flags.failures;
60
+ const notesStr = flags.notes;
57
61
 
58
62
  const criteria_scores = {};
59
63
  if (scoresStr) {
60
64
  for (const pair of scoresStr.split(",")) {
61
65
  const [k, v] = pair.split(":");
62
- if (k && v) criteria_scores[k.trim()] = parseFloat(v);
66
+ if (k && v) {
67
+ const score = parseFloat(v);
68
+ if (!Number.isFinite(score)) {
69
+ throw inputError(
70
+ 'INPUT_BAD_SCORE',
71
+ `Score value for "${k.trim()}" is not a number: "${v}"`,
72
+ 'Use the form --scores silhouette:0.9,palette:0.8'
73
+ );
74
+ }
75
+ criteria_scores[k.trim()] = score;
76
+ }
63
77
  }
64
78
  }
65
79
 
@@ -69,17 +83,44 @@ export async function run(argv = process.argv.slice(2)) {
69
83
  const recordPath = join(GAME_ROOT, `records/${assetId}.json`);
70
84
  let record;
71
85
  try {
72
- record = JSON.parse(await readFile(recordPath, "utf-8"));
73
- } catch {
74
- throw new Error(`Record not found: ${recordPath}`);
86
+ record = await readJsonFile(recordPath);
87
+ } catch (err) {
88
+ if (err && err.code === 'INPUT_FILE_NOT_FOUND') {
89
+ throw inputError('INPUT_UNKNOWN_RECORD', `Record not found: ${recordPath}`);
90
+ }
91
+ throw err;
75
92
  }
76
93
 
77
- // Update record first (write before move to avoid orphaned images)
78
- const oldPath = join(GAME_ROOT, record.asset_path);
94
+ const originalAssetPath = record.asset_path;
95
+ const oldPath = join(GAME_ROOT, originalAssetPath);
79
96
  const newDir = `outputs/${status}`;
80
97
  const newPath = `${newDir}/${assetId}.png`;
98
+ const newFullPath = join(GAME_ROOT, newPath);
99
+
100
+ if (dryRun) {
101
+ console.log(`\x1b[33m(dry-run)\x1b[0m ${assetId}`);
102
+ console.log(` status: ${status}`);
103
+ console.log(` move: ${originalAssetPath} -> ${newPath}`);
104
+ console.log(` note: ${explanation}`);
105
+ if (failure_modes.length) console.log(` failures: ${failure_modes.join(", ")}`);
106
+ return;
107
+ }
108
+
81
109
  await mkdir(join(GAME_ROOT, newDir), { recursive: true });
82
110
 
111
+ // Move image FIRST — if the rename fails we still have a clean record.
112
+ try {
113
+ await rename(oldPath, newFullPath);
114
+ } catch (err) {
115
+ throw runtimeError(
116
+ 'RUNTIME_MOVE_FAILED',
117
+ `Could not move ${oldPath} to ${newPath}: ${err.message}`,
118
+ 'Check that the source image exists and the destination is writable.',
119
+ err
120
+ );
121
+ }
122
+
123
+ // Now safely update the record. If writing fails, try to restore the image.
83
124
  record.asset_path = newPath;
84
125
  record.judgment = {
85
126
  status,
@@ -92,16 +133,23 @@ export async function run(argv = process.argv.slice(2)) {
92
133
  confidence: 0.9,
93
134
  };
94
135
 
95
- await writeFile(recordPath, JSON.stringify(record, null, 2));
96
-
97
- // Move image file
98
136
  try {
99
- await rename(oldPath, join(GAME_ROOT, newPath));
100
- } catch {
101
- throw new Error(`Could not move ${oldPath} to ${newPath}`);
137
+ await writeFile(recordPath, JSON.stringify(record, null, 2));
138
+ } catch (err) {
139
+ // Attempt rollback: put the image back.
140
+ try { await rename(newFullPath, oldPath); } catch {}
141
+ throw runtimeError(
142
+ 'RUNTIME_WRITE_FAILED',
143
+ `Could not write record ${recordPath}: ${err.message}`,
144
+ null,
145
+ err
146
+ );
102
147
  }
103
148
 
104
- console.log(`\x1b[32m✓\x1b[0m ${assetId} → ${status}`);
149
+ // Final artifact path — always shown, even under --quiet.
150
+ result('curate', `${assetId} → ${status}`);
151
+ result(` image: ${newPath}`);
152
+ result(` record: records/${assetId}.json`);
105
153
  console.log(` ${explanation}`);
106
154
  if (failure_modes.length) console.log(` Failures: ${failure_modes.join(", ")}`);
107
155
  }
@@ -135,8 +183,5 @@ async function listUncurated(GAME_ROOT) {
135
183
 
136
184
  // Direct execution guard
137
185
  if (process.argv[1] && (process.argv[1].endsWith('curate.js') || process.argv[1].endsWith('curate'))) {
138
- run().catch((err) => {
139
- console.error(err.message || err);
140
- process.exit(1);
141
- });
186
+ run().catch(handleCliError);
142
187
  }
package/scripts/doctor.js CHANGED
@@ -15,6 +15,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { getProjectName } from '../lib/args.js';
17
17
  import { REPO_ROOT } from '../lib/paths.js';
18
+ import { runtimeError, handleCliError } from '../lib/errors.js';
18
19
 
19
20
  const REQUIRED_CONFIG_FILES = [
20
21
  'project.json',
@@ -41,6 +42,13 @@ const REQUIRED_DIRS = [
41
42
  'training/packages',
42
43
  'training/eval-runs',
43
44
  'training/implementations',
45
+ 'workflows/profiles',
46
+ 'workflows/batch-modes',
47
+ 'briefs',
48
+ 'runs',
49
+ 'batches',
50
+ 'selections',
51
+ 'inbox/generated',
44
52
  ];
45
53
 
46
54
  function pass(msg) { console.log(` \x1b[32m✓\x1b[0m ${msg}`); }
@@ -284,7 +292,168 @@ export async function run(argv = process.argv.slice(2)) {
284
292
  }
285
293
  }
286
294
 
287
- // ── 9. Records sanity check ──
295
+ // ── 9. Workflow profile validation ──
296
+ const workflowDir = join(projectDir, 'workflows', 'profiles');
297
+ if (existsSync(workflowDir)) {
298
+ console.log('');
299
+ const wfFiles = readdirSync(workflowDir).filter(f => f.endsWith('.json'));
300
+ if (wfFiles.length > 0) {
301
+ const VALID_SUBJECT_MODES = ['required', 'optional', 'forbidden'];
302
+ const VALID_OUTPUT_MODES = ['portrait_set', 'expression_sheet', 'variant_pack', 'moodboard', 'silhouette_sheet', 'turnaround'];
303
+ const laneIds = new Set((lanes?.lanes || []).map(l => l.id));
304
+
305
+ let wfErrors = 0;
306
+ for (const file of wfFiles) {
307
+ const { data, error } = tryParseJson(join(workflowDir, file));
308
+ if (error) { fail(`workflows/profiles/${file} — invalid JSON: ${error}`); wfErrors++; continue; }
309
+
310
+ const missing = ['workflow_id', 'label', 'lane_id', 'output_mode', 'output_count',
311
+ 'prompt_strategy', 'negative_strategy', 'canon_focus', 'drift_guards', 'runtime_defaults']
312
+ .filter(f => data[f] === undefined || data[f] === null);
313
+ if (missing.length > 0) {
314
+ fail(`workflows/profiles/${file} — missing fields: ${missing.join(', ')}`);
315
+ wfErrors++;
316
+ continue;
317
+ }
318
+
319
+ if (data.lane_id && laneIds.size > 0 && !laneIds.has(data.lane_id)) {
320
+ fail(`workflows/profiles/${file} — lane_id "${data.lane_id}" not found in lanes.json`);
321
+ wfErrors++;
322
+ }
323
+
324
+ if (data.subject_mode && !VALID_SUBJECT_MODES.includes(data.subject_mode)) {
325
+ fail(`workflows/profiles/${file} — invalid subject_mode "${data.subject_mode}"`);
326
+ wfErrors++;
327
+ }
328
+
329
+ if (!VALID_OUTPUT_MODES.includes(data.output_mode)) {
330
+ fail(`workflows/profiles/${file} — invalid output_mode "${data.output_mode}"`);
331
+ wfErrors++;
332
+ }
333
+
334
+ const ps = data.prompt_strategy;
335
+ if (!ps || !Array.isArray(ps.style_prefix) || !ps.structure || !Array.isArray(ps.must_include)) {
336
+ fail(`workflows/profiles/${file} — prompt_strategy needs style_prefix[], structure, must_include[]`);
337
+ wfErrors++;
338
+ }
339
+
340
+ const ns = data.negative_strategy;
341
+ if (!ns || !Array.isArray(ns.must_avoid)) {
342
+ fail(`workflows/profiles/${file} — negative_strategy needs must_avoid[]`);
343
+ wfErrors++;
344
+ }
345
+
346
+ if (!data.runtime_defaults?.adapter_target) {
347
+ fail(`workflows/profiles/${file} — runtime_defaults needs adapter_target`);
348
+ wfErrors++;
349
+ }
350
+ }
351
+
352
+ if (wfErrors === 0) {
353
+ pass(`${wfFiles.length} workflow profiles — all valid`);
354
+ passes++;
355
+ } else {
356
+ failures += wfErrors;
357
+ }
358
+ } else {
359
+ pass('workflows/profiles/ — empty (no workflows yet)');
360
+ passes++;
361
+ }
362
+ }
363
+
364
+ // ── 10. Batch mode validation ──
365
+ const batchModesDir = join(projectDir, 'workflows', 'batch-modes');
366
+ if (existsSync(batchModesDir)) {
367
+ console.log('');
368
+ const bmFiles = readdirSync(batchModesDir).filter(f => f.endsWith('.json'));
369
+ if (bmFiles.length > 0) {
370
+ const VALID_BATCH_TYPES = ['expression_sheet', 'environment_board', 'silhouette_pack', 'continuity_variants'];
371
+ const VALID_SUBJECT_MODES_BM = ['required', 'optional', 'forbidden'];
372
+ const VALID_LAYOUTS = ['grid', 'moodboard', 'strip', 'freeform'];
373
+ const wfFiles = existsSync(join(projectDir, 'workflows', 'profiles'))
374
+ ? readdirSync(join(projectDir, 'workflows', 'profiles')).filter(f => f.endsWith('.json')).map(f => f.replace('.json', ''))
375
+ : [];
376
+
377
+ let bmErrors = 0;
378
+ for (const file of bmFiles) {
379
+ const { data, error } = tryParseJson(join(batchModesDir, file));
380
+ if (error) { fail(`workflows/batch-modes/${file} — invalid JSON: ${error}`); bmErrors++; continue; }
381
+
382
+ if (!data.mode_id) { fail(`workflows/batch-modes/${file} — missing mode_id`); bmErrors++; }
383
+ if (!data.batch_type || !VALID_BATCH_TYPES.includes(data.batch_type)) {
384
+ fail(`workflows/batch-modes/${file} — invalid batch_type "${data.batch_type}"`);
385
+ bmErrors++;
386
+ }
387
+ if (data.subject_mode && !VALID_SUBJECT_MODES_BM.includes(data.subject_mode)) {
388
+ fail(`workflows/batch-modes/${file} — invalid subject_mode "${data.subject_mode}"`);
389
+ bmErrors++;
390
+ }
391
+ if (data.base_workflow_id && wfFiles.length > 0 && !wfFiles.includes(data.base_workflow_id)) {
392
+ fail(`workflows/batch-modes/${file} — base_workflow_id "${data.base_workflow_id}" not found in profiles`);
393
+ bmErrors++;
394
+ }
395
+ if (!Array.isArray(data.variant_plan) || data.variant_plan.length === 0) {
396
+ fail(`workflows/batch-modes/${file} — variant_plan must be non-empty array`);
397
+ bmErrors++;
398
+ } else {
399
+ const slotIds = data.variant_plan.map(s => s.slot_id);
400
+ const dupes = slotIds.filter((id, i) => slotIds.indexOf(id) !== i);
401
+ if (dupes.length > 0) {
402
+ fail(`workflows/batch-modes/${file} — duplicate slot_ids: ${[...new Set(dupes)].join(', ')}`);
403
+ bmErrors++;
404
+ }
405
+ }
406
+ if (data.assembly?.layout && !VALID_LAYOUTS.includes(data.assembly.layout)) {
407
+ fail(`workflows/batch-modes/${file} — invalid assembly.layout "${data.assembly.layout}"`);
408
+ bmErrors++;
409
+ }
410
+ }
411
+
412
+ if (bmErrors === 0) {
413
+ pass(`${bmFiles.length} batch modes — all valid`);
414
+ passes++;
415
+ } else {
416
+ failures += bmErrors;
417
+ }
418
+ } else {
419
+ pass('workflows/batch-modes/ — empty (no batch modes yet)');
420
+ passes++;
421
+ }
422
+ }
423
+
424
+ // ── 11. Runtime templates check ──
425
+ {
426
+ console.log('');
427
+ const runtimeDir = join(REPO_ROOT, 'runtime', 'comfyui');
428
+ if (existsSync(runtimeDir)) {
429
+ const templateFiles = readdirSync(runtimeDir).filter(f => f.endsWith('.json'));
430
+ if (templateFiles.length > 0) {
431
+ let templateErrors = 0;
432
+ for (const file of templateFiles) {
433
+ const { data, error } = tryParseJson(join(runtimeDir, file));
434
+ if (error) { fail(`runtime/comfyui/${file}: invalid JSON — ${error}`); templateErrors++; continue; }
435
+ if (!data.template_id) { fail(`runtime/comfyui/${file}: missing template_id`); templateErrors++; }
436
+ if (!data.compatible_modes || !Array.isArray(data.compatible_modes)) {
437
+ fail(`runtime/comfyui/${file}: missing compatible_modes[]`); templateErrors++;
438
+ }
439
+ }
440
+ if (templateErrors === 0) {
441
+ pass(`${templateFiles.length} runtime templates — all valid`);
442
+ passes++;
443
+ } else {
444
+ failures += templateErrors;
445
+ }
446
+ } else {
447
+ warn('runtime/comfyui/ — no templates found');
448
+ warnings++;
449
+ }
450
+ } else {
451
+ warn('runtime/comfyui/ — directory not found');
452
+ warnings++;
453
+ }
454
+ }
455
+
456
+ // ── 12. Records sanity check ──
288
457
  const recordsDir = join(projectDir, 'records');
289
458
  if (existsSync(recordsDir)) {
290
459
  console.log('');
@@ -316,14 +485,11 @@ export async function run(argv = process.argv.slice(2)) {
316
485
  console.log(`═══ ${status} — ${passes} passed, ${failures} failed, ${warnings} warnings ═══`);
317
486
 
318
487
  if (failures > 0) {
319
- throw new Error(`Project "${projectName}" has ${failures} issue(s).`);
488
+ throw runtimeError('PROJECT_UNHEALTHY', `Project "${projectName}" has ${failures} issue(s).`);
320
489
  }
321
490
  }
322
491
 
323
492
  // Direct execution guard
324
493
  if (process.argv[1] && (process.argv[1].endsWith('doctor.js') || process.argv[1].endsWith('doctor'))) {
325
- run().catch((err) => {
326
- console.error(err.message || err);
327
- process.exit(1);
328
- });
494
+ run().catch(handleCliError);
329
495
  }