@mcptoolshop/style-dataset-lab 1.2.1 → 2.1.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 (62) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +138 -113
  3. package/bin/sdlab.js +126 -0
  4. package/lib/args.js +106 -0
  5. package/lib/card.js +145 -0
  6. package/lib/comfyui.js +109 -0
  7. package/lib/config.js +248 -0
  8. package/lib/deprecation.js +14 -0
  9. package/lib/eligibility.js +110 -0
  10. package/lib/eval-pack.js +283 -0
  11. package/lib/export.js +259 -0
  12. package/lib/paths.js +38 -0
  13. package/lib/records.js +69 -0
  14. package/lib/snapshot.js +211 -0
  15. package/lib/split.js +409 -0
  16. package/package.json +20 -9
  17. package/scripts/bulk-curate-wave2-5.js +1 -1
  18. package/scripts/bulk-curate-waves11-18.js +1 -1
  19. package/scripts/canon-bind.js +188 -338
  20. package/scripts/card.js +73 -0
  21. package/scripts/compare.js +40 -30
  22. package/scripts/curate-wave25.js +1 -1
  23. package/scripts/curate.js +34 -28
  24. package/scripts/doctor.js +324 -0
  25. package/scripts/eligibility.js +91 -0
  26. package/scripts/eval-pack.js +92 -0
  27. package/scripts/export.js +85 -0
  28. package/scripts/generate-controlnet.js +36 -121
  29. package/scripts/generate-identity.js +38 -106
  30. package/scripts/generate-ipadapter.js +42 -119
  31. package/scripts/generate.js +22 -81
  32. package/scripts/init.js +229 -0
  33. package/scripts/migrate-records.js +15 -21
  34. package/scripts/painterly-test.js +20 -47
  35. package/scripts/painterly.js +27 -106
  36. package/scripts/snapshot.js +132 -0
  37. package/scripts/split.js +154 -0
  38. package/templates/domains/architecture/constitution.json +97 -0
  39. package/templates/domains/architecture/lanes.json +41 -0
  40. package/templates/domains/architecture/project.json +20 -0
  41. package/templates/domains/architecture/rubric.json +40 -0
  42. package/templates/domains/architecture/terminology.json +19 -0
  43. package/templates/domains/character-design/constitution.json +88 -0
  44. package/templates/domains/character-design/lanes.json +35 -0
  45. package/templates/domains/character-design/project.json +17 -0
  46. package/templates/domains/character-design/rubric.json +38 -0
  47. package/templates/domains/character-design/terminology.json +21 -0
  48. package/templates/domains/creature-design/constitution.json +87 -0
  49. package/templates/domains/creature-design/lanes.json +41 -0
  50. package/templates/domains/creature-design/project.json +18 -0
  51. package/templates/domains/creature-design/rubric.json +39 -0
  52. package/templates/domains/creature-design/terminology.json +20 -0
  53. package/templates/domains/game-art/constitution.json +113 -0
  54. package/templates/domains/game-art/lanes.json +47 -0
  55. package/templates/domains/game-art/project.json +17 -0
  56. package/templates/domains/game-art/rubric.json +36 -0
  57. package/templates/domains/game-art/terminology.json +30 -0
  58. package/templates/domains/vehicle-mech/constitution.json +87 -0
  59. package/templates/domains/vehicle-mech/lanes.json +41 -0
  60. package/templates/domains/vehicle-mech/project.json +17 -0
  61. package/templates/domains/vehicle-mech/rubric.json +40 -0
  62. package/templates/domains/vehicle-mech/terminology.json +21 -0
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * card.js — Generate dataset cards.
5
+ *
6
+ * Usage:
7
+ * sdlab card generate --snapshot <id> --split <id> [--project <name>]
8
+ */
9
+
10
+ import { join } from 'node:path';
11
+ import { writeFile } from 'node:fs/promises';
12
+ import { getProjectName } from '../lib/args.js';
13
+ import { REPO_ROOT } from '../lib/paths.js';
14
+ import { listSnapshots } from '../lib/snapshot.js';
15
+ import { listSplits } from '../lib/split.js';
16
+ import { generateCard } from '../lib/card.js';
17
+
18
+ export async function run(argv = process.argv.slice(2)) {
19
+ const projectName = getProjectName(argv);
20
+ const projectRoot = join(REPO_ROOT, 'projects', projectName);
21
+ const subcommand = argv.find(a => !a.startsWith('--')) || 'generate';
22
+ const args = argv.filter(a => a !== subcommand);
23
+
24
+ if (subcommand === 'generate') {
25
+ // Find snapshot
26
+ let snapshotId;
27
+ const snapIdx = args.indexOf('--snapshot');
28
+ if (snapIdx >= 0) {
29
+ snapshotId = args[snapIdx + 1];
30
+ } else {
31
+ const snapshots = await listSnapshots(projectRoot);
32
+ if (snapshots.length === 0) throw new Error('No snapshots found. Run: sdlab snapshot create');
33
+ snapshotId = snapshots[snapshots.length - 1].id;
34
+ }
35
+
36
+ // Find split
37
+ let splitId;
38
+ const splitIdx = args.indexOf('--split');
39
+ if (splitIdx >= 0) {
40
+ splitId = args[splitIdx + 1];
41
+ } else {
42
+ const splits = await listSplits(projectRoot);
43
+ if (splits.length === 0) throw new Error('No splits found. Run: sdlab split build');
44
+ splitId = splits[splits.length - 1].id;
45
+ }
46
+
47
+ console.log(`\x1b[1msdlab card generate\x1b[0m — ${projectName}`);
48
+ console.log(` Snapshot: ${snapshotId}`);
49
+ console.log(` Split: ${splitId}`);
50
+ console.log('');
51
+
52
+ const { markdown, json } = await generateCard(projectRoot, snapshotId, splitId);
53
+
54
+ // Write to project root
55
+ const mdPath = join(projectRoot, 'dataset-card.md');
56
+ const jsonPath = join(projectRoot, 'dataset-card.json');
57
+ await writeFile(mdPath, markdown);
58
+ await writeFile(jsonPath, JSON.stringify(json, null, 2) + '\n');
59
+
60
+ console.log(` \x1b[32m✓\x1b[0m dataset-card.md`);
61
+ console.log(` \x1b[32m✓\x1b[0m dataset-card.json`);
62
+
63
+ } else {
64
+ throw new Error(`Unknown subcommand: ${subcommand}. Use: generate`);
65
+ }
66
+ }
67
+
68
+ if (process.argv[1] && (process.argv[1].endsWith('card.js') || process.argv[1].endsWith('card'))) {
69
+ run().catch((err) => {
70
+ console.error(err.message || err);
71
+ process.exit(1);
72
+ });
73
+ }
@@ -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,324 @@
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
+ 'snapshots',
37
+ 'splits',
38
+ 'eval-packs',
39
+ ];
40
+
41
+ function pass(msg) { console.log(` \x1b[32m✓\x1b[0m ${msg}`); }
42
+ function fail(msg) { console.log(` \x1b[31m✗\x1b[0m ${msg}`); }
43
+ function warn(msg) { console.log(` \x1b[33m⚠\x1b[0m ${msg}`); }
44
+
45
+ function tryParseJson(filePath) {
46
+ try {
47
+ return { data: JSON.parse(readFileSync(filePath, 'utf-8')), error: null };
48
+ } catch (err) {
49
+ return { data: null, error: err.message };
50
+ }
51
+ }
52
+
53
+ export async function run(argv = process.argv.slice(2)) {
54
+ const projectName = getProjectName(argv);
55
+ const projectDir = join(REPO_ROOT, 'projects', projectName);
56
+
57
+ console.log(`\x1b[1msdlab project doctor\x1b[0m — ${projectName}`);
58
+ console.log(` Path: ${projectDir}\n`);
59
+
60
+ let passes = 0;
61
+ let failures = 0;
62
+ let warnings = 0;
63
+
64
+ // ── 1. Project directory exists ──
65
+ if (!existsSync(projectDir)) {
66
+ fail(`Project directory not found: ${projectDir}`);
67
+ console.log(`\n Run: sdlab init ${projectName} --domain <domain>`);
68
+ return;
69
+ }
70
+ pass('Project directory exists');
71
+ passes++;
72
+
73
+ // ── 2. Required config files present and parseable ──
74
+ console.log('');
75
+ const configs = {};
76
+ for (const file of REQUIRED_CONFIG_FILES) {
77
+ const filePath = join(projectDir, file);
78
+ if (!existsSync(filePath)) {
79
+ fail(`Missing: ${file}`);
80
+ failures++;
81
+ continue;
82
+ }
83
+ const { data, error } = tryParseJson(filePath);
84
+ if (error) {
85
+ fail(`${file} — invalid JSON: ${error}`);
86
+ failures++;
87
+ continue;
88
+ }
89
+ configs[file] = data;
90
+ pass(`${file} — valid JSON`);
91
+ passes++;
92
+ }
93
+
94
+ // ── 3. Required directories ──
95
+ console.log('');
96
+ for (const dir of REQUIRED_DIRS) {
97
+ if (existsSync(join(projectDir, dir))) {
98
+ pass(`${dir}/`);
99
+ passes++;
100
+ } else {
101
+ warn(`${dir}/ — missing (will be created on first use)`);
102
+ warnings++;
103
+ }
104
+ }
105
+
106
+ // ── 4. Project.json validation ──
107
+ const meta = configs['project.json'];
108
+ if (meta) {
109
+ console.log('');
110
+ if (meta.name) { pass(`project.json: name = "${meta.name}"`); passes++; }
111
+ else { fail('project.json: missing "name"'); failures++; }
112
+
113
+ if (meta.domain) { pass(`project.json: domain = "${meta.domain}"`); passes++; }
114
+ else { warn('project.json: no "domain" set'); warnings++; }
115
+ }
116
+
117
+ // ── 5. Constitution validation ──
118
+ const constitution = configs['constitution.json'];
119
+ if (constitution) {
120
+ console.log('');
121
+ const rules = constitution.rules || (Array.isArray(constitution) ? constitution : []);
122
+ if (rules.length === 0) {
123
+ fail('constitution.json: no rules defined');
124
+ failures++;
125
+ } else {
126
+ pass(`constitution.json: ${rules.length} rules`);
127
+ passes++;
128
+
129
+ // Check required rule fields
130
+ const ruleIds = new Set();
131
+ let ruleErrors = 0;
132
+ for (const rule of rules) {
133
+ if (!rule.id) { fail(`constitution.json: rule missing "id"`); ruleErrors++; continue; }
134
+ if (ruleIds.has(rule.id)) { fail(`constitution.json: duplicate rule ID "${rule.id}"`); ruleErrors++; }
135
+ ruleIds.add(rule.id);
136
+ if (!rule.dims || rule.dims.length === 0) {
137
+ fail(`constitution.json: rule ${rule.id} has no "dims" (scoring dimensions)`);
138
+ ruleErrors++;
139
+ }
140
+ }
141
+ if (ruleErrors === 0) { pass('constitution.json: all rules have id + dims'); passes++; }
142
+ else { failures += ruleErrors; }
143
+ }
144
+ }
145
+
146
+ // ── 6. Lanes validation ──
147
+ const lanes = configs['lanes.json'];
148
+ if (lanes) {
149
+ console.log('');
150
+ const laneList = lanes.lanes || [];
151
+ if (laneList.length === 0) {
152
+ warn('lanes.json: no lanes defined (default lane only)');
153
+ warnings++;
154
+ } else {
155
+ pass(`lanes.json: ${laneList.length} lanes`);
156
+ passes++;
157
+
158
+ // Check lane IDs unique
159
+ const laneIds = new Set();
160
+ let laneErrors = 0;
161
+ for (const lane of laneList) {
162
+ if (!lane.id) { fail('lanes.json: lane missing "id"'); laneErrors++; continue; }
163
+ if (laneIds.has(lane.id)) { fail(`lanes.json: duplicate lane ID "${lane.id}"`); laneErrors++; }
164
+ laneIds.add(lane.id);
165
+
166
+ // Check patterns compile
167
+ for (const pattern of lane.id_patterns || []) {
168
+ try { new RegExp(pattern); }
169
+ catch { fail(`lanes.json: lane "${lane.id}" has invalid pattern "${pattern}"`); laneErrors++; }
170
+ }
171
+ }
172
+ if (laneErrors === 0) { pass('lanes.json: all lane IDs unique, patterns compile'); passes++; }
173
+ else { failures += laneErrors; }
174
+ }
175
+
176
+ if (!lanes.default_lane) {
177
+ warn('lanes.json: no "default_lane" set');
178
+ warnings++;
179
+ }
180
+ }
181
+
182
+ // ── 7. Rubric validation ──
183
+ const rubric = configs['rubric.json'];
184
+ if (rubric) {
185
+ console.log('');
186
+ const dims = rubric.dimensions || [];
187
+ if (dims.length === 0) {
188
+ fail('rubric.json: no dimensions defined');
189
+ failures++;
190
+ } else {
191
+ pass(`rubric.json: ${dims.length} dimensions`);
192
+ passes++;
193
+ }
194
+
195
+ if (!rubric.thresholds) {
196
+ fail('rubric.json: missing "thresholds"');
197
+ failures++;
198
+ } else {
199
+ pass('rubric.json: thresholds present');
200
+ passes++;
201
+ }
202
+
203
+ // Cross-reference: constitution dims should exist in rubric
204
+ if (constitution && dims.length > 0) {
205
+ const dimSet = new Set(dims);
206
+ const rules = constitution.rules || [];
207
+ const missingDims = new Set();
208
+ for (const rule of rules) {
209
+ for (const d of rule.dims || []) {
210
+ if (!dimSet.has(d)) missingDims.add(d);
211
+ }
212
+ }
213
+ if (missingDims.size > 0) {
214
+ fail(`rubric.json: constitution references dimensions not in rubric: ${[...missingDims].join(', ')}`);
215
+ failures++;
216
+ } else {
217
+ pass('rubric.json: all constitution dimensions present in rubric');
218
+ passes++;
219
+ }
220
+ }
221
+
222
+ // Check failure_to_rules references valid rule IDs
223
+ if (rubric.failure_to_rules && constitution) {
224
+ const ruleIds = new Set((constitution.rules || []).map(r => r.id));
225
+ let badRefs = 0;
226
+ for (const [mode, ruleList] of Object.entries(rubric.failure_to_rules)) {
227
+ for (const ruleId of ruleList) {
228
+ if (!ruleIds.has(ruleId)) {
229
+ fail(`rubric.json: failure mode "${mode}" references unknown rule "${ruleId}"`);
230
+ badRefs++;
231
+ }
232
+ }
233
+ }
234
+ if (badRefs === 0 && Object.keys(rubric.failure_to_rules).length > 0) {
235
+ pass(`rubric.json: ${Object.keys(rubric.failure_to_rules).length} failure modes, all reference valid rules`);
236
+ passes++;
237
+ }
238
+ if (badRefs > 0) failures += badRefs;
239
+ }
240
+ }
241
+
242
+ // ── 8. Terminology validation ──
243
+ const terminology = configs['terminology.json'];
244
+ if (terminology) {
245
+ console.log('');
246
+ const groups = Object.keys(terminology.groups || {});
247
+ if (groups.length === 0) {
248
+ pass('terminology.json: no groups defined (valid for projects without faction/group system)');
249
+ passes++;
250
+ } else {
251
+ pass(`terminology.json: ${groups.length} groups (${groups.join(', ')})`);
252
+ passes++;
253
+
254
+ // Check detection order references valid groups
255
+ for (const orderKey of ['id_detection_order', 'prompt_detection_order']) {
256
+ const order = terminology[orderKey] || [];
257
+ for (const name of order) {
258
+ if (!terminology.groups[name]) {
259
+ fail(`terminology.json: ${orderKey} references unknown group "${name}"`);
260
+ failures++;
261
+ }
262
+ }
263
+ }
264
+
265
+ // Check group patterns compile
266
+ let patternErrors = 0;
267
+ for (const [gName, group] of Object.entries(terminology.groups)) {
268
+ for (const pattern of group.id_patterns || []) {
269
+ try { new RegExp(pattern); }
270
+ catch { fail(`terminology.json: group "${gName}" has invalid id_pattern "${pattern}"`); patternErrors++; }
271
+ }
272
+ for (const pattern of group.prompt_patterns || []) {
273
+ try { new RegExp(pattern); }
274
+ catch { fail(`terminology.json: group "${gName}" has invalid prompt_pattern "${pattern}"`); patternErrors++; }
275
+ }
276
+ }
277
+ if (patternErrors === 0) { pass('terminology.json: all detection patterns compile'); passes++; }
278
+ else { failures += patternErrors; }
279
+ }
280
+ }
281
+
282
+ // ── 9. Records sanity check ──
283
+ const recordsDir = join(projectDir, 'records');
284
+ if (existsSync(recordsDir)) {
285
+ console.log('');
286
+ const recordFiles = readdirSync(recordsDir).filter(f => f.endsWith('.json'));
287
+ if (recordFiles.length > 0) {
288
+ // Spot-check first 3 records
289
+ let recordErrors = 0;
290
+ const sample = recordFiles.slice(0, 3);
291
+ for (const f of sample) {
292
+ const { data, error } = tryParseJson(join(recordsDir, f));
293
+ if (error) { fail(`records/${f}: invalid JSON`); recordErrors++; }
294
+ else if (!data.id) { fail(`records/${f}: missing "id"`); recordErrors++; }
295
+ }
296
+ if (recordErrors === 0) {
297
+ pass(`${recordFiles.length} records (spot-check passed)`);
298
+ passes++;
299
+ } else {
300
+ failures += recordErrors;
301
+ }
302
+ } else {
303
+ pass('records/ — empty (new project)');
304
+ passes++;
305
+ }
306
+ }
307
+
308
+ // ── Summary ──
309
+ console.log('');
310
+ const status = failures === 0 ? '\x1b[32mHEALTHY\x1b[0m' : '\x1b[31mFAILED\x1b[0m';
311
+ console.log(`═══ ${status} — ${passes} passed, ${failures} failed, ${warnings} warnings ═══`);
312
+
313
+ if (failures > 0) {
314
+ throw new Error(`Project "${projectName}" has ${failures} issue(s).`);
315
+ }
316
+ }
317
+
318
+ // Direct execution guard
319
+ if (process.argv[1] && (process.argv[1].endsWith('doctor.js') || process.argv[1].endsWith('doctor'))) {
320
+ run().catch((err) => {
321
+ console.error(err.message || err);
322
+ process.exit(1);
323
+ });
324
+ }