@mcptoolshop/style-dataset-lab 3.3.0 → 3.4.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 (92) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.es.md +35 -25
  3. package/README.fr.md +38 -28
  4. package/README.hi.md +36 -26
  5. package/README.it.md +37 -27
  6. package/README.ja.md +40 -30
  7. package/README.md +15 -5
  8. package/README.pt-BR.md +35 -25
  9. package/README.zh.md +33 -23
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +36 -2
  28. package/lib/eligibility.js +121 -9
  29. package/lib/eval-pack.js +14 -14
  30. package/lib/eval-runs.js +33 -0
  31. package/lib/export.js +122 -34
  32. package/lib/freeze-gate.js +13 -3
  33. package/lib/freeze-stamp.js +28 -3
  34. package/lib/generated-provenance.js +16 -1
  35. package/lib/implementation-packs.js +95 -17
  36. package/lib/ingest.js +396 -0
  37. package/lib/judgment-provenance.js +220 -0
  38. package/lib/measure.js +422 -0
  39. package/lib/paths.js +196 -7
  40. package/lib/reingest-selected.js +98 -16
  41. package/lib/run-manifest.js +102 -16
  42. package/lib/run-summary.js +12 -6
  43. package/lib/runtime-runs.js +45 -4
  44. package/lib/snapshot.js +114 -15
  45. package/lib/split.js +364 -48
  46. package/lib/training-manifests.js +114 -21
  47. package/lib/training-packages.js +147 -15
  48. package/lib/workflow-profiles.js +24 -3
  49. package/package.json +3 -2
  50. package/runtime/comfyui/txt2img-qwen.json +38 -0
  51. package/runtime/comfyui/txt2img-standard.json +21 -0
  52. package/scripts/_resume.js +62 -0
  53. package/scripts/_shared-args.js +44 -0
  54. package/scripts/brief-compile.js +2 -1
  55. package/scripts/brief-show.js +2 -1
  56. package/scripts/canon-bind.js +2 -2
  57. package/scripts/canon-build.js +27 -20
  58. package/scripts/canon-drift.js +16 -7
  59. package/scripts/canon-freeze-status.js +14 -6
  60. package/scripts/canon-freeze.js +27 -11
  61. package/scripts/canon-unfreeze.js +17 -9
  62. package/scripts/card.js +2 -2
  63. package/scripts/compare.js +2 -2
  64. package/scripts/critique.js +16 -1
  65. package/scripts/curate.js +2 -2
  66. package/scripts/doctor.js +181 -55
  67. package/scripts/eligibility.js +42 -2
  68. package/scripts/eval-pack.js +2 -2
  69. package/scripts/eval-run.js +2 -2
  70. package/scripts/export.js +2 -2
  71. package/scripts/generate-controlnet.js +45 -25
  72. package/scripts/generate-identity.js +53 -23
  73. package/scripts/generate-ipadapter.js +44 -23
  74. package/scripts/generate.js +48 -5
  75. package/scripts/implementation-pack.js +2 -2
  76. package/scripts/ingest.js +145 -0
  77. package/scripts/init.js +73 -13
  78. package/scripts/measure.js +170 -0
  79. package/scripts/measure_image.py +458 -0
  80. package/scripts/migrate-records.js +142 -0
  81. package/scripts/painterly-test.js +4 -3
  82. package/scripts/painterly.js +7 -3
  83. package/scripts/qwen_generate.py +138 -20
  84. package/scripts/reingest.js +2 -2
  85. package/scripts/sheet.js +142 -0
  86. package/scripts/snapshot.js +2 -2
  87. package/scripts/split.js +2 -2
  88. package/scripts/training-manifest.js +2 -2
  89. package/scripts/training-package.js +2 -2
  90. package/scripts/training-profile.js +2 -2
  91. package/scripts/workflow-list.js +2 -1
  92. package/scripts/workflow-show.js +2 -1
package/scripts/doctor.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { getProjectName } from '../lib/args.js';
17
- import { REPO_ROOT } from '../lib/paths.js';
17
+ import { REPO_ROOT, getProjectRoot, getRuntimeDir } from '../lib/paths.js';
18
18
  import { runtimeError, handleCliError } from '../lib/errors.js';
19
19
 
