@mcptoolshop/style-dataset-lab 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/README.es.md +40 -19
  3. package/README.fr.md +74 -53
  4. package/README.hi.md +67 -46
  5. package/README.it.md +42 -21
  6. package/README.ja.md +44 -23
  7. package/README.md +25 -4
  8. package/README.pt-BR.md +42 -21
  9. package/README.zh.md +66 -45
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +114 -27
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +82 -3
  28. package/lib/critique-render.js +45 -0
  29. package/lib/eligibility.js +121 -9
  30. package/lib/eval-pack.js +14 -14
  31. package/lib/eval-runs.js +33 -0
  32. package/lib/export.js +122 -34
  33. package/lib/freeze-gate.js +69 -16
  34. package/lib/freeze-stamp.js +28 -3
  35. package/lib/generated-provenance.js +16 -1
  36. package/lib/implementation-packs.js +95 -17
  37. package/lib/ingest.js +396 -0
  38. package/lib/judgment-provenance.js +220 -0
  39. package/lib/measure.js +422 -0
  40. package/lib/paths.js +196 -7
  41. package/lib/reingest-selected.js +98 -16
  42. package/lib/run-manifest.js +394 -0
  43. package/lib/run-summary.js +12 -6
  44. package/lib/runtime-runs.js +45 -4
  45. package/lib/snapshot.js +121 -16
  46. package/lib/split.js +364 -48
  47. package/lib/training-manifests.js +114 -21
  48. package/lib/training-packages.js +147 -15
  49. package/lib/verifier.js +67 -0
  50. package/lib/workflow-profiles.js +24 -3
  51. package/package.json +3 -2
  52. package/runtime/comfyui/txt2img-qwen.json +38 -0
  53. package/runtime/comfyui/txt2img-standard.json +21 -0
  54. package/scripts/_resume.js +62 -0
  55. package/scripts/_shared-args.js +44 -0
  56. package/scripts/brief-compile.js +2 -1
  57. package/scripts/brief-show.js +2 -1
  58. package/scripts/canon-bind.js +2 -2
  59. package/scripts/canon-build.js +27 -20
  60. package/scripts/canon-drift.js +16 -7
  61. package/scripts/canon-freeze-status.js +14 -6
  62. package/scripts/canon-freeze.js +27 -11
  63. package/scripts/canon-unfreeze.js +17 -9
  64. package/scripts/card.js +2 -2
  65. package/scripts/compare.js +2 -2
  66. package/scripts/critique.js +48 -8
  67. package/scripts/curate.js +23 -2
  68. package/scripts/doctor.js +181 -55
  69. package/scripts/eligibility.js +42 -2
  70. package/scripts/eval-pack.js +2 -2
  71. package/scripts/eval-run.js +2 -2
  72. package/scripts/export.js +2 -2
  73. package/scripts/generate-controlnet.js +45 -25
  74. package/scripts/generate-identity.js +53 -23
  75. package/scripts/generate-ipadapter.js +44 -23
  76. package/scripts/generate.js +72 -5
  77. package/scripts/implementation-pack.js +2 -2
  78. package/scripts/ingest.js +145 -0
  79. package/scripts/init.js +73 -13
  80. package/scripts/measure.js +170 -0
  81. package/scripts/measure_image.py +458 -0
  82. package/scripts/migrate-records.js +142 -0
  83. package/scripts/painterly-test.js +4 -3
  84. package/scripts/painterly.js +7 -3
  85. package/scripts/qwen_generate.py +334 -13
  86. package/scripts/reingest.js +2 -2
  87. package/scripts/run-generate.js +3 -0
  88. package/scripts/sheet.js +142 -0
  89. package/scripts/snapshot.js +2 -2
  90. package/scripts/split.js +2 -2
  91. package/scripts/training-manifest.js +2 -2
  92. package/scripts/training-package.js +2 -2
  93. package/scripts/training-profile.js +2 -2
  94. package/scripts/workflow-list.js +2 -1
  95. package/scripts/workflow-show.js +2 -1
