@mcptoolshop/style-dataset-lab 3.2.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 (95) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/README.es.md +40 -19
  3. package/README.fr.md +74 -53
  4. package/README.hi.md +67 -46
  5. package/README.it.md +42 -21
  6. package/README.ja.md +44 -23
  7. package/README.md +25 -4
  8. package/README.pt-BR.md +42 -21
  9. package/README.zh.md +66 -45
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +114 -27
  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 +82 -3
  28. package/lib/critique-render.js +45 -0
  29. package/lib/eligibility.js +121 -9
  30. package/lib/eval-pack.js +14 -14
  31. package/lib/eval-runs.js +33 -0
  32. package/lib/export.js +122 -34
  33. package/lib/freeze-gate.js +69 -16
  34. package/lib/freeze-stamp.js +28 -3
  35. package/lib/generated-provenance.js +16 -1
  36. package/lib/implementation-packs.js +95 -17
  37. package/lib/ingest.js +396 -0
  38. package/lib/judgment-provenance.js +220 -0
  39. package/lib/measure.js +422 -0
  40. package/lib/paths.js +196 -7
  41. package/lib/reingest-selected.js +98 -16
  42. package/lib/run-manifest.js +394 -0
  43. package/lib/run-summary.js +12 -6
  44. package/lib/runtime-runs.js +45 -4
  45. package/lib/snapshot.js +121 -16
  46. package/lib/split.js +364 -48
  47. package/lib/training-manifests.js +114 -21
  48. package/lib/training-packages.js +147 -15
  49. package/lib/verifier.js +67 -0
  50. package/lib/workflow-profiles.js +24 -3
  51. package/package.json +3 -2
  52. package/runtime/comfyui/txt2img-qwen.json +38 -0
  53. package/runtime/comfyui/txt2img-standard.json +21 -0
  54. package/scripts/_resume.js +62 -0
  55. package/scripts/_shared-args.js +44 -0
  56. package/scripts/brief-compile.js +2 -1
  57. package/scripts/brief-show.js +2 -1
  58. package/scripts/canon-bind.js +2 -2
  59. package/scripts/canon-build.js +27 -20
  60. package/scripts/canon-drift.js +16 -7
  61. package/scripts/canon-freeze-status.js +14 -6
  62. package/scripts/canon-freeze.js +27 -11
  63. package/scripts/canon-unfreeze.js +17 -9
  64. package/scripts/card.js +2 -2
  65. package/scripts/compare.js +2 -2
  66. package/scripts/critique.js +48 -8
  67. package/scripts/curate.js +23 -2
  68. package/scripts/doctor.js +181 -55
  69. package/scripts/eligibility.js +42 -2
  70. package/scripts/eval-pack.js +2 -2
  71. package/scripts/eval-run.js +2 -2
  72. package/scripts/export.js +2 -2
  73. package/scripts/generate-controlnet.js +45 -25
  74. package/scripts/generate-identity.js +53 -23
  75. package/scripts/generate-ipadapter.js +44 -23
  76. package/scripts/generate.js +72 -5
  77. package/scripts/implementation-pack.js +2 -2
  78. package/scripts/ingest.js +145 -0
  79. package/scripts/init.js +73 -13
  80. package/scripts/measure.js +170 -0
  81. package/scripts/measure_image.py +458 -0
  82. package/scripts/migrate-records.js +142 -0
  83. package/scripts/painterly-test.js +4 -3
  84. package/scripts/painterly.js +7 -3
  85. package/scripts/qwen_generate.py +334 -13
  86. package/scripts/reingest.js +2 -2
  87. package/scripts/run-generate.js +3 -0
  88. package/scripts/sheet.js +142 -0
  89. package/scripts/snapshot.js +2 -2
  90. package/scripts/split.js +2 -2
  91. package/scripts/training-manifest.js +2 -2
  92. package/scripts/training-package.js +2 -2
  93. package/scripts/training-profile.js +2 -2
  94. package/scripts/workflow-list.js +2 -1
  95. package/scripts/workflow-show.js +2 -1
