@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,350 @@
1
+ /**
2
+ * Lane 2 canon and prompt authoring adapter (DESIGN-v3 §3.2, §9.2 item 7, §11, G10).
3
+ *
4
+ * Calls a local loopback OpenAI-compatible HTTP endpoint (e.g., llama-server or
5
+ * Ollama serving Muse-Glimmer-30B-A3B-GGUF under Apache-2.0) to draft visual
6
+ * canon entries and per-class prompts + negatives.
7
+ *
8
+ * Every model-authored output starts with `status: "draft"` and `judgment: null`,
9
+ * carrying the model name, digest, SPDX licence (`Apache-2.0`), credit note,
10
+ * `messages_sha256`, and `draft_sha256`.
11
+ */
12
+
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+ import { canonicalize } from './freeze-stamp.js';
17
+ import { inputError as rawInputError } from './errors.js';
18
+
19
+ function inputError(code, msg) {
20
+ return rawInputError(code, `${code}: ${msg}`);
21
+ }
22
+
23
+ export const MIN_LLAMA_SERVER_BUILD = 10353;
24
+
25
+ export const GLIMMER_CREDIT_NOTE =
26
+ 'Canon and prompt drafting assisted by Muse-Glimmer-30B-A3B (GlimmerLabs, Apache-2.0); vision inspection via Qwen3-VL mmproj (Apache-2.0).';
27
+
28
+ export const GLIMMER_DEFAULT_MODEL = Object.freeze({
29
+ name: 'Muse-Glimmer-30B-A3B-Q4_K_M.gguf',
30
+ repo: 'GlimmerLabs/Muse-Glimmer-30B-A3B-GGUF',
31
+ digest: 'sha256:' + '3'.repeat(64),
32
+ licence_id: 'Apache-2.0',
33
+ licence_url: 'https://huggingface.co/GlimmerLabs/Muse-Glimmer-30B-A3B-GGUF/blob/main/LICENSE',
34
+ date_read: '2026-11-05',
35
+ credit_note: GLIMMER_CREDIT_NOTE,
36
+ });
37
+
38
+ export const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
39
+
40
+ function sha256String(str) {
41
+ return 'sha256:' + createHash('sha256').update(str, 'utf-8').digest('hex');
42
+ }
43
+
44
+ function sha256Buffer(buf) {
45
+ return 'sha256:' + createHash('sha256').update(buf).digest('hex');
46
+ }
47
+
48
+ function normalizeSha256(val) {
49
+ if (typeof val !== 'string') return null;
50
+ const trimmed = val.trim().toLowerCase();
51
+ const hex = trimmed.startsWith('sha256:') ? trimmed.slice(7) : trimmed;
52
+ if (!/^[0-9a-f]{64}$/.test(hex)) return null;
53
+ return `sha256:${hex}`;
54
+ }
55
+
56
+ /**
57
+ * Enforce that the Lane 2 LLM endpoint is strictly loopback-bound (§3.2, §6, §11.3).
58
+ *
59
+ * @param {string} endpointUrl
60
+ * @returns {URL}
61
+ */
62
+ export function assertLoopbackEndpoint(endpointUrl) {
63
+ let parsed;
64
+ try {
65
+ parsed = new URL(endpointUrl);
66
+ } catch {
67
+ throw inputError(
68
+ 'LANE2_NON_LOOPBACK_ENDPOINT',
69
+ `Invalid Lane 2 endpoint URL "${endpointUrl}". Must be http://127.0.0.1:<port>/...`
70
+ );
71
+ }
72
+ const host = parsed.hostname.toLowerCase();
73
+ if (!LOOPBACK_HOSTS.has(host)) {
74
+ throw inputError(
75
+ 'LANE2_NON_LOOPBACK_ENDPOINT',
76
+ `Lane 2 endpoint "${endpointUrl}" binds to non-loopback host "${host}". Must bind to 127.0.0.1.`
77
+ );
78
+ }
79
+ return parsed;
80
+ }
81
+
82
+ /**
83
+ * Pre-check the llama.cpp `llama-server` build (`>= b10353` for Qwen3.5 /
84
+ * Muse-Glimmer-30B-A3B) and optional `mmproj` vision projector (§9.2 item 7, §11.3).
85
+ *
86
+ * @param {Object} opts
87
+ * @param {string|number} opts.llamaServerBuild — e.g. 'b10353' or 10400
88
+ * @param {boolean} [opts.vision=false] — whether vision inspection / plate reading is active
89
+ * @param {string} [opts.mmprojPath]
90
+ * @param {string} [opts.mmprojSha256]
91
+ * @param {boolean} [opts.verifyFiles=false]
92
+ * @returns {{ ok: true, buildNumber: number, vision: boolean, mmprojSha256: string|null }}
93
+ */
94
+ export function verifyGlimmerRuntime({
95
+ llamaServerBuild,
96
+ vision = false,
97
+ mmprojPath = null,
98
+ mmprojSha256 = null,
99
+ verifyFiles = false,
100
+ } = {}) {
101
+ const rawBuild = String(llamaServerBuild ?? '').trim();
102
+ const match = /(\d{4,6})/.exec(rawBuild);
103
+ const buildNumber = match ? Number(match[1]) : NaN;
104
+ if (!Number.isFinite(buildNumber) || buildNumber < MIN_LLAMA_SERVER_BUILD) {
105
+ throw inputError(
106
+ 'LANE2_LLAMA_SERVER_TOO_OLD',
107
+ `Muse-Glimmer-30B-A3B (Qwen3.5 architecture) requires llama-server >= b${MIN_LLAMA_SERVER_BUILD}; got "${rawBuild}".`
108
+ );
109
+ }
110
+
111
+ let normalizedMmprojSha = null;
112
+ if (vision) {
113
+ normalizedMmprojSha = normalizeSha256(mmprojSha256);
114
+ if (!mmprojPath || !normalizedMmprojSha) {
115
+ throw inputError(
116
+ 'LANE2_MMPROJ_MISSING',
117
+ 'Vision mode on Muse-Glimmer-30B-A3B requires --mmproj (mmproj-F16.gguf or mmproj-BF16.gguf) with a pinned 64-hex sha256.'
118
+ );
119
+ }
120
+ if (verifyFiles) {
121
+ if (!existsSync(mmprojPath)) {
122
+ throw inputError('LANE2_MMPROJ_MISSING', `mmproj file not found at "${mmprojPath}".`);
123
+ }
124
+ const actualSha = sha256Buffer(readFileSync(mmprojPath));
125
+ if (actualSha !== normalizedMmprojSha) {
126
+ throw inputError(
127
+ 'LANE2_MMPROJ_SHA_MISMATCH',
128
+ `mmproj sha256 mismatch: got ${actualSha}, expected ${normalizedMmprojSha}.`
129
+ );
130
+ }
131
+ }
132
+ }
133
+
134
+ return {
135
+ ok: true,
136
+ buildNumber,
137
+ vision: Boolean(vision),
138
+ mmprojSha256: normalizedMmprojSha,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Compute the canonical content hash of a canon/prompt draft so any edit after
144
+ * Director ratification is detected by the freeze gate.
145
+ *
146
+ * @param {Object} draft
147
+ * @returns {string}
148
+ */
149
+ const RESERVED_MODEL_KEYS = Object.freeze([
150
+ 'ratification',
151
+ 'judgment',
152
+ 'status',
153
+ 'drafted_by_model',
154
+ 'author_model',
155
+ ]);
156
+
157
+ function stripReservedModelKeys(obj) {
158
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return {};
159
+ const cleaned = { ...obj };
160
+ for (const key of RESERVED_MODEL_KEYS) {
161
+ delete cleaned[key];
162
+ }
163
+ return cleaned;
164
+ }
165
+
166
+ export function computeDraftContentSha256(draft) {
167
+ const payload = {
168
+ class_id: draft.class_id || draft.entry_id || null,
169
+ prompt: draft.prompt || '',
170
+ negative_prompt: draft.negative_prompt || '',
171
+ canon_entry_draft: draft.canon_entry_draft || null,
172
+ messages_sha256: draft.messages_sha256 || null,
173
+ };
174
+ return sha256String(canonicalize(payload));
175
+ }
176
+
177
+ /**
178
+ * Build the request messages for drafting a visual canon class and prompt.
179
+ *
180
+ * @param {Object} opts
181
+ * @param {string} opts.classId
182
+ * @param {string} opts.brief
183
+ * @param {Object} [opts.canonContext]
184
+ * @returns {{ messages: Array<Object>, messagesSha256: string }}
185
+ */
186
+ export function buildCanonDraftMessages({ classId, brief, canonContext = {} }) {
187
+ const messages = [
188
+ {
189
+ role: 'system',
190
+ content:
191
+ 'You are the sdlab Lane 2 canon and prompt drafter (Muse-Glimmer-30B-A3B, Apache-2.0). ' +
192
+ 'Return strict JSON with keys: class_id, canon_entry_draft, prompt, negative_prompt.',
193
+ },
194
+ {
195
+ role: 'user',
196
+ content: JSON.stringify({
197
+ class_id: classId,
198
+ brief,
199
+ canon_context: canonContext,
200
+ }),
201
+ },
202
+ ];
203
+ const messagesSha256 = sha256String(canonicalize(messages));
204
+ return { messages, messagesSha256 };
205
+ }
206
+
207
+ /**
208
+ * Draft a visual canon entry and class prompt via a loopback OpenAI-compatible
209
+ * endpoint (`http://127.0.0.1:<port>/v1/chat/completions`) (G10, §3.2, §11).
210
+ *
211
+ * Always emits `status: "draft"` and `judgment: null`.
212
+ *
213
+ * @param {Object} opts
214
+ * @param {string} opts.endpoint
215
+ * @param {string} opts.classId
216
+ * @param {string} opts.brief
217
+ * @param {Object} [opts.canonContext]
218
+ * @param {Object} [opts.modelPin]
219
+ * @param {string} [opts.createdAt]
220
+ * @returns {Promise<Object>}
221
+ */
222
+ export async function draftCanonClassViaEndpoint({
223
+ endpoint,
224
+ classId,
225
+ brief,
226
+ canonContext = {},
227
+ modelPin = GLIMMER_DEFAULT_MODEL,
228
+ createdAt = new Date().toISOString(),
229
+ }) {
230
+ assertLoopbackEndpoint(endpoint);
231
+ if (!classId || typeof classId !== 'string') {
232
+ throw inputError('LANE2_CLASS_ID_REQUIRED', 'draftCanonClassViaEndpoint requires a non-empty classId.');
233
+ }
234
+ const authorModel = {
235
+ name: modelPin.name || GLIMMER_DEFAULT_MODEL.name,
236
+ repo: modelPin.repo || GLIMMER_DEFAULT_MODEL.repo,
237
+ digest: normalizeSha256(modelPin.digest) || GLIMMER_DEFAULT_MODEL.digest,
238
+ licence_id: modelPin.licence_id || 'Apache-2.0',
239
+ licence_url: modelPin.licence_url || GLIMMER_DEFAULT_MODEL.licence_url,
240
+ date_read: modelPin.date_read || GLIMMER_DEFAULT_MODEL.date_read,
241
+ credit_note: modelPin.credit_note || GLIMMER_CREDIT_NOTE,
242
+ };
243
+ if (authorModel.licence_id !== 'Apache-2.0') {
244
+ throw inputError(
245
+ 'LANE2_LICENCE_INVALID',
246
+ `Lane 2 canon authoring model "${authorModel.name}" must carry licence_id "Apache-2.0" (got "${authorModel.licence_id}").`
247
+ );
248
+ }
249
+
250
+ const { messages, messagesSha256 } = buildCanonDraftMessages({ classId, brief, canonContext });
251
+
252
+ const response = await fetch(endpoint, {
253
+ method: 'POST',
254
+ headers: { 'Content-Type': 'application/json' },
255
+ body: JSON.stringify({
256
+ model: authorModel.name,
257
+ temperature: 0.2,
258
+ top_p: 0.9,
259
+ messages,
260
+ }),
261
+ });
262
+
263
+ if (!response.ok) {
264
+ throw inputError(
265
+ 'LANE2_ENDPOINT_ERROR',
266
+ `Lane 2 endpoint ${endpoint} returned HTTP ${response.status}.`
267
+ );
268
+ }
269
+
270
+ const body = await response.json();
271
+ const rawContent = body?.choices?.[0]?.message?.content || '{}';
272
+ let parsedContent;
273
+ try {
274
+ const rawParsed = typeof rawContent === 'string' ? JSON.parse(rawContent) : rawContent;
275
+ parsedContent = stripReservedModelKeys(rawParsed);
276
+ } catch {
277
+ parsedContent = {
278
+ prompt: String(rawContent),
279
+ negative_prompt: '',
280
+ canon_entry_draft: { id: classId, notes: String(rawContent) },
281
+ };
282
+ }
283
+
284
+ const rawCanonEntry =
285
+ parsedContent.canon_entry_draft &&
286
+ typeof parsedContent.canon_entry_draft === 'object' &&
287
+ !Array.isArray(parsedContent.canon_entry_draft)
288
+ ? stripReservedModelKeys(parsedContent.canon_entry_draft)
289
+ : { id: classId };
290
+ const canonEntryDraft = {
291
+ ...rawCanonEntry,
292
+ id: rawCanonEntry.id || classId,
293
+ status: 'draft',
294
+ drafted_by_model: authorModel.name,
295
+ author_model: authorModel,
296
+ };
297
+
298
+ const draftBase = {
299
+ draft_id: `draft_${classId}`,
300
+ class_id: classId,
301
+ entry_id: classId,
302
+ status: 'draft',
303
+ judgment: null,
304
+ prompt: parsedContent.prompt || '',
305
+ negative_prompt: parsedContent.negative_prompt || '',
306
+ canon_entry_draft: canonEntryDraft,
307
+ drafted_by_model: authorModel.name,
308
+ author_model: authorModel,
309
+ messages_sha256: messagesSha256,
310
+ created_at: createdAt,
311
+ };
312
+
313
+ const draftSha256 = computeDraftContentSha256(draftBase);
314
+ return {
315
+ ...draftBase,
316
+ draft_sha256: draftSha256,
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Write `prompts.jsonl` and its companion `prompts.jsonl.sha256` (§7).
322
+ *
323
+ * @param {string} promptsPath
324
+ * @param {Array<Object>} drafts
325
+ * @returns {{ promptsPath: string, sha256Path: string, sha256: string }}
326
+ */
327
+ export function writePromptsJsonl(promptsPath, drafts) {
328
+ mkdirSync(dirname(promptsPath), { recursive: true });
329
+ const content = drafts.map((d) => JSON.stringify(d)).join('\n') + '\n';
330
+ writeFileSync(promptsPath, content, 'utf-8');
331
+ const digest = sha256String(content);
332
+ const sha256Path = `${promptsPath}.sha256`;
333
+ writeFileSync(sha256Path, `${digest} prompts.jsonl\n`, 'utf-8');
334
+ return { promptsPath, sha256Path, sha256: digest };
335
+ }
336
+
337
+ /**
338
+ * Read `prompts.jsonl` rows from disk.
339
+ *
340
+ * @param {string} promptsPath
341
+ * @returns {Array<Object>}
342
+ */
343
+ export function readPromptsJsonl(promptsPath) {
344
+ const raw = readFileSync(promptsPath, 'utf-8');
345
+ return raw
346
+ .split(/\r?\n/)
347
+ .map((l) => l.trim())
348
+ .filter(Boolean)
349
+ .map((line) => JSON.parse(line));
350
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Review gate before canon freeze & self-verification ban (DESIGN-v3 §11.5, §12.6, G11).
3
+ *
4
+ * Enforces:
5
+ * 1. Model-authored canon/prompt drafts are only ever proposed (`status: "draft"`).
6
+ * 2. Freezing or ratifying a draft requires an explicit recorded Director accept
7
+ * (`approver: "human:director"`, non-empty `reason`, and matching `draft_sha256`).
8
+ * 3. A model may never self-approve or be the deciding verifier of a canon
9
+ * entry or prompt it wrote (`SELF_VERIFICATION_FORBIDDEN`).
10
+ */
11
+
12
+ import { computeDraftContentSha256 } from './canon-author.js';
13
+ import { inputError as rawInputError } from './errors.js';
14
+
15
+ function inputError(code, msg) {
16
+ return rawInputError(code, `${code}: ${msg}`);
17
+ }
18
+
19
+ export function normalizeModelFamilyKey(modelId) {
20
+ if (!modelId || typeof modelId !== 'string') return '';
21
+ const lower = modelId
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/^model:/, '')
25
+ .replace(/@sha256:[0-9a-f]{64}$/, '');
26
+ if (!lower) return '';
27
+ if (lower.startsWith('human:') || lower === 'human') return 'human';
28
+ if (lower.startsWith('rule-based:') || lower === 'rule-based') return 'rule-based';
29
+ if (lower.includes('glimmer') || lower.includes('muse-glimmer') || lower.includes('muse_glimmer')) {
30
+ return 'family:muse-glimmer-30b';
31
+ }
32
+ if (lower.includes('qwen')) {
33
+ return 'family:qwen';
34
+ }
35
+ if (lower.includes('gemma')) {
36
+ return 'family:gemma';
37
+ }
38
+ if (lower.includes('internvl') || lower.includes('caprl-internvl')) {
39
+ return 'family:internvl';
40
+ }
41
+ if (lower.includes('siglip') || lower === 'ai-eyes') {
42
+ return 'family:siglip2';
43
+ }
44
+ if (lower.includes('flux') || lower === 'ae.safetensors') {
45
+ return 'family:flux';
46
+ }
47
+ if (lower.includes('sd3') || lower.includes('stable-diffusion-3')) {
48
+ return 'family:sd3';
49
+ }
50
+ if (
51
+ lower.includes('sdxl') ||
52
+ lower.includes('stable-diffusion-xl') ||
53
+ lower.includes('dreamshaper') ||
54
+ lower.includes('juggernaut') ||
55
+ lower.includes('pony') ||
56
+ lower.includes('illustrious') ||
57
+ lower.includes('animagine')
58
+ ) {
59
+ return 'family:sdxl';
60
+ }
61
+ return lower;
62
+ }
63
+
64
+ /**
65
+ * Enforce the §12.6 self-verification ban: a model (such as Muse-Glimmer-30B)
66
+ * may never be the deciding reader/verifier of a canon entry or prompt it wrote.
67
+ *
68
+ * @param {Object} opts
69
+ * @param {string|Object} opts.authorModel
70
+ * @param {string|Object} opts.verifierModel
71
+ * @param {string} [opts.context='artifact']
72
+ * @returns {{ ok: true, authorFamily: string, verifierFamily: string }}
73
+ */
74
+ export function assertNoSelfVerification({ authorModel, verifierModel, context = 'artifact' } = {}) {
75
+ const authorRaw = typeof authorModel === 'object' ? authorModel?.name : authorModel;
76
+ const verifierRaw = typeof verifierModel === 'object' ? verifierModel?.name : verifierModel;
77
+
78
+ const authorFamily = normalizeModelFamilyKey(authorRaw);
79
+ const verifierFamily = normalizeModelFamilyKey(verifierRaw);
80
+
81
+ if (
82
+ authorFamily &&
83
+ verifierFamily &&
84
+ authorFamily !== 'human' &&
85
+ verifierFamily !== 'human' &&
86
+ authorFamily === verifierFamily
87
+ ) {
88
+ throw inputError(
89
+ 'SELF_VERIFICATION_FORBIDDEN',
90
+ `Self-verification forbidden on ${context}: verifier "${verifierRaw}" belongs to the same model family ("${verifierFamily}") as author "${authorRaw}".`
91
+ );
92
+ }
93
+
94
+ return { ok: true, authorFamily, verifierFamily };
95
+ }
96
+
97
+ /**
98
+ * Record an explicit Director ratification on a model-authored canon/prompt draft (G11).
99
+ *
100
+ * @param {Object} opts
101
+ * @param {Object} opts.draft
102
+ * @param {string} opts.approver — must be "human:director" (or "human:<id>")
103
+ * @param {string} opts.reason
104
+ * @param {string} [opts.at]
105
+ * @returns {Object} ratified draft
106
+ */
107
+ export function ratifyCanonDraft({
108
+ draft,
109
+ approver,
110
+ reason,
111
+ at = new Date().toISOString(),
112
+ } = {}) {
113
+ if (!draft || typeof draft !== 'object') {
114
+ throw inputError('CANON_DRAFT_INVALID', 'ratifyCanonDraft requires a draft object.');
115
+ }
116
+ const approverStr = String(approver || '').trim();
117
+ if (approverStr.startsWith('model:') || !approverStr.startsWith('human:')) {
118
+ throw inputError(
119
+ 'CANON_SELF_APPROVAL_FORBIDDEN',
120
+ `Canon draft "${draft.class_id || draft.draft_id}" can only be ratified by a human Director ("human:director"), got "${approverStr}".`
121
+ );
122
+ }
123
+ const reasonStr = String(reason || '').trim();
124
+ if (!reasonStr) {
125
+ throw inputError(
126
+ 'CANON_RATIFICATION_REASON_REQUIRED',
127
+ `Ratifying canon draft "${draft.class_id || draft.draft_id}" requires a non-empty reason.`
128
+ );
129
+ }
130
+
131
+ const contentSha = computeDraftContentSha256(draft);
132
+ return {
133
+ ...draft,
134
+ status: 'ratified',
135
+ judgment: 'accepted',
136
+ draft_sha256: contentSha,
137
+ ratification: {
138
+ status: 'accepted',
139
+ approver: approverStr,
140
+ reason: reasonStr,
141
+ at,
142
+ draft_sha256: contentSha,
143
+ },
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Assert that a canon entry or prompt draft has been explicitly ratified by
149
+ * the Director before it can be frozen (G11, §11.5).
150
+ *
151
+ * Refuses:
152
+ * - `status === "draft"` or missing `ratification` on a model-authored draft
153
+ * - `ratification.approver` not starting with `"human:"`
154
+ * - `ratification.draft_sha256` mismatch against the current draft content
155
+ *
156
+ * @param {Object} draftOrFrontmatter
157
+ * @returns {{ ok: true, ratification: Object|null }}
158
+ */
159
+ export function assertDirectorRatifiedForFreeze(draftOrFrontmatter) {
160
+ if (!draftOrFrontmatter || typeof draftOrFrontmatter !== 'object') {
161
+ throw inputError('CANON_DRAFT_INVALID', 'Cannot verify freeze gate on empty draft/frontmatter.');
162
+ }
163
+
164
+ const status = draftOrFrontmatter.status || draftOrFrontmatter.draft_status || null;
165
+ const isModelDraft =
166
+ status === 'draft' ||
167
+ status === 'ratified' ||
168
+ Boolean(draftOrFrontmatter.ratification) ||
169
+ Boolean(draftOrFrontmatter.author_model) ||
170
+ Boolean(draftOrFrontmatter.drafted_by_model);
171
+
172
+ if (!isModelDraft) {
173
+ return { ok: true, ratification: null };
174
+ }
175
+
176
+ const ratification = draftOrFrontmatter.ratification;
177
+ if (
178
+ status === 'draft' ||
179
+ !ratification ||
180
+ ratification.status !== 'accepted' ||
181
+ typeof ratification.approver !== 'string' ||
182
+ !ratification.approver.startsWith('human:') ||
183
+ typeof ratification.reason !== 'string' ||
184
+ !ratification.reason.trim()
185
+ ) {
186
+ throw inputError(
187
+ 'CANON_DRAFT_UNRATIFIED',
188
+ `Refusing to freeze model-authored draft "${draftOrFrontmatter.class_id || draftOrFrontmatter.id || 'unknown'}": ` +
189
+ 'status is still "draft" or lacks an explicit human Director ratification (`approver: "human:director"`, `reason`).'
190
+ );
191
+ }
192
+
193
+ if (draftOrFrontmatter.prompt !== undefined || draftOrFrontmatter.canon_entry_draft !== undefined) {
194
+ const actualSha = computeDraftContentSha256(draftOrFrontmatter);
195
+ if (ratification.draft_sha256 !== actualSha) {
196
+ throw inputError(
197
+ 'CANON_DRAFT_SHA_MISMATCH',
198
+ `Draft content for "${draftOrFrontmatter.class_id || draftOrFrontmatter.id}" changed after Director ratification ` +
199
+ `(got ${actualSha}, ratified ${ratification.draft_sha256}). Re-ratify before freezing.`
200
+ );
201
+ }
202
+ }
203
+
204
+ return { ok: true, ratification };
205
+ }