@@ -0,0 +1,62 @@
1
+ /**
2
+ * _resume.js — internal helper module (NOT a CLI command; underscore prefix
3
+ * matches the existing scripts/_batch_cut.py / _seam_cutout.py convention
4
+ * for "helper, not dispatched by bin/sdlab.js").
5
+ *
6
+ * H5: generate.js was the only one of the four ComfyUI generator scripts
7
+ * that implemented `--resume` — skip an asset whose provenance record AND
8
+ * output image already exist on disk, WITHOUT skipping its seed slot, so a
9
+ * resumed run stays bit-identical to an uninterrupted one. generate-identity
10
+ * / generate-controlnet / generate-ipadapter had no --resume at all, which
11
+ * is expensive specifically for generate-identity (the priciest per-image
12
+ * generator in the pipeline): interrupting a 12-subject run after subject 9
13
+ * meant regenerating all 12 from scratch.
14
+ *
15
+ * Extracted here (once) so all four generators — and any future one — get
16
+ * the same discipline by importing it, instead of by copy-pasting the check
17
+ * next to generate.js's inline version. `lib/` is outside this fix's file
18
+ * ownership, so this lives under scripts/** instead; see the dispatch
19
+ * report for the full rationale.
20
+ *
21
+ * `requireRecord` exists because not every generator writes a provenance
22
+ * record: generate.js and generate-identity.js both write
23
+ * records/<id>.json, but generate-controlnet.js and generate-ipadapter.js
24
+ * are lighter-weight discovery scripts that only ever write the output PNG
25
+ * — they have no records/ write path at all. Requiring a record that a
26
+ * script structurally never writes would make --resume a permanent no-op
27
+ * for those two, which is worse than not having the flag. Pass
28
+ * `{ requireRecord: false }` for generators with no records/ write path.
29
+ */
30
+ import { existsSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+
33
+ /**
34
+ * @param {string} projectRoot — GAME_ROOT
35
+ * @param {string} assetId — the record id / output filename stem (no extension)
36
+ * @param {Object} [opts]
37
+ * @param {boolean} [opts.requireRecord=true] — also require records/<id>.json
38
+ * @param {string} [opts.recordsSubdir='records']
39
+ * @param {string} [opts.candidatesSubdir='outputs/candidates']
40
+ * @returns {boolean} true when the asset is safe to skip on --resume
41
+ */
42
+ export function isResumable(projectRoot, assetId, opts = {}) {
43
+ const requireRecord = opts.requireRecord !== false;
44
+ const candidatesSubdir = opts.candidatesSubdir || 'outputs/candidates';
45
+ const imagePath = join(projectRoot, candidatesSubdir, `${assetId}.png`);
46
+ if (!existsSync(imagePath)) return false;
47
+
48
+ if (!requireRecord) return true;
49
+
50
+ const recordsSubdir = opts.recordsSubdir || 'records';
51
+ const recordPath = join(projectRoot, recordsSubdir, `${assetId}.json`);
52
+ return existsSync(recordPath);
53
+ }
54
+
55
+ /**
56
+ * Print the standard "(resumed — skipped)" progress line — matches
57
+ * generate.js's console.log verbatim so all four generators read
58
+ * identically under --resume.
59
+ */
60
+ export function logResumedSkip(index, total, assetId) {
61
+ console.log(` [${index}/${total}] ${assetId} \x1b[2m(resumed — skipped)\x1b[0m`);
62
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * _shared-args.js — internal helper module (NOT a CLI command; underscore
3
+ * prefix matches the existing scripts/_batch_cut.py / _seam_cutout.py
4
+ * convention for "helper, not dispatched by bin/sdlab.js").
5
+ *
6
+ * H4 (second half): the five canon-*.js scripts (canon-build.js,
7
+ * canon-drift.js, canon-freeze.js, canon-freeze-status.js,
8
+ * canon-unfreeze.js) all use Node's built-in `util.parseArgs({strict:
9
+ * false})`. strict:false is there deliberately, so a bare positional
10
+ * argument (the entity id) isn't rejected — but it has a side effect: ANY
11
+ * unrecognized `--flag` is silently accepted too. A misspelled OPTIONAL
12
+ * flag (`--staus follow` instead of `--status follow`, `--jsn` instead of
13
+ * `--json`) produces no error and no warning — the option you meant to set
14
+ * just quietly keeps its default, and Node's parser stores the typo under
15
+ * ITS OWN key in `parsed.values`, never touching the one you meant.
16
+ *
17
+ * assertKnownOptions() closes that gap without giving up strict:false's
18
+ * positional leniency: after parsing, walk `parsed.values`'s keys — since
19
+ * every DECLARED option (default or explicit) and every UNDECLARED flag
20
+ * alike lands there — and reject any key outside the known option set.
21
+ * Mirrors lib/args.js's own UNKNOWN_FLAG shape (same code, same
22
+ * "Did you mean" hint via findClosest) so a typo on a canon-*.js flag reads
23
+ * identically to a typo anywhere else in the CLI.
24
+ */
25
+ import { inputError } from '../lib/errors.js';
26
+ import { findClosest } from '../lib/args.js';
27
+
28
+ /**
29
+ * @param {Object} parsedValues — the `.values` object returned by
30
+ * node:util's parseArgs({strict:false, ...})
31
+ * @param {Object} optionsSpec — the SAME `options` object passed to parseArgs
32
+ * @param {{ command?: string }} [opts] — command label for the hint text
33
+ */
34
+ export function assertKnownOptions(parsedValues, optionsSpec, opts = {}) {
35
+ const known = new Set(Object.keys(optionsSpec));
36
+ for (const key of Object.keys(parsedValues || {})) {
37
+ if (known.has(key)) continue;
38
+ const suggestion = findClosest(key, [...known]);
39
+ const hint = suggestion
40
+ ? `Did you mean --${suggestion}? (Run "sdlab ${opts.command ? opts.command + ' ' : ''}--help" to see all supported flags.)`
41
+ : `Run "sdlab ${opts.command ? opts.command + ' ' : ''}--help" to see supported flags.`;
42
+ throw inputError('UNKNOWN_FLAG', `Unknown flag: --${key}`, hint);
43
+ }
44
+ }
@@ -14,6 +14,7 @@ import { parseArgs, getProjectName, parseNumberFlag } from '../lib/args.js';
14
14
  import { getProjectRoot } from '../lib/paths.js';
15
15
  import { compileBrief, saveCompiledBrief } from '../lib/brief-compiler.js';
16
16
  import { info } from '../lib/log.js';
17
+ import { handleCliError } from '../lib/errors.js';
17
18
 
18
19
  export async function run(argv = process.argv.slice(2)) {
19
20
  const { flags } = parseArgs(argv, {
@@ -130,5 +131,5 @@ export async function run(argv = process.argv.slice(2)) {
130
131
  }
131
132
 
132
133
  if (process.argv[1] && (process.argv[1].endsWith('brief-compile.js') || process.argv[1].endsWith('brief-compile'))) {
133
- run().catch((err) => { console.error(err.message || err); process.exit(1); });
134
+ run().catch(handleCliError);
134
135
  }
@@ -13,6 +13,7 @@ import { parseArgs, getProjectName } from '../lib/args.js';
13
13
  import { getProjectRoot } from '../lib/paths.js';
14
14
  import { loadCompiledBrief } from '../lib/brief-compiler.js';
15
15
  import { renderBriefText, renderBriefMarkdown } from '../lib/brief-render.js';
16
+ import { handleCliError } from '../lib/errors.js';
16
17
 
17
18
  export async function run(argv = process.argv.slice(2)) {
18
19
  const { flags, positionals } = parseArgs(argv, {
@@ -49,5 +50,5 @@ export async function run(argv = process.argv.slice(2)) {
49
50
  }
50
51
 
51
52
  if (process.argv[1] && (process.argv[1].endsWith('brief-show.js') || process.argv[1].endsWith('brief-show'))) {
52
- run().catch((err) => { console.error(err.message || err); process.exit(1); });
53
+ run().catch(handleCliError);
53
54
  }
@@ -19,7 +19,7 @@
19
19
  import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
20
20
  import { join } from 'path';
21
21
  import { getProjectName } from '../lib/args.js';
22
- import { REPO_ROOT } from '../lib/paths.js';
22
+ import { getProjectRoot } from '../lib/paths.js';
23
23
  import { loadConstitution, loadLanes, loadRubric, loadTerminology, detectLane, detectGroup } from '../lib/config.js';
24
24
  import { inputError, handleCliError } from '../lib/errors.js';
25
25
 
@@ -155,7 +155,7 @@ function generateAssertions(record, constitutionRules, lanesConfig, rubricConfig
155
155
 
156
156
  export async function run(argv = process.argv.slice(2)) {
157
157
  const projectName = getProjectName(argv);
158
- const PROJECT_ROOT = join(REPO_ROOT, 'projects', projectName);
158
+ const PROJECT_ROOT = getProjectRoot(projectName);
159
159
  const RECORDS_DIR = join(PROJECT_ROOT, 'records');
160
160
  const DRY_RUN = argv.includes('--dry-run');
161
161
  const STATS_ONLY = argv.includes('--stats');
@@ -7,27 +7,35 @@
7
7
  */
8
8
 
9
9
  import { parseArgs } from 'node:util';
10
- import { join } from 'node:path';
11
- import { existsSync } from 'node:fs';
10
+ import { getProjectRoot } from '../lib/paths.js';
12
11
  import { runBuild } from '../lib/canon-build/build.js';
13
12
  import { inputError } from '../lib/errors.js';
13
+ import { assertKnownOptions } from './_shared-args.js';
14
14
 
15
15
  export async function run(argv) {
16
+ const options = {
17
+ project: { type: 'string' },
18
+ game: { type: 'string' }, // deprecated alias
19
+ full: { type: 'boolean', default: false },
20
+ 'no-cache': { type: 'boolean', default: false },
21
+ 'dry-run': { type: 'boolean', default: false },
22
+ only: { type: 'string' },
23
+ json: { type: 'boolean', default: false },
24
+ quiet: { type: 'boolean', default: false },
25
+ };
16
26
  const parsed = parseArgs({
17
27
  args: argv,
18
- options: {
19
- project: { type: 'string' },
20
- game: { type: 'string' }, // deprecated alias
21
- full: { type: 'boolean', default: false },
22
- 'no-cache': { type: 'boolean', default: false },
23
- 'dry-run': { type: 'boolean', default: false },
24
- only: { type: 'string' },
25
- json: { type: 'boolean', default: false },
26
- quiet: { type: 'boolean', default: false },
27
- },
28
+ options,
28
29
  allowPositionals: true,
29
30
  strict: false,
30
31
  });
32
+ // H4: strict:false is deliberate (it lets the entity-id positional through
33
+ // unrejected) but its side effect is that ANY unrecognized --flag is
34
+ // silently accepted too — a misspelled OPTIONAL flag (--ful instead of
35
+ // --full, --dry-Run) produces no error, no warning, and the option you
36
+ // meant to set just quietly keeps its default. Post-validate instead of
37
+ // trusting strict:false's leniency for everything.
38
+ assertKnownOptions(parsed.values, options, { command: 'canon build' });
31
39
 
32
40
  const projectName = parsed.values.project || parsed.values.game;
33
41
  if (!projectName) {
@@ -38,14 +46,13 @@ export async function run(argv) {
38
46
  );
39
47
  }
40
48
 
41
- const projectRoot = join(process.cwd(), 'projects', projectName);
42
- if (!existsSync(projectRoot)) {
43
- throw inputError(
44
- 'INPUT_PROJECT_NOT_FOUND',
45
- `Project directory not found: ${projectRoot}`,
46
- `Run from the style-dataset-lab repo root, or scaffold the project with "sdlab init ${projectName}".`,
47
- );
48
- }
49
+ // SDL-H12: resolve via the hardened workspace-root-aware resolver instead of
50
+ // join(process.cwd(), 'projects', projectName) — for a globally-installed
51
+ // sdlab the user is never inside the package directory, so process.cwd()
52
+ // silently pointed at the wrong place. getProjectRoot() also subsumes the
53
+ // existence check this used to do by hand (same INPUT_UNKNOWN_PROJECT shape
54
+ // as every other command, with an accurate "sdlab init" hint).
55
+ const projectRoot = getProjectRoot(projectName);
49
56
 
50
57
  const only = parsed.values.only
51
58
  ? parsed.values.only.split(',').map((s) => s.trim()).filter(Boolean)
@@ -19,24 +19,33 @@ import { loadSchema } from '../lib/canon-build/load-schema.js';
19
19
  import { readFreezeStatus, resolveWatchFields, computeWatchHash } from '../lib/freeze-stamp.js';
20
20
  import { readEventsSince } from '../lib/freeze-events.js';
21
21
  import { inputError } from '../lib/errors.js';
22
+ import { getProjectRoot } from '../lib/paths.js';
23
+ import { assertKnownOptions } from './_shared-args.js';
22
24
 
23
25
  export async function run(argv) {
26
+ const options = {
27
+ project: { type: 'string' },
28
+ game: { type: 'string' },
29
+ since: { type: 'string' },
30
+ json: { type: 'boolean', default: false },
31
+ };
24
32
  const parsed = parseArgs({
25
33
  args: argv,
26
- options: {
27
- project: { type: 'string' },
28
- game: { type: 'string' },
29
- since: { type: 'string' },
30
- json: { type: 'boolean', default: false },
31
- },
34
+ options,
32
35
  allowPositionals: true,
33
36
  strict: false,
34
37
  });
38
+ // H4: see canon-build.js's comment — strict:false's leniency on unknown
39
+ // flags means a typo'd optional flag (e.g. --sinse instead of --since)
40
+ // silently no-ops instead of erroring.
41
+ assertKnownOptions(parsed.values, options, { command: 'canon drift' });
35
42
 
36
43
  const projectName = parsed.values.project || parsed.values.game;
37
44
  if (!projectName) throw inputError('INPUT_MISSING_FLAG', '--project required');
38
45
 
39
- const projectRoot = join(process.cwd(), 'projects', projectName);
46
+ // SDL-H12: resolve via the hardened workspace-root-aware resolver, not
47
+ // process.cwd() (wrong base for a globally-installed sdlab).
48
+ const projectRoot = getProjectRoot(projectName);
40
49
  const config = await loadBuildConfig(join(projectRoot, 'canon-build', 'config.json'), projectRoot);
41
50
 
42
51
  // Load latest build manifest for the stamped watch-hashes
@@ -10,25 +10,33 @@ import { loadCanonEntriesInDir } from '../lib/canon-build/load-entry.js';
10
10
  import { readFreezeStatus, resolveWatchFields } from '../lib/freeze-stamp.js';
11
11
  import { readEventsFor } from '../lib/freeze-events.js';
12
12
  import { inputError } from '../lib/errors.js';
13
+ import { getProjectRoot } from '../lib/paths.js';
14
+ import { assertKnownOptions } from './_shared-args.js';
13
15
 
14
16
  export async function run(argv) {
17
+ const options = {
18
+ project: { type: 'string' },
19
+ game: { type: 'string' },
20
+ json: { type: 'boolean', default: false },
21
+ };
15
22
  const parsed = parseArgs({
16
23
  args: argv,
17
- options: {
18
- project: { type: 'string' },
19
- game: { type: 'string' },
20
- json: { type: 'boolean', default: false },
21
- },
24
+ options,
22
25
  allowPositionals: true,
23
26
  strict: false,
24
27
  });
28
+ // H4: see canon-build.js's comment — strict:false's leniency on unknown
29
+ // flags means a typo'd optional flag silently no-ops instead of erroring.
30
+ assertKnownOptions(parsed.values, options, { command: 'canon freeze-status' });
25
31
 
26
32
  const entityIdArg = (parsed.positionals || [])[0];
27
33
  if (!entityIdArg) throw inputError('INPUT_MISSING_ARG', 'sdlab canon freeze-status requires an entity id.');
28
34
  const projectName = parsed.values.project || parsed.values.game;
29
35
  if (!projectName) throw inputError('INPUT_MISSING_FLAG', '--project required');
30
36
 
31
- const projectRoot = join(process.cwd(), 'projects', projectName);
37
+ // SDL-H12: resolve via the hardened workspace-root-aware resolver, not
38
+ // process.cwd() (wrong base for a globally-installed sdlab).
39
+ const projectRoot = getProjectRoot(projectName);
32
40
  const config = await loadBuildConfig(join(projectRoot, 'canon-build', 'config.json'), projectRoot);
33
41
 
34
42
  let resolved = null;
@@ -18,23 +18,33 @@ import { loadCanonEntriesInDir, entryId } from '../lib/canon-build/load-entry.js
18
18
  import { buildFreezeBlock, FREEZE_STATUSES, resolveWatchFields } from '../lib/freeze-stamp.js';
19
19
  import { appendEvent } from '../lib/freeze-events.js';
20
20
  import { inputError } from '../lib/errors.js';
21
+ import { getProjectRoot } from '../lib/paths.js';
22
+ import { assertKnownOptions } from './_shared-args.js';
21
23
 
22
24
  export async function run(argv) {
25
+ const 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
+ };
23
35
  const parsed = parseArgs({
24
36
  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
- },
37
+ options,
35
38
  allowPositionals: true,
36
39
  strict: false,
37
40
  });
41
+ // H4: see canon-build.js's comment — strict:false's leniency on unknown
42
+ // flags means a typo'd optional flag silently no-ops instead of erroring.
43
+ // This one matters more than most: `sdlab canon freeze X --staus
44
+ // soft-advisory` (typo) would silently keep status at its default
45
+ // 'frozen' — a HARD block — instead of the 'soft-advisory' the operator
46
+ // typed, and a typo'd --watch/--build silently drops a real override too.
47
+ assertKnownOptions(parsed.values, options, { command: 'canon freeze' });
38
48
 