20
20
  const REQUIRED_CONFIG_FILES = [
@@ -52,7 +52,16 @@ const REQUIRED_DIRS = [
52
52
  ];
53
53
 
54
54
  function pass(msg) { console.log(` \x1b[32m✓\x1b[0m ${msg}`); }
55
- function fail(msg) { console.log(` \x1b[31m✗\x1b[0m ${msg}`); }
55
+ // M1: fail() carried no hint — every OTHER command in this CLI prints a
56
+ // "Hint:" line (lib/errors.js handleCliError) when one is available, but
57
+ // doctor's ~40 failure sites just dropped it on the floor. Doctor is the
58
+ // tool people run when confused; its output IS the error-recovery story.
59
+ // hint is optional so existing terse call sites (a handful of genuinely
60
+ // self-explanatory failures) keep working unchanged.
61
+ function fail(msg, hint) {
62
+ console.log(` \x1b[31m✗\x1b[0m ${msg}`);
63
+ if (hint) console.log(` \x1b[33mHint:\x1b[0m ${hint}`);
64
+ }
56
65
  function warn(msg) { console.log(` \x1b[33m⚠\x1b[0m ${msg}`); }
57
66
 
58
67
  function tryParseJson(filePath) {
@@ -65,7 +74,23 @@ function tryParseJson(filePath) {
65
74
 
66
75
  export async function run(argv = process.argv.slice(2)) {
67
76
  const projectName = getProjectName(argv);
68
- const projectDir = join(REPO_ROOT, 'projects', projectName);
77
+ // SDL-C3-completion: resolve through the hardened getProjectRoot() instead of
78
+ // a raw join(REPO_ROOT, 'projects', projectName) — the raw join let a
79
+ // --project value like "../../ai-eyes-mcp" escape the projects/ directory
80
+ // entirely (path traversal). getProjectRoot() rejects any name containing
81
+ // "/", "\" or ".." (INPUT_UNSAFE_PROJECT_NAME) and re-verifies containment,
82
+ // and throws INPUT_UNKNOWN_PROJECT (with the same "run sdlab init" hint this
83
+ // function used to print by hand) when the project does not exist.
84
+ //
85
+ // That second throw is also the SDL-C4 fix: this function used to check
86
+ // existence itself with `if (!existsSync(projectDir)) { fail(...); return; }`
87
+ // — a bare `return` that resolved the run() promise successfully, so
88
+ // handleCliError's process.exit() never ran and `sdlab project doctor
89
+ // --project <missing>` exited 0. getProjectRoot() throws instead of
90
+ // returning, so a missing project now rejects run() and reaches the CLI's
91
+ // normal nonzero-exit error path — the ANDON gate this command IS (it's
92
+ // exactly what `npm run verify` and the CI smoke-test step call).
93
+ const projectDir = getProjectRoot(projectName);
69
94
 
70
95
  console.log(`\x1b[1msdlab project doctor\x1b[0m — ${projectName}`);
71
96
  console.log(` Path: ${projectDir}\n`);
@@ -75,11 +100,8 @@ export async function run(argv = process.argv.slice(2)) {
75
100
  let warnings = 0;
76
101
 
77
102
  // ── 1. Project directory exists ──
78
- if (!existsSync(projectDir)) {
79
- fail(`Project directory not found: ${projectDir}`);
80
- console.log(`\n Run: sdlab init ${projectName} --domain <domain>`);
81
- return;
82
- }
103
+ // Guaranteed true here — getProjectRoot() above already throws otherwise.
104
+ // Recorded as a pass so the check numbering below stays stable.
83
105
  pass('Project directory exists');
84
106
  passes++;
85
107
 
@@ -89,13 +111,17 @@ export async function run(argv = process.argv.slice(2)) {
89
111
  for (const file of REQUIRED_CONFIG_FILES) {
90
112
  const filePath = join(projectDir, file);
91
113
  if (!existsSync(filePath)) {
92
- fail(`Missing: ${file}`);
114
+ fail(
115
+ `Missing: ${file}`,
116
+ `Run "sdlab init <name> --domain <domain>" to scaffold it, or hand-author ${file} — see ` +
117
+ `templates/domains/*/${file} for the expected shape.`
118
+ );
93
119
  failures++;
94
120
  continue;
95
121
  }
96
122
  const { data, error } = tryParseJson(filePath);
97
123
  if (error) {
98
- fail(`${file} — invalid JSON: ${error}`);
124
+ fail(`${file} — invalid JSON: ${error}`, `Open ${filePath} and fix the syntax error above.`);
99
125
  failures++;
100
126
  continue;
101
127
  }
@@ -121,7 +147,7 @@ export async function run(argv = process.argv.slice(2)) {
121
147
  if (meta) {
122
148
  console.log('');
123
149
  if (meta.name) { pass(`project.json: name = "${meta.name}"`); passes++; }
124
- else { fail('project.json: missing "name"'); failures++; }
150
+ else { fail('project.json: missing "name"', 'Add a top-level "name" field matching the project directory name.'); failures++; }
125
151
 
126
152
  if (meta.domain) { pass(`project.json: domain = "${meta.domain}"`); passes++; }
127
153
  else { warn('project.json: no "domain" set'); warnings++; }
@@ -133,21 +159,42 @@ export async function run(argv = process.argv.slice(2)) {
133
159
  console.log('');
134
160
  const rules = constitution.rules || (Array.isArray(constitution) ? constitution : []);
135
161
  if (rules.length === 0) {
136
- fail('constitution.json: no rules defined');
162
+ fail(
163
+ 'constitution.json: no rules defined',
164
+ 'Add at least one rule object to the "rules" array — see templates/domains/*/constitution.json for the shape.'
165
+ );
137
166
  failures++;
138
167
  } else {
139
168
  pass(`constitution.json: ${rules.length} rules`);
140
169
  passes++;
141
170
 
142
171
  // Check required rule fields
143
- const ruleIds = new Set();
172
+ // M1: localize WHICH index is missing an id, and WHICH TWO indices
173
+ // collide on a duplicate — a rule id is not the record's only
174
+ // identity, and "duplicate rule ID X" without saying where left the
175
+ // operator grepping the file by hand.
176
+ const ruleIdFirstIndex = new Map(); // id -> first index that defined it
144
177
  let ruleErrors = 0;
145
- for (const rule of rules) {
146
- if (!rule.id) { fail(`constitution.json: rule missing "id"`); ruleErrors++; continue; }
147
- if (ruleIds.has(rule.id)) { fail(`constitution.json: duplicate rule ID "${rule.id}"`); ruleErrors++; }
148
- ruleIds.add(rule.id);
178
+ for (const [i, rule] of rules.entries()) {
179
+ if (!rule.id) {
180
+ fail(`constitution.json: rule missing "id"`, `Add an "id" field to rules[${i}] (e.g. "STY-002").`);
181
+ ruleErrors++;
182
+ continue;
183
+ }
184
+ if (ruleIdFirstIndex.has(rule.id)) {
185
+ fail(
186
+ `constitution.json: duplicate rule ID "${rule.id}"`,
187
+ `rules[${ruleIdFirstIndex.get(rule.id)}] and rules[${i}] both use id "${rule.id}" — give one of them a unique id.`
188
+ );
189
+ ruleErrors++;
190
+ } else {
191
+ ruleIdFirstIndex.set(rule.id, i);
192
+ }
149
193
  if (!rule.dims || rule.dims.length === 0) {
150
- fail(`constitution.json: rule ${rule.id} has no "dims" (scoring dimensions)`);
194
+ fail(
195
+ `constitution.json: rule ${rule.id} has no "dims" (scoring dimensions)`,
196
+ `Add a non-empty "dims" array to rules[${i}] (e.g. ["style_consistency"]) — see rubric.json's "dimensions" for valid values.`
197
+ );
151
198
  ruleErrors++;
152
199
  }
153
200
  }
@@ -168,18 +215,35 @@ export async function run(argv = process.argv.slice(2)) {
168
215
  pass(`lanes.json: ${laneList.length} lanes`);
169
216
  passes++;
170
217
 
171
- // Check lane IDs unique
172
- const laneIds = new Set();
218
+ // Check lane IDs unique — same index-localization as constitution rules above.
219
+ const laneIdFirstIndex = new Map();
173
220
  let laneErrors = 0;
174
- for (const lane of laneList) {
175
- if (!lane.id) { fail('lanes.json: lane missing "id"'); laneErrors++; continue; }
176
- if (laneIds.has(lane.id)) { fail(`lanes.json: duplicate lane ID "${lane.id}"`); laneErrors++; }
177
- laneIds.add(lane.id);
221
+ for (const [i, lane] of laneList.entries()) {
222
+ if (!lane.id) {
223
+ fail('lanes.json: lane missing "id"', `Add an "id" field to lanes[${i}].`);
224
+ laneErrors++;
225
+ continue;
226
+ }
227
+ if (laneIdFirstIndex.has(lane.id)) {
228
+ fail(
229
+ `lanes.json: duplicate lane ID "${lane.id}"`,
230
+ `lanes[${laneIdFirstIndex.get(lane.id)}] and lanes[${i}] both use id "${lane.id}" — give one of them a unique id.`
231
+ );
232
+ laneErrors++;
233
+ } else {
234
+ laneIdFirstIndex.set(lane.id, i);
235
+ }
178
236
 
179
237
  // Check patterns compile
180
238
  for (const pattern of lane.id_patterns || []) {
181
239
  try { new RegExp(pattern); }
182
- catch { fail(`lanes.json: lane "${lane.id}" has invalid pattern "${pattern}"`); laneErrors++; }
240
+ catch {
241
+ fail(
242
+ `lanes.json: lane "${lane.id}" has invalid pattern "${pattern}"`,
243
+ `Fix the regular expression syntax for lane "${lane.id}"'s id_patterns entry "${pattern}".`
244
+ );
245
+ laneErrors++;
246
+ }
183
247
  }
184
248
  }
185
249
  if (laneErrors === 0) { pass('lanes.json: all lane IDs unique, patterns compile'); passes++; }
@@ -198,7 +262,10 @@ export async function run(argv = process.argv.slice(2)) {
198
262
  console.log('');
199
263
  const dims = rubric.dimensions || [];
200
264
  if (dims.length === 0) {
201
- fail('rubric.json: no dimensions defined');
265
+ fail(
266
+ 'rubric.json: no dimensions defined',
267
+ 'Add at least one dimension string to "dimensions" (e.g. ["style_consistency", "composition"]).'
268
+ );
202
269
  failures++;
203
270
  } else {
204
271
  pass(`rubric.json: ${dims.length} dimensions`);
@@ -206,7 +273,10 @@ export async function run(argv = process.argv.slice(2)) {
206
273
  }
207
274
 
208
275
  if (!rubric.thresholds) {
209
- fail('rubric.json: missing "thresholds"');
276
+ fail(
277
+ 'rubric.json: missing "thresholds"',
278
+ 'Add a "thresholds" object with approved/borderline/rejected bands — see templates/domains/*/rubric.json for the shape.'
279
+ );
210
280
  failures++;
211
281
  } else {
212
282
  pass('rubric.json: thresholds present');
@@ -224,7 +294,10 @@ export async function run(argv = process.argv.slice(2)) {
224
294
  }
225
295
  }
226
296
  if (missingDims.size > 0) {
227
- fail(`rubric.json: constitution references dimensions not in rubric: ${[...missingDims].join(', ')}`);
297
+ fail(
298
+ `rubric.json: constitution references dimensions not in rubric: ${[...missingDims].join(', ')}`,
299
+ `Add [${[...missingDims].map(d => `"${d}"`).join(', ')}] to rubric.json's "dimensions" array, or fix the referencing rule(s) in constitution.json.`
300
+ );
228
301
  failures++;
229
302
  } else {
230
303
  pass('rubric.json: all constitution dimensions present in rubric');
@@ -239,7 +312,10 @@ export async function run(argv = process.argv.slice(2)) {
239
312
  for (const [mode, ruleList] of Object.entries(rubric.failure_to_rules)) {
240
313
  for (const ruleId of ruleList) {
241
314
  if (!ruleIds.has(ruleId)) {
242
- fail(`rubric.json: failure mode "${mode}" references unknown rule "${ruleId}"`);
315
+ fail(
316
+ `rubric.json: failure mode "${mode}" references unknown rule "${ruleId}"`,
317
+ `Add a rule with id "${ruleId}" to constitution.json, or remove it from rubric.json's failure_to_rules["${mode}"].`
318
+ );
243
319
  badRefs++;
244
320
  }
245
321
  }
@@ -269,7 +345,10 @@ export async function run(argv = process.argv.slice(2)) {
269
345
  const order = terminology[orderKey] || [];
270
346
  for (const name of order) {
271
347
  if (!terminology.groups[name]) {
272
- fail(`terminology.json: ${orderKey} references unknown group "${name}"`);
348
+ fail(
349
+ `terminology.json: ${orderKey} references unknown group "${name}"`,
350
+ `Add "${name}" to terminology.json's "groups" object, or remove it from "${orderKey}".`
351
+ );
273
352
  failures++;
274
353
  }
275
354
  }
@@ -280,11 +359,23 @@ export async function run(argv = process.argv.slice(2)) {
280
359
  for (const [gName, group] of Object.entries(terminology.groups)) {
281
360
  for (const pattern of group.id_patterns || []) {
282
361
  try { new RegExp(pattern); }
283
- catch { fail(`terminology.json: group "${gName}" has invalid id_pattern "${pattern}"`); patternErrors++; }
362
+ catch {
363
+ fail(
364
+ `terminology.json: group "${gName}" has invalid id_pattern "${pattern}"`,
365
+ `Fix the regular expression syntax for group "${gName}"'s id_patterns entry "${pattern}".`
366
+ );
367
+ patternErrors++;
368
+ }
284
369
  }
285
370
  for (const pattern of group.prompt_patterns || []) {
286
371
  try { new RegExp(pattern); }
287
- catch { fail(`terminology.json: group "${gName}" has invalid prompt_pattern "${pattern}"`); patternErrors++; }
372
+ catch {
373
+ fail(
374
+ `terminology.json: group "${gName}" has invalid prompt_pattern "${pattern}"`,
375
+ `Fix the regular expression syntax for group "${gName}"'s prompt_patterns entry "${pattern}".`
376
+ );
377
+ patternErrors++;
378
+ }
288
379
  }
289
380
  }
290
381
  if (patternErrors === 0) { pass('terminology.json: all detection patterns compile'); passes++; }
@@ -305,46 +396,56 @@ export async function run(argv = process.argv.slice(2)) {
305
396
  let wfErrors = 0;
306
397
  for (const file of wfFiles) {
307
398
  const { data, error } = tryParseJson(join(workflowDir, file));
308
- if (error) { fail(`workflows/profiles/${file} — invalid JSON: ${error}`); wfErrors++; continue; }
399
+ if (error) {
400
+ fail(`workflows/profiles/${file} — invalid JSON: ${error}`, `Open workflows/profiles/${file} and fix the syntax error above.`);
401
+ wfErrors++;
402
+ continue;
403
+ }
309
404
 
310
405
  const missing = ['workflow_id', 'label', 'lane_id', 'output_mode', 'output_count',
311
406
  'prompt_strategy', 'negative_strategy', 'canon_focus', 'drift_guards', 'runtime_defaults']
312
407
  .filter(f => data[f] === undefined || data[f] === null);
313
408
  if (missing.length > 0) {
314
- fail(`workflows/profiles/${file} — missing fields: ${missing.join(', ')}`);
409
+ fail(`workflows/profiles/${file} — missing fields: ${missing.join(', ')}`, `Add [${missing.join(', ')}] to workflows/profiles/${file}.`);
315
410
  wfErrors++;
316
411
  continue;
317
412
  }
318
413
 
319
414
  if (data.lane_id && laneIds.size > 0 && !laneIds.has(data.lane_id)) {
320
- fail(`workflows/profiles/${file} — lane_id "${data.lane_id}" not found in lanes.json`);
415
+ fail(
416
+ `workflows/profiles/${file} — lane_id "${data.lane_id}" not found in lanes.json`,
417
+ `Use one of lanes.json's existing lane ids [${[...laneIds].join(', ')}], or add "${data.lane_id}" as a new lane.`
418
+ );
321
419
  wfErrors++;
322
420
  }
323
421
 
324
422
  if (data.subject_mode && !VALID_SUBJECT_MODES.includes(data.subject_mode)) {
325
- fail(`workflows/profiles/${file} — invalid subject_mode "${data.subject_mode}"`);
423
+ fail(`workflows/profiles/${file} — invalid subject_mode "${data.subject_mode}"`, `subject_mode must be one of: ${VALID_SUBJECT_MODES.join(', ')}.`);
326
424
  wfErrors++;
327
425
  }
328
426
 
329
427
  if (!VALID_OUTPUT_MODES.includes(data.output_mode)) {
330
- fail(`workflows/profiles/${file} — invalid output_mode "${data.output_mode}"`);
428
+ fail(`workflows/profiles/${file} — invalid output_mode "${data.output_mode}"`, `output_mode must be one of: ${VALID_OUTPUT_MODES.join(', ')}.`);
331
429
  wfErrors++;
332
430
  }
333
431
 
334
432
  const ps = data.prompt_strategy;
335
433
  if (!ps || !Array.isArray(ps.style_prefix) || !ps.structure || !Array.isArray(ps.must_include)) {
336
- fail(`workflows/profiles/${file} — prompt_strategy needs style_prefix[], structure, must_include[]`);
434
+ fail(
435
+ `workflows/profiles/${file} — prompt_strategy needs style_prefix[], structure, must_include[]`,
436
+ `Add prompt_strategy.style_prefix (array), .structure (string), and .must_include (array) to workflows/profiles/${file}.`
437
+ );
337
438
  wfErrors++;
338
439
  }
339
440
 
340
441
  const ns = data.negative_strategy;
341
442
  if (!ns || !Array.isArray(ns.must_avoid)) {
342
- fail(`workflows/profiles/${file} — negative_strategy needs must_avoid[]`);
443
+ fail(`workflows/profiles/${file} — negative_strategy needs must_avoid[]`, `Add negative_strategy.must_avoid (array) to workflows/profiles/${file}.`);
343
444
  wfErrors++;
344
445
  }
345
446
 
346
447
  if (!data.runtime_defaults?.adapter_target) {
347
- fail(`workflows/profiles/${file} — runtime_defaults needs adapter_target`);
448
+ fail(`workflows/profiles/${file} — runtime_defaults needs adapter_target`, `Add runtime_defaults.adapter_target (e.g. "comfyui") to workflows/profiles/${file}.`);
348
449
  wfErrors++;
349
450
  }
350
451
  }
@@ -377,34 +478,41 @@ export async function run(argv = process.argv.slice(2)) {
377
478
  let bmErrors = 0;
378
479
  for (const file of bmFiles) {
379
480
  const { data, error } = tryParseJson(join(batchModesDir, file));
380
- if (error) { fail(`workflows/batch-modes/${file} — invalid JSON: ${error}`); bmErrors++; continue; }
481
+ if (error) {
482
+ fail(`workflows/batch-modes/${file} — invalid JSON: ${error}`, `Open workflows/batch-modes/${file} and fix the syntax error above.`);
483
+ bmErrors++;
484
+ continue;
485
+ }
381
486
 
382
- if (!data.mode_id) { fail(`workflows/batch-modes/${file} — missing mode_id`); bmErrors++; }
487
+ if (!data.mode_id) { fail(`workflows/batch-modes/${file} — missing mode_id`, `Add a "mode_id" field to workflows/batch-modes/${file}.`); bmErrors++; }
383
488
  if (!data.batch_type || !VALID_BATCH_TYPES.includes(data.batch_type)) {
384
- fail(`workflows/batch-modes/${file} — invalid batch_type "${data.batch_type}"`);
489
+ fail(`workflows/batch-modes/${file} — invalid batch_type "${data.batch_type}"`, `batch_type must be one of: ${VALID_BATCH_TYPES.join(', ')}.`);
385
490
  bmErrors++;
386
491
  }
387
492
  if (data.subject_mode && !VALID_SUBJECT_MODES_BM.includes(data.subject_mode)) {
388
- fail(`workflows/batch-modes/${file} — invalid subject_mode "${data.subject_mode}"`);
493
+ fail(`workflows/batch-modes/${file} — invalid subject_mode "${data.subject_mode}"`, `subject_mode must be one of: ${VALID_SUBJECT_MODES_BM.join(', ')}.`);
389
494
  bmErrors++;
390
495
  }
391
496
  if (data.base_workflow_id && wfFiles.length > 0 && !wfFiles.includes(data.base_workflow_id)) {
392
- fail(`workflows/batch-modes/${file} — base_workflow_id "${data.base_workflow_id}" not found in profiles`);
497
+ fail(
498
+ `workflows/batch-modes/${file} — base_workflow_id "${data.base_workflow_id}" not found in profiles`,
499
+ `Use one of the existing workflow ids [${wfFiles.join(', ')}], or add a matching profile under workflows/profiles/.`
500
+ );
393
501
  bmErrors++;
394
502
  }
395
503
  if (!Array.isArray(data.variant_plan) || data.variant_plan.length === 0) {
396
- fail(`workflows/batch-modes/${file} — variant_plan must be non-empty array`);
504
+ fail(`workflows/batch-modes/${file} — variant_plan must be non-empty array`, `Add at least one slot to variant_plan in workflows/batch-modes/${file}.`);
397
505
  bmErrors++;
398
506
  } else {
399
507
  const slotIds = data.variant_plan.map(s => s.slot_id);
400
508
  const dupes = slotIds.filter((id, i) => slotIds.indexOf(id) !== i);
401
509
  if (dupes.length > 0) {
402
- fail(`workflows/batch-modes/${file} — duplicate slot_ids: ${[...new Set(dupes)].join(', ')}`);
510
+ fail(`workflows/batch-modes/${file} — duplicate slot_ids: ${[...new Set(dupes)].join(', ')}`, `Give each entry in variant_plan a unique slot_id.`);
403
511
  bmErrors++;
404
512
  }
405
513
  }
406
514
  if (data.assembly?.layout && !VALID_LAYOUTS.includes(data.assembly.layout)) {
407
- fail(`workflows/batch-modes/${file} — invalid assembly.layout "${data.assembly.layout}"`);
515
+ fail(`workflows/batch-modes/${file} — invalid assembly.layout "${data.assembly.layout}"`, `assembly.layout must be one of: ${VALID_LAYOUTS.join(', ')}.`);
408
516
  bmErrors++;
409
517
  }
410
518
  }
@@ -424,17 +532,26 @@ export async function run(argv = process.argv.slice(2)) {
424
532
  // ── 11. Runtime templates check ──
425
533
  {
426
534
  console.log('');
427
- const runtimeDir = join(REPO_ROOT, 'runtime', 'comfyui');
535
+ // PACKAGE-relative, not workspace-relative — `runtime/` ships inside the
536
+ // npm tarball (see getPackageRoot() in lib/paths.js). Joining it to
537
+ // REPO_ROOT made doctor report "runtime/comfyui/ — directory not found"
538
+ // for every installed user while the templates sat in the package.
539
+ const runtimeDir = join(getRuntimeDir(), 'comfyui');
428
540
  if (existsSync(runtimeDir)) {
429
541
  const templateFiles = readdirSync(runtimeDir).filter(f => f.endsWith('.json'));
430
542
  if (templateFiles.length > 0) {
431
543
  let templateErrors = 0;
432
544
  for (const file of templateFiles) {
433
545
  const { data, error } = tryParseJson(join(runtimeDir, file));
434
- if (error) { fail(`runtime/comfyui/${file}: invalid JSON — ${error}`); templateErrors++; continue; }
435
- if (!data.template_id) { fail(`runtime/comfyui/${file}: missing template_id`); templateErrors++; }
546
+ if (error) {
547
+ fail(`runtime/comfyui/${file}: invalid JSON — ${error}`, `Open ${join(runtimeDir, file)} and fix the syntax error above.`);
548
+ templateErrors++;
549
+ continue;
550
+ }
551
+ if (!data.template_id) { fail(`runtime/comfyui/${file}: missing template_id`, `Add a "template_id" field to ${file}.`); templateErrors++; }
436
552
  if (!data.compatible_modes || !Array.isArray(data.compatible_modes)) {
437
- fail(`runtime/comfyui/${file}: missing compatible_modes[]`); templateErrors++;
553
+ fail(`runtime/comfyui/${file}: missing compatible_modes[]`, `Add a "compatible_modes" array to ${file}.`);
554
+ templateErrors++;
438
555
  }
439
556
  }
440
557
  if (templateErrors === 0) {
@@ -464,8 +581,13 @@ export async function run(argv = process.argv.slice(2)) {
464
581
  const sample = recordFiles.slice(0, 3);
465
582
  for (const f of sample) {
466
583
  const { data, error } = tryParseJson(join(recordsDir, f));
467
- if (error) { fail(`records/${f}: invalid JSON`); recordErrors++; }
468
- else if (!data.id) { fail(`records/${f}: missing "id"`); recordErrors++; }
584
+ if (error) {
585
+ fail(`records/${f}: invalid JSON`, `Open records/${f} and fix the syntax error, or delete/regenerate it if it's an aborted write.`);
586
+ recordErrors++;
587
+ } else if (!data.id) {
588
+ fail(`records/${f}: missing "id"`, `Add an "id" field to records/${f} matching its filename stem.`);
589
+ recordErrors++;
590
+ }
469
591
  }
470
592
  if (recordErrors === 0) {
471
593
  pass(`${recordFiles.length} records (spot-check passed)`);
@@ -485,7 +607,11 @@ export async function run(argv = process.argv.slice(2)) {
485
607
  console.log(`═══ ${status} — ${passes} passed, ${failures} failed, ${warnings} warnings ═══`);
486
608
 
487
609
  if (failures > 0) {
488
- throw runtimeError('PROJECT_UNHEALTHY', `Project "${projectName}" has ${failures} issue(s).`);
610
+ throw runtimeError(
611
+ 'PROJECT_UNHEALTHY',
612
+ `Project "${projectName}" has ${failures} issue(s).`,
613
+ `Fix the ✗ lines above — each one now carries its own Hint: with a concrete next action — then re-run: sdlab project doctor --project ${projectName}`
614
+ );
489
615
  }
490
616
  }
491
617
 
@@ -10,10 +10,11 @@
10
10
  import { readdir, readFile } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import { parseArgs, getProjectName } from '../lib/args.js';
13
- import { REPO_ROOT } from '../lib/paths.js';
13
+ import { getProjectRoot } from '../lib/paths.js';
14
14
  import { loadProjectConfig, loadSelectionProfile } from '../lib/config.js';
15
15
  import { handleCliError } from '../lib/errors.js';
16
16
  import { evaluateEligibility, categorizeExclusions } from '../lib/eligibility.js';
17
+ import { auditJudgmentProvenance, formatProvenanceWarning, formatReuseNote } from '../lib/judgment-provenance.js';
17
18
 
18
19
  export async function run(argv = process.argv.slice(2)) {
19
20
  const { flags } = parseArgs(argv, {
@@ -26,7 +27,7 @@ export async function run(argv = process.argv.slice(2)) {
26
27
  });
27
28
 
28
29
  const projectName = flags.project || getProjectName(argv);
29
- const projectRoot = join(REPO_ROOT, 'projects', projectName);
30
+ const projectRoot = getProjectRoot(projectName);
30
31
 
31
32
  const profile = loadSelectionProfile(projectRoot, flags.profile);
32
33
  const config = loadProjectConfig(projectRoot);
@@ -42,10 +43,15 @@ export async function run(argv = process.argv.slice(2)) {
42
43
  const eligible = [];
43
44
  const excluded = [];
44
45
  const nearMiss = [];
46
+ // Retained for the judgment-provenance audit below, which is a corpus-level
47
+ // question (is this rationale reused across records?) and cannot be answered
48
+ // one record at a time.
49
+ const allRecords = [];
45
50
 
46
51
  for (const file of files) {
47
52
  const raw = await readFile(join(recordsDir, file), 'utf-8');
48
53
  const record = JSON.parse(raw);
54
+ allRecords.push(record);
49
55
 
50
56
  const result = evaluateEligibility(record, profile, config.lanes, config.terminology);
51
57
 
@@ -72,6 +78,40 @@ export async function run(argv = process.argv.slice(2)) {
72
78
  }
73
79
  }
74
80
 
81
+ // Judgment provenance (Law 2). Eligibility asks "does this record have a
82
+ // judgment"; it has never asked "does that judgment describe THIS image."
83
+ // On star-freight the difference is 948 of 1,182 records — rationales a
84
+ // deleted bulk script selected by id-prefix, one string reused on 144
85
+ // images. Those records pass every eligibility check and land in training
86
+ // sets carrying an explanation about a category, not a picture.
87
+ //
88
+ // Read-only: nothing here mutates a record (Director's ruling, 2026-08-04).
89
+ const provenance = auditJudgmentProvenance(allRecords);
90
+ const provenanceWarning = formatProvenanceWarning(provenance);
91
+ if (provenanceWarning) {
92
+ console.log('');
93
+ console.log(` \x1b[33m⚠\x1b[0m ${provenanceWarning}`);
94
+ if (provenance.top_reused.length > 0) {
95
+ console.log(' Most-reused rationales:');
96
+ for (const r of provenance.top_reused.slice(0, 3)) {
97
+ const snippet = r.explanation.length > 62
98
+ ? `${r.explanation.slice(0, 62)}…`
99
+ : r.explanation;
100
+ console.log(` ×${String(r.count).padStart(3)} "${snippet}"`);
101
+ }
102
+ }
103
+ } else {
104
+ // No script reviewer. Reuse here is usually a real systematic defect
105
+ // described accurately across the wave it affected — informational, not a
106
+ // finding. Printed at the same indent but without the warning glyph, so it
107
+ // reads as context rather than an accusation.
108
+ const reuseNote = formatReuseNote(provenance);
109
+ if (reuseNote) {
110
+ console.log('');
111
+ console.log(` ${reuseNote}`);
112
+ }
113
+ }
114
+
75
115
  if (nearMiss.length > 0) {
76
116
  console.log(`\n Improvement opportunities (${nearMiss.length} records with 1 failing check):`);
77
117
  const byReason = {};
@@ -12,7 +12,7 @@
12
12
  import { readFile } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
14
  import { parseArgs, getProjectName } from '../lib/args.js';
15
- import { REPO_ROOT } from '../lib/paths.js';
15
+ import { getProjectRoot } from '../lib/paths.js';
16
16
  import { inputError, handleCliError } from '../lib/errors.js';
17
17
  import { buildEvalPack, listEvalPacks, loadEvalPack } from '../lib/eval-pack.js';
18
18
 
@@ -23,7 +23,7 @@ export async function run(argv = process.argv.slice(2)) {
23
23
  });
24
24
 
25
25
  const projectName = flags.project || getProjectName(argv);
26
- const projectRoot = join(REPO_ROOT, 'projects', projectName);
26
+ const projectRoot = getProjectRoot(projectName);
27
27
  const subcommand = positionals[0] || 'list';
28
28
 
29
29
  if (subcommand === 'build') {
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { join } from 'node:path';
14
14
  import { parseArgs, getProjectName } from '../lib/args.js';
15
- import { REPO_ROOT, resolveSafeProjectPath } from '../lib/paths.js';
15
+ import { REPO_ROOT, getProjectRoot, resolveSafeProjectPath } from '../lib/paths.js';
16
16
  import { inputError, handleCliError } from '../lib/errors.js';
17
17
  import { listTrainingManifests } from '../lib/training-manifests.js';
18
18
  import { listEvalPacks } from '../lib/eval-pack.js';
@@ -30,7 +30,7 @@ export async function run(argv = process.argv.slice(2)) {
30
30
  });
31
31
 
32
32
  const projectName = flags.project || getProjectName(argv);
33
- const projectRoot = join(REPO_ROOT, 'projects', projectName);
33
+ const projectRoot = getProjectRoot(projectName);
34
34
  // First non-flag positional that isn't an er-id is the subcommand
35
35
  const subcommand = positionals.find(a => !a.startsWith('er-')) || 'list';
36
36
 
package/scripts/export.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { join } from 'node:path';
12
12
  import { parseArgs, getProjectName } from '../lib/args.js';
13
- import { REPO_ROOT } from '../lib/paths.js';
13
+ import { getProjectRoot } from '../lib/paths.js';
14
14
  import { inputError, handleCliError } from '../lib/errors.js';
15
15
  import { listSnapshots } from '../lib/snapshot.js';
16
16
  import { listSplits } from '../lib/split.js';
@@ -29,7 +29,7 @@ export async function run(argv = process.argv.slice(2)) {
29
29
  });
30
30
 
31
31
  const projectName = flags.project || getProjectName(argv);
32
- const projectRoot = join(REPO_ROOT, 'projects', projectName);
32
+ const projectRoot = getProjectRoot(projectName);
33
33
  const subcommand = positionals[0] || 'list';
34
34
 
35
35
  if (subcommand === 'build') {