@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
package/lib/log.js CHANGED
@@ -3,6 +3,13 @@
3
3
  *
4
4
  * Levels: quiet < normal < verbose
5
5
  * Set via --quiet or --verbose flags (parsed by caller).
6
+ *
7
+ * Context prefix:
8
+ * info('generate', 'starting wave 1') → '[generate] starting wave 1'
9
+ * info('starting wave 1') → 'starting wave 1' (no prefix)
10
+ * The first argument is treated as a command/context label when it is a
11
+ * short single-token string with no whitespace; otherwise the call is
12
+ * prefix-less and backwards-compatible.
6
13
  */
7
14
 
8
15
  let level = 'normal'; // quiet | normal | verbose
@@ -15,22 +22,52 @@ export function getLogLevel() {
15
22
  return level;
16
23
  }
17
24
 
25
+ // Accept a leading short-token context label as the first arg.
26
+ // Heuristic: string, no whitespace, ≤ 32 chars, and there is at least
27
+ // one additional argument after it. This keeps existing call sites
28
+ // (where args is typically a full message) unchanged.
29
+ function applyContextPrefix(args) {
30
+ if (args.length < 2) return args;
31
+ const first = args[0];
32
+ if (typeof first !== 'string') return args;
33
+ if (first.length > 32) return args;
34
+ if (/\s/.test(first)) return args;
35
+ // Avoid treating a message that happens to start with a format token as
36
+ // a context: require it to look like an identifier (word chars, dots, dashes).
37
+ if (!/^[A-Za-z][\w.:-]*$/.test(first)) return args;
38
+ return [`[${first}]`, ...args.slice(1)];
39
+ }
40
+
18
41
  /** Always shown (errors, final results). */
19
42
  export function log(...args) {
20
- console.log(...args);
43
+ console.log(...applyContextPrefix(args));
44
+ }
45
+
46
+ /**
47
+ * Artifact / final-output path helper.
48
+ * Always prints regardless of verbosity (survives --quiet) so scripts
49
+ * that produce files have a machine-parseable path on stdout.
50
+ */
51
+ export function result(...args) {
52
+ console.log(...applyContextPrefix(args));
53
+ }
54
+
55
+ /** Always shown (used for final success lines). */
56
+ export function success(...args) {
57
+ console.log(...applyContextPrefix(args));
21
58
  }
22
59
 
23
60
  /** Shown at normal and verbose levels (progress, summaries). */
24
61
  export function info(...args) {
25
- if (level !== 'quiet') console.log(...args);
62
+ if (level !== 'quiet') console.log(...applyContextPrefix(args));
26
63
  }
27
64
 
28
65
  /** Shown only at verbose level (per-record details, debug context). */
29
66
  export function verbose(...args) {
30
- if (level === 'verbose') console.log(...args);
67
+ if (level === 'verbose') console.log(...applyContextPrefix(args));
31
68
  }
32
69
 
33
70
  /** Always shown on stderr. */
34
71
  export function warn(...args) {
35
- console.error('\x1b[33m⚠\x1b[0m', ...args);
72
+ console.error('\x1b[33m⚠\x1b[0m', ...applyContextPrefix(args));
36
73
  }
package/lib/paths.js CHANGED
@@ -3,8 +3,9 @@
3
3
  */
4
4
 
5
5
  import { fileURLToPath } from 'node:url';
6
- import { dirname, join } from 'node:path';
6
+ import { dirname, join, resolve, relative, isAbsolute } from 'node:path';
7
7
  import { existsSync } from 'node:fs';
8
+ import { inputError } from './errors.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = dirname(__filename);
@@ -18,10 +19,18 @@ export const REPO_ROOT = join(__dirname, '..');
18
19
  * @returns {string} absolute path to projects/<name>/
19
20
  */