39
49
  const positional = parsed.positionals || [];
40
50
  const entityIdArg = positional[0];
@@ -72,7 +82,13 @@ export async function run(argv) {
72
82
  );
73
83
  }
74
84
 
75
- const projectRoot = join(process.cwd(), 'projects', projectName);
85
+ // SDL-H12: was join(process.cwd(), 'projects', projectName) — wrong base for
86
+ // a globally-installed sdlab. getProjectRoot() resolves from the workspace
87
+ // root (not cwd) and throws a clear INPUT_UNKNOWN_PROJECT before ever
88
+ // reaching loadBuildConfig(), instead of a deeper, misleading
89
+ // CANON_BUILD_CONFIG_NOT_FOUND pointed at a config that "already exists" —
90
+ // just under a different (correct) project root.
91
+ const projectRoot = getProjectRoot(projectName);
76
92
  const configPath = join(projectRoot, 'canon-build', 'config.json');
77
93
  const config = await loadBuildConfig(configPath, projectRoot);
78
94
 
@@ -18,21 +18,27 @@ import { loadCanonEntriesInDir } from '../lib/canon-build/load-entry.js';
18
18
  import { appendOverrideToFreezeBlock, readFreezeStatus } from '../lib/freeze-stamp.js';
19
19
  import { appendEvent } from '../lib/freeze-events.js';
