@mcptoolshop/style-dataset-lab 3.3.0 → 3.4.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 (92) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.es.md +35 -25
  3. package/README.fr.md +38 -28
  4. package/README.hi.md +36 -26
  5. package/README.it.md +37 -27
  6. package/README.ja.md +40 -30
  7. package/README.md +15 -5
  8. package/README.pt-BR.md +35 -25
  9. package/README.zh.md +33 -23
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +36 -2
  28. package/lib/eligibility.js +121 -9
  29. package/lib/eval-pack.js +14 -14
  30. package/lib/eval-runs.js +33 -0
  31. package/lib/export.js +122 -34
  32. package/lib/freeze-gate.js +13 -3
  33. package/lib/freeze-stamp.js +28 -3
  34. package/lib/generated-provenance.js +16 -1
  35. package/lib/implementation-packs.js +95 -17
  36. package/lib/ingest.js +396 -0
  37. package/lib/judgment-provenance.js +220 -0
  38. package/lib/measure.js +422 -0
  39. package/lib/paths.js +196 -7
  40. package/lib/reingest-selected.js +98 -16
  41. package/lib/run-manifest.js +102 -16
  42. package/lib/run-summary.js +12 -6
  43. package/lib/runtime-runs.js +45 -4
  44. package/lib/snapshot.js +114 -15
  45. package/lib/split.js +364 -48
  46. package/lib/training-manifests.js +114 -21
  47. package/lib/training-packages.js +147 -15
  48. package/lib/workflow-profiles.js +24 -3
  49. package/package.json +3 -2
  50. package/runtime/comfyui/txt2img-qwen.json +38 -0
  51. package/runtime/comfyui/txt2img-standard.json +21 -0
  52. package/scripts/_resume.js +62 -0
  53. package/scripts/_shared-args.js +44 -0
  54. package/scripts/brief-compile.js +2 -1
  55. package/scripts/brief-show.js +2 -1
  56. package/scripts/canon-bind.js +2 -2
  57. package/scripts/canon-build.js +27 -20
  58. package/scripts/canon-drift.js +16 -7
  59. package/scripts/canon-freeze-status.js +14 -6
  60. package/scripts/canon-freeze.js +27 -11
  61. package/scripts/canon-unfreeze.js +17 -9
  62. package/scripts/card.js +2 -2
  63. package/scripts/compare.js +2 -2
  64. package/scripts/critique.js +16 -1
  65. package/scripts/curate.js +2 -2
  66. package/scripts/doctor.js +181 -55
  67. package/scripts/eligibility.js +42 -2
  68. package/scripts/eval-pack.js +2 -2
  69. package/scripts/eval-run.js +2 -2
  70. package/scripts/export.js +2 -2
  71. package/scripts/generate-controlnet.js +45 -25
  72. package/scripts/generate-identity.js +53 -23
  73. package/scripts/generate-ipadapter.js +44 -23
  74. package/scripts/generate.js +48 -5
  75. package/scripts/implementation-pack.js +2 -2
  76. package/scripts/ingest.js +145 -0
  77. package/scripts/init.js +73 -13
  78. package/scripts/measure.js +170 -0
  79. package/scripts/measure_image.py +458 -0
  80. package/scripts/migrate-records.js +142 -0
  81. package/scripts/painterly-test.js +4 -3
  82. package/scripts/painterly.js +7 -3
  83. package/scripts/qwen_generate.py +138 -20
  84. package/scripts/reingest.js +2 -2
  85. package/scripts/sheet.js +142 -0
  86. package/scripts/snapshot.js +2 -2
  87. package/scripts/split.js +2 -2
  88. package/scripts/training-manifest.js +2 -2
  89. package/scripts/training-package.js +2 -2
  90. package/scripts/training-profile.js +2 -2
  91. package/scripts/workflow-list.js +2 -1
  92. package/scripts/workflow-show.js +2 -1
package/lib/export.js CHANGED
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs';
11
11
  import { platform } from 'node:os';
12
12
  import { createHash, randomBytes } from 'node:crypto';
13
13
  import { loadProjectConfig, loadExportProfile, detectLane, detectGroup } from './config.js';
14
- import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
14
+ import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
15
15
  import { loadSplit, loadSplitPartition } from './split.js';
16
16
  import { loadRecord } from './records.js';
17
17
  import { generateCard } from './card.js';
