@mcptoolshop/style-dataset-lab 3.0.1 → 3.2.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 +77 -0
- package/README.es.md +154 -41
- package/README.fr.md +153 -40
- package/README.hi.md +160 -47
- package/README.it.md +155 -42
- package/README.ja.md +159 -46
- package/README.md +40 -0
- package/README.pt-BR.md +154 -41
- package/README.zh.md +154 -41
- package/bin/sdlab.js +81 -2
- package/lib/adapters/ai-toolkit.js +209 -0
- package/lib/adapters/comfyui-runner.js +11 -15
- 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/batch-runs.js +62 -0
- 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/comfyui-output.js +70 -0
- package/lib/config.js +19 -0
- package/lib/freeze-events.js +109 -0
- package/lib/freeze-gate.js +165 -0
- package/lib/freeze-stamp.js +161 -0
- package/lib/rows.js +228 -0
- package/lib/training-adapters.js +84 -0
- package/lib/training-packages.js +25 -32
- package/lib/training-profiles.js +43 -0
- package/package.json +29 -2
- package/scripts/_batch_cut.py +71 -0
- package/scripts/_seam_cutout.py +74 -0
- package/scripts/batch-generate.js +56 -13
- 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/curate.js +12 -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 +250 -102
- package/scripts/qwen_generate.py +124 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sdlab canon drift` — drift report (D7 command).
|
|
3
|
+
*
|
|
4
|
+
* For every frozen or on-canon-change entry, computes the current watch-hash
|
|
5
|
+
* and compares against the hash stamped in the latest canon-build manifest.
|
|
6
|
+
* Surfaces:
|
|
7
|
+
* - Frozen entries whose current projection drifts from their witness.
|
|
8
|
+
* - on-canon-change entries where the watch-fields have changed since freeze.
|
|
9
|
+
* - Overrides since a given build hash (or since the last clean build).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseArgs } from 'node:util';
|
|
13
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { loadBuildConfig } from '../lib/canon-build/load-config.js';
|
|
17
|
+
import { loadCanonEntriesInDir } from '../lib/canon-build/load-entry.js';
|
|
18
|
+
import { loadSchema } from '../lib/canon-build/load-schema.js';
|
|
19
|
+
import { readFreezeStatus, resolveWatchFields, computeWatchHash } from '../lib/freeze-stamp.js';
|
|
20
|
+
import { readEventsSince } from '../lib/freeze-events.js';
|
|
21
|
+
import { inputError } from '../lib/errors.js';
|
|
22
|
+
|
|
23
|
+
export async function run(argv) {
|
|
24
|
+
const parsed = parseArgs({
|
|
25
|
+
args: argv,
|
|
26
|
+
options: {
|
|
27
|
+
project: { type: 'string' },
|
|
28
|
+
game: { type: 'string' },
|
|
29
|
+
since: { type: 'string' },
|
|
30
|
+
json: { type: 'boolean', default: false },
|
|
31
|
+
},
|
|
32
|
+
allowPositionals: true,
|
|
33
|
+
strict: false,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const projectName = parsed.values.project || parsed.values.game;
|
|
37
|
+
if (!projectName) throw inputError('INPUT_MISSING_FLAG', '--project required');
|
|
38
|
+
|
|
39
|
+
const projectRoot = join(process.cwd(), 'projects', projectName);
|
|
40
|
+
const config = await loadBuildConfig(join(projectRoot, 'canon-build', 'config.json'), projectRoot);
|
|
41
|
+
|
|
42
|
+
// Load latest build manifest for the stamped watch-hashes
|
|
43
|
+
const latestManifest = await findLatestBuildManifest(projectRoot);
|
|
44
|
+
|
|
45
|
+
// Load schemas for version info
|
|
46
|
+
const schemas = {};
|
|
47
|
+
for (const schemaName of Object.keys(config.entity_dirs)) {
|
|
48
|
+
const path = join(config.schema_dir, schemaName);
|
|
49
|
+
schemas[schemaName] = await loadSchema(path);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Walk entries, compute drift
|
|
53
|
+
const drifted = [];
|
|
54
|
+
const cleanFrozen = [];
|
|
55
|
+
let totalEntries = 0;
|
|
56
|
+
let autoCount = 0;
|
|
57
|
+
|
|
58
|
+
for (const [schemaName, subdir] of Object.entries(config.entity_dirs)) {
|
|
59
|
+
const dir = join(config.canon_root, subdir);
|
|
60
|
+
const entries = await loadCanonEntriesInDir(dir);
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
totalEntries++;
|
|
63
|
+
const status = readFreezeStatus(entry.frontmatter);
|
|
64
|
+
if (status === 'auto') { autoCount++; continue; }
|
|
65
|
+
|
|
66
|
+
const watchFields = resolveWatchFields(entry.frontmatter, schemaName, config);
|
|
67
|
+
const currentHash = computeWatchHash(
|
|
68
|
+
entry.frontmatter,
|
|
69
|
+
watchFields,
|
|
70
|
+
schemas[schemaName].version,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Look up stamped hash from latest manifest. The manifest stores a
|
|
74
|
+
// richer stamp object (status / watch_hash / watch_fields /
|
|
75
|
+
// locked_at_build); the drift comparison only needs `.watch_hash`.
|
|
76
|
+
const entityId = entry.frontmatter.id;
|
|
77
|
+
const stamp = latestManifest?.frozen_entries_hashes?.[entityId] || null;
|
|
78
|
+
const stampedHash = stamp?.watch_hash || null;
|
|
79
|
+
|
|
80
|
+
if (stampedHash && stampedHash !== currentHash) {
|
|
81
|
+
drifted.push({
|
|
82
|
+
entity_id: entityId,
|
|
83
|
+
schema_kind: schemaName.replace('.schema.json', ''),
|
|
84
|
+
status,
|
|
85
|
+
locked_at_build: stamp?.locked_at_build || entry.frontmatter.freeze?.locked_at_build || null,
|
|
86
|
+
witness_hash: stampedHash,
|
|
87
|
+
current_hash: currentHash,
|
|
88
|
+
watch_fields: watchFields,
|
|
89
|
+
});
|
|
90
|
+
} else if (stampedHash) {
|
|
91
|
+
cleanFrozen.push({
|
|
92
|
+
entity_id: entityId,
|
|
93
|
+
status,
|
|
94
|
+
witness_hash: stampedHash,
|
|
95
|
+
});
|
|
96
|
+
} else {
|
|
97
|
+
// Entry is frozen now but was not in the last build manifest (e.g.
|
|
98
|
+
// newly frozen since). Flag as "no witness yet" rather than mis-
|
|
99
|
+
// classifying as either drifted or clean.
|
|
100
|
+
cleanFrozen.push({
|
|
101
|
+
entity_id: entityId,
|
|
102
|
+
status,
|
|
103
|
+
witness_hash: null,
|
|
104
|
+
note: 'no witness in latest build — rebuild to stamp',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Events since a reference build hash
|
|
111
|
+
const sinceHash = parsed.values.since || latestManifest?.generated_from || null;
|
|
112
|
+
const eventsSince = sinceHash
|
|
113
|
+
? await readEventsSince(projectRoot, sinceHash)
|
|
114
|
+
: [];
|
|
115
|
+
|
|
116
|
+
const report = {
|
|
117
|
+
project_id: config.project_id,
|
|
118
|
+
latest_build: latestManifest?.generated_from || null,
|
|
119
|
+
latest_built_at: latestManifest?.built_at || null,
|
|
120
|
+
entities_total: totalEntries,
|
|
121
|
+
entities_auto: autoCount,
|
|
122
|
+
entities_frozen_clean: cleanFrozen.length,
|
|
123
|
+
entities_drifted: drifted.length,
|
|
124
|
+
drifted,
|
|
125
|
+
overrides_since: eventsSince.filter((e) => e.type !== 'freeze'),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (parsed.values.json) {
|
|
129
|
+
console.log(JSON.stringify(report, null, 2));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
renderHumanReport(report);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderHumanReport(r) {
|
|
137
|
+
console.log(`Drift report for ${r.project_id}`);
|
|
138
|
+
console.log(` latest build: ${r.latest_build || '(no build output found)'}`);
|
|
139
|
+
console.log(` latest built_at: ${r.latest_built_at || '(unknown)'}`);
|
|
140
|
+
console.log('');
|
|
141
|
+
console.log(` auto entries: ${r.entities_auto}`);
|
|
142
|
+
console.log(` frozen entries (clean): ${r.entities_frozen_clean}`);
|
|
143
|
+
console.log(` frozen entries (drift): ${r.entities_drifted}`);
|
|
144
|
+
console.log('');
|
|
145
|
+
|
|
146
|
+
if (r.drifted.length > 0) {
|
|
147
|
+
console.log('Drifted frozen entries:');
|
|
148
|
+
for (const d of r.drifted) {
|
|
149
|
+
console.log(` ${d.schema_kind}:${d.entity_id} (${d.status})`);
|
|
150
|
+
console.log(` witness hash: ${d.witness_hash.slice(0, 16)}...`);
|
|
151
|
+
console.log(` current hash: ${d.current_hash.slice(0, 16)}...`);
|
|
152
|
+
console.log(` watch fields: ${d.watch_fields.join(', ') || '(none)'}`);
|
|
153
|
+
}
|
|
154
|
+
console.log('');
|
|
155
|
+
} else if (r.entities_frozen_clean > 0) {
|
|
156
|
+
console.log('No drift detected on frozen entries.');
|
|
157
|
+
console.log('');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (r.overrides_since.length > 0) {
|
|
161
|
+
console.log(`Overrides since last build (${r.overrides_since.length}):`);
|
|
162
|
+
for (const e of r.overrides_since) {
|
|
163
|
+
console.log(` ${e.type.padEnd(10)} ${e.entity_id} by ${e.by} — ${e.reason}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function findLatestBuildManifest(projectRoot) {
|
|
169
|
+
const canonBuildDir = join(projectRoot, 'canon-build');
|
|
170
|
+
if (!existsSync(canonBuildDir)) return null;
|
|
171
|
+
const entries = await readdir(canonBuildDir, { withFileTypes: true });
|
|
172
|
+
const candidates = entries
|
|
173
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.includes('.tmp'))
|
|
174
|
+
.map((e) => e.name);
|
|
175
|
+
|
|
176
|
+
let latest = null;
|
|
177
|
+
let latestTime = 0;
|
|
178
|
+
for (const name of candidates) {
|
|
179
|
+
const manifestPath = join(canonBuildDir, name, 'manifest.json');
|
|
180
|
+
if (!existsSync(manifestPath)) continue;
|
|
181
|
+
try {
|
|
182
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
183
|
+
const built = new Date(manifest.built_at || 0).getTime();
|
|
184
|
+
if (built > latestTime) {
|
|
185
|
+
latestTime = built;
|
|
186
|
+
latest = manifest;
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return latest;
|
|
193
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sdlab canon freeze-status <entity_id>` — read-only glance at an entry's
|
|
3
|
+
* freeze state. Human-readable by default; --json for machine consumption.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { loadBuildConfig } from '../lib/canon-build/load-config.js';
|
|
9
|
+
import { loadCanonEntriesInDir } from '../lib/canon-build/load-entry.js';
|
|
10
|
+
import { readFreezeStatus, resolveWatchFields } from '../lib/freeze-stamp.js';
|
|
11
|
+
import { readEventsFor } from '../lib/freeze-events.js';
|
|
12
|
+
import { inputError } from '../lib/errors.js';
|
|
13
|
+
|
|
14
|
+
export async function run(argv) {
|
|
15
|
+
const parsed = parseArgs({
|
|
16
|
+
args: argv,
|
|
17
|
+
options: {
|
|
18
|
+
project: { type: 'string' },
|
|
19
|
+
game: { type: 'string' },
|
|
20
|
+
json: { type: 'boolean', default: false },
|
|
21
|
+
},
|
|
22
|
+
allowPositionals: true,
|
|
23
|
+
strict: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const entityIdArg = (parsed.positionals || [])[0];
|
|
27
|
+
if (!entityIdArg) throw inputError('INPUT_MISSING_ARG', 'sdlab canon freeze-status requires an entity id.');
|
|
28
|
+
const projectName = parsed.values.project || parsed.values.game;
|
|
29
|
+
if (!projectName) throw inputError('INPUT_MISSING_FLAG', '--project required');
|
|
30
|
+
|
|
31
|
+
const projectRoot = join(process.cwd(), 'projects', projectName);
|
|
32
|
+
const config = await loadBuildConfig(join(projectRoot, 'canon-build', 'config.json'), projectRoot);
|
|
33
|
+
|
|
34
|
+
let resolved = null;
|
|
35
|
+
for (const [schemaName, subdir] of Object.entries(config.entity_dirs)) {
|
|
36
|
+
const dir = join(config.canon_root, subdir);
|
|
37
|
+
const entries = await loadCanonEntriesInDir(dir);
|
|
38
|
+
const hit = entries.find((e) => e.frontmatter?.id === entityIdArg);
|
|
39
|
+
if (hit) { resolved = { entry: hit, schemaName }; break; }
|
|
40
|
+
}
|
|
41
|
+
if (!resolved) throw inputError('CANON_ENTRY_NOT_FOUND', `No canon entry "${entityIdArg}".`);
|
|
42
|
+
|
|
43
|
+
const fm = resolved.entry.frontmatter;
|
|
44
|
+
const status = readFreezeStatus(fm);
|
|
45
|
+
const block = fm.freeze || {};
|
|
46
|
+
const events = await readEventsFor(projectRoot, entityIdArg);
|
|
47
|
+
|
|
48
|
+
// Entry override OR config default — the same resolution used by the
|
|
49
|
+
// build, so a reader sees the actual watched fields, not just what's
|
|
50
|
+
// literally in the entry frontmatter.
|
|
51
|
+
const resolvedWatchFields = resolveWatchFields(fm, resolved.schemaName, config);
|
|
52
|
+
const watchFieldsSource = Array.isArray(block.watch_fields) && block.watch_fields.length
|
|
53
|
+
? 'entry-override'
|
|
54
|
+
: (resolvedWatchFields.length ? 'config-default' : 'none');
|
|
55
|
+
|
|
56
|
+
const result = {
|
|
57
|
+
entity_id: entityIdArg,
|
|
58
|
+
schema_kind: resolved.schemaName.replace('.schema.json', ''),
|
|
59
|
+
status,
|
|
60
|
+
locked_at_build: block.locked_at_build || null,
|
|
61
|
+
frozen_by: block.frozen_by || null,
|
|
62
|
+
frozen_reason: block.frozen_reason || null,
|
|
63
|
+
watch_fields: resolvedWatchFields,
|
|
64
|
+
watch_fields_source: watchFieldsSource,
|
|
65
|
+
overrides_count: Array.isArray(block.overrides) ? block.overrides.length : 0,
|
|
66
|
+
event_count: events.length,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (parsed.values.json) {
|
|
70
|
+
console.log(JSON.stringify(result, null, 2));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`${entityIdArg} (${result.schema_kind})`);
|
|
75
|
+
console.log(` status: ${status}`);
|
|
76
|
+
if (status !== 'auto') {
|
|
77
|
+
console.log(` locked_at_build: ${result.locked_at_build || '(unset)'}`);
|
|
78
|
+
console.log(` frozen_by: ${result.frozen_by || '(unset)'}`);
|
|
79
|
+
console.log(` frozen_reason: ${result.frozen_reason || '(unset)'}`);
|
|
80
|
+
const fieldsDisplay = result.watch_fields.length ? result.watch_fields.join(', ') : '(none)';
|
|
81
|
+
console.log(` watch_fields: ${fieldsDisplay} [${result.watch_fields_source}]`);
|
|
82
|
+
}
|
|
83
|
+
console.log(` overrides[]: ${result.overrides_count} recorded on entry`);
|
|
84
|
+
console.log(` event log: ${result.event_count} events total`);
|
|
85
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sdlab canon freeze <entity_id> --reason "<text>"` — stamp a freeze block
|
|
3
|
+
* on a canon entry (D7 command).
|
|
4
|
+
*
|
|
5
|
+
* Validates the entry exists, that a canon-build output exists to witness
|
|
6
|
+
* against (so the `locked_at_build` field has a real value — D1), rewrites
|
|
7
|
+
* the entry's frontmatter with the new `freeze` block, and appends a
|
|
8
|
+
* `freeze` event to freeze-events.jsonl (D4).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parseArgs } from 'node:util';
|
|
12
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
16
|
+
import { loadBuildConfig } from '../lib/canon-build/load-config.js';
|
|
17
|
+
import { loadCanonEntriesInDir, entryId } from '../lib/canon-build/load-entry.js';
|
|
18
|
+
import { buildFreezeBlock, FREEZE_STATUSES, resolveWatchFields } from '../lib/freeze-stamp.js';
|
|
19
|
+
import { appendEvent } from '../lib/freeze-events.js';
|
|
20
|
+
import { inputError } from '../lib/errors.js';
|
|
21
|
+
|
|
22
|
+
export async function run(argv) {
|
|
23
|
+
const parsed = parseArgs({
|
|
24
|
+
args: argv,
|
|
25
|
+
options: {
|
|
26
|
+
project: { type: 'string' },
|
|
27
|
+
game: { type: 'string' },
|
|
28
|
+
reason: { type: 'string' },
|
|
29
|
+
status: { type: 'string', default: 'frozen' },
|
|
30
|
+
watch: { type: 'string' },
|
|
31
|
+
build: { type: 'string' },
|
|
32
|
+
by: { type: 'string', default: 'mike' },
|
|
33
|
+
json: { type: 'boolean', default: false },
|
|
34
|
+
},
|
|
35
|
+
allowPositionals: true,
|
|
36
|
+
strict: false,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const positional = parsed.positionals || [];
|
|
40
|
+
const entityIdArg = positional[0];
|
|
41
|
+
if (!entityIdArg) {
|
|
42
|
+
throw inputError(
|
|
43
|
+
'INPUT_MISSING_ARG',
|
|
44
|
+
'sdlab canon freeze requires an entity id as the first positional argument.',
|
|
45
|
+
'Example: sdlab canon freeze heracles --reason "Labor III reference plate approved"',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const projectName = parsed.values.project || parsed.values.game;
|
|
50
|
+
if (!projectName) {
|
|
51
|
+
throw inputError('INPUT_MISSING_FLAG', 'sdlab canon freeze requires --project <name>', 'Example: --project greek-rpg');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!parsed.values.reason) {
|
|
55
|
+
throw inputError(
|
|
56
|
+
'FREEZE_REASON_REQUIRED',
|
|
57
|
+
'sdlab canon freeze requires --reason "<short text>".',
|
|
58
|
+
'The reason lands in both the entry frontmatter and canon-build/freeze-events.jsonl as the audit record. Future you (and the LLM crew) need it to understand why.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!FREEZE_STATUSES.includes(parsed.values.status)) {
|
|
63
|
+
throw inputError(
|
|
64
|
+
'INPUT_INVALID_VALUE',
|
|
65
|
+
`--status must be one of ${FREEZE_STATUSES.join(', ')} (got "${parsed.values.status}")`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (parsed.values.status === 'auto') {
|
|
69
|
+
throw inputError(
|
|
70
|
+
'INPUT_INVALID_VALUE',
|
|
71
|
+
'--status auto means "no freeze" — use `sdlab canon unfreeze` instead of freezing with status=auto.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const projectRoot = join(process.cwd(), 'projects', projectName);
|
|
76
|
+
const configPath = join(projectRoot, 'canon-build', 'config.json');
|
|
77
|
+
const config = await loadBuildConfig(configPath, projectRoot);
|
|
78
|
+
|
|
79
|
+
// Find the entry file
|
|
80
|
+
const resolved = await findEntryFile(config, entityIdArg);
|
|
81
|
+
if (!resolved) {
|
|
82
|
+
throw inputError(
|
|
83
|
+
'CANON_ENTRY_NOT_FOUND',
|
|
84
|
+
`No canon entry with id "${entityIdArg}" found under ${config.canon_root}.`,
|
|
85
|
+
`Check the entry filename matches the id field, and that the schema-subdirectory mapping in canon-build/config.json covers this entity type.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Resolve the witness build hash
|
|
90
|
+
const lockedAtBuild = parsed.values.build || await findLatestBuildHash(projectRoot);
|
|
91
|
+
if (!lockedAtBuild) {
|
|
92
|
+
throw inputError(
|
|
93
|
+
'FREEZE_NO_BUILD_WITNESS',
|
|
94
|
+
'No canon-build output exists to witness this freeze against.',
|
|
95
|
+
'Run `sdlab canon build` first to produce a build artifact, then retry the freeze. The witness chain (D1) requires a real build hash to stamp.',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Resolve watch-fields: --watch override > entry override > config default
|
|
100
|
+
const watchFields = resolveEffectiveWatchFields({
|
|
101
|
+
entry: resolved.entry,
|
|
102
|
+
schemaName: resolved.schemaName,
|
|
103
|
+
buildConfig: config,
|
|
104
|
+
cliOverride: parsed.values.watch,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Build the new freeze block, preserving existing overrides[] if any
|
|
108
|
+
const existing = resolved.entry.frontmatter.freeze || {};
|
|
109
|
+
const priorStatus = existing.status || 'auto';
|
|
110
|
+
const newBlock = buildFreezeBlock({
|
|
111
|
+
status: parsed.values.status,
|
|
112
|
+
lockedAtBuild,
|
|
113
|
+
frozenBy: parsed.values.by,
|
|
114
|
+
frozenReason: parsed.values.reason,
|
|
115
|
+
watchFields,
|
|
116
|
+
overrides: existing.overrides,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Rewrite the entry file
|
|
120
|
+
const updatedFm = { ...resolved.entry.frontmatter, freeze: newBlock };
|
|
121
|
+
await rewriteEntryFrontmatter(resolved.filePath, updatedFm, resolved.entry.body);
|
|
122
|
+
|
|
123
|
+
// Append audit event
|
|
124
|
+
const event = await appendEvent(projectRoot, {
|
|
125
|
+
type: 'freeze',
|
|
126
|
+
entity_id: entityIdArg,
|
|
127
|
+
schema_kind: resolved.schemaName.replace('.schema.json', ''),
|
|
128
|
+
by: parsed.values.by,
|
|
129
|
+
reason: parsed.values.reason,
|
|
130
|
+
prior_status: priorStatus,
|
|
131
|
+
new_status: parsed.values.status,
|
|
132
|
+
watch_fields: watchFields,
|
|
133
|
+
build_hash: lockedAtBuild,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const result = {
|
|
137
|
+
ok: true,
|
|
138
|
+
entity_id: entityIdArg,
|
|
139
|
+
status: parsed.values.status,
|
|
140
|
+
locked_at_build: lockedAtBuild,
|
|
141
|
+
watch_fields: watchFields,
|
|
142
|
+
event_at: event.at,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (parsed.values.json) {
|
|
146
|
+
console.log(JSON.stringify(result, null, 2));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(`froze "${entityIdArg}" (${parsed.values.status}) witnessed at ${lockedAtBuild}`);
|
|
151
|
+
console.log(` watching: ${watchFields.length ? watchFields.join(', ') : '(no fields — build-config has no default for this schema and no --watch was passed)'}`);
|
|
152
|
+
console.log(` reason: ${parsed.values.reason}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
async function findEntryFile(config, entityId) {
|
|
158
|
+
for (const [schemaName, subdir] of Object.entries(config.entity_dirs)) {
|
|
159
|
+
const dir = join(config.canon_root, subdir);
|
|
160
|
+
const entries = await loadCanonEntriesInDir(dir);
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const id = entry.frontmatter?.id;
|
|
163
|
+
if (id === entityId) {
|
|
164
|
+
return { entry, schemaName, filePath: entry.filePath };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function findLatestBuildHash(projectRoot) {
|
|
172
|
+
const canonBuildDir = join(projectRoot, 'canon-build');
|
|
173
|
+
if (!existsSync(canonBuildDir)) return null;
|
|
174
|
+
const entries = await readdir(canonBuildDir, { withFileTypes: true });
|
|
175
|
+
const buildDirs = entries
|
|
176
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.endsWith('.tmp') && !e.name.includes('.tmp-'))
|
|
177
|
+
.map((e) => e.name);
|
|
178
|
+
if (buildDirs.length === 0) return null;
|
|
179
|
+
|
|
180
|
+
// Pick the most recent by mtime of its manifest.json
|
|
181
|
+
let latest = null;
|
|
182
|
+
let latestMtime = 0;
|
|
183
|
+
for (const name of buildDirs) {
|
|
184
|
+
const manifestPath = join(canonBuildDir, name, 'manifest.json');
|
|
185
|
+
if (!existsSync(manifestPath)) continue;
|
|
186
|
+
try {
|
|
187
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
188
|
+
const built = new Date(manifest.built_at || 0).getTime();
|
|
189
|
+
if (built > latestMtime) {
|
|
190
|
+
latestMtime = built;
|
|
191
|
+
latest = manifest.generated_from || name;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return latest;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveEffectiveWatchFields({ entry, schemaName, buildConfig, cliOverride }) {
|
|
201
|
+
if (cliOverride) {
|
|
202
|
+
return cliOverride.split(',').map((s) => s.trim()).filter(Boolean);
|
|
203
|
+
}
|
|
204
|
+
return resolveWatchFields(entry.frontmatter, schemaName, buildConfig);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function rewriteEntryFrontmatter(filePath, frontmatter, body) {
|
|
208
|
+
const yaml = stringifyYaml(frontmatter).trimEnd();
|
|
209
|
+
const content = `---\n${yaml}\n---\n${body}`;
|
|
210
|
+
await writeFile(filePath, content, 'utf-8');
|
|
211
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sdlab canon unfreeze <entity_id> --reason "<text>"` — lift a freeze (D7).
|
|
3
|
+
*
|
|
4
|
+
* Writes the entry's `freeze.status` to `auto`, appends an `unfreeze` event
|
|
5
|
+
* with the prior state captured. Preserves the `overrides[]` history on the
|
|
6
|
+
* entry (append-only) so re-freeze later shows the full lifecycle.
|
|
7
|
+
*
|
|
8
|
+
* --reason is REQUIRED — unfreezes are where shipping studios (Sabotage,
|
|
9
|
+
* Supergiant, Pentiment) most often wished they had captured intent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseArgs } from 'node:util';
|
|
13
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
16
|
+
import { loadBuildConfig } from '../lib/canon-build/load-config.js';
|
|
17
|
+
import { loadCanonEntriesInDir } from '../lib/canon-build/load-entry.js';
|
|
18
|
+
import { appendOverrideToFreezeBlock, readFreezeStatus } from '../lib/freeze-stamp.js';
|
|
19
|
+
import { appendEvent } from '../lib/freeze-events.js';
|
|
20
|
+
import { inputError } from '../lib/errors.js';
|
|
21
|
+
|
|
22
|
+
export async function run(argv) {
|
|
23
|
+
const parsed = parseArgs({
|
|
24
|
+
args: argv,
|
|
25
|
+
options: {
|
|
26
|
+
project: { type: 'string' },
|
|
27
|
+
game: { type: 'string' },
|
|
28
|
+
reason: { type: 'string' },
|
|
29
|
+
by: { type: 'string', default: 'mike' },
|
|
30
|
+
build: { type: 'string' },
|
|
31
|
+
json: { type: 'boolean', default: false },
|
|
32
|
+
},
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
strict: false,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const entityIdArg = (parsed.positionals || [])[0];
|
|
38
|
+
if (!entityIdArg) {
|
|
39
|
+
throw inputError('INPUT_MISSING_ARG', 'sdlab canon unfreeze requires an entity id.');
|
|
40
|
+
}
|
|
41
|
+
const projectName = parsed.values.project || parsed.values.game;
|
|
42
|
+
if (!projectName) throw inputError('INPUT_MISSING_FLAG', '--project required');
|
|
43
|
+
if (!parsed.values.reason) {
|
|
44
|
+
throw inputError(
|
|
45
|
+
'FREEZE_REASON_REQUIRED',
|
|
46
|
+
'sdlab canon unfreeze requires --reason "<text>".',
|
|
47
|
+
'Unfreezes are where shipping studios most often regret not capturing intent. The reason lands in canon-build/freeze-events.jsonl and in the entry frontmatter\'s overrides[] append-only log.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const projectRoot = join(process.cwd(), 'projects', projectName);
|
|
52
|
+
const config = await loadBuildConfig(join(projectRoot, 'canon-build', 'config.json'), projectRoot);
|
|
53
|
+
const resolved = await findEntryFile(config, entityIdArg);
|
|
54
|
+
if (!resolved) {
|
|
55
|
+
throw inputError('CANON_ENTRY_NOT_FOUND', `No canon entry with id "${entityIdArg}".`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fm = resolved.entry.frontmatter;
|
|
59
|
+
const priorStatus = readFreezeStatus(fm);
|
|
60
|
+
if (priorStatus === 'auto') {
|
|
61
|
+
throw inputError(
|
|
62
|
+
'FREEZE_ALREADY_AUTO',
|
|
63
|
+
`Canon entry "${entityIdArg}" is already at status=auto; nothing to unfreeze.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const priorFreeze = fm.freeze || {};
|
|
68
|
+
const buildHash = parsed.values.build || priorFreeze.locked_at_build || null;
|
|
69
|
+
|
|
70
|
+
// Append override record to the entry's overrides[] log
|
|
71
|
+
const overrideRecord = {
|
|
72
|
+
at: buildHash || new Date().toISOString(),
|
|
73
|
+
by: parsed.values.by,
|
|
74
|
+
reason: parsed.values.reason,
|
|
75
|
+
prior_status: priorStatus,
|
|
76
|
+
prior_build_hash: priorFreeze.locked_at_build || null,
|
|
77
|
+
};
|
|
78
|
+
const updatedBlock = appendOverrideToFreezeBlock(
|
|
79
|
+
{ ...priorFreeze, status: 'auto' },
|
|
80
|
+
overrideRecord,
|
|
81
|
+
);
|
|
82
|
+
// Drop fields that only make sense on an active freeze
|
|
83
|
+
delete updatedBlock.locked_at_build;
|
|
84
|
+
delete updatedBlock.locked_at_canon_version;
|
|
85
|
+
delete updatedBlock.frozen_by;
|
|
86
|
+
delete updatedBlock.frozen_reason;
|
|
87
|
+
delete updatedBlock.watch_fields;
|
|
88
|
+
|
|
89
|
+
const updatedFm = { ...fm, freeze: updatedBlock };
|
|
90
|
+
await rewriteEntryFrontmatter(resolved.filePath, updatedFm, resolved.entry.body);
|
|
91
|
+
|
|
92
|
+
const event = await appendEvent(projectRoot, {
|
|
93
|
+
type: 'unfreeze',
|
|
94
|
+
entity_id: entityIdArg,
|
|
95
|
+
schema_kind: resolved.schemaName.replace('.schema.json', ''),
|
|
96
|
+
by: parsed.values.by,
|
|
97
|
+
reason: parsed.values.reason,
|
|
98
|
+
prior_status: priorStatus,
|
|
99
|
+
new_status: 'auto',
|
|
100
|
+
prior_build_hash: priorFreeze.locked_at_build || null,
|
|
101
|
+
build_hash: buildHash,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = {
|
|
105
|
+
ok: true,
|
|
106
|
+
entity_id: entityIdArg,
|
|
107
|
+
prior_status: priorStatus,
|
|
108
|
+
new_status: 'auto',
|
|
109
|
+
event_at: event.at,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (parsed.values.json) {
|
|
113
|
+
console.log(JSON.stringify(result, null, 2));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
console.log(`unfroze "${entityIdArg}" (was ${priorStatus})`);
|
|
117
|
+
console.log(` reason: ${parsed.values.reason}`);
|
|
118
|
+
console.log(` overrides[] history preserved with ${updatedBlock.overrides.length} entries`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function findEntryFile(config, entityId) {
|
|
122
|
+
for (const [schemaName, subdir] of Object.entries(config.entity_dirs)) {
|
|
123
|
+
const dir = join(config.canon_root, subdir);
|
|
124
|
+
const entries = await loadCanonEntriesInDir(dir);
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
if (entry.frontmatter?.id === entityId) {
|
|
127
|
+
return { entry, schemaName, filePath: entry.filePath };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function rewriteEntryFrontmatter(filePath, frontmatter, body) {
|
|
135
|
+
const yaml = stringifyYaml(frontmatter).trimEnd();
|
|
136
|
+
await writeFile(filePath, `---\n${yaml}\n---\n${body}`, 'utf-8');
|
|
137
|
+
}
|
package/scripts/curate.js
CHANGED
|
@@ -17,6 +17,7 @@ import { REPO_ROOT } from "../lib/paths.js";
|
|
|
17
17
|
import { readJsonFile } from "../lib/config.js";
|
|
18
18
|
import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
|
|
19
19
|
import { result } from "../lib/log.js";
|
|
20
|
+
import { assertNotFrozenByAssetPath } from "../lib/freeze-gate.js";
|
|
20
21
|
|
|
21
22
|
export async function run(argv = process.argv.slice(2)) {
|
|
22
23
|
const { flags, positionals } = parseArgs(argv, {
|
|
@@ -27,6 +28,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
27
28
|
notes: { type: 'string' },
|
|
28
29
|
list: { type: 'boolean', default: false },
|
|
29
30
|
'dry-run': { type: 'boolean', default: false },
|
|
31
|
+
'i-know': { type: 'boolean', default: false },
|
|
30
32
|
},
|
|
31
33
|
deprecated: { game: 'project' },
|
|
32
34
|
});
|
|
@@ -106,6 +108,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
// Freeze gate: refuse to recurate to a path that a frozen canon entry owns.
|
|
112
|
+
// No-op when the project has no canon-build config. The explanation doubles
|
|
113
|
+
// as the bypass reason when the target entry is soft-advisory.
|
|
114
|
+
await assertNotFrozenByAssetPath(GAME_ROOT, newPath, {
|
|
115
|
+
action: 'curate',
|
|
116
|
+
allowSoftAdvisoryBypass: flags['i-know'],
|
|
117
|
+
bypassReason: explanation,
|
|
118
|
+
by: 'mike',
|
|
119
|
+
});
|
|
120
|
+
|
|
109
121
|
await mkdir(join(GAME_ROOT, newDir), { recursive: true });
|
|
110
122
|
|
|
111
123
|
// Move image FIRST — if the rename fails we still have a clean record.
|
|
@@ -19,6 +19,7 @@ import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
|
|
|
19
19
|
import { readJsonFile } from "../lib/config.js";
|
|
20
20
|
import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
|
|
21
21
|
import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
|
|
22
|
+
import { assertNotFrozenBySubject } from "../lib/freeze-gate.js";
|
|
22
23
|
|
|
23
24
|
const DEFAULTS = {
|
|
24
25
|
checkpoint: "dreamshaperXL_v21TurboDPMSDE.safetensors",
|
|
@@ -177,6 +178,17 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
177
178
|
throw inputError('INPUT_MISSING_FLAG', "--guide <path> is required");
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
// Freeze gate (advisory): refuse if the subject's canon entry is frozen.
|
|
182
|
+
// Supports --i-know + --reason bypass for soft-advisory entries.
|
|
183
|
+
const reasonFlagIdx = argv.indexOf('--reason');
|
|
184
|
+
const bypassReason = reasonFlagIdx >= 0 && argv[reasonFlagIdx + 1] ? argv[reasonFlagIdx + 1] : null;
|
|
185
|
+
await assertNotFrozenBySubject(GAME_ROOT, opts.subject, {
|
|
186
|
+
action: 'generate:controlnet',
|
|
187
|
+
allowSoftAdvisoryBypass: argv.includes('--i-know'),
|
|
188
|
+
bypassReason,
|
|
189
|
+
by: 'mike',
|
|
190
|
+
});
|
|
191
|
+
|
|
180
192
|
// Validate guide stays inside the project tree.
|
|
181
193
|
const safeGuide = resolveSafeProjectPath(GAME_ROOT, opts.guidePath, { flagName: 'guide' });
|
|
182
194
|
|