20
20
  import { inputError } from '../lib/errors.js';
21
+ import { getProjectRoot } from '../lib/paths.js';
22
+ import { assertKnownOptions } from './_shared-args.js';
21
23
 
22
24
  export async function run(argv) {
25
+ const 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
+ };
23
33
  const parsed = parseArgs({
24
34
  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
- },
35
+ options,
33
36
  allowPositionals: true,
34
37
  strict: false,
35
38
  });
39
+ // H4: see canon-build.js's comment — strict:false's leniency on unknown
40
+ // flags means a typo'd optional flag silently no-ops instead of erroring.
41
+ assertKnownOptions(parsed.values, options, { command: 'canon unfreeze' });
36
42
 
37
43
  const entityIdArg = (parsed.positionals || [])[0];
38
44
  if (!entityIdArg) {
@@ -48,7 +54,9 @@ export async function run(argv) {
48
54
  );
49
55
  }
50
56
 
51
- const projectRoot = join(process.cwd(), 'projects', projectName);
57
+ // SDL-H12: resolve via the hardened workspace-root-aware resolver, not
58
+ // process.cwd() (wrong base for a globally-installed sdlab).
59
+ const projectRoot = getProjectRoot(projectName);
52
60
  const config = await loadBuildConfig(join(projectRoot, 'canon-build', 'config.json'), projectRoot);
