@mcptoolshop/style-dataset-lab 3.3.0 → 3.5.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 (119) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +117 -55
  3. package/README.fr.md +125 -65
  4. package/README.hi.md +117 -68
  5. package/README.it.md +119 -58
  6. package/README.ja.md +124 -74
  7. package/README.md +73 -7
  8. package/README.pt-BR.md +128 -63
  9. package/README.zh.md +116 -65
  10. package/bin/sdlab.js +100 -4
  11. package/lib/adapters/ai-toolkit.js +224 -35
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +94 -11
  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/asset-ingest.js +575 -0
  18. package/lib/asset-masks.js +323 -0
  19. package/lib/asset-source.js +1176 -0
  20. package/lib/batch-modes.js +29 -8
  21. package/lib/batch-runs.js +29 -1
  22. package/lib/batch-sheet-render.js +13 -3
  23. package/lib/brief-compiler.js +19 -0
  24. package/lib/candidate-sheet.js +327 -0
  25. package/lib/canon-author.js +350 -0
  26. package/lib/canon-build/build.js +146 -93
  27. package/lib/canon-review-gate.js +205 -0
  28. package/lib/captions.js +57 -0
  29. package/lib/card.js +74 -2
  30. package/lib/colab-launcher-guards.js +285 -0
  31. package/lib/comfyui.js +116 -18
  32. package/lib/config.js +116 -12
  33. package/lib/critique-engine.js +36 -2
  34. package/lib/eligibility.js +121 -9
  35. package/lib/eval-pack.js +14 -14
  36. package/lib/eval-runs.js +33 -0
  37. package/lib/export.js +122 -34
  38. package/lib/freeze-gate.js +16 -3
  39. package/lib/freeze-stamp.js +28 -3
  40. package/lib/generated-provenance.js +194 -6
  41. package/lib/implementation-packs.js +95 -17
  42. package/lib/ingest.js +396 -0
  43. package/lib/judgment-provenance.js +220 -0
  44. package/lib/lane1-render.js +668 -0
  45. package/lib/measure.js +422 -0
  46. package/lib/memory-store.js +663 -0
  47. package/lib/model-licences.js +305 -0
  48. package/lib/npy-meta.js +130 -0
  49. package/lib/paths.js +196 -7
  50. package/lib/png-meta.js +512 -0
  51. package/lib/records.js +54 -8
  52. package/lib/reingest-selected.js +113 -17
  53. package/lib/reingest.js +27 -3
  54. package/lib/run-manifest.js +159 -25
  55. package/lib/run-summary.js +12 -6
  56. package/lib/runtime-runs.js +45 -4
  57. package/lib/snapshot.js +114 -15
  58. package/lib/split.js +364 -48
  59. package/lib/train-runs.js +214 -0
  60. package/lib/training-manifests.js +114 -21
  61. package/lib/training-packages.js +147 -15
  62. package/lib/verifier.js +3 -0
  63. package/lib/verify-chain.js +367 -0
  64. package/lib/workflow-profiles.js +24 -3
  65. package/package.json +4 -3
  66. package/runtime/comfyui/txt2img-qwen.json +38 -0
  67. package/runtime/comfyui/txt2img-standard.json +21 -0
  68. package/scripts/_resume.js +62 -0
  69. package/scripts/_shared-args.js +44 -0
  70. package/scripts/asset-ingest.js +160 -0
  71. package/scripts/brief-compile.js +2 -1
  72. package/scripts/brief-show.js +2 -1
  73. package/scripts/canon-bind.js +2 -2
  74. package/scripts/canon-build.js +27 -20
  75. package/scripts/canon-draft.js +82 -0
  76. package/scripts/canon-drift.js +16 -7
  77. package/scripts/canon-freeze-status.js +14 -6
  78. package/scripts/canon-freeze.js +31 -11
  79. package/scripts/canon-unfreeze.js +17 -9
  80. package/scripts/card.js +2 -2
  81. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  82. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  83. package/scripts/colab/colab_window.sh +587 -0
  84. package/scripts/colab/lane1_render.py +970 -0
  85. package/scripts/colab/launch_guards.py +564 -0
  86. package/scripts/colab/launch_lane1_stub.sh +309 -0
  87. package/scripts/colab/launch_lane3_stub.sh +366 -0
  88. package/scripts/colab/remote_runner.py +59 -0
  89. package/scripts/compare.js +2 -2
  90. package/scripts/critique.js +16 -1
  91. package/scripts/curate.js +2 -2
  92. package/scripts/doctor.js +182 -55
  93. package/scripts/eligibility.js +42 -2
  94. package/scripts/eval-pack.js +2 -2
  95. package/scripts/eval-run.js +2 -2
  96. package/scripts/export.js +2 -2
  97. package/scripts/generate-controlnet.js +45 -25
  98. package/scripts/generate-identity.js +53 -23
  99. package/scripts/generate-ipadapter.js +44 -23
  100. package/scripts/generate.js +86 -17
  101. package/scripts/implementation-pack.js +2 -2
  102. package/scripts/ingest.js +145 -0
  103. package/scripts/init.js +73 -13
  104. package/scripts/measure.js +170 -0
  105. package/scripts/measure_image.py +458 -0
  106. package/scripts/migrate-records.js +142 -0
  107. package/scripts/painterly-test.js +4 -3
  108. package/scripts/painterly.js +7 -3
  109. package/scripts/qwen_generate.py +138 -20
  110. package/scripts/reingest.js +2 -2
  111. package/scripts/sheet.js +142 -0
  112. package/scripts/snapshot.js +2 -2
  113. package/scripts/split.js +2 -2
  114. package/scripts/training-manifest.js +2 -2
  115. package/scripts/training-package.js +2 -2
  116. package/scripts/training-profile.js +2 -2
  117. package/scripts/verify.js +67 -0
  118. package/scripts/workflow-list.js +2 -1
  119. package/scripts/workflow-show.js +2 -1
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Remote kernel runner executed via `colab exec -s <session> -f remote_runner.py`.
3
+
4
+ Runs the uploaded remote shell script (`SDLAB_REMOTE_SCRIPT`, e.g.
5
+ `/content/remote_lane1_stub.sh` or `/content/remote_lane3_stub.sh`) via
6
+ `subprocess.Popen`, streaming each stdout/stderr line to the IPython kernel's
7
+ stdout as it arrives so the rig's vitals and DEAD HARNESS watcher read it in
8
+ real time, and finishes by printing a `SDLAB_REMOTE_RC=<rc>` marker line.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+
18
+
19
+ def _resolve_bash(env: dict[str, str]) -> str:
20
+ git_bash = "C:/Program Files/Git/bin/bash.exe"
21
+ if os.name == "nt" and os.path.exists(git_bash):
22
+ return git_bash
23
+ found = shutil.which("bash", path=env.get("PATH"))
24
+ return found or "bash"
25
+
26
+
27
+ def run_remote_script(
28
+ script_path: str | None = None,
29
+ *,
30
+ env: dict[str, str] | None = None,
31
+ ) -> int:
32
+ effective_env = dict(os.environ if env is None else env)
33
+ target_script = script_path or effective_env.get("SDLAB_REMOTE_SCRIPT", "").strip()
34
+ if not target_script:
35
+ print("REFUSED: SDLAB_REMOTE_SCRIPT is required", file=sys.stderr, flush=True)
36
+ print("SDLAB_REMOTE_RC=2", flush=True)
37
+ return 2
38
+
39
+ cmd = [_resolve_bash(effective_env), target_script]
40
+ proc = subprocess.Popen(
41
+ cmd,
42
+ stdin=subprocess.DEVNULL,
43
+ stdout=subprocess.PIPE,
44
+ stderr=subprocess.STDOUT,
45
+ text=True,
46
+ bufsize=1,
47
+ env=effective_env,
48
+ )
49
+ assert proc.stdout is not None
50
+ for line in proc.stdout:
51
+ sys.stdout.write(line)
52
+ sys.stdout.flush()
53
+ rc = int(proc.wait())
54
+ print(f"SDLAB_REMOTE_RC={rc}", flush=True)
55
+ return rc
56
+
57
+
58
+ if __name__ == "__main__":
59
+ run_remote_script()
@@ -11,7 +11,7 @@
11
11
  import { writeFile, readdir, mkdir, readFile } from "node:fs/promises";