@@ -36,6 +36,63 @@ async function sha256File(filePath) {
36
36
  return createHash('sha256').update(data).digest('hex');
37
37
  }
38
38
 
39
+ /**
40
+ * SDL-H6: validate that a record-relative asset_path does not escape
41
+ * projectRoot. Rejects absolute paths outright (ASSET_PATH_INVALID) and
42
+ * rejects any relative path that resolves outside projectRoot
43
+ * (ASSET_PATH_TRAVERSAL — e.g. "../../../../Users/victim/.ssh/id_rsa").
44
+ * Returns the resolved absolute path on success.
45
+ *
46
+ * This was previously inlined here only (D-007) and never ported to the
47
+ * training-package adapters, which checked only `existsSync` — a
48
+ * traversal path satisfies existsSync just as well as a legitimate one.
49
+ * lib/training-packages.js and the adapters import this so the exact same
50
+ * guard (same error codes) applies on every path that turns asset_path
51
+ * into a filesystem read.
52
+ */
53
+ export function assertSafeAssetPath(assetPath, projectRoot, recordId) {
54
+ if (isAbsolute(assetPath)) {
55
+ throw inputError(
56
+ 'ASSET_PATH_INVALID',
57
+ `Record "${recordId}" has absolute asset_path: ${assetPath}`,
58
+ 'asset_path must be relative to the project root.',
59
+ );
60
+ }
61
+ const projectRootResolved = resolve(projectRoot);
62
+ const resolvedSource = resolve(projectRootResolved, assetPath);
63
+ if (resolvedSource !== projectRootResolved &&
64
+ !resolvedSource.startsWith(projectRootResolved + sep)) {
65
+ throw inputError(
66
+ 'ASSET_PATH_TRAVERSAL',
67
+ `Record "${recordId}" asset_path escapes projectRoot: ${assetPath}`,
68
+ 'Reject path traversal segments (e.g. "../") in asset_path.',
69
+ );
70
+ }
71
+ return resolvedSource;
72
+ }
73
+
74
+ /**
75
+ * SDL-H5: allowlist model for an id destined to become a filename or path
76
+ * segment (letters, digits, dot, underscore, hyphen only — no `/`, `\`,
77
+ * or `..`, so no traversal is expressible). Mirrors the character class
78
+ * lib/selections.js's SAFE_FILENAME_RE uses for approved-output filenames;
79
+ * that constant isn't exported and additionally requires an image
80
+ * extension suffix that doesn't apply to a bare record/entity id, so this
81
+ * is a parallel definition over the same allowlist model rather than a
82
+ * copy of a differently-shaped regex.
83
+ */
84
+ export const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/;
85
+
86
+ export function assertSafeId(id, context) {
87
+ if (typeof id !== 'string' || !SAFE_ID_RE.test(id)) {
88
+ throw inputError(
89
+ 'UNSAFE_RECORD_ID',
90
+ `Invalid id "${id}" in ${context}`,
91
+ 'ids must match [A-Za-z0-9._-]+ — letters, digits, dot, underscore, hyphen only.',
92
+ );
93
+ }
94
+ }
95
+
39
96
  /**
40
97
  * Build an export package.
41
98
  *
@@ -55,22 +112,12 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
55
112
  const included = await loadSnapshotIncluded(projectRoot, snapshotId);
56
113
  const recordsDir = join(projectRoot, 'records');
57
114
 
58
- const exportId = generateExportId();
59
- const exportDir = join(projectRoot, 'exports', exportId);
60
-
61
- // D-005: existsSync guard prevents silent overwrite on ID collision
62
- if (existsSync(exportDir)) {
63
- throw inputError(
64
- 'EXPORT_ID_COLLISION',
65
- `Export directory already exists: ${exportDir}`,
66
- 'Retry to generate a fresh export ID, or delete the existing directory if this was intentional.',
67
- );
68
- }
69
-
70
115
  // D-001: Verify fingerprint has not drifted since snapshot creation.
71
116
  // We inherit the snapshot's fingerprint in the export manifest; if a caller
72
117
  // has edited project config mid-flight we surface that loudly rather than
73
118
  // silently embedding a new fingerprint that masks reproducibility loss.
119
+ // (Checked BEFORE claiming an export id/dir below — no point reserving
120
+ // one if we're about to abort.)
74
121
  const currentFingerprint = computeConfigFingerprint(projectRoot);
75
122
  if (snapshot.config_fingerprint && currentFingerprint !== snapshot.config_fingerprint) {
76
123
  throw inputError(
@@ -80,18 +127,44 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
80
127
  );
81
128
  }
82
129
 
83
- await mkdir(exportDir, { recursive: true });
130
+ // SDL-M10: atomically claim the export id/dir — closes the
131
+ // check-then-act race the old existsSync+recursive-mkdir pattern had
132
+ // (D-005's intent, now actually race-proof; see claimIdDir in snapshot.js).
133
+ const { id: exportId, dir: exportDir } = await claimIdDir(
134
+ join(projectRoot, 'exports'),
135
+ generateExportId,
136
+ {
137
+ code: 'EXPORT_ID_COLLISION',
138
+ message: 'Could not claim a unique export ID — every candidate collided with an existing export directory.',
139
+ hint: 'Retry; if this persists, another sdlab process may be creating exports in a tight loop.',
140
+ },
141
+ );
84
142
 
85
143
  // Build metadata + collect image paths
86
144
  const metadata = [];
87
145
  const imagePaths = [];
88
146
  const checksumEntries = [];
147
+ // F3: a record the (frozen, Law-1) snapshot included whose file was
148
+ // later deleted from records/ must never silently drop the export's
149
+ // record count below snapshot.counts.included. loadRecord() returns
150
+ // null on ENOENT (lib/records.js) — previously that null was a silent
151
+ // `continue` with no counter, no warning, no id recorded, so an export
152
+ // built from a snapshot with stale/deleted records would just quietly
153
+ // contain fewer rows than snapshot.counts.included promised. Accumulated
154
+ // across the whole loop (not fail-fast on the first) so the operator
155
+ // sees every missing id in one error instead of fixing them one at a
156
+ // time; hard-failed below rather than warned — see the comment at the
157
+ // throw site for why export specifically earns that escalation.
158
+ const missingRecords = [];
89
159
 
90
160
  const projectRootResolved = resolve(projectRoot);
91
161
 
92
162
  for (const entry of included) {
93
163
  const record = await loadRecord(recordsDir, entry.record_id);
94
- if (!record) continue;
164
+ if (!record) {
165
+ missingRecords.push({ record_id: entry.record_id, stage: 'export' });
166
+ continue;
167
+ }
95
168
 
96
169
  const prompt = record.provenance?.prompt || '';
97
170
  const lane = detectLane(record.id, prompt, config.lanes);
@@ -115,28 +188,20 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
115
188
 
116
189
  // Track image for linking
117
190
  if (profile.include_images && record.asset_path) {
118
- // D-007: validate asset_path does not escape projectRoot, use
119
- // platform-safe basename, and name output files by record.id
120
- // (D-009) to avoid collisions on shared basenames.
121
- if (isAbsolute(record.asset_path)) {
122
- throw inputError(
123
- 'ASSET_PATH_INVALID',
124
- `Record "${record.id}" has absolute asset_path: ${record.asset_path}`,
125
- 'asset_path must be relative to the project root.',
126
- );
127
- }
128
- const resolvedSource = resolve(projectRootResolved, record.asset_path);
129
- if (resolvedSource !== projectRootResolved &&
130
- !resolvedSource.startsWith(projectRootResolved + sep)) {
131
- throw inputError(
132
- 'ASSET_PATH_TRAVERSAL',
133
- `Record "${record.id}" asset_path escapes projectRoot: ${record.asset_path}`,
134
- 'Reject path traversal segments (e.g. "../") in asset_path.',
135
- );
136
- }
191
+ // D-007 / SDL-H6: validate asset_path does not escape projectRoot.
192
+ const resolvedSource = assertSafeAssetPath(record.asset_path, projectRootResolved, record.id);
137
193
  const sourceBasename = basename(record.asset_path);
138
194
  const extMatch = sourceBasename.match(/\.[^.]+$/);
139
195
  const ext = extMatch ? extMatch[0] : '';
196
+ // SDL-H5: the block above only validated the SOURCE asset_path —
197
+ // record.id becomes part of the DESTINATION filename below and was
198
+ // completely unvalidated despite the variable being named
199
+ // "safeFilename". A record id of "../../../evil" would escape the
200
+ // export dir on write. Reachable without hand-authoring: reingest
201
+ // derives ids from generated-output filenames.
202
+ assertSafeId(record.id, `record.id for export image filename (record "${record.id}")`);
203
+ // D-009: name output files by record.id to avoid collisions on
204
+ // shared basenames.
140
205
  const safeFilename = `${record.id}${ext}`;
141
206
  imagePaths.push({
142
207
  record_id: record.id,
@@ -146,6 +211,29 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
146
211
  }
147
212
  }
148
213
 
214
+ // F3: hard-fail on any record the snapshot promised but records/ no
215
+ // longer has. Nothing has been written to exportDir yet (metadata.jsonl,
216
+ // images/, manifest.json, etc. are all written below this point) — an
217
+ // aborted export leaves only the empty claimed directory behind, no
218
+ // partial/misleading artifacts. Export (and training-packages.js one
219
+ // layer further downstream) is where Law 4 ("exports are reproducible —
220
+ // rebuildable from snapshot + split + profile") makes completeness an
221
+ // actual contract rather than a courtesy, which is why this escalates to
222
+ // a thrown error instead of the warn()-and-continue ledger pattern used
223
+ // in split.js and implementation-packs.js, where the artifact produced
224
+ // is not itself the reproducibility-guaranteeing one.
225
+ if (missingRecords.length > 0) {
226
+ const sample = missingRecords.slice(0, 10).map(m => m.record_id);
227
+ throw inputError(
228
+ 'EXPORT_MISSING_RECORDS',
229
+ `${missingRecords.length} of ${included.length} record(s) referenced by snapshot "${snapshotId}" could not be loaded from ${recordsDir} — this export would silently contain fewer records than the snapshot's included count.` +
230
+ (missingRecords.length > 10
231
+ ? ` First 10: ${sample.join(', ')}, ...`
232
+ : ` Missing: ${sample.join(', ')}.`),
233
+ 'Record file(s) were likely deleted after the snapshot was created (a snapshot freezes the SELECTION, not the underlying record files). Restore the missing file(s), or create a fresh snapshot that reflects current on-disk records before exporting.',
234
+ );
235
+ }
236
+
149
237
  // Write metadata.jsonl
150
238
  const metadataPath = join(exportDir, 'metadata.jsonl');
151
239
  await writeFile(metadataPath, metadata.map(r => JSON.stringify(r)).join('\n') + '\n');
@@ -26,6 +26,7 @@ import { loadCanonEntriesInDir, entryId } from './canon-build/load-entry.js';
26
26
  import { readFreezeStatus } from './freeze-stamp.js';
27
27
  import { appendEvent, readEventsFor } from './freeze-events.js';
28
28
  import { inputError } from './errors.js';
29
+ import { warn } from './log.js';
29
30
 
30
31
  /**
31
32
  * Best-effort freeze date for an entry: the `at` of its most recent freeze /
@@ -74,9 +75,18 @@ export async function loadAllCanonEntries(projectRoot) {
74
75
  let config;
75
76
  try {
76
77
  config = await loadBuildConfig(configPath, projectRoot);
77
- } catch {
78
- // Invalid config → degrade to "no gate" rather than blocking every write.
79
- // The operator will hit the config error on `sdlab canon build`.
78
+ } catch (err) {
79
+ // Invalid config → degrade to "no gate" rather than blocking every write
80
+ // (a config typo shouldn't turn into "no one can generate/curate/paint
81
+ // anything in this project"). But that degrade-open MUST be audible —
82
+ // silently disabling freeze protection project-wide with zero operator
83
+ // signal is its own defect. Warn loudly to stderr; the operator will
84
+ // also hit the same config error surfaced properly on `sdlab canon build`.
85
+ warn(
86
+ `freeze gate disabled for this project: canon-build/config.json is invalid ` +
87
+ `(${err.message}). Every entry is being treated as not canon-bound — freeze/soft-advisory ` +
88
+ `protection is OFF until the config is fixed. Run "sdlab canon build" to see the full error.`,
89
+ );
80
90
  return [];
81
91
  }
82
92
  const entries = [];
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { createHash } from 'node:crypto';
12
12
  import { getByPath } from './canon-build/load-config.js';
13
+ import { inputError } from './errors.js';
13
14
 
14
15
  /**
15
16
  * The four legal freeze statuses. Entries without a `freeze` block behave
@@ -19,13 +20,37 @@ export const FREEZE_STATUSES = Object.freeze(['auto', 'frozen', 'soft-advisory',
19
20
 
20
21
  /**
21
22
  * Read the normalized freeze status for an entry frontmatter.
22
- * Missing block / missing status / invalid value all collapse to `auto`.
23
+ *
24
+ * A missing `freeze` block, or a `freeze` block with no `status` key at all,
25
+ * collapses to `auto` — that permissive default is deliberate (D8): an entry
26
+ * that has never opted into freeze protection should never block a write.
27
+ *
28
+ * But a `freeze` block that IS present with a `status` value that is a
29
+ * non-empty string outside `FREEZE_STATUSES` is not "absent" — it is
30
+ * malformed. Coercing that case to `auto` too would silently disable hard
31
+ * freeze protection on a hand-edited typo (e.g. `status: Frozen` against the
32
+ * lowercase-only enum) with no error, no warning, and no bypass event, since
33
+ * the bypass path is never entered. Fail loud instead: throw so the operator
34
+ * fixes the typo rather than unknowingly shipping an unprotected entry.
35
+ *
36
+ * @throws {SdlabError} CANON_FREEZE_STATUS_INVALID when `freeze` is present
37
+ * and `status` is a non-empty string that isn't a legal FREEZE_STATUSES value.
23
38
  */