53
61
  const resolved = await findEntryFile(config, entityIdArg);
54
62
  if (!resolved) {
package/scripts/card.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import { join } from 'node:path';
11
11
  import { writeFile } from 'node:fs/promises';
12
12
  import { parseArgs, getProjectName } from '../lib/args.js';
13
- import { REPO_ROOT } from '../lib/paths.js';
13
+ import { getProjectRoot } from '../lib/paths.js';
14
14
  import { inputError, handleCliError } from '../lib/errors.js';
15
15
  import { listSnapshots } from '../lib/snapshot.js';
16
16
  import { listSplits } from '../lib/split.js';
@@ -27,7 +27,7 @@ export async function run(argv = process.argv.slice(2)) {
27
27
  });
28
28
 
29
29
  const projectName = flags.project || getProjectName(argv);
30
- const projectRoot = join(REPO_ROOT, 'projects', projectName);
30
+ const projectRoot = getProjectRoot(projectName);
31
31
  const subcommand = positionals[0] || 'generate';
32
32
 
33
33
  if (subcommand === 'generate') {
@@ -11,7 +11,7 @@
11
11
  import { writeFile, readdir, mkdir, readFile } from "node:fs/promises";
12
12
  import { join } from "node:path";
13
13
  import { parseArgs, getProjectName } from "../lib/args.js";
14
- import { REPO_ROOT } from "../lib/paths.js";
14
+ import { getProjectRoot } from "../lib/paths.js";
15
15
  import { readJsonFile } from "../lib/config.js";
16
16
  import { inputError, handleCliError } from "../lib/errors.js";
17
17
 
@@ -25,7 +25,7 @@ export async function run(argv = process.argv.slice(2)) {
25
25
  });
