@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +36 -8
  3. package/bin/sdlab.js +129 -0
  4. package/lib/adapters/comfyui-runner.js +170 -0
  5. package/lib/adapters/comfyui-workflows.js +163 -0
  6. package/lib/batch-compiler.js +118 -0
  7. package/lib/batch-modes.js +111 -0
  8. package/lib/batch-runs.js +168 -0
  9. package/lib/batch-sheet-render.js +206 -0
  10. package/lib/brief-compiler.js +598 -0
  11. package/lib/brief-render.js +205 -0
  12. package/lib/critique-engine.js +413 -0
  13. package/lib/critique-render.js +171 -0
  14. package/lib/generated-provenance.js +87 -0
  15. package/lib/paths.js +31 -0
  16. package/lib/refine-briefs.js +329 -0
  17. package/lib/reingest-selected.js +173 -0
  18. package/lib/run-summary.js +131 -0
  19. package/lib/runtime-runs.js +168 -0
  20. package/lib/selections.js +323 -0
  21. package/lib/workflow-profiles.js +161 -0
  22. package/package.json +6 -3
  23. package/scripts/batch-generate.js +167 -0
  24. package/scripts/batch-sheet.js +46 -0
  25. package/scripts/batch-show.js +86 -0
  26. package/scripts/brief-compile.js +134 -0
  27. package/scripts/brief-show.js +52 -0
  28. package/scripts/critique-show.js +50 -0
  29. package/scripts/critique.js +70 -0
  30. package/scripts/doctor.js +169 -1
  31. package/scripts/init.js +22 -0
  32. package/scripts/refine.js +119 -0
  33. package/scripts/reingest-selected.js +78 -0
  34. package/scripts/reingest.js +7 -1
  35. package/scripts/run-generate.js +76 -0
  36. package/scripts/run-list.js +58 -0
  37. package/scripts/run-show.js +63 -0
  38. package/scripts/select.js +118 -0
  39. package/scripts/selection-show.js +88 -0
  40. package/scripts/workflow-list.js +61 -0
  41. package/scripts/workflow-show.js +96 -0
  42. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  43. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  44. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  45. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  46. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  47. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  48. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  49. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  50. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  51. package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