24
39
  export function readFreezeStatus(frontmatter) {
25
40
  const status = frontmatter?.freeze?.status;
26
41
  if (typeof status !== 'string') return 'auto';
27
- if (!FREEZE_STATUSES.includes(status)) return 'auto';
28
- return status;
42
+ if (FREEZE_STATUSES.includes(status)) return status;
43
+ if (status.length > 0) {
44
+ throw inputError(
45
+ 'CANON_FREEZE_STATUS_INVALID',
46
+ `Invalid freeze.status "${status}"${frontmatter?.id ? ` on entry "${frontmatter.id}"` : ''}: ` +
47
+ `not one of [${FREEZE_STATUSES.join(', ')}].`,
48
+ `Fix the freeze.status value (check for a case typo — statuses are lowercase-only). ` +
49
+ `A malformed status is treated as an error, not as "auto", so a typo can never ` +
50
+ `silently disable freeze protection.`,
51
+ );
52
+ }
53
+ return 'auto';
29
54
  }
30
55
 
31
56
  /**
@@ -9,6 +9,7 @@
9
9
  import { readFileSync, existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { createHash } from 'node:crypto';
12
+ import { warn } from './log.js';
12
13
 
13
14
  /**
14
15
  * Build a generation provenance payload for a selected output.
@@ -68,7 +69,21 @@ export function buildGeneratedProvenance({
68
69
  const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
69
70
  if (brief.subject_id) prov.subject_id = brief.subject_id;
70
71
  if (brief.parent_brief_id) prov.parent_brief_id = brief.parent_brief_id;
71
- } catch { /* skip */ }
72
+ } catch (err) {
73
+ // B08: the brief exists but couldn't be read/parsed (e.g. a
74
+ // concurrent mid-write) — lineage is genuinely UNKNOWN here, not
75
+ // merely absent. Surface it instead of silently shipping
76
+ // provenance with no lineage and no signal that anything failed.
77
+ warn(`generated-provenance: brief ${briefPath} could not be read (${err.message}) — lineage_incomplete`);
78
+ prov.lineage_incomplete = true;
79
+ }
80
+ } else {
81
+ // B08: no brief on disk at all for this run — the same "genuinely
82
+ // unknown" lineage state as an unreadable brief, not a normal
83
+ // "this brief simply has no subject_id" case (that one is NOT
84
+ // flagged — see the brief-read-fine-but-no-subject_id branch above).
85
+ warn(`generated-provenance: brief not found at ${briefPath} — lineage_incomplete`);
86
+ prov.lineage_incomplete = true;
72
87
  }