12
12
  import { join } from "node:path";
13
13
  import { parseArgs, getProjectName } from "../lib/args.js";
14
- import { REPO_ROOT } from "../lib/paths.js";
14
+ import { getProjectRoot } from "../lib/paths.js";
15
15
  import { readJsonFile } from "../lib/config.js";
16
16
  import { inputError, handleCliError } from "../lib/errors.js";
17
17
 
@@ -25,7 +25,7 @@ export async function run(argv = process.argv.slice(2)) {
25
25
  });
26
26
 
27
27
  const projectName = flags.project || getProjectName(argv);
28
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
28
+ const GAME_ROOT = getProjectRoot(projectName);
29
29
 
30
30
  const [assetAId, assetBId, winner, ...reasonParts] = positionals;
31
31
 
@@ -84,9 +84,24 @@ export async function run(argv = process.argv.slice(2)) {
84
84
  const action = report.recommended_action;
85
85
  console.log(`Critique saved to: runs/${flags.run}/critique.json`);
86
86
 
87
+ // H2: `refine_from_one` fires today purely from every candidate sitting
88
+ // at the unreviewed default overall_fit "usable" (nothing in the
89
+ // codebase currently sets it to anything else) — action.reviewed
90
+ // (lib/critique-engine.js recommendAction) distinguishes that from an
91
+ // actually-assessed run. Printing "Next: ..." with the same confidence
92
+ // either way presented brief-keyword drift tiebreaking as a considered
93
+ // recommendation. Qualify instead of suppressing outright — the pick is
94
+ // still a reasonable place to look first, it's just not a judgment.
87
95
  if (action.mode === 'refine_from_one' && action.preferred_candidate) {
88
96
  console.log('');
89
- info(`Next: sdlab refine --run ${flags.run} --pick ${action.preferred_candidate}`);
97
+ if (action.reviewed) {
98
+ info(`Next: sdlab refine --run ${flags.run} --pick ${action.preferred_candidate}`);
99
+ } else {
100
+ info(`No candidate has been reviewed yet — every image is at the rule-based pass's default "usable" ` +
101
+ `until a human or LLM judge assesses fit.`);
102
+ info(`If you want to proceed anyway: sdlab refine --run ${flags.run} --pick ${action.preferred_candidate} ` +
103
+ `(picked by fewest rule-based drift issues, not by looking at the image).`);
104
+ }
90
105
  } else if (action.mode === 'accept_one' && action.preferred_candidate) {
91
106
  console.log('');
92
107
  info(`Preferred candidate: ${action.preferred_candidate}`);
package/scripts/curate.js CHANGED
@@ -13,7 +13,7 @@
13
13
  import { writeFile, rename, readdir, mkdir, readFile } from "node:fs/promises";
14
14
  import { join } from "node:path";
15
15
  import { parseArgs, getProjectName } from "../lib/args.js";
16
- import { REPO_ROOT } from "../lib/paths.js";
16
+ import { getProjectRoot } from "../lib/paths.js";
17
17
  import { readJsonFile } from "../lib/config.js";
18
18
  import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
19
19
  import { result } from "../lib/log.js";
@@ -35,7 +35,7 @@ export async function run(argv = process.argv.slice(2)) {
35
35
  });
36
36
 
37
37
  const projectName = flags.project || getProjectName(argv);
38
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
38
+ const GAME_ROOT = getProjectRoot(projectName);
39
39
  const dryRun = flags['dry-run'] || flags.dryRun;
40
40
 
41
41
  if (flags.list) {
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,10 +147,11 @@ 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++; }
154
+ if (meta.display_name === 'TEMPLATE') { fail('project.json: display_name is still "TEMPLATE"', 'Set a real display_name in project.json instead of the unedited template placeholder.'); failures++; }
128
155
  }
