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