@mcptoolshop/style-dataset-lab 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +108 -15
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +71 -0
  4. package/README.es.md +99 -8
  5. package/README.fr.md +100 -9
  6. package/README.hi.md +102 -9
  7. package/README.it.md +99 -8
  8. package/README.ja.md +100 -9
  9. package/README.md +64 -1
  10. package/README.pt-BR.md +99 -8
  11. package/README.zh.md +100 -9
  12. package/SECURITY.md +6 -5
  13. package/bin/sdlab.js +289 -62
  14. package/lib/adapters/comfyui-runner.js +11 -15
  15. package/lib/adapters/diffusers-lora.js +7 -2
  16. package/lib/adapters/generic-image-caption.js +6 -1
  17. package/lib/args.js +200 -13
  18. package/lib/batch-compiler.js +7 -3
  19. package/lib/batch-runs.js +162 -31
  20. package/lib/batch-sheet-render.js +5 -2
  21. package/lib/brief-compiler.js +38 -11
  22. package/lib/card.js +11 -4
  23. package/lib/comfyui-output.js +70 -0
  24. package/lib/comfyui.js +100 -6
  25. package/lib/config.js +68 -3
  26. package/lib/deprecation.js +1 -1
  27. package/lib/eval-pack.js +94 -4
  28. package/lib/eval-runs.js +42 -3
  29. package/lib/export.js +144 -34
  30. package/lib/implementation-packs.js +15 -1
  31. package/lib/log.js +41 -4
  32. package/lib/paths.js +41 -3
  33. package/lib/refine-briefs.js +16 -9
  34. package/lib/reingest-selected.js +14 -3
  35. package/lib/reingest.js +15 -1
  36. package/lib/run-summary.js +3 -2
  37. package/lib/runtime-runs.js +59 -9
  38. package/lib/selections.js +54 -2
  39. package/lib/snapshot.js +100 -7
  40. package/lib/split.js +175 -17
  41. package/lib/training-adapters.js +79 -0
  42. package/lib/training-manifests.js +17 -3
  43. package/lib/training-packages.js +29 -28
  44. package/lib/training-profiles.js +10 -0
  45. package/package.json +27 -3
  46. package/scripts/batch-generate.js +72 -20
  47. package/scripts/batch-sheet.js +3 -3
  48. package/scripts/batch-show.js +3 -3
  49. package/scripts/brief-compile.js +8 -8
  50. package/scripts/brief-show.js +4 -3
  51. package/scripts/canon-bind.js +7 -9
  52. package/scripts/card.js +22 -25
  53. package/scripts/compare.js +38 -33
  54. package/scripts/critique-show.js +3 -3
  55. package/scripts/critique.js +3 -3
  56. package/scripts/curate.js +91 -46
  57. package/scripts/doctor.js +3 -5
  58. package/scripts/eligibility.js +17 -14
  59. package/scripts/eval-pack.js +13 -11
  60. package/scripts/eval-run.js +30 -29
  61. package/scripts/export.js +27 -31
  62. package/scripts/generate-controlnet.js +31 -17
  63. package/scripts/generate-identity.js +37 -20
  64. package/scripts/generate-ipadapter.js +22 -16
  65. package/scripts/generate.js +96 -29
  66. package/scripts/implementation-pack.js +19 -14
  67. package/scripts/init.js +115 -17
  68. package/scripts/painterly.js +8 -10
  69. package/scripts/refine.js +3 -3
  70. package/scripts/reingest-selected.js +3 -3
  71. package/scripts/reingest.js +33 -20
  72. package/scripts/run-generate.js +4 -4
  73. package/scripts/run-list.js +3 -3
  74. package/scripts/run-show.js +3 -3
  75. package/scripts/select.js +5 -4
  76. package/scripts/selection-show.js +3 -3
  77. package/scripts/snapshot.js +22 -24
  78. package/scripts/split.js +26 -31
  79. package/scripts/training-manifest.js +29 -24
  80. package/scripts/training-package.js +24 -20
  81. package/scripts/training-profile.js +13 -8
  82. package/scripts/workflow-list.js +2 -2
  83. package/scripts/workflow-show.js +4 -3
  84. package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
  85. package/templates/domains/architecture/terminology.json +10 -8
  86. package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
  87. package/templates/domains/character-design/project.json +4 -2
  88. package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
  89. package/templates/domains/creature-design/project.json +4 -2
  90. package/templates/domains/creature-design/terminology.json +1 -0
  91. package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
  92. package/templates/domains/game-art/project.json +4 -2
  93. package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
  94. package/templates/domains/vehicle-mech/project.json +4 -2
  95. package/scripts/bulk-curate-wave2-5.js +0 -138
  96. package/scripts/bulk-curate-waves11-18.js +0 -413
  97. package/scripts/curate-wave25.js +0 -209
  98. package/scripts/migrate-records.js +0 -141
  99. package/templates/inputs/prompts/example-wave.json +0 -27
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Training adapter registry.
3
+ *
4
+ * Single source of truth for training-package adapters. Each adapter
5
+ * lives in `lib/adapters/<id>.js` and exports `buildPackage(opts)`.
6
+ *
7
+ * To add a new adapter (e.g. `kohya-lora`, `onedtrainer`):
8
+ * 1. Add `lib/adapters/<id>.js` exporting `buildPackage`.
9
+ * 2. Add an entry to ADAPTER_REGISTRY below with a one-line description.
10
+ *
11
+ * Why explicit registration: the available adapter list shows up in error
12
+ * messages, training-profile validation, and (eventually) CLI completion.
13
+ * A static map keeps those views consistent and makes new adapters a
14
+ * one-line surface change rather than a directory-listing side effect.
15
+ */
16
+
17
+ import { inputError } from './errors.js';
18
+
19
+ export const ADAPTER_REGISTRY = Object.freeze({
20
+ 'generic-image-caption': {
21
+ id: 'generic-image-caption',
22
+ description: 'Image folders + caption JSONL — broadest trainer compatibility',
23
+ module: './adapters/generic-image-caption.js',
24
+ },
25
+ 'diffusers-lora': {
26
+ id: 'diffusers-lora',
27
+ description: 'Diffusers fine-tuning layout: image + caption .txt sidecars + per-partition JSONL',
28
+ module: './adapters/diffusers-lora.js',
29
+ },
30
+ });
31
+
32
+ /**
33
+ * Names of all registered adapters.
34
+ * @returns {string[]}
35
+ */
36
+ export function listAdapters() {
37
+ return Object.keys(ADAPTER_REGISTRY);
38
+ }
39
+
40
+ /**
41
+ * Whether `id` names a registered adapter.
42
+ * @param {string} id
43
+ * @returns {boolean}
44
+ */
45
+ export function isRegisteredAdapter(id) {
46
+ return Object.prototype.hasOwnProperty.call(ADAPTER_REGISTRY, id);
47
+ }
48
+
49
+ /**
50
+ * Resolve and dynamically import an adapter module.
51
+ *
52
+ * Throws `ADAPTER_NOT_REGISTERED` (input error, exit 1) for unknown ids
53
+ * — distinct from `MODULE_LOAD_FAILED` (runtime, exit 2) for the rare
54
+ * case where the file vanished after registration.
55
+ *
56
+ * @param {string} id
57
+ * @returns {Promise<{ buildPackage: Function }>}
58
+ */
59
+ export async function loadAdapter(id) {
60
+ if (!isRegisteredAdapter(id)) {
61
+ throw inputError(
62
+ 'ADAPTER_NOT_REGISTERED',
63
+ `Adapter "${id}" is not registered. Available: ${listAdapters().join(', ')}`,
64
+ 'Pick one of the registered adapters or add a new one to lib/training-adapters.js.'
65
+ );
66
+ }
67
+ const entry = ADAPTER_REGISTRY[id];
68
+ try {
69
+ return await import(entry.module);
70
+ } catch (err) {
71
+ // The registry says it should exist but import failed — surface the
72
+ // underlying error so it doesn't get masked as a typo.
73
+ throw inputError(
74
+ 'ADAPTER_MODULE_LOAD_FAILED',
75
+ `Adapter "${id}" is registered but its module failed to load: ${err.message}`,
76
+ `Check that ${entry.module} exists and exports buildPackage().`
77
+ );
78
+ }
79
+ }
@@ -12,8 +12,9 @@ import { existsSync } from 'node:fs';
12
12
  import { createHash, randomBytes } from 'node:crypto';