@@ -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
+ }
@@ -0,0 +1,118 @@
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
30
+ if (slot.negative_additions?.length > 0) {
31
+ const existingLower = modified.negative_prompt.toLowerCase().split(',').map(s => s.trim());
32
+ const additions = slot.negative_additions.filter(
33
+ n => !existingLower.some(e => e.includes(n.toLowerCase()))
34
+ );
35
+ if (additions.length > 0) {
36
+ modified.negative_prompt = `${modified.negative_prompt}, ${additions.join(', ')}`;
37
+ }
38
+ }
39
+
40
+ // Runtime overrides
41
+ if (slot.runtime_overrides) {
42
+ for (const [key, val] of Object.entries(slot.runtime_overrides)) {
43
+ if (modified.runtime_plan && val !== undefined) {
44
+ modified.runtime_plan[key] = val;
45
+ }
46
+ }
47
+ }
48
+
49
+ // Tag the slot
50
+ modified._batch_slot_id = slot.slot_id;
51
+ modified._batch_slot_label = slot.label;
52
+
53
+ return modified;
54
+ }
55
+
56
+ /**
57
+ * Compile a full batch — one brief per variant slot.
58
+ *
59
+ * @param {Object} opts
60
+ * @param {string} opts.projectRoot — absolute project path
61
+ * @param {string} opts.projectId — project name
62
+ * @param {string} opts.modeId — batch mode ID
63
+ * @param {string} [opts.subjectId] — subject for subject-driven modes
64
+ * @param {string} [opts.theme] — theme label for environment modes
65
+ * @param {string} [opts.assetRef] — optional training asset reference
66
+ * @param {Object} [opts.overrides] — CLI runtime overrides
67
+ * @returns {Promise<{ mode: Object, baseBrief: Object, slotBriefs: Object[] }>}
68
+ */
69
+ export async function compileBatch({
70
+ projectRoot,
71
+ projectId,
72
+ modeId,
73
+ subjectId,
74
+ theme,
75
+ assetRef,
76
+ overrides = {},
77
+ }) {
78
+ // Load batch mode
79
+ const mode = getBatchMode(projectRoot, modeId);
80
+
81
+ // Enforce subject mode
82
+ const subjectMode = mode.subject_mode || 'optional';
83
+ if (subjectMode === 'required' && !subjectId) {
84
+ throw inputError(
85
+ 'BATCH_SUBJECT_REQUIRED',
86
+ `Batch mode "${modeId}" requires a subject`,
87
+ 'Add --subject <id> to the command.'
88
+ );
89
+ }
90
+ if (subjectMode === 'forbidden' && subjectId) {
91
+ throw inputError(
92
+ 'BATCH_SUBJECT_FORBIDDEN',
93
+ `Batch mode "${modeId}" does not accept a subject`,
94
+ 'Remove --subject from the command.'
95
+ );
96
+ }
97
+
98
+ // Compile the base brief using the mode's base workflow
99
+ // Force output_count to 1 — each slot produces one primary candidate
100
+ const baseBrief = await compileBrief({
101
+ projectRoot,
102
+ projectId,
103
+ workflowId: mode.base_workflow_id,
104
+ subjectId,
105
+ assetRef,
106
+ overrides: { ...overrides, output_count: 1 },
107
+ });
108
+
109
+ // Add theme to prompt if provided
110
+ if (theme) {
111
+ baseBrief.prompt = `${baseBrief.prompt}, ${theme}`;
112
+ }
113
+
114
+ // Expand into slot briefs
115
+ const slotBriefs = mode.variant_plan.map(slot => applySlotDelta(baseBrief, slot));
116
+
117
+ return { mode, baseBrief, slotBriefs };
118
+ }
@@ -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
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Batch runs — create batch directories, execute coordinated runs, save manifests.
3
+ *
4
+ * A batch groups multiple runs under one artifact:
5
+ * batch_YYYY-MM-DD_NNN/
6
+ * manifest.json
7
+ * summary.json / summary.md
8
+ * sheets/
9
+ * runs/ (symlinks or copies to project-level runs)
10
+ * briefs/ (slot briefs)
11
+ */
12
+
13
+ import { writeFile, mkdir, copyFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
16
+ import { getProjectRoot, getRunsDir } from './paths.js';
17
+ import { executeRun } from './adapters/comfyui-runner.js';
18
+ import { saveRunSummary } from './run-summary.js';
19
+ import { log, info, warn } from './log.js';
20
+ import { inputError } from './errors.js';
21
+
22
+ /**
23
+ * Return the batches directory for a project.
24
+ */
25
+ export function getBatchesDir(projectRoot) {
26
+ return join(projectRoot, 'batches');
27
+ }
28
+
29
+ /**
30
+ * Generate batch ID: batch_YYYY-MM-DD_NNN
31
+ */
32
+ export function generateBatchId(batchesDir) {
33
+ const today = new Date().toISOString().slice(0, 10);
34
+ const prefix = `batch_${today}_`;
35
+
36
+ let maxSeq = 0;
37
+ if (existsSync(batchesDir)) {
38
+ const existing = readdirSync(batchesDir, { withFileTypes: true })
39
+ .filter(e => e.isDirectory() && e.name.startsWith(prefix));
40
+ for (const e of existing) {
41
+ const seqStr = e.name.slice(prefix.length);
42
+ const seq = parseInt(seqStr, 10);
43
+ if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
44
+ }
45
+ }
46
+
47
+ const seq = String(maxSeq + 1).padStart(3, '0');
48
+ return `${prefix}${seq}`;
49
+ }
50
+
51
+ /**
52
+ * Create a batch directory and prepare its structure.
53
+ */
54
+ export async function createBatchDir(projectRoot) {
55
+ const batchesDir = getBatchesDir(projectRoot);
56
+ const batchId = generateBatchId(batchesDir);
57
+ const batchDir = join(batchesDir, batchId);
58
+
59
+ await mkdir(join(batchDir, 'sheets'), { recursive: true });
60
+ await mkdir(join(batchDir, 'briefs'), { recursive: true });
61
+
62
+ return { batchId, batchDir };
63
+ }
64
+
65
+ /**
66
+ * Execute all slot briefs as coordinated runs.
67
+ *
68
+ * @param {Object} opts
69
+ * @param {string} opts.projectRoot
70
+ * @param {string} opts.projectName
71
+ * @param {string} opts.batchDir
72
+ * @param {Object} opts.mode — batch mode definition
73
+ * @param {Object[]} opts.slotBriefs — one brief per slot
74
+ * @param {boolean} [opts.dryRun]
75
+ * @returns {Promise<Object[]>} — array of { slot, brief, manifest }
76
+ */
77
+ export async function executeBatchRuns({
78
+ projectRoot,
79
+ projectName,
80
+ batchDir,
81
+ mode,
82
+ slotBriefs,
83
+ dryRun = false,
84
+ }) {
85
+ const results = [];
86
+
87
+ for (let i = 0; i < slotBriefs.length; i++) {
88
+ const slot = mode.variant_plan[i];
89
+ const brief = slotBriefs[i];
90
+ const slotLabel = slot.label || slot.slot_id;
91
+
92
+ info(`[${i + 1}/${slotBriefs.length}] Slot: ${slotLabel}`);
93
+
94
+ // Save brief into batch/briefs/
95
+ const briefFilename = `${slot.slot_id}.json`;
96
+ await writeFile(
97
+ join(batchDir, 'briefs', briefFilename),
98
+ JSON.stringify(brief, null, 2) + '\n'
99
+ );
100
+
101
+ // Execute the run through the existing 4B pipeline
102
+ const manifest = await executeRun({
103
+ projectRoot,
104
+ projectName,
105
+ brief,
106
+ dryRun,
107
+ });
108
+
109
+ // Save run summary
110
+ const runDir = join(getRunsDir(projectRoot), manifest.run_id);
111
+ await saveRunSummary(runDir, manifest);
112
+
113
+ results.push({
114
+ slot_id: slot.slot_id,
115
+ label: slotLabel,
116
+ brief_id: brief.brief_id,
117
+ run_id: manifest.run_id,
118
+ manifest,
119
+ // Pick the first successful output as the selected one
120
+ selected_output: manifest.outputs.find(o =>
121
+ o.status === 'ok' || o.status === 'dry_run'
122
+ )?.filename || null,
123
+ });
124
+ }
125
+
126
+ return results;
127
+ }
128
+
129
+ /**
130
+ * Save a batch manifest.
131
+ */
132
+ export async function saveBatchManifest(batchDir, manifest) {
133
+ await writeFile(
134
+ join(batchDir, 'manifest.json'),
135
+ JSON.stringify(manifest, null, 2) + '\n'
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Load a batch manifest by ID.
141
+ */
142
+ export function loadBatchManifest(projectRoot, batchId) {
143
+ const batchDir = join(getBatchesDir(projectRoot), batchId);
144
+ const manifestPath = join(batchDir, 'manifest.json');
145
+
146
+ if (!existsSync(manifestPath)) {
147
+ throw inputError(
148
+ 'BATCH_NOT_FOUND',
149
+ `Batch "${batchId}" not found`,
150
+ 'Check the batch ID or run: sdlab batch generate --mode <id>'
151
+ );
152
+ }
153
+
154
+ return JSON.parse(readFileSync(manifestPath, 'utf-8'));
155
+ }
156
+
157
+ /**
158
+ * List all batches for a project.
159
+ */
160
+ export function listBatches(projectRoot) {
161
+ const batchesDir = getBatchesDir(projectRoot);
162
+ if (!existsSync(batchesDir)) return [];
163
+
164
+ return readdirSync(batchesDir, { withFileTypes: true })
165
+ .filter(e => e.isDirectory() && e.name.startsWith('batch_'))
166
+ .sort((a, b) => a.name.localeCompare(b.name))
167
+ .map(e => e.name);
168
+ }