@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.
- package/CHANGELOG.md +125 -0
- package/README.es.md +76 -144
- package/README.fr.md +75 -143
- package/README.hi.md +75 -143
- package/README.it.md +76 -144
- package/README.ja.md +75 -143
- package/README.md +36 -8
- package/README.pt-BR.md +75 -143
- package/README.zh.md +76 -144
- package/SECURITY.md +3 -1
- package/bin/sdlab.js +146 -17
- package/lib/adapters/comfyui-runner.js +170 -0
- package/lib/adapters/comfyui-workflows.js +163 -0
- package/lib/batch-compiler.js +118 -0
- package/lib/batch-modes.js +111 -0
- package/lib/batch-runs.js +168 -0
- package/lib/batch-sheet-render.js +206 -0
- package/lib/brief-compiler.js +598 -0
- package/lib/brief-render.js +205 -0
- package/lib/critique-engine.js +413 -0
- package/lib/critique-render.js +171 -0
- package/lib/errors.js +82 -0
- package/lib/generated-provenance.js +87 -0
- package/lib/log.js +36 -0
- package/lib/paths.js +31 -0
- package/lib/refine-briefs.js +329 -0
- package/lib/reingest-selected.js +173 -0
- package/lib/run-summary.js +131 -0
- package/lib/runtime-runs.js +168 -0
- package/lib/selections.js +323 -0
- package/lib/snapshot.js +5 -2
- package/lib/split.js +5 -2
- package/lib/workflow-profiles.js +161 -0
- package/package.json +8 -6
- package/scripts/batch-generate.js +167 -0
- package/scripts/batch-sheet.js +46 -0
- package/scripts/batch-show.js +86 -0
- package/scripts/brief-compile.js +134 -0
- package/scripts/brief-show.js +52 -0
- package/scripts/critique-show.js +50 -0
- package/scripts/critique.js +70 -0
- package/scripts/doctor.js +169 -1
- package/scripts/init.js +22 -0
- package/scripts/refine.js +119 -0
- package/scripts/reingest-selected.js +78 -0
- package/scripts/reingest.js +7 -1
- package/scripts/run-generate.js +76 -0
- package/scripts/run-list.js +58 -0
- package/scripts/run-show.js +63 -0
- package/scripts/select.js +118 -0
- package/scripts/selection-show.js +88 -0
- package/scripts/snapshot.js +27 -23
- package/scripts/split.js +3 -1
- package/scripts/workflow-list.js +61 -0
- package/scripts/workflow-show.js +96 -0
- package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
- package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
- package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
- package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
- package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
- package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
- package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
- package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
- package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
- 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
|
+
}
|