@mcptoolshop/style-dataset-lab 1.2.1 → 2.0.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 (50) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +95 -116
  3. package/bin/sdlab.js +103 -0
  4. package/lib/args.js +106 -0
  5. package/lib/comfyui.js +109 -0
  6. package/lib/config.js +104 -0
  7. package/lib/deprecation.js +14 -0
  8. package/lib/paths.js +38 -0
  9. package/lib/records.js +69 -0
  10. package/package.json +13 -4
  11. package/scripts/bulk-curate-wave2-5.js +1 -1
  12. package/scripts/bulk-curate-waves11-18.js +1 -1
  13. package/scripts/canon-bind.js +268 -332
  14. package/scripts/compare.js +40 -30
  15. package/scripts/curate-wave25.js +1 -1
  16. package/scripts/curate.js +34 -28
  17. package/scripts/doctor.js +321 -0
  18. package/scripts/generate-controlnet.js +36 -121
  19. package/scripts/generate-identity.js +38 -106
  20. package/scripts/generate-ipadapter.js +42 -119
  21. package/scripts/generate.js +22 -81
  22. package/scripts/init.js +226 -0
  23. package/scripts/migrate-records.js +15 -21
  24. package/scripts/painterly-test.js +20 -47
  25. package/scripts/painterly.js +27 -106
  26. package/templates/domains/architecture/constitution.json +97 -0
  27. package/templates/domains/architecture/lanes.json +41 -0
  28. package/templates/domains/architecture/project.json +20 -0
  29. package/templates/domains/architecture/rubric.json +40 -0
  30. package/templates/domains/architecture/terminology.json +19 -0
  31. package/templates/domains/character-design/constitution.json +88 -0
  32. package/templates/domains/character-design/lanes.json +35 -0
  33. package/templates/domains/character-design/project.json +17 -0
  34. package/templates/domains/character-design/rubric.json +38 -0
  35. package/templates/domains/character-design/terminology.json +21 -0
  36. package/templates/domains/creature-design/constitution.json +87 -0
  37. package/templates/domains/creature-design/lanes.json +41 -0
  38. package/templates/domains/creature-design/project.json +18 -0
  39. package/templates/domains/creature-design/rubric.json +39 -0
  40. package/templates/domains/creature-design/terminology.json +20 -0
  41. package/templates/domains/game-art/constitution.json +113 -0
  42. package/templates/domains/game-art/lanes.json +47 -0
  43. package/templates/domains/game-art/project.json +17 -0
  44. package/templates/domains/game-art/rubric.json +36 -0
  45. package/templates/domains/game-art/terminology.json +30 -0
  46. package/templates/domains/vehicle-mech/constitution.json +87 -0
  47. package/templates/domains/vehicle-mech/lanes.json +41 -0
  48. package/templates/domains/vehicle-mech/project.json +17 -0
  49. package/templates/domains/vehicle-mech/rubric.json +40 -0
  50. package/templates/domains/vehicle-mech/terminology.json +21 -0
@@ -5,37 +5,54 @@
5
5
  *
6
6
  * Usage:
7
7
  * node scripts/compare.js <asset_a_id> <asset_b_id> <winner: a|b|tie> <reasoning>
8
- * node scripts/compare.js iron_sword_base iron_sword_icon_mid a "Better silhouette, painterly execution"
8
+ * sdlab compare iron_sword_base iron_sword_icon_mid a "Better silhouette" --project star-freight
9
9
  */
10
10
 
11
11
  import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
12
12
  import { join } from "node:path";
13
+ import { getProjectName } from "../lib/args.js";
14
+ import { REPO_ROOT } from "../lib/paths.js";
13
15
 