13
13
  import { loadExport } from './export.js';
14
14
  import { loadTrainingProfile } from './training-profiles.js';
15
- import { computeConfigFingerprint } from './snapshot.js';
15
+ import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
16
16
  import { listEvalPacks } from './eval-pack.js';
17
+ import { inputError } from './errors.js';
17
18
 
18
19
  /**
19
20
  * Generate a training manifest ID: tm-YYYYMMDD-HHMMSS-XXXX
@@ -64,6 +65,8 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
64
65
 
65
66
  const manifestId = generateManifestId();
66
67
  const manifest = {
68
+ // DB-001 / DB-006: stamp manifest schema version
69
+ schema_version: SCHEMA_VERSION,
67
70
  training_manifest_id: manifestId,
68
71
  created_at: new Date().toISOString(),
69
72
  created_by: 'sdlab-training-manifest-v1',
@@ -89,7 +92,16 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
89
92
  // Write manifest
90
93
  const dir = join(projectRoot, 'training', 'manifests');
91
94
  await mkdir(dir, { recursive: true });
92
- await writeFile(join(dir, `${manifestId}.json`), JSON.stringify(manifest, null, 2) + '\n');
95
+ const manifestPath = join(dir, `${manifestId}.json`);
96
+ // D-005: refuse to silently overwrite on ID collision.
97
+ if (existsSync(manifestPath)) {
98
+ throw inputError(
99
+ 'TRAINING_MANIFEST_ID_COLLISION',
100
+ `Training manifest file already exists: ${manifestPath}`,
101
+ 'Retry to generate a fresh manifest ID.',
102
+ );
103
+ }
104
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
93
105
 
94
106
  return { manifestId, exportId, profileId };
95
107
  }
@@ -139,7 +151,9 @@ export async function loadTrainingManifest(projectRoot, manifestId) {
139
151
  if (!existsSync(path)) {
140
152
  throw new Error(`Training manifest "${manifestId}" not found at ${path}`);
141
153
  }
142
- return JSON.parse(await readFile(path, 'utf-8'));
154
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
155
+ checkManifestVersion(manifest, 'training-manifest');
156
+ return manifest;
143
157
  }
144
158
 
145
159
  /**
@@ -15,6 +15,9 @@ import { loadTrainingProfile } from './training-profiles.js';
15
15
  import { loadSplitPartition } from './split.js';
16
16
  import { loadRecord } from './records.js';
17
17
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
+ import { inputError } from './errors.js';
19
+ import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
20
+ import { loadAdapter } from './training-adapters.js';
18
21
 
19
22
  /**
20
23
  * Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
@@ -31,20 +34,6 @@ async function sha256File(filePath) {
31
34
  return createHash('sha256').update(data).digest('hex');
32
35
  }
33
36
 
34
- /**
35
- * Load an adapter module by name.
36
- */
37
- async function loadAdapter(adapterTarget) {
38
- const adapterPath = `./adapters/${adapterTarget}.js`;
39
- try {
40
- return await import(adapterPath);
41
- } catch {
42
- throw new Error(
43
- `Adapter "${adapterTarget}" not found. Available: generic-image-caption, diffusers-lora`
44
- );
45
- }
46
- }
47
-
48
37
  /**
49
38
  * Build a training package from a manifest.
50
39
  *
@@ -63,6 +52,14 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
63
52
  const recordsDir = join(projectRoot, 'records');
64
53
  const packageId = generatePackageId();
65
54
  const packageDir = join(projectRoot, 'training', 'packages', packageId);
55
+ // D-005: refuse to silently overwrite on ID collision.
56
+ if (existsSync(packageDir)) {
57
+ throw inputError(
58
+ 'TRAINING_PACKAGE_ID_COLLISION',
59
+ `Training package directory already exists: ${packageDir}`,
60
+ 'Retry to generate a fresh package ID.',
61
+ );
62
+ }
66
63
 
67
64
  // Load split partitions
68
65
  const partitions = {};
@@ -105,19 +102,10 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
105
102
 
106
103
  totalImages = adapterResult.imageCount || 0;
107
104
 
108
- // Generate checksums for all files in the package
109
- const checksumEntries = [];
110
- const allFiles = await collectFiles(packageDir);
111
- for (const filePath of allFiles.sort()) {
112
- const relPath = relative(packageDir, filePath);
113
- if (relPath === 'checksums.txt') continue;
114
- const hash = await sha256File(filePath);
115
- checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
116
- }
117
- await writeFile(join(packageDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
118
-
119
- // Write package manifest (last — references everything)
105
+ // D-015: Write manifest + README FIRST so they are covered by checksums.
120
106
  const packageManifest = {
107
+ // DB-001 / DB-006: stamp manifest schema version
108
+ schema_version: SCHEMA_VERSION,
121
109
  training_package_id: packageId,
122
110
  created_at: new Date().toISOString(),
123
111
  created_by: 'sdlab-training-package-v1',
@@ -134,7 +122,7 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
134
122
  val: partitions.val.length,
135
123
  test: partitions.test.length,
136
124
  images: totalImages,
137
- checksums: checksumEntries.length,
125
+ checksums: 0, // updated after checksum pass below
138
126
  },
139
127
  };
140
128
  await writeFile(join(packageDir, 'manifest.json'), JSON.stringify(packageManifest, null, 2) + '\n');
@@ -143,6 +131,17 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
143
131
  const readme = generatePackageReadme(packageManifest, profile, manifest, adapterTarget);
144
132
  await writeFile(join(packageDir, 'README.md'), readme);
145
133
 
134
+ // Generate checksums AFTER manifest+README are on disk so they are included.
135
+ const checksumEntries = [];
136
+ const allFiles = await collectFiles(packageDir);
137
+ for (const filePath of allFiles.sort()) {
138
+ const relPath = relative(packageDir, filePath);
139
+ if (relPath === 'checksums.txt') continue;
140
+ const hash = await sha256File(filePath);
141
+ checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
142
+ }
143
+ await writeFile(join(packageDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
144
+
146
145
  return { packageId, records: totalRecords, images: totalImages };
147
146
  }
148
147
 
@@ -237,5 +236,7 @@ export async function loadTrainingPackage(projectRoot, packageId) {
237
236
  if (!existsSync(path)) {
238
237
  throw new Error(`Training package "${packageId}" not found at ${path}`);
239
238
  }
240
- return JSON.parse(await readFile(path, 'utf-8'));
239
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
240
+ checkManifestVersion(manifest, 'training-package');
241
+ return manifest;
241
242
  }
@@ -9,6 +9,7 @@
9
9
  import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { existsSync } from 'node:fs';
12
+ import { isRegisteredAdapter, listAdapters } from './training-adapters.js';
12
13
 
13
14
  const REQUIRED_FIELDS = ['profile_id', 'label', 'target_family', 'asset_type', 'eligible_lanes', 'adapter_targets'];
14
15
 
@@ -32,6 +33,15 @@ export function validateProfile(profile) {
32
33
  if (profile.adapter_targets?.length === 0) {
33
34
  errors.push('adapter_targets must have at least one target');
34
35
  }
36
+ // Reject unregistered adapter targets early — surfaces typos at profile
37
+ // load time instead of at package-build time.
38
+ if (Array.isArray(profile.adapter_targets)) {
39
+ for (const target of profile.adapter_targets) {
40
+ if (!isRegisteredAdapter(target)) {
41
+ errors.push(`adapter_targets contains unregistered adapter "${target}". Available: ${listAdapters().join(', ')}`);
42
+ }
43
+ }
44
+ }
35
45
  return { valid: errors.length === 0, errors };
36
46
  }
37
47
 
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@mcptoolshop/style-dataset-lab",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Canon-aligned dataset production and generation workbench — define visual rules, build versioned training data, compile production briefs, run local workflows, batch-produce, select winners, and re-ingest into your corpus",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sdlab": "./bin/sdlab.js"
8
8
  },
9
9
  "scripts": {
10
+ "test": "node --test tests/**/*.test.js",
11
+ "coverage": "c8 npm test",
10
12
  "verify": "node bin/sdlab.js --help && node bin/sdlab.js project doctor --project star-freight",
