@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.1

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 (106) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +71 -0
  4. package/README.md +82 -9
  5. package/SECURITY.md +6 -5
  6. package/bin/sdlab.js +359 -11
  7. package/lib/adapters/comfyui-runner.js +170 -0
  8. package/lib/adapters/comfyui-workflows.js +163 -0
  9. package/lib/adapters/diffusers-lora.js +7 -2
  10. package/lib/adapters/generic-image-caption.js +6 -1
  11. package/lib/args.js +200 -13
  12. package/lib/batch-compiler.js +122 -0
  13. package/lib/batch-modes.js +111 -0
  14. package/lib/batch-runs.js +237 -0
  15. package/lib/batch-sheet-render.js +209 -0
  16. package/lib/brief-compiler.js +625 -0
  17. package/lib/brief-render.js +205 -0
  18. package/lib/card.js +11 -4
  19. package/lib/comfyui.js +100 -6
  20. package/lib/config.js +68 -3
  21. package/lib/critique-engine.js +413 -0
  22. package/lib/critique-render.js +171 -0
  23. package/lib/deprecation.js +1 -1
  24. package/lib/eval-pack.js +94 -4
  25. package/lib/eval-runs.js +42 -3
  26. package/lib/export.js +144 -34
  27. package/lib/generated-provenance.js +87 -0
  28. package/lib/implementation-packs.js +15 -1
  29. package/lib/log.js +41 -4
  30. package/lib/paths.js +72 -3
  31. package/lib/refine-briefs.js +336 -0
  32. package/lib/reingest-selected.js +184 -0
  33. package/lib/reingest.js +15 -1
  34. package/lib/run-summary.js +132 -0
  35. package/lib/runtime-runs.js +218 -0
  36. package/lib/selections.js +375 -0
  37. package/lib/snapshot.js +100 -7
  38. package/lib/split.js +175 -17
  39. package/lib/training-manifests.js +17 -3
  40. package/lib/training-packages.js +28 -14
  41. package/lib/workflow-profiles.js +161 -0
  42. package/package.json +13 -4
  43. package/scripts/batch-generate.js +176 -0
  44. package/scripts/batch-sheet.js +46 -0
  45. package/scripts/batch-show.js +86 -0
  46. package/scripts/brief-compile.js +134 -0
  47. package/scripts/brief-show.js +53 -0
  48. package/scripts/canon-bind.js +7 -9
  49. package/scripts/card.js +22 -25
  50. package/scripts/compare.js +38 -33
  51. package/scripts/critique-show.js +50 -0
  52. package/scripts/critique.js +70 -0
  53. package/scripts/curate.js +91 -46
  54. package/scripts/doctor.js +172 -6
  55. package/scripts/eligibility.js +17 -14
  56. package/scripts/eval-pack.js +13 -11
  57. package/scripts/eval-run.js +30 -29
  58. package/scripts/export.js +27 -31
  59. package/scripts/generate-controlnet.js +31 -17
  60. package/scripts/generate-identity.js +37 -20
  61. package/scripts/generate-ipadapter.js +22 -16
  62. package/scripts/generate.js +68 -13
  63. package/scripts/implementation-pack.js +19 -14
  64. package/scripts/init.js +137 -17
  65. package/scripts/painterly.js +8 -10
  66. package/scripts/refine.js +119 -0
  67. package/scripts/reingest-selected.js +78 -0
  68. package/scripts/reingest.js +37 -18
  69. package/scripts/run-generate.js +76 -0
  70. package/scripts/run-list.js +58 -0
  71. package/scripts/run-show.js +63 -0
  72. package/scripts/select.js +119 -0
  73. package/scripts/selection-show.js +88 -0
  74. package/scripts/snapshot.js +22 -24
  75. package/scripts/split.js +26 -31
  76. package/scripts/training-manifest.js +29 -24
  77. package/scripts/training-package.js +24 -20
  78. package/scripts/training-profile.js +13 -8
  79. package/scripts/workflow-list.js +61 -0
  80. package/scripts/workflow-show.js +97 -0
  81. package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
  82. package/templates/domains/architecture/terminology.json +10 -8
  83. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  84. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  85. package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
  86. package/templates/domains/character-design/project.json +4 -2
  87. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  88. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  89. package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
  90. package/templates/domains/creature-design/project.json +4 -2
  91. package/templates/domains/creature-design/terminology.json +1 -0
  92. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  93. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  94. package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
  95. package/templates/domains/game-art/project.json +4 -2
  96. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  97. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  98. package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
  99. package/templates/domains/vehicle-mech/project.json +4 -2
  100. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  101. package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
  102. package/scripts/bulk-curate-wave2-5.js +0 -138
  103. package/scripts/bulk-curate-waves11-18.js +0 -413
  104. package/scripts/curate-wave25.js +0 -209
  105. package/scripts/migrate-records.js +0 -141
  106. package/templates/inputs/prompts/example-wave.json +0 -27
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Brief renderer — converts compiled brief JSON to human-readable formats.
3
+ */
4
+
5
+ /**
6
+ * Render a compiled brief as Markdown.
7
+ */
8
+ export function renderBriefMarkdown(brief) {
9
+ const lines = [];
10
+
11
+ lines.push(`# Brief: ${brief.brief_id}`);
12
+ lines.push('');
13
+ lines.push(`**Workflow:** ${brief.workflow_id}`);
14
+ lines.push(`**Project:** ${brief.project_id}`);
15
+ lines.push(`**Lane:** ${brief.lane_id}`);
16
+ if (brief.subject_id) {
17
+ lines.push(`**Subject:** ${brief.subject_id}`);
18
+ }
19
+ if (brief.training_asset_ref) {
20
+ lines.push(`**Training Asset:** ${brief.training_asset_ref}`);
21
+ }
22
+ if (brief.implementation_pack_ref) {
23
+ lines.push(`**Implementation Pack:** ${brief.implementation_pack_ref}`);
24
+ }
25
+ lines.push('');
26
+
27
+ // Prompt
28
+ lines.push('## Prompt');
29
+ lines.push('');
30
+ lines.push('```');
31
+ lines.push(brief.prompt);
32
+ lines.push('```');
33
+ lines.push('');
34
+
35
+ // Negative prompt
36
+ lines.push('## Negative Prompt');
37
+ lines.push('');
38
+ lines.push('```');
39
+ lines.push(brief.negative_prompt);
40
+ lines.push('```');
41
+ lines.push('');
42
+
43
+ // Canon constraints
44
+ if (brief.canon_constraints?.length > 0) {
45
+ lines.push('## Canon Constraints');
46
+ lines.push('');
47
+ for (const c of brief.canon_constraints) {
48
+ lines.push(`- ${c}`);
49
+ }
50
+ lines.push('');
51
+ }
52
+
53
+ // Subject constraints
54
+ if (brief.subject_constraints?.length > 0) {
55
+ lines.push('## Subject Constraints');
56
+ lines.push('');
57
+ for (const c of brief.subject_constraints) {
58
+ lines.push(`- ${c}`);
59
+ }
60
+ lines.push('');
61
+ }
62
+
63
+ // Drift warnings
64
+ if (brief.drift_warnings?.length > 0) {
65
+ lines.push('## Drift Warnings');
66
+ lines.push('');
67
+ for (const w of brief.drift_warnings) {
68
+ lines.push(`- ⚠ ${w}`);
69
+ }
70
+ lines.push('');
71
+ }
72
+
73
+ // Runtime plan
74
+ lines.push('## Runtime Plan');
75
+ lines.push('');
76
+ lines.push('| Parameter | Value |');
77
+ lines.push('|-----------|-------|');
78
+ const rp = brief.runtime_plan;
79
+ if (rp) {
80
+ lines.push(`| Adapter | ${rp.adapter_target} |`);
81
+ lines.push(`| Width | ${rp.width} |`);
82
+ lines.push(`| Height | ${rp.height} |`);
83
+ lines.push(`| Steps | ${rp.steps} |`);
84
+ lines.push(`| CFG | ${rp.cfg} |`);
85
+ lines.push(`| Sampler | ${rp.sampler} |`);
86
+ lines.push(`| Seed Mode | ${rp.seed_mode} |`);
87
+ }
88
+ lines.push('');
89
+
90
+ // Expected outputs
91
+ lines.push('## Expected Outputs');
92
+ lines.push('');
93
+ const eo = brief.expected_outputs;
94
+ if (eo) {
95
+ lines.push(`- **Mode:** ${eo.output_mode}`);
96
+ lines.push(`- **Count:** ${eo.output_count}`);
97
+ if (eo.review_surface) {
98
+ lines.push(`- **Review Surface:** ${eo.review_surface}`);
99
+ }
100
+ }
101
+ lines.push('');
102
+
103
+ // Selected references
104
+ if (brief.reference_selection?.length > 0) {
105
+ lines.push('## Selected References');
106
+ lines.push('');
107
+ for (const ref of brief.reference_selection) {
108
+ lines.push(`- **${ref.record_id}** (${ref.role}): ${ref.reason}`);
109
+ }
110
+ lines.push('');
111
+ }
112
+
113
+ // Metadata
114
+ lines.push('---');
115
+ lines.push('');
116
+ lines.push(`Compiled: ${brief.compiled_at}`);
117
+ lines.push(`Config fingerprint: \`${brief.config_fingerprint}\``);
118
+ lines.push('');
119
+
120
+ return lines.join('\n');
121
+ }
122
+
123
+ /**
124
+ * Render a compiled brief as plain text for terminal output.
125
+ */
126
+ export function renderBriefText(brief) {
127
+ const lines = [];
128
+
129
+ lines.push(`\x1b[1mBrief: ${brief.brief_id}\x1b[0m`);
130
+ lines.push(` Workflow: ${brief.workflow_id}`);
131
+ lines.push(` Project: ${brief.project_id}`);
132
+ lines.push(` Lane: ${brief.lane_id}`);
133
+ if (brief.subject_id) {
134
+ lines.push(` Subject: ${brief.subject_id}`);
135
+ }
136
+ if (brief.training_asset_ref) {
137
+ lines.push(` Asset: ${brief.training_asset_ref}`);
138
+ }
139
+ lines.push('');
140
+
141
+ lines.push('\x1b[1mPrompt:\x1b[0m');
142
+ lines.push(` ${brief.prompt}`);
143
+ lines.push('');
144
+
145
+ lines.push('\x1b[1mNegative:\x1b[0m');
146
+ lines.push(` ${brief.negative_prompt}`);
147
+ lines.push('');
148
+
149
+ if (brief.canon_constraints?.length > 0) {
150
+ lines.push('\x1b[1mCanon Constraints:\x1b[0m');
151
+ for (const c of brief.canon_constraints) {
152
+ lines.push(` • ${c}`);
153
+ }
154
+ lines.push('');
155
+ }
156
+
157
+ if (brief.subject_constraints?.length > 0) {
158
+ lines.push('\x1b[1mSubject Constraints:\x1b[0m');
159
+ for (const c of brief.subject_constraints) {
160
+ lines.push(` • ${c}`);
161
+ }
162
+ lines.push('');
163
+ }
164
+
165
+ if (brief.drift_warnings?.length > 0) {
166
+ lines.push('\x1b[1mDrift Warnings:\x1b[0m');
167
+ for (const w of brief.drift_warnings) {
168
+ lines.push(` \x1b[33m⚠\x1b[0m ${w}`);
169
+ }
170
+ lines.push('');
171
+ }
172
+
173
+ lines.push('\x1b[1mRuntime Plan:\x1b[0m');
174
+ const rp = brief.runtime_plan;
175
+ if (rp) {
176
+ lines.push(` Adapter: ${rp.adapter_target}`);
177
+ lines.push(` Size: ${rp.width}×${rp.height}`);
178
+ lines.push(` Steps: ${rp.steps}`);
179
+ lines.push(` CFG: ${rp.cfg}`);
180
+ lines.push(` Sampler: ${rp.sampler}`);
181
+ lines.push(` Seed mode: ${rp.seed_mode}`);
182
+ }
183
+ lines.push('');
184
+
185
+ lines.push('\x1b[1mExpected Outputs:\x1b[0m');
186
+ const eo = brief.expected_outputs;
187
+ if (eo) {
188
+ lines.push(` Mode: ${eo.output_mode}`);
189
+ lines.push(` Count: ${eo.output_count}`);
190
+ }
191
+ lines.push('');
192
+
193
+ if (brief.reference_selection?.length > 0) {
194
+ lines.push('\x1b[1mSelected References:\x1b[0m');
195
+ for (const ref of brief.reference_selection) {
196
+ lines.push(` ${ref.record_id} (${ref.role}) — ${ref.reason}`);
197
+ }
198
+ lines.push('');
199
+ }
200
+
201
+ lines.push(` Compiled: ${brief.compiled_at}`);
202
+ lines.push(` Fingerprint: ${brief.config_fingerprint}`);
203
+
204
+ return lines.join('\n');
205
+ }
package/lib/card.js CHANGED
@@ -70,6 +70,13 @@ export async function generateCard(projectRoot, snapshotId, splitId) {
70
70
  return { markdown, json };
71
71
  }
