@mcptoolshop/style-dataset-lab 3.1.0 → 3.3.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 (61) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.es.md +96 -63
  3. package/README.fr.md +97 -64
  4. package/README.hi.md +108 -77
  5. package/README.it.md +97 -64
  6. package/README.ja.md +105 -72
  7. package/README.md +33 -0
  8. package/README.pt-BR.md +94 -61
  9. package/README.zh.md +98 -65
  10. package/bin/sdlab.js +71 -0
  11. package/lib/adapters/ai-toolkit.js +209 -0
  12. package/lib/adapters/comfyui-runner.js +36 -2
  13. package/lib/adapters/comfyui-workflows.js +133 -2
  14. package/lib/adapters/diffusers-lora.js +46 -66
  15. package/lib/adapters/generic-image-caption.js +22 -50
  16. package/lib/canon-build/build.js +450 -0
  17. package/lib/canon-build/emitters/base.js +122 -0
  18. package/lib/canon-build/emitters/character.js +167 -0
  19. package/lib/canon-build/emitters/deity.js +165 -0
  20. package/lib/canon-build/emitters/faction.js +161 -0
  21. package/lib/canon-build/emitters/index.js +38 -0
  22. package/lib/canon-build/emitters/location.js +153 -0
  23. package/lib/canon-build/emitters/monster.js +131 -0
  24. package/lib/canon-build/emitters/relic.js +142 -0
  25. package/lib/canon-build/emitters/ship.js +154 -0
  26. package/lib/canon-build/emitters/species.js +165 -0
  27. package/lib/canon-build/frontmatter.js +76 -0
  28. package/lib/canon-build/lane-resolve.js +64 -0
  29. package/lib/canon-build/load-config.js +117 -0
  30. package/lib/canon-build/load-entry.js +62 -0
  31. package/lib/canon-build/load-schema.js +46 -0
  32. package/lib/captions.js +197 -0
  33. package/lib/config.js +19 -0
  34. package/lib/critique-engine.js +46 -1
  35. package/lib/critique-render.js +45 -0
  36. package/lib/freeze-events.js +109 -0
  37. package/lib/freeze-gate.js +208 -0
  38. package/lib/freeze-stamp.js +161 -0
  39. package/lib/rows.js +228 -0
  40. package/lib/run-manifest.js +308 -0
  41. package/lib/snapshot.js +7 -1
  42. package/lib/training-adapters.js +5 -0
  43. package/lib/training-packages.js +24 -18
  44. package/lib/training-profiles.js +33 -0
  45. package/lib/verifier.js +67 -0
  46. package/package.json +12 -3
  47. package/scripts/_batch_cut.py +71 -0
  48. package/scripts/_seam_cutout.py +74 -0
  49. package/scripts/canon-build.js +88 -0
  50. package/scripts/canon-drift.js +193 -0
  51. package/scripts/canon-freeze-status.js +85 -0
  52. package/scripts/canon-freeze.js +211 -0
  53. package/scripts/canon-unfreeze.js +137 -0
  54. package/scripts/critique.js +32 -7
  55. package/scripts/curate.js +33 -0
  56. package/scripts/generate-controlnet.js +12 -0
  57. package/scripts/generate-identity.js +20 -4
  58. package/scripts/generate-ipadapter.js +11 -0
  59. package/scripts/generate.js +262 -102
  60. package/scripts/qwen_generate.py +327 -0
  61. package/scripts/run-generate.js +3 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Append-only event log for freeze transitions (D4).