11
13
  "generate": "node scripts/generate.js",
12
14
  "curate": "node scripts/curate.js",
@@ -15,15 +17,34 @@
15
17
  "painterly": "node scripts/painterly.js",
16
18
  "generate:identity": "node scripts/generate-identity.js"
17
19
  },
20
+ "c8": {
21
+ "include": ["lib/**/*.js"],
22
+ "exclude": [
23
+ "tests/**",
24
+ "scripts/**",
25
+ "site/**",
26
+ "projects/**",
27
+ "templates/**",
28
+ "**/*.test.js"
29
+ ],
30
+ "reporter": ["text", "lcov", "text-summary"],
31
+ "report-dir": "coverage",
32
+ "all": true
33
+ },
18
34
  "files": [
19
35
  "bin/",
20
36
  "lib/",
21
37
  "scripts/",
38
+ "!scripts/bulk-curate-*.js",
39
+ "!scripts/curate-wave*.js",
40
+ "!scripts/migrate-records.js",
22
41
  "templates/",
23
42
  "README.md",
24
43
  "LICENSE",
25
44
  "SECURITY.md",
26
- "CHANGELOG.md"
45
+ "CHANGELOG.md",
46
+ "CONTRIBUTING.md",
47
+ "CODE_OF_CONDUCT.md"
27
48
  ],
