@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,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/critique.js
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* sdlab critique --project star-freight --run run_2026-04-15_001 --json
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { parseArgs, getProjectName } from '../lib/args.js';
|
|
11
|
+
import { parseArgs, getProjectName, parseNumberFlag } from '../lib/args.js';
|
|
12
12
|
import { getProjectRoot } from '../lib/paths.js';
|
|
13
|
-
import { critiqueRun, saveCritique } from '../lib/critique-engine.js';
|
|
14
|
-
import { renderCritiqueMarkdown, renderCritiqueText } from '../lib/critique-render.js';
|
|
13
|
+
import { critiqueRun, saveCritique, triageCandidates, DEFAULT_DRIFT_THRESHOLD } from '../lib/critique-engine.js';
|
|
14
|
+
import { renderCritiqueMarkdown, renderCritiqueText, renderCritiqueTriage } from '../lib/critique-render.js';
|
|
15
15
|
import { info } from '../lib/log.js';
|
|
16
16
|
|
|
17
17
|
export async function run(argv = process.argv.slice(2)) {
|
|
@@ -20,16 +20,20 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
20
20
|
project: { type: 'string' },
|
|
21
21
|
run: { type: 'string' },
|
|
22
22
|
json: { type: 'boolean' },
|
|
23
|
+
triage: { type: 'boolean' },
|
|
24
|
+
'drift-threshold': { type: 'string' },
|
|
23
25
|
},
|
|
24
26
|
deprecated: { game: 'project' },
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
if (!flags.run) {
|
|
28
|
-
console.log('Usage: sdlab critique --run <id> [--project <name>]');
|
|
30
|
+
console.log('Usage: sdlab critique --run <id> [--project <name>] [--triage]');
|
|
29
31
|
console.log('');
|
|
30
32
|
console.log('Options:');
|
|
31
|
-
console.log(' --run <id>
|
|
32
|
-
console.log(' --
|
|
33
|
+
console.log(' --run <id> Run to critique (required)');
|
|
34
|
+
console.log(' --triage Show only candidates needing attention (off-model OR ≥ threshold drift issues)');
|
|
35
|
+
console.log(` --drift-threshold <n> Drift-issue count that flags a candidate under --triage (default ${DEFAULT_DRIFT_THRESHOLD})`);
|
|
36
|
+
console.log(' --json Output raw JSON');
|
|
33
37
|
return;
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -48,10 +52,31 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
48
52
|
runId: flags.run,
|
|
49
53
|
});
|
|
50
54
|
|
|
51
|
-
// Save
|
|
55
|
+
// Save the FULL report — triage is a view over it, never a mutation.
|
|
52
56
|
const md = renderCritiqueMarkdown(report);
|
|
53
57
|
await saveCritique(projectRoot, flags.run, report, md);
|
|
54
58
|
|
|
59
|
+
// UNCERTAINTY_GATED_HUMANS: --triage surfaces only the candidates that need a
|
|
60
|
+
// human — off-model OR >= threshold drift issues — so attention isn't spent on
|
|
61
|
+
// every item. The full critique.json still holds all candidates.
|
|
62
|
+
if (flags.triage) {
|
|
63
|
+
const driftThreshold = flags['drift-threshold']
|
|
64
|
+
? parseNumberFlag('drift-threshold', flags['drift-threshold'], { int: true, min: 0 })
|
|
65
|
+
: DEFAULT_DRIFT_THRESHOLD;
|
|
66
|
+
const triage = triageCandidates(report.candidates, { driftThreshold });
|
|
67
|
+
if (flags.json) {
|
|
68
|
+
console.log(JSON.stringify({
|
|
69
|
+
run_id: report.run_id,
|
|
70
|
+
triage: { drift_threshold: driftThreshold, flagged_count: triage.flagged.length, suppressed_count: triage.suppressed.length },
|
|
71
|
+
candidates: triage.flagged,
|
|
72
|
+
}, null, 2));
|
|
73
|
+
} else {
|
|
74
|
+
console.log(renderCritiqueTriage(report, triage));
|
|
75
|
+
console.log(`Full critique saved to: runs/${flags.run}/critique.json`);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
if (flags.json) {
|
|
56
81
|
console.log(JSON.stringify(report, null, 2));
|
|
57
82
|
} else {
|
package/scripts/curate.js
CHANGED
|
@@ -17,6 +17,8 @@ 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";
|
|
21
|
+
import { deriveGeneratorModel, warnIfSelfVerification, HUMAN_JUDGE } from "../lib/verifier.js";
|
|
20
22
|
|
|
21
23
|
export async function run(argv = process.argv.slice(2)) {
|
|
22
24
|
const { flags, positionals } = parseArgs(argv, {
|
|
@@ -27,6 +29,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
27
29
|
notes: { type: 'string' },
|
|
28
30
|
list: { type: 'boolean', default: false },
|
|
29
31
|
'dry-run': { type: 'boolean', default: false },
|
|
32
|
+
'i-know': { type: 'boolean', default: false },
|
|
30
33
|
},
|
|
31
34
|
deprecated: { game: 'project' },
|
|
32
35
|
});
|
|
@@ -106,6 +109,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
106
109
|
return;
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
// Freeze gate: refuse to recurate to a path that a frozen canon entry owns.
|
|
113
|
+
// No-op when the project has no canon-build config. The explanation doubles
|
|
114
|
+
// as the bypass reason when the target entry is soft-advisory.
|
|
115
|
+
await assertNotFrozenByAssetPath(GAME_ROOT, newPath, {
|
|
116
|
+
action: 'curate',
|
|
117
|
+
allowSoftAdvisoryBypass: flags['i-know'],
|
|
118
|
+
bypassReason: explanation,
|
|
119
|
+
by: 'mike',
|
|
120
|
+
});
|
|
121
|
+
|
|
109
122
|
await mkdir(join(GAME_ROOT, newDir), { recursive: true });
|
|
110
123
|
|
|
111
124
|
// Move image FIRST — if the rename fails we still have a clean record.
|
|
@@ -120,11 +133,19 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
120
133
|
);
|
|
121
134
|
}
|
|
122
135
|
|
|
136
|
+
// EXTERNAL_VERIFIER: record WHO judged (human) and WHAT generated the artifact,
|
|
137
|
+
// and warn if they ever coincide (they never do for human curation — the field
|
|
138
|
+
// + warning install the muscle for when an LLM judge enters the loop).
|
|
139
|
+
const generator_model = deriveGeneratorModel(record.provenance);
|
|
140
|
+
warnIfSelfVerification(HUMAN_JUDGE, generator_model, assetId);
|
|
141
|
+
|
|
123
142
|
// Now safely update the record. If writing fails, try to restore the image.
|
|
124
143
|
record.asset_path = newPath;
|
|
125
144
|
record.judgment = {
|
|
126
145
|
status,
|
|
127
146
|
reviewer: "human:mike",
|
|
147
|
+
judged_by_model: HUMAN_JUDGE,
|
|
148
|
+
generator_model,
|
|
128
149
|
reviewed_at: new Date().toISOString(),
|
|
129
150
|
explanation,
|
|
130
151
|
criteria_scores,
|
|
@@ -152,6 +173,18 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
152
173
|
result(` record: records/${assetId}.json`);
|
|
153
174
|
console.log(` ${explanation}`);
|
|
154
175
|
if (failure_modes.length) console.log(` Failures: ${failure_modes.join(", ")}`);
|
|
176
|
+
|
|
177
|
+
// UNCERTAINTY_GATED_HUMANS: contrastive advisory on borderline (Horvitz CHI
|
|
178
|
+
// 1999). State the default (HOLD — borderline is not training-eligible) and
|
|
179
|
+
// what a later promotion must justify, so the promoter argues against a stated
|
|
180
|
+
// default rather than nudging a status upward unexamined.
|
|
181
|
+
if (status === 'borderline') {
|
|
182
|
+
const drift = failure_modes.length ? failure_modes.join(', ') : 'the drift you noted';
|
|
183
|
+
console.log(
|
|
184
|
+
` \x1b[2mDefault: HOLD — 'borderline' is not training-eligible. ` +
|
|
185
|
+
`To later promote to 'approved', justify why ${drift} is acceptable for canon (re-run curate approved with that reason).\x1b[0m`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
155
188
|
}
|
|
156
189
|
|
|
157
190
|
async function listUncurated(GAME_ROOT) {
|
|
@@ -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
|
|
|
@@ -24,9 +24,10 @@ import { writeFile, mkdir } from "node:fs/promises";
|
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { getProjectName, parseNumberFlag } from "../lib/args.js";
|
|
26
26
|
import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
|
|
27
|
-
import { readJsonFile } from "../lib/config.js";
|
|
27
|
+
import { readJsonFile, loadProjectMeta, resolveGpuModel } from "../lib/config.js";
|
|
28
28
|
import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
|
|
29
29
|
import { comfyHealth, submitAndWait, downloadImage } from "../lib/comfyui.js";
|
|
30
|
+
import { assertNotFrozenBySubject } from "../lib/freeze-gate.js";
|
|
30
31
|
|
|
31
32
|
// ── Defaults (match existing lab pipeline) ──
|
|
32
33
|
|
|
@@ -309,7 +310,7 @@ function buildImg2ImgWorkflow(prompt, negativePrompt, seed, imagePath, denoise)
|
|
|
309
310
|
|
|
310
311
|
// ── Record Builder ──
|
|
311
312
|
|
|
312
|
-
function buildRecord(pack, subject, shot, seed, seedIndex, imgData, destPath, elapsedMs, phase, anchorInfo) {
|
|
313
|
+
function buildRecord(pack, subject, shot, seed, seedIndex, imgData, destPath, elapsedMs, phase, anchorInfo, gpuModel) {
|
|
313
314
|
const assetId = `${shot.id}_s${seedIndex}`;
|
|
314
315
|
const fullPrompt = `${pack.style_prefix}, ${shot.prompt}`;
|
|
315
316
|
const negativePrompt = [
|
|
@@ -343,7 +344,7 @@ function buildRecord(pack, subject, shot, seed, seedIndex, imgData, destPath, el
|
|
|
343
344
|
width: DEFAULTS.width,
|
|
344
345
|
height: DEFAULTS.height,
|
|
345
346
|
generation_time_ms: elapsedMs,
|
|
346
|
-
gpu_model:
|
|
347
|
+
gpu_model: gpuModel,
|
|
347
348
|
denoise: phase === "follow_on" ? (anchorInfo?.denoise || 0.38) : 1.0,
|
|
348
349
|
},
|
|
349
350
|
identity: {
|
|
@@ -379,6 +380,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
379
380
|
const projectName = getProjectName(argv);
|
|
380
381
|
const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
|
|
381
382
|
const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
|
|
383
|
+
const gpuModel = resolveGpuModel(loadProjectMeta(GAME_ROOT)?.defaults);
|
|
382
384
|
|
|
383
385
|
// Collect positionals, skipping values that belong to known flags
|
|
384
386
|
const knownFlagsWithValue = new Set(['--subject', '--seeds', '--phase', '--anchor', '--denoise', '--project', '--game']);
|
|
@@ -396,6 +398,20 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
396
398
|
const subjectFilter = argv.includes("--subject")
|
|
397
399
|
? argv[argv.indexOf("--subject") + 1]
|
|
398
400
|
: null;
|
|
401
|
+
|
|
402
|
+
// Freeze gate (advisory): when --subject is given, refuse if that subject's
|
|
403
|
+
// canon entry is frozen. No-op when --subject is absent (pack-driven) or
|
|
404
|
+
// when the project has no canon-build config.
|
|
405
|
+
if (subjectFilter) {
|
|
406
|
+
const reasonFlagIdx = argv.indexOf('--reason');
|
|
407
|
+
const bypassReason = reasonFlagIdx >= 0 && argv[reasonFlagIdx + 1] ? argv[reasonFlagIdx + 1] : null;
|
|
408
|
+
await assertNotFrozenBySubject(GAME_ROOT, subjectFilter, {
|
|
409
|
+
action: 'generate:identity',
|
|
410
|
+
allowSoftAdvisoryBypass: argv.includes('--i-know'),
|
|
411
|
+
bypassReason,
|
|
412
|
+
by: 'mike',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
399
415
|
const seedCount = argv.includes("--seeds")
|
|
400
416
|
? parseNumberFlag('seeds', argv[argv.indexOf("--seeds") + 1], { int: true, min: 1 })
|
|
401
417
|
: 3;
|
|
@@ -536,7 +552,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
536
552
|
|
|
537
553
|
const record = buildRecord(
|
|
538
554
|
pack, subject, shot, seed, si,
|
|
539
|
-
imgData, destPath, elapsed, phase, anchorInfo
|
|
555
|
+
imgData, destPath, elapsed, phase, anchorInfo, gpuModel
|
|
540
556
|
);
|
|
541
557
|
|
|
542
558
|
await writeFile(
|
|
@@ -17,6 +17,7 @@ import { getProjectName, parseNumberFlag } from "../lib/args.js";
|
|
|
17
17
|
import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
|
|
18
18
|
import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
|
|
19
19
|
import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
|
|
20
|
+
import { assertNotFrozenBySubject } from "../lib/freeze-gate.js";
|
|
20
21
|
|
|
21
22
|
const DEFAULTS = {
|
|
22
23
|
checkpoint: "dreamshaperXL_v21TurboDPMSDE.safetensors",
|
|
@@ -186,6 +187,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
186
187
|
throw inputError('INPUT_MISSING_FLAG', "--prompt is required");
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
// Freeze gate (advisory): refuse if the subject's canon entry is frozen.
|
|
191
|
+
const reasonFlagIdx = argv.indexOf('--reason');
|
|
192
|
+
const bypassReason = reasonFlagIdx >= 0 && argv[reasonFlagIdx + 1] ? argv[reasonFlagIdx + 1] : null;
|
|
193
|
+
await assertNotFrozenBySubject(GAME_ROOT, opts.subject, {
|
|
194
|
+
action: 'generate:ipadapter',
|
|
195
|
+
allowSoftAdvisoryBypass: argv.includes('--i-know'),
|
|
196
|
+
bypassReason,
|
|
197
|
+
by: 'mike',
|
|
198
|
+
});
|
|
199
|
+
|
|
189
200
|
// Validate --ref path stays inside project tree.
|
|
190
201
|
const safeRefPath = resolveSafeProjectPath(GAME_ROOT, opts.refPath, { flagName: 'ref' });
|
|
191
202
|
|