@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,625 @@
1
+ /**
2
+ * Brief compiler — merges project truth into a deterministic generation contract.
3
+ *
4
+ * Merge order (lowest to highest precedence):
5
+ * 1. Project canon (constitution rules, global negatives)
6
+ * 2. Lane config (lane-specific constraints)
7
+ * 3. Subject config (identity anchors from records)
8
+ * 4. Training asset / implementation pack hints
9
+ * 5. Workflow profile (prompt strategy, output shape, runtime defaults)
10
+ * 6. CLI overrides (output count, dimensions, sampler, etc.)
11
+ *
12
+ * Rule: same inputs → same brief body (deterministic except brief_id + compiled_at)
13
+ */
14
+
15
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
18
+ import { createHash, randomBytes } from 'node:crypto';
19
+ import { loadProjectConfig } from './config.js';
20
+ import { getWorkflowProfile } from './workflow-profiles.js';
21
+ import { getBriefsDir } from './paths.js';
22
+ import { inputError, runtimeError } from './errors.js';
23
+ import { renderBriefMarkdown } from './brief-render.js';
24
+
25
+ // ─── Brief ID generation ─────────────────────────────────────────
26
+
27
+ /**
28
+ * Generate a brief ID: brief_YYYY-MM-DD_NNN
29
+ * NNN is a zero-padded sequence within the day, based on existing briefs.
30
+ */
31
+ function generateBriefId(briefsDir) {
32
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
33
+ const prefix = `brief_${today}_`;
34
+
35
+ let maxSeq = 0;
36
+ if (existsSync(briefsDir)) {
37
+ const existing = readdirSync(briefsDir)
38
+ .filter(f => f.startsWith(prefix) && f.endsWith('.json'));
39
+ for (const f of existing) {
40
+ const seqStr = f.slice(prefix.length, -5); // strip prefix and .json
41
+ const seq = parseInt(seqStr, 10);
42
+ if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
43
+ }
44
+ }
45
+
46
+ const seq = String(maxSeq + 1).padStart(3, '0');
47
+ return `${prefix}${seq}`;
48
+ }
49
+
50
+ // ─── Canonical JSON ──────────────────────────────────────────────
51
+
52
+ /**
53
+ * Recursively canonicalize an object: sort keys at every nesting level.
54
+ * Arrays retain their order (order is semantically meaningful for arrays).
55
+ * Primitives pass through untouched.
56
+ */
57
+ export function canonicalize(value) {
58
+ if (value === null || typeof value !== 'object') return value;
59
+ if (Array.isArray(value)) return value.map(canonicalize);
60
+ const sortedKeys = Object.keys(value).sort();
61
+ const out = {};
62
+ for (const key of sortedKeys) {
63
+ out[key] = canonicalize(value[key]);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ // ─── Config fingerprint ──────────────────────────────────────────
69
+
70
+ /**
71
+ * Compute fingerprint of all inputs that feed the brief.
72
+ * Includes project config files + workflow profile content.
73
+ */
74
+ function computeBriefFingerprint(projectRoot, workflow, subjectRecords) {
75
+ const hash = createHash('sha256');
76
+
77
+ // Project config files (alphabetical)
78
+ const configFiles = ['constitution.json', 'lanes.json', 'project.json', 'rubric.json', 'terminology.json'];
79
+ for (const file of configFiles) {
80
+ const filePath = join(projectRoot, file);
81
+ if (existsSync(filePath)) {
82
+ hash.update(`--- ${file} ---\n`);
83
+ hash.update(readFileSync(filePath, 'utf-8'));
84
+ hash.update('\n');
85
+ }
86
+ }
87
+
88
+ // Workflow profile (serialized deterministically, recursively key-sorted)
89
+ hash.update('--- workflow ---\n');
90
+ hash.update(JSON.stringify(canonicalize(workflow)));
91
+ hash.update('\n');
92
+
93
+ // Subject records (sorted by ID)
94
+ if (subjectRecords.length > 0) {
95
+ hash.update('--- subjects ---\n');
96
+ for (const rec of subjectRecords) {
97
+ hash.update(`${rec.id}\n`);
98
+ }
99
+ }
100
+
101
+ return hash.digest('hex');
102
+ }
103
+
104
+ // ─── Subject resolution ──────────────────────────────────────────
105
+
106
+ /**
107
+ * Resolve subject records — find records matching a subject ID.
108
+ * Looks for records with identity.subject_id matching the given ID,
109
+ * or records whose ID starts with the subject prefix.
110
+ */
111
+ async function resolveSubject(recordsDir, subjectId) {
112
+ if (!existsSync(recordsDir)) return [];
113
+
114
+ const files = readdirSync(recordsDir).filter(f => f.endsWith('.json'));
115
+ const matches = [];
116
+ const lowerSubject = subjectId.toLowerCase().replace(/-/g, '_');
117
+
118
+ for (const file of files) {
119
+ try {
120
+ const data = JSON.parse(readFileSync(join(recordsDir, file), 'utf-8'));
121
+
122
+ // Match by identity.subject_id
123
+ if (data.identity?.subject_id) {
124
+ const recordSubject = data.identity.subject_id.toLowerCase().replace(/-/g, '_');
125
+ if (recordSubject === lowerSubject) {
126
+ matches.push(data);
127
+ continue;
128
+ }
129
+ }
130
+
131
+ // Match by ID prefix
132
+ if (data.id && data.id.toLowerCase().replace(/-/g, '_').startsWith(lowerSubject)) {
133
+ matches.push(data);
134
+ }
135
+ } catch {
136
+ // Skip unparseable records
137
+ }
138
+ }
139
+
140
+ // Sort deterministically by ID
141
+ matches.sort((a, b) => a.id.localeCompare(b.id));
142
+ return matches;
143
+ }
144
+
145
+ /**
146
+ * Extract subject constraints from resolved records.
147
+ */
148
+ function extractSubjectContext(subjectRecords) {
149
+ const constraints = [];
150
+ const negatives = [];
151
+ const anchors = [];
152
+
153
+ for (const rec of subjectRecords) {
154
+ const identity = rec.identity;
155
+ if (!identity) continue;
156
+
157
+ // Stable traits
158
+ if (identity.stable_traits) {
159
+ for (const trait of identity.stable_traits) {
160
+ if (!constraints.includes(trait)) constraints.push(trait);
161
+ }
162
+ }
163
+
164
+ // Faction / role
165
+ if (identity.faction) constraints.push(`faction: ${identity.faction}`);
166
+ if (identity.role) constraints.push(`role: ${identity.role}`);
167
+ if (identity.species) constraints.push(`species: ${identity.species}`);
168
+
169
+ // Prohibited drift
170
+ if (identity.prohibited_drift) {
171
+ for (const drift of identity.prohibited_drift) {
172
+ if (!negatives.includes(drift)) negatives.push(drift);
173
+ }
174
+ }
175
+
176
+ // Anchor records
177
+ if (rec.judgment?.status === 'approved') {
178
+ anchors.push({
179
+ record_id: rec.id,
180
+ role: rec.lineage?.generation_role === 'anchor' ? 'anchor' : 'supporting',
181
+ reason: 'approved subject reference',
182
+ });
183
+ }
184
+ }
185
+
186
+ return { constraints, negatives, anchors };
187
+ }
188
+
189
+ // ─── Reference resolution ────────────────────────────────────────
190
+
191
+ /**
192
+ * Resolve explicit reference IDs to records.
193
+ */
194
+ async function resolveReferences(recordsDir, referenceIds) {
195
+ if (!referenceIds || referenceIds.length === 0) return [];
196
+
197
+ const refs = [];
198
+ const sorted = [...referenceIds].sort();
199
+
200
+ for (const refId of sorted) {
201
+ const filePath = join(recordsDir, `${refId}.json`);
202
+ if (!existsSync(filePath)) {
203
+ throw inputError(
204
+ 'BRIEF_REF_NOT_FOUND',
205
+ `Reference record "${refId}" not found`,
206
+ `Check the record ID exists in records/.`
207
+ );
208
+ }
209
+
210
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
211
+ if (data.judgment?.status !== 'approved') {
212
+ throw inputError(
213
+ 'BRIEF_REF_NOT_APPROVED',
214
+ `Reference record "${refId}" is not approved (status: ${data.judgment?.status || 'none'})`,
215
+ `Only approved records can be used as references.`
216
+ );
217
+ }
218
+
219
+ refs.push({
220
+ record_id: data.id,
221
+ role: data.lineage?.generation_role === 'anchor' ? 'anchor' : 'reference',
222
+ reason: `explicit reference selection`,
223
+ });
224
+ }
225
+
226
+ return refs;
227
+ }
228
+
229
+ // ─── Prompt assembly ─────────────────────────────────────────────
230
+
231
+ /**
232
+ * Build the prompt block from structured sections.
233
+ * Sections are assembled per the workflow's prompt_strategy.structure field.
234
+ */
235
+ function buildPromptBlock(workflow, config, lane, subjectContext) {
236
+ const ps = workflow.prompt_strategy;
237
+ const sections = {};
238
+
239
+ // Style prefix from workflow
240
+ sections.style_prefix = ps.style_prefix.join(', ');
241
+
242
+ // Subject identity — from resolved subject constraints
243
+ if (subjectContext?.constraints?.length > 0) {
244
+ sections.subject_identity = subjectContext.constraints.join(', ');
245
+ }
246
+
247
+ // Lane goal
248
+ if (lane) {
249
+ sections.lane_goal = lane.description || lane.label || lane.id;
250
+ }
251
+
252
+ // Canon focus — derive prompt fragments from matching constitution rules
253
+ const canonFragments = [];
254
+ const constitution = config.constitution;
255
+ if (constitution?.rules && workflow.canon_focus) {
256
+ for (const rule of constitution.rules) {
257
+ // Match rules whose dims overlap with canon_focus
258
+ const ruleDims = rule.dims || [];
259
+ const focusMatch = ruleDims.some(d => {
260
+ // Map dimension names to canon_focus categories
261
+ const dimMap = {
262
+ silhouette_clarity: 'silhouette',
263
+ material_fidelity: 'material_language',
264
+ palette_adherence: 'palette',
265
+ composition: 'composition',
266
+ style_consistency: 'palette',
267
+ faction_read: 'faction_read',
268
+ wear_level: 'surface_wear',
269
+ };
270
+ return workflow.canon_focus.includes(dimMap[d] || d);
271
+ });
272
+
273
+ if (focusMatch && rule.desc) {
274
+ canonFragments.push(rule.desc);
275
+ }
276
+ }
277
+ }
278
+ sections.canon_focus = canonFragments.join(', ');
279
+
280
+ // Controlled variation — optional blocks from workflow
281
+ if (ps.optional_blocks?.length > 0) {
282
+ sections.controlled_variation = ps.optional_blocks.join(', ');
283
+ }
284
+
285
+ // Assemble in structure order
286
+ const structureParts = ps.structure.split(',').map(s => s.trim());
287
+ const promptParts = [];
288
+
289
+ for (const part of structureParts) {
290
+ const section = sections[part];
291
+ if (section && section.length > 0) {
292
+ promptParts.push(section);
293
+ }
294
+ }
295
+
296
+ return promptParts.join(', ');
297
+ }
298
+
299
+ /**
300
+ * Build the negative prompt block from merged sources.
301
+ */
302
+ function buildNegativeBlock(workflow, config, subjectContext) {
303
+ const parts = [];
304
+
305
+ // Workflow must_avoid (highest priority — first in negative)
306
+ if (workflow.negative_strategy?.must_avoid) {
307
+ parts.push(...workflow.negative_strategy.must_avoid);
308
+ }
309
+
310
+ // Lane-specific avoid
311
+ if (workflow.negative_strategy?.lane_specific_avoid) {
312
+ parts.push(...workflow.negative_strategy.lane_specific_avoid);
313
+ }
314
+
315
+ // Subject-specific avoid (from workflow defaults)
316
+ if (workflow.negative_strategy?.subject_specific_avoid) {
317
+ parts.push(...workflow.negative_strategy.subject_specific_avoid);
318
+ }
319
+
320
+ // Subject-specific negatives from identity
321
+ if (subjectContext?.negatives) {
322
+ for (const neg of subjectContext.negatives) {
323
+ if (!parts.includes(neg)) parts.push(neg);
324
+ }
325
+ }
326
+
327
+ return parts.join(', ');
328
+ }
329
+
330
+ // ─── Canon constraints ───────────────────────────────────────────
331
+
332
+ /**
333
+ * Extract relevant canon constraints based on workflow canon_focus.
334
+ */
335
+ function buildCanonConstraints(workflow, config) {
336
+ const constraints = [];
337
+ const constitution = config.constitution;
338
+
339
+ if (!constitution?.rules || !workflow.canon_focus) return constraints;
340
+
341
+ for (const rule of constitution.rules) {
342
+ const ruleDims = rule.dims || [];
343
+ const dimMap = {
344
+ silhouette_clarity: 'silhouette',
345
+ material_fidelity: 'material_language',
346
+ palette_adherence: 'palette',
347
+ composition: 'composition',
348
+ style_consistency: 'palette',
349
+ faction_read: 'faction_read',
350
+ wear_level: 'surface_wear',
351
+ };
352
+
353
+ const focusMatch = ruleDims.some(d => workflow.canon_focus.includes(dimMap[d] || d));
354
+ if (focusMatch) {
355
+ constraints.push(`[${rule.id}] ${rule.desc}`);
356
+ }
357
+ }
358
+
359
+ return constraints.sort();
360
+ }
361
+
362
+ /**
363
+ * Build drift warnings — merge workflow drift_guards with canon forbidden patterns.
364
+ */
365
+ function buildDriftWarnings(workflow, config) {
366
+ const warnings = [];
367
+
368
+ // Workflow drift guards
369
+ if (workflow.drift_guards) {
370
+ warnings.push(...workflow.drift_guards);
371
+ }
372
+
373
+ // Constitution-level forbidden patterns (rules that define failure)
374
+ const constitution = config.constitution;
375
+ if (constitution?.rules) {
376
+ for (const rule of constitution.rules) {
377
+ if (rule.rationale_fail && workflow.canon_focus) {
378
+ const ruleDims = rule.dims || [];
379
+ const dimMap = {
380
+ silhouette_clarity: 'silhouette',
381
+ material_fidelity: 'material_language',
382
+ palette_adherence: 'palette',
383
+ composition: 'composition',
384
+ style_consistency: 'palette',
385
+ faction_read: 'faction_read',
386
+ wear_level: 'surface_wear',
387
+ };
388
+ const focusMatch = ruleDims.some(d => workflow.canon_focus.includes(dimMap[d] || d));
389
+ if (focusMatch) {
390
+ // Extract the drift pattern from the fail rationale
391
+ const failHint = rule.rationale_fail.replace(/\$\{[^}]+\}/g, '').trim();
392
+ if (failHint && !warnings.includes(failHint)) {
393
+ warnings.push(`[${rule.id}] ${failHint}`);
394
+ }
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ return warnings;
401
+ }
402
+
403
+ /**
404
+ * Build the runtime plan — merge workflow defaults with CLI overrides.
405
+ */
406
+ export function buildRuntimePlan(workflow, overrides = {}) {
407
+ const rd = workflow.runtime_defaults || {};
408
+ return {
409
+ adapter_target: overrides.adapter_target ?? rd.adapter_target ?? 'comfyui',
410
+ width: overrides.width ?? rd.width ?? 1024,
411
+ height: overrides.height ?? rd.height ?? 1024,
412
+ steps: overrides.steps ?? rd.steps ?? 30,
413
+ cfg: overrides.cfg ?? rd.cfg ?? 6.5,
414
+ sampler: overrides.sampler ?? rd.sampler ?? 'dpmpp_2m',
415
+ seed_mode: overrides.seed_mode ?? rd.seed_mode ?? 'increment',
416
+ };
417
+ }
418
+
419
+ // ─── Main compiler ───────────────────────────────────────────────
420
+
421
+ /**
422
+ * Compile a generation brief from project truth.
423
+ *
424
+ * @param {Object} opts
425
+ * @param {string} opts.projectRoot — absolute path to project
426
+ * @param {string} opts.projectId — project name
427
+ * @param {string} opts.workflowId — workflow profile ID
428
+ * @param {string} [opts.subjectId] — optional subject ID
429
+ * @param {string} [opts.assetRef] — optional training asset reference
430
+ * @param {string} [opts.implementationPackRef] — optional implementation pack reference
431
+ * @param {string[]} [opts.referenceIds] — optional explicit reference record IDs
432
+ * @param {Object} [opts.overrides] — CLI overrides (output_count, width, height, etc.)
433
+ * @returns {Promise<Object>} compiled brief
434
+ */
435
+ export async function compileBrief({
436
+ projectRoot,
437
+ projectId,
438
+ workflowId,
439
+ subjectId,
440
+ assetRef,
441
+ implementationPackRef,
442
+ referenceIds,
443
+ overrides = {},
444
+ }) {
445
+ // Load workflow profile
446
+ const workflow = await getWorkflowProfile(projectRoot, workflowId);
447
+
448
+ // Validate required workflow fields
449
+ if (typeof workflow.output_mode !== 'string' || workflow.output_mode.length === 0) {
450
+ throw inputError(
451
+ 'WORKFLOW_INVALID',
452
+ `Workflow "${workflowId}" is missing a non-empty "output_mode"`,
453
+ `Check the workflow profile JSON — output_mode must be a non-empty string.`
454
+ );
455
+ }
456
+
457
+ // Enforce subject_mode
458
+ const subjectMode = workflow.subject_mode || 'optional';
459
+ if (subjectMode === 'required' && !subjectId) {
460
+ throw inputError(
461
+ 'BRIEF_SUBJECT_REQUIRED',
462
+ `Workflow "${workflowId}" requires a subject (subject_mode: required)`,
463
+ `Add --subject <id> to the command.`
464
+ );
465
+ }
466
+ if (subjectMode === 'forbidden' && subjectId) {
467
+ throw inputError(
468
+ 'BRIEF_SUBJECT_FORBIDDEN',
469
+ `Workflow "${workflowId}" forbids a subject (subject_mode: forbidden)`,
470
+ `Remove --subject from the command.`
471
+ );
472
+ }
473
+
474
+ // Load project config
475
+ const config = loadProjectConfig(projectRoot);
476
+
477
+ // Resolve lane
478
+ const lane = (config.lanes.lanes || []).find(l => l.id === workflow.lane_id) || null;
479
+
480
+ // Resolve subject
481
+ const recordsDir = join(projectRoot, 'records');
482
+ let subjectRecords = [];
483
+ let subjectContext = { constraints: [], negatives: [], anchors: [] };
484
+
485
+ if (subjectId) {
486
+ subjectRecords = await resolveSubject(recordsDir, subjectId);
487
+ subjectContext = extractSubjectContext(subjectRecords);
488
+ }
489
+
490
+ // Resolve explicit references
491
+ const referenceSelection = await resolveReferences(recordsDir, referenceIds);
492
+
493
+ // Add subject anchors to reference selection
494
+ for (const anchor of subjectContext.anchors) {
495
+ if (!referenceSelection.find(r => r.record_id === anchor.record_id)) {
496
+ referenceSelection.push(anchor);
497
+ }
498
+ }
499
+
500
+ // Sort references deterministically
501
+ referenceSelection.sort((a, b) => a.record_id.localeCompare(b.record_id));
502
+
503
+ // Compute fingerprint
504
+ const configFingerprint = computeBriefFingerprint(projectRoot, workflow, subjectRecords);
505
+
506
+ // Build brief sections
507
+ const prompt = buildPromptBlock(workflow, config, lane, subjectContext);
508
+ const negativePrompt = buildNegativeBlock(workflow, config, subjectContext);
509
+ const canonConstraints = buildCanonConstraints(workflow, config);
510
+ const driftWarnings = buildDriftWarnings(workflow, config);
511
+ const runtimePlan = buildRuntimePlan(workflow, overrides);
512
+
513
+ // Expected outputs
514
+ const expectedOutputs = {
515
+ output_mode: workflow.output_mode,
516
+ output_count: overrides.output_count ?? workflow.output_count,
517
+ review_surface: workflow.output_mode.replace(/_/g, ' '),
518
+ };
519
+
520
+ // Generate brief ID
521
+ const briefsDir = getBriefsDir(projectRoot);
522
+ const briefId = generateBriefId(briefsDir);
523
+
524
+ // Assemble the brief
525
+ const brief = {
526
+ brief_id: briefId,
527
+ project_id: projectId,
528
+ workflow_id: workflowId,
529
+ lane_id: workflow.lane_id,
530
+ compiled_at: new Date().toISOString(),
531
+ config_fingerprint: configFingerprint,
532
+ };
533
+
534
+ // Optional fields — only include when present
535
+ if (subjectId) brief.subject_id = subjectId;
536
+ if (assetRef) brief.training_asset_ref = assetRef;
537
+ if (implementationPackRef) brief.implementation_pack_ref = implementationPackRef;
538
+
539
+ // Reference selection
540
+ if (referenceSelection.length > 0) {
541
+ brief.reference_selection = referenceSelection;
542
+ }
543
+
544
+ // Core content
545
+ brief.prompt = prompt;
546
+ brief.negative_prompt = negativePrompt;
547
+ brief.canon_constraints = canonConstraints;
548
+
549
+ if (subjectContext.constraints.length > 0) {
550
+ brief.subject_constraints = subjectContext.constraints;
551
+ }
552
+
553
+ brief.drift_warnings = driftWarnings;
554
+ brief.runtime_plan = runtimePlan;
555
+ brief.expected_outputs = expectedOutputs;
556
+
557
+ return brief;
558
+ }
559
+
560
+ /**
561
+ * Save a compiled brief to disk (JSON + Markdown).
562
+ */
563
+ export async function saveCompiledBrief(projectRoot, brief) {
564
+ const briefsDir = getBriefsDir(projectRoot);
565
+ await mkdir(briefsDir, { recursive: true });
566
+
567
+ // Write JSON
568
+ const jsonPath = join(briefsDir, `${brief.brief_id}.json`);
569
+ await writeFile(jsonPath, JSON.stringify(brief, null, 2) + '\n');
570
+
571
+ // Write Markdown
572
+ const md = renderBriefMarkdown(brief);
573
+ const mdPath = join(briefsDir, `${brief.brief_id}.md`);
574
+ await writeFile(mdPath, md);
575
+
576
+ return { jsonPath, mdPath };
577
+ }
578
+
579
+ /**
580
+ * Load a compiled brief from disk.
581
+ */
582
+ export async function loadCompiledBrief(projectRoot, briefId) {
583
+ const briefsDir = getBriefsDir(projectRoot);
584
+ const jsonPath = join(briefsDir, `${briefId}.json`);
585
+
586
+ if (!existsSync(jsonPath)) {
587
+ throw inputError(
588
+ 'BRIEF_NOT_FOUND',
589
+ `Brief "${briefId}" not found`,
590
+ `Run: sdlab brief compile --project <name> --workflow <id>`
591
+ );
592
+ }
593
+
594
+ return JSON.parse(await readFile(jsonPath, 'utf-8'));
595
+ }
596
+
597
+ /**
598
+ * List all compiled briefs in a project.
599
+ */
600
+ export async function listCompiledBriefs(projectRoot) {
601
+ const briefsDir = getBriefsDir(projectRoot);
602
+ if (!existsSync(briefsDir)) return [];
603
+
604
+ const files = readdirSync(briefsDir).filter(f => f.endsWith('.json')).sort();
605
+ const briefs = [];
606
+
607
+ for (const file of files) {
608
+ try {
609
+ const data = JSON.parse(readFileSync(join(briefsDir, file), 'utf-8'));
610
+ briefs.push({
611
+ brief_id: data.brief_id,
612
+ workflow_id: data.workflow_id,
613
+ lane_id: data.lane_id,
614
+ subject_id: data.subject_id || null,
615
+ compiled_at: data.compiled_at,
616
+ output_mode: data.expected_outputs?.output_mode,
617
+ output_count: data.expected_outputs?.output_count,
618
+ });
619
+ } catch {
620
+ // Skip malformed
621
+ }
622
+ }
623
+
624
+ return briefs;
625
+ }