package/lib/paths.js CHANGED
@@ -10,12 +10,176 @@ import { inputError } from './errors.js';
10
10
  const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = dirname(__filename);
12
12
 
13
- /** Absolute path to the repository root. */
14
- export const REPO_ROOT = join(__dirname, '..');
13
+ // ─── Workspace root resolution (SDL-C1) ───────────────────────────
14
+ //
15
+ // `REPO_ROOT` used to be `join(__dirname, '..')` — always the directory
16
+ // this module happens to live in. That is correct for an in-repo checkout,
17
+ // but for an npm-installed consumer it resolves to
18
+ // `node_modules/@mcptoolshop/style-dataset-lab`, so `sdlab init demo`
19
+ // silently wrote the user's whole project tree into node_modules — gone on
20
+ // the next `npm ci`.
21
+ //
22
+ // The fix is a 4-step search, cheapest/most-explicit signal first:
23
+ // 1. SDLAB_ROOT env var, if set and it exists.
24
+ // 2. Walk up from process.cwd() looking for a directory containing
25
+ // `projects/`. First hit wins — this is how a user's own workspace
26
+ // (wherever it lives) gets found.
27
+ // 3. The module's own root, IF it already has a `projects/` dir — this is
28
+ // what preserves every existing in-repo dev/test workflow, where cwd
29
+ // already resolves via step 2 anyway, but is kept as an explicit
30
+ // fallback for callers with an unusual cwd.
31
+ // 4. process.cwd() itself — a fresh workspace where `init` will create
32
+ // `projects/`.
33
+ //
34
+ // Whatever step wins, the result is never allowed to sit inside a
35
+ // node_modules directory — that is the exact shape of the original bug, so
36
+ // it is a hard error instead of a silent write into someone's
37
+ // node_modules tree.
38
+
39
+ /**
40
+ * True if any path segment of `absPath` is literally `node_modules`.
41
+ */
42
+ function isInsideNodeModules(absPath) {
43
+ return resolve(absPath)
44
+ .split(/[\\/]+/)
45
+ .some((seg) => seg.toLowerCase() === 'node_modules');
46
+ }
47
+
48
+ function assertNotInNodeModules(absPath, label) {
49
+ if (isInsideNodeModules(absPath)) {
50
+ throw inputError(
51
+ 'INPUT_ROOT_IN_NODE_MODULES',
52
+ `Resolved workspace root (${label}) sits inside a node_modules directory: ${absPath}`,
53
+ 'Run sdlab from your own project directory, or set SDLAB_ROOT to your workspace path.'
54
+ );
55
+ }
56
+ return absPath;
57
+ }
58
+
59
+ /**
60
+ * Walk up from `startDir` looking for a directory that contains a
61
+ * `projects/` subdirectory. Returns the first hit, or null if the walk
62
+ * reaches the filesystem root without finding one.
63
+ */
64
+ function walkUpForProjectsDir(startDir) {
65
+ let dir = resolve(startDir);
66
+ while (true) {
67
+ if (existsSync(join(dir, 'projects'))) return dir;
68
+ const parent = dirname(dir);
69
+ if (parent === dir) return null; // reached the filesystem root
70
+ dir = parent;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Pure resolution logic for the workspace root, taking every input
76
+ * explicitly (cwd, env, module root) rather than reading global process
77
+ * state directly. This is what makes it possible to unit-test all four
78
+ * branches deterministically without mutating the real process.cwd() or
79
+ * environment (which are shared, global, and unsafe to mutate across a
80
+ * concurrently-run test suite).
81
+ *
82
+ * Production callers should use `getWorkspaceRoot()` instead, which
83
+ * supplies the real process.cwd()/env and memoizes the result.
84
+ *
85
+ * @param {string} cwd
86
+ * @param {{env?: Object, moduleRoot?: string}} [opts]
87
+ * @returns {string} absolute workspace root
88
+ */
89
+ export function resolveWorkspaceRootFrom(cwd, opts = {}) {
90
+ const env = opts.env || process.env;
91
+ const moduleRoot = opts.moduleRoot || join(__dirname, '..');
92
+
93
+ // 1. SDLAB_ROOT env var, if set and it exists.
94
+ const envRoot = env.SDLAB_ROOT;
95
+ if (envRoot && existsSync(envRoot)) {
96
+ return assertNotInNodeModules(resolve(envRoot), 'from SDLAB_ROOT');
97
+ }
98
+
99
+ // 2. Walk up from cwd looking for projects/.
100
+ const fromCwd = walkUpForProjectsDir(cwd);
101
+ if (fromCwd) {
102
+ return assertNotInNodeModules(fromCwd, 'found by walking up from the current directory');
103
+ }
104
+
105
+ // 3. The module root itself is the repo checkout.
106
+ if (existsSync(join(moduleRoot, 'projects'))) {
107
+ return assertNotInNodeModules(resolve(moduleRoot), 'the style-dataset-lab install directory');
108
+ }
109
+
110
+ // 4. Fresh workspace — init will create projects/ here.
111
+ return assertNotInNodeModules(resolve(cwd), 'the current directory');
112
+ }
113
+
114
+ let _workspaceRootCache = null;
115
+
116
+ /**
117
+ * Resolve (and memoize) the workspace root using the real process.cwd()
118
+ * and process.env. Safe to call repeatedly — it only walks the filesystem
119
+ * once per process, unless reset.
120
+ * @returns {string}
121
+ */
122
+ export function getWorkspaceRoot() {
123
+ if (_workspaceRootCache === null) {
124
+ _workspaceRootCache = resolveWorkspaceRootFrom(process.cwd());
125
+ }
126
+ return _workspaceRootCache;
127
+ }
128
+
129
+ /**
130
+ * Clear the memoized workspace root so the next getWorkspaceRoot() call
131
+ * re-resolves from scratch. Exists for tests that need to exercise the
132
+ * resolver under different SDLAB_ROOT/cwd conditions within one process.
133
+ */
134
+ export function resetWorkspaceRootCache() {
135
+ _workspaceRootCache = null;
136
+ }
137
+
138
+ /**
139
+ * Absolute path to the workspace root — the directory that contains (or
140
+ * will contain) `projects/`. Kept as a plain exported constant for
141
+ * backward compatibility with the ~30 existing call sites that do
142
+ * `join(REPO_ROOT, ...)`, but its VALUE now comes from the resolver above
143
+ * rather than being hardcoded to this module's own location.
144
+ */
145
+ export const REPO_ROOT = getWorkspaceRoot();
146
+
147
+ /**
148
+ * Absolute path to the INSTALLED PACKAGE's own root — always module-relative,
149
+ * never the user's workspace.
150
+ *
151
+ * There are two distinct roots and conflating them is a shipping bug:
152
+ *
153
+ * - **workspace root** (`getWorkspaceRoot()`) — where the user's `projects/`
154
+ * live. Correctly cwd-relative, so an npm-installed user's data lands in
155
+ * their own directory instead of inside `node_modules`.
156
+ * - **package root** (this) — where assets that SHIP IN THE TARBALL live:
157
+ * `templates/` (domain starter packs) and `runtime/` (ComfyUI workflow
158
+ * graphs). These belong to the install, not to the workspace.
159
+ *
160
+ * In a repo checkout the two coincide, which is exactly why the distinction
161
+ * is easy to lose and impossible to catch with in-repo tests. Measured in a
162
+ * clean-room tarball install: with `templates/` resolved against the
163
+ * workspace root, `sdlab init demo --domain character-design` failed with
164
+ * `INPUT_UNKNOWN_DOMAIN: Available: generic,` — the five domain packs were
165
+ * sitting in the package the whole time, being looked for in the user's cwd.
166
+ */
167
+ export function getPackageRoot() {
168
+ return resolve(join(__dirname, '..'));
169
+ }
170
+
171
+ // ─── Project name / path safety (SDL-C3) ──────────────────────────
172
+
173
+ // A project name must be a single path segment: no separators, no `..`.
174
+ // This is deliberately a blacklist (not the stricter creation-time
175
+ // `/^[a-z0-9][a-z0-9-]*[a-z0-9]$/` from scripts/init.js) so existing
176
+ // project directories keep resolving; it is paired with the
177
+ // resolveSafeProjectPath() containment check below as defense in depth.
178
+ const UNSAFE_PROJECT_NAME_RE = /\.\.|[\\/]/;
15
179
 
16
180
  /**
17
181
  * Resolve the root directory for a named project.
18
- * @param {string} name — project name (e.g. 'star-freight')
182
+ * @param {string} name — a single path segment, e.g. 'my-project'
19
183
  * @returns {string} absolute path to projects/<name>/
20
184
  */
21
185
  export function getProjectRoot(name) {
@@ -26,12 +190,32 @@ export function getProjectRoot(name) {
26
190
  'Pass --project <name>.'
27
191
  );
28
192
  }
29
- const root = join(REPO_ROOT, 'projects', name);
193
+ if (UNSAFE_PROJECT_NAME_RE.test(name)) {
194
+ throw inputError(
195
+ 'INPUT_UNSAFE_PROJECT_NAME',
196
+ `Project name "${name}" is not valid — it contains a path separator or "..".`,
197
+ `--project takes a name, not a path: it names a directory under projects/. ` +
198
+ `Try --project my-project. Run "sdlab init <name>" to create one.`
199
+ );
200
+ }
201
+ const projectsDir = join(getWorkspaceRoot(), 'projects');
202
+ // Defense in depth: even though the blacklist above already rejects the
203
+ // characters needed to escape projectsDir, reuse the same containment
204
+ // pattern as every other user-path entry point in this file so a future
205
+ // change to the blacklist can't silently reopen the traversal.
206
+ const root = resolveSafeProjectPath(projectsDir, name, { flagName: 'project' });
30
207
  if (!existsSync(root)) {
31
208
  throw inputError(
32
209
  'INPUT_UNKNOWN_PROJECT',
33
210
  `Project "${name}" not found at ${root}.`,
34
- `Run: sdlab init ${name} --domain <domain>`
211
+ // Two different causes land here and they need opposite responses, so
212
+ // name both rather than assuming the project is missing. Since the
213
+ // workspace root is resolved from the current directory, running from
214
+ // the wrong place produces this same error — and blindly following
215
+ // "run init" would then scaffold a duplicate project somewhere else.
216
+ `Looking under ${projectsDir} (workspace resolved from the current directory).\n` +
217
+ ` · Wrong directory? cd to your workspace, or set SDLAB_ROOT to it.\n` +
218
+ ` · New project? sdlab init ${name} --domain character-design`
35
219
  );
36
220
  }
37
221
  return root;
@@ -100,8 +284,13 @@ export function getRunsDir(projectRoot) {
100
284
  }
101
285
 
102
286
  /**
103
- * Return the runtime templates directory (repo-level).
287
+ * Return the ComfyUI runtime workflow templates directory.
288
+ *
289
+ * PACKAGE-relative, deliberately — see `getPackageRoot()`. `runtime/` ships
290
+ * inside the npm tarball; it is not workspace content. Resolving it against
291
+ * the workspace root made `listWorkflowTemplates()` return `[]` for every
292
+ * installed user, which is the bug `getPackageRoot()` exists to prevent.
104
293
  */
105
294
  export function getRuntimeDir() {
106
- return join(REPO_ROOT, 'runtime');
295
+ return join(getPackageRoot(), 'runtime');
107
296
  }
@@ -6,7 +6,7 @@
6
6
  * canon-binding flow as everything else. No bypass.
7
7
  */
8
8
 
9
- import { writeFile, mkdir, copyFile } from 'node:fs/promises';
9
+ import { writeFile, mkdir, copyFile, rename, unlink } from 'node:fs/promises';
10
10
  import { join, extname } from 'node:path';
11
11
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
12
12
  import { getRunsDir } from './paths.js';
@@ -16,6 +16,35 @@ import { buildGeneratedProvenance } from './generated-provenance.js';
16
16
  import { buildBaseRecord } from './records.js';
17
17
  import { inputError } from './errors.js';
18
18
 
19
+ // SDL-H7: the only shape a selection id can ever legitimately have,
20
+ // mirroring generateSelectionId() in lib/selections.js (selection_<date>_<seq>,
21
+ // same prefix/date/seq scheme as run/batch ids). Validated BEFORE the id is
22
+ // used anywhere — including before loadSelection(), which does its own
23
+ // unguarded join — so a traversal payload never reaches the filesystem, and
24
+ // before the join at `selectionDir` below, which this module writes
25
+ // provenance.jsonl into. A mismatch reuses loadSelection's own
26
+ // SELECTION_NOT_FOUND shape (not a distinct code) because no real selection
27
+ // can ever have an id outside this shape.
28
+ const SELECTION_ID_RE = /^selection_\d{4}-\d{2}-\d{2}_\d{3}$/;
29
+
30
+ /**
31
+ * B03: atomic write helper — write temp + rename, mirroring the same
32
+ * temp-file-then-rename checkpoint pattern lib/batch-runs.js uses for its
33
+ * per-slot manifest checkpoint. Rename is atomic on a single filesystem, so
34
+ * a crash mid-write can never leave a half-written provenance.jsonl in
35
+ * place of a good one.
36
+ */
37
+ async function atomicWriteText(targetPath, text) {
38
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
39
+ try {
40
+ await writeFile(tmpPath, text);
41
+ await rename(tmpPath, targetPath);
42
+ } catch (err) {
43
+ try { await unlink(tmpPath); } catch { /* ignore */ }
44
+ throw err;
45
+ }
46
+ }
47
+
19
48
  /**
20
49
  * Re-ingest a selection — creates candidate records from chosen outputs.
21
50
  *
@@ -27,6 +56,14 @@ import { inputError } from './errors.js';
27
56
  * @returns {Promise<{created: string[], skipped: string[]}>}
28
57
  */
29
58
  export async function reingestSelection({ projectRoot, projectId, selectionId, dryRun }) {
59
+ if (typeof selectionId !== 'string' || !SELECTION_ID_RE.test(selectionId)) {
60
+ throw inputError(
61
+ 'SELECTION_NOT_FOUND',
62
+ `Selection "${selectionId}" not found`,
63
+ 'Check the selection ID or run: sdlab select --run <id> --approve <files>',
64
+ );
65
+ }
66
+
30
67
  const manifest = loadSelection(projectRoot, selectionId);
31
68
 
32
69
  if (!manifest.reingest_ready) {
@@ -69,7 +106,46 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
69
106
 
70
107
  const created = [];
71
108
  const skipped = [];
109
+
110
+ // B03: provenance.jsonl is now built incrementally — checkpointed (atomic
111
+ // full-rewrite, temp+rename) after every item — instead of accumulated in
112
+ // memory and overwritten once at the end. `provenanceLines` seeds from
113
+ // whatever is ALREADY on disk (e.g. left by an interrupted prior run of
114
+ // this same selection) so: (a) a rerun never duplicates a line for a
115
+ // record it already logged, and (b) a partial rerun can never ERASE lines
116
+ // a previous call already committed — the old end-of-run `writeFile`
117
+ // overwrote with only the CURRENT call's accumulator, so a second call
118
+ // touching fewer items than the first could silently wipe out provenance
119
+ // the first call had already written to disk.
120
+ const provenancePath = join(selectionDir, 'provenance.jsonl');
72
121
  const provenanceLines = [];
122
+ const seenProvenanceIds = new Set();
123
+ if (existsSync(provenancePath)) {
124
+ for (const line of readFileSync(provenancePath, 'utf-8').split('\n')) {
125
+ if (!line.trim()) continue;
126
+ provenanceLines.push(line);
127
+ try {
128
+ const parsed = JSON.parse(line);
129
+ if (parsed?.record_id) seenProvenanceIds.add(parsed.record_id);
130
+ } catch {
131
+ // Tolerate a corrupt/partial trailing line from a prior crash —
132
+ // keep it verbatim rather than dropping it, just don't index it.
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Checkpoint one provenance line: skips dryRun, and skips a record_id
139
+ * that's already logged (idempotent — safe to call on both the fresh-write
140
+ * path and the skip/backfill path below).
141
+ */
142
+ async function checkpointProvenance(recordId, prov) {
143
+ if (dryRun) return;
144
+ if (seenProvenanceIds.has(recordId)) return;
145
+ provenanceLines.push(JSON.stringify({ record_id: recordId, ...prov }));
146
+ seenProvenanceIds.add(recordId);
147
+ await atomicWriteText(provenancePath, provenanceLines.join('\n') + '\n');
148
+ }
73
149
 
74
150
  // D-010: use entries() — not indexOf() — to avoid O(n^2) behavior and
75
151
  // silent duplicate-ID bugs when manifest.items contains structurally
@@ -88,12 +164,11 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
88
164
  seenIds.add(recordId);
89
165
  const recordPath = join(recordsDir, `${recordId}.json`);
90
166
 
91
- if (existsSync(recordPath)) {
92
- skipped.push(recordId);
93
- continue;
94
- }
95
-
96
- // Resolve the run manifest for this item
167
+ // Resolve the run manifest for this item + build its provenance BEFORE
168
+ // the existsSync check below — B03: a skipped (already-created) item
169
+ // still needs its provenance backfilled if an earlier interrupted run
170
+ // stopped short of appending it, so this can't be gated behind "the
171
+ // record is new".
97
172
  let runManifest;
98
173
  if (manifest.source_type === 'run') {
99
174
  runManifest = getRunManifest(manifest.source_id);
@@ -126,6 +201,16 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
126
201
  projectRoot,
127
202
  });
128
203
 
204
+ if (existsSync(recordPath)) {
205
+ // B03: the record already landed on disk (this item was completed by
206
+ // a prior, possibly-interrupted run of this same re-ingest). Backfill
207
+ // its provenance line if that prior run never got to append one —
208
+ // never let a record exist with no corresponding provenance entry.
209
+ await checkpointProvenance(recordId, prov);
210
+ skipped.push(recordId);
211
+ continue;
212
+ }
213
+
129
214
  // Image source in selection/chosen/
130
215
  const srcImage = join(selectionDir, 'chosen', item.filename);
131
216
  const ext = extname(item.filename) || '.png';
@@ -166,18 +251,15 @@ export async function reingestSelection({ projectRoot, projectId, selectionId, d
166
251
  }
167
252
  // Write record
168
253
  await writeFile(recordPath, JSON.stringify(record, null, 2) + '\n');
254
+ // B03: checkpoint immediately after the record write (per-item,
255
+ // matching lib/batch-runs.js's per-slot checkpoint pattern) — NOT
256
+ // batched to the end of the run. A crash on item 120 of 200 must
257
+ // leave provenance for 1-119 durably on disk, not lose it because
258
+ // item 200 never finished.
259
+ await checkpointProvenance(recordId, prov);
169
260
  }
170
261
 
171
262
  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
263
  }
182
264
 
183
265
  return { created, skipped };