28
49
  "keywords": [
29
50
  "dataset",
@@ -52,5 +73,8 @@
52
73
  "node": ">=20"
53
74
  },
54
75
  "author": "mcp-tool-shop",
55
- "license": "MIT"
76
+ "license": "MIT",
77
+ "devDependencies": {
78
+ "c8": "^10.1.3"
79
+ }
56
80
  }
@@ -9,61 +9,95 @@
9
9
  * sdlab batch generate --mode silhouette-pack --project star-freight --dry-run
10
10
  */
11
11
 
12
- import { parseArgs } from '../lib/args.js';
12
+ import { parseArgs, getProjectName } from '../lib/args.js';
13
13
  import { getProjectRoot, getRunsDir } from '../lib/paths.js';
14
14
  import { compileBatch } from '../lib/batch-compiler.js';
15
15
  import { getBatchMode } from '../lib/batch-modes.js';
16
16
  import {
17
17
  createBatchDir,
18
+ openBatchDirForResume,
18
19
  executeBatchRuns,
19
20
  saveBatchManifest,
20
21
  } from '../lib/batch-runs.js';
21
22
  import { renderSheetHTML, saveSheet } from '../lib/batch-sheet-render.js';
22
- import { info } from '../lib/log.js';
23
+ import { info, result } from '../lib/log.js';
23
24
  import { join } from 'node:path';
