@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.
- package/CHANGELOG.md +61 -0
- package/README.es.md +96 -63
- package/README.fr.md +97 -64
- package/README.hi.md +108 -77
- package/README.it.md +97 -64
- package/README.ja.md +105 -72
- package/README.md +33 -0
- package/README.pt-BR.md +94 -61
- package/README.zh.md +98 -65
- package/bin/sdlab.js +71 -0
- package/lib/adapters/ai-toolkit.js +209 -0
- package/lib/adapters/comfyui-runner.js +36 -2
- package/lib/adapters/comfyui-workflows.js +133 -2
- package/lib/adapters/diffusers-lora.js +46 -66
- package/lib/adapters/generic-image-caption.js +22 -50
- package/lib/canon-build/build.js +450 -0
- package/lib/canon-build/emitters/base.js +122 -0
- package/lib/canon-build/emitters/character.js +167 -0
- package/lib/canon-build/emitters/deity.js +165 -0
- package/lib/canon-build/emitters/faction.js +161 -0
- package/lib/canon-build/emitters/index.js +38 -0
- package/lib/canon-build/emitters/location.js +153 -0
- package/lib/canon-build/emitters/monster.js +131 -0
- package/lib/canon-build/emitters/relic.js +142 -0
- package/lib/canon-build/emitters/ship.js +154 -0
- package/lib/canon-build/emitters/species.js +165 -0
- package/lib/canon-build/frontmatter.js +76 -0
- package/lib/canon-build/lane-resolve.js +64 -0
- package/lib/canon-build/load-config.js +117 -0
- package/lib/canon-build/load-entry.js +62 -0
- package/lib/canon-build/load-schema.js +46 -0
- package/lib/captions.js +197 -0
- package/lib/config.js +19 -0
- package/lib/critique-engine.js +46 -1
- package/lib/critique-render.js +45 -0
- package/lib/freeze-events.js +109 -0
- package/lib/freeze-gate.js +208 -0
- package/lib/freeze-stamp.js +161 -0
- package/lib/rows.js +228 -0
- package/lib/run-manifest.js +308 -0
- package/lib/snapshot.js +7 -1
- package/lib/training-adapters.js +5 -0
- package/lib/training-packages.js +24 -18
- package/lib/training-profiles.js +33 -0
- package/lib/verifier.js +67 -0
- package/package.json +12 -3
- package/scripts/_batch_cut.py +71 -0
- package/scripts/_seam_cutout.py +74 -0
- package/scripts/canon-build.js +88 -0
- package/scripts/canon-drift.js +193 -0
- package/scripts/canon-freeze-status.js +85 -0
- package/scripts/canon-freeze.js +211 -0
- package/scripts/canon-unfreeze.js +137 -0
- package/scripts/critique.js +32 -7
- package/scripts/curate.js +33 -0
- package/scripts/generate-controlnet.js +12 -0
- package/scripts/generate-identity.js +20 -4
- package/scripts/generate-ipadapter.js +11 -0
- package/scripts/generate.js +262 -102
- package/scripts/qwen_generate.py +327 -0
- 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
|
+
}
|