26
26
 
27
27
  const projectName = flags.project || getProjectName(argv);
28
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
28
+ const GAME_ROOT = getProjectRoot(projectName);
29
29
 
30
30
  const [assetAId, assetBId, winner, ...reasonParts] = positionals;
31
31
 
@@ -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> Run to critique (required)');
32
- console.log(' --json Output raw JSON');
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 {
@@ -59,9 +84,24 @@ export async function run(argv = process.argv.slice(2)) {
59
84
  const action = report.recommended_action;
60
85
  console.log(`Critique saved to: runs/${flags.run}/critique.json`);
61
86
 
87
+ // H2: `refine_from_one` fires today purely from every candidate sitting
88
+ // at the unreviewed default overall_fit "usable" (nothing in the
89
+ // codebase currently sets it to anything else) — action.reviewed
90
+ // (lib/critique-engine.js recommendAction) distinguishes that from an
91
+ // actually-assessed run. Printing "Next: ..." with the same confidence
92
+ // either way presented brief-keyword drift tiebreaking as a considered
93
+ // recommendation. Qualify instead of suppressing outright — the pick is
94
+ // still a reasonable place to look first, it's just not a judgment.
62
95
  if (action.mode === 'refine_from_one' && action.preferred_candidate) {
63
96
  console.log('');
64
- info(`Next: sdlab refine --run ${flags.run} --pick ${action.preferred_candidate}`);
97
+ if (action.reviewed) {
98
+ info(`Next: sdlab refine --run ${flags.run} --pick ${action.preferred_candidate}`);
99
+ } else {
100
+ info(`No candidate has been reviewed yet — every image is at the rule-based pass's default "usable" ` +
101
+ `until a human or LLM judge assesses fit.`);
102
+ info(`If you want to proceed anyway: sdlab refine --run ${flags.run} --pick ${action.preferred_candidate} ` +
103
+ `(picked by fewest rule-based drift issues, not by looking at the image).`);
104
+ }
65
105
  } else if (action.mode === 'accept_one' && action.preferred_candidate) {
66
106
  console.log('');
67
107
  info(`Preferred candidate: ${action.preferred_candidate}`);
package/scripts/curate.js CHANGED
@@ -13,11 +13,12 @@
13
13
  import { writeFile, rename, readdir, mkdir, readFile } from "node:fs/promises";
14
14
  import { join } from "node:path";
15
15
  import { parseArgs, getProjectName } from "../lib/args.js";
16
- import { REPO_ROOT } from "../lib/paths.js";
16
+ import { getProjectRoot } 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
20
  import { assertNotFrozenByAssetPath } from "../lib/freeze-gate.js";
21
+ import { deriveGeneratorModel, warnIfSelfVerification, HUMAN_JUDGE } from "../lib/verifier.js";
21
22
 
22
23
  export async function run(argv = process.argv.slice(2)) {
23
24
  const { flags, positionals } = parseArgs(argv, {
@@ -34,7 +35,7 @@ export async function run(argv = process.argv.slice(2)) {
34
35
  });
35
36
 
36
37
  const projectName = flags.project || getProjectName(argv);
37
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
38
+ const GAME_ROOT = getProjectRoot(projectName);
38
39
  const dryRun = flags['dry-run'] || flags.dryRun;
39
40
 
40
41
  if (flags.list) {
@@ -132,11 +133,19 @@ export async function run(argv = process.argv.slice(2)) {
132
133
  );
133
134
  }
134
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
+
135
142
  // Now safely update the record. If writing fails, try to restore the image.
136
143
  record.asset_path = newPath;
137
144
  record.judgment = {
138
145
  status,
139
146
  reviewer: "human:mike",
147
+ judged_by_model: HUMAN_JUDGE,
148
+ generator_model,
140
149
  reviewed_at: new Date().toISOString(),
141
150
  explanation,
142
151
  criteria_scores,
@@ -164,6 +173,18 @@ export async function run(argv = process.argv.slice(2)) {
164
173
  result(` record: records/${assetId}.json`);
165
174
  console.log(` ${explanation}`);
166
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
+ }
167
188
  }
168
189
 
169
190
  async function listUncurated(GAME_ROOT) {