20
21
  export function getProjectRoot(name) {
22
+ if (!name || typeof name !== 'string') {
23
+ throw inputError(
24
+ 'INPUT_MISSING_PROJECT',
25
+ 'Project name is required.',
26
+ 'Pass --project <name>.'
27
+ );
28
+ }
21
29
  const root = join(REPO_ROOT, 'projects', name);
22
30
  if (!existsSync(root)) {
23
- throw new Error(
24
- `Project "${name}" not found at ${root}.\n` +
31
+ throw inputError(
32
+ 'INPUT_UNKNOWN_PROJECT',
33
+ `Project "${name}" not found at ${root}.`,
25
34
  `Run: sdlab init ${name} --domain <domain>`
26
35
  );
27
36
  }
@@ -36,3 +45,63 @@ export function getProjectRoot(name) {
36
45
  export function resolveProjectPath(name, ...segments) {
37
46
  return join(getProjectRoot(name), ...segments);
38
47
  }
48
+
49
+ /**
50
+ * Resolve a user-supplied path relative to a project root and verify
51
+ * it does not escape the root (no `..` traversal, no absolute-path escapes
52
+ * out of root). Returns the absolute resolved path.
53
+ *
54
+ * @param {string} projectRoot — absolute project root (trusted)
55
+ * @param {string} userPath — user-supplied, possibly malicious path
56
+ * @param {{baseRoot?:string, flagName?:string}} [opts]
57
+ * @returns {string} absolute path guaranteed to be within baseRoot
58
+ */
59
+ export function resolveSafeProjectPath(projectRoot, userPath, opts = {}) {
60
+ const flagName = opts.flagName || 'path';
61
+ if (userPath == null || userPath === '') {
62
+ throw inputError('INPUT_MISSING_VALUE', `Flag --${flagName} requires a path.`);
63
+ }
64
+ const baseRoot = opts.baseRoot || projectRoot;
65
+ const absBase = resolve(baseRoot);
66
+ const resolved = isAbsolute(userPath) ? resolve(userPath) : resolve(absBase, userPath);
67
+ const rel = relative(absBase, resolved);
68
+ if (rel.startsWith('..') || (isAbsolute(rel) && rel !== '')) {
69
+ throw inputError(
70
+ 'INPUT_PATH_TRAVERSAL',
71
+ `Path "${userPath}" resolves outside of ${absBase}.`,
72
+ `Paths passed via --${flagName} must stay within the project tree.`
73
+ );
74
+ }
75
+ return resolved;
76
+ }
77
+
78
+ /**
79
+ * Return the workflow profiles directory for a project root.
80
+ * @param {string} projectRoot — absolute path to project
81
+ */
82
+ export function getWorkflowProfilesDir(projectRoot) {
83
+ return join(projectRoot, 'workflows', 'profiles');
84
+ }
85
+
86
+ /**
87
+ * Return the briefs directory for a project root.
88
+ * @param {string} projectRoot — absolute path to project
89
+ */
90
+ export function getBriefsDir(projectRoot) {
91
+ return join(projectRoot, 'briefs');
92
+ }
93
+
94
+ /**
95
+ * Return the runs directory for a project root.
96
+ * @param {string} projectRoot — absolute path to project
97
+ */
98
+ export function getRunsDir(projectRoot) {
99
+ return join(projectRoot, 'runs');
100
+ }
101
+
102
+ /**
103
+ * Return the runtime templates directory (repo-level).
104
+ */
105
+ export function getRuntimeDir() {
106
+ return join(REPO_ROOT, 'runtime');
107
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Refine briefs — derive a next-pass brief from critique of a previous run.
3
+ *
4
+ * A refined brief is NOT a brand-new brief. It is a delta layer:
5
+ * - Preserves: original workflow, subject, lane, canon constraints, runtime plan
6
+ * - Adds: preserve/push/suppress instructions derived from critique
7
+ * - Updates: prompt and negative prompt with targeted refinements
8
+ *
9
+ * This keeps the loop stable — each pass improves, never scrambles.
10
+ */
11
+
12
+ import { writeFile, mkdir } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { existsSync, readdirSync } from 'node:fs';
15
+ import { getRunsDir } from './paths.js';
16
+ import { inputError } from './errors.js';
17
+
18
+ // ─── Refined brief ID generation ─────────────────────────────────
19
+
20
+ /**
21
+ * Generate refined brief ID: <parent_brief_id>_refine_NN
22
+ */
23
+ function generateRefinedBriefId(refineDir, parentBriefId) {
24
+ const prefix = `${parentBriefId}_refine_`;
25
+
26
+ let maxSeq = 0;
27
+ if (existsSync(refineDir)) {
28
+ const existing = readdirSync(refineDir)
29
+ .filter(f => f.startsWith(prefix) && f.endsWith('.json'));
30
+ for (const f of existing) {
31
+ const seqStr = f.slice(prefix.length, -5);
32
+ const seq = parseInt(seqStr, 10);
33
+ if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
34
+ }
35
+ }
36
+
37
+ const seq = String(maxSeq + 1).padStart(2, '0');
38
+ return `${prefix}${seq}`;
39
+ }
40
+
41
+ // ─── Prompt refinement ───────────────────────────────────────────
42
+
43
+ /**
44
+ * Render a refined prompt from parent prompt + delta instructions.
45
+ *
46
+ * Strategy:
47
+ * - Start with the original prompt
48
+ * - Append preserve notes as reinforcement
49
+ * - Append push notes as emphasis
50
+ * - Do NOT remove anything from the original — only add emphasis
51
+ */
52
+ export function renderRefinedPrompt(parentPrompt, delta) {
53
+ const parts = [parentPrompt];
54
+
55
+ // Preserve instructions — reinforce what worked
56
+ if (delta.preserve?.length > 0) {
57
+ parts.push(`[preserve: ${delta.preserve.join(', ')}]`);
58
+ }
59
+
60
+ // Push instructions — emphasize what needs to be stronger
61
+ if (delta.push?.length > 0) {
62
+ parts.push(`[emphasize: ${delta.push.join(', ')}]`);
63
+ }
64
+
65
+ return parts.join(', ');
66
+ }
67
+
68
+ /**
69
+ * Render a refined negative prompt from parent negative + suppress instructions.
70
+ *
71
+ * Strategy:
72
+ * - Keep the entire original negative prompt
73
+ * - Append suppress items that aren't already present
74
+ */
75
+ export function renderRefinedNegative(parentNegative, delta) {
76
+ // PB-007: coerce missing/null/non-string negatives to '' so we don't
77
+ // throw on legacy briefs or hand-edited JSON that dropped the field.
78
+ const parent = typeof parentNegative === 'string' ? parentNegative : '';
79
+ const existing = parent
80
+ .split(',')
81
+ .map(s => s.trim().toLowerCase())
82
+ .filter(s => s.length > 0);
83
+ const additions = [];
84
+
85
+ for (const item of delta.suppress || []) {
86
+ const lower = item.toLowerCase().trim();
87
+ // Don't duplicate if already in negative (token-exact match)
88
+ if (lower.length > 0 && !existing.includes(lower)) {
89
+ additions.push(item);
90
+ }
91
+ }
92
+
93
+ if (additions.length === 0) return parent;
94
+ if (parent.length === 0) return additions.join(', ');
95
+ return `${parent}, ${additions.join(', ')}`;
96
+ }
97
+
98
+ // ─── Delta extraction from critique ──────────────────────────────
99
+
100
+ /**
101
+ * Extract a refine delta from a critique report + picked candidate.
102
+ *
103
+ * @param {Object} critique — critique report
104
+ * @param {string} pickedFilename — filename of the picked candidate
105
+ * @param {Object} cliOverrides — { preserve: string[], push: string[], suppress: string[] }
106
+ * @returns {{ preserve: string[], push: string[], suppress: string[] }}
107
+ */
108
+ export function extractDelta(critique, pickedFilename, cliOverrides = {}) {
109
+ // Find the picked candidate in the critique
110
+ const picked = critique.candidates.find(c => c.filename === pickedFilename);
111
+ if (!picked) {
112
+ throw inputError(
113
+ 'REFINE_CANDIDATE_NOT_FOUND',
114
+ `Candidate "${pickedFilename}" not found in critique for run "${critique.run_id}"`,
115
+ `Available candidates: ${critique.candidates.map(c => c.filename).join(', ')}`
116
+ );
117
+ }
118
+
119
+ // Start with critique-derived instructions
120
+ const preserve = [...(picked.preserve_next_pass || [])];
121
+ const push = [];
122
+ const suppress = [];
123
+
124
+ // Derive push from drift issues that need correction
125
+ for (const issue of picked.correct_next_pass || []) {
126
+ push.push(issue);
127
+ }
128
+
129
+ // Derive suppress from drift issues
130
+ for (const drift of picked.drift_issues || []) {
131
+ // Strip "check:" and "negative check:" prefixes for cleaner suppress
132
+ const cleaned = drift
133
+ .replace(/^\[.*?\]\s*check:\s*/i, '')
134
+ .replace(/^negative check:\s*/i, '')
135
+ .trim();
136
+ if (cleaned) suppress.push(cleaned);
137
+ }
138
+
139
+ // CLI overrides — append without duplicating
140
+ if (cliOverrides.preserve) {
141
+ for (const p of cliOverrides.preserve) {
142
+ if (!preserve.includes(p)) preserve.push(p);
143
+ }
144
+ }
145
+ if (cliOverrides.push) {
146
+ for (const p of cliOverrides.push) {
147
+ if (!push.includes(p)) push.push(p);
148
+ }
149
+ }
150
+ if (cliOverrides.suppress) {
151
+ for (const s of cliOverrides.suppress) {
152
+ if (!suppress.includes(s)) suppress.push(s);
153
+ }
154
+ }
155
+
156
+ return { preserve, push, suppress };
157
+ }
158
+
159
+ // ─── Build refined brief ─────────────────────────────────────────
160
+
161
+ /**
162
+ * Build a refined brief from parent brief, critique, and delta.
163
+ *
164
+ * @param {Object} opts
165
+ * @param {Object} opts.parentBrief — original compiled brief
166
+ * @param {Object} opts.critique — critique report
167
+ * @param {string} opts.pickedCandidate — filename of selected candidate
168
+ * @param {Object} opts.delta — { preserve, push, suppress }
169
+ * @param {string} opts.runId — source run ID
170
+ * @param {string} opts.refineDir — path to write refined brief
171
+ * @returns {Object} refined brief
172
+ */
173
+ export function buildRefinedBrief({ parentBrief, critique, pickedCandidate, delta, runId, refineDir }) {
174
+ const briefId = generateRefinedBriefId(refineDir, parentBrief.brief_id);
175
+
176
+ // Render updated prompts — PB-007 guards missing fields upstream.
177
+ const prompt = renderRefinedPrompt(parentBrief.prompt ?? '', delta);
178
+ const negativePrompt = renderRefinedNegative(parentBrief.negative_prompt ?? '', delta);
179
+
180
+ const refined = {
181
+ brief_id: briefId,
182
+ parent_brief_id: parentBrief.brief_id,
183
+ source_run_id: runId,
184
+ source_candidate: pickedCandidate,
185
+
186
+ project_id: parentBrief.project_id,
187
+ workflow_id: parentBrief.workflow_id,
188
+ lane_id: parentBrief.lane_id,
189
+ compiled_at: new Date().toISOString(),
190
+ config_fingerprint: parentBrief.config_fingerprint,
191
+
192
+ prompt,
193
+ negative_prompt: negativePrompt,
194
+
195
+ refine_delta: delta,
196
+
197
+ // Carry forward from parent
198
+ canon_constraints: parentBrief.canon_constraints || [],
199
+ drift_warnings: parentBrief.drift_warnings || [],
200
+ runtime_plan: { ...parentBrief.runtime_plan },
201
+ expected_outputs: { ...parentBrief.expected_outputs },
202
+ };
203
+
204
+ // Optional fields
205
+ if (parentBrief.subject_id) refined.subject_id = parentBrief.subject_id;
206
+ if (parentBrief.subject_constraints) refined.subject_constraints = parentBrief.subject_constraints;
207
+ if (parentBrief.reference_selection) refined.reference_selection = parentBrief.reference_selection;
208
+
209
+ return refined;
210
+ }
211
+
212
+ // ─── Markdown renderer ──────────────────────────────────────────
213
+
214
+ /**
215
+ * Render a refined brief as Markdown.
216
+ */
217
+ export function renderRefinedBriefMarkdown(brief) {
218
+ const lines = [];
219
+
220
+ lines.push(`# Refined Brief: ${brief.brief_id}`);
221
+ lines.push('');
222
+ lines.push(`**Parent Brief:** ${brief.parent_brief_id}`);
223
+ lines.push(`**Source Run:** ${brief.source_run_id}`);
224
+ lines.push(`**Picked Candidate:** ${brief.source_candidate}`);
225
+ lines.push('');
226
+ lines.push(`**Workflow:** ${brief.workflow_id}`);
227
+ lines.push(`**Project:** ${brief.project_id}`);
228
+ lines.push(`**Lane:** ${brief.lane_id}`);
229
+ if (brief.subject_id) lines.push(`**Subject:** ${brief.subject_id}`);
230
+ lines.push('');
231
+
232
+ // Refine delta — the key section
233
+ lines.push('## Refine Delta');
234
+ lines.push('');
235
+
236
+ if (brief.refine_delta?.preserve?.length > 0) {
237
+ lines.push('### Preserve');
238
+ for (const p of brief.refine_delta.preserve) lines.push(`- ✓ ${p}`);
239
+ lines.push('');
240
+ }
241
+
242
+ if (brief.refine_delta?.push?.length > 0) {
243
+ lines.push('### Push');
244
+ for (const p of brief.refine_delta.push) lines.push(`- → ${p}`);
245
+ lines.push('');
246
+ }
247
+
248
+ if (brief.refine_delta?.suppress?.length > 0) {
249
+ lines.push('### Suppress');
250
+ for (const s of brief.refine_delta.suppress) lines.push(`- ✗ ${s}`);
251
+ lines.push('');
252
+ }
253
+
254
+ // Prompt
255
+ lines.push('## Prompt');
256
+ lines.push('');
257
+ lines.push('```');
258
+ lines.push(brief.prompt);
259
+ lines.push('```');
260
+ lines.push('');
261
+
262
+ // Negative prompt
263
+ lines.push('## Negative Prompt');
264
+ lines.push('');
265
+ lines.push('```');
266
+ lines.push(brief.negative_prompt);
267
+ lines.push('```');
268
+ lines.push('');
269
+
270
+ // Canon constraints
271
+ if (brief.canon_constraints?.length > 0) {
272
+ lines.push('## Canon Constraints');
273
+ lines.push('');
274
+ for (const c of brief.canon_constraints) lines.push(`- ${c}`);
275
+ lines.push('');
276
+ }
277
+
278
+ // Drift warnings
279
+ if (brief.drift_warnings?.length > 0) {
280
+ lines.push('## Drift Warnings');
281
+ lines.push('');
282
+ for (const w of brief.drift_warnings) lines.push(`- ⚠ ${w}`);
283
+ lines.push('');
284
+ }
285
+
286
+ // Runtime plan
287
+ lines.push('## Runtime Plan');
288
+ lines.push('');
289
+ lines.push('| Parameter | Value |');
290
+ lines.push('|-----------|-------|');
291
+ const rp = brief.runtime_plan || {};
292
+ lines.push(`| Adapter | ${rp.adapter_target || '?'} |`);
293
+ lines.push(`| Width | ${rp.width || '?'} |`);
294
+ lines.push(`| Height | ${rp.height || '?'} |`);
295
+ lines.push(`| Steps | ${rp.steps || '?'} |`);
296
+ lines.push(`| CFG | ${rp.cfg || '?'} |`);
297
+ lines.push(`| Sampler | ${rp.sampler || '?'} |`);
298
+ lines.push(`| Seed Mode | ${rp.seed_mode || '?'} |`);
299
+ lines.push('');
300
+
301
+ // Expected outputs
302
+ lines.push('## Expected Outputs');
303
+ lines.push('');
304
+ const eo = brief.expected_outputs || {};
305
+ lines.push(`- **Mode:** ${eo.output_mode || '?'}`);
306
+ lines.push(`- **Count:** ${eo.output_count || '?'}`);
307
+ lines.push('');
308
+
309
+ // Metadata
310
+ lines.push('---');
311
+ lines.push('');
312
+ lines.push(`Compiled: ${brief.compiled_at}`);
313
+ lines.push(`Config fingerprint: \`${brief.config_fingerprint || '—'}\``);
314
+ lines.push('');
315
+
316
+ return lines.join('\n');
317
+ }
318
+
319
+ // ─── Save ────────────────────────────────────────────────────────
320
+
321
+ /**
322
+ * Save a refined brief to the run's refine directory.
323
+ */
324
+ export async function saveRefinedBrief(projectRoot, runId, refinedBrief) {
325
+ const refineDir = join(getRunsDir(projectRoot), runId, 'refine');
326
+ await mkdir(refineDir, { recursive: true });
327
+
328
+ const jsonPath = join(refineDir, `${refinedBrief.brief_id}.json`);
329
+ await writeFile(jsonPath, JSON.stringify(refinedBrief, null, 2) + '\n');
330
+
331
+ const md = renderRefinedBriefMarkdown(refinedBrief);
332
+ const mdPath = join(refineDir, `${refinedBrief.brief_id}.md`);
333
+ await writeFile(mdPath, md);
334
+
335
+ return { jsonPath, mdPath, refineDir };
336
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Re-ingest selected outputs as new candidate records.
3
+ *
4
+ * Selected outputs re-enter the project as generated candidates
5
+ * with full provenance. They go through the same review and
6
+ * canon-binding flow as everything else. No bypass.
7
+ */
8
+
9
+ import { writeFile, mkdir, copyFile } from 'node:fs/promises';
10
+ import { join, extname } from 'node:path';
11
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
12
+ import { getRunsDir } from './paths.js';
13
+ import { getBatchesDir } from './batch-runs.js';
14
+ import { getSelectionsDir, loadSelection } from './selections.js';
15
+ import { buildGeneratedProvenance } from './generated-provenance.js';
16
+ import { buildBaseRecord } from './records.js';
17
+ import { inputError } from './errors.js';
18
+
19
+ /**
20
+ * Re-ingest a selection — creates candidate records from chosen outputs.
21
+ *
22
+ * @param {Object} opts
23
+ * @param {string} opts.projectRoot
24
+ * @param {string} opts.projectId
25
+ * @param {string} opts.selectionId
26
+ * @param {boolean} [opts.dryRun]
27
+ * @returns {Promise<{created: string[], skipped: string[]}>}
28
+ */
29
+ export async function reingestSelection({ projectRoot, projectId, selectionId, dryRun }) {
30
+ const manifest = loadSelection(projectRoot, selectionId);
31
+
32
+ if (!manifest.reingest_ready) {
33
+ throw inputError(
34
+ 'SELECTION_NOT_READY',
35
+ `Selection "${selectionId}" is not marked reingest_ready`,
36
+ 'Set reingest_ready: true in the selection manifest.',
37
+ );
38
+ }
39
+
40
+ const selectionDir = join(getSelectionsDir(projectRoot), selectionId);
41
+ const recordsDir = join(projectRoot, 'records');
42
+ const inboxDir = join(projectRoot, 'inbox', 'generated');
43
+
44
+ if (!dryRun) {
45
+ await mkdir(recordsDir, { recursive: true });
46
+ await mkdir(inboxDir, { recursive: true });
47
+ }
48
+
49
+ // Load source manifests for provenance
50
+ const runsDir = getRunsDir(projectRoot);
51
+ const runManifestCache = new Map();
52
+
53
+ function getRunManifest(runId) {
54
+ if (runManifestCache.has(runId)) return runManifestCache.get(runId);
55
+ const mPath = join(runsDir, runId, 'manifest.json');
56
+ if (!existsSync(mPath)) return null;
57
+ const m = JSON.parse(readFileSync(mPath, 'utf-8'));
58
+ runManifestCache.set(runId, m);
59
+ return m;
60
+ }
61
+
62
+ let batchManifest = null;
63
+ if (manifest.source_type === 'batch') {
64
+ const bmPath = join(getBatchesDir(projectRoot), manifest.source_id, 'manifest.json');
65
+ if (existsSync(bmPath)) {
66
+ batchManifest = JSON.parse(readFileSync(bmPath, 'utf-8'));
67
+ }
68
+ }
69
+
70
+ const created = [];
71
+ const skipped = [];
72
+ const provenanceLines = [];
73
+
74
+ // D-010: use entries() — not indexOf() — to avoid O(n^2) behavior and
75
+ // silent duplicate-ID bugs when manifest.items contains structurally
76
+ // equal or repeated references. Assert uniqueness of generated IDs.
77
+ const seenIds = new Set();
78
+ for (const [i, item] of manifest.items.entries()) {
79
+ const idx = String(i + 1).padStart(3, '0');
80
+ const recordId = `gen_${selectionId}_${idx}`;
81
+ if (seenIds.has(recordId)) {
82
+ throw inputError(
83
+ 'REINGEST_DUPLICATE_RECORD_ID',
84
+ `Duplicate record ID generated during re-ingest: ${recordId}`,
85
+ 'This indicates a bug in record ID derivation; report this along with the selection manifest.',
86
+ );
87
+ }
88
+ seenIds.add(recordId);
89
+ const recordPath = join(recordsDir, `${recordId}.json`);
90
+
91
+ if (existsSync(recordPath)) {
92
+ skipped.push(recordId);
93
+ continue;
94
+ }
95
+
96
+ // Resolve the run manifest for this item
97
+ let runManifest;
98
+ if (manifest.source_type === 'run') {
99
+ runManifest = getRunManifest(manifest.source_id);
100
+ } else if (manifest.source_type === 'batch' && batchManifest) {
101
+ // Find which run this slot belongs to
102
+ const slotId = item.slot_or_output.split(':')[0];
103
+ const slot = batchManifest.slots?.find(s => s.slot_id === slotId);
104
+ if (slot) {
105
+ runManifest = getRunManifest(slot.run_id);
106
+ }
107
+ }
108
+
109
+ if (!runManifest) {
110
+ // Fallback: minimal provenance
111
+ runManifest = {
112
+ run_id: manifest.source_id,
113
+ brief_id: 'unknown',
114
+ workflow_template_id: manifest.workflow_id || 'unknown',
115
+ created_at: manifest.created_at,
116
+ };
117
+ }
118
+
119
+ // Build provenance
120
+ const prov = buildGeneratedProvenance({
121
+ sourceType: manifest.source_type,
122
+ sourceId: manifest.source_id,
123
+ runManifest,
124
+ batchManifest,
125
+ item,
126
+ projectRoot,
127
+ });
128
+
129
+ // Image source in selection/chosen/
130
+ const srcImage = join(selectionDir, 'chosen', item.filename);
131
+ const ext = extname(item.filename) || '.png';
132
+ const destImage = `${recordId}${ext}`;
133
+ const inboxPath = join(inboxDir, destImage);
134
+ const assetPath = `inbox/generated/${destImage}`;
135
+
136
+ // Get file size
137
+ let fileBytes = 0;
138
+ if (existsSync(srcImage)) {
139
+ fileBytes = statSync(srcImage).size;
140
+ }
141
+
142
+ // Build record
143
+ const record = buildBaseRecord(recordId, assetPath, {
144
+ width: null,
145
+ height: null,
146
+ bytes: fileBytes,
147
+ }, {
148
+ source: 'generated',
149
+ selection_id: selectionId,
150
+ generation_provenance: prov,
151
+ });
152
+
153
+ record.schema_version = '2.2.0';
154
+
155
+ // Tags
156
+ const recordTags = ['generated', 'selected'];
157
+ if (manifest.workflow_id) recordTags.push(manifest.workflow_id);
158
+ if (manifest.subject_id) recordTags.push(manifest.subject_id);
159
+ if (item.tags?.length) recordTags.push(...item.tags);
160
+ record.tags = [...new Set(recordTags)];
161
+
162
+ if (!dryRun) {
163
+ // Copy image into inbox
164
+ if (existsSync(srcImage)) {
165
+ await copyFile(srcImage, inboxPath);
166
+ }
167
+ // Write record
168
+ await writeFile(recordPath, JSON.stringify(record, null, 2) + '\n');
169
+ }
170
+
171
+ created.push(recordId);
172
+ provenanceLines.push(JSON.stringify({ record_id: recordId, ...prov }));
173
+ }
174
+
175
+ // Write provenance log
176
+ if (!dryRun && provenanceLines.length > 0) {
177
+ await writeFile(
178
+ join(selectionDir, 'provenance.jsonl'),
179
+ provenanceLines.join('\n') + '\n',
180
+ );
181
+ }
182
+
183
+ return { created, skipped };
184
+ }