72
72
 
73
+ // D-025: safe percentage formatter — renders '—' for zero-total cases
74
+ // instead of 'NaN%'.
75
+ function pct(numerator, denominator) {
76
+ if (!denominator || denominator <= 0) return '—';
77
+ return `${(numerator / denominator * 100).toFixed(1)}%`;
78
+ }
79
+
73
80
  function renderMarkdown(card, config) {
74
81
  const lines = [];
75
82
  const add = (s = '') => lines.push(s);
@@ -87,7 +94,7 @@ function renderMarkdown(card, config) {
87
94
  add(`- **Snapshot:** \`${card.snapshot.id}\``);
88
95
  add(`- **Config fingerprint:** \`${card.snapshot.config_fingerprint.slice(0, 16)}...\``);
89
96
  add(`- **Total records evaluated:** ${card.snapshot.total_records}`);
90
- add(`- **Included:** ${card.snapshot.included} (${(card.snapshot.included / card.snapshot.total_records * 100).toFixed(1)}%)`);
97
+ add(`- **Included:** ${card.snapshot.included} (${pct(card.snapshot.included, card.snapshot.total_records)})`);
91
98
  add(`- **Excluded:** ${card.snapshot.excluded}`);
92
99
  add();
93
100
 
@@ -111,9 +118,9 @@ function renderMarkdown(card, config) {
111
118
  add('| Partition | Records | % |');
112
119
  add('|-----------|---------|---|');
113
120
  const total = card.split.counts.total_records;
114
- add(`| Train | ${card.split.counts.train} | ${(card.split.counts.train / total * 100).toFixed(1)}% |`);
115
- add(`| Val | ${card.split.counts.val} | ${(card.split.counts.val / total * 100).toFixed(1)}% |`);
116
- add(`| Test | ${card.split.counts.test} | ${(card.split.counts.test / total * 100).toFixed(1)}% |`);
121
+ add(`| Train | ${card.split.counts.train} | ${pct(card.split.counts.train, total)} |`);
122
+ add(`| Val | ${card.split.counts.val} | ${pct(card.split.counts.val, total)} |`);
123
+ add(`| Test | ${card.split.counts.test} | ${pct(card.split.counts.test, total)} |`);
117
124
  add();
118
125
 
119
126
  add('## Lane Balance');
package/lib/comfyui.js CHANGED
@@ -6,9 +6,15 @@
6
6
  */
7
7
 
8
8
  import { readFile } from 'node:fs/promises';
9
+ import { info } from './log.js';
9
10
 
10
11
  const DEFAULT_URL = 'http://127.0.0.1:8188';
11
12
  const MAX_POLL_MS = 600_000; // 10 minutes
13
+ const HEARTBEAT_MS = 15_000; // emit a heartbeat at most every 15s
14
+ // PB-004: if we see an unfamiliar status shape for this many consecutive
15
+ // polls without completed/error, bail with a descriptive message rather
16
+ // than hang until MAX_POLL_MS.
17
+ const UNKNOWN_STATUS_BAIL_POLLS = 30; // ~30s of unrecognized shape
12
18
 
13
19
  /**
14
20
  * Check if ComfyUI is reachable.
@@ -48,21 +54,102 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
48
54
 
49
55
  const { prompt_id: promptId } = await res.json();
50
56
 
57
+ const MAX_CONSECUTIVE_HISTORY_FAILURES = 5;
58
+ let consecutiveHistoryFailures = 0;
59
+ // PB-001: heartbeats
60
+ let lastHeartbeatAt = Date.now();
61
+ let announcedStart = false;
62
+ let seenEntry = false;
63
+ // PB-004: count polls where entry exists but status shape is unfamiliar
64
+ let unknownStatusPolls = 0;
65
+ let lastObservedStatus = null;
51
66
  const start = Date.now();
52
67
  while (true) {
53
- if (Date.now() - start > timeoutMs) {
68
+ const now = Date.now();
69
+ if (now - start > timeoutMs) {
54
70
  throw new Error(`ComfyUI poll timeout after ${timeoutMs / 1000}s for prompt ${promptId}`);
55
71
  }
56
72
  await new Promise(r => setTimeout(r, 1000));
57
- const histRes = await fetch(`${comfyUrl}/history/${promptId}`);
58
- if (!histRes.ok) continue;
73
+ let histRes;
74
+ try {
75
+ histRes = await fetch(`${comfyUrl}/history/${promptId}`);
76
+ } catch (err) {
77
+ consecutiveHistoryFailures++;
78
+ if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
79
+ throw new Error(
80
+ `ComfyUI history endpoint unreachable after ${MAX_CONSECUTIVE_HISTORY_FAILURES} consecutive attempts for prompt ${promptId}: ${err.message}`
81
+ );
82
+ }
83
+ continue;
84
+ }
85
+ if (!histRes.ok) {
86
+ consecutiveHistoryFailures++;
87
+ if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
88
+ throw new Error(
89
+ `ComfyUI history endpoint returned ${histRes.status} ${MAX_CONSECUTIVE_HISTORY_FAILURES} times in a row for prompt ${promptId}`
90
+ );
91
+ }
92
+ continue;
93
+ }
94
+ consecutiveHistoryFailures = 0;
59
95
  const history = await histRes.json();
60
96
  const entry = history[promptId];
61
- if (!entry) continue;
62
- if (entry.status?.completed) return entry;
97
+
98
+ // PB-001: emit heartbeats so the user knows the pipeline isn't hung.
99
+ const elapsedSec = Math.round((Date.now() - start) / 1000);
100
+ const heartbeatDue = Date.now() - lastHeartbeatAt >= HEARTBEAT_MS;
101
+ if (!entry) {
102
+ // Queued on ComfyUI but not yet visible in /history.
103
+ if (!announcedStart) {
104
+ info(`ComfyUI: queued (prompt ${promptId})`);
105
+ announcedStart = true;
106
+ lastHeartbeatAt = Date.now();
107
+ } else if (heartbeatDue) {
108
+ info(`ComfyUI: queued, waiting... (${elapsedSec}s)`);
109
+ lastHeartbeatAt = Date.now();
110
+ }
111
+ continue;
112
+ }
113
+
114
+ if (!seenEntry) {
115
+ seenEntry = true;
116
+ info(`ComfyUI: generating... (prompt ${promptId})`);
117
+ lastHeartbeatAt = Date.now();
118
+ } else if (heartbeatDue) {
119
+ info(`ComfyUI: generating... (${elapsedSec}s elapsed)`);
120
+ lastHeartbeatAt = Date.now();
121
+ }
122
+
123
+ // PB-004: success signals — accept both completed===true and status_str==='success'
124
+ // (future-proof for schema drift where one may change without the other).
125
+ if (entry.status?.completed === true || entry.status?.status_str === 'success') {
126
+ info(`ComfyUI: completed in ${elapsedSec}s`);
127
+ return entry;
128
+ }
129
+
130
+ // PB-004: known failure signals — bail immediately.
63
131
  if (entry.status?.status_str === 'error') {
64
132
  throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status)}`);
65
133
  }
134
+ if (entry.status?.execution_error || entry.execution_error) {
135
+ const errObj = entry.status?.execution_error || entry.execution_error;
136
+ throw new Error(`ComfyUI execution error for prompt ${promptId}: ${JSON.stringify(errObj)}`);
137
+ }
138
+
139
+ // PB-004: the entry exists but matches neither a known success nor
140
+ // a known failure. This is the schema-drift hang scenario — count
141
+ // consecutive unknown-shape polls and bail with context.
142
+ const statusStr = entry.status?.status_str ?? null;
143
+ lastObservedStatus = entry.status ?? null;
144
+ unknownStatusPolls++;
145
+ if (unknownStatusPolls >= UNKNOWN_STATUS_BAIL_POLLS) {
146
+ throw new Error(
147
+ `ComfyUI returned an unrecognized status shape for prompt ${promptId} ` +
148
+ `after ${unknownStatusPolls} polls (status_str=${JSON.stringify(statusStr)}). ` +
149
+ `This usually means the ComfyUI history schema changed. ` +
150
+ `Observed status: ${JSON.stringify(lastObservedStatus)}`
151
+ );
152
+ }
66
153
  }
67
154
  }
68
155
 
@@ -75,7 +162,14 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
75
162
  * @returns {Promise<Buffer>}
76
163
  */
77
164
  export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL, type = 'output') {
78
- const url = `${comfyUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || '')}&type=${type}`;
165
+ const ALLOWED_TYPES = ['output', 'input', 'temp'];
166
+ if (!ALLOWED_TYPES.includes(type)) {
167
+ throw new Error(`Invalid ComfyUI image type "${type}" — must be one of: ${ALLOWED_TYPES.join(', ')}`);
168
+ }
169
+ if (typeof comfyUrl !== 'string' || comfyUrl.length === 0 || !/^https?:\/\//i.test(comfyUrl)) {
170
+ throw new Error(`Invalid comfyUrl "${comfyUrl}" — must start with http:// or https://`);
171
+ }
172
+ const url = `${comfyUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || '')}&type=${encodeURIComponent(type)}`;
79
173
  const res = await fetch(url);
80
174
  if (!res.ok) throw new Error(`Download failed: ${res.status}`);
81
175
  return Buffer.from(await res.arrayBuffer());
package/lib/config.js CHANGED
@@ -7,17 +7,70 @@
7
7
  */
8
8
 
9
9
  import { readFileSync, existsSync } from 'node:fs';
10
+ import { readFile } from 'node:fs/promises';
10
11
  import { join } from 'node:path';
12
+ import { inputError, runtimeError } from './errors.js';
11
13
 
12
14
  function loadJsonFile(filePath) {
13
15
  if (!existsSync(filePath)) return null;
14
16
  try {
15
17
  return JSON.parse(readFileSync(filePath, 'utf-8'));
16
18
  } catch (err) {
17
- throw new Error(`Failed to parse ${filePath}: ${err.message}`);
19
+ throw runtimeError(
20
+ 'INPUT_INVALID_JSON',
21
+ `Failed to parse ${filePath}: ${err.message}`,
22
+ 'Check the file for JSON syntax errors (trailing commas, unquoted keys, etc).',
23
+ err
24
+ );
18
25
  }
19
26
  }
20
27
 
28
+ /**
29
+ * Async-safe readFile + JSON.parse with structured error handling.
30
+ * Optionally validates that required top-level fields are present.
31
+ *
32
+ * @param {string} filePath — absolute path to a JSON file
33
+ * @param {{requiredFields?: string[]}} [opts]
34
+ */
35
+ export async function readJsonFile(filePath, opts = {}) {
36
+ let raw;
37
+ try {
38
+ raw = await readFile(filePath, 'utf-8');
39
+ } catch (err) {
40
+ if (err && err.code === 'ENOENT') {
41
+ throw inputError(
42
+ 'INPUT_FILE_NOT_FOUND',
43
+ `File not found: ${filePath}`,
44
+ 'Check the path and try again.'
45
+ );
46
+ }
47
+ throw runtimeError('RUNTIME_READ_FAILED', `Could not read ${filePath}: ${err.message}`, null, err);
48
+ }
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ } catch (err) {
53
+ throw runtimeError(
54
+ 'INPUT_INVALID_JSON',
55
+ `Failed to parse ${filePath}: ${err.message}`,
56
+ 'Check the file for JSON syntax errors.',
57
+ err
58
+ );
59
+ }
60
+ const required = opts.requiredFields || [];
61
+ if (required.length) {
62
+ const missing = required.filter((f) => parsed == null || parsed[f] === undefined);
63
+ if (missing.length) {
64
+ throw inputError(
65
+ 'INPUT_MISSING_FIELDS',
66
+ `${filePath} is missing required fields: ${missing.join(', ')}`,
67
+ `Add the fields to the file, or use a valid example as template.`
68
+ );
69
+ }
70
+ }
71
+ return parsed;
72
+ }
73
+
21
74
  /**
22
75
  * Load project.json — project identity and generation defaults.
23
76
  */
@@ -42,7 +95,13 @@ export function loadConstitution(projectRoot) {
42
95
  const data = loadJsonFile(join(projectRoot, 'constitution.json'));
43
96
  if (!data) return { rules: [] };
44
97
  if (Array.isArray(data)) return { rules: data };
45
- if (!data.rules) throw new Error(`constitution.json must have a "rules" array`);
98
+ if (!data.rules) {
99
+ throw inputError(
100
+ 'INPUT_BAD_CONFIG',
101
+ `constitution.json must have a "rules" array`,
102
+ 'Wrap your rules in { "rules": [...] } or use an array at the top level.'
103
+ );
104
+ }
46
105
  return data;
47
106
  }
48
107
 
@@ -54,7 +113,13 @@ export function loadLanes(projectRoot) {
54
113
  if (!data) {
55
114
  return { default_lane: 'default', lanes: [] };
56
115
  }
57
- if (!data.lanes) throw new Error(`lanes.json must have a "lanes" array`);
116
+ if (!data.lanes) {
117
+ throw inputError(
118
+ 'INPUT_BAD_CONFIG',
119
+ `lanes.json must have a "lanes" array`,
120
+ 'Top-level shape: { "default_lane": "...", "lanes": [ ... ] }.'
121
+ );
122
+ }
58
123
  return data;
59
124
  }
60
125