@mcptoolshop/style-dataset-lab 2.0.0 → 2.2.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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Implementation example packs.
3
+ *
4
+ * Shows how to use the trained asset in practice: prompt examples,
5
+ * lane-targeted examples, subject continuity examples, known failure
6
+ * cases, and re-ingest guidance.
7
+ *
8
+ * Every implementation pack is tied to a training manifest.
9
+ */
10
+
11
+ import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { existsSync } from 'node:fs';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { loadTrainingManifest } from './training-manifests.js';
16
+ import { loadTrainingProfile } from './training-profiles.js';
17
+ import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
+ import { loadSplitPartition } from './split.js';
19
+ import { loadRecord } from './records.js';
20
+
21
+ function generateImplId() {
22
+ const now = new Date();
23
+ const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
24
+ const rand = randomBytes(2).toString('hex');
25
+ return `impl-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
26
+ }
27
+
28
+ /**
29
+ * Build an implementation pack from a training manifest.
30
+ *
31
+ * @param {string} projectRoot
32
+ * @param {string} manifestId
33
+ * @returns {Promise<{implId: string, prompts: number, failures: number, subjects: number}>}
34
+ */
35
+ export async function buildImplementationPack(projectRoot, manifestId) {
36
+ const manifest = await loadTrainingManifest(projectRoot, manifestId);
37
+ const profile = await loadTrainingProfile(projectRoot, manifest.training_profile_id);
38
+ const config = loadProjectConfig(projectRoot);
39
+ const recordsDir = join(projectRoot, 'records');
40
+
41
+ // Load test partition records (best for implementation examples)
42
+ const testEntries = await loadSplitPartition(projectRoot, manifest.source_split_id, 'test');
43
+
44
+ // Also load some train records for broader coverage
45
+ const trainEntries = await loadSplitPartition(projectRoot, manifest.source_split_id, 'train');
46
+
47
+ // Build prompt examples — per-lane best records
48
+ const promptExamples = [];
49
+ const laneExamples = new Map();
50
+
51
+ for (const entry of [...testEntries, ...trainEntries.slice(0, 50)]) {
52
+ const record = await loadRecord(recordsDir, entry.record_id);
53
+ if (!record) continue;
54
+ if (record.judgment?.status !== 'approved') continue;
55
+
56
+ const prompt = record.provenance?.prompt || '';
57
+ const lane = detectLane(record.id, prompt, config.lanes);
58
+ const group = detectGroup(record.id, prompt, config.terminology);
59
+
60
+ // Filter by profile lanes
61
+ if (profile.eligible_lanes.length > 0 && !profile.eligible_lanes.includes(lane)) continue;
62
+
63
+ if (!laneExamples.has(lane)) laneExamples.set(lane, []);
64
+ if (laneExamples.get(lane).length < 3) {
65
+ laneExamples.get(lane).push(record);
66
+
67
+ const passRatio = record.canon?.assertion_count > 0
68
+ ? record.canon.pass_count / record.canon.assertion_count : 0;
69
+
70
+ promptExamples.push({
71
+ record_id: record.id,
72
+ lane,
73
+ group: group || 'unknown',
74
+ prompt: prompt.slice(0, 500),
75
+ pass_ratio: +passRatio.toFixed(3),
76
+ asset_path: record.asset_path,
77
+ usage_note: `Use as ${lane} reference for ${group || 'general'} style`,
78
+ });
79
+ }
80
+ }
81
+
82
+ // Build known failure cases from rejected records
83
+ const knownFailures = [];
84
+ const allFiles = (await readdir(recordsDir)).filter(f => f.endsWith('.json')).sort();
85
+ let failCount = 0;
86
+
87
+ for (const file of allFiles) {
88
+ if (failCount >= 10) break;
89
+ const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
90
+ if (record.judgment?.status !== 'rejected') continue;
91
+
92
+ const prompt = record.provenance?.prompt || '';
93
+ const lane = detectLane(record.id, prompt, config.lanes);
94
+ if (profile.eligible_lanes.length > 0 && !profile.eligible_lanes.includes(lane)) continue;
95
+
96
+ const failureModes = record.judgment?.failure_modes || [];
97
+ if (failureModes.length === 0 && !record.judgment?.explanation) continue;
98
+
99
+ knownFailures.push({
100
+ record_id: record.id,
101
+ lane,
102
+ failure_modes: failureModes,
103
+ explanation: record.judgment?.explanation || null,
104
+ lesson: `Avoid: ${failureModes.join(', ') || record.judgment?.explanation?.slice(0, 100)}`,
105
+ });
106
+ failCount++;
107
+ }
108
+
109
+ // Build subject continuity examples
110
+ const subjectExamples = [];
111
+ const bySubject = new Map();
112
+
113
+ for (const entry of [...testEntries, ...trainEntries]) {
114
+ const record = await loadRecord(recordsDir, entry.record_id);
115
+ if (!record?.identity?.subject_name) continue;
116
+ const name = record.identity.subject_name;
117
+ if (!bySubject.has(name)) bySubject.set(name, []);
118
+ bySubject.get(name).push(record);
119
+ }
120
+
121
+ for (const [name, records] of [...bySubject].sort((a, b) => b[1].length - a[1].length).slice(0, 5)) {
122
+ subjectExamples.push({
123
+ subject_name: name,
124
+ record_count: records.length,
125
+ record_ids: records.map(r => r.id).sort(),
126
+ faction: records[0].identity?.faction || null,
127
+ note: `${records.length} views of ${name} — use to verify identity consistency`,
128
+ });
129
+ }
130
+
131
+ // Build expected behaviors
132
+ const expectedBehaviors = {
133
+ style_markers: buildStyleMarkers(profile, config),
134
+ forbidden_patterns: buildForbiddenPatterns(config),
135
+ quality_thresholds: config.rubric.thresholds || {},
136
+ };
137
+
138
+ // Write implementation pack
139
+ const implId = generateImplId();
140
+ const implDir = join(projectRoot, 'training', 'implementations', implId);
141
+ await mkdir(implDir, { recursive: true });
142
+
143
+ const implManifest = {
144
+ implementation_pack_id: implId,
145
+ created_at: new Date().toISOString(),
146
+ created_by: 'sdlab-implementation-v1',
147
+ training_manifest_id: manifestId,
148
+ training_profile_id: manifest.training_profile_id,
149
+ counts: {
150
+ prompt_examples: promptExamples.length,
151
+ known_failures: knownFailures.length,
152
+ subject_examples: subjectExamples.length,
153
+ lanes_covered: laneExamples.size,
154
+ },
155
+ };
156
+
157
+ await writeFile(join(implDir, 'manifest.json'), JSON.stringify(implManifest, null, 2) + '\n');
158
+ await writeFile(join(implDir, 'prompts.jsonl'), promptExamples.map(p => JSON.stringify(p)).join('\n') + '\n');
159
+ await writeFile(join(implDir, 'known-failures.json'), JSON.stringify(knownFailures, null, 2) + '\n');
160
+ await writeFile(join(implDir, 'expected-behaviors.json'), JSON.stringify(expectedBehaviors, null, 2) + '\n');
161
+
162
+ if (subjectExamples.length > 0) {
163
+ await writeFile(join(implDir, 'subject-continuity.json'), JSON.stringify(subjectExamples, null, 2) + '\n');
164
+ }
165
+
166
+ // Write eval tasks JSONL — tasks that can be run against generated outputs
167
+ const evalTasks = promptExamples.map(p => ({
168
+ task: 'generate_and_compare',
169
+ prompt: p.prompt,
170
+ lane: p.lane,
171
+ reference_record: p.record_id,
172
+ expected: 'style-consistent output matching canon',
173
+ }));
174
+ await writeFile(join(implDir, 'eval-tasks.jsonl'), evalTasks.map(t => JSON.stringify(t)).join('\n') + '\n');
175
+
176
+ // Write reingest guide
177
+ const reingestGuide = generateReingestGuide(profile, manifest);
178
+ await writeFile(join(implDir, 'reingest-guide.md'), reingestGuide);
179
+
180
+ // Write README
181
+ const readme = generateImplReadme(implManifest, profile, manifest);
182
+ await writeFile(join(implDir, 'README.md'), readme);
183
+
184
+ return {
185
+ implId,
186
+ prompts: promptExamples.length,
187
+ failures: knownFailures.length,
188
+ subjects: subjectExamples.length,
189
+ };
190
+ }
191
+
192
+ function buildStyleMarkers(profile, config) {
193
+ const markers = [];
194
+ const constitution = config.constitution;
195
+ if (constitution?.rules) {
196
+ for (const rule of constitution.rules.slice(0, 10)) {
197
+ markers.push({ rule_id: rule.id, summary: rule.summary || rule.id });
198
+ }
199
+ }
200
+ return markers;
201
+ }
202
+
203
+ function buildForbiddenPatterns(config) {
204
+ const forbidden = [];
205
+ const rubric = config.rubric;
206
+ if (rubric?.failure_to_rules) {
207
+ for (const [mode, rules] of Object.entries(rubric.failure_to_rules)) {
208
+ forbidden.push({ failure_mode: mode, linked_rules: rules });
209
+ }
210
+ }
211
+ return forbidden;
212
+ }
213
+
214
+ function generateReingestGuide(profile, manifest) {
215
+ return `# Re-ingest Guide
216
+
217
+ ## When to re-ingest
218
+
219
+ After using the trained ${profile.asset_type} (${profile.label}) to generate new images,
220
+ accepted outputs should be re-ingested into the project as new records.
221
+
222
+ ## How to re-ingest
223
+
224
+ \`\`\`bash
225
+ sdlab reingest generated --project <name> --source <outputs-dir>
226
+ \`\`\`
227
+
228
+ ## Rules
229
+
230
+ 1. Generated outputs enter as new records with \`provenance.source: "generated"\`
231
+ 2. They must go through normal curation (\`sdlab curate\`)
232
+ 3. They must be canon-bound (\`sdlab bind\`)
233
+ 4. No bypass around review — generated work is judged like everything else
234
+ 5. Accepted re-ingested records become eligible for future snapshots
235
+
236
+ ## Provenance fields
237
+
238
+ Re-ingested records carry:
239
+
240
+ - \`provenance.source\`: \`"generated"\`
241
+ - \`provenance.training_manifest_id\`: \`"${manifest.training_manifest_id}"\`
242
+ - \`provenance.training_profile_id\`: \`"${manifest.training_profile_id}"\`
243
+ - \`provenance.adapter_target\`: \`"${manifest.adapter_target}"\`
244
+ - \`provenance.base_model\`: \`"${manifest.base_model || '(not specified)'}"\`
245
+ `;
246
+ }
247
+
248
+ function generateImplReadme(impl, profile, manifest) {
249
+ return `# Implementation Pack: ${impl.implementation_pack_id}
250
+
251
+ **Profile:** ${profile.label} (${profile.profile_id})
252
+ **Training manifest:** ${manifest.training_manifest_id}
253
+ **Created:** ${impl.created_at}
254
+
255
+ ## Contents
256
+
257
+ | File | Description |
258
+ |------|-------------|
259
+ | prompts.jsonl | ${impl.counts.prompt_examples} prompt examples across ${impl.counts.lanes_covered} lanes |
260
+ | known-failures.json | ${impl.counts.known_failures} documented failure cases to avoid |
261
+ | expected-behaviors.json | Style markers and forbidden patterns from constitution |
262
+ | subject-continuity.json | ${impl.counts.subject_examples} named-subject groups for identity testing |
263
+ | eval-tasks.jsonl | Generate-and-compare tasks for automated evaluation |
264
+ | reingest-guide.md | How to re-ingest accepted generated outputs |
265
+
266
+ ## Usage
267
+
268
+ 1. Load the trained ${profile.asset_type} with the prompt strategy from the profile
269
+ 2. Use prompts.jsonl as starting points for each lane
270
+ 3. Check generated outputs against expected-behaviors.json
271
+ 4. Compare with known-failures.json to catch common drift
272
+ 5. For named subjects, verify against subject-continuity.json
273
+ 6. Re-ingest accepted outputs per reingest-guide.md
274
+ `;
275
+ }
276
+
277
+ /**
278
+ * List all implementation packs.
279
+ */
280
+ export async function listImplementationPacks(projectRoot) {
281
+ const dir = join(projectRoot, 'training', 'implementations');
282
+ if (!existsSync(dir)) return [];
283
+
284
+ const entries = await readdir(dir, { withFileTypes: true });
285
+ const packs = [];
286
+
287
+ for (const entry of entries) {
288
+ if (!entry.isDirectory() || !entry.name.startsWith('impl-')) continue;
289
+ const manifestPath = join(dir, entry.name, 'manifest.json');
290
+ if (!existsSync(manifestPath)) continue;
291
+ try {
292
+ const data = JSON.parse(await readFile(manifestPath, 'utf-8'));
293
+ packs.push({
294
+ id: data.implementation_pack_id,
295
+ created_at: data.created_at,
296
+ manifest: data.training_manifest_id,
297
+ profile: data.training_profile_id,
298
+ prompts: data.counts.prompt_examples,
299
+ failures: data.counts.known_failures,
300
+ });
301
+ } catch { /* skip */ }
302
+ }
303
+
304
+ return packs.sort((a, b) => a.created_at.localeCompare(b.created_at));
305
+ }
306
+
307
+ /**
308
+ * Load an implementation pack manifest.
309
+ */
310
+ export async function loadImplementationPack(projectRoot, implId) {
311
+ const path = join(projectRoot, 'training', 'implementations', implId, 'manifest.json');
312
+ if (!existsSync(path)) {
313
+ throw new Error(`Implementation pack "${implId}" not found at ${path}`);
314
+ }
315
+ return JSON.parse(await readFile(path, 'utf-8'));
316
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Re-ingest engine for generated outputs.
3
+ *
4
+ * Accepted generated outputs re-enter the project as new records
5
+ * with provenance fields linking back to the training manifest.
6
+ * No bypass around review — generated work must be curated and
7
+ * canon-bound like everything else.
8
+ */
9
+
10
+ import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
11
+ import { join, basename, extname } from 'node:path';
12
+ import { existsSync } from 'node:fs';
13
+ import { loadTrainingManifest } from './training-manifests.js';
14
+ import { buildBaseRecord } from './records.js';
15
+
16
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
17
+
18
+ /**
19
+ * Re-ingest generated outputs from a directory into the project.
20
+ *
21
+ * Each image in the source directory becomes a new record with:
22
+ * - provenance.source = "generated"
23
+ * - provenance.training_manifest_id = <manifest-id>
24
+ * - judgment = null (must be reviewed)
25
+ * - canon = null (must be bound)
26
+ *
27
+ * @param {string} projectRoot
28
+ * @param {string} sourcePath — directory containing generated images
29
+ * @param {string} manifestId — training manifest that produced them
30
+ * @param {Object} options — { dryRun? }
31
+ * @returns {Promise<{created: number, skipped: number, records: string[]}>}
32
+ */
33
+ export async function reingestGenerated(projectRoot, sourcePath, manifestId, options = {}) {
34
+ const manifest = await loadTrainingManifest(projectRoot, manifestId);
35
+ const recordsDir = join(projectRoot, 'records');
36
+
37
+ // Find image files in source directory
38
+ const entries = await readdir(sourcePath);
39
+ const imageFiles = entries
40
+ .filter(f => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
41
+ .sort();
42
+
43
+ const created = [];
44
+ const skipped = [];
45
+
46
+ for (const file of imageFiles) {
47
+ const stem = basename(file, extname(file));
48
+ const recordId = `gen_${stem}`;
49
+ const recordPath = join(recordsDir, `${recordId}.json`);
50
+
51
+ // Skip if record already exists
52
+ if (existsSync(recordPath)) {
53
+ skipped.push(recordId);
54
+ continue;
55
+ }
56
+
57
+ const filePath = join(sourcePath, file);
58
+ const fileStat = await stat(filePath);
59
+
60
+ // Relative asset path from project root
61
+ const assetPath = `outputs/candidates/${file}`;
62
+
63
+ const provenance = {
64
+ source: 'generated',
65
+ training_manifest_id: manifestId,
66
+ training_profile_id: manifest.training_profile_id,
67
+ adapter_target: manifest.adapter_target,
68
+ base_model: manifest.base_model || null,
69
+ source_export_id: manifest.source_export_id,
70
+ generated_at: new Date().toISOString(),
71
+ original_path: filePath,
72
+ };
73
+
74
+ const record = buildBaseRecord(recordId, assetPath, {
75
+ width: null,
76
+ height: null,
77
+ bytes: fileStat.size,
78
+ }, provenance);
79
+
80
+ record.schema_version = '2.1.0';
81
+
82
+ if (!options.dryRun) {
83
+ await writeFile(recordPath, JSON.stringify(record, null, 2) + '\n');
84
+ }
85
+
86
+ created.push(recordId);
87
+ }
88
+
89
+ return { created: created.length, skipped: skipped.length, records: created };
90
+ }
91
+
92
+ /**
93
+ * Audit re-ingested records — check that they've been properly reviewed.
94
+ *
95
+ * @param {string} projectRoot
96
+ * @returns {Promise<{total: number, reviewed: number, unreviewed: number, bound: number, unbound: number}>}
97
+ */
98
+ export async function auditReingest(projectRoot) {
99
+ const recordsDir = join(projectRoot, 'records');
100
+ const files = (await readdir(recordsDir))
101
+ .filter(f => f.startsWith('gen_') && f.endsWith('.json'))
102
+ .sort();
103
+
104
+ let reviewed = 0;
105
+ let unreviewed = 0;
106
+ let bound = 0;
107
+ let unbound = 0;
108
+
109
+ for (const file of files) {
110
+ const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
111
+
112
+ if (record.judgment) {
113
+ reviewed++;
114
+ if (record.canon?.assertions?.length > 0) {
115
+ bound++;
116
+ } else {
117
+ unbound++;
118
+ }
119
+ } else {
120
+ unreviewed++;
121
+ }
122
+ }
123
+
124
+ return {
125
+ total: files.length,
126
+ reviewed,
127
+ unreviewed,
128
+ bound,
129
+ unbound,
130
+ };
131
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Snapshot creation and management.
3
+ *
4
+ * A snapshot is a frozen, versioned selection of records from a project
5
+ * at a point in time. Once created, it never silently changes.
6
+ */
7
+
8
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
9
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { createHash, randomBytes } from 'node:crypto';
12
+ import { loadProjectConfig, loadSelectionProfile, detectLane, detectGroup } from './config.js';
13
+ import { evaluateEligibility } from './eligibility.js';
14
+
15
+ /**
16
+ * Generate a snapshot ID: snap-YYYYMMDD-HHMMSS-XXXX
17
+ */
18
+ function generateSnapshotId() {
19
+ const now = new Date();
20
+ const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14); // YYYYMMDDHHMMSS
21
+ const rand = randomBytes(2).toString('hex');
22
+ return `snap-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
23
+ }
24
+
25
+ /**
26
+ * Compute config fingerprint — SHA-256 of all 5 config files concatenated.
27
+ * Deterministic: files read in alphabetical order.
28
+ */
29
+ export function computeConfigFingerprint(projectRoot) {
30
+ const configFiles = [
31
+ 'constitution.json',
32
+ 'lanes.json',
33
+ 'project.json',
34
+ 'rubric.json',
35
+ 'terminology.json',
36
+ ];
37
+
38
+ const hash = createHash('sha256');
39
+ for (const file of configFiles) {
40
+ const filePath = join(projectRoot, file);
41
+ if (existsSync(filePath)) {
42
+ hash.update(`--- ${file} ---\n`);
43
+ hash.update(readFileSync(filePath, 'utf-8'));
44
+ hash.update('\n');
45
+ }
46
+ }
47
+ return hash.digest('hex');
48
+ }
49
+
50
+ /**
51
+ * Create a snapshot — frozen selection of eligible records.
52
+ * @param {string} projectRoot — absolute path to project
53
+ * @param {Object} profile — selection profile
54
+ * @returns {Promise<{snapshotId: string, included: number, excluded: number}>}
55
+ */
56
+ export async function createSnapshot(projectRoot, profile) {
57
+ const config = loadProjectConfig(projectRoot);
58
+ const recordsDir = join(projectRoot, 'records');
59
+ const snapshotId = generateSnapshotId();
60
+ const snapshotDir = join(projectRoot, 'snapshots', snapshotId);
61
+
62
+ // Load all records (sorted for deterministic output)
63
+ const files = (await readdir(recordsDir))
64
+ .filter(f => f.endsWith('.json'))
65
+ .sort();
66
+
67
+ const included = [];
68
+ const excluded = [];
69
+ const laneDistribution = {};
70
+ const factionDistribution = {};
71
+ const verdictDistribution = { pass: 0, fail: 0, partial: 0 };
72
+
73
+ for (const file of files) {
74
+ const raw = await readFile(join(recordsDir, file), 'utf-8');
75
+ const record = JSON.parse(raw);
76
+
77
+ const { eligible, reasons } = evaluateEligibility(
78
+ record, profile, config.lanes, config.terminology
79
+ );
80
+
81
+ if (eligible) {
82
+ const prompt = record.provenance?.prompt || '';
83
+ const lane = detectLane(record.id, prompt, config.lanes);
84
+ const faction = detectGroup(record.id, prompt, config.terminology);
85
+
86
+ // Build reason trace
87
+ const passRatio = record.canon?.assertion_count > 0
88
+ ? `${record.canon.pass_count}/${record.canon.assertion_count}`
89
+ : 'n/a';
90
+ const reason = `${record.judgment.status}, canon-bound, pass_ratio=${passRatio}, lane=${lane}`;
91
+
92
+ included.push({ record_id: record.id, reason });
93
+
94
+ // Track distributions
95
+ laneDistribution[lane] = (laneDistribution[lane] || 0) + 1;
96
+ factionDistribution[faction || 'unknown'] = (factionDistribution[faction || 'unknown'] || 0) + 1;
97
+ if (record.canon?.assertions) {
98
+ for (const a of record.canon.assertions) {
99
+ verdictDistribution[a.verdict] = (verdictDistribution[a.verdict] || 0) + 1;
100
+ }
101
+ }
102
+ } else {
103
+ excluded.push({ record_id: record.id, reasons });
104
+ }
105
+ }
106
+
107
+ // Write snapshot files
108
+ await mkdir(snapshotDir, { recursive: true });
109
+
110
+ const manifest = {
111
+ snapshot_id: snapshotId,
112
+ created_at: new Date().toISOString(),
113
+ created_by: 'sdlab-snapshot-v1',
114
+ project_name: config.meta.name,
115
+ frozen: true,
116
+ selection_profile: profile,
117
+ config_fingerprint: computeConfigFingerprint(projectRoot),
118
+ config_versions: {
119
+ constitution: config.constitution.version || '1.0.0',
120
+ project: config.meta.version || '0.0.0',
121
+ },
122
+ counts: {
123
+ total_records: files.length,
124
+ evaluated: files.length,
125
+ included: included.length,
126
+ excluded: excluded.length,
127
+ },
128
+ };
129
+
130
+ const summary = {
131
+ snapshot_id: snapshotId,
132
+ lane_distribution: laneDistribution,
133
+ faction_distribution: factionDistribution,
134
+ verdict_distribution: verdictDistribution,
135
+ };
136
+
137
+ await writeFile(join(snapshotDir, 'snapshot.json'), JSON.stringify(manifest, null, 2) + '\n');
138
+ await writeFile(join(snapshotDir, 'included.jsonl'), included.map(r => JSON.stringify(r)).join('\n') + '\n');
139
+ await writeFile(join(snapshotDir, 'excluded.jsonl'), excluded.map(r => JSON.stringify(r)).join('\n') + '\n');
140
+ await writeFile(join(snapshotDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n');
141
+
142
+ return { snapshotId, included: included.length, excluded: excluded.length };
143
+ }
144
+
145
+ /**
146
+ * Load a snapshot manifest.
147
+ */
148
+ export async function loadSnapshot(projectRoot, snapshotId) {
149
+ const path = join(projectRoot, 'snapshots', snapshotId, 'snapshot.json');
150
+ if (!existsSync(path)) {
151
+ throw new Error(`Snapshot "${snapshotId}" not found at ${path}`);
152
+ }
153
+ return JSON.parse(await readFile(path, 'utf-8'));
154
+ }
155
+
156
+ /**
157
+ * Load included record IDs from a snapshot.
158
+ */
159
+ export async function loadSnapshotIncluded(projectRoot, snapshotId) {
160
+ const path = join(projectRoot, 'snapshots', snapshotId, 'included.jsonl');
161
+ const raw = await readFile(path, 'utf-8');
162
+ return raw.trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
163
+ }
164
+
165
+ /**
166
+ * List all snapshots in a project.
167
+ */
168
+ export async function listSnapshots(projectRoot) {
169
+ const snapshotsDir = join(projectRoot, 'snapshots');
170
+ if (!existsSync(snapshotsDir)) return [];
171
+
172
+ const entries = await readdir(snapshotsDir, { withFileTypes: true });
173
+ const snapshots = [];
174
+
175
+ for (const entry of entries) {
176
+ if (!entry.isDirectory() || !entry.name.startsWith('snap-')) continue;
177
+ try {
178
+ const manifest = JSON.parse(
179
+ await readFile(join(snapshotsDir, entry.name, 'snapshot.json'), 'utf-8')
180
+ );
181
+ snapshots.push({
182
+ id: manifest.snapshot_id,
183
+ created_at: manifest.created_at,
184
+ included: manifest.counts.included,
185
+ excluded: manifest.counts.excluded,
186
+ fingerprint: manifest.config_fingerprint.slice(0, 12),
187
+ });
188
+ } catch {
189
+ // Skip malformed snapshots
190
+ }
191
+ }
192
+
193
+ return snapshots.sort((a, b) => a.created_at.localeCompare(b.created_at));
194
+ }
195
+
196
+ /**
197
+ * Diff two snapshots — records added, removed, and shared.
198
+ */
199
+ export async function diffSnapshots(projectRoot, idA, idB) {
200
+ const includedA = await loadSnapshotIncluded(projectRoot, idA);
201
+ const includedB = await loadSnapshotIncluded(projectRoot, idB);
202
+
203
+ const setA = new Set(includedA.map(r => r.record_id));
204
+ const setB = new Set(includedB.map(r => r.record_id));
205
+
206
+ const added = [...setB].filter(id => !setA.has(id));
207
+ const removed = [...setA].filter(id => !setB.has(id));
208
+ const shared = [...setA].filter(id => setB.has(id));
209
+
210
+ return { added, removed, shared, countA: setA.size, countB: setB.size };
211
+ }