@mcptoolshop/style-dataset-lab 3.4.0 → 3.5.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 (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.es.md +111 -59
  3. package/README.fr.md +117 -67
  4. package/README.hi.md +109 -70
  5. package/README.it.md +111 -60
  6. package/README.ja.md +116 -76
  7. package/README.md +58 -2
  8. package/README.pt-BR.md +119 -64
  9. package/README.zh.md +108 -67
  10. package/bin/sdlab.js +50 -1
  11. package/lib/adapters/ai-toolkit.js +187 -30
  12. package/lib/adapters/comfyui-workflows.js +84 -9
  13. package/lib/asset-ingest.js +575 -0
  14. package/lib/asset-masks.js +323 -0
  15. package/lib/asset-source.js +1176 -0
  16. package/lib/canon-author.js +350 -0
  17. package/lib/canon-review-gate.js +205 -0
  18. package/lib/colab-launcher-guards.js +285 -0
  19. package/lib/export.js +1 -1
  20. package/lib/freeze-gate.js +3 -0
  21. package/lib/generated-provenance.js +178 -5
  22. package/lib/lane1-render.js +668 -0
  23. package/lib/memory-store.js +663 -0
  24. package/lib/model-licences.js +305 -0
  25. package/lib/npy-meta.js +130 -0
  26. package/lib/png-meta.js +512 -0
  27. package/lib/records.js +54 -8
  28. package/lib/reingest-selected.js +15 -1
  29. package/lib/reingest.js +27 -3
  30. package/lib/run-manifest.js +59 -11
  31. package/lib/train-runs.js +214 -0
  32. package/lib/verifier.js +3 -0
  33. package/lib/verify-chain.js +367 -0
  34. package/package.json +2 -2
  35. package/scripts/asset-ingest.js +160 -0
  36. package/scripts/canon-draft.js +82 -0
  37. package/scripts/canon-freeze.js +4 -0
  38. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  39. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  40. package/scripts/colab/colab_window.sh +587 -0
  41. package/scripts/colab/lane1_render.py +970 -0
  42. package/scripts/colab/launch_guards.py +564 -0
  43. package/scripts/colab/launch_lane1_stub.sh +309 -0
  44. package/scripts/colab/launch_lane3_stub.sh +366 -0
  45. package/scripts/colab/remote_runner.py +59 -0
  46. package/scripts/doctor.js +1 -0
  47. package/scripts/generate.js +38 -12
  48. package/scripts/verify.js +67 -0
@@ -0,0 +1,367 @@
1
+ /**
2
+ * End-to-end offline provenance chain verifier (G9, §2.2–§2.3).
3
+ *
4
+ * Walks every link required for CC-BY-4.0 release eligibility:
5
+ * 1. owner_and_licence (G5): owner === "mcp-tool-shop", licence === "CC-BY-4.0", ai_assisted_disclosure === true
6
+ * 2. canon_link (G6): canon_sha, entry_id, entry_sha256 match canon-build/<canon_sha>/manifest.json per_entity_hashes
7
+ * 3. run_manifest_link (§2.2.1): run_id, seed, prompt/prompt_sha256, comfy_workflow_sha match runs/<run_id>/manifest.json, and image exists
8
+ * 4. model_licences (G4): all models and LoRAs in pinning have cleared SPDX licence_id, non-empty licence_url, and date_read
9
+ * 5. quarantine_guard (§2.2.5): no reference to held Portlight data (`plsvessel`, `nano-banana`)
10
+ * 6. verdict_separation (§2.2.6): approved records cite a reviewer/judge distinct from the generator model
11
+ */
12
+
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { join, resolve, relative, isAbsolute } from 'node:path';
15
+ import { STUDIO_OWNER, STUDIO_LICENCE } from './records.js';
16
+ import {
17
+ isClearedLicenceEntry,
18
+ isQuarantinedName,
19
+ hasModelSha256,
20
+ normalizeModelFamilyKey,
21
+ } from './model-licences.js';
22
+
23
+ function isSafeRelativeAssetPath(projectRoot, assetPath) {
24
+ if (!assetPath || typeof assetPath !== 'string') return false;
25
+ const absRoot = resolve(projectRoot);
26
+ const absAsset = resolve(projectRoot, assetPath);
27
+ const rel = relative(absRoot, absAsset);
28
+ return Boolean(rel && !rel.startsWith('..') && !isAbsolute(rel));
29
+ }
30
+
31
+ /**
32
+ * Verify all provenance links for a single record object against on-disk manifests.
33
+ */
34
+ export function verifyRecordProvenance(projectRoot, record) {
35
+ const recordId = record?.id || record?.record_id || '<unknown>';
36
+ const failures = [];
37
+ const links = {
38
+ owner_and_licence: 'pass',
39
+ canon_link: 'pass',
40
+ run_manifest_link: 'pass',
41
+ model_licences: 'pass',
42
+ quarantine_guard: 'pass',
43
+ verdict_separation: 'pass',
44
+ };
45
+
46
+ function fail(link, code, message) {
47
+ links[link] = 'fail';
48
+ failures.push({ record_id: recordId, link, code, message });
49
+ }
50
+
51
+ // 1. G5: Owner and CC-BY-4.0 licence stamp
52
+ const owner = record?.owner ?? record?.rights?.owner ?? null;
53
+ const licence = record?.licence ?? record?.rights?.licence ?? null;
54
+ const disclosure = record?.rights?.ai_assisted_disclosure === true;
55
+ if (owner !== STUDIO_OWNER || licence !== STUDIO_LICENCE || !disclosure) {
56
+ fail(
57
+ 'owner_and_licence',
58
+ 'MISSING_OWNER_OR_LICENCE',
59
+ `Record "${recordId}" must carry owner="${STUDIO_OWNER}", licence="${STUDIO_LICENCE}", and rights.ai_assisted_disclosure=true (got owner=${JSON.stringify(owner)}, licence=${JSON.stringify(licence)}, disclosure=${JSON.stringify(disclosure)})`,
60
+ );
61
+ }
62
+
63
+ const genProv =
64
+ record?.generation_provenance ||
65
+ record?.provenance?.generation_provenance ||
66
+ record?.provenance ||
67
+ {};
68
+
69
+ // 2. G6: Canon link (canon_sha, entry_id, entry_sha256 -> canon-build/<canon_sha>/manifest.json)
70
+ const canonSha = record?.canon?.canon_sha ?? genProv?.canon_sha ?? null;
71
+ const entryId = record?.canon?.entry_id ?? genProv?.entry_id ?? null;
72
+ const entrySha256 = record?.canon?.entry_sha256 ?? genProv?.entry_sha256 ?? null;
73
+
74
+ if (!canonSha || !entryId || !entrySha256) {
75
+ fail(
76
+ 'canon_link',
77
+ 'BROKEN_CANON_LINK',
78
+ `Record "${recordId}" is missing canon link fields (canon_sha=${canonSha}, entry_id=${entryId}, entry_sha256=${entrySha256})`,
79
+ );
80
+ } else {
81
+ const dirName = String(canonSha).replace(/:/g, '-');
82
+ const canonManifestPath = existsSync(join(projectRoot, 'canon-build', String(canonSha), 'manifest.json'))
83
+ ? join(projectRoot, 'canon-build', String(canonSha), 'manifest.json')
84
+ : join(projectRoot, 'canon-build', dirName, 'manifest.json');
85
+ if (!existsSync(canonManifestPath)) {
86
+ fail(
87
+ 'canon_link',
88
+ 'BROKEN_CANON_LINK',
89
+ `Canon build manifest not found at canon-build/${canonSha}/manifest.json for record "${recordId}"`,
90
+ );
91
+ } else {
92
+ try {
93
+ const canonManifest = JSON.parse(readFileSync(canonManifestPath, 'utf-8'));
94
+ const hashes = canonManifest?.per_entity_hashes || {};
95
+ const bareId = String(entryId).includes('/') ? String(entryId).split('/').pop() : null;
96
+ const expectedSha = hashes[entryId] ?? (bareId ? hashes[bareId] : undefined);
97
+ if (!expectedSha || expectedSha !== entrySha256) {
98
+ fail(
99
+ 'canon_link',
100
+ 'BROKEN_CANON_LINK',
101
+ `Record "${recordId}" entry_sha256 (${entrySha256}) does not match canon-build/${canonSha}/manifest.json per_entity_hashes["${entryId}"] (${expectedSha || 'missing'})`,
102
+ );
103
+ }
104
+ } catch (err) {
105
+ fail(
106
+ 'canon_link',
107
+ 'BROKEN_CANON_LINK',
108
+ `Canon build manifest canon-build/${canonSha}/manifest.json could not be parsed: ${err.message}`,
109
+ );
110
+ }
111
+ }
112
+ }
113
+
114
+ // 3. §2.2.1: Run manifest link + workflow sha + image existence
115
+ const runId = genProv?.run_id ?? null;
116
+ const recWorkflowSha = genProv?.comfy_workflow_sha ?? record?.provenance?.pinning?.comfy_workflow_sha ?? null;
117
+ const hasSeed = Number.isFinite(genProv?.seed);
118
+ const hasPrompt =
119
+ (typeof genProv?.prompt === 'string' && genProv.prompt.length > 0) ||
120
+ (typeof genProv?.prompt_sha256 === 'string' && genProv.prompt_sha256.length > 0);
121
+
122
+ let runManifest = null;
123
+ if (!runId || !recWorkflowSha || !hasSeed || !hasPrompt) {
124
+ fail(
125
+ 'run_manifest_link',
126
+ 'BROKEN_RUN_MANIFEST_LINK',
127
+ `Record "${recordId}" provenance is missing run_id (${runId}), comfy_workflow_sha (${recWorkflowSha}), seed (${genProv?.seed}), or prompt`,
128
+ );
129
+ } else {
130
+ const runManifestPath = join(projectRoot, 'runs', String(runId), 'manifest.json');
131
+ if (!existsSync(runManifestPath)) {
132
+ fail(
133
+ 'run_manifest_link',
134
+ 'BROKEN_RUN_MANIFEST_LINK',
135
+ `Run manifest runs/${runId}/manifest.json not found for record "${recordId}"`,
136
+ );
137
+ } else {
138
+ try {
139
+ runManifest = JSON.parse(readFileSync(runManifestPath, 'utf-8'));
140
+ const manifestWfSha = runManifest?.pinning?.comfy_workflow_sha ?? null;
141
+ if (!manifestWfSha || manifestWfSha !== recWorkflowSha) {
142
+ fail(
143
+ 'run_manifest_link',
144
+ 'BROKEN_RUN_MANIFEST_LINK',
145
+ `Record "${recordId}" comfy_workflow_sha (${recWorkflowSha}) does not match runs/${runId}/manifest.json (${manifestWfSha})`,
146
+ );
147
+ }
148
+ } catch (err) {
149
+ fail(
150
+ 'run_manifest_link',
151
+ 'BROKEN_RUN_MANIFEST_LINK',
152
+ `Run manifest runs/${runId}/manifest.json could not be parsed: ${err.message}`,
153
+ );
154
+ }
155
+ }
156
+ }
157
+
158
+ const assetPath = record?.asset_path ?? record?.image_path ?? null;
159
+ if (!isSafeRelativeAssetPath(projectRoot, assetPath) || !existsSync(join(projectRoot, assetPath))) {
160
+ fail(
161
+ 'run_manifest_link',
162
+ 'BROKEN_RUN_MANIFEST_LINK',
163
+ `Record "${recordId}" asset_path (${assetPath}) does not exist inside the project`,
164
+ );
165
+ }
166
+
167
+ // 4. G4: Model and LoRA licences and sha256 digests
168
+ const pinning = runManifest?.pinning ?? record?.provenance?.pinning ?? null;
169
+ const modelEntries = Object.entries(pinning?.models || {}).map(([role, entry]) => ({ role, ...entry }));
170
+ const loraEntries = (pinning?.loras || []).map((entry, idx) => ({ role: `lora[${idx}]`, ...entry }));
171
+ const allWeightEntries = [...modelEntries, ...loraEntries];
172
+
173
+ const recModelLicences =
174
+ record?.generation_provenance?.model_licences ?? genProv?.model_licences ?? null;
175
+ const recModelEntries = recModelLicences
176
+ ? Object.entries(recModelLicences.models || {}).map(([role, entry]) => ({
177
+ role: `generation_provenance.model_licences.models.${role}`,
178
+ ...entry,
179
+ }))
180
+ : [];
181
+ const recLoraEntries = recModelLicences
182
+ ? (recModelLicences.loras || []).map((entry, idx) => ({
183
+ role: `generation_provenance.model_licences.loras[${idx}]`,
184
+ ...entry,
185
+ }))
186
+ : [];
187
+ const allCheckedEntries = [...allWeightEntries, ...recModelEntries, ...recLoraEntries];
188
+
189
+ if (!pinning || allWeightEntries.length === 0) {
190
+ fail(
191
+ 'model_licences',
192
+ 'UNLICENSED_OR_EXCLUDED_MODEL',
193
+ `Record "${recordId}" has no pinned model weights to verify licences against`,
194
+ );
195
+ } else {
196
+ const missingShaWeights = allCheckedEntries.filter((entry) => !hasModelSha256(entry));
197
+ if (
198
+ record?.generation_provenance &&
199
+ (!recModelLicences || recModelEntries.length < modelEntries.length || recLoraEntries.length < loraEntries.length)
200
+ ) {
201
+ missingShaWeights.push({
202
+ role: 'generation_provenance.model_licences',
203
+ name: '<missing>',
204
+ });
205
+ }
206
+ if (missingShaWeights.length > 0) {
207
+ const shaDesc = missingShaWeights
208
+ .map((w) => `${w.role}:${w.name || '<unnamed>'}`)
209
+ .join(', ');
210
+ fail(
211
+ 'model_licences',
212
+ 'MISSING_MODEL_SHA256',
213
+ `Record "${recordId}" is missing a non-empty sha256 for model/LoRA weights: ${shaDesc}`,
214
+ );
215
+ }
216
+
217
+ const invalidLicenceWeights = allCheckedEntries.filter(
218
+ (entry) => !isClearedLicenceEntry(entry, { requireSha256: false }),
219
+ );
220
+ if (
221
+ invalidLicenceWeights.length > 0 ||
222
+ (missingShaWeights.length === 0 &&
223
+ (pinning.cc_by_4_eligible === false || recModelLicences?.cc_by_4_eligible === false))
224
+ ) {
225
+ const desc = invalidLicenceWeights
226
+ .map((w) => `${w.role}:${w.name || '<unnamed>'}(licence=${w.licence_id || 'missing'})`)
227
+ .join(', ');
228
+ fail(
229
+ 'model_licences',
230
+ 'UNLICENSED_OR_EXCLUDED_MODEL',
231
+ `Record "${recordId}" uses unlicensed or excluded model/LoRA weights: ${desc || 'cc_by_4_eligible=false'}`,
232
+ );
233
+ }
234
+ }
235
+
236
+ // 5. §2.2.5: Quarantine guard (no held Portlight data / plsvessel / nano-banana)
237
+ const serializedChain = JSON.stringify({ record, runManifest });
238
+ if (isQuarantinedName(serializedChain)) {
239
+ fail(
240
+ 'quarantine_guard',
241
+ 'QUARANTINED_SOURCE_REFERENCED',
242
+ `Record "${recordId}" or its run manifest references quarantined/held data (plsvessel / nano-banana)`,
243
+ );
244
+ }
245
+
246
+ // 6. §2.2.6 / §12.5: Verdict separation (judge family != generator family on approved records)
247
+ if (record?.judgment && record.judgment.status === 'approved') {
248
+ const judge = record.judgment.judged_by_model || record.judgment.reviewed_by || null;
249
+ const generator =
250
+ record.judgment.generator_model ||
251
+ (pinning?.models?.unet?.name ? `qwen-image:${pinning.models.unet.name}` : null);
252
+ const judgeFamily = normalizeModelFamilyKey(judge);
253
+ const generatorFamily = normalizeModelFamilyKey(generator);
254
+ const sameFamily =
255
+ Boolean(judgeFamily && generatorFamily) &&
256
+ judgeFamily !== 'human' &&
257
+ judgeFamily !== 'rule-based' &&
258
+ judgeFamily === generatorFamily;
259
+ if (!judge || (generator && (judge === generator || sameFamily))) {
260
+ fail(
261
+ 'verdict_separation',
262
+ 'INVALID_VERDICT_PROVENANCE',
263
+ `Record "${recordId}" has an invalid approval verdict (judge=${judge} [${judgeFamily || 'none'}], generator=${generator} [${generatorFamily || 'none'}])`,
264
+ );
265
+ }
266
+ }
267
+
268
+ return {
269
+ record_id: recordId,
270
+ ok: failures.length === 0,
271
+ links,
272
+ failures,
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Verify the provenance chain across a project (or a single record / snapshot).
278
+ */
279
+ export async function verifyProvenanceChain(
280
+ projectRoot,
281
+ { recordId = null, snapshotId = null, allowEmpty = false } = {},
282
+ ) {
283
+ const recordsDir = join(projectRoot, 'records');
284
+ const recordsToCheck = [];
285
+ const globalFailures = [];
286
+
287
+ if (recordId) {
288
+ const recPath = join(recordsDir, `${recordId}.json`);
289
+ if (!existsSync(recPath)) {
290
+ globalFailures.push({
291
+ record_id: recordId,
292
+ link: 'scope',
293
+ code: 'RECORD_NOT_FOUND',
294
+ message: `Record "${recordId}" not found at records/${recordId}.json`,
295
+ });
296
+ } else {
297
+ recordsToCheck.push(JSON.parse(readFileSync(recPath, 'utf-8')));
298
+ }
299
+ } else if (snapshotId) {
300
+ const incPath = join(projectRoot, 'snapshots', snapshotId, 'included.jsonl');
301
+ if (!existsSync(incPath)) {
302
+ globalFailures.push({
303
+ record_id: null,
304
+ link: 'scope',
305
+ code: 'SNAPSHOT_NOT_FOUND',
306
+ message: `Snapshot "${snapshotId}" included.jsonl not found`,
307
+ });
308
+ } else {
309
+ const lines = readFileSync(incPath, 'utf-8')
310
+ .split('\n')
311
+ .filter((l) => l.trim())
312
+ .map((l) => JSON.parse(l));
313
+ for (const item of lines) {
314
+ const rid = item.record_id || item.id;
315
+ const recPath = join(recordsDir, `${rid}.json`);
316
+ if (!existsSync(recPath)) {
317
+ globalFailures.push({
318
+ record_id: rid,
319
+ link: 'scope',
320
+ code: 'RECORD_NOT_FOUND',
321
+ message: `Snapshot record "${rid}" not found at records/${rid}.json`,
322
+ });
323
+ } else {
324
+ recordsToCheck.push(JSON.parse(readFileSync(recPath, 'utf-8')));
325
+ }
326
+ }
327
+ }
328
+ } else if (existsSync(recordsDir)) {
329
+ const files = readdirSync(recordsDir)
330
+ .filter((f) => f.endsWith('.json'))
331
+ .sort();
332
+ for (const file of files) {
333
+ recordsToCheck.push(JSON.parse(readFileSync(join(recordsDir, file), 'utf-8')));
334
+ }
335
+ }
336
+
337
+ if (recordsToCheck.length === 0 && globalFailures.length === 0 && !allowEmpty) {
338
+ globalFailures.push({
339
+ record_id: null,
340
+ link: 'scope',
341
+ code: 'NO_RECORDS_IN_SCOPE',
342
+ message: 'No records found in scope to verify',
343
+ });
344
+ }
345
+
346
+ const results = [];
347
+ const failures = [...globalFailures];
348
+
349
+ for (const rec of recordsToCheck) {
350
+ const res = verifyRecordProvenance(projectRoot, rec);
351
+ results.push(res);
352
+ failures.push(...res.failures);
353
+ }
354
+
355
+ const passedCount = results.filter((r) => r.ok).length;
356
+ const failedCount = results.length - passedCount;
357
+
358
+ return {
359
+ ok: failures.length === 0,
360
+ project_root: projectRoot,
361
+ records_checked: results.length,
362
+ passed_count: passedCount,
363
+ failed_count: failedCount,
364
+ results,
365
+ failures,
366
+ };
367
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/style-dataset-lab",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Canon-aligned dataset production and generation workbench — define visual rules, build versioned training data, compile production briefs, run local workflows, batch-produce, select winners, and re-ingest into your corpus",
5
5
  "type": "module",
6
6
  "bin": {
@@ -82,7 +82,7 @@
82
82
  "author": "mcp-tool-shop",
83
83
  "license": "MIT",
84
84
  "devDependencies": {
85
- "c8": "^10.1.3"
85
+ "c8": "^12.0.0"
86
86
  },
87
87
  "dependencies": {
88
88
  "yaml": "^2.8.3"
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * asset-ingest.js — `sdlab asset ingest <dir>`: register a provenance-carrying
5
+ * asset export (asset-source.json + renders + masks + channels) into a
6
+ * project, through the full contract of lib/asset-source.js and the
7
+ * receiving-dock gates of lib/asset-ingest.js.
8
+ *
9
+ * The manifest-driven, gated sibling of `sdlab ingest` (which takes bare
10
+ * images with no contract). See docs/asset-lane-design.md.
11
+ *
12
+ * PATH NOTE (deliberate deviation from resolveSafeProjectPath): the export
13
+ * directory is allowed to live OUTSIDE the sdlab workspace — asset exports
14
+ * live where the asset pipeline puts them (e.g. a training area on another
15
+ * part of the disk). The source is READ-ONLY here; every path the manifest
16
+ * references is contained to the export directory by lib/asset-source.js,
17
+ * and every WRITE stays inside the project. Containment where it protects,
18
+ * reach where the user explicitly points.
19
+ *
20
+ * Usage:
21
+ * sdlab asset ingest <dir> --project <name>
22
+ * sdlab asset ingest <dir> --project <name> --dry-run
23
+ * sdlab asset ingest <dir> --project <name> --json
24
+ */
25
+
26
+ import { resolve } from 'node:path';
27
+ import { parseArgs, getProjectName } from '../lib/args.js';
28
+ import { getProjectRoot } from '../lib/paths.js';
29
+ import { inputError, handleCliError } from '../lib/errors.js';
30
+ import { ingestAssetSource } from '../lib/asset-ingest.js';
31
+ import { result } from '../lib/log.js';
32
+
33
+ const USAGE = `Usage: sdlab asset ingest <dir> --project <name> [--dry-run] [--json]
34
+
35
+ Validate an asset export's asset-source.json (schema, containment, encoding
36
+ proofs, categorical palette proofs, the acceptance verdict) and register its
37
+ renders as candidate records. Refuses loudly on any contract violation —
38
+ nothing registers on a bad manifest.
39
+
40
+ Positional:
41
+ dir Asset export directory containing asset-source.json.
42
+ May live outside the workspace (read-only source).
43
+
44
+ Flags:
45
+ --project <name> Project to register into
46
+ --dry-run Validate + run gates + report the plan; write nothing
47
+ --json Emit the result as JSON on stdout
48
+
49
+ What lands on success:
50
+ records/<asset>__<render>.json uncurated records (judgment: null),
51
+ provenance.source: "asset", hashes,
52
+ camera/facing, gate measurements
53
+ outputs/candidates/<asset>__<render>.png
54
+ assets/<asset>/ manifest copy, silhouettes, channel
55
+ instances, pairs, ingest-receipt.json
56
+ (the receipt lists every written path —
57
+ it is the undo checklist)
58
+
59
+ Next steps after ingesting:
60
+ 1. Review: sdlab curate <id> approved/rejected "explanation"
61
+ 2. Bind: sdlab bind --project <name>`;
62
+
63
+ export async function run(argv = process.argv.slice(2)) {
64
+ if (argv.includes('--help') || argv.includes('-h')) {
65
+ console.log(USAGE);
66
+ return;
67
+ }
68
+
69
+ const { flags, positionals } = parseArgs(argv, {
70
+ flags: {
71
+ project: { type: 'string' },
72
+ 'dry-run': { type: 'boolean', default: false },
73
+ json: { type: 'boolean', default: false },
74
+ },
75
+ deprecated: { game: 'project' },
76
+ });
77
+
78
+ const dirArg = positionals[0];
79
+ if (!dirArg) {
80
+ throw inputError('INPUT_MISSING_ARGS', 'An asset export directory is required.', USAGE);
81
+ }
82
+
83
+ const projectName = flags.project || getProjectName(argv);
84
+ const projectRoot = getProjectRoot(projectName);
85
+ const dryRun = flags['dry-run'] || flags.dryRun;
86
+ const sourceDir = resolve(dirArg);
87
+
88
+ if (!flags.json) {
89
+ console.log(`\x1b[1msdlab asset ingest\x1b[0m — ${projectName}`);
90
+ console.log(` Source: ${sourceDir}`);
91
+ if (dryRun) console.log(` Mode: DRY RUN (no files written)`);
92
+ console.log('');
93
+ }
94
+
95
+ const report = await ingestAssetSource(projectRoot, sourceDir, { dryRun });
96
+
97
+ if (flags.json) {
98
+ console.log(JSON.stringify(report, null, 2));
99
+ return;
100
+ }
101
+
102
+ if (report.alreadyIngested) {
103
+ console.log(` ○ Asset "${report.assetId}" is already ingested (same manifest hash) — nothing to do.`);
104
+ console.log(` Receipt: ${report.receiptPath}`);
105
+ return;
106
+ }
107
+
108
+ console.log(` \x1b[32m✓\x1b[0m Registered: ${report.created.length} render record(s)${dryRun ? ' (dry run — nothing written)' : ''}`);
109
+ for (const id of report.created) {
110
+ const gate = report.gates[id];
111
+ console.log(` ✓ ${id} (off-palette ${gate.offpalette_pct}%${gate.withdrawn_pct_bound ? ' [diagnostic]' : ''}, blob ${gate.largest_blob_px}px)`);
112
+ }
113
+
114
+ if (report.skipped.length > 0) {
115
+ console.log(` Skipped: ${report.skipped.length} (already registered by this ingest)`);
116
+ for (const id of report.skipped) console.log(` ○ ${id}`);
117
+ }
118
+
119
+ if (report.rejected.length > 0) {
120
+ console.log(` \x1b[33m⚠\x1b[0m Rejected by the asset's own palette gate: ${report.rejected.length}`);
121
+ for (const r of report.rejected) {
122
+ const d = r.gate.dominant;
123
+ console.log(` ✗ ${r.render_id}: blob ${r.gate.largest_blob_px}px (limit ${'≤'} manifest gate)` +
124
+ (d ? ` — dominant hue ${d.hue_lo}-${d.hue_hi}°, median C* ${d.median_chroma}, rgb(${d.median_rgb})` : ''));
125
+ }
126
+ }
127
+
128
+ // Provenance notices — what the manifest left undeclared. Not failures:
129
+ // the ingest succeeded. These are facts about the training data that only
130
+ // the manifest could have supplied, printed so they are not discovered
131
+ // later by a split or a training run that quietly assumed them.
132
+ if (report.notices?.length > 0) {
133
+ const gaps = report.notices.filter((n) => n.kind === 'gap');
134
+ const infos = report.notices.filter((n) => n.kind !== 'gap');
135
+ console.log('');
136
+ console.log(` \x1b[36mProvenance notices\x1b[0m (${report.notices.length}) — the ingest SUCCEEDED; these describe the records it created:`);
137
+ if (gaps.length > 0) {
138
+ console.log(` \x1b[33m gaps\x1b[0m (${gaps.length}) — declarations the manifest did not make; closing them is export-side work:`);
139
+ for (const n of gaps) console.log(` • [${n.code}] ${n.message}`);
140
+ }
141
+ if (infos.length > 0) {
142
+ console.log(` info (${infos.length}) — declarations it DID make that change how these records read:`);
143
+ for (const n of infos) console.log(` • [${n.code}] ${n.message}`);
144
+ }
145
+ }
146
+
147
+ if (!dryRun && report.created.length > 0) {
148
+ console.log('');
149
+ result(`Receipt: ${report.receiptPath}`);
150
+ console.log('');
151
+ console.log(' Next steps:');
152
+ console.log(' 1. Review: sdlab curate <id> approved/rejected "explanation"');
153
+ console.log(' 2. Bind: sdlab bind --project ' + projectName);
154
+ }
155
+ }
156
+
157
+ // Direct execution guard
158
+ if (process.argv[1] && (process.argv[1].endsWith('asset-ingest.js') || process.argv[1].endsWith('asset-ingest'))) {
159
+ run().catch(handleCliError);
160
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `sdlab canon draft <class_id> --endpoint <url> --brief "<text>" --out <path>`
3
+ *
4
+ * CLI wrapper for the Lane 2 canon and prompt authoring adapter (DESIGN-v3 §3.2,
5
+ * §11, G10). Calls a loopback-only OpenAI-compatible server (e.g., llama-server
6
+ * serving Muse-Glimmer-30B-A3B under Apache-2.0) and writes `prompts.jsonl`
7
+ * (+ `prompts.jsonl.sha256`) with `status: "draft"` and `judgment: null`.
8
+ */
9
+
10
+ import { parseArgs } from 'node:util';
11
+ import {
12
+ draftCanonClassViaEndpoint,
13
+ verifyGlimmerRuntime,
14
+ writePromptsJsonl,
15
+ } from '../lib/canon-author.js';
16
+ import { inputError } from '../lib/errors.js';
17
+ import { assertKnownOptions } from './_shared-args.js';
18
+
19
+ export async function run(argv) {
20
+ const options = {
21
+ endpoint: { type: 'string', default: 'http://127.0.0.1:8080/v1/chat/completions' },
22
+ brief: { type: 'string' },
23
+ out: { type: 'string' },
24
+ build: { type: 'string', default: 'b10353' },
25
+ vision: { type: 'boolean', default: false },
26
+ mmproj: { type: 'string' },
27
+ 'mmproj-sha': { type: 'string' },
28
+ json: { type: 'boolean', default: false },
29
+ };
30
+ const parsed = parseArgs({
31
+ args: argv,
32
+ options,
33
+ allowPositionals: true,
34
+ strict: false,
35
+ });
36
+ assertKnownOptions(parsed.values, options, { command: 'canon draft' });
37
+
38
+ const classId = parsed.positionals?.[0];
39
+ if (!classId) {
40
+ throw inputError(
41
+ 'INPUT_MISSING_ARG',
42
+ 'sdlab canon draft requires a <class_id> positional argument.'
43
+ );
44
+ }
45
+ if (!parsed.values.brief) {
46
+ throw inputError(
47
+ 'INPUT_MISSING_FLAG',
48
+ 'sdlab canon draft requires --brief "<description>".'
49
+ );
50
+ }
51
+
52
+ verifyGlimmerRuntime({
53
+ llamaServerBuild: parsed.values.build,
54
+ vision: Boolean(parsed.values.vision),
55
+ mmprojPath: parsed.values.mmproj || null,
56
+ mmprojSha256: parsed.values['mmproj-sha'] || null,
57
+ verifyFiles: false,
58
+ });
59
+
60
+ const draft = await draftCanonClassViaEndpoint({
61
+ endpoint: parsed.values.endpoint,
62
+ classId,
63
+ brief: parsed.values.brief,
64
+ });
65
+
66
+ let written = null;
67
+ if (parsed.values.out) {
68
+ written = writePromptsJsonl(parsed.values.out, [draft]);
69
+ }
70
+
71
+ if (parsed.values.json) {
72
+ console.log(JSON.stringify({ ok: true, draft, written }, null, 2));
73
+ return;
74
+ }
75
+
76
+ console.log(`drafted "${classId}" (status=${draft.status}, judgment=${draft.judgment})`);
77
+ console.log(` draft_sha256: ${draft.draft_sha256}`);
78
+ console.log(` messages_sha256: ${draft.messages_sha256}`);
79
+ if (written) {
80
+ console.log(` prompts.jsonl: ${written.promptsPath} (${written.sha256})`);
81
+ }
82
+ }
@@ -17,6 +17,7 @@ import { loadBuildConfig } from '../lib/canon-build/load-config.js';
17
17
  import { loadCanonEntriesInDir, entryId } from '../lib/canon-build/load-entry.js';
18
18
  import { buildFreezeBlock, FREEZE_STATUSES, resolveWatchFields } from '../lib/freeze-stamp.js';
19
19
  import { appendEvent } from '../lib/freeze-events.js';
20
+ import { assertDirectorRatifiedForFreeze } from '../lib/canon-review-gate.js';
20
21
  import { inputError } from '../lib/errors.js';
21
22
  import { getProjectRoot } from '../lib/paths.js';
22
23
  import { assertKnownOptions } from './_shared-args.js';
@@ -102,6 +103,9 @@ export async function run(argv) {
102
103
  );
103
104
  }
104
105
 
106
+ // G11: Refuse to freeze a model-drafted canon entry unless explicitly ratified by the Director
107
+ assertDirectorRatifiedForFreeze(resolved.entry.frontmatter);
108
+
105
109
  // Resolve the witness build hash
106
110
  const lockedAtBuild = parsed.values.build || await findLatestBuildHash(projectRoot);
107
111
  if (!lockedAtBuild) {
@@ -0,0 +1,14 @@
1
+ # Real-Service Assumption Audit (`scripts/colab/colab_window.sh`, `scripts/colab/launch_lane1_stub.sh`, `scripts/colab/launch_lane3_stub.sh`, `scripts/colab/remote_runner.py`, & `scripts/colab/launch_guards.py`)
2
+
3
+ This document audits every interaction the style-dataset-lab Colab kits (`scripts/colab/colab_window.sh`, `scripts/colab/launch_lane1_stub.sh`, `scripts/colab/launch_lane3_stub.sh`, `scripts/colab/remote_runner.py`, and `scripts/colab/launch_guards.py`) have with the `colab` CLI (`google-colab-cli` v0.7.4, captured in `tests/colab/fixtures/colab_help.txt` and `tests/colab/fixtures/colab-cli-contract.json`) and the remote Colab IPython runtime.
4
+
5
+ | # | Subcommand / Area | Help Line Relied On (`tests/colab/fixtures/colab_help.txt`) | Where Code Calls / Relies On It (`path:line`) | Fake Enforcement (`tests/colab/fixtures/fake_colab.py`) |
6
+ |---:|---|---|---|---|
7
+ | 1 | `usage` (text format, balance, and burn rate) | `Usage: colab usage [OPTIONS]` (`tests/colab/fixtures/colab_help.txt:143`); live output lines `Current balance: <float> compute units`, `Usage rate: <float>/hr` (or `/h`), `Active assignments: <int>`. | `scripts/colab/colab_window.sh:43-128`, `scripts/colab/launch_guards.py:122-177`, `scripts/colab/launch_guards.py:180-217`, `scripts/colab/launch_lane1_stub.sh:109`, `scripts/colab/launch_lane3_stub.sh:110` | **Enforced** (`tests/colab/fixtures/fake_colab.py:390-416`): rejects any positionals or unknown flags; prints the 3-line text format; supports `usage_fail_start`, `usage_fail_midrun`, and `cap_burn` modes. `launch_guards.py:122-177` parses with anchored regexes (`CURRENT_BALANCE_RE`, `USAGE_RATE_RE`), removes any `10.0` fallback, records `null` plus `usage_parse_error` in `window.json`, and refuses/stops with `COLAB_USAGE_PARSE_FAILED` (including before `colab new` if the initial read fails). |
8
+ | 2 | `new` (session open under lock) | `Usage: colab new [OPTIONS]` (`tests/colab/fixtures/colab_help.txt:54`); options `--session -s <str>`, `--gpu <str>`, `--high-mem` (`tests/colab/fixtures/colab_help.txt:59-67`). | `scripts/colab/colab_window.sh:292-327`, `scripts/colab/launch_lane1_stub.sh:125-126`, `scripts/colab/launch_lane3_stub.sh:126-127` | **Enforced** (`tests/colab/fixtures/fake_colab.py:424-441`): validates options against `colab-cli-contract.json`, rejects any positional arguments, requires `-s/--session`, and records whether `/tmp/colab-window-open.lock` was held. |
9
+ | 3 | `upload` (stage scripts and overrides to `/content/`) | `Usage: colab upload [OPTIONS] {local_path} {remote_path}` (`tests/colab/fixtures/colab_help.txt:95`); option `--session -s <str>` (`tests/colab/fixtures/colab_help.txt:104`). | `scripts/colab/launch_lane1_stub.sh:248-250`, `scripts/colab/launch_lane3_stub.sh:308-310` | **Enforced** (`tests/colab/fixtures/fake_colab.py:443-467`): requires `-s/--session` and exactly 2 positional arguments (`{local_path}` `{remote_path}`), verifies the local file exists and `{remote_path}` starts with `/content/`, and copies the file into the fake `/content` directory. |
10
+ | 4 | `exec` (execute local `.py` file via `-f` with `--timeout` and `--env`) | `Usage: colab exec [OPTIONS]` (`tests/colab/fixtures/colab_help.txt:80`); options `--session -s <str>`, `--file -f <str>`, `--timeout <float>`, `--env <str>` (`KEY=VALUE`, repeatable), `--output-image <str>` (`tests/colab/fixtures/colab_help.txt:85-90`). | `scripts/colab/colab_window.sh:181-282`, `scripts/colab/remote_runner.py:16-47`, `scripts/colab/launch_lane1_stub.sh:252-260`, `scripts/colab/launch_lane3_stub.sh:312-319` | **Enforced** (`tests/colab/fixtures/fake_colab.py:368-373` & `498-563`): refuses any positional command (`expected 0 positional arguments`), requires `-s/--session` and an existing `.py` file for `-f/--file`, validates `--timeout > 0` and `--env KEY=VALUE`, strips rig `PINNED_*` / `SDLAB_*` variables so only `--env` values reach the remote kernel, and executes the `.py` source with `cwd` set to the fake `/content` directory and `__file__` undefined. |
11
+ | 5 | `exec` (unreliable CLI exit code; marker and bundle verification) | Inside `colab exec -f`, `__file__` is undefined, `cwd` is `/content`, and the CLI exit code is not reliable on remote exceptions or `sys.exit`. | `scripts/colab/remote_runner.py:30-46`, `scripts/colab/colab_window.sh:259-272`, `scripts/colab/launch_lane1_stub.sh:263-267`, `scripts/colab/launch_lane3_stub.sh:322-326` | **Enforced** (`tests/colab/fixtures/fake_colab.py:559-561`): `FAKE_COLAB_FORCE_EXEC_RC_ZERO` defaults to `"1"` (returning `0` from `colab exec` even when the remote script exits non-zero), so `colab_window.sh` and the kits decide success strictly from the streamed `SDLAB_REMOTE_RC=<rc>` marker line and the downloaded non-empty result bundle. |
12
+ | 6 | `download` (fetch result bundle from `/content/` to rig) | `Usage: colab download [OPTIONS] {remote_path} {local_path}` (`tests/colab/fixtures/colab_help.txt:109`); option `--session -s <str>` (`tests/colab/fixtures/colab_help.txt:118`). | `scripts/colab/launch_lane1_stub.sh:263`, `scripts/colab/launch_lane3_stub.sh:322` | **Enforced** (`tests/colab/fixtures/fake_colab.py:469-486`): requires `-s/--session` and exactly 2 positional arguments (`{remote_path}` `{local_path}`), verifies `{remote_path}` is under `/content/`, and copies the artifact to `{local_path}`. |
13
+ | 7 | `stop` (close session in `EXIT` trap) | `Usage: colab stop [OPTIONS]` (`tests/colab/fixtures/colab_help.txt:123`); option `--session -s <str>` (`tests/colab/fixtures/colab_help.txt:128`). | `scripts/colab/colab_window.sh:284-290`, `scripts/colab/launch_lane1_stub.sh:93`, `scripts/colab/launch_lane3_stub.sh:94` | **Enforced** (`tests/colab/fixtures/fake_colab.py:488-496`): rejects any positional arguments, requires `-s/--session`, and logs `STOP -s <session>` to `stops.log`. |
14
+ | 8 | `sessions` (verify active sessions after stop) | `Usage: colab sessions [OPTIONS]` (`tests/colab/fixtures/colab_help.txt:71`); options `--help -h` (`tests/colab/fixtures/colab_help.txt:76`). | `scripts/colab/colab_window.sh:288` | **Enforced** (`tests/colab/fixtures/fake_colab.py:418-422`): rejects any positional arguments or unknown options and prints `[colab] No active sessions found on server.`. |