@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,285 @@
1
+ /**
2
+ * C2 Colab launcher guards (DESIGN-v3 §9.2 item 6 — lessons v4k, v4p, v4q).
3
+ *
4
+ * Implements the four required launcher guards:
5
+ * 1. Independent wall watcher (`evaluateWallGuard`, v4k): honours `wallS` and
6
+ * `graceS` in ALL modes including `stub` mode.
7
+ * 2. Independent cap watcher (`evaluateCapGuard`, v4k): honours `cap` and
8
+ * `margin` in ALL modes including `stub` mode (`spend = max(drop, rate * elapsed_h)`).
9
+ * 3. `vitals.log` parser & `DEAD HARNESS` / `STALL` watcher (`parseVitalsLog`,
10
+ * `evaluateVitalsAndStallGuard`, v4p): trips `DEAD_HARNESS_TRIPPED` when 2
11
+ * consecutive `phase: run` samples have 0 active worker processes, and trips
12
+ * `STALL_GUARD_TRIPPED` when neither rows nor `vitals.log` advance in `stallS`.
13
+ * 4. Host-RAM / cgroup limit pre-load projection check & between-item fetch
14
+ * (`checkHostRamProjection`, `runBetweenItemFetch`, v4q): refuses any step
15
+ * whose projected anonymous host RAM (`projectedMb * 1.05 > limitMb`) would
16
+ * trigger a kernel SIGKILL (`rc=-9`), and copies completed artifacts listed
17
+ * in `current.json` before `current.json` is overwritten for the next item.
18
+ */
19
+
20
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { inputError as rawInputError } from './errors.js';
23
+
24
+ function inputError(code, msg) {
25
+ return rawInputError(code, `${code}: ${msg}`);
26
+ }
27
+
28
+ const WORKER_PROC_RE = /\b(comfy|main\.py|ai-toolkit|run\.py|llama-server|ollama|lane1_render\.py)\b/i;
29
+
30
+ /**
31
+ * 1. Independent wall-clock watcher (v4k).
32
+ * Never disabled in `stub` mode: trips when `elapsedS >= wallS - graceS`.
33
+ *
34
+ * @param {Object} opts
35
+ * @param {string} [opts.mode='live']
36
+ * @param {number} opts.elapsedS
37
+ * @param {number} [opts.wallS=3000]
38
+ * @param {number} [opts.graceS=60]
39
+ * @returns {{ mode: string, tripped: boolean, code: string|null, remainingS: number, thresholdS: number }}
40
+ */
41
+ export function evaluateWallGuard({
42
+ mode = 'live',
43
+ elapsedS,
44
+ wallS = 3000,
45
+ graceS = 60,
46
+ } = {}) {
47
+ const thresholdS = Math.max(0, Number(wallS) - Number(graceS));
48
+ const remainingS = thresholdS - Number(elapsedS);
49
+ const tripped = Number(elapsedS) >= thresholdS;
50
+ return {
51
+ mode,
52
+ tripped,
53
+ code: tripped ? 'WALL_GUARD_TRIPPED' : null,
54
+ remainingS,
55
+ thresholdS,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * 2. Independent CCU spend cap watcher (v4k).
61
+ * Never disabled in `stub` mode: computes `spend = max(startBalance - currentBalance, rateCcuPerHour * elapsedH)`
62
+ * and trips when `spend >= cap - margin`.
63
+ *
64
+ * @param {Object} opts
65
+ * @param {string} [opts.mode='live']
66
+ * @param {number} [opts.cap=0.05]
67
+ * @param {number} [opts.margin=0.01]
68
+ * @param {number|null} [opts.startBalance=null]
69
+ * @param {number|null} [opts.currentBalance=null]
70
+ * @param {number} [opts.rateCcuPerHour=0]
71
+ * @param {number} [opts.elapsedS=0]
72
+ * @returns {{ mode: string, tripped: boolean, code: string|null, spendCcu: number, ceilingCcu: number }}
73
+ */
74
+ export function evaluateCapGuard({
75
+ mode = 'live',
76
+ cap = 0.05,
77
+ margin = 0.01,
78
+ startBalance = null,
79
+ currentBalance = null,
80
+ rateCcuPerHour = 0,
81
+ elapsedS = 0,
82
+ } = {}) {
83
+ const balanceDrop =
84
+ startBalance !== null && currentBalance !== null
85
+ ? Math.max(0, Number(startBalance) - Number(currentBalance))
86
+ : 0;
87
+ const rateEst = Math.max(0, (Number(rateCcuPerHour) * Number(elapsedS)) / 3600);
88
+ const spendCcu = Math.max(balanceDrop, rateEst);
89
+ const ceilingCcu = Math.max(0, Number(cap) - Number(margin));
90
+ const tripped = spendCcu >= ceilingCcu;
91
+ return {
92
+ mode,
93
+ tripped,
94
+ code: tripped ? 'CAP_GUARD_TRIPPED' : null,
95
+ spendCcu: Number(spendCcu.toFixed(6)),
96
+ ceilingCcu: Number(ceilingCcu.toFixed(6)),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Parse `vitals.log` blocks (`=== VITALS <iso> ===`) emitted by the VM vitals sampler (v4p).
102
+ *
103
+ * @param {string} vitalsText
104
+ * @returns {Array<{ timestamp: string, phase: string|null, workerProcCount: number, rawBlock: string }>}
105
+ */
106
+ export function parseVitalsLog(vitalsText = '') {
107
+ const blocks = String(vitalsText)
108
+ .split(/^===\s*VITALS\s+/m)
109
+ .map((b) => b.trim())
110
+ .filter(Boolean);
111
+
112
+ const samples = [];
113
+ for (const block of blocks) {
114
+ const lines = block.split(/\r?\n/);
115
+ const header = lines[0] || '';
116
+ const tsMatch = /^([^\s=]+)/.exec(header);
117
+ const timestamp = tsMatch ? tsMatch[1] : '';
118
+
119
+ let phase = null;
120
+ let workerProcCount = 0;
121
+ let inProc = false;
122
+
123
+ for (let i = 1; i < lines.length; i++) {
124
+ const line = lines[i];
125
+ if (line.startsWith('PROC:')) {
126
+ inProc = true;
127
+ continue;
128
+ }
129
+ if (line.startsWith('HARNESS:')) {
130
+ inProc = false;
131
+ const payload = line.slice('HARNESS:'.length).trim();
132
+ try {
133
+ const parsed = JSON.parse(payload);
134
+ phase = parsed.phase || null;
135
+ } catch {
136
+ const m = /"phase"\s*:\s*"([^"]+)"/.exec(payload) || /phase:\s*([a-z0-9_-]+)/i.exec(payload);
137
+ phase = m ? m[1] : null;
138
+ }
139
+ continue;
140
+ }
141
+ if (inProc && WORKER_PROC_RE.test(line)) {
142
+ workerProcCount++;
143
+ }
144
+ }
145
+
146
+ samples.push({
147
+ timestamp,
148
+ phase,
149
+ workerProcCount,
150
+ rawBlock: block,
151
+ });
152
+ }
153
+ return samples;
154
+ }
155
+
156
+ /**
157
+ * 3. Evaluate the `DEAD HARNESS` and `STALL` watcher over `vitals.log` + idle timer (v4p).
158
+ *
159
+ * Trips `DEAD_HARNESS_TRIPPED` when the last 2 vitals samples both show
160
+ * `phase === 'run'` (or `phase.startsWith('run')`) and `workerProcCount === 0`.
161
+ * Otherwise trips `STALL_GUARD_TRIPPED` when `idleSeconds >= stallS`.
162
+ *
163
+ * @param {Object} opts
164
+ * @param {string} opts.vitalsText
165
+ * @param {number} [opts.idleSeconds=0]
166
+ * @param {number} [opts.stallS=300]
167
+ * @returns {{ tripped: boolean, code: string|null, reason: string|null, samplesCount: number }}
168
+ */
169
+ export function evaluateVitalsAndStallGuard({
170
+ vitalsText = '',
171
+ idleSeconds = 0,
172
+ stallS = 300,
173
+ } = {}) {
174
+ const samples = parseVitalsLog(vitalsText);
175
+ if (samples.length >= 2) {
176
+ const lastTwo = samples.slice(-2);
177
+ const bothDeadInRun = lastTwo.every(
178
+ (s) => typeof s.phase === 'string' && s.phase.startsWith('run') && s.workerProcCount === 0
179
+ );
180
+ if (bothDeadInRun) {
181
+ return {
182
+ tripped: true,
183
+ code: 'DEAD_HARNESS_TRIPPED',
184
+ reason: '2 consecutive vitals samples in phase=run with 0 active worker processes',
185
+ samplesCount: samples.length,
186
+ };
187
+ }
188
+ }
189
+
190
+ if (Number(idleSeconds) >= Number(stallS)) {
191
+ return {
192
+ tripped: true,
193
+ code: 'STALL_GUARD_TRIPPED',
194
+ reason: `No output row or vitals progress for ${idleSeconds}s (stallS=${stallS}s)`,
195
+ samplesCount: samples.length,
196
+ };
197
+ }
198
+
199
+ return {
200
+ tripped: false,
201
+ code: null,
202
+ reason: null,
203
+ samplesCount: samples.length,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * 4a. Host-RAM / cgroup limit pre-load projection check (v4q).
209
+ * Refuses any step whose projected anonymous host RAM (`(currentAnonMb + projectedDeltaMb) * safetyFactor`)
210
+ * exceeds `min(memTotalMb, cgroupMaxMb)`.
211
+ *
212
+ * @param {Object} opts
213
+ * @param {number} opts.currentAnonMb
214
+ * @param {number} opts.projectedDeltaMb
215
+ * @param {number} opts.memTotalMb
216
+ * @param {number|null} [opts.cgroupMaxMb=null]
217
+ * @param {number} [opts.safetyFactor=1.05]
218
+ * @returns {{ ok: true, projectedMb: number, limitMb: number }}
219
+ */
220
+ export function checkHostRamProjection({
221
+ currentAnonMb,
222
+ projectedDeltaMb,
223
+ memTotalMb,
224
+ cgroupMaxMb = null,
225
+ safetyFactor = 1.05,
226
+ }) {
227
+ const limits = [Number(memTotalMb)];
228
+ if (cgroupMaxMb !== null && cgroupMaxMb !== undefined && Number.isFinite(Number(cgroupMaxMb))) {
229
+ limits.push(Number(cgroupMaxMb));
230
+ }
231
+ const limitMb = Math.min(...limits);
232
+ const rawProjected = Number(currentAnonMb) + Number(projectedDeltaMb);
233
+ const projectedWithMargin = rawProjected * Number(safetyFactor);
234
+
235
+ if (projectedWithMargin > limitMb) {
236
+ throw inputError(
237
+ 'HOST_RAM_PROJECTION_EXCEEDED',
238
+ `Projected anonymous host RAM ${projectedWithMargin.toFixed(1)} MB (${rawProjected} MB * ${safetyFactor}) exceeds host/cgroup ceiling ${limitMb} MB (v4q guard against kernel SIGKILL rc=-9).`
239
+ );
240
+ }
241
+
242
+ return {
243
+ ok: true,
244
+ projectedMb: Number(projectedWithMargin.toFixed(1)),
245
+ limitMb,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * 4b. Between-item fetch (v4q): copies every completed file listed in `current.json`
251
+ * from `remoteDir` to `localDir` BEFORE `current.json` is overwritten for `nextItem`.
252
+ *
253
+ * @param {Object} opts
254
+ * @param {string} opts.remoteDir
255
+ * @param {string} opts.localDir
256
+ * @param {Object|null} [opts.nextItem=null]
257
+ * @returns {{ copiedFiles: string[], previousItem: Object|null }}
258
+ */
259
+ export function runBetweenItemFetch({ remoteDir, localDir, nextItem = null }) {
260
+ mkdirSync(localDir, { recursive: true });
261
+ const currentJsonPath = join(remoteDir, 'current.json');
262
+ const copiedFiles = [];
263
+ let previousItem = null;
264
+
265
+ if (existsSync(currentJsonPath)) {
266
+ previousItem = JSON.parse(readFileSync(currentJsonPath, 'utf-8'));
267
+ const files = Array.isArray(previousItem?.files) ? previousItem.files : [];
268
+ for (const relFile of files) {
269
+ const src = join(remoteDir, relFile);
270
+ if (existsSync(src)) {
271
+ const dst = join(localDir, relFile);
272
+ mkdirSync(join(dst, '..'), { recursive: true });
273
+ copyFileSync(src, dst);
274
+ copiedFiles.push(relFile);
275
+ }
276
+ }
277
+ copyFileSync(currentJsonPath, join(localDir, 'current.json'));
278
+ }
279
+
280
+ if (nextItem) {
281
+ writeFileSync(currentJsonPath, JSON.stringify(nextItem, null, 2) + '\n', 'utf-8');
282
+ }
283
+
284
+ return { copiedFiles, previousItem };
285
+ }
package/lib/export.js CHANGED
@@ -40,7 +40,7 @@ async function sha256File(filePath) {
40
40
  * SDL-H6: validate that a record-relative asset_path does not escape
41
41
  * projectRoot. Rejects absolute paths outright (ASSET_PATH_INVALID) and
42
42
  * rejects any relative path that resolves outside projectRoot
43
- * (ASSET_PATH_TRAVERSAL — e.g. "../../../../Users/victim/.ssh/id_rsa").
43
+ * (ASSET_PATH_TRAVERSAL — e.g. "../../../../etc/passwd").
44
44
  * Returns the resolved absolute path on success.
45
45
  *
46
46
  * This was previously inlined here only (D-007) and never ported to the
@@ -27,6 +27,9 @@ import { readFreezeStatus } from './freeze-stamp.js';
27
27
  import { appendEvent, readEventsFor } from './freeze-events.js';
28
28
  import { inputError } from './errors.js';
29
29
  import { warn } from './log.js';
30
+ import { assertDirectorRatifiedForFreeze } from './canon-review-gate.js';
31
+
32
+ export { assertDirectorRatifiedForFreeze };
30
33
 
31
34
  /**
32
35
  * Best-effort freeze date for an entry: the `at` of its most recent freeze /
@@ -3,13 +3,117 @@
3
3
  * attached to records created from selected run/batch outputs.
4
4
  *
5
5
  * Normalizes source context (run vs batch) into a single shape
6
- * that traces back through brief → workflow → seed → config.
6
+ * that traces back through brief → workflow → seed → config,
7
+ * plus G4 (model licences), G5 (owner + CC-BY-4.0), and G6 (canon link).
7
8
  */
8
9
 
9
10
  import { readFileSync, existsSync } from 'node:fs';
10
11
  import { join } from 'node:path';
11
12
  import { createHash } from 'node:crypto';
12
13
  import { warn } from './log.js';
14
+ import { STUDIO_OWNER, STUDIO_LICENCE } from './records.js';
15
+
16
+ /**
17
+ * Resolve `{ canon_sha, entry_id, entry_sha256 }` from explicit metadata or
18
+ * the project's `canon-build/<canon_sha>/manifest.json` (`per_entity_hashes`).
19
+ */
20
+ export function resolveCanonLink(
21
+ projectRoot,
22
+ {
23
+ canonSha = null,
24
+ entryId = null,
25
+ entrySha256 = null,
26
+ subjectId = null,
27
+ runManifest = null,
28
+ batchManifest = null,
29
+ item = null,
30
+ brief = null,
31
+ } = {},
32
+ ) {
33
+ let resolvedCanonSha =
34
+ canonSha ||
35
+ item?.canon_sha ||
36
+ runManifest?.canon_sha ||
37
+ batchManifest?.canon_sha ||
38
+ brief?.canon_sha ||
39
+ null;
40
+
41
+ if (!resolvedCanonSha && projectRoot) {
42
+ const currentPath = join(projectRoot, 'canon-build', 'CURRENT');
43
+ if (existsSync(currentPath)) {
44
+ try {
45
+ const raw = readFileSync(currentPath, 'utf-8').trim();
46
+ if (raw) resolvedCanonSha = raw;
47
+ } catch {
48
+ // best-effort read of CURRENT pointer
49
+ }
50
+ }
51
+ }
52
+
53
+ let resolvedEntryId =
54
+ entryId ||
55
+ item?.entry_id ||
56
+ runManifest?.entry_id ||
57
+ batchManifest?.entry_id ||
58
+ brief?.entry_id ||
59
+ subjectId ||
60
+ item?.subject_id ||
61
+ runManifest?.subject_id ||
62
+ batchManifest?.subject_id ||
63
+ brief?.subject_id ||
64
+ null;
65
+
66
+ let resolvedEntrySha =
67
+ entrySha256 ||
68
+ item?.entry_sha256 ||
69
+ runManifest?.entry_sha256 ||
70
+ batchManifest?.entry_sha256 ||
71
+ brief?.entry_sha256 ||
72
+ null;
73
+
74
+ if (resolvedCanonSha && projectRoot) {
75
+ const dirName = String(resolvedCanonSha).replace(/:/g, '-');
76
+ const manifestPath = existsSync(join(projectRoot, 'canon-build', String(resolvedCanonSha), 'manifest.json'))
77
+ ? join(projectRoot, 'canon-build', String(resolvedCanonSha), 'manifest.json')
78
+ : join(projectRoot, 'canon-build', dirName, 'manifest.json');
79
+ if (existsSync(manifestPath)) {
80
+ try {
81
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
82
+ const hashes = manifest.per_entity_hashes || {};
83
+ if (resolvedEntryId) {
84
+ if (hashes[resolvedEntryId]) {
85
+ resolvedEntrySha = resolvedEntrySha || hashes[resolvedEntryId];
86
+ } else {
87
+ const suffix = `/${resolvedEntryId}`;
88
+ const matchKey = Object.keys(hashes).find((k) => k.endsWith(suffix));
89
+ if (matchKey) {
90
+ resolvedEntryId = matchKey;
91
+ resolvedEntrySha = resolvedEntrySha || hashes[matchKey];
92
+ }
93
+ }
94
+ } else {
95
+ const keys = Object.keys(hashes);
96
+ if (keys.length === 1) {
97
+ resolvedEntryId = keys[0];
98
+ resolvedEntrySha = resolvedEntrySha || hashes[keys[0]];
99
+ }
100
+ }
101
+ } catch {
102
+ // best-effort manifest lookup
103
+ }
104
+ }
105
+ }
106
+
107
+ if (!resolvedCanonSha && !resolvedEntryId && !resolvedEntrySha) {
108
+ return null;
109
+ }
110
+
111
+ return {
112
+ canon_sha: resolvedCanonSha,
113
+ entry_id: resolvedEntryId,
114
+ entry_sha256: resolvedEntrySha,
115
+ };
116
+ }
13
117
 
14
118
  /**
15
119
  * Build a generation provenance payload for a selected output.
@@ -21,6 +125,9 @@ import { warn } from './log.js';
21
125
  * @param {Object} [opts.batchManifest] — batch manifest if source is batch
22
126
  * @param {Object} opts.item — the selection item (slot_or_output, filename, seed)
23
127
  * @param {string} opts.projectRoot
128
+ * @param {string} [opts.canonSha]
129
+ * @param {string} [opts.entryId]
130
+ * @param {string} [opts.entrySha256]
24
131
  * @returns {Object} generation_provenance block
25
132
  */
26
133
  export function buildGeneratedProvenance({
@@ -30,8 +137,13 @@ export function buildGeneratedProvenance({
30
137
  batchManifest,
31
138
  item,
32
139
  projectRoot,
140
+ canonSha = null,
141
+ entryId = null,
142
+ entrySha256 = null,
33
143
  }) {
34
144
  const prov = {
145
+ owner: STUDIO_OWNER,
146
+ licence: STUDIO_LICENCE,
35
147
  source_type: sourceType,
36
148
  source_id: sourceId,
37
149
  run_id: runManifest.run_id,
@@ -45,12 +157,55 @@ export function buildGeneratedProvenance({
45
157
  prov.batch_id = batchManifest.batch_id;
46
158
  }
47
159
 
160
+ const matchedOutput = runManifest.outputs
161
+ ? runManifest.outputs.find((o) => o.filename === item.filename)
162
+ : null;
163
+
48
164
  // Seed from the item or from the run manifest outputs
49
165
  if (item.seed !== undefined) {
50
166
  prov.seed = item.seed;
51
- } else if (runManifest.outputs) {
52
- const match = runManifest.outputs.find(o => o.filename === item.filename);
53
- if (match?.seed !== undefined) prov.seed = match.seed;
167
+ } else if (matchedOutput?.seed !== undefined) {
168
+ prov.seed = matchedOutput.seed;
169
+ }
170
+
171
+ // Prompt and prompt_sha256 when available on the item or output
172
+ const promptText = item.prompt ?? matchedOutput?.prompt ?? null;
173
+ if (typeof promptText === 'string' && promptText.length > 0) {
174
+ prov.prompt = promptText;
175
+ prov.prompt_sha256 = 'sha256:' + createHash('sha256').update(promptText).digest('hex');
176
+ } else if (item.prompt_sha256 || matchedOutput?.prompt_sha256) {
177
+ prov.prompt_sha256 = item.prompt_sha256 || matchedOutput.prompt_sha256;
178
+ }
179
+
180
+ // Comfy workflow sha and model licences from run manifest pinning (G4, G9)
181
+ const wfSha = runManifest.pinning?.comfy_workflow_sha ?? runManifest.comfy_workflow_sha ?? null;
182
+ if (wfSha) {
183
+ prov.comfy_workflow_sha = wfSha;
184
+ }
185
+ if (runManifest.pinning?.models || runManifest.pinning?.loras) {
186
+ prov.model_licences = {
187
+ cc_by_4_eligible: runManifest.pinning.cc_by_4_eligible ?? null,
188
+ models: Object.fromEntries(
189
+ Object.entries(runManifest.pinning.models || {}).map(([role, m]) => [
190
+ role,
191
+ {
192
+ name: m.name,
193
+ sha256: m.sha256 ?? null,
194
+ licence_id: m.licence_id ?? m.licence?.spdx_id ?? null,
195
+ licence_url: m.licence_url ?? m.licence?.url ?? null,
196
+ date_read: m.date_read ?? m.licence?.verified_at ?? null,
197
+ },
198
+ ]),
199
+ ),
200
+ loras: (runManifest.pinning.loras || []).map((l) => ({
201
+ name: l.name,
202
+ weight: l.weight ?? 1.0,
203
+ sha256: l.sha256 ?? null,
204
+ licence_id: l.licence_id ?? l.licence?.spdx_id ?? null,
205
+ licence_url: l.licence_url ?? l.licence?.url ?? null,
206
+ date_read: l.date_read ?? l.licence?.verified_at ?? null,
207
+ })),
208
+ };
54
209
  }
55
210
 
56
211
  // Adapter target from run manifest
@@ -58,15 +213,17 @@ export function buildGeneratedProvenance({
58
213
  prov.adapter_target = runManifest.adapter_target;
59
214
  }
60
215
 
216
+ let parsedBrief = null;
61
217
  // Subject from run brief or batch manifest
62
218
  if (batchManifest?.subject_id) {
63
219
  prov.subject_id = batchManifest.subject_id;
64
- } else {
220
+ } else if (projectRoot && runManifest.brief_id) {
65
221
  // Try to read subject from brief
66
222
  const briefPath = join(projectRoot, 'briefs', `${runManifest.brief_id}.json`);
67
223
  if (existsSync(briefPath)) {
68
224
  try {
69
225
  const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
226
+ parsedBrief = brief;
70
227
  if (brief.subject_id) prov.subject_id = brief.subject_id;
71
228
  if (brief.parent_brief_id) prov.parent_brief_id = brief.parent_brief_id;
72
229
  } catch (err) {
@@ -87,6 +244,22 @@ export function buildGeneratedProvenance({
87
244
  }
88
245
  }
89
246
 
247
+ const canonLink = resolveCanonLink(projectRoot, {
248
+ canonSha,
249
+ entryId,
250
+ entrySha256,
251
+ subjectId: prov.subject_id,
252
+ runManifest,
253
+ batchManifest,
254
+ item,
255
+ brief: parsedBrief,
256
+ });
257
+ if (canonLink) {
258
+ prov.canon_sha = canonLink.canon_sha;
259
+ prov.entry_id = canonLink.entry_id;
260
+ prov.entry_sha256 = canonLink.entry_sha256;
261
+ }
262
+
90
263
  // Config fingerprint — hash the run manifest for reproducibility tracing
91
264
  prov.config_fingerprint = createHash('sha256')
92
265
  .update(JSON.stringify({