14
- const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
15
- const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
16
- const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
16
+ function getFlagValue(args, flag) {
17
+ const prefix = `--${flag}=`;
18
+ for (const a of args) {
19
+ if (a.startsWith(prefix)) return a.slice(prefix.length);
20
+ }
21
+ const idx = args.indexOf(`--${flag}`);
22
+ if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
23
+ return undefined;
24
+ }
25
+
26
+ export async function run(argv = process.argv.slice(2)) {
27
+ const projectName = getProjectName(argv);
28
+ const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
29
+
30
+ // Strip --project/--game and their values from positional parsing
31
+ const args = argv.filter((a, i) => {
32
+ if (a === '--project' || a === '--game') return false;
33
+ if (i > 0 && (argv[i - 1] === '--project' || argv[i - 1] === '--game')) return false;
34
+ return true;
35
+ });
17
36
 
18
- async function main() {
19
- const args = process.argv.slice(2);
20
37
  const [assetAId, assetBId, winner, ...reasonParts] = args;
21
38
 
22
39
  if (!assetAId || !assetBId || !winner) {
23
- console.log("Usage: node scripts/compare.js <asset_a_id> <asset_b_id> <a|b|tie> <reasoning>");
24
- process.exit(1);
40
+ throw new Error("Usage: sdlab compare <asset_a_id> <asset_b_id> <a|b|tie> <reasoning>");
25
41
  }
26
42
 
27
43
  if (!["a", "b", "tie"].includes(winner)) {
28
- console.error(`Invalid winner: ${winner}. Must be a, b, or tie.`);
29
- process.exit(1);
44
+ throw new Error(`Invalid winner: ${winner}. Must be a, b, or tie.`);
30
45
  }
31
46
 
32
- const reasoning = reasonParts.join(" ") || null;
47
+ const reasoning = reasonParts.filter(r => !r.startsWith('--')).join(" ") || null;
33
48
 
34
49
  // Load records to get paths
35
- const recordA = await loadRecord(assetAId);
36
- const recordB = await loadRecord(assetBId);
50
+ const recordA = await loadRecord(GAME_ROOT, assetAId);
51
+ const recordB = await loadRecord(GAME_ROOT, assetBId);
37
52
 
38
- if (!recordA || !recordB) process.exit(1);
53
+ if (!recordA || !recordB) {
54
+ throw new Error("Could not load one or both records.");
55
+ }
39
56
 
40
57
  // Parse optional scores
41
58
  const scoresStr = getFlagValue(args, "scores");
@@ -52,7 +69,7 @@ async function main() {
52
69
  }
53
70
 
54
71
  // Build comparison ID
55
- const existingComps = await countComparisons();
72
+ const existingComps = await countComparisons(GAME_ROOT);
56
73
  const cmpId = `cmp_${String(existingComps + 1).padStart(3, "0")}`;
57
74
 
58
75
  const comparison = {
@@ -81,7 +98,7 @@ async function main() {
81
98
  if (reasoning) console.log(` ${reasoning}`);
82
99
  }
83
100
 
84
- async function loadRecord(id) {
101
+ async function loadRecord(GAME_ROOT, id) {
85
102
  try {
86
103
  const path = join(GAME_ROOT, `records/${id}.json`);
87
104
  return JSON.parse(await readFile(path, "utf-8"));
@@ -91,7 +108,7 @@ async function loadRecord(id) {
91
108
  }
92
109
  }
93
110
 
94
- async function countComparisons() {
111
+ async function countComparisons(GAME_ROOT) {
95
112
  try {
96
113
  const files = await readdir(join(GAME_ROOT, "comparisons"));
97
114
  return files.filter((f) => f.endsWith(".json")).length;
@@ -100,17 +117,10 @@ async function countComparisons() {
100
117
  }
101
118
  }
102
119
 
103
- function getFlagValue(args, flag) {
104
- const prefix = `--${flag}=`;
105
- for (const a of args) {
106
- if (a.startsWith(prefix)) return a.slice(prefix.length);
107
- }
108
- const idx = args.indexOf(`--${flag}`);
109
- if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
110
- return undefined;
120
+ // Direct execution guard
121
+ if (process.argv[1] && (process.argv[1].endsWith('compare.js') || process.argv[1].endsWith('compare'))) {
122
+ run().catch((err) => {
123
+ console.error(err.message || err);
124
+ process.exit(1);
125
+ });
111
126
  }
112
-
113
- main().catch((err) => {
114
- console.error(err);
115
- process.exit(1);
116
- });
@@ -18,7 +18,7 @@ import { join, basename } from 'path';
18
18
 
19
19
  const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
20
20
  const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
21
- const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
21
+ const GAME_ROOT = join(REPO_ROOT, 'projects', GAME);
22
22
 
23
23
  const RECORDS_DIR = join(GAME_ROOT, 'records');
24
24
  const CANDIDATES_DIR = join(GAME_ROOT, 'outputs', 'candidates');
package/scripts/curate.js CHANGED
@@ -7,32 +7,47 @@
7
7
  * node scripts/curate.js <asset_id> approved "Clean silhouette, correct palette" --scores silhouette:0.9,palette:0.8
8
8
  * node scripts/curate.js <asset_id> rejected "3D render look, alpha halo" --failures alpha_halo,3d_render_look
9
9
  * node scripts/curate.js --list (show uncurated candidates)
10
+ * sdlab curate <asset_id> approved "explanation" --project star-freight
10
11
  */
11
12
 
12
13
  import { readFile, writeFile, rename, readdir, mkdir } from "node:fs/promises";
13
14
  import { join } from "node:path";
15
+ import { getProjectName } from "../lib/args.js";
16
+ import { REPO_ROOT } from "../lib/paths.js";
14
17
 
15
- const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
16
- const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
17
- const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
18
+ function getFlagValue(args, flag) {
19
+ const prefix = `--${flag}=`;
20
+ for (const a of args) {
21
+ if (a.startsWith(prefix)) return a.slice(prefix.length);
22
+ }
23
+ const idx = args.indexOf(`--${flag}`);
24
+ if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
25
+ return undefined;
26
+ }
18
27
 
19
- async function main() {
20
- const args = process.argv.slice(2);
28
+ export async function run(argv = process.argv.slice(2)) {
29
+ const projectName = getProjectName(argv);
30
+ const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
31
+
32
+ // Strip --project/--game and their values from positional parsing
33
+ const args = argv.filter((a, i) => {
34
+ if (a === '--project' || a === '--game') return false;
35
+ if (i > 0 && (argv[i - 1] === '--project' || argv[i - 1] === '--game')) return false;
36
+ return true;
37
+ });
21
38
 
22
39
  if (args.includes("--list")) {
23
- return listUncurated();
40
+ return listUncurated(GAME_ROOT);
24
41
  }
25
42
 
26
43
  const [assetId, status, explanation, ...rest] = args;
27
44
 
28
45
  if (!assetId || !status || !explanation) {
29
- console.log("Usage: node scripts/curate.js <asset_id> <approved|rejected|borderline> <explanation> [--scores k:v,...] [--failures f1,f2]");
30
- process.exit(1);
46
+ throw new Error("Usage: sdlab curate <asset_id> <approved|rejected|borderline> <explanation> [--scores k:v,...] [--failures f1,f2]");
31
47
  }
32
48
 
33
49
  if (!["approved", "rejected", "borderline"].includes(status)) {
34
- console.error(`Invalid status: ${status}. Must be approved, rejected, or borderline.`);
35
- process.exit(1);
50
+ throw new Error(`Invalid status: ${status}. Must be approved, rejected, or borderline.`);
36
51
  }
37
52
 
38
53
  // Parse optional flags
@@ -56,8 +71,7 @@ async function main() {
56
71
  try {
57
72
  record = JSON.parse(await readFile(recordPath, "utf-8"));
58
73
  } catch {
59
- console.error(`Record not found: ${recordPath}`);
60
- process.exit(1);
74
+ throw new Error(`Record not found: ${recordPath}`);
61
75
  }
62
76
 
63
77
  // Update record first (write before move to avoid orphaned images)
@@ -84,8 +98,7 @@ async function main() {
84
98
  try {
85
99
  await rename(oldPath, join(GAME_ROOT, newPath));
86
100
  } catch {
87
- console.error(`Could not move ${oldPath} to ${newPath}`);
88
- process.exit(1);
101
+ throw new Error(`Could not move ${oldPath} to ${newPath}`);
89
102
  }
90
103
 
91
104
  console.log(`\x1b[32m✓\x1b[0m ${assetId} → ${status}`);
@@ -93,7 +106,7 @@ async function main() {
93
106
  if (failure_modes.length) console.log(` Failures: ${failure_modes.join(", ")}`);
94
107
  }
95
108
 
96
- async function listUncurated() {
109
+ async function listUncurated(GAME_ROOT) {
97
110
  const recordsDir = join(GAME_ROOT, "records");
98
111
  let files;
99
112
  try {
@@ -120,17 +133,10 @@ async function listUncurated() {
120
133
  console.log(`\n${uncurated} uncurated, ${curated} curated`);
121
134
  }
122
135
 
123
- function getFlagValue(args, flag) {
124
- const prefix = `--${flag}=`;
125
- for (const a of args) {
126
- if (a.startsWith(prefix)) return a.slice(prefix.length);
127
- }
128
- const idx = args.indexOf(`--${flag}`);
129
- if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
130
- return undefined;
136
+ // Direct execution guard
137
+ if (process.argv[1] && (process.argv[1].endsWith('curate.js') || process.argv[1].endsWith('curate'))) {
138
+ run().catch((err) => {
139
+ console.error(err.message || err);
140
+ process.exit(1);
141
+ });
131
142
  }
132
-
133
- main().catch((err) => {
134
- console.error(err);
135
- process.exit(1);
136
- });
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * doctor.js — Validate project config completeness and correctness.
5
+ *
6
+ * Checks structural health without deep business logic linting.
7
+ * Every check produces a specific, actionable message on failure.
8
+ *
9
+ * Usage:
10
+ * sdlab project doctor --project star-freight
11
+ * node scripts/doctor.js --project my-project
12
+ */
13
+
14
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { getProjectName } from '../lib/args.js';
17
+ import { REPO_ROOT } from '../lib/paths.js';
18
+
19
+ const REQUIRED_CONFIG_FILES = [
20
+ 'project.json',
21
+ 'constitution.json',
22
+ 'lanes.json',
23
+ 'rubric.json',
24
+ 'terminology.json',
25
+ ];
26
+
27
+ const REQUIRED_DIRS = [
28
+ 'canon',
29
+ 'records',
30
+ 'comparisons',
31
+ 'inputs/prompts',
32
+ 'outputs/candidates',
33
+ 'outputs/approved',
34
+ 'outputs/rejected',
35
+ 'exports',
36
+ ];
37
+
38
+ function pass(msg) { console.log(` \x1b[32m✓\x1b[0m ${msg}`); }
39
+ function fail(msg) { console.log(` \x1b[31m✗\x1b[0m ${msg}`); }
40
+ function warn(msg) { console.log(` \x1b[33m⚠\x1b[0m ${msg}`); }
41
+
42
+ function tryParseJson(filePath) {
43
+ try {
44
+ return { data: JSON.parse(readFileSync(filePath, 'utf-8')), error: null };
45
+ } catch (err) {
46
+ return { data: null, error: err.message };
47
+ }
48
+ }
49
+
50
+ export async function run(argv = process.argv.slice(2)) {
51
+ const projectName = getProjectName(argv);
52
+ const projectDir = join(REPO_ROOT, 'projects', projectName);
53
+
54
+ console.log(`\x1b[1msdlab project doctor\x1b[0m — ${projectName}`);
55
+ console.log(` Path: ${projectDir}\n`);
56
+
57
+ let passes = 0;
58
+ let failures = 0;
59
+ let warnings = 0;
60
+
61
+ // ── 1. Project directory exists ──
62
+ if (!existsSync(projectDir)) {
63
+ fail(`Project directory not found: ${projectDir}`);
64
+ console.log(`\n Run: sdlab init ${projectName} --domain <domain>`);
65
+ return;
66
+ }
67
+ pass('Project directory exists');
68
+ passes++;
69
+
70
+ // ── 2. Required config files present and parseable ──
71
+ console.log('');
72
+ const configs = {};
73
+ for (const file of REQUIRED_CONFIG_FILES) {
74
+ const filePath = join(projectDir, file);
75
+ if (!existsSync(filePath)) {
76
+ fail(`Missing: ${file}`);
77
+ failures++;
78
+ continue;
79
+ }
80
+ const { data, error } = tryParseJson(filePath);
81
+ if (error) {
82
+ fail(`${file} — invalid JSON: ${error}`);
83
+ failures++;
84
+ continue;
85
+ }
86
+ configs[file] = data;
87
+ pass(`${file} — valid JSON`);
88
+ passes++;
89
+ }
90
+
91
+ // ── 3. Required directories ──
92
+ console.log('');
93
+ for (const dir of REQUIRED_DIRS) {
94
+ if (existsSync(join(projectDir, dir))) {
95
+ pass(`${dir}/`);
96
+ passes++;
97
+ } else {
98
+ warn(`${dir}/ — missing (will be created on first use)`);
99
+ warnings++;
100
+ }
101
+ }
102
+
103
+ // ── 4. Project.json validation ──
104
+ const meta = configs['project.json'];
105
+ if (meta) {
106
+ console.log('');
107
+ if (meta.name) { pass(`project.json: name = "${meta.name}"`); passes++; }
108
+ else { fail('project.json: missing "name"'); failures++; }
109
+
110
+ if (meta.domain) { pass(`project.json: domain = "${meta.domain}"`); passes++; }
111
+ else { warn('project.json: no "domain" set'); warnings++; }
112
+ }
113
+
114
+ // ── 5. Constitution validation ──
115
+ const constitution = configs['constitution.json'];
116
+ if (constitution) {
117
+ console.log('');
118
+ const rules = constitution.rules || (Array.isArray(constitution) ? constitution : []);
119
+ if (rules.length === 0) {
120
+ fail('constitution.json: no rules defined');
121
+ failures++;
122
+ } else {
123
+ pass(`constitution.json: ${rules.length} rules`);
124
+ passes++;
125
+
126
+ // Check required rule fields
127
+ const ruleIds = new Set();
128
+ let ruleErrors = 0;
129
+ for (const rule of rules) {
130
+ if (!rule.id) { fail(`constitution.json: rule missing "id"`); ruleErrors++; continue; }
131
+ if (ruleIds.has(rule.id)) { fail(`constitution.json: duplicate rule ID "${rule.id}"`); ruleErrors++; }
132
+ ruleIds.add(rule.id);
133
+ if (!rule.dims || rule.dims.length === 0) {
134
+ fail(`constitution.json: rule ${rule.id} has no "dims" (scoring dimensions)`);
135
+ ruleErrors++;
136
+ }
137
+ }
138
+ if (ruleErrors === 0) { pass('constitution.json: all rules have id + dims'); passes++; }
139
+ else { failures += ruleErrors; }
140
+ }
141
+ }
142
+
143
+ // ── 6. Lanes validation ──
144
+ const lanes = configs['lanes.json'];
145
+ if (lanes) {
146
+ console.log('');
147
+ const laneList = lanes.lanes || [];
148
+ if (laneList.length === 0) {
149
+ warn('lanes.json: no lanes defined (default lane only)');
150
+ warnings++;
151
+ } else {
152
+ pass(`lanes.json: ${laneList.length} lanes`);
153
+ passes++;
154
+
155
+ // Check lane IDs unique
156
+ const laneIds = new Set();
157
+ let laneErrors = 0;
158
+ for (const lane of laneList) {
159
+ if (!lane.id) { fail('lanes.json: lane missing "id"'); laneErrors++; continue; }
160
+ if (laneIds.has(lane.id)) { fail(`lanes.json: duplicate lane ID "${lane.id}"`); laneErrors++; }
161
+ laneIds.add(lane.id);
162
+
163
+ // Check patterns compile
164
+ for (const pattern of lane.id_patterns || []) {
165
+ try { new RegExp(pattern); }
166
+ catch { fail(`lanes.json: lane "${lane.id}" has invalid pattern "${pattern}"`); laneErrors++; }
167
+ }
168
+ }
169
+ if (laneErrors === 0) { pass('lanes.json: all lane IDs unique, patterns compile'); passes++; }
170
+ else { failures += laneErrors; }
171
+ }
172
+
173
+ if (!lanes.default_lane) {
174
+ warn('lanes.json: no "default_lane" set');
175
+ warnings++;
176
+ }
177
+ }
178
+
179
+ // ── 7. Rubric validation ──
180
+ const rubric = configs['rubric.json'];
181
+ if (rubric) {
182
+ console.log('');
183
+ const dims = rubric.dimensions || [];
184
+ if (dims.length === 0) {
185
+ fail('rubric.json: no dimensions defined');
186
+ failures++;
187
+ } else {
188
+ pass(`rubric.json: ${dims.length} dimensions`);
189
+ passes++;
190
+ }
191
+
192
+ if (!rubric.thresholds) {
193
+ fail('rubric.json: missing "thresholds"');
194
+ failures++;
195
+ } else {
196
+ pass('rubric.json: thresholds present');
197
+ passes++;
198
+ }
199
+
200
+ // Cross-reference: constitution dims should exist in rubric
201
+ if (constitution && dims.length > 0) {
202
+ const dimSet = new Set(dims);
203
+ const rules = constitution.rules || [];
204
+ const missingDims = new Set();
205
+ for (const rule of rules) {
206
+ for (const d of rule.dims || []) {
207
+ if (!dimSet.has(d)) missingDims.add(d);
208
+ }
209
+ }
210
+ if (missingDims.size > 0) {
211
+ fail(`rubric.json: constitution references dimensions not in rubric: ${[...missingDims].join(', ')}`);
212
+ failures++;
213
+ } else {
214
+ pass('rubric.json: all constitution dimensions present in rubric');
215
+ passes++;
216
+ }
217
+ }
218
+
219
+ // Check failure_to_rules references valid rule IDs
220
+ if (rubric.failure_to_rules && constitution) {
221
+ const ruleIds = new Set((constitution.rules || []).map(r => r.id));
222
+ let badRefs = 0;
223
+ for (const [mode, ruleList] of Object.entries(rubric.failure_to_rules)) {
224
+ for (const ruleId of ruleList) {
225
+ if (!ruleIds.has(ruleId)) {
226
+ fail(`rubric.json: failure mode "${mode}" references unknown rule "${ruleId}"`);
227
+ badRefs++;
228
+ }
229
+ }
230
+ }
231
+ if (badRefs === 0 && Object.keys(rubric.failure_to_rules).length > 0) {
232
+ pass(`rubric.json: ${Object.keys(rubric.failure_to_rules).length} failure modes, all reference valid rules`);
233
+ passes++;
234
+ }
235
+ if (badRefs > 0) failures += badRefs;
236
+ }
237
+ }
238
+
239
+ // ── 8. Terminology validation ──
240
+ const terminology = configs['terminology.json'];
241
+ if (terminology) {
242
+ console.log('');
243
+ const groups = Object.keys(terminology.groups || {});
244
+ if (groups.length === 0) {
245
+ pass('terminology.json: no groups defined (valid for projects without faction/group system)');
246
+ passes++;
247
+ } else {
248
+ pass(`terminology.json: ${groups.length} groups (${groups.join(', ')})`);
249
+ passes++;
250
+
251
+ // Check detection order references valid groups
252
+ for (const orderKey of ['id_detection_order', 'prompt_detection_order']) {
253
+ const order = terminology[orderKey] || [];
254
+ for (const name of order) {
255
+ if (!terminology.groups[name]) {
256
+ fail(`terminology.json: ${orderKey} references unknown group "${name}"`);
257
+ failures++;
258
+ }
259
+ }
260
+ }
261
+
262
+ // Check group patterns compile
263
+ let patternErrors = 0;
264
+ for (const [gName, group] of Object.entries(terminology.groups)) {
265
+ for (const pattern of group.id_patterns || []) {
266
+ try { new RegExp(pattern); }
267
+ catch { fail(`terminology.json: group "${gName}" has invalid id_pattern "${pattern}"`); patternErrors++; }
268
+ }
269
+ for (const pattern of group.prompt_patterns || []) {
270
+ try { new RegExp(pattern); }
271
+ catch { fail(`terminology.json: group "${gName}" has invalid prompt_pattern "${pattern}"`); patternErrors++; }
272
+ }
273
+ }
274
+ if (patternErrors === 0) { pass('terminology.json: all detection patterns compile'); passes++; }
275
+ else { failures += patternErrors; }
276
+ }
277
+ }
278
+
279
+ // ── 9. Records sanity check ──
280
+ const recordsDir = join(projectDir, 'records');
281
+ if (existsSync(recordsDir)) {
282
+ console.log('');
283
+ const recordFiles = readdirSync(recordsDir).filter(f => f.endsWith('.json'));
284
+ if (recordFiles.length > 0) {
285
+ // Spot-check first 3 records
286
+ let recordErrors = 0;
287
+ const sample = recordFiles.slice(0, 3);
288
+ for (const f of sample) {
289
+ const { data, error } = tryParseJson(join(recordsDir, f));
290
+ if (error) { fail(`records/${f}: invalid JSON`); recordErrors++; }
291
+ else if (!data.id) { fail(`records/${f}: missing "id"`); recordErrors++; }
292
+ }
293
+ if (recordErrors === 0) {
294
+ pass(`${recordFiles.length} records (spot-check passed)`);
295
+ passes++;
296
+ } else {
297
+ failures += recordErrors;
298
+ }
299
+ } else {
300
+ pass('records/ — empty (new project)');
301
+ passes++;
302
+ }
303
+ }
304
+
305
+ // ── Summary ──
306
+ console.log('');
307
+ const status = failures === 0 ? '\x1b[32mHEALTHY\x1b[0m' : '\x1b[31mFAILED\x1b[0m';
308
+ console.log(`═══ ${status} — ${passes} passed, ${failures} failed, ${warnings} warnings ═══`);
309
+
310
+ if (failures > 0) {
311
+ throw new Error(`Project "${projectName}" has ${failures} issue(s).`);
312
+ }
313
+ }
314
+
315
+ // Direct execution guard
316
+ if (process.argv[1] && (process.argv[1].endsWith('doctor.js') || process.argv[1].endsWith('doctor'))) {
317
+ run().catch((err) => {
318
+ console.error(err.message || err);
319
+ process.exit(1);
320
+ });
321
+ }