24
25
  import { writeFile } from 'node:fs/promises';
25
26
 
26
27
  export async function run(argv = process.argv.slice(2)) {
27
28
  const { flags } = parseArgs(argv, {
28
29
  flags: {
29
- project: { type: 'string', default: 'star-freight' },
30
+ project: { type: 'string' },
30
31
  mode: { type: 'string' },
31
32
  subject: { type: 'string' },
32
33
  theme: { type: 'string' },
33
34
  asset: { type: 'string' },
35
+ resume: { type: 'string' },
34
36
  'dry-run': { type: 'boolean' },
35
37
  json: { type: 'boolean' },
36
38
  },
37
39
  deprecated: { game: 'project' },
38
40
  });
39
41
 
40
- if (!flags.mode) {
42
+ if (!flags.mode && !flags.resume) {
41
43
  console.log('Usage: sdlab batch generate --mode <id> [--subject <id>] [--theme <label>] [--project <name>]');
44
+ console.log(' sdlab batch generate --resume <batch_id> [--project <name>]');
42
45
  console.log('');
43
46
  console.log('Options:');
44
- console.log(' --mode <id> Batch mode (required)');
47
+ console.log(' --mode <id> Batch mode (required unless --resume is given)');
45
48
  console.log(' --subject <id> Subject for subject-driven modes');
46
49
  console.log(' --theme <label> Theme label for environment modes');
47
50
  console.log(' --asset <id> Training asset reference');
51
+ console.log(' --resume <id> Resume an existing batch — re-runs only failed/missing slots');
48
52
  console.log(' --dry-run Prepare batch without submitting to ComfyUI');
49
53
  console.log(' --json Output manifest as JSON');
50
54
  return;
51
55
  }
52
56
 
53
- const projectName = flags.project;
57
+ const projectName = flags.project || getProjectName(argv);
54
58
  const projectRoot = getProjectRoot(projectName);
55
59
  const dryRun = flags['dry-run'] || false;
60
+ const resumeId = flags.resume || null;
61
+
62
+ // Resume short-circuit: load prior manifest and reuse its mode_id + subject/theme
63
+ // so the user only needs to pass --resume <batch_id>.
64
+ let batchId;
65
+ let batchDir;
66
+ let priorResults = [];
67
+ let resumedMode = null;
68
+ let resumedSubject = null;
69
+ let resumedTheme = null;
70
+ let resumedAsset = null;
71
+ if (resumeId) {
72
+ const opened = openBatchDirForResume(projectRoot, resumeId);
73
+ batchId = opened.batchId;
74
+ batchDir = opened.batchDir;
75
+ priorResults = opened.priorResults;
76
+ resumedMode = opened.priorManifest.mode_id || null;
77
+ resumedSubject = opened.priorManifest.subject_id || null;
78
+ resumedTheme = opened.priorManifest.theme || null;
79
+ resumedAsset = opened.priorManifest.asset_ref || null;
80
+ }
81
+
82
+ const modeId = flags.mode || resumedMode;
83
+ const subjectId = flags.subject || resumedSubject;
84
+ const theme = flags.theme || resumedTheme;
85
+ const assetRef = flags.asset || resumedAsset;
56
86
 
57
87
  // Load mode for display
58
- const mode = getBatchMode(projectRoot, flags.mode);
88
+ const mode = getBatchMode(projectRoot, modeId);
59
89
 
60
90
  console.log(`\x1b[1mstyle-dataset-lab\x1b[0m batch generate`);
61
91
  console.log(` Project: ${projectName}`);
62
92
  console.log(` Mode: ${mode.label} (${mode.mode_id})`);
63
93
  console.log(` Type: ${mode.batch_type}`);
64
94
  console.log(` Slots: ${mode.variant_plan.length}`);
65
- if (flags.subject) console.log(` Subject: ${flags.subject}`);
66
- if (flags.theme) console.log(` Theme: ${flags.theme}`);
95
+ if (subjectId) console.log(` Subject: ${subjectId}`);
96
+ if (theme) console.log(` Theme: ${theme}`);
97
+ if (resumeId) {
98
+ const completed = priorResults.filter(r => r && r.status === 'ok').length;
99
+ console.log(` Resume: ${resumeId} (${completed}/${priorResults.length} slots already complete)`);
100
+ }
67
101
  if (dryRun) console.log(' ⚠ DRY RUN');
68
102
  console.log('');
69
103
 
@@ -71,17 +105,22 @@ export async function run(argv = process.argv.slice(2)) {
71
105
  const { baseBrief, slotBriefs } = await compileBatch({
72
106
  projectRoot,
73
107
  projectId: projectName,
74
- modeId: flags.mode,
75
- subjectId: flags.subject,
76
- theme: flags.theme,
77
- assetRef: flags.asset,
108
+ modeId,
109
+ subjectId,
110
+ theme,
111
+ assetRef,
78
112
  });
79
113
 
80
- // 2. Create batch directory
81
- const { batchId, batchDir } = await createBatchDir(projectRoot);
82
- info(`Batch: ${batchId}`);
114
+ // 2. Create or reuse batch directory
115
+ if (!resumeId) {
116
+ const created = await createBatchDir(projectRoot);
117
+ batchId = created.batchId;
118
+ batchDir = created.batchDir;
119
+ }
120
+ info('batch-generate', `Batch: ${batchId}`);
83
121
 
84
122
  // 3. Execute runs
123
+ const batchStartMs = Date.now();
85
124
  const results = await executeBatchRuns({
86
125
  projectRoot,
87
126
  projectName,
@@ -89,7 +128,14 @@ export async function run(argv = process.argv.slice(2)) {
89
128
  mode,
90
129
  slotBriefs,
91
130
  dryRun,
131
+ priorResults,
92
132
  });
133
+ const batchElapsedMs = Date.now() - batchStartMs;
134
+ if (!dryRun && results.length > 0) {
135
+ const avgSec = Math.round(batchElapsedMs / results.length / 1000);
136
+ const totalSec = Math.round(batchElapsedMs / 1000);
137
+ info('batch-generate', `Completed ${results.length} slot(s) in ${totalSec}s (avg ${avgSec}s/slot)`);
138
+ }
93
139
 
94
140
  // 4. Build manifest
95
141
  const manifest = {
@@ -104,7 +150,9 @@ export async function run(argv = process.argv.slice(2)) {
104
150
  label: r.label,
105
151
  brief_id: r.brief_id,
106
152
  run_id: r.run_id,
153
+ status: r.status,
107
154
  selected_output: r.selected_output,
155
+ ...(r.error ? { error: r.error } : {}),
108
156
  })),
109
157
  sheet_paths: [],
110
158
  summary: {
@@ -114,8 +162,10 @@ export async function run(argv = process.argv.slice(2)) {
114
162
  },
115
163
  };
116
164
 
117
- if (flags.subject) manifest.subject_id = flags.subject;
118
- if (flags.theme) manifest.theme = flags.theme;
165
+ if (subjectId) manifest.subject_id = subjectId;
166
+ if (theme) manifest.theme = theme;
167
+ if (assetRef) manifest.asset_ref = assetRef;
168
+ if (resumeId) manifest.resumed_from = resumeId;
119
169
 
120
170
  // 5. Render sheet
121
171
  const sheetHtml = renderSheetHTML({ manifest, mode, projectRoot, batchDir });
@@ -161,7 +211,9 @@ export async function run(argv = process.argv.slice(2)) {
161
211
  console.log(` ${status} ${r.label.padEnd(24)} → ${r.run_id} ${r.selected_output || '(dry run)'}`);
162
212
  }
163
213
  console.log('');
164
- info(`Sheet: batches/${batchId}/sheets/primary-sheet.html`);
165
- info(`Manifest: batches/${batchId}/manifest.json`);
214
+ // Primary artifact paths — always print (survives --quiet) so pipelines
215
+ // can parse the output location.
216
+ result(`Sheet: batches/${batchId}/sheets/primary-sheet.html`);
217
+ result(`Manifest: batches/${batchId}/manifest.json`);
166
218
  }
167
219
  }
@@ -7,7 +7,7 @@
7
7
  * sdlab batch sheet batch_2025-06-30_001 --project star-freight
8
8
  */
9
9
 
10
- import { parseArgs } from '../lib/args.js';
10
+ import { parseArgs, getProjectName } from '../lib/args.js';
11
11
  import { getProjectRoot } from '../lib/paths.js';
12
12
  import { loadBatchManifest } from '../lib/batch-runs.js';
13
13
  import { getBatchMode } from '../lib/batch-modes.js';
@@ -20,13 +20,13 @@ import { info } from '../lib/log.js';
20
20
  export async function run(argv = process.argv.slice(2)) {
21
21
  const { flags, positionals } = parseArgs(argv, {
22
22
  flags: {
23
- project: { type: 'string', default: 'star-freight' },
23
+ project: { type: 'string' },
24
24
  batch: { type: 'string' },
25
25
  },
26
26
  deprecated: { game: 'project' },
27
27
  });
28
28
 
29
- const projectName = flags.project;
29
+ const projectName = flags.project || getProjectName(argv);
30
30
  const projectRoot = getProjectRoot(projectName);
31
31
  const batchId = positionals[0] || flags.batch;
32
32
 
@@ -9,21 +9,21 @@
9
9
  * sdlab batch show batch_2025-06-30_001 --json # as JSON
10
10
  */
11
11
 
12
- import { parseArgs } from '../lib/args.js';
12
+ import { parseArgs, getProjectName } from '../lib/args.js';
13
13
  import { getProjectRoot } from '../lib/paths.js';
14
14
  import { listBatches, loadBatchManifest } from '../lib/batch-runs.js';
15
15
 
16
16
  export async function run(argv = process.argv.slice(2)) {
17
17
  const { flags, positionals } = parseArgs(argv, {
18
18
  flags: {
19
- project: { type: 'string', default: 'star-freight' },
19
+ project: { type: 'string' },
20
20
  batch: { type: 'string' },
21
21
  json: { type: 'boolean' },
22
22
  },
23
23
  deprecated: { game: 'project' },
24
24
  });
25
25
 
26
- const projectName = flags.project;
26
+ const projectName = flags.project || getProjectName(argv);
27
27
  const projectRoot = getProjectRoot(projectName);
28
28
  const batchId = positionals[0] || flags.batch;
29
29
 
@@ -10,7 +10,7 @@
10
10
  * sdlab brief compile --project star-freight --workflow character-portrait-set --subject kael_maren --output-count 8 --json
11
11
  */
12
12
 
13
- import { parseArgs } from '../lib/args.js';
13
+ import { parseArgs, getProjectName, parseNumberFlag } from '../lib/args.js';
14
14
  import { getProjectRoot } from '../lib/paths.js';
15
15
  import { compileBrief, saveCompiledBrief } from '../lib/brief-compiler.js';
16
16
  import { info } from '../lib/log.js';
@@ -18,7 +18,7 @@ import { info } from '../lib/log.js';
18
18
  export async function run(argv = process.argv.slice(2)) {
19
19
  const { flags } = parseArgs(argv, {
20
20
  flags: {
21
- project: { type: 'string', default: 'star-freight' },
21
+ project: { type: 'string' },
22
22
  workflow: { type: 'string' },
23
23
  subject: { type: 'string' },
24
24
  asset: { type: 'string' },
@@ -58,7 +58,7 @@ export async function run(argv = process.argv.slice(2)) {
58
58
  return;
59
59
  }
60
60
 
61
- const projectName = flags.project;
61
+ const projectName = flags.project || getProjectName(argv);
62
62
  const projectRoot = getProjectRoot(projectName);
63
63
 
64
64
  // Parse reference IDs
@@ -68,11 +68,11 @@ export async function run(argv = process.argv.slice(2)) {
68
68
 
69
69
  // Parse overrides
70
70
  const overrides = {};
71
- if (flags['output-count']) overrides.output_count = parseInt(flags['output-count'], 10);
72
- if (flags.width) overrides.width = parseInt(flags.width, 10);
73
- if (flags.height) overrides.height = parseInt(flags.height, 10);
74
- if (flags.steps) overrides.steps = parseInt(flags.steps, 10);
75
- if (flags.cfg) overrides.cfg = parseFloat(flags.cfg);
71
+ if (flags['output-count']) overrides.output_count = parseNumberFlag('output-count', flags['output-count'], { int: true, min: 1 });
72
+ if (flags.width) overrides.width = parseNumberFlag('width', flags.width, { int: true, min: 1 });
73
+ if (flags.height) overrides.height = parseNumberFlag('height', flags.height, { int: true, min: 1 });
74
+ if (flags.steps) overrides.steps = parseNumberFlag('steps', flags.steps, { int: true, min: 1 });
75
+ if (flags.cfg) overrides.cfg = parseNumberFlag('cfg', flags.cfg, { min: 0 });
76
76
  if (flags.sampler) overrides.sampler = flags.sampler;
77
77
  if (flags['seed-mode']) overrides.seed_mode = flags['seed-mode'];
78
78
 
@@ -9,7 +9,7 @@
9
9
  * sdlab brief show brief_2026-04-15_001 --md
10
10
  */
11
11
 
12
- import { parseArgs } from '../lib/args.js';
12
+ import { parseArgs, getProjectName } from '../lib/args.js';
13
13
  import { getProjectRoot } from '../lib/paths.js';
14
14
  import { loadCompiledBrief } from '../lib/brief-compiler.js';
15
15
  import { renderBriefText, renderBriefMarkdown } from '../lib/brief-render.js';
@@ -17,7 +17,7 @@ import { renderBriefText, renderBriefMarkdown } from '../lib/brief-render.js';
17
17
  export async function run(argv = process.argv.slice(2)) {
18
18
  const { flags, positionals } = parseArgs(argv, {
19
19
  flags: {
20
- project: { type: 'string', default: 'star-freight' },
20
+ project: { type: 'string' },
21
21
  json: { type: 'boolean' },
22
22
  md: { type: 'boolean' },
23
23
  },
@@ -31,7 +31,8 @@ export async function run(argv = process.argv.slice(2)) {
31
31
  return;
32
32
  }
33
33
 
34
- const projectRoot = getProjectRoot(flags.project);
34
+ const projectName = flags.project || getProjectName(argv);
35
+ const projectRoot = getProjectRoot(projectName);
35
36
  const brief = await loadCompiledBrief(projectRoot, briefId);
36
37
 
37
38
  if (flags.json) {
@@ -21,6 +21,7 @@ import { join } from 'path';
21
21
  import { getProjectName } from '../lib/args.js';
22
22
  import { REPO_ROOT } from '../lib/paths.js';
23
23
  import { loadConstitution, loadLanes, loadRubric, loadTerminology, detectLane, detectGroup } from '../lib/config.js';
24
+ import { inputError, handleCliError } from '../lib/errors.js';
24
25
 
25
26
  /**
26
27
  * Build rationale string from rule's template, interpolating variables.
@@ -162,10 +163,10 @@ export async function run(argv = process.argv.slice(2)) {
162
163
 
163
164
  // Load project config — fail loudly if missing
164
165
  if (!existsSync(join(PROJECT_ROOT, 'constitution.json'))) {
165
- throw new Error(
166
- `No constitution.json found in ${PROJECT_ROOT}.\n` +
167
- `Canon binding requires per-project config files.\n` +
168
- `Run: sdlab init ${projectName} --domain <domain>`
166
+ throw inputError(
167
+ 'INPUT_MISSING_CONFIG',
168
+ `No constitution.json found in ${PROJECT_ROOT}.`,
169
+ `Canon binding requires per-project config files. Run: sdlab init ${projectName} --domain <domain>`
169
170
  );
170
171
  }
171
172
 
@@ -175,7 +176,7 @@ export async function run(argv = process.argv.slice(2)) {
175
176
  const termConfig = loadTerminology(PROJECT_ROOT);
176
177
 
177
178
  if (!constitutionConfig.rules || constitutionConfig.rules.length === 0) {
178
- throw new Error(`constitution.json has no rules — cannot bind.`);
179
+ throw inputError('INPUT_EMPTY_CONFIG', `constitution.json has no rules — cannot bind.`);
179
180
  }
180
181
 
181
182
  const constitutionRules = constitutionConfig.rules;
@@ -280,8 +281,5 @@ export async function run(argv = process.argv.slice(2)) {
280
281
 
281
282
  // Direct execution guard
282
283
  if (process.argv[1] && (process.argv[1].endsWith('canon-bind.js') || process.argv[1].endsWith('canon-bind'))) {
283
- run().catch((err) => {
284
- console.error(err.message || err);
285
- process.exit(1);
286
- });
284
+ run().catch(handleCliError);
287
285
  }