@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
@@ -0,0 +1,163 @@
1
+ /**
2
+ * ComfyUI workflow adapter — builds executable ComfyUI prompt graphs from briefs.
3
+ *
4
+ * The brief carries everything needed: prompt, negative, runtime plan,
5
+ * plus project defaults for checkpoint/LoRAs. This module translates
6
+ * that into a ComfyUI node graph.
7
+ */
8
+
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { getRuntimeDir } from '../paths.js';
13
+
14
+ // ─── Template registry ───────────────────────────────────────────
15
+
16
+ /**
17
+ * List available workflow templates from runtime/comfyui/.
18
+ */
19
+ export function listWorkflowTemplates() {
20
+ const dir = join(getRuntimeDir(), 'comfyui');
21
+ if (!existsSync(dir)) return [];
22
+ return readdirSync(dir)
23
+ .filter(f => f.endsWith('.json'))
24
+ .map(f => {
25
+ const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
26
+ return {
27
+ template_id: data.template_id,
28
+ label: data.label,
29
+ compatible_modes: data.compatible_modes || [],
30
+ };
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Resolve a template for a given output mode.
36
+ * Returns 'txt2img-standard' for all standard modes.
37
+ * Throws if no template matches.
38
+ */
39
+ export function resolveTemplateId(outputMode) {
40
+ const templates = listWorkflowTemplates();
41
+ const match = templates.find(t => t.compatible_modes.includes(outputMode));
42
+ if (match) return match.template_id;
43
+ // Default fallback for unknown modes
44
+ return 'txt2img-standard';
45
+ }
46
+
47
+ // ─── Graph builder ───────────────────────────────────────────────
48
+
49
+ /**
50
+ * Build a ComfyUI prompt graph from a compiled brief + project config.
51
+ *
52
+ * @param {Object} opts
53
+ * @param {Object} opts.brief — compiled brief from brief-compiler
54
+ * @param {Object} opts.projectDefaults — project.json defaults block
55
+ * @param {number} opts.seed — specific seed for this generation
56
+ * @returns {{ nodes: Object, saveNodeId: string }}
57
+ */
58
+ export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
59
+ const rp = brief.runtime_plan;
60
+ const defaults = projectDefaults;
61
+
62
+ const checkpoint = defaults.checkpoint;
63
+ const loras = defaults.loras || [];
64
+ const prompt = brief.prompt;
65
+ const negativePrompt = brief.negative_prompt;
66
+ const width = rp.width || defaults.width || 1024;
67
+ const height = rp.height || defaults.height || 1024;
68
+ const steps = rp.steps || defaults.steps || 30;
69
+ const cfg = rp.cfg || defaults.cfg || 6.5;
70
+ const sampler = rp.sampler || defaults.sampler || 'dpmpp_2m';
71
+ const scheduler = rp.scheduler || defaults.scheduler || 'karras';
72
+
73
+ const nodes = {};
74
+ let nextId = 1;
75
+
76
+ // Checkpoint loader
77
+ const ckptId = String(nextId++);
78
+ nodes[ckptId] = {
79
+ class_type: 'CheckpointLoaderSimple',
80
+ inputs: { ckpt_name: checkpoint },
81
+ };
82
+
83
+ let modelOut = [ckptId, 0];
84
+ let clipOut = [ckptId, 1];
85
+
86
+ // LoRA loaders (chained)
87
+ for (const lora of loras) {
88
+ const loraId = String(nextId++);
89
+ nodes[loraId] = {
90
+ class_type: 'LoraLoader',
91
+ inputs: {
92
+ model: modelOut,
93
+ clip: clipOut,
94
+ lora_name: lora.name,
95
+ strength_model: lora.weight,
96
+ strength_clip: lora.clip_weight ?? lora.weight,
97
+ },
98
+ };
99
+ modelOut = [loraId, 0];
100
+ clipOut = [loraId, 1];
101
+ }
102
+
103
+ // CLIP Text Encode — positive
104
+ const posId = String(nextId++);
105
+ nodes[posId] = {
106
+ class_type: 'CLIPTextEncode',
107
+ inputs: { text: prompt, clip: clipOut },
108
+ };
109
+
110
+ // CLIP Text Encode — negative
111
+ const negId = String(nextId++);
112
+ nodes[negId] = {
113
+ class_type: 'CLIPTextEncode',
114
+ inputs: { text: negativePrompt, clip: clipOut },
115
+ };
116
+
117
+ // Empty latent image
118
+ const latentId = String(nextId++);
119
+ nodes[latentId] = {
120
+ class_type: 'EmptyLatentImage',
121
+ inputs: { width, height, batch_size: 1 },
122
+ };
123
+
124
+ // KSampler
125
+ const samplerId = String(nextId++);
126
+ nodes[samplerId] = {
127
+ class_type: 'KSampler',
128
+ inputs: {
129
+ model: modelOut,
130
+ positive: [posId, 0],
131
+ negative: [negId, 0],
132
+ latent_image: [latentId, 0],
133
+ seed,
134
+ steps,
135
+ cfg,
136
+ sampler_name: sampler,
137
+ scheduler,
138
+ denoise: 1.0,
139
+ },
140
+ };
141
+
142
+ // VAE Decode
143
+ const vaeId = String(nextId++);
144
+ nodes[vaeId] = {
145
+ class_type: 'VAEDecode',
146
+ inputs: {
147
+ samples: [samplerId, 0],
148
+ vae: [ckptId, 2],
149
+ },
150
+ };
151
+
152
+ // Save Image
153
+ const saveId = String(nextId++);
154
+ nodes[saveId] = {
155
+ class_type: 'SaveImage',
156
+ inputs: {
157
+ images: [vaeId, 0],
158
+ filename_prefix: 'sdl_run',
159
+ },
160
+ };
161
+
162
+ return { nodes, saveNodeId: saveId };
163
+ }
@@ -68,8 +68,13 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
68
68
  const sourcePath = join(projectRoot, record.asset_path);
69
69
  if (!existsSync(sourcePath)) continue;
70
70
 
71
- const filename = record.asset_path.split('/').pop();
72
- const stem = filename.replace(/\.[^.]+$/, '');
71
+ // D-009: Name output files by record.id (not source basename) to
72
+ // avoid silent collisions when two records share a filename stem.
73
+ const sourceBasename = record.asset_path.split(/[\\/]/).pop();
74
+ const extMatch = sourceBasename.match(/\.[^.]+$/);
75
+ const ext = extMatch ? extMatch[0] : '';
76
+ const filename = `${record.id}${ext}`;
77
+ const stem = record.id;
73
78
  const destPath = join(datasetDir, filename);
74
79
 
75
80
  try {
@@ -57,7 +57,12 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
57
57
  const sourcePath = join(projectRoot, record.asset_path);
58
58
  if (!existsSync(sourcePath)) continue;
59
59
 
60
- const filename = record.asset_path.split('/').pop();
60
+ // D-009: Name output files by record.id (not source basename) to
61
+ // avoid silent collisions when two records share a filename stem.
62
+ const sourceBasename = record.asset_path.split(/[\\/]/).pop();
63
+ const extMatch = sourceBasename.match(/\.[^.]+$/);
64
+ const ext = extMatch ? extMatch[0] : '';
65
+ const filename = `${record.id}${ext}`;
61
66
  const destPath = join(datasetDir, filename);
62
67
 
63
68
  try {
package/lib/args.js CHANGED
@@ -10,12 +10,14 @@
10
10
  */
11
11
 
12
12
  import { warnDeprecated } from './deprecation.js';
13
+ import { inputError } from './errors.js';
13
14
 
14
15
  /**
15
16
  * @typedef {Object} ArgSpec
16
17
  * @property {Object<string, {type: 'string'|'boolean', default?: any, alias?: string}>} flags
17
18
  * @property {string[]} [positionals] - names for positional args (in order)
18
19
  * @property {Object<string, string>} [deprecated] - old flag name → new flag name
20
+ * @property {boolean} [allowUnknown] - permit unknown flags (default false)
19
21
  */
20
22
 
21
23
  /**
@@ -28,6 +30,22 @@ export function parseArgs(argv, spec = {}) {
28
30
  const flagDefs = spec.flags || {};
29
31
  const deprecated = spec.deprecated || {};
30
32
  const positionalNames = spec.positionals || [];
33
+ const allowUnknown = spec.allowUnknown === true;
34
+
35
+ // Build alias index once; detect duplicate aliases.
36
+ const aliasIndex = {};
37
+ for (const [name, def] of Object.entries(flagDefs)) {
38
+ if (def && def.alias) {
39
+ if (aliasIndex[def.alias]) {
40
+ throw inputError(
41
+ 'INPUT_DUPLICATE_ALIAS',
42
+ `Duplicate CLI alias "-${def.alias}" (on both "${aliasIndex[def.alias]}" and "${name}")`,
43
+ 'Fix the flag spec: each alias must be unique.'
44
+ );
45
+ }
46
+ aliasIndex[def.alias] = name;
47
+ }
48
+ }
31
49
 
32
50
  const flags = {};
33
51
  const positionals = [];
@@ -61,22 +79,44 @@ export function parseArgs(argv, spec = {}) {
61
79
  key = deprecated[key];
62
80
  }
63
81
 
64
- // Handle short aliases defined in flagDefs
65
- const canonical = Object.entries(flagDefs).find(([, def]) => def.alias === key);
66
- if (canonical) key = canonical[0];
82
+ // Handle short aliases defined in flagDefs (O(1) via prebuilt index)
83
+ if (aliasIndex[key]) key = aliasIndex[key];
67
84
 
68
85
  // Normalize kebab-case to camelCase for lookup
69
86
  const camelKey = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
70
87
  const def = flagDefs[key] || flagDefs[camelKey];
71
88
 
89
+ if (!def && !allowUnknown) {
90
+ const known = Object.keys(flagDefs);
91
+ const suggestion = findClosest(key, known);
92
+ const hint = suggestion
93
+ ? `Did you mean --${suggestion}? (Run with --help to see all supported flags.)`
94
+ : 'Run with --help to see supported flags.';
95
+ throw inputError(
96
+ 'UNKNOWN_FLAG',
97
+ `Unknown flag: --${key}`,
98
+ hint
99
+ );
100
+ }
101
+
102
+ const storeKey = key in flagDefs ? key : camelKey;
103
+
72
104
  if (def?.type === 'boolean') {
73
- flags[key in flagDefs ? key : camelKey] = true;
105
+ flags[storeKey] = true;
74
106
  } else if (value !== undefined) {
75
- flags[key in flagDefs ? key : camelKey] = value;
107
+ flags[storeKey] = value;
76
108
  } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
77
- flags[key in flagDefs ? key : camelKey] = argv[++i];
109
+ flags[storeKey] = argv[++i];
110
+ } else if (def && def.type === 'string') {
111
+ // String flag with no value — that's a user error.
112
+ throw inputError(
113
+ 'INPUT_MISSING_VALUE',
114
+ `Flag --${key} requires a value.`,
115
+ `Pass a value after --${key}.`
116
+ );
78
117
  } else {
79
- flags[key in flagDefs ? key : camelKey] = true;
118
+ // Unknown flag in allowUnknown mode (no def), treat as boolean-style.
119
+ flags[storeKey] = true;
80
120
  }
81
121
  } else {
82
122
  positionals.push(arg);
@@ -95,12 +135,159 @@ export function parseArgs(argv, spec = {}) {
95
135
 
96
136
  /**
97
137
  * Extract the --project (or deprecated --game) value from argv.
98
- * Convenience for scripts that just need the project name.
138
+ *
139
+ * Returns the explicit value if given. If missing, falls back to
140
+ * 'star-freight' with a LOUD stderr warning — the fallback exists for
141
+ * backward compatibility with v2.x scripts but should never be relied on
142
+ * in new code or documentation.
143
+ *
144
+ * Set SDLAB_QUIET_FALLBACK=1 to silence the warning in scripted pipelines.
99
145
  */
100
146
  export function getProjectName(argv) {
101
- const { flags } = parseArgs(argv, {
102
- flags: { project: { type: 'string', default: 'star-freight' } },
103
- deprecated: { game: 'project' },
104
- });
105
- return flags.project;
147
+ // Manual parse (avoids triggering UNKNOWN_FLAG on caller's sibling flags)
148
+ let explicit = null;
149
+ for (let i = 0; i < argv.length; i++) {
150
+ const a = argv[i];
151
+ if (a === '--project' || a === '--game') {
152
+ if (a === '--game') warnDeprecated('game', 'project');
153
+ explicit = argv[i + 1];
154
+ break;
155
+ }
156
+ if (a.startsWith('--project=')) { explicit = a.slice('--project='.length); break; }
157
+ if (a.startsWith('--game=')) { warnDeprecated('game', 'project'); explicit = a.slice('--game='.length); break; }
158
+ }
159
+ if (explicit) return explicit;
160
+
161
+ // Backward compat: fall back to star-freight but warn LOUDLY.
162
+ if (process.env.SDLAB_QUIET_FALLBACK !== '1') {
163
+ process.stderr.write(
164
+ '\x1b[33m⚠ No --project specified, falling back to "star-freight".\x1b[0m\n' +
165
+ '\x1b[33m Use --project <name> to be explicit (set SDLAB_QUIET_FALLBACK=1 to silence).\x1b[0m\n'
166
+ );
167
+ }
168
+ return 'star-freight';
169
+ }
170
+
171
+ /**
172
+ * Compute Levenshtein edit distance between two strings.
173
+ * Small hand-rolled implementation — O(m*n) time, O(min(m,n)) space.
174
+ */
175
+ export function levenshtein(a, b) {
176
+ if (a === b) return 0;
177
+ if (!a) return b.length;
178
+ if (!b) return a.length;
179
+ // Keep the shorter string in the inner loop for better memory.
180
+ if (a.length > b.length) { const t = a; a = b; b = t; }
181
+ const prev = new Array(a.length + 1);
182
+ const curr = new Array(a.length + 1);
183
+ for (let i = 0; i <= a.length; i++) prev[i] = i;
184
+ for (let j = 1; j <= b.length; j++) {
185
+ curr[0] = j;
186
+ for (let i = 1; i <= a.length; i++) {
187
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
188
+ curr[i] = Math.min(
189
+ curr[i - 1] + 1, // insertion
190
+ prev[i] + 1, // deletion
191
+ prev[i - 1] + cost // substitution
192
+ );
193
+ }
194
+ for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
195
+ }
196
+ return prev[a.length];
197
+ }
198
+
199
+ /**
200
+ * Find the nearest match to `needle` in `candidates` within `maxDistance`.
201
+ * Returns the closest candidate, or null if nothing qualifies.
202
+ * Used for "did you mean?" hints on unknown flags / commands.
203
+ */
204
+ export function findClosest(needle, candidates, maxDistance = 2) {
205
+ if (!needle || !Array.isArray(candidates) || candidates.length === 0) return null;
206
+ // Short inputs have little tolerance.
207
+ const cap = needle.length <= 3 ? 1 : maxDistance;
208
+ let best = null;
209
+ let bestDist = Infinity;
210
+ for (const c of candidates) {
211
+ if (typeof c !== 'string') continue;
212
+ const d = levenshtein(needle.toLowerCase(), c.toLowerCase());
213
+ if (d < bestDist && d <= cap) {
214
+ bestDist = d;
215
+ best = c;
216
+ }
217
+ }
218
+ return best;
219
+ }
220
+
221
+ /**
222
+ * Parse a CLI value as a number with NaN validation.
223
+ * @param {string} name - flag name, for error messages
224
+ * @param {string|number|undefined} value - the raw flag value
225
+ * @param {{min?:number,max?:number,int?:boolean,default?:number}} opts
226
+ */
227
+ export function parseNumberFlag(name, value, opts = {}) {
228
+ if (value === undefined || value === null || value === '') {
229
+ if (opts.default !== undefined) return opts.default;
230
+ throw inputError(
231
+ 'INPUT_MISSING_VALUE',
232
+ `Flag --${name} requires a numeric value.`,
233
+ `Example: --${name} 4`
234
+ );
235
+ }
236
+ const n = opts.int ? Number.parseInt(String(value), 10) : Number.parseFloat(String(value));
237
+ if (!Number.isFinite(n)) {
238
+ throw inputError(
239
+ 'INPUT_BAD_NUMBER',
240
+ `Flag --${name} expected a number, got "${value}".`,
241
+ `Pass a ${opts.int ? 'whole number' : 'number'} instead.`
242
+ );
243
+ }
244
+ if (opts.min !== undefined && n < opts.min) {
245
+ throw inputError('INPUT_OUT_OF_RANGE', `--${name} must be ≥ ${opts.min}, got ${n}.`);
246
+ }
247
+ if (opts.max !== undefined && n > opts.max) {
248
+ throw inputError('INPUT_OUT_OF_RANGE', `--${name} must be ≤ ${opts.max}, got ${n}.`);
249
+ }
250
+ return n;
251
+ }
252
+
253
+ /**
254
+ * Read an indexed-next-arg value from argv but refuse to consume a flag-like token.
255
+ * Useful for legacy scripts still using indexOf/argv[idx+1] patterns — wraps the access
256
+ * with a safety check so `--source --manifest` doesn't silently treat `--manifest` as a path.
257
+ */
258
+ export function takeFlagValue(argv, flagName) {
259
+ const idx = argv.indexOf(`--${flagName}`);
260
+ if (idx < 0) {
261
+ // Try --flag=value form
262
+ const eq = argv.find(a => a.startsWith(`--${flagName}=`));
263
+ if (eq) return eq.slice(`--${flagName}=`.length);
264
+ return undefined;
265
+ }
266
+ const nxt = argv[idx + 1];
267
+ if (nxt === undefined) {
268
+ throw inputError('INPUT_MISSING_VALUE', `Flag --${flagName} requires a value.`);
269
+ }
270
+ if (typeof nxt === 'string' && nxt.startsWith('--')) {
271
+ throw inputError(
272
+ 'INPUT_MISSING_VALUE',
273
+ `Flag --${flagName} is missing its value (got another flag: ${nxt}).`,
274
+ `Pass a value after --${flagName}.`
275
+ );
276
+ }
277
+ return nxt;
278
+ }
279
+
280
+ /**
281
+ * Strip the subcommand token at position argv[firstPositionalIndex] by slicing,
282
+ * avoiding the value-based filter bug where a project/id that matches the
283
+ * subcommand string would be stripped too.
284
+ *
285
+ * Returns the args AFTER the first non-flag token. If no non-flag token is
286
+ * found, returns a copy of argv.
287
+ */
288
+ export function sliceAfterSubcommand(argv, subcommand) {
289
+ if (subcommand == null) return argv.slice();
290
+ const idx = argv.indexOf(subcommand);
291
+ if (idx < 0) return argv.slice();
292
+ return argv.slice(idx + 1);
106
293
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Batch compiler — expand a batch mode into multiple coordinated briefs.
3
+ *
4
+ * Takes a batch mode + subject/theme inputs and produces one brief per
5
+ * variant slot, each with controlled prompt/negative/runtime deltas
6
+ * on top of a base compiled brief from 4A.
7
+ */
8
+
9
+ import { compileBrief, saveCompiledBrief } from './brief-compiler.js';
10
+ import { getBatchMode } from './batch-modes.js';
11
+ import { getWorkflowProfile } from './workflow-profiles.js';
12
+ import { inputError } from './errors.js';
13
+
14
+ /**
15
+ * Apply slot-level deltas to a base brief.
16
+ *
17
+ * @param {Object} baseBrief — compiled brief from brief-compiler
18
+ * @param {Object} slot — variant_plan slot definition
19
+ * @returns {Object} — modified brief (new object, base untouched)
20
+ */
21
+ export function applySlotDelta(baseBrief, slot) {
22
+ const modified = JSON.parse(JSON.stringify(baseBrief));
23
+
24
+ // Prompt additions — append to the end
25
+ if (slot.prompt_additions?.length > 0) {
26
+ modified.prompt = `${modified.prompt}, ${slot.prompt_additions.join(', ')}`;
27
+ }
28
+
29
+ // Negative additions — append without duplicating (token-exact match)
30
+ if (slot.negative_additions?.length > 0) {
31
+ const existingLower = modified.negative_prompt
32
+ .toLowerCase()
33
+ .split(',')
34
+ .map(s => s.trim())
35
+ .filter(s => s.length > 0);
36
+ const additions = slot.negative_additions.filter(
37
+ n => !existingLower.includes(n.toLowerCase().trim())
38
+ );
39
+ if (additions.length > 0) {
40
+ modified.negative_prompt = `${modified.negative_prompt}, ${additions.join(', ')}`;
41
+ }
42
+ }
43
+
44
+ // Runtime overrides
45
+ if (slot.runtime_overrides) {
46
+ for (const [key, val] of Object.entries(slot.runtime_overrides)) {
47
+ if (modified.runtime_plan && val !== undefined) {
48
+ modified.runtime_plan[key] = val;
49
+ }
50
+ }
51
+ }
52
+
53
+ // Tag the slot
54
+ modified._batch_slot_id = slot.slot_id;
55
+ modified._batch_slot_label = slot.label;
56
+
57
+ return modified;
58
+ }
59
+
60
+ /**
61
+ * Compile a full batch — one brief per variant slot.
62
+ *
63
+ * @param {Object} opts
64
+ * @param {string} opts.projectRoot — absolute project path
65
+ * @param {string} opts.projectId — project name
66
+ * @param {string} opts.modeId — batch mode ID
67
+ * @param {string} [opts.subjectId] — subject for subject-driven modes
68
+ * @param {string} [opts.theme] — theme label for environment modes
69
+ * @param {string} [opts.assetRef] — optional training asset reference
70
+ * @param {Object} [opts.overrides] — CLI runtime overrides
71
+ * @returns {Promise<{ mode: Object, baseBrief: Object, slotBriefs: Object[] }>}
72
+ */
73
+ export async function compileBatch({
74
+ projectRoot,
75
+ projectId,
76
+ modeId,
77
+ subjectId,
78
+ theme,
79
+ assetRef,
80
+ overrides = {},
81
+ }) {
82
+ // Load batch mode
83
+ const mode = getBatchMode(projectRoot, modeId);
84
+
85
+ // Enforce subject mode
86
+ const subjectMode = mode.subject_mode || 'optional';
87
+ if (subjectMode === 'required' && !subjectId) {
88
+ throw inputError(
89
+ 'BATCH_SUBJECT_REQUIRED',
90
+ `Batch mode "${modeId}" requires a subject`,
91
+ 'Add --subject <id> to the command.'
92
+ );
93
+ }
94
+ if (subjectMode === 'forbidden' && subjectId) {
95
+ throw inputError(
96
+ 'BATCH_SUBJECT_FORBIDDEN',
97
+ `Batch mode "${modeId}" does not accept a subject`,
98
+ 'Remove --subject from the command.'
99
+ );
100
+ }
101
+
102
+ // Compile the base brief using the mode's base workflow
103
+ // Force output_count to 1 — each slot produces one primary candidate
104
+ const baseBrief = await compileBrief({
105
+ projectRoot,
106
+ projectId,
107
+ workflowId: mode.base_workflow_id,
108
+ subjectId,
109
+ assetRef,
110
+ overrides: { ...overrides, output_count: 1 },
111
+ });
112
+
113
+ // Add theme to prompt if provided
114
+ if (theme) {
115
+ baseBrief.prompt = `${baseBrief.prompt}, ${theme}`;
116
+ }
117
+
118
+ // Expand into slot briefs
119
+ const slotBriefs = mode.variant_plan.map(slot => applySlotDelta(baseBrief, slot));
120
+
121
+ return { mode, baseBrief, slotBriefs };
122
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Batch modes — load, validate, list batch production mode definitions.
3
+ *
4
+ * A batch mode is a production recipe above the single-brief level.
5
+ * It defines what deliverable is being made, how many coordinated briefs
6
+ * are needed, and how results should be assembled.
7
+ */
8
+
9
+ import { join } from 'node:path';
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { getProjectRoot } from './paths.js';
12
+ import { inputError } from './errors.js';
13
+
14
+ const VALID_BATCH_TYPES = [
15
+ 'expression_sheet', 'variant_board', 'moodboard',
16
+ 'silhouette_grid', 'continuity_pack',
17
+ ];
18
+ const VALID_LAYOUTS = ['grid', 'moodboard', 'sheet', 'strip'];
19
+ const VALID_SUBJECT_MODES = ['required', 'optional', 'forbidden'];
20
+
21
+ /**
22
+ * Return the batch modes directory for a project.
23
+ */
24
+ export function getBatchModesDir(projectRoot) {
25
+ return join(projectRoot, 'workflows', 'batch-modes');
26
+ }
27
+
28
+ /**
29
+ * List all batch modes for a project.
30
+ */
31
+ export function listBatchModes(projectRoot) {
32
+ const dir = getBatchModesDir(projectRoot);
33
+ if (!existsSync(dir)) return [];
34
+
35
+ return readdirSync(dir)
36
+ .filter(f => f.endsWith('.json'))
37
+ .map(f => {
38
+ const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
39
+ return {
40
+ mode_id: data.mode_id,
41
+ label: data.label,
42
+ batch_type: data.batch_type,
43
+ base_workflow_id: data.base_workflow_id,
44
+ subject_mode: data.subject_mode || 'optional',
45
+ slot_count: data.variant_plan?.length || 0,
46
+ };
47
+ })
48
+ .sort((a, b) => a.mode_id.localeCompare(b.mode_id));
49
+ }
50
+
51
+ /**
52
+ * Load a batch mode by ID.
53
+ */
54
+ export function getBatchMode(projectRoot, modeId) {
55
+ const dir = getBatchModesDir(projectRoot);
56
+ const filePath = join(dir, `${modeId}.json`);
57
+
58
+ if (!existsSync(filePath)) {
59
+ throw inputError(
60
+ 'BATCH_MODE_NOT_FOUND',
61
+ `Batch mode "${modeId}" not found`,
62
+ `Check workflows/batch-modes/ or run: sdlab batch list`
63
+ );
64
+ }
65
+
66
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
67
+ }
68
+
69
+ /**
70
+ * Validate a batch mode definition.
71
+ * @returns {{ valid: boolean, errors: string[] }}
72
+ */
73
+ export function validateBatchMode(mode) {
74
+ const errors = [];
75
+
76
+ if (!mode.mode_id) errors.push('missing mode_id');
77
+ if (!mode.label) errors.push('missing label');
78
+ if (!mode.base_workflow_id) errors.push('missing base_workflow_id');
79
+
80
+ if (!mode.batch_type || !VALID_BATCH_TYPES.includes(mode.batch_type)) {
81
+ errors.push(`invalid batch_type "${mode.batch_type}" — must be one of: ${VALID_BATCH_TYPES.join(', ')}`);
82
+ }
83
+
84
+ if (mode.subject_mode && !VALID_SUBJECT_MODES.includes(mode.subject_mode)) {
85
+ errors.push(`invalid subject_mode "${mode.subject_mode}"`);
86
+ }
87
+
88
+ if (!Array.isArray(mode.variant_plan) || mode.variant_plan.length === 0) {
89
+ errors.push('variant_plan must be a non-empty array');
90
+ } else {
91
+ const slotIds = new Set();
92
+ for (const slot of mode.variant_plan) {
93
+ if (!slot.slot_id) errors.push('variant slot missing slot_id');
94
+ if (!slot.label) errors.push('variant slot missing label');
95
+ if (slot.slot_id && slotIds.has(slot.slot_id)) {
96
+ errors.push(`duplicate slot_id "${slot.slot_id}"`);
97
+ }
98
+ slotIds.add(slot.slot_id);
99
+ }
100
+ }
101
+
102
+ if (!mode.assembly) {
103
+ errors.push('missing assembly');
104
+ } else {
105
+ if (!mode.assembly.layout || !VALID_LAYOUTS.includes(mode.assembly.layout)) {
106
+ errors.push(`invalid assembly.layout "${mode.assembly?.layout}" — must be one of: ${VALID_LAYOUTS.join(', ')}`);
107
+ }
108
+ }
109
+
110
+ return { valid: errors.length === 0, errors };
111
+ }