@mcptoolshop/style-dataset-lab 3.0.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 (88) hide show
  1. package/CHANGELOG.md +68 -15
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +71 -0
  4. package/README.md +46 -1
  5. package/SECURITY.md +6 -5
  6. package/bin/sdlab.js +281 -62
  7. package/lib/adapters/diffusers-lora.js +7 -2
  8. package/lib/adapters/generic-image-caption.js +6 -1
  9. package/lib/args.js +200 -13
  10. package/lib/batch-compiler.js +7 -3
  11. package/lib/batch-runs.js +100 -31
  12. package/lib/batch-sheet-render.js +5 -2
  13. package/lib/brief-compiler.js +38 -11
  14. package/lib/card.js +11 -4
  15. package/lib/comfyui.js +100 -6
  16. package/lib/config.js +68 -3
  17. package/lib/deprecation.js +1 -1
  18. package/lib/eval-pack.js +94 -4
  19. package/lib/eval-runs.js +42 -3
  20. package/lib/export.js +144 -34
  21. package/lib/implementation-packs.js +15 -1
  22. package/lib/log.js +41 -4
  23. package/lib/paths.js +41 -3
  24. package/lib/refine-briefs.js +16 -9
  25. package/lib/reingest-selected.js +14 -3
  26. package/lib/reingest.js +15 -1
  27. package/lib/run-summary.js +3 -2
  28. package/lib/runtime-runs.js +59 -9
  29. package/lib/selections.js +54 -2
  30. package/lib/snapshot.js +100 -7
  31. package/lib/split.js +175 -17
  32. package/lib/training-manifests.js +17 -3
  33. package/lib/training-packages.js +28 -14
  34. package/package.json +8 -2
  35. package/scripts/batch-generate.js +16 -7
  36. package/scripts/batch-sheet.js +3 -3
  37. package/scripts/batch-show.js +3 -3
  38. package/scripts/brief-compile.js +8 -8
  39. package/scripts/brief-show.js +4 -3
  40. package/scripts/canon-bind.js +7 -9
  41. package/scripts/card.js +22 -25
  42. package/scripts/compare.js +38 -33
  43. package/scripts/critique-show.js +3 -3
  44. package/scripts/critique.js +3 -3
  45. package/scripts/curate.js +91 -46
  46. package/scripts/doctor.js +3 -5
  47. package/scripts/eligibility.js +17 -14
  48. package/scripts/eval-pack.js +13 -11
  49. package/scripts/eval-run.js +30 -29
  50. package/scripts/export.js +27 -31
  51. package/scripts/generate-controlnet.js +31 -17
  52. package/scripts/generate-identity.js +37 -20
  53. package/scripts/generate-ipadapter.js +22 -16
  54. package/scripts/generate.js +68 -13
  55. package/scripts/implementation-pack.js +19 -14
  56. package/scripts/init.js +115 -17
  57. package/scripts/painterly.js +8 -10
  58. package/scripts/refine.js +3 -3
  59. package/scripts/reingest-selected.js +3 -3
  60. package/scripts/reingest.js +33 -20
  61. package/scripts/run-generate.js +4 -4
  62. package/scripts/run-list.js +3 -3
  63. package/scripts/run-show.js +3 -3
  64. package/scripts/select.js +5 -4
  65. package/scripts/selection-show.js +3 -3
  66. package/scripts/snapshot.js +22 -24
  67. package/scripts/split.js +26 -31
  68. package/scripts/training-manifest.js +29 -24
  69. package/scripts/training-package.js +24 -20
  70. package/scripts/training-profile.js +13 -8
  71. package/scripts/workflow-list.js +2 -2
  72. package/scripts/workflow-show.js +4 -3
  73. package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
  74. package/templates/domains/architecture/terminology.json +10 -8
  75. package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
  76. package/templates/domains/character-design/project.json +4 -2
  77. package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
  78. package/templates/domains/creature-design/project.json +4 -2
  79. package/templates/domains/creature-design/terminology.json +1 -0
  80. package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
  81. package/templates/domains/game-art/project.json +4 -2
  82. package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
  83. package/templates/domains/vehicle-mech/project.json +4 -2
  84. package/scripts/bulk-curate-wave2-5.js +0 -138
  85. package/scripts/bulk-curate-waves11-18.js +0 -413
  86. package/scripts/curate-wave25.js +0 -209
  87. package/scripts/migrate-records.js +0 -141
  88. package/templates/inputs/prompts/example-wave.json +0 -27
@@ -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,8 @@ 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';
18
20
 