3
+ *
4
+ * Lives at `<project>/canon-build/freeze-events.jsonl`. One line per event,
5
+ * JSON, stable key order. The jsonl is the audit source of truth; the
6
+ * `freeze.overrides[]` denormalization on the entry is a glance cache.
7
+ *
8
+ * Atomic append: we write the new event as a line-append operation at the
9
+ * OS level. Node's `appendFile` is atomic per-line on POSIX but not
10
+ * guaranteed on Windows when two processes race. For single-operator
11
+ * workflow this is fine; for a paranoid hardening pass later we can add
12
+ * a lockfile. Documented as a known hazard.
13
+ */
14
+
15
+ import { readFile, appendFile, mkdir } from 'node:fs/promises';
16
+ import { join, dirname } from 'node:path';
17
+ import { existsSync } from 'node:fs';
18
+
19
+ export const FREEZE_EVENT_TYPES = Object.freeze(['freeze', 'unfreeze', 'override', 'bypass']);
20
+
21
+ export function eventsPath(projectRoot) {
22
+ return join(projectRoot, 'canon-build', 'freeze-events.jsonl');
23
+ }
24
+
25
+ /**
26
+ * Append one event to the log. Creates the parent dir + file on first write.
27
+ *
28
+ * @param {string} projectRoot — absolute path to the sdlab project root
29
+ * @param {Object} event — at minimum: {type, entity_id, by, reason}
30
+ */
31
+ export async function appendEvent(projectRoot, event) {
32
+ if (!FREEZE_EVENT_TYPES.includes(event.type)) {
33
+ throw new Error(`appendEvent: unknown event type "${event.type}" (allowed: ${FREEZE_EVENT_TYPES.join(', ')})`);
34
+ }
35
+ if (!event.entity_id) throw new Error('appendEvent: entity_id required');
36
+ if (!event.by) throw new Error('appendEvent: by required');
37
+ if (!event.reason) throw new Error('appendEvent: reason required');
38
+
39
+ const path = eventsPath(projectRoot);
40
+ await mkdir(dirname(path), { recursive: true });
41
+
42
+ // Serialize with a stable key order so `diff`-style reading stays readable.
43
+ const ordered = {
44
+ at: event.at || new Date().toISOString(),
45
+ build_hash: event.build_hash ?? null,
46
+ type: event.type,
47
+ entity_id: event.entity_id,
48
+ schema_kind: event.schema_kind ?? null,
49
+ by: event.by,
50
+ reason: event.reason,
51
+ prior_status: event.prior_status ?? null,
52
+ new_status: event.new_status ?? null,
53
+ watch_fields: event.watch_fields ?? null,
54
+ prior_build_hash: event.prior_build_hash ?? null,
55
+ };
56
+
57
+ await appendFile(path, JSON.stringify(ordered) + '\n', 'utf-8');
58
+ return ordered;
59
+ }
60
+
61
+ /**
62
+ * Read all events from the log, in append order.
63
+ */
64
+ export async function readEvents(projectRoot) {
65
+ const path = eventsPath(projectRoot);
66
+ if (!existsSync(path)) return [];
67
+ const text = await readFile(path, 'utf-8');
68
+ const events = [];
69
+ const lines = text.split('\n');
70
+ for (let i = 0; i < lines.length; i++) {
71
+ const line = lines[i].trim();
72
+ if (!line) continue;
73
+ try {
74
+ events.push(JSON.parse(line));
75
+ } catch (err) {
76
+ throw new Error(`readEvents: line ${i + 1} of ${path} is not valid JSON (${err.message})`);
77
+ }
78
+ }
79
+ return events;
80
+ }
81
+
82
+ /**
83
+ * Return events for a specific entity, in append order.
84
+ */
85
+ export async function readEventsFor(projectRoot, entityId) {
86
+ const all = await readEvents(projectRoot);
87
+ return all.filter((e) => e.entity_id === entityId);
88
+ }
89
+
90
+ /**
91
+ * Return events newer than a given build_hash. Used by `sdlab canon drift`
92
+ * to compute "overrides since last build."
93
+ *
94
+ * Semantics: events are "since X" when their `build_hash` field differs
95
+ * from X. Since the log is append-only, that approximates temporal ordering.
96
+ */
97
+ export async function readEventsSince(projectRoot, buildHash) {
98
+ if (!buildHash) return readEvents(projectRoot);
99
+ const all = await readEvents(projectRoot);
100
+ // Find last event with matching build_hash; return everything after it.
101
+ let cutoff = -1;
102
+ for (let i = all.length - 1; i >= 0; i--) {
103
+ if (all[i].build_hash === buildHash) {
104
+ cutoff = i;
105
+ break;
106
+ }
107
+ }
108
+ return cutoff < 0 ? all : all.slice(cutoff + 1);
109
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Freeze enforcement gate (D3).
3
+ *
4
+ * Called from CLI commands before any write to a canon-bound asset or record.
5
+ * Semantics:
6
+ * - frozen → block outright (CANON_ENTRY_FROZEN)
7
+ * - soft-advisory → block unless --i-know + --reason; on override, append
8
+ * a 'bypass' event to freeze-events.jsonl and proceed
9
+ * - auto → no block
10
+ * - on-canon-change → no block at write time; canon-build surfaces drift
11
+ * in its summary output (enforcement lives at the build
12
+ * boundary, not the CLI boundary, because drift is a
13
+ * build-observable condition)
14
+ *
15
+ * The canon reverse-map helpers resolve an action's identifier (subject id,
16
+ * asset path) to a canon entry so the gate can look up its freeze state.
17
+ * Actions without a canon binding (prompt-pack-driven generate, reingest of
18
+ * un-bound generated outputs) skip the gate naturally: no entry = nothing
19
+ * to protect.
20
+ */
21
+
22
+ import { join } from 'node:path';
23
+ import { existsSync } from 'node:fs';
24
+ import { loadBuildConfig } from './canon-build/load-config.js';
25
+ import { loadCanonEntriesInDir, entryId } from './canon-build/load-entry.js';
26
+ import { readFreezeStatus } from './freeze-stamp.js';
27
+ import { appendEvent, readEventsFor } from './freeze-events.js';
28
+ import { inputError } from './errors.js';
29
+
30
+ /**
31
+ * Best-effort freeze date for an entry: the `at` of its most recent freeze /
32
+ * override event. Returns null when the log is absent/unreadable (the freeze
33
+ * block itself carries no timestamp). Used only to enrich the contrastive
34
+ * refuse message — never load-bearing for the gate decision.
35
+ */
36
+ async function latestFreezeAt(projectRoot, id) {
37
+ try {
38
+ const events = await readEventsFor(projectRoot, id);
39
+ for (let i = events.length - 1; i >= 0; i--) {
40
+ if (events[i].type === 'freeze' || events[i].type === 'override') return events[i].at;
41
+ }
42
+ } catch { /* no log / unreadable → no date */ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Contrastive lead (Horvitz CHI 1999): state the DEFAULT action and the system's
48
+ * reasoning — who froze it, when, and why — before the override path. This is the
49
+ * "you probably expected to just regenerate; I'm refusing, here's why" framing.
50
+ */
51
+ function contrastiveLead(id, status, freeze, frozenAt) {
52
+ const by = freeze?.frozen_by || 'unknown';
53
+ const reason = freeze?.frozen_reason || '(no reason recorded)';
54
+ const when = frozenAt ? ` on ${String(frozenAt).slice(0, 10)}` : '';
55
+ return `Default: REFUSE — "${id}" is ${status} frozen by ${by}${when}, reason: "${reason}".`;
56
+ }
57
+
58
+ /** Render the watched-field list for the contrastive override condition. */
59
+ function watchList(freeze) {
60
+ const w = freeze?.watch_fields || [];
61
+ return w.length ? `[${w.join(', ')}]` : '[no watched fields]';
62
+ }
63
+
64
+ /**
65
+ * Load every canon entry in a project, keyed by schema.
66
+ *
67
+ * Returns a flat array of `{ entry, schemaName }` objects.
68
+ * Returns [] (no-op) when the project has no canon-build config — meaning
69
+ * the project isn't canon-integrated yet, so the gate has nothing to enforce.
70
+ */
71
+ export async function loadAllCanonEntries(projectRoot) {
72
+ const configPath = join(projectRoot, 'canon-build', 'config.json');
73
+ if (!existsSync(configPath)) return [];
74
+ let config;
75
+ try {
76
+ config = await loadBuildConfig(configPath, projectRoot);
77
+ } catch {
78
+ // Invalid config → degrade to "no gate" rather than blocking every write.
79
+ // The operator will hit the config error on `sdlab canon build`.
80
+ return [];
81
+ }
82
+ const entries = [];
83
+ for (const [schemaName, subdir] of Object.entries(config.entity_dirs)) {
84
+ const dir = join(config.canon_root, subdir);
85
+ const loaded = await loadCanonEntriesInDir(dir);
86
+ for (const entry of loaded) {
87
+ entries.push({ entry, schemaName });
88
+ }
89
+ }
90
+ return entries;
91
+ }
92
+
93
+ /**
94
+ * Find the canon entry whose `id` matches the given subject identifier.
95
+ * Returns { entry, schemaName } or null.
96
+ */
97
+ export async function resolveEntryBySubject(projectRoot, subjectId) {
98
+ if (!subjectId) return null;
99
+ const all = await loadAllCanonEntries(projectRoot);
100
+ const hit = all.find(({ entry }) => entryId(entry) === subjectId);
101
+ return hit || null;
102
+ }
103
+
104
+ /**
105
+ * Find the canon entry whose `visual.reference_plate_uri` (or top-level
106
+ * `reference_plate_uri`, for monsters) equals the given asset path.
107
+ * Returns { entry, schemaName } or null.
108
+ */
109
+ export async function resolveEntryByAssetPath(projectRoot, assetPath) {
110
+ if (!assetPath) return null;
111
+ const all = await loadAllCanonEntries(projectRoot);
112
+ const normalized = assetPath.replace(/\\/g, '/');
113
+ const hit = all.find(({ entry }) => {
114
+ const fm = entry.frontmatter;
115
+ const plate = (fm.visual?.reference_plate_uri || fm.reference_plate_uri || '').replace(/\\/g, '/');
116
+ return plate && plate === normalized;
117
+ });
118
+ return hit || null;
119
+ }
120
+
121
+ /**
122
+ * Core gate. Throws CANON_ENTRY_FROZEN on block; on soft-advisory bypass,
123
+ * appends a 'bypass' event and returns without throwing.
124
+ *
125
+ * @param {Object} args
126
+ * @param {string} args.projectRoot
127
+ * @param {Object|null} args.resolved — { entry, schemaName } or null
128
+ * @param {string} args.action — short verb for error context ("generate", "painterly", "curate", ...)
129
+ * @param {boolean} [args.allowSoftAdvisoryBypass] — the command supports --i-know
130
+ * @param {string} [args.bypassReason] — value of --reason when bypassing
131
+ * @param {string} [args.by] — who is invoking (default 'mike')
132
+ */
133
+ export async function assertNotFrozen({
134
+ projectRoot,
135
+ resolved,
136
+ action,
137
+ allowSoftAdvisoryBypass = false,
138
+ bypassReason = null,
139
+ by = 'mike',
140
+ }) {
141
+ if (!resolved || !resolved.entry) return; // nothing canon-bound; no gate
142
+ const fm = resolved.entry.frontmatter;
143
+ const status = readFreezeStatus(fm);
144
+ const id = entryId(resolved.entry);
145
+
146
+ // Contrastive refuse messaging (Horvitz CHI 1999): lead with the default
147
+ // action + the system's reasoning (who/when/why) pulled from the freeze
148
+ // stamp, then state the override path and its condition (don't touch the
149
+ // watched fields). frozenAt is best-effort from the event log.
150
+ if (status === 'frozen' || status === 'soft-advisory') {
151
+ const frozenAt = await latestFreezeAt(projectRoot, id);
152
+ const lead = contrastiveLead(id, status, fm.freeze, frozenAt);
153
+ const watched = watchList(fm.freeze);
154
+
155
+ if (status === 'frozen') {
156
+ throw inputError(
157
+ 'CANON_ENTRY_FROZEN',
158
+ `${lead} A hard freeze has no inline bypass, so "${action}" will not proceed.`,
159
+ `Unfreeze with: sdlab canon unfreeze ${id} --reason "<why>" (appended to canon-build/freeze-events.jsonl as an audit record). This freeze watches ${watched}.`,
160
+ );
161
+ }
162
+
163
+ // soft-advisory
164
+ if (!allowSoftAdvisoryBypass) {
165
+ throw inputError(
166
+ 'CANON_ENTRY_FROZEN',
167
+ `${lead} "${action}" has no --i-know bypass.`,
168
+ `Run: sdlab canon unfreeze ${id} --reason "<why>" for a proper unfreeze, or pick a command that accepts --i-know. Watched fields: ${watched}.`,
169
+ );
170
+ }
171
+ if (!bypassReason) {
172
+ throw inputError(
173
+ 'CANON_ENTRY_FROZEN',
174
+ `${lead} Override requires --i-know AND --reason.`,
175
+ `Re-run with --i-know --reason "<why>" ONLY if your change does not touch the watched fields ${watched}. The bypass is logged to canon-build/freeze-events.jsonl.`,
176
+ );
177
+ }
178
+ // Bypass allowed — log and proceed.
179
+ await appendEvent(projectRoot, {
180
+ type: 'bypass',
181
+ entity_id: id,
182
+ schema_kind: resolved.schemaName?.replace('.schema.json', ''),
183
+ by,
184
+ reason: bypassReason,
185
+ prior_status: 'soft-advisory',
186
+ new_status: 'soft-advisory',
187
+ });
188
+ return;
189
+ }
190
+
191
+ // auto and on-canon-change: no write-side block.
192
+ }
193
+
194
+ /**
195
+ * Convenience wrapper: resolve by subject id, then gate.
196
+ */
197
+ export async function assertNotFrozenBySubject(projectRoot, subjectId, options = {}) {
198
+ const resolved = await resolveEntryBySubject(projectRoot, subjectId);
199
+ return assertNotFrozen({ projectRoot, resolved, ...options });
200
+ }
201
+
202
+ /**
203
+ * Convenience wrapper: resolve by asset_path, then gate.
204
+ */
205
+ export async function assertNotFrozenByAssetPath(projectRoot, assetPath, options = {}) {
206
+ const resolved = await resolveEntryByAssetPath(projectRoot, assetPath);
207
+ return assertNotFrozen({ projectRoot, resolved, ...options });
208
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Freeze block helpers — read/write the `freeze` block on a canon entry,
3
+ * compute watch-field hashes for the witness chain (D1).
4
+ *
5
+ * The `freeze` block (schema v2.0.0+) is the sole freeze surface on canon
6
+ * entries; the legacy `visual_locked_at` + `regen_policy` fields have been
7
+ * removed per post-research D10. See
8
+ * memory/freeze-tooling-research-2026-04-24.md for the full contract.
9
+ */
10
+
11
+ import { createHash } from 'node:crypto';
12
+ import { getByPath } from './canon-build/load-config.js';
13
+
14
+ /**
15
+ * The four legal freeze statuses. Entries without a `freeze` block behave
16
+ * as `auto` (permissive default — see post-research D8).
17
+ */
18
+ export const FREEZE_STATUSES = Object.freeze(['auto', 'frozen', 'soft-advisory', 'on-canon-change']);
19
+
20
+ /**
21
+ * Read the normalized freeze status for an entry frontmatter.
22
+ * Missing block / missing status / invalid value all collapse to `auto`.
23
+ */
24
+ export function readFreezeStatus(frontmatter) {
25
+ const status = frontmatter?.freeze?.status;
26
+ if (typeof status !== 'string') return 'auto';
27
+ if (!FREEZE_STATUSES.includes(status)) return 'auto';
28
+ return status;
29
+ }
30
+
31
+ /**
32
+ * Whether an entry's freeze status blocks a regen-like action outright.
33
+ * `frozen` blocks. `soft-advisory` blocks unless the caller overrides via
34
+ * --i-know + --reason. `auto` / `on-canon-change` do not block.
35
+ */
36
+ export function freezeBlocksAction(status) {
37
+ return status === 'frozen';
38
+ }
39
+
40
+ /**
41
+ * Whether an entry's freeze status warrants a soft-advisory bypass path.
42
+ */
43
+ export function freezeIsAdvisory(status) {
44
+ return status === 'soft-advisory';
45
+ }
46
+
47
+ /**
48
+ * Stable canonical JSON serialization for an object used as hash input.
49
+ *
50
+ * Sorts object keys; arrays preserve order (arrays are meaningful sequences).
51
+ * Normalizes `undefined` → omitted. Bit-stability matters because the witness
52
+ * chain compares hashes across builds — the same semantic value must hash
53
+ * identically regardless of key insertion order or JSON formatting drift.
54
+ */
55
+ export function canonicalize(value) {
56
+ if (value === null) return 'null';
57
+ if (value === undefined) return 'null';
58
+ if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null';
59
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
60
+ if (typeof value === 'string') return JSON.stringify(value);
61
+ if (Array.isArray(value)) {
62
+ return '[' + value.map(canonicalize).join(',') + ']';
63
+ }
64
+ if (typeof value === 'object') {
65
+ const keys = Object.keys(value).sort();
66
+ const parts = [];
67
+ for (const k of keys) {
68
+ if (value[k] === undefined) continue;
69
+ parts.push(JSON.stringify(k) + ':' + canonicalize(value[k]));
70
+ }
71
+ return '{' + parts.join(',') + '}';
72
+ }
73
+ return 'null';
74
+ }
75
+
76
+ /**
77
+ * Compute the watch-hash for an entry given its frontmatter, the list of
78
+ * watched fields, and the schema version.
79
+ *
80
+ * The hash is sha256 of a canonical JSON representation of the watched
81
+ * field values + the schema version. Any change to a watched field, or a
82
+ * schema version bump, invalidates the hash.
83
+ *
84
+ * @param {Object} frontmatter — entry frontmatter object
85
+ * @param {Array<string>} watchFields — dotted paths (e.g. "visual.silhouette_cue")
86
+ * @param {string} schemaVersion — schema `version` string
87
+ * @returns {string} 64-char hex sha256 digest
88
+ */
89
+ export function computeWatchHash(frontmatter, watchFields, schemaVersion) {
90
+ const payload = {
91
+ schema_version: schemaVersion,
92
+ watched: {},
93
+ };
94
+ for (const field of watchFields) {
95
+ payload.watched[field] = getByPath(frontmatter, field) ?? null;
96
+ }
97
+ return createHash('sha256').update(canonicalize(payload)).digest('hex');
98
+ }
99
+
100
+ /**
101
+ * Resolve the effective watch-field list for an entry.
102
+ *
103
+ * Precedence (entry override wins over build-config default):
104
+ * 1. `frontmatter.freeze.watch_fields` if present + non-empty
105
+ * 2. `buildConfig.freeze_watch_defaults[schemaName]` if present
106
+ * 3. [] (empty — a freeze with no watch-fields stamps only the schema
107
+ * version, useful for "freeze but don't drift-check" intent)
108
+ */
109
+ export function resolveWatchFields(frontmatter, schemaName, buildConfig) {
110
+ const entryOverride = frontmatter?.freeze?.watch_fields;
111
+ if (Array.isArray(entryOverride) && entryOverride.length > 0) {
112
+ return entryOverride;
113
+ }
114
+ const defaults = buildConfig?.freeze_watch_defaults?.[schemaName];
115
+ if (Array.isArray(defaults) && defaults.length > 0) {
116
+ return defaults;
117
+ }
118
+ return [];
119
+ }
120
+
121
+ /**
122
+ * Build a fresh freeze block for writing to an entry (canon freeze command).
123
+ *
124
+ * @param {Object} args
125
+ * @param {string} args.status — 'frozen' | 'soft-advisory' | 'on-canon-change'
126
+ * @param {string} args.lockedAtBuild — generated_from of the witness build
127
+ * @param {string} [args.lockedAtCanonVersion]
128
+ * @param {string} args.frozenBy
129
+ * @param {string} args.frozenReason
130
+ * @param {Array<string>} [args.watchFields]
131
+ * @param {Array} [args.overrides] — existing override history (preserved on re-freeze)
132
+ */
133
+ export function buildFreezeBlock({
134
+ status,
135
+ lockedAtBuild,
136
+ lockedAtCanonVersion,
137
+ frozenBy,
138
+ frozenReason,
139
+ watchFields,
140
+ overrides,
141
+ }) {
142
+ const block = { status };
143
+ if (lockedAtBuild) block.locked_at_build = lockedAtBuild;
144
+ if (lockedAtCanonVersion) block.locked_at_canon_version = lockedAtCanonVersion;
145
+ if (frozenBy) block.frozen_by = frozenBy;
146
+ if (frozenReason) block.frozen_reason = frozenReason;
147
+ if (Array.isArray(watchFields) && watchFields.length) block.watch_fields = watchFields;
148
+ if (Array.isArray(overrides) && overrides.length) block.overrides = overrides;
149
+ return block;
150
+ }
151
+
152
+ /**
153
+ * Append an override record to an entry's freeze.overrides[] list.
154
+ * Denormalizes the event into the entry for glance-reading; the authoritative
155
+ * source of truth is the append-only log at canon-build/freeze-events.jsonl.
156
+ */
157
+ export function appendOverrideToFreezeBlock(freeze, override) {
158
+ const next = { ...freeze };
159
+ next.overrides = Array.isArray(freeze?.overrides) ? [...freeze.overrides, override] : [override];
160
+ return next;
161
+ }