73
88
  }
74
89
 
@@ -8,7 +8,7 @@
8
8
  * Every implementation pack is tied to a training manifest.
9
9
  */
10
10
 
11
- import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
11
+ import { readFile, writeFile, readdir } from 'node:fs/promises';
12
12
  import { join } from 'node:path';
13
13
  import { existsSync } from 'node:fs';
14
14
  import { randomBytes } from 'node:crypto';
@@ -17,8 +17,8 @@ import { loadTrainingProfile } from './training-profiles.js';
17
17
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
18
  import { loadSplitPartition } from './split.js';
19
19
  import { loadRecord } from './records.js';
20
- import { inputError } from './errors.js';
21
- import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
20
+ import { SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
21
+ import { warn } from './log.js';
22
22
 
23
23
  function generateImplId() {
24
24
  const now = new Date();
@@ -46,13 +46,27 @@ export async function buildImplementationPack(projectRoot, manifestId) {
46
46
  // Also load some train records for broader coverage
47
47
  const trainEntries = await loadSplitPartition(projectRoot, manifest.source_split_id, 'train');
48
48
 
49
+ // F3: a record the split promised (via source_split_id's train/test
50
+ // partitions) whose file was later deleted from records/ must not
51
+ // silently drop out of the implementation pack with no counter, no
52
+ // warning, no id recorded. loadRecord() returns null on ENOENT
53
+ // (lib/records.js); previously `if (!record) continue` was the entire
54
+ // handling at both loops below. WARN + ledger (not hard-fail): an
55
+ // implementation pack is an illustrative/example artifact, not the
56
+ // Law-4 reproducibility-guaranteeing one (export.js and
57
+ // training-packages.js are, and hard-fail on the identical situation).
58
+ const missingRecords = [];
59
+
49
60
  // Build prompt examples — per-lane best records
50
61
  const promptExamples = [];
51
62
  const laneExamples = new Map();
52
63
 
53
64
  for (const entry of [...testEntries, ...trainEntries.slice(0, 50)]) {
54
65
  const record = await loadRecord(recordsDir, entry.record_id);
55
- if (!record) continue;
66
+ if (!record) {
67
+ missingRecords.push({ record_id: entry.record_id, stage: 'implementation-pack', source: 'prompt-examples' });
68
+ continue;
69
+ }
56
70
  if (record.judgment?.status !== 'approved') continue;
57
71
 
58
72
  const prompt = record.provenance?.prompt || '';
@@ -85,10 +99,26 @@ export async function buildImplementationPack(projectRoot, manifestId) {
85
99
  const knownFailures = [];
86
100
  const allFiles = (await readdir(recordsDir)).filter(f => f.endsWith('.json')).sort();
87
101
  let failCount = 0;
102
+ // F4: adopt the DB-003 pattern already proven in snapshot.js's
103
+ // createSnapshot (and mirrored in eval-pack.js's buildEvalPack) — this
104
+ // loop was the one place left in the codebase still doing a bare
105
+ // JSON.parse per record file with no try/catch. One truncated/corrupt
106
+ // record file threw a filename-free SyntaxError here and discarded the
107
+ // prompt-examples work (the loop above) and the subject-continuity work
108
+ // (the loop below) this function had already computed, since every
109
+ // artifact is written to disk only at the very end of
110
+ // buildImplementationPack — skip, count, warn, continue instead.
111
+ const unreadable = [];
88
112
 
89
113
  for (const file of allFiles) {
90
114
  if (failCount >= 10) break;
91
- const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
115
+ let record;
116
+ try {
117
+ record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
118
+ } catch (err) {
119
+ unreadable.push({ record_id: file.replace(/\.json$/, ''), file, error: err.message });
120
+ continue;
121
+ }
92
122
  if (record.judgment?.status !== 'rejected') continue;
93
123
 
94
124
  const prompt = record.provenance?.prompt || '';
@@ -107,6 +137,15 @@ export async function buildImplementationPack(projectRoot, manifestId) {
107
137
  });
108
138
  failCount++;
109
139
  }
140
+ // F4: surface unreadable records loudly (DB-003 parity) — same shape,
141
+ // same warn() visibility as snapshot.js's createSnapshot.
142
+ if (unreadable.length > 0) {
143
+ const sample = unreadable.slice(0, 5).map(u => u.file).join(', ');
144
+ warn(
145
+ `implementation-pack: ${unreadable.length} record file(s) were unreadable and skipped` +
146
+ (unreadable.length > 5 ? ` (first 5: ${sample}, ...)` : ` (${sample})`),
147
+ );
148
+ }
110
149
 
111
150
  // Build subject continuity examples
112
151
  const subjectExamples = [];
@@ -114,12 +153,33 @@ export async function buildImplementationPack(projectRoot, manifestId) {
114
153
 
115
154
  for (const entry of [...testEntries, ...trainEntries]) {
116
155
  const record = await loadRecord(recordsDir, entry.record_id);
117
- if (!record?.identity?.subject_name) continue;
156
+ // F3: keep "record file is missing" (a data-integrity problem worth a
157
+ // warning) distinct from "record loaded fine but simply has no
158
+ // identity.subject_name" (the normal, expected case for most records —
159
+ // not a missing-record situation). The original combined
160
+ // `!record?.identity?.subject_name` check could not tell these apart.
161
+ if (!record) {
162
+ missingRecords.push({ record_id: entry.record_id, stage: 'implementation-pack', source: 'subject-continuity' });
163
+ continue;
164
+ }
165
+ if (!record.identity?.subject_name) continue;
118
166
  const name = record.identity.subject_name;
119
167
  if (!bySubject.has(name)) bySubject.set(name, []);
120
168
  bySubject.get(name).push(record);
121
169
  }
122
170
 
171
+ // F3: warn once for both loops combined — the ledger records which
172
+ // loop(s) actually hit the miss via `source`, so the summary line stays
173
+ // useful even though it's a single warning for the whole pack build.
174
+ if (missingRecords.length > 0) {
175
+ const sample = missingRecords.slice(0, 10).map(m => `${m.record_id} (${m.source})`);
176
+ warn(
177
+ `implementation-pack: ${missingRecords.length} record(s) referenced by split "${manifest.source_split_id}" ` +
178
+ `could not be loaded from ${recordsDir}` +
179
+ (missingRecords.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`),
180
+ );
181
+ }
182
+
123
183
  for (const [name, records] of [...bySubject].sort((a, b) => b[1].length - a[1].length).slice(0, 5)) {
124
184
  subjectExamples.push({
125
185
  subject_name: name,
@@ -138,17 +198,18 @@ export async function buildImplementationPack(projectRoot, manifestId) {
138
198
  };
139
199
 
140
200
  // Write implementation pack
141
- const implId = generateImplId();
142
- const implDir = join(projectRoot, 'training', 'implementations', implId);
143
- // D-005: refuse to silently overwrite on ID collision.
144
- if (existsSync(implDir)) {
145
- throw inputError(
146
- 'IMPL_PACK_ID_COLLISION',
147
- `Implementation pack directory already exists: ${implDir}`,
148
- 'Retry to generate a fresh implementation pack ID.',
149
- );
150
- }
151
- await mkdir(implDir, { recursive: true });
201
+ // SDL-M10: atomically claim the implementation-pack id/dir — closes the
202
+ // check-then-act race the old existsSync+recursive-mkdir pattern had
203
+ // (D-005's intent, now actually race-proof; see claimIdDir in snapshot.js).
204
+ const { id: implId, dir: implDir } = await claimIdDir(
205
+ join(projectRoot, 'training', 'implementations'),
206
+ generateImplId,
207
+ {
208
+ code: 'IMPL_PACK_ID_COLLISION',
209
+ message: 'Could not claim a unique implementation-pack ID — every candidate collided with an existing implementation-pack directory.',
210
+ hint: 'Retry; if this persists, another sdlab process may be creating implementation packs in a tight loop.',
211
+ },
212
+ );
152
213
 
153
214
  const implManifest = {
154
215
  // DB-001 / DB-006: stamp manifest schema version
@@ -163,7 +224,24 @@ export async function buildImplementationPack(projectRoot, manifestId) {
163
224
  known_failures: knownFailures.length,
164
225
  subject_examples: subjectExamples.length,
165
226
  lanes_covered: laneExamples.size,
227
+ // F3: how many split-referenced records could not be loaded while
228
+ // building this pack. 0 in the common case; see missing_records
229
+ // below for the actual ids.
230
+ missing_records: missingRecords.length,
231
+ // F4: how many record files (from the known-failures scan over ALL
232
+ // of records/) existed but threw on JSON.parse — distinct from
233
+ // missing_records above, which is "no file at all". 0 in the common
234
+ // case; see unreadable_records below for the actual files.
235
+ unreadable_records: unreadable.length,
166
236
  },
237
+ // F3: ledger of every record_id that failed to load while building
238
+ // this pack (mirrors the images_failed pattern export.js established
239
+ // at export time) — record_id + stage + which loop hit it, so an
240
+ // operator can trace it without re-diffing the split against records/.
241
+ missing_records: missingRecords,
242
+ // F4: ledger of record files that existed but failed to JSON.parse
243
+ // (DB-003 parity with snapshot.js's `errors` field).
244
+ unreadable_records: unreadable,
167
245
  };
168
246
 
169
247
  await writeFile(join(implDir, 'manifest.json'), JSON.stringify(implManifest, null, 2) + '\n');