@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
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
  }
@@ -26,11 +26,15 @@ export function applySlotDelta(baseBrief, slot) {
26
26
  modified.prompt = `${modified.prompt}, ${slot.prompt_additions.join(', ')}`;
27
27
  }
28
28
 
29
- // Negative additions — append without duplicating
29
+ // Negative additions — append without duplicating (token-exact match)
30
30
  if (slot.negative_additions?.length > 0) {
31
- const existingLower = modified.negative_prompt.toLowerCase().split(',').map(s => s.trim());
31
+ const existingLower = modified.negative_prompt
32
+ .toLowerCase()
33
+ .split(',')
34
+ .map(s => s.trim())
35
+ .filter(s => s.length > 0);
32
36
  const additions = slot.negative_additions.filter(
33
- n => !existingLower.some(e => e.includes(n.toLowerCase()))
37
+ n => !existingLower.includes(n.toLowerCase().trim())
34
38
  );
35
39
  if (additions.length > 0) {
36
40
  modified.negative_prompt = `${modified.negative_prompt}, ${additions.join(', ')}`;
package/lib/batch-runs.js CHANGED
@@ -10,15 +10,32 @@
10
10
  * briefs/ (slot briefs)
11
11
  */
12
12
 
13
- import { writeFile, mkdir, copyFile } from 'node:fs/promises';
13
+ import { writeFile, mkdir, copyFile, rename, unlink } from 'node:fs/promises';
14
14
  import { join } from 'node:path';
15
15
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
16
16
  import { getProjectRoot, getRunsDir } from './paths.js';
17
17
  import { executeRun } from './adapters/comfyui-runner.js';
18
18
  import { saveRunSummary } from './run-summary.js';
19
- import { log, info, warn } from './log.js';
19
+ import { log, info, warn, verbose } from './log.js';
20
20
  import { inputError } from './errors.js';
21
21
 
22
+ /**
23
+ * PB-002 / PB-005: atomic write helper — write temp + rename.
24
+ * Rename is atomic on a single filesystem, so a crash mid-write cannot
25
+ * corrupt an existing manifest.
26
+ */
27
+ async function atomicWriteJson(targetPath, obj) {
28
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
29
+ const body = JSON.stringify(obj, null, 2) + '\n';
30
+ try {
31
+ await writeFile(tmpPath, body);
32
+ await rename(tmpPath, targetPath);
33
+ } catch (err) {
34
+ try { await unlink(tmpPath); } catch { /* ignore */ }
35
+ throw err;
36
+ }
37
+ }
38
+
22
39
  /**
23
40
  * Return the batches directory for a project.
24
41
  */
@@ -29,6 +46,10 @@ export function getBatchesDir(projectRoot) {
29
46
  /**
30
47
  * Generate batch ID: batch_YYYY-MM-DD_NNN
31
48
  */
49
+ // PB-008: tighten regex so stray directories like 'batch_<date>_001_wip'
50
+ // don't inflate the sequence via lenient parseInt.
51
+ const BATCH_SEQ_RE = /^(\d{3})$/;
52
+
32
53
  export function generateBatchId(batchesDir) {
33
54
  const today = new Date().toISOString().slice(0, 10);
34
55
  const prefix = `batch_${today}_`;
@@ -39,8 +60,10 @@ export function generateBatchId(batchesDir) {
39
60
  .filter(e => e.isDirectory() && e.name.startsWith(prefix));
40
61
  for (const e of existing) {
41
62
  const seqStr = e.name.slice(prefix.length);
42
- const seq = parseInt(seqStr, 10);
43
- if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
63
+ const match = BATCH_SEQ_RE.exec(seqStr);
64
+ if (!match) continue;
65
+ const seq = parseInt(match[1], 10);
66
+ if (seq > maxSeq) maxSeq = seq;
44
67
  }
45
68
  }
46
69
 
@@ -82,7 +105,20 @@ export async function executeBatchRuns({
82
105
  slotBriefs,
83
106
  dryRun = false,
84
107
  }) {
108
+ // PB-005: fail fast on length mismatch instead of crashing deep in the loop.
109
+ if (!Array.isArray(mode?.variant_plan) || mode.variant_plan.length !== slotBriefs.length) {
110
+ throw inputError(
111
+ 'BATCH_SLOT_PLAN_MISMATCH',
112
+ `Batch variant_plan has ${mode?.variant_plan?.length ?? 0} slots but ` +
113
+ `${slotBriefs.length} brief(s) were compiled`,
114
+ 'This is an internal invariant — rebuild the batch from the mode definition.'
115
+ );
116
+ }
117
+
85
118
  const results = [];
119
+ // PB-005: incremental batch-progress manifest — written after every slot
120
+ // so a crash never orphans the batch dir without a record of what ran.
121
+ const progressPath = join(batchDir, 'manifest.json');
86
122
 
87
123
  for (let i = 0; i < slotBriefs.length; i++) {
88
124
  const slot = mode.variant_plan[i];
@@ -98,29 +134,63 @@ export async function executeBatchRuns({
98
134
  JSON.stringify(brief, null, 2) + '\n'
99
135
  );
100
136
 
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
- });
137
+ // PB-005: per-slot try/catch — one slot failure no longer throws out of
138
+ // the whole batch. Record the error and keep going so the user doesn't
139
+ // lose already-completed slots.
140
+ try {
141
+ // Execute the run through the existing 4B pipeline
142
+ const manifest = await executeRun({
143
+ projectRoot,
144
+ projectName,
145
+ brief,
146
+ dryRun,
147
+ });
148
+
149
+ // Save run summary
150
+ const runDir = join(getRunsDir(projectRoot), manifest.run_id);
151
+ await saveRunSummary(runDir, manifest);
152
+
153
+ results.push({
154
+ slot_id: slot.slot_id,
155
+ label: slotLabel,
156
+ brief_id: brief.brief_id,
157
+ run_id: manifest.run_id,
158
+ manifest,
159
+ status: 'ok',
160
+ // Pick the first successful output as the selected one
161
+ selected_output: manifest.outputs.find(o =>
162
+ o.status === 'ok' || o.status === 'dry_run'
163
+ )?.filename || null,
164
+ });
165
+ } catch (err) {
166
+ warn(` ✗ slot ${slotLabel} failed: ${err.message}`);
167
+ results.push({
168
+ slot_id: slot.slot_id,
169
+ label: slotLabel,
170
+ brief_id: brief.brief_id,
171
+ run_id: null,
172
+ manifest: null,
173
+ status: 'error',
174
+ error: err.message,
175
+ selected_output: null,
176
+ });
177
+ }
178
+
179
+ // PB-005: checkpoint after every slot (atomic write).
180
+ try {
181
+ await atomicWriteJson(progressPath, {
182
+ batch_dir: batchDir,
183
+ mode_id: mode?.mode_id ?? mode?.id ?? null,
184
+ incremental: true,
185
+ last_checkpoint_at: new Date().toISOString(),
186
+ completed: i + 1,
187
+ total: slotBriefs.length,
188
+ slots: results,
189
+ });
190
+ verbose(`batch checkpoint: ${i + 1}/${slotBriefs.length} slots`);
191
+ } catch (err) {
192
+ warn(` batch checkpoint write failed: ${err.message}`);
193
+ }
124
194
  }
125
195
 
126
196
  return results;
@@ -130,10 +200,9 @@ export async function executeBatchRuns({
130
200
  * Save a batch manifest.
131
201
  */
132
202
  export async function saveBatchManifest(batchDir, manifest) {
133
- await writeFile(
134
- join(batchDir, 'manifest.json'),
135
- JSON.stringify(manifest, null, 2) + '\n'
136
- );
203
+ // PB-002: atomic write — safe to overwrite an in-progress checkpoint
204
+ // because rename is atomic on a single filesystem.
205
+ await atomicWriteJson(join(batchDir, 'manifest.json'), manifest);
137
206
  }
138
207
 
139
208
  /**
@@ -41,12 +41,15 @@ function resolveImagePath(projectRoot, result, batchDir) {
41
41
  export function renderSheetHTML({ manifest, mode, projectRoot, batchDir }) {
42
42
  const assembly = mode.assembly || {};
43
43
  const layout = assembly.layout || 'grid';
44
- const columns = assembly.columns || computeColumns(manifest.slots.length);
44
+ const slots = manifest.slots || [];
45
+ const rawColumns = assembly.columns;
46
+ const columns = (typeof rawColumns === 'number' && rawColumns > 0)
47
+ ? rawColumns
48
+ : computeColumns(slots.length);
45
49
  const showSlotLabels = assembly.annotate_slots !== false;
46
50
  const showSeed = assembly.show_seed || false;
47
51
 
48
52
  const title = `${manifest.mode_id} — ${manifest.subject_id || manifest.theme || manifest.project_id}`;
49
- const slots = manifest.slots || [];
50
53
 
51
54
  // Build slot cells
52
55
  const cells = slots.map(slot => {
@@ -47,6 +47,24 @@ function generateBriefId(briefsDir) {
47
47
  return `${prefix}${seq}`;
48
48
  }
49
49
 
50
+ // ─── Canonical JSON ──────────────────────────────────────────────
51
+
52
+ /**
53
+ * Recursively canonicalize an object: sort keys at every nesting level.
54
+ * Arrays retain their order (order is semantically meaningful for arrays).
55
+ * Primitives pass through untouched.
56
+ */
57
+ export function canonicalize(value) {
58
+ if (value === null || typeof value !== 'object') return value;
59
+ if (Array.isArray(value)) return value.map(canonicalize);
60
+ const sortedKeys = Object.keys(value).sort();
61
+ const out = {};
62
+ for (const key of sortedKeys) {
63
+ out[key] = canonicalize(value[key]);
64
+ }
65
+ return out;
66
+ }
67
+
50
68
  // ─── Config fingerprint ──────────────────────────────────────────
51
69
 
52
70
  /**
@@ -67,9 +85,9 @@ function computeBriefFingerprint(projectRoot, workflow, subjectRecords) {
67
85
  }
68
86
  }
69
87
 
70
- // Workflow profile (serialized deterministically)
88
+ // Workflow profile (serialized deterministically, recursively key-sorted)
71
89
  hash.update('--- workflow ---\n');
72
- hash.update(JSON.stringify(workflow, Object.keys(workflow).sort()));
90
+ hash.update(JSON.stringify(canonicalize(workflow)));
73
91
  hash.update('\n');
74
92
 
75
93
  // Subject records (sorted by ID)
@@ -385,16 +403,16 @@ function buildDriftWarnings(workflow, config) {
385
403
  /**
386
404
  * Build the runtime plan — merge workflow defaults with CLI overrides.
387
405
  */
388
- function buildRuntimePlan(workflow, overrides = {}) {
406
+ export function buildRuntimePlan(workflow, overrides = {}) {
389
407
  const rd = workflow.runtime_defaults || {};
390
408
  return {
391
- adapter_target: overrides.adapter_target || rd.adapter_target || 'comfyui',
392
- width: overrides.width || rd.width || 1024,
393
- height: overrides.height || rd.height || 1024,
394
- steps: overrides.steps || rd.steps || 30,
395
- cfg: overrides.cfg || rd.cfg || 6.5,
396
- sampler: overrides.sampler || rd.sampler || 'dpmpp_2m',
397
- seed_mode: overrides.seed_mode || rd.seed_mode || 'increment',
409
+ adapter_target: overrides.adapter_target ?? rd.adapter_target ?? 'comfyui',
410
+ width: overrides.width ?? rd.width ?? 1024,
411
+ height: overrides.height ?? rd.height ?? 1024,
412
+ steps: overrides.steps ?? rd.steps ?? 30,
413
+ cfg: overrides.cfg ?? rd.cfg ?? 6.5,
414
+ sampler: overrides.sampler ?? rd.sampler ?? 'dpmpp_2m',
415
+ seed_mode: overrides.seed_mode ?? rd.seed_mode ?? 'increment',
398
416
  };
399
417
  }
400
418
 
@@ -427,6 +445,15 @@ export async function compileBrief({
427
445
  // Load workflow profile
428
446
  const workflow = await getWorkflowProfile(projectRoot, workflowId);
429
447
 
448
+ // Validate required workflow fields
449
+ if (typeof workflow.output_mode !== 'string' || workflow.output_mode.length === 0) {
450
+ throw inputError(
451
+ 'WORKFLOW_INVALID',
452
+ `Workflow "${workflowId}" is missing a non-empty "output_mode"`,
453
+ `Check the workflow profile JSON — output_mode must be a non-empty string.`
454
+ );
455
+ }
456
+
430
457
  // Enforce subject_mode
431
458
  const subjectMode = workflow.subject_mode || 'optional';
432
459
  if (subjectMode === 'required' && !subjectId) {
@@ -486,7 +513,7 @@ export async function compileBrief({
486
513
  // Expected outputs
487
514
  const expectedOutputs = {
488
515
  output_mode: workflow.output_mode,
489
- output_count: overrides.output_count || workflow.output_count,
516
+ output_count: overrides.output_count ?? workflow.output_count,
490
517
  review_surface: workflow.output_mode.replace(/_/g, ' '),
491
518
  };
492
519
 
package/lib/card.js CHANGED
@@ -70,6 +70,13 @@ export async function generateCard(projectRoot, snapshotId, splitId) {
70
70
  return { markdown, json };
71
71
  }
72
72
 
73
+ // D-025: safe percentage formatter — renders '—' for zero-total cases
74
+ // instead of 'NaN%'.
75
+ function pct(numerator, denominator) {
76
+ if (!denominator || denominator <= 0) return '—';
77
+ return `${(numerator / denominator * 100).toFixed(1)}%`;
78
+ }
79
+
73
80
  function renderMarkdown(card, config) {
74
81
  const lines = [];
75
82
  const add = (s = '') => lines.push(s);
@@ -87,7 +94,7 @@ function renderMarkdown(card, config) {
87
94
  add(`- **Snapshot:** \`${card.snapshot.id}\``);
88
95
  add(`- **Config fingerprint:** \`${card.snapshot.config_fingerprint.slice(0, 16)}...\``);
89
96
  add(`- **Total records evaluated:** ${card.snapshot.total_records}`);
90
- add(`- **Included:** ${card.snapshot.included} (${(card.snapshot.included / card.snapshot.total_records * 100).toFixed(1)}%)`);
97
+ add(`- **Included:** ${card.snapshot.included} (${pct(card.snapshot.included, card.snapshot.total_records)})`);
91
98
  add(`- **Excluded:** ${card.snapshot.excluded}`);
92
99
  add();
93
100
 
@@ -111,9 +118,9 @@ function renderMarkdown(card, config) {
111
118
  add('| Partition | Records | % |');
112
119
  add('|-----------|---------|---|');
113
120
  const total = card.split.counts.total_records;
114
- add(`| Train | ${card.split.counts.train} | ${(card.split.counts.train / total * 100).toFixed(1)}% |`);
115
- add(`| Val | ${card.split.counts.val} | ${(card.split.counts.val / total * 100).toFixed(1)}% |`);
116
- add(`| Test | ${card.split.counts.test} | ${(card.split.counts.test / total * 100).toFixed(1)}% |`);
121
+ add(`| Train | ${card.split.counts.train} | ${pct(card.split.counts.train, total)} |`);
122
+ add(`| Val | ${card.split.counts.val} | ${pct(card.split.counts.val, total)} |`);
123
+ add(`| Test | ${card.split.counts.test} | ${pct(card.split.counts.test, total)} |`);
117
124
  add();
118
125
 
119
126
  add('## Lane Balance');