129
156
 
130
157
  // ── 5. Constitution validation ──
@@ -133,21 +160,42 @@ export async function run(argv = process.argv.slice(2)) {
133
160
  console.log('');
134
161
  const rules = constitution.rules || (Array.isArray(constitution) ? constitution : []);
135
162
  if (rules.length === 0) {
136
- fail('constitution.json: no rules defined');
163
+ fail(
164
+ 'constitution.json: no rules defined',
165
+ 'Add at least one rule object to the "rules" array — see templates/domains/*/constitution.json for the shape.'
166
+ );
137
167
  failures++;
138
168
  } else {
139
169
  pass(`constitution.json: ${rules.length} rules`);
140
170
  passes++;
141
171
 
142
172
  // Check required rule fields
143
- const ruleIds = new Set();
173
+ // M1: localize WHICH index is missing an id, and WHICH TWO indices
174
+ // collide on a duplicate — a rule id is not the record's only
175
+ // identity, and "duplicate rule ID X" without saying where left the
176
+ // operator grepping the file by hand.
177
+ const ruleIdFirstIndex = new Map(); // id -> first index that defined it
144
178
  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);
179
+ for (const [i, rule] of rules.entries()) {
180
+ if (!rule.id) {
181
+ fail(`constitution.json: rule missing "id"`, `Add an "id" field to rules[${i}] (e.g. "STY-002").`);
182
+ ruleErrors++;
183
+ continue;
184
+ }
185
+ if (ruleIdFirstIndex.has(rule.id)) {
186
+ fail(
187
+ `constitution.json: duplicate rule ID "${rule.id}"`,
188
+ `rules[${ruleIdFirstIndex.get(rule.id)}] and rules[${i}] both use id "${rule.id}" — give one of them a unique id.`
189
+ );
190
+ ruleErrors++;
191
+ } else {
192
+ ruleIdFirstIndex.set(rule.id, i);
193
+ }
149
194
  if (!rule.dims || rule.dims.length === 0) {
150
- fail(`constitution.json: rule ${rule.id} has no "dims" (scoring dimensions)`);
195
+ fail(
196
+ `constitution.json: rule ${rule.id} has no "dims" (scoring dimensions)`,
197
+ `Add a non-empty "dims" array to rules[${i}] (e.g. ["style_consistency"]) — see rubric.json's "dimensions" for valid values.`
198
+ );
151
199
  ruleErrors++;
152
200
  }
153
201
  }
@@ -168,18 +216,35 @@ export async function run(argv = process.argv.slice(2)) {
168
216
  pass(`lanes.json: ${laneList.length} lanes`);
169
217
  passes++;
170
218
 
171
- // Check lane IDs unique
172
- const laneIds = new Set();
219
+ // Check lane IDs unique — same index-localization as constitution rules above.
220
+ const laneIdFirstIndex = new Map();
173
221
  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);
222
+ for (const [i, lane] of laneList.entries()) {
223
+ if (!lane.id) {
224
+ fail('lanes.json: lane missing "id"', `Add an "id" field to lanes[${i}].`);
225
+ laneErrors++;
226
+ continue;
227
+ }
228
+ if (laneIdFirstIndex.has(lane.id)) {
229
+ fail(
230
+ `lanes.json: duplicate lane ID "${lane.id}"`,
231
+ `lanes[${laneIdFirstIndex.get(lane.id)}] and lanes[${i}] both use id "${lane.id}" — give one of them a unique id.`
232
+ );
233
+ laneErrors++;
234
+ } else {
235
+ laneIdFirstIndex.set(lane.id, i);
236
+ }
178
237
 
179
238
  // Check patterns compile
180
239
  for (const pattern of lane.id_patterns || []) {
181
240
  try { new RegExp(pattern); }
182
- catch { fail(`lanes.json: lane "${lane.id}" has invalid pattern "${pattern}"`); laneErrors++; }
241
+ catch {
242
+ fail(
243
+ `lanes.json: lane "${lane.id}" has invalid pattern "${pattern}"`,
244
+ `Fix the regular expression syntax for lane "${lane.id}"'s id_patterns entry "${pattern}".`
245
+ );
246
+ laneErrors++;
247
+ }
183
248
  }
184
249
  }
185
250
  if (laneErrors === 0) { pass('lanes.json: all lane IDs unique, patterns compile'); passes++; }
@@ -198,7 +263,10 @@ export async function run(argv = process.argv.slice(2)) {
198
263
  console.log('');
199
264
  const dims = rubric.dimensions || [];
200
265
  if (dims.length === 0) {
201
- fail('rubric.json: no dimensions defined');
266
+ fail(
267
+ 'rubric.json: no dimensions defined',
268
+ 'Add at least one dimension string to "dimensions" (e.g. ["style_consistency", "composition"]).'
269
+ );
202
270
  failures++;
203
271
  } else {
204
272
  pass(`rubric.json: ${dims.length} dimensions`);
@@ -206,7 +274,10 @@ export async function run(argv = process.argv.slice(2)) {
206
274
  }
207
275
 
208
276
  if (!rubric.thresholds) {
209
- fail('rubric.json: missing "thresholds"');
277
+ fail(
278
+ 'rubric.json: missing "thresholds"',
279
+ 'Add a "thresholds" object with approved/borderline/rejected bands — see templates/domains/*/rubric.json for the shape.'
280
+ );
210
281
  failures++;
211
282
  } else {
212
283
  pass('rubric.json: thresholds present');
@@ -224,7 +295,10 @@ export async function run(argv = process.argv.slice(2)) {
224
295
  }
225
296
  }
226
297
  if (missingDims.size > 0) {
227
- fail(`rubric.json: constitution references dimensions not in rubric: ${[...missingDims].join(', ')}`);
298
+ fail(
299
+ `rubric.json: constitution references dimensions not in rubric: ${[...missingDims].join(', ')}`,
300
+ `Add [${[...missingDims].map(d => `"${d}"`).join(', ')}] to rubric.json's "dimensions" array, or fix the referencing rule(s) in constitution.json.`
301
+ );
228
302
  failures++;
229
303
  } else {
230
304
  pass('rubric.json: all constitution dimensions present in rubric');
@@ -239,7 +313,10 @@ export async function run(argv = process.argv.slice(2)) {
239
313
  for (const [mode, ruleList] of Object.entries(rubric.failure_to_rules)) {
240
314
  for (const ruleId of ruleList) {
241
315
  if (!ruleIds.has(ruleId)) {
242
- fail(`rubric.json: failure mode "${mode}" references unknown rule "${ruleId}"`);
316
+ fail(
317
+ `rubric.json: failure mode "${mode}" references unknown rule "${ruleId}"`,
318
+ `Add a rule with id "${ruleId}" to constitution.json, or remove it from rubric.json's failure_to_rules["${mode}"].`
319
+ );
243
320
  badRefs++;
244
321
  }
245
322
  }
@@ -269,7 +346,10 @@ export async function run(argv = process.argv.slice(2)) {
269
346
  const order = terminology[orderKey] || [];
270
347
  for (const name of order) {
271
348
  if (!terminology.groups[name]) {
272
- fail(`terminology.json: ${orderKey} references unknown group "${name}"`);
349
+ fail(
350
+ `terminology.json: ${orderKey} references unknown group "${name}"`,
351
+ `Add "${name}" to terminology.json's "groups" object, or remove it from "${orderKey}".`
352
+ );
273
353
  failures++;
274
354
  }
275
355
  }
@@ -280,11 +360,23 @@ export async function run(argv = process.argv.slice(2)) {
280
360
  for (const [gName, group] of Object.entries(terminology.groups)) {
281
361
  for (const pattern of group.id_patterns || []) {
282
362
  try { new RegExp(pattern); }
283
- catch { fail(`terminology.json: group "${gName}" has invalid id_pattern "${pattern}"`); patternErrors++; }
363
+ catch {
364
+ fail(
365
+ `terminology.json: group "${gName}" has invalid id_pattern "${pattern}"`,
366
+ `Fix the regular expression syntax for group "${gName}"'s id_patterns entry "${pattern}".`
367
+ );
368
+ patternErrors++;
369
+ }
284
370
  }
285
371
  for (const pattern of group.prompt_patterns || []) {
286
372
  try { new RegExp(pattern); }
287
- catch { fail(`terminology.json: group "${gName}" has invalid prompt_pattern "${pattern}"`); patternErrors++; }
373
+ catch {
374
+ fail(
375
+ `terminology.json: group "${gName}" has invalid prompt_pattern "${pattern}"`,
376
+ `Fix the regular expression syntax for group "${gName}"'s prompt_patterns entry "${pattern}".`
377
+ );
378
+ patternErrors++;
379
+ }
288
380
  }
289
381
  }
290
382
  if (patternErrors === 0) { pass('terminology.json: all detection patterns compile'); passes++; }
@@ -305,46 +397,56 @@ export async function run(argv = process.argv.slice(2)) {
305
397
  let wfErrors = 0;
306
398
  for (const file of wfFiles) {
307
399
  const { data, error } = tryParseJson(join(workflowDir, file));
308
- if (error) { fail(`workflows/profiles/${file} — invalid JSON: ${error}`); wfErrors++; continue; }
400
+ if (error) {
401
+ fail(`workflows/profiles/${file} — invalid JSON: ${error}`, `Open workflows/profiles/${file} and fix the syntax error above.`);
402
+ wfErrors++;
403
+ continue;
404
+ }
309
405
 
310
406
  const missing = ['workflow_id', 'label', 'lane_id', 'output_mode', 'output_count',
311
407
  'prompt_strategy', 'negative_strategy', 'canon_focus', 'drift_guards', 'runtime_defaults']
312
408
  .filter(f => data[f] === undefined || data[f] === null);
313
409
  if (missing.length > 0) {
314
- fail(`workflows/profiles/${file} — missing fields: ${missing.join(', ')}`);
410
+ fail(`workflows/profiles/${file} — missing fields: ${missing.join(', ')}`, `Add [${missing.join(', ')}] to workflows/profiles/${file}.`);
315
411
  wfErrors++;
316
412
  continue;
317
413
  }
318
414
 
319
415
  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`);
416
+ fail(
417
+ `workflows/profiles/${file} — lane_id "${data.lane_id}" not found in lanes.json`,
418
+ `Use one of lanes.json's existing lane ids [${[...laneIds].join(', ')}], or add "${data.lane_id}" as a new lane.`
419
+ );
321
420
  wfErrors++;
322
421
  }
323
422
 
324
423
  if (data.subject_mode && !VALID_SUBJECT_MODES.includes(data.subject_mode)) {
325
- fail(`workflows/profiles/${file} — invalid subject_mode "${data.subject_mode}"`);
424
+ fail(`workflows/profiles/${file} — invalid subject_mode "${data.subject_mode}"`, `subject_mode must be one of: ${VALID_SUBJECT_MODES.join(', ')}.`);
326
425
  wfErrors++;
327
426
  }
328
427
 
329
428
  if (!VALID_OUTPUT_MODES.includes(data.output_mode)) {
330
- fail(`workflows/profiles/${file} — invalid output_mode "${data.output_mode}"`);
429
+ fail(`workflows/profiles/${file} — invalid output_mode "${data.output_mode}"`, `output_mode must be one of: ${VALID_OUTPUT_MODES.join(', ')}.`);
331
430
  wfErrors++;
332
431
  }
333
432
 
334
433
  const ps = data.prompt_strategy;
335
434
  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[]`);
435
+ fail(
436
+ `workflows/profiles/${file} — prompt_strategy needs style_prefix[], structure, must_include[]`,
437
+ `Add prompt_strategy.style_prefix (array), .structure (string), and .must_include (array) to workflows/profiles/${file}.`
438
+ );
337
439
  wfErrors++;
338
440
  }
339
441
 
340
442
  const ns = data.negative_strategy;
341
443
  if (!ns || !Array.isArray(ns.must_avoid)) {
342
- fail(`workflows/profiles/${file} — negative_strategy needs must_avoid[]`);
444
+ fail(`workflows/profiles/${file} — negative_strategy needs must_avoid[]`, `Add negative_strategy.must_avoid (array) to workflows/profiles/${file}.`);
343
445
  wfErrors++;
344
446
  }
345
447
 
346
448
  if (!data.runtime_defaults?.adapter_target) {
347
- fail(`workflows/profiles/${file} — runtime_defaults needs adapter_target`);
449
+ fail(`workflows/profiles/${file} — runtime_defaults needs adapter_target`, `Add runtime_defaults.adapter_target (e.g. "comfyui") to workflows/profiles/${file}.`);
348
450
  wfErrors++;
349
451
  }
350
452
  }
@@ -377,34 +479,41 @@ export async function run(argv = process.argv.slice(2)) {
377
479
  let bmErrors = 0;
378
480
  for (const file of bmFiles) {
379
481
  const { data, error } = tryParseJson(join(batchModesDir, file));
380
- if (error) { fail(`workflows/batch-modes/${file} — invalid JSON: ${error}`); bmErrors++; continue; }
482
+ if (error) {
483
+ fail(`workflows/batch-modes/${file} — invalid JSON: ${error}`, `Open workflows/batch-modes/${file} and fix the syntax error above.`);
484
+ bmErrors++;
485
+ continue;
486
+ }
381
487
 
382
- if (!data.mode_id) { fail(`workflows/batch-modes/${file} — missing mode_id`); bmErrors++; }
488
+ if (!data.mode_id) { fail(`workflows/batch-modes/${file} — missing mode_id`, `Add a "mode_id" field to workflows/batch-modes/${file}.`); bmErrors++; }
383
489
  if (!data.batch_type || !VALID_BATCH_TYPES.includes(data.batch_type)) {
384
- fail(`workflows/batch-modes/${file} — invalid batch_type "${data.batch_type}"`);
490
+ fail(`workflows/batch-modes/${file} — invalid batch_type "${data.batch_type}"`, `batch_type must be one of: ${VALID_BATCH_TYPES.join(', ')}.`);
385
491
  bmErrors++;
386
492
  }
387
493
  if (data.subject_mode && !VALID_SUBJECT_MODES_BM.includes(data.subject_mode)) {
388
- fail(`workflows/batch-modes/${file} — invalid subject_mode "${data.subject_mode}"`);
494
+ fail(`workflows/batch-modes/${file} — invalid subject_mode "${data.subject_mode}"`, `subject_mode must be one of: ${VALID_SUBJECT_MODES_BM.join(', ')}.`);
389
495
  bmErrors++;
390
496
  }
391
497
  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`);
498
+ fail(
499
+ `workflows/batch-modes/${file} — base_workflow_id "${data.base_workflow_id}" not found in profiles`,
500
+ `Use one of the existing workflow ids [${wfFiles.join(', ')}], or add a matching profile under workflows/profiles/.`
501
+ );
393
502
  bmErrors++;
394
503
  }
395
504
  if (!Array.isArray(data.variant_plan) || data.variant_plan.length === 0) {
396
- fail(`workflows/batch-modes/${file} — variant_plan must be non-empty array`);
505
+ 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
506
  bmErrors++;
398
507
  } else {
399
508
  const slotIds = data.variant_plan.map(s => s.slot_id);
400
509
  const dupes = slotIds.filter((id, i) => slotIds.indexOf(id) !== i);
401
510
  if (dupes.length > 0) {
402
- fail(`workflows/batch-modes/${file} — duplicate slot_ids: ${[...new Set(dupes)].join(', ')}`);
511
+ fail(`workflows/batch-modes/${file} — duplicate slot_ids: ${[...new Set(dupes)].join(', ')}`, `Give each entry in variant_plan a unique slot_id.`);
403
512
  bmErrors++;
404
513
  }
405
514
  }
406
515
  if (data.assembly?.layout && !VALID_LAYOUTS.includes(data.assembly.layout)) {
407
- fail(`workflows/batch-modes/${file} — invalid assembly.layout "${data.assembly.layout}"`);
516
+ fail(`workflows/batch-modes/${file} — invalid assembly.layout "${data.assembly.layout}"`, `assembly.layout must be one of: ${VALID_LAYOUTS.join(', ')}.`);
408
517
  bmErrors++;
409
518
  }
410
519
  }
@@ -424,17 +533,26 @@ export async function run(argv = process.argv.slice(2)) {
424
533
  // ── 11. Runtime templates check ──
425
534
  {
426
535
  console.log('');
427
- const runtimeDir = join(REPO_ROOT, 'runtime', 'comfyui');
536
+ // PACKAGE-relative, not workspace-relative — `runtime/` ships inside the
537
+ // npm tarball (see getPackageRoot() in lib/paths.js). Joining it to
538
+ // REPO_ROOT made doctor report "runtime/comfyui/ — directory not found"
539
+ // for every installed user while the templates sat in the package.
540
+ const runtimeDir = join(getRuntimeDir(), 'comfyui');
428
541
  if (existsSync(runtimeDir)) {
429
542
  const templateFiles = readdirSync(runtimeDir).filter(f => f.endsWith('.json'));
430
543
  if (templateFiles.length > 0) {
431
544
  let templateErrors = 0;
432
545
  for (const file of templateFiles) {
433
546
  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++; }
547
+ if (error) {
548
+ fail(`runtime/comfyui/${file}: invalid JSON — ${error}`, `Open ${join(runtimeDir, file)} and fix the syntax error above.`);
549
+ templateErrors++;
550
+ continue;
551
+ }
552
+ if (!data.template_id) { fail(`runtime/comfyui/${file}: missing template_id`, `Add a "template_id" field to ${file}.`); templateErrors++; }
436
553
  if (!data.compatible_modes || !Array.isArray(data.compatible_modes)) {
437
- fail(`runtime/comfyui/${file}: missing compatible_modes[]`); templateErrors++;
554
+ fail(`runtime/comfyui/${file}: missing compatible_modes[]`, `Add a "compatible_modes" array to ${file}.`);
555
+ templateErrors++;
438
556
  }
439
557
  }
440
558
  if (templateErrors === 0) {
@@ -464,8 +582,13 @@ export async function run(argv = process.argv.slice(2)) {
464
582
  const sample = recordFiles.slice(0, 3);
465
583
  for (const f of sample) {
466
584
  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++; }
585
+ if (error) {
586
+ fail(`records/${f}: invalid JSON`, `Open records/${f} and fix the syntax error, or delete/regenerate it if it's an aborted write.`);
587
+ recordErrors++;
588
+ } else if (!data.id) {
589
+ fail(`records/${f}: missing "id"`, `Add an "id" field to records/${f} matching its filename stem.`);
590
+ recordErrors++;
591
+ }
469
592
  }
470
593
  if (recordErrors === 0) {
471
594
  pass(`${recordFiles.length} records (spot-check passed)`);
@@ -485,7 +608,11 @@ export async function run(argv = process.argv.slice(2)) {
485
608
  console.log(`═══ ${status} — ${passes} passed, ${failures} failed, ${warnings} warnings ═══`);
486
609
 
487
610
  if (failures > 0) {
488
- throw runtimeError('PROJECT_UNHEALTHY', `Project "${projectName}" has ${failures} issue(s).`);
611
+ throw runtimeError(
612
+ 'PROJECT_UNHEALTHY',
613
+ `Project "${projectName}" has ${failures} issue(s).`,
614
+ `Fix the ✗ lines above — each one now carries its own Hint: with a concrete next action — then re-run: sdlab project doctor --project ${projectName}`
615
+ );
489
616
  }
490
617
  }
491
618
 
@@ -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 = {};