@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
package/lib/comfyui.js CHANGED
@@ -16,36 +16,114 @@ const HEARTBEAT_MS = 15_000; // emit a heartbeat at most every 15s
16
16
  // than hang until MAX_POLL_MS.
17
17
  const UNKNOWN_STATUS_BAIL_POLLS = 30; // ~30s of unrecognized shape
18
18
 
19
+ // SDL-H8: per-REQUEST timeout, distinct from the per-CALL poll budget
20
+ // (timeoutMs/MAX_POLL_MS above, which only bounds the interval *between*
21
+ // requests). Without this, a ComfyUI that accepts the TCP connection and
22
+ // never responds hangs `await fetch()` forever — a hang never rejects, so
23
+ // no retry/catch logic anywhere downstream ever fires. Configurable via env
24
+ // so operators can widen it for a slow/remote ComfyUI without a code change.
25
+ const DEFAULT_REQUEST_TIMEOUT_MS = Number(process.env.SDLAB_COMFY_REQUEST_TIMEOUT_MS) || 30_000;
26
+
27
+ /**
28
+ * fetch() with a hard per-request timeout via AbortController. Guarantees
29
+ * the returned promise always settles (resolve or reject) within
30
+ * `timeoutMs`, even against a server that accepts the connection and then
31
+ * never responds. On timeout, rejects with a plain Error (not a raw
32
+ * AbortError/DOMException) naming the URL and elapsed time, so it reads
33
+ * like any other network failure to callers' existing catch/retry logic.
34
+ *
35
+ * @param {string} url
36
+ * @param {RequestInit} [init]
37
+ * @param {number} [timeoutMs]
38
+ * @returns {Promise<Response>}
39
+ */
40
+ async function fetchWithTimeout(url, init = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
43
+ const start = Date.now();
44
+ try {
45
+ return await fetch(url, { ...init, signal: controller.signal });
46
+ } catch (err) {
47
+ if (err?.name === 'AbortError') {
48
+ const elapsedSec = ((Date.now() - start) / 1000).toFixed(1);
49
+ throw new Error(
50
+ `ComfyUI request timed out after ${elapsedSec}s (limit ${(timeoutMs / 1000).toFixed(1)}s): ${url}`
51
+ );
52
+ }
53
+ throw err;
54
+ } finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+
19
59
  /**
20
- * Check if ComfyUI is reachable.
60
+ * B04: comfyHealth() used to collapse EVERY failure mode into a bare
61
+ * `false` via `catch { return false }` with no bound error. A 503 while a
62
+ * custom node loads, a refused connection, a DNS typo, and a genuine
63
+ * timeout all produced the identical "ComfyUI not reachable — start
64
+ * ComfyUI" message downstream — factually wrong for the 503 (ComfyUI IS
65
+ * reachable and running) and useless for the others (starting ComfyUI does
66
+ * not fix a DNS typo).
67
+ *
68
+ * Check if ComfyUI is reachable, WITH a reason when it isn't.
69
+ *
21
70
  * @param {string} [comfyUrl]
22
- * @returns {Promise<boolean>}
71
+ * @param {{timeoutMs?: number}} [opts]
72
+ * @returns {Promise<{ok: boolean, reason: string|null}>} reason is one of
73
+ * 'timeout' | 'refused' | 'dns' | 'http-<status>' | 'unknown' | null (when ok)
23
74
  */
24
- export async function comfyHealth(comfyUrl = DEFAULT_URL) {
75
+ export async function comfyHealthDetailed(comfyUrl = DEFAULT_URL, opts = {}) {
25
76
  try {
26
- const res = await fetch(`${comfyUrl}/system_stats`);
27
- return res.ok;
28
- } catch {
29
- return false;
77
+ const res = await fetchWithTimeout(`${comfyUrl}/system_stats`, {}, opts.timeoutMs);
78
+ if (res.ok) return { ok: true, reason: null };
79
+ return { ok: false, reason: `http-${res.status}` };
80
+ } catch (err) {
81
+ if (/timed out/i.test(err?.message || '')) return { ok: false, reason: 'timeout' };
82
+ const code = err?.cause?.code || err?.code;
83
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return { ok: false, reason: 'dns' };
84
+ if (code === 'ECONNREFUSED' || code === 'ECONNRESET') return { ok: false, reason: 'refused' };
85
+ return { ok: false, reason: 'unknown' };
30
86
  }
31
87
  }
32
88
 
89
+ /**
90
+ * Check if ComfyUI is reachable. Thin plain-boolean wrapper over
91
+ * comfyHealthDetailed() — kept as a SEPARATE, unchanged-signature export
92
+ * because 5 scripts (generate.js, generate-identity.js,
93
+ * generate-controlnet.js, generate-ipadapter.js, painterly.js) already call
94
+ * `if (!(await comfyHealth(...)))`; returning a richer object here would
95
+ * make every one of those checks permanently truthy (an object is always
96
+ * truthy) and silently disable their offline handling.
97
+ *
98
+ * @param {string} [comfyUrl]
99
+ * @param {{timeoutMs?: number}} [opts]
100
+ * @returns {Promise<boolean>}
101
+ */
102
+ export async function comfyHealth(comfyUrl = DEFAULT_URL, opts = {}) {
103
+ const { ok } = await comfyHealthDetailed(comfyUrl, opts);
104
+ return ok;
105
+ }
106
+
33
107
  /**
34
108
  * Submit a workflow and poll until complete.
35
109
  * @param {Object} workflow — ComfyUI prompt graph
36
110
  * @param {string} [comfyUrl]
37
- * @param {{ clientPrefix?: string, timeoutMs?: number }} [opts]
111
+ * @param {{ clientPrefix?: string, timeoutMs?: number, requestTimeoutMs?: number }} [opts]
112
+ * `timeoutMs` bounds the OVERALL poll loop (unchanged, default 10 min).
113
+ * `requestTimeoutMs` (SDL-H8) bounds each INDIVIDUAL fetch — the submit
114
+ * POST and every /history poll — so a server that never responds can't
115
+ * hang the whole call forever.
38
116
  * @returns {Promise<Object>} — history entry
39
117
  */
40
118
  export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {}) {
41
- const { clientPrefix = 'sdl', timeoutMs = MAX_POLL_MS } = opts;
119
+ const { clientPrefix = 'sdl', timeoutMs = MAX_POLL_MS, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = opts;
42
120
  const clientId = `${clientPrefix}-${Date.now()}`;
43
121
 
44
- const res = await fetch(`${comfyUrl}/prompt`, {
122
+ const res = await fetchWithTimeout(`${comfyUrl}/prompt`, {
45
123
  method: 'POST',
46
124
  headers: { 'Content-Type': 'application/json' },
47
125
  body: JSON.stringify({ prompt: workflow, client_id: clientId }),
48
- });
126
+ }, requestTimeoutMs);
49
127
 
50
128
  if (!res.ok) {
51
129
  const text = await res.text();
@@ -72,7 +150,10 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
72
150
  await new Promise(r => setTimeout(r, 1000));
73
151
  let histRes;
74
152
  try {
75
- histRes = await fetch(`${comfyUrl}/history/${promptId}`);
153
+ // SDL-H8: bounded so a server that accepts the connection and never
154
+ // responds lands in this catch (as an AbortError → clear timeout
155
+ // Error) instead of hanging `await fetch()` forever.
156
+ histRes = await fetchWithTimeout(`${comfyUrl}/history/${promptId}`, {}, requestTimeoutMs);
76
157
  } catch (err) {
77
158
  consecutiveHistoryFailures++;
78
159
  if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
@@ -91,8 +172,23 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
91
172
  }
92
173
  continue;
93
174
  }
175
+ // SDL-L3: histRes.json() used to sit outside the retry block above, so
176
+ // a 200-OK non-JSON body (e.g. a proxy error page) threw a raw
177
+ // SyntaxError here, uncaught, permanently failing the image the
178
+ // adjacent 5-strike tolerance would otherwise have recovered from.
179
+ let history;
180
+ try {
181
+ history = await histRes.json();
182
+ } catch (err) {
183
+ consecutiveHistoryFailures++;
184
+ if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
185
+ throw new Error(
186
+ `ComfyUI history endpoint returned an unparseable response ${MAX_CONSECUTIVE_HISTORY_FAILURES} times in a row for prompt ${promptId}: ${err.message}`
187
+ );
188
+ }
189
+ continue;
190
+ }
94
191
  consecutiveHistoryFailures = 0;
95
- const history = await histRes.json();
96
192
  const entry = history[promptId];
97
193
 
98
194
  // PB-001: emit heartbeats so the user knows the pipeline isn't hung.
@@ -159,9 +255,10 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
159
255
  * @param {string} [subfolder]
160
256
  * @param {string} [comfyUrl]
161
257
  * @param {string} [type='output']
258
+ * @param {{timeoutMs?: number}} [opts]
162
259
  * @returns {Promise<Buffer>}
163
260
  */
164
- export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL, type = 'output') {
261
+ export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL, type = 'output', opts = {}) {
165
262
  const ALLOWED_TYPES = ['output', 'input', 'temp'];
166
263
  if (!ALLOWED_TYPES.includes(type)) {
167
264
  throw new Error(`Invalid ComfyUI image type "${type}" — must be one of: ${ALLOWED_TYPES.join(', ')}`);
@@ -170,7 +267,7 @@ export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL,
170
267
  throw new Error(`Invalid comfyUrl "${comfyUrl}" — must start with http:// or https://`);
171
268
  }
172
269
  const url = `${comfyUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || '')}&type=${encodeURIComponent(type)}`;
173
- const res = await fetch(url);
270
+ const res = await fetchWithTimeout(url, {}, opts.timeoutMs);
174
271
  if (!res.ok) throw new Error(`Download failed: ${res.status}`);
175
272
  return Buffer.from(await res.arrayBuffer());
176
273
  }
@@ -180,18 +277,19 @@ export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL,
180
277
  * @param {string} filePath — local file to upload
181
278
  * @param {string} filename — name to assign in ComfyUI
182
279
  * @param {string} [comfyUrl]
280
+ * @param {{timeoutMs?: number}} [opts]
183
281
  * @returns {Promise<string>} — assigned name from ComfyUI
184
282
  */
185
- export async function uploadImage(filePath, filename, comfyUrl = DEFAULT_URL) {
283
+ export async function uploadImage(filePath, filename, comfyUrl = DEFAULT_URL, opts = {}) {
186
284
  const data = await readFile(filePath);
187
285
  const formData = new FormData();
188
286
  formData.append('image', new Blob([data], { type: 'image/png' }), filename);
189
287
  formData.append('overwrite', 'true');
190
288
 
191
- const res = await fetch(`${comfyUrl}/upload/image`, {
289
+ const res = await fetchWithTimeout(`${comfyUrl}/upload/image`, {
192
290
  method: 'POST',
193
291
  body: formData,
194
- });
292
+ }, opts.timeoutMs);
195
293
 
196
294
  if (!res.ok) {
197
295
  const text = await res.text();
package/lib/config.js CHANGED
@@ -10,6 +10,7 @@ import { readFileSync, existsSync } from 'node:fs';
10
10
  import { readFile } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import { inputError, runtimeError } from './errors.js';
13
+ import { warn } from './log.js';
13
14
 
14
15
  function loadJsonFile(filePath) {
15
16
  if (!existsSync(filePath)) return null;
@@ -25,6 +26,80 @@ function loadJsonFile(filePath) {
25
26
  }
26
27
  }
27
28
 
29
+ /**
30
+ * Validate a user-authored regex pattern string at CONFIG LOAD TIME rather
31
+ * than lazily inside detectLane()/detectGroup() on whatever record happens
32
+ * to process first.
33
+ *
34
+ * B02: detectLane()/detectGroup() build `new RegExp(pattern)` from strings
35
+ * loaded verbatim from lanes.json/terminology.json across 7 construction
36
+ * sites, with zero validation before this. One unescaped bracket
37
+ * (`"[freeport"`) used to abort a whole `sdlab bind` pass with a raw
38
+ * `SyntaxError: Invalid regular expression` naming neither the file, the
39
+ * lane/group, nor the offending pattern — finding it required manual
40
+ * bisection of the config. This throws ONE structured error naming all
41
+ * three, before any record is processed.
42
+ *
43
+ * @param {string} pattern
44
+ * @param {{file: string, context: string}} where
45
+ */
46
+ function validateRegexPattern(pattern, { file, context }) {
47
+ try {
48
+ // eslint-disable-next-line no-new -- validation only, result unused
49
+ new RegExp(pattern);
50
+ } catch (err) {
51
+ throw inputError(
52
+ 'INPUT_BAD_REGEX',
53
+ `Invalid regex pattern in ${file} (${context}): "${pattern}" — ${err.message}`,
54
+ 'Fix or escape the pattern (e.g. escape literal brackets/parens/braces) and try again.'
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Validate every id_patterns entry across all lanes in a loaded lanes.json.
61
+ * Fails fast on the FIRST invalid pattern (not an aggregate report) so the
62
+ * thrown error always names one concrete offending pattern.
63
+ */
64
+ function validateLanePatterns(lanesData) {
65
+ for (const lane of lanesData.lanes || []) {
66
+ for (const pattern of lane.id_patterns || []) {
67
+ validateRegexPattern(pattern, { file: 'lanes.json', context: `lane "${lane.id}"` });
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Validate every pattern site across a loaded terminology.json: per-group
74
+ * id_patterns/prompt_patterns, plus the four dict/array pattern collections
75
+ * detectGroup() also builds RegExp from (cross_group_patterns, edge_defaults,
76
+ * null_faction_patterns, id_fallbacks) — the full set of 6 sites detectGroup()
77
+ * itself constructs (the 7th is detectLane()'s single site, covered by
78
+ * validateLanePatterns above).
79
+ */
80
+ function validateTerminologyPatterns(termData) {
81
+ for (const [groupName, group] of Object.entries(termData.groups || {})) {
82
+ for (const pattern of group?.id_patterns || []) {
83
+ validateRegexPattern(pattern, { file: 'terminology.json', context: `group "${groupName}" id_patterns` });
84
+ }
85
+ for (const pattern of group?.prompt_patterns || []) {
86
+ validateRegexPattern(pattern, { file: 'terminology.json', context: `group "${groupName}" prompt_patterns` });
87
+ }
88
+ }
89
+ for (const pattern of Object.keys(termData.cross_group_patterns || {})) {
90
+ validateRegexPattern(pattern, { file: 'terminology.json', context: 'cross_group_patterns' });
91
+ }
92
+ for (const pattern of Object.keys(termData.edge_defaults || {})) {
93
+ validateRegexPattern(pattern, { file: 'terminology.json', context: 'edge_defaults' });
94
+ }
95
+ for (const pattern of termData.null_faction_patterns || []) {
96
+ validateRegexPattern(pattern, { file: 'terminology.json', context: 'null_faction_patterns' });
97
+ }
98
+ for (const pattern of Object.keys(termData.id_fallbacks || {})) {
99
+ validateRegexPattern(pattern, { file: 'terminology.json', context: 'id_fallbacks' });
100
+ }
101
+ }
102
+
28
103
  /**
29
104
  * Async-safe readFile + JSON.parse with structured error handling.
30
105
  * Optionally validates that required top-level fields are present.
@@ -75,8 +150,12 @@ export async function readJsonFile(filePath, opts = {}) {
75
150
  * Load project.json — project identity and generation defaults.
76
151
  */
77
152
  export function loadProjectMeta(projectRoot) {
78
- const data = loadJsonFile(join(projectRoot, 'project.json'));
79
- if (!data) {
153
+ const filePath = join(projectRoot, 'project.json');
154
+ if (!existsSync(filePath)) {
155
+ // B05: this loader used to substitute its default with zero logging —
156
+ // the module didn't even import lib/log.js. A dropped/never-created
157
+ // project.json now names its expected path instead of degrading silently.
158
+ warn(`project.json not found at ${filePath} — using built-in defaults (name: "unknown")`);
80
159
  return {
81
160
  name: 'unknown',
82
161
  display_name: 'Unknown Project',
@@ -85,7 +164,7 @@ export function loadProjectMeta(projectRoot) {
85
164
  defaults: {},
86
165
  };
87
166
  }
88
- return data;
167
+ return loadJsonFile(filePath);
89
168
  }
90
169
 
91
170
  /**
@@ -111,8 +190,14 @@ export function resolveGpuModel(projectDefaults) {
111
190
  * Load constitution.json — array of rule objects.
112
191
  */
113
192
  export function loadConstitution(projectRoot) {
114
- const data = loadJsonFile(join(projectRoot, 'constitution.json'));
115
- if (!data) return { rules: [] };
193
+ const filePath = join(projectRoot, 'constitution.json');
194
+ if (!existsSync(filePath)) {
195
+ // B05: see loadProjectMeta above — warn once, naming the expected path,
196
+ // instead of silently substituting the empty-rules default.
197
+ warn(`constitution.json not found at ${filePath} — using built-in defaults (no rules configured)`);
198
+ return { rules: [] };
199
+ }
200
+ const data = loadJsonFile(filePath);
116
201
  if (Array.isArray(data)) return { rules: data };
117
202
  if (!data.rules) {
118
203
  throw inputError(
@@ -128,10 +213,14 @@ export function loadConstitution(projectRoot) {
128
213
  * Load lanes.json — lane definitions with detection patterns.
129
214
  */
130
215
  export function loadLanes(projectRoot) {
131
- const data = loadJsonFile(join(projectRoot, 'lanes.json'));
132
- if (!data) {
216
+ const filePath = join(projectRoot, 'lanes.json');
217
+ if (!existsSync(filePath)) {
218
+ // B05: warn once, naming the expected path, instead of silently
219
+ // substituting the no-lanes default.
220
+ warn(`lanes.json not found at ${filePath} — using built-in defaults (no lanes configured)`);
133
221
  return { default_lane: 'default', lanes: [] };
134
222
  }
223
+ const data = loadJsonFile(filePath);
135
224
  if (!data.lanes) {
136
225
  throw inputError(
137
226
  'INPUT_BAD_CONFIG',
@@ -139,6 +228,9 @@ export function loadLanes(projectRoot) {
139
228
  'Top-level shape: { "default_lane": "...", "lanes": [ ... ] }.'
140
229
  );
141
230
  }
231
+ // B02: validate every id_patterns regex now, before any record is ever
232
+ // tested against it — see validateRegexPattern's docstring above.
233
+ validateLanePatterns(data);
142
234
  return data;
143
235
  }
144
236
 
@@ -146,23 +238,31 @@ export function loadLanes(projectRoot) {
146
238
  * Load rubric.json — scoring dimensions, thresholds, failure modes.
147
239
  */
148
240
  export function loadRubric(projectRoot) {
149
- const data = loadJsonFile(join(projectRoot, 'rubric.json'));
150
- if (!data) {
241
+ const filePath = join(projectRoot, 'rubric.json');
242
+ if (!existsSync(filePath)) {
243
+ // B05: warn once, naming the expected path, instead of silently
244
+ // substituting the built-in thresholds default.
245
+ warn(`rubric.json not found at ${filePath} — using built-in defaults (pass=0.7/partial=0.5/fail_ceiling=0.4)`);
151
246
  return {
152
247
  dimensions: [],
153
248
  thresholds: { pass: 0.7, partial: 0.5, fail_ceiling: 0.4 },
154
249
  failure_to_rules: {},
155
250
  };
156
251
  }
157
- return data;
252
+ return loadJsonFile(filePath);
158
253
  }
159
254
 
160
255
  /**
161
256
  * Load terminology.json — group vocabulary and detection patterns.
162
257
  */
163
258
  export function loadTerminology(projectRoot) {
164
- const data = loadJsonFile(join(projectRoot, 'terminology.json'));
165
- if (!data) {
259
+ const filePath = join(projectRoot, 'terminology.json');
260
+ if (!existsSync(filePath)) {
261
+ // B05: this is the finding's own example — a dropped terminology.json
262
+ // used to make detectGroup() return null for every record with zero
263
+ // signal. Warn once, naming the expected path, instead of substituting
264
+ // the empty-groups default silently.
265
+ warn(`terminology.json not found at ${filePath} — using built-in defaults (no groups configured)`);
166
266
  return {
167
267
  group_label: 'group',
168
268
  groups: {},
@@ -171,6 +271,10 @@ export function loadTerminology(projectRoot) {
171
271
  prompt_fallbacks: {},
172
272
  };
173
273
  }
274
+ const data = loadJsonFile(filePath);
275
+ // B02: validate every pattern site now, before any record is ever tested
276
+ // against it — see validateRegexPattern's docstring above.
277
+ validateTerminologyPatterns(data);
174
278
  return data;
175
279
  }
176
280
 
@@ -255,6 +255,17 @@ export function critiqueCandidate({ brief, workflow, candidate, activeDimensions
255
255
  filename: candidate.filename,
256
256
  seed: candidate.seed,
257
257
  overall_fit: 'usable', // Default — human or LLM overrides
258
+ // H2: this is the ONLY assignment site for overall_fit anywhere in the
259
+ // codebase — nothing currently overrides it, so every candidate in
260
+ // every critique report is uniformly 'usable' by construction. That is
261
+ // a non-judgment ("nobody has looked at the image yet"), not a judgment
262
+ // ("looked, and they're all fine") — `reviewed: false` records which one
263
+ // this is so recommendAction() (below) and the CLI can tell the two
264
+ // apart instead of presenting keyword-drift tiebreaking as a considered
265
+ // recommendation. Flip to true at the (future, NOT built here — see the
266
+ // finding's explicit scoping note) point where a human or LLM judge
267
+ // actually assesses fit and overrides overall_fit.
268
+ reviewed: false,
258
269
  // EXTERNAL_VERIFIER: this rule-based engine is a different artifact class
259
270
  // from the image generator, so it is a legitimate cross-artifact verifier.
260
271
  judged_by_model: RULE_BASED_JUDGE,
@@ -301,12 +312,27 @@ export function triageCandidates(candidates, { driftThreshold = DEFAULT_DRIFT_TH
301
312
 
302
313
  /**
303
314
  * Derive a run-level recommended action from candidate notes.
315
+ *
316
+ * H2: `overall_fit: 'usable'` (critiqueCandidate's default, and — today —
317
+ * its ONLY assignment anywhere in the codebase) means "nobody has assessed
318
+ * fit yet", not "assessed and found uniformly usable". Before this fix,
319
+ * recommendAction() could not tell those two states apart, so an unreviewed
320
+ * run always took the same `usable.length > 0` branch a genuinely-reviewed
321
+ * run would, picking a "preferred" candidate by brief-keyword drift count —
322
+ * not by looking at the image — and presenting it with the same confidence
323
+ * either way. `reviewed` on the returned recommendation lets the CLI (see
324
+ * scripts/critique.js) qualify or suppress the auto "Next:" suggestion when
325
+ * nothing has actually been reviewed, without changing `mode` /
326
+ * `preferred_candidate` semantics for callers that don't look at it —
327
+ * critique.json's shape stays backward-compatible (additive field only).
304
328
  */
305
329
  export function recommendAction({ candidates }) {
306
330
  if (candidates.length === 0) {
307
- return { mode: 'discard_run', reason: 'No candidates in run' };
331
+ return { mode: 'discard_run', reason: 'No candidates in run', reviewed: false };
308
332
  }
309
333
 
334
+ const anyReviewed = candidates.some(c => c.reviewed === true);
335
+
310
336
  const strong = candidates.filter(c => c.overall_fit === 'strong');
311
337
  const usable = candidates.filter(c => c.overall_fit === 'usable');
312
338
  const offModel = candidates.filter(c => c.overall_fit === 'off-model');
@@ -317,6 +343,7 @@ export function recommendAction({ candidates }) {
317
343
  mode: 'accept_one',
318
344
  preferred_candidate: strong[0].filename,
319
345
  reason: `${strong.length} strong candidate(s) — ${strong[0].filename} is the best fit`,
346
+ reviewed: anyReviewed,
320
347
  };
321
348
  }
322
349
 
@@ -327,7 +354,12 @@ export function recommendAction({ candidates }) {
327
354
  return {
328
355
  mode: 'refine_from_one',
329
356
  preferred_candidate: sorted[0].filename,
330
- reason: `${usable.length} usable candidate(s) — ${sorted[0].filename} has fewest drift issues`,
357
+ reason: anyReviewed
358
+ ? `${usable.length} usable candidate(s) — ${sorted[0].filename} has fewest drift issues`
359
+ : `${usable.length} candidate(s) at the unreviewed default "usable" — nobody has assessed fit yet. ` +
360
+ `${sorted[0].filename} has the fewest rule-based drift issues, which is a tiebreaker, not a ` +
361
+ `judgment of the image.`,
362
+ reviewed: anyReviewed,
331
363
  };
332
364
  }
333
365
 
@@ -336,6 +368,7 @@ export function recommendAction({ candidates }) {
336
368
  return {
337
369
  mode: 'discard_run',
338
370
  reason: 'All candidates are off-model — brief may need fundamental revision',
371
+ reviewed: anyReviewed,
339
372
  };
340
373
  }
341
374
 
@@ -343,6 +376,7 @@ export function recommendAction({ candidates }) {
343
376
  return {
344
377
  mode: 'rerun_broader',
345
378
  reason: 'No clear winner — try broader seed range or adjusted parameters',
379
+ reviewed: anyReviewed,
346
380
  };
347
381
  }
348
382