19
21
  /**
20
22
  * Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
@@ -63,6 +65,14 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
63
65
  const recordsDir = join(projectRoot, 'records');
64
66
  const packageId = generatePackageId();
65
67
  const packageDir = join(projectRoot, 'training', 'packages', packageId);
68
+ // D-005: refuse to silently overwrite on ID collision.
69
+ if (existsSync(packageDir)) {
70
+ throw inputError(
71
+ 'TRAINING_PACKAGE_ID_COLLISION',
72
+ `Training package directory already exists: ${packageDir}`,
73
+ 'Retry to generate a fresh package ID.',
74
+ );
75
+ }
66
76
 
67
77
  // Load split partitions
68
78
  const partitions = {};
@@ -105,19 +115,10 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
105
115
 
106
116
  totalImages = adapterResult.imageCount || 0;
107
117
 
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)
118
+ // D-015: Write manifest + README FIRST so they are covered by checksums.
120
119
  const packageManifest = {
120
+ // DB-001 / DB-006: stamp manifest schema version
121
+ schema_version: SCHEMA_VERSION,
121
122
  training_package_id: packageId,
122
123
  created_at: new Date().toISOString(),
123
124
  created_by: 'sdlab-training-package-v1',
@@ -134,7 +135,7 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
134
135
  val: partitions.val.length,
135
136
  test: partitions.test.length,
136
137
  images: totalImages,
137
- checksums: checksumEntries.length,
138
+ checksums: 0, // updated after checksum pass below
138
139
  },
139
140
  };
140
141
  await writeFile(join(packageDir, 'manifest.json'), JSON.stringify(packageManifest, null, 2) + '\n');
@@ -143,6 +144,17 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
143
144
  const readme = generatePackageReadme(packageManifest, profile, manifest, adapterTarget);
144
145
  await writeFile(join(packageDir, 'README.md'), readme);
145
146
 
147
+ // Generate checksums AFTER manifest+README are on disk so they are included.
148
+ const checksumEntries = [];
149
+ const allFiles = await collectFiles(packageDir);
150
+ for (const filePath of allFiles.sort()) {
151
+ const relPath = relative(packageDir, filePath);
152
+ if (relPath === 'checksums.txt') continue;
153
+ const hash = await sha256File(filePath);
154
+ checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
155
+ }
156
+ await writeFile(join(packageDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
157
+
146
158
  return { packageId, records: totalRecords, images: totalImages };
147
159
  }
148
160
 
@@ -237,5 +249,7 @@ export async function loadTrainingPackage(projectRoot, packageId) {
237
249
  if (!existsSync(path)) {
238
250
  throw new Error(`Training package "${packageId}" not found at ${path}`);
239
251
  }
240
- return JSON.parse(await readFile(path, 'utf-8'));
252
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
253
+ checkManifestVersion(manifest, 'training-package');
254
+ return manifest;
241
255
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@mcptoolshop/style-dataset-lab",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
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",
10
11
  "verify": "node bin/sdlab.js --help && node bin/sdlab.js project doctor --project star-freight",
11
12
  "generate": "node scripts/generate.js",
12
13
  "curate": "node scripts/curate.js",
@@ -19,11 +20,16 @@
19
20
  "bin/",
20
21
  "lib/",
21
22
  "scripts/",
23
+ "!scripts/bulk-curate-*.js",
24
+ "!scripts/curate-wave*.js",
25
+ "!scripts/migrate-records.js",
22
26
  "templates/",
23
27
  "README.md",
24
28
  "LICENSE",
25
29
  "SECURITY.md",
26
- "CHANGELOG.md"
30
+ "CHANGELOG.md",
31
+ "CONTRIBUTING.md",
32
+ "CODE_OF_CONDUCT.md"
27
33
  ],
28
34
  "keywords": [
29
35
  "dataset",
@@ -9,7 +9,7 @@
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';
@@ -19,14 +19,14 @@ import {
19
19
  saveBatchManifest,
20
20
  } from '../lib/batch-runs.js';
21
21
  import { renderSheetHTML, saveSheet } from '../lib/batch-sheet-render.js';
22
- import { info } from '../lib/log.js';
22
+ import { info, result } from '../lib/log.js';
23
23
  import { join } from 'node:path';
24
24
  import { writeFile } from 'node:fs/promises';
25
25
 
26
26
  export async function run(argv = process.argv.slice(2)) {
27
27
  const { flags } = parseArgs(argv, {
28
28
  flags: {
29
- project: { type: 'string', default: 'star-freight' },
29
+ project: { type: 'string' },
30
30
  mode: { type: 'string' },
31
31
  subject: { type: 'string' },
32
32
  theme: { type: 'string' },
@@ -50,7 +50,7 @@ export async function run(argv = process.argv.slice(2)) {
50
50
  return;
51
51
  }
52
52
 
53
- const projectName = flags.project;
53
+ const projectName = flags.project || getProjectName(argv);
54
54
  const projectRoot = getProjectRoot(projectName);
55
55
  const dryRun = flags['dry-run'] || false;
56
56
 
@@ -79,9 +79,10 @@ export async function run(argv = process.argv.slice(2)) {
79
79
 
80
80
  // 2. Create batch directory
81
81
  const { batchId, batchDir } = await createBatchDir(projectRoot);
82
- info(`Batch: ${batchId}`);
82
+ info('batch-generate', `Batch: ${batchId}`);
83
83
 
84
84
  // 3. Execute runs
85
+ const batchStartMs = Date.now();
85
86
  const results = await executeBatchRuns({
86
87
  projectRoot,
87
88
  projectName,
@@ -90,6 +91,12 @@ export async function run(argv = process.argv.slice(2)) {
90
91
  slotBriefs,
91
92
  dryRun,
92
93
  });
94
+ const batchElapsedMs = Date.now() - batchStartMs;
95
+ if (!dryRun && results.length > 0) {
96
+ const avgSec = Math.round(batchElapsedMs / results.length / 1000);
97
+ const totalSec = Math.round(batchElapsedMs / 1000);
98
+ info('batch-generate', `Completed ${results.length} slot(s) in ${totalSec}s (avg ${avgSec}s/slot)`);
99
+ }
93
100
 
94
101
  // 4. Build manifest
95
102
  const manifest = {
@@ -161,7 +168,9 @@ export async function run(argv = process.argv.slice(2)) {
161
168
  console.log(` ${status} ${r.label.padEnd(24)} → ${r.run_id} ${r.selected_output || '(dry run)'}`);
162
169
  }
163
170
  console.log('');
164
- info(`Sheet: batches/${batchId}/sheets/primary-sheet.html`);
165
- info(`Manifest: batches/${batchId}/manifest.json`);
171
+ // Primary artifact paths — always print (survives --quiet) so pipelines
172
+ // can parse the output location.
173
+ result(`Sheet: batches/${batchId}/sheets/primary-sheet.html`);
174
+ result(`Manifest: batches/${batchId}/manifest.json`);
166
175
  }
167
176
  }
@@ -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
  }
package/scripts/card.js CHANGED
@@ -4,43 +4,44 @@
4
4
  * card.js — Generate dataset cards.
5
5
  *
6
6
  * Usage:
7
- * sdlab card generate --snapshot <id> --split <id> [--project <name>]
7
+ * sdlab card generate [--snapshot <id>] [--split <id>] [--project <name>]
8
8
  */
9
9
 
10
10
  import { join } from 'node:path';
11
11
  import { writeFile } from 'node:fs/promises';
12
- import { getProjectName } from '../lib/args.js';
12
+ import { parseArgs, getProjectName } from '../lib/args.js';
13
13
  import { REPO_ROOT } from '../lib/paths.js';
14
+ import { inputError, handleCliError } from '../lib/errors.js';
14
15
  import { listSnapshots } from '../lib/snapshot.js';
15
16
  import { listSplits } from '../lib/split.js';
16
17
  import { generateCard } from '../lib/card.js';
17
18
 
18
19
  export async function run(argv = process.argv.slice(2)) {
19
- const projectName = getProjectName(argv);
20
+ const { flags, positionals } = parseArgs(argv, {
21
+ flags: {
22
+ project: { type: 'string' },
23
+ snapshot: { type: 'string' },
24
+ split: { type: 'string' },
25
+ },
26
+ deprecated: { game: 'project' },
27
+ });
28
+
29
+ const projectName = flags.project || getProjectName(argv);
20
30
  const projectRoot = join(REPO_ROOT, 'projects', projectName);
21
- const subcommand = argv.find(a => !a.startsWith('--')) || 'generate';
22
- const args = argv.filter(a => a !== subcommand);
31
+ const subcommand = positionals[0] || 'generate';
23
32
 
24
33
  if (subcommand === 'generate') {
25
- // Find snapshot
26
- let snapshotId;
27
- const snapIdx = args.indexOf('--snapshot');
28
- if (snapIdx >= 0) {
29
- snapshotId = args[snapIdx + 1];
30
- } else {
34
+ let snapshotId = flags.snapshot;
35
+ if (!snapshotId) {
31
36
  const snapshots = await listSnapshots(projectRoot);
32
- if (snapshots.length === 0) throw new Error('No snapshots found. Run: sdlab snapshot create');
37
+ if (snapshots.length === 0) throw inputError('INPUT_NO_SNAPSHOT', 'No snapshots found. Run: sdlab snapshot create');
33
38
  snapshotId = snapshots[snapshots.length - 1].id;
34
39
  }
35
40
 
36
- // Find split
37
- let splitId;
38
- const splitIdx = args.indexOf('--split');
39
- if (splitIdx >= 0) {
40
- splitId = args[splitIdx + 1];
41
- } else {
41
+ let splitId = flags.split;
42
+ if (!splitId) {
42
43
  const splits = await listSplits(projectRoot);
43
- if (splits.length === 0) throw new Error('No splits found. Run: sdlab split build');
44
+ if (splits.length === 0) throw inputError('INPUT_NO_SPLIT', 'No splits found. Run: sdlab split build');
44
45
  splitId = splits[splits.length - 1].id;
45
46
  }
46
47
 
@@ -51,7 +52,6 @@ export async function run(argv = process.argv.slice(2)) {
51
52
 
52
53
  const { markdown, json } = await generateCard(projectRoot, snapshotId, splitId);
53
54
 
54
- // Write to project root
55
55
  const mdPath = join(projectRoot, 'dataset-card.md');
56
56
  const jsonPath = join(projectRoot, 'dataset-card.json');
57
57
  await writeFile(mdPath, markdown);
@@ -61,13 +61,10 @@ export async function run(argv = process.argv.slice(2)) {
61
61
  console.log(` \x1b[32m✓\x1b[0m dataset-card.json`);
62
62
 
63
63
  } else {
64
- throw new Error(`Unknown subcommand: ${subcommand}. Use: generate`);
64
+ throw inputError('INPUT_BAD_SUBCOMMAND', `Unknown subcommand: ${subcommand}. Use: generate`);
65
65
  }
66
66
  }
67
67
 
68
68
  if (process.argv[1] && (process.argv[1].endsWith('card.js') || process.argv[1].endsWith('card'))) {
69
- run().catch((err) => {
70
- console.error(err.message || err);
71
- process.exit(1);
72
- });
69
+ run().catch(handleCliError);
73
70
  }
@@ -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
  }
@@ -9,7 +9,7 @@
9
9
  * sdlab critique show --project star-freight --run run_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 { loadCritique } from '../lib/critique-engine.js';
15
15
  import { renderCritiqueMarkdown, renderCritiqueText } from '../lib/critique-render.js';
@@ -17,7 +17,7 @@ import { renderCritiqueMarkdown, renderCritiqueText } from '../lib/critique-rend
17
17
  export async function run(argv = process.argv.slice(2)) {
18
18
  const { flags } = parseArgs(argv, {
19
19
  flags: {
20
- project: { type: 'string', default: 'star-freight' },
20
+ project: { type: 'string' },
21
21
  run: { type: 'string' },
22
22
  json: { type: 'boolean' },
23
23
  md: { type: 'boolean' },
@@ -35,7 +35,7 @@ export async function run(argv = process.argv.slice(2)) {
35
35
  return;
36
36
  }
37
37
 
38
- const projectName = flags.project;
38
+ const projectName = flags.project || getProjectName(argv);
39
39
  const projectRoot = getProjectRoot(projectName);
40
40
 
41
41
  const report = loadCritique(projectRoot, flags.run);
@@ -8,7 +8,7 @@
8
8
  * sdlab critique --project star-freight --run run_2026-04-15_001 --json
9
9
  */
10
10
 
11
- import { parseArgs } from '../lib/args.js';
11
+ import { parseArgs, getProjectName } from '../lib/args.js';
12
12
  import { getProjectRoot } from '../lib/paths.js';
13
13
  import { critiqueRun, saveCritique } from '../lib/critique-engine.js';
14
14
  import { renderCritiqueMarkdown, renderCritiqueText } from '../lib/critique-render.js';
@@ -17,7 +17,7 @@ import { info } from '../lib/log.js';
17
17
  export async function run(argv = process.argv.slice(2)) {
18
18
  const { flags } = parseArgs(argv, {
19
19
  flags: {
20
- project: { type: 'string', default: 'star-freight' },
20
+ project: { type: 'string' },
21
21
  run: { type: 'string' },
22
22
  json: { type: 'boolean' },
23
23
  },
@@ -33,7 +33,7 @@ export async function run(argv = process.argv.slice(2)) {
33
33
  return;
34
34
  }
35
35
 
36
- const projectName = flags.project;
36
+ const projectName = flags.project || getProjectName(argv);
37
37
  const projectRoot = getProjectRoot(projectName);
38
38
 
39
39
  console.log(`\x1b[1mstyle-dataset-lab\x1b[0m critique`);