orizu 0.5.21 → 0.5.22

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 (41) hide show
  1. package/README.md +9 -6
  2. package/dist/gepa-engine-dispatch.d.ts.map +1 -1
  3. package/dist/gepa-engine-dispatch.js +20 -1
  4. package/dist/gepa-python-paths.d.ts.map +1 -1
  5. package/dist/gepa-python-paths.js +25 -6
  6. package/dist/help.d.ts.map +1 -1
  7. package/dist/help.js +36 -14
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -39
  10. package/dist/instruction-set-loader.d.ts.map +1 -1
  11. package/dist/instruction-set-loader.js +57 -5
  12. package/dist/instruction-sets-cli.d.ts +5 -2
  13. package/dist/instruction-sets-cli.d.ts.map +1 -1
  14. package/dist/instruction-sets-cli.js +134 -24
  15. package/dist/skilled-proposer-config.d.ts +2 -0
  16. package/dist/skilled-proposer-config.d.ts.map +1 -0
  17. package/dist/skilled-proposer-config.js +212 -0
  18. package/dist/skilled-proposer-launch.d.ts +4 -0
  19. package/dist/skilled-proposer-launch.d.ts.map +1 -1
  20. package/dist/skilled-proposer-launch.js +65 -1
  21. package/package.json +1 -1
  22. package/src/gepa-engine-dispatch.ts +21 -1
  23. package/src/gepa-python-paths.ts +24 -6
  24. package/src/help.ts +36 -14
  25. package/src/index.ts +6 -53
  26. package/src/instruction-set-loader.ts +55 -5
  27. package/src/instruction-sets-cli.ts +173 -19
  28. package/src/skilled-proposer-config.ts +191 -0
  29. package/src/skilled-proposer-launch.ts +59 -1
  30. package/vendor/orizu-gepa/src/orizu_gepa_connector/engine.py +36 -3
  31. package/vendor/orizu-gepa/src/orizu_gepa_connector/instruction_set_loader.py +31 -3
  32. package/vendor/orizu-gepa/src/orizu_gepa_connector/skilled_proposer_bridge.py +354 -5
  33. package/vendor/skills/orizu-cli/SKILL.md +50 -16
  34. package/vendor/skills/orizu-cli/references/building-judges.md +2 -2
  35. package/vendor/skills/orizu-cli/references/cli-reference.md +57 -28
  36. package/vendor/skills/orizu-cli/references/instructions-after-prompts.md +120 -0
  37. package/vendor/skills/orizu-cli/references/migrate-existing-gepa-setup.md +109 -49
  38. package/vendor/skills/orizu-cli/references/optimization-reports.md +16 -11
  39. package/vendor/skills/orizu-cli/references/optimization-with-gepa.md +29 -28
  40. package/vendor/skills/orizu-cli/references/primer.md +21 -5
  41. package/vendor/skills/orizu-cli/references/prompt-control-plane.md +132 -85
package/src/help.ts CHANGED
@@ -89,6 +89,40 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
89
89
  },
90
90
  { path: ['instructions', 'archive'], aliases: [['instruction-sets', 'archive']], usage: 'orizu instructions archive <slug-or-exact-name> --project <team/project> [--json]', summary: 'Archive an instruction set without changing resolution or sync.', group: 'Instruction sets', options: [{ name: '--project <team/project>', help: 'Project slug.', required: true }, { name: '--json', help: 'Print JSON.' }], examples: ['orizu instructions archive planner --project core/evals'] },
91
91
  { path: ['instructions', 'restore'], aliases: [['instruction-sets', 'restore']], usage: 'orizu instructions restore <slug-or-exact-name> --project <team/project> [--json]', summary: 'Restore an archived instruction set.', group: 'Instruction sets', options: [{ name: '--project <team/project>', help: 'Project slug.', required: true }, { name: '--json', help: 'Print JSON.' }], examples: ['orizu instructions restore planner --project core/evals'] },
92
+ {
93
+ path: ['instructions', 'scorers', 'set-headline'],
94
+ aliases: [['instruction-sets', 'scorers', 'set-headline']],
95
+ usage: 'orizu instructions scorers set-headline <set> --key <component-key> --scorer-version <id> [--dataset-version <id> --split-set <id> --split <name>] [--project <team/project>] [--json]',
96
+ summary: 'Bind the headline scorer for one instruction-set component.',
97
+ group: 'Instruction sets',
98
+ options: [
99
+ { name: '--key <component-key>', help: 'Instruction-set component key.', required: true },
100
+ { name: '--scorer-version <id>', help: 'Scorer version to bind.', required: true },
101
+ { name: '--dataset-version <id>', help: 'Default dataset version.' },
102
+ { name: '--split-set <id>', help: 'Default dataset split set.' },
103
+ { name: '--split <name>', help: 'Default split name.' },
104
+ { name: '--project <team/project>', help: 'Project slug.' },
105
+ { name: '--json', help: 'Print JSON.' },
106
+ ],
107
+ examples: ['orizu instructions scorers set-headline planner --key system --scorer-version <id> --project core/evals'],
108
+ },
109
+ {
110
+ path: ['instructions', 'scorers', 'add'],
111
+ aliases: [['instruction-sets', 'scorers', 'add']],
112
+ usage: 'orizu instructions scorers add <set> --key <component-key> --scorer-version <id> [--dataset-version <id> --split-set <id> --split <name>] [--project <team/project>] [--json]',
113
+ summary: 'Track an additional scorer for one instruction-set component.',
114
+ group: 'Instruction sets',
115
+ options: [
116
+ { name: '--key <component-key>', help: 'Instruction-set component key.', required: true },
117
+ { name: '--scorer-version <id>', help: 'Scorer version to bind.', required: true },
118
+ { name: '--dataset-version <id>', help: 'Default dataset version.' },
119
+ { name: '--split-set <id>', help: 'Default dataset split set.' },
120
+ { name: '--split <name>', help: 'Default split name.' },
121
+ { name: '--project <team/project>', help: 'Project slug.' },
122
+ { name: '--json', help: 'Print JSON.' },
123
+ ],
124
+ examples: ['orizu instructions scorers add planner --key system --scorer-version <id> --project core/evals'],
125
+ },
92
126
  {
93
127
  path: ['login'],
94
128
  usage: 'orizu login [--no-prompt-if-logged-in]',
@@ -413,18 +447,6 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
413
447
  summary: 'Move or set a non-production prompt label; use instructions profiles promote for production.',
414
448
  group: 'Prompts and judges',
415
449
  },
416
- {
417
- path: ['prompts', 'scorers', 'set-headline'],
418
- usage: 'orizu prompts scorers set-headline <prompt-id> --scorer-version <id> [--dataset-version <id> --split-set <id> --split <name>] [--project <team/project>] [--json]',
419
- summary: 'Bind the headline scorer for a prompt.',
420
- group: 'Scorers and runners',
421
- },
422
- {
423
- path: ['prompts', 'scorers', 'add'],
424
- usage: 'orizu prompts scorers add <prompt-id> --scorer-version <id> [--dataset-version <id> --split-set <id> --split <name>] [--project <team/project>] [--json]',
425
- summary: 'Track an additional scorer for a prompt.',
426
- group: 'Scorers and runners',
427
- },
428
450
  {
429
451
  path: ['judges', 'list'],
430
452
  usage: 'orizu judges list --project <team/project> [--status active|archived|all]',
@@ -578,8 +600,8 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
578
600
  },
579
601
  {
580
602
  path: ['optimizations', 'run-gepa'],
581
- usage: 'orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> (--candidate-version-id <id> | --instruction-set <name> --model-config <identity> [--component-selector round-robin|all]) --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> [--engine official|legacy] [--scorer-input-contract gepa|flat_row] [--scorer-candidate-field <row-field>] [--allow-degenerate-seed] --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--budget auto|light|medium|heavy | --max-metric-calls <n> | --max-full-evals <n> | --max-iterations <n> | --max-candidate-proposals <n>] [--num-threads auto|N] [--reflection-retry-attempts N] [--reflection-http-timeout-seconds N] [--log-dir logs]',
582
- summary: 'Run official GEPA locally and stream events; pass --engine legacy only for the frozen safety hatch. --instruction-set requires --model-config and cannot be combined with --candidate-version-id; round-robin updates one component-map entry per round and all updates every component. --max-candidate-proposals is official-engine-only. Validates the scorer contract on the seed before iterating; judge runners built for flat-row score runs need --scorer-input-contract flat_row (ALI-1158).',
603
+ usage: 'orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> (--candidate-version-id <id> | --instruction-set <name> --model-config <identity> [--component-selector round-robin|all]) --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> [--engine official|legacy] [--candidate-proposer skilled-proposer [--candidate-proposer-config <@file|path>] [--proposal-max-calls <n>] [--proposal-max-tokens <n>]] [--scorer-input-contract gepa|flat_row] [--scorer-candidate-field <row-field>] [--allow-degenerate-seed] --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--budget auto|light|medium|heavy | --max-metric-calls <n> | --max-full-evals <n> | --max-iterations <n> | --max-candidate-proposals <n>] [--num-threads auto|N] [--reflection-max-tokens N] [--reflection-retry-attempts N] [--reflection-http-timeout-seconds N] [--log-dir logs]',
604
+ summary: 'Run official GEPA locally and stream events; pass --engine legacy only for the frozen safety hatch. Skilled-proposer is official-only, its config requires that selection, and the selection cannot be combined with --reflection-prompt-template. --instruction-set requires --model-config and cannot be combined with --candidate-version-id; round-robin updates one component-map entry per round and all updates every component. --max-candidate-proposals is official-engine-only. Validates the scorer contract on the seed before iterating; judge runners built for flat-row score runs need --scorer-input-contract flat_row (ALI-1158).',
583
605
  group: 'Optimizations',
584
606
  },
585
607
  {
package/src/index.ts CHANGED
@@ -117,7 +117,7 @@ import { dispatchGepaEngine } from './gepa-engine-dispatch.js'
117
117
  import { resolveGepaInstructionSetProfileVersion } from './instruction-set-gepa-launch.js'
118
118
  import { getGepaPythonCommand } from './gepa-python-command.js'
119
119
  import { getGepaPythonPathEntries } from './gepa-python-paths.js'
120
- import { prepareSkilledProposerLaunch } from './skilled-proposer-launch.js'
120
+ import { prepareSkilledProposerLaunch, spawnSkilledProposerChild } from './skilled-proposer-launch.js'
121
121
 
122
122
  export { parseJsonResponse, sanitizeTerminalText } from './json-response.js'
123
123
 
@@ -1767,53 +1767,6 @@ function refusePromptMutation(replacement: string): never {
1767
1767
  throw new Error(`Use: orizu instructions ${replacement}`)
1768
1768
  }
1769
1769
 
1770
- async function bindPromptScorer(role: 'headline' | 'tracked') {
1771
- const promptId = getPositionalArg(3)
1772
- const project = getArg('--project') || await resolveProjectSlug(null)
1773
- const scorerVersionId = getArg('--scorer-version')
1774
-
1775
- if (!promptId || !scorerVersionId) {
1776
- throw new Error(`Usage: orizu prompts scorers ${role === 'headline' ? 'set-headline' : 'add'} <prompt-id> --scorer-version <id> [--dataset-version <id> --split-set <id> --split <name>] [--project <team/project>] [--json]`)
1777
- }
1778
-
1779
- const response = await authedFetch(`/api/cli/prompts/scorers?project=${encodeURIComponent(project)}`, {
1780
- method: 'POST',
1781
- headers: { 'Content-Type': 'application/json' },
1782
- body: JSON.stringify({
1783
- promptId,
1784
- scorerVersionId,
1785
- role,
1786
- datasetVersionId: getArg('--dataset-version') || undefined,
1787
- splitSetId: getArg('--split-set') || undefined,
1788
- splitName: getArg('--split') || undefined,
1789
- }),
1790
- })
1791
-
1792
- if (!response.ok) {
1793
- throw new Error(`Failed to bind prompt scorer: ${await response.text()}`)
1794
- }
1795
-
1796
- const data = await parseJsonResponse<{
1797
- promptScorer: Record<string, unknown>
1798
- owner?: {
1799
- instructionSetSlug: string
1800
- componentKey: string
1801
- }
1802
- }>(response, 'Prompt scorer bind')
1803
- if (hasJsonFlag()) {
1804
- printJson(data)
1805
- return
1806
- }
1807
-
1808
- printLine(`${role === 'headline' ? 'Set headline' : 'Added'} scorer ${sanitizeTerminalText(scorerVersionId)} for ${sanitizeTerminalText(promptId)}`)
1809
- if (data.owner) {
1810
- printLine(
1811
- `Owner: ${sanitizeTerminalText(promptId)} -> ` +
1812
- `${sanitizeTerminalText(data.owner.instructionSetSlug)} / ${sanitizeTerminalText(data.owner.componentKey)}`
1813
- )
1814
- }
1815
- }
1816
-
1817
1770
  function getPositionalArg(index: number): string | null {
1818
1771
  const value = cliArgs[index]
1819
1772
  return value && !value.startsWith('--') ? value : null
@@ -2539,7 +2492,9 @@ async function runGepaOptimization() {
2539
2492
  if (instructionSetProfileVersionId) dispatch.environment.ORIZU_INSTRUCTION_SET_PROFILE_VERSION_ID = instructionSetProfileVersionId
2540
2493
  selectedEngine = dispatch.engine
2541
2494
  const launch = prepareSkilledProposerLaunch(python, dispatch.engine, dispatch.environment)
2542
- result = spawnSync(launch.python, ['-m', dispatch.module, ...dispatch.args], { stdio: 'inherit', env: launch.environment })
2495
+ result = await spawnSkilledProposerChild(
2496
+ launch.python, ['-m', dispatch.module, ...dispatch.args], dispatch.engine, launch.environment,
2497
+ )
2543
2498
  } finally {
2544
2499
  verified.cleanup()
2545
2500
  }
@@ -6236,13 +6191,11 @@ export async function main(rawArgs = process.argv.slice(2)) {
6236
6191
  }
6237
6192
 
6238
6193
  if (command === 'prompts' && subcommand === 'scorers' && cliArgs[2] === 'set-headline') {
6239
- await bindPromptScorer('headline')
6240
- return
6194
+ refusePromptMutation('scorers set-headline <set> --key <component-key> --scorer-version <id> --project <team/project>')
6241
6195
  }
6242
6196
 
6243
6197
  if (command === 'prompts' && subcommand === 'scorers' && cliArgs[2] === 'add') {
6244
- await bindPromptScorer('tracked')
6245
- return
6198
+ refusePromptMutation('scorers add <set> --key <component-key> --scorer-version <id> --project <team/project>')
6246
6199
  }
6247
6200
 
6248
6201
  if (command === 'judges' && subcommand === 'list') {
@@ -1,4 +1,4 @@
1
- import { existsSync, realpathSync, readFileSync, statSync } from 'node:fs'
1
+ import { existsSync, realpathSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
2
  import { resolve, sep } from 'node:path'
3
3
 
4
4
  export class InstructionSetLoaderError extends Error {
@@ -6,14 +6,64 @@ export class InstructionSetLoaderError extends Error {
6
6
  }
7
7
 
8
8
  interface Material { files: Record<string, string>; pinnedComponents?: Record<string, unknown> }
9
- interface Manifest { manifestVersion: number; name: string; shape: string[]; default: Material; profiles: Array<{ modelConfigIdentity: string; production: Material | null }>; filteredTo?: string[] }
9
+ interface Manifest { manifestVersion: number; projectId?: string; instructionSetId?: string; name: string; slug?: string; shape: string[]; default: Material; profiles: Array<{ modelConfigIdentity: string; production: Material | null }>; filteredTo?: string[] }
10
10
 
11
11
  function inside(root: string, candidate: string) { return candidate === root || candidate.startsWith(`${root}${sep}`) }
12
12
 
13
+ function manifestMatchesReference(candidate: string, reference: string): boolean {
14
+ try {
15
+ const manifest = JSON.parse(
16
+ readFileSync(resolve(candidate, 'manifest.json'), 'utf8')
17
+ ) as Pick<Manifest, 'name' | 'slug'>
18
+ return manifest.name === reference || manifest.slug === reference
19
+ } catch {
20
+ return false
21
+ }
22
+ }
23
+
24
+ function resolveSetRoot(directory: string, reference: string): string {
25
+ let directoryRoot: string
26
+ try { directoryRoot = realpathSync(directory) } catch { throw new InstructionSetLoaderError('instruction_set_not_synced') }
27
+ const direct = resolve(directoryRoot, reference)
28
+ try {
29
+ if (inside(directoryRoot, direct) && existsSync(direct) && statSync(direct).isDirectory()) {
30
+ const realDirect = realpathSync(direct)
31
+ if (inside(directoryRoot, realDirect)) {
32
+ try {
33
+ const manifest = JSON.parse(
34
+ readFileSync(resolve(realDirect, 'manifest.json'), 'utf8')
35
+ ) as Pick<Manifest, 'name' | 'slug'>
36
+ if (manifest.name === reference || manifest.slug === reference) {
37
+ return realDirect
38
+ }
39
+ } catch {
40
+ // Preserve the exact-path loader's specific missing/invalid manifest error.
41
+ return realDirect
42
+ }
43
+ }
44
+ }
45
+ } catch {
46
+ // A stale or unreadable direct-path candidate must not shadow a valid set.
47
+ }
48
+ const matches = readdirSync(directoryRoot, { withFileTypes: true }).flatMap(entry => {
49
+ try {
50
+ if (entry.name.startsWith('.') || !entry.isDirectory()) return []
51
+ const candidate = realpathSync(resolve(directoryRoot, entry.name))
52
+ return inside(directoryRoot, candidate) && manifestMatchesReference(candidate, reference)
53
+ ? [candidate]
54
+ : []
55
+ } catch {
56
+ return []
57
+ }
58
+ })
59
+ if (matches.length !== 1) throw new InstructionSetLoaderError(
60
+ matches.length > 1 ? 'instruction_set_reference_ambiguous' : 'instruction_set_not_synced'
61
+ )
62
+ return matches[0]!
63
+ }
64
+
13
65
  export function loadInstructionSet(directory: string, name: string, modelConfigIdentity: string): Record<string, string> {
14
- const unresolvedRoot = resolve(directory, name)
15
- if (!existsSync(unresolvedRoot) || !statSync(unresolvedRoot).isDirectory()) throw new InstructionSetLoaderError('instruction_set_not_synced')
16
- const setRoot = realpathSync(unresolvedRoot)
66
+ const setRoot = resolveSetRoot(directory, name)
17
67
  const manifestPath = resolve(setRoot, 'manifest.json')
18
68
  if (!existsSync(manifestPath)) throw new InstructionSetLoaderError('instruction_set_manifest_missing')
19
69
  let manifest: Manifest
@@ -43,13 +43,30 @@ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/u
43
43
  export interface SyncComponent { body?: string; repoPath?: string; contentSha?: string; commitSha?: string }
44
44
  export interface SyncMaterial { profileVersionId: string; versionNumber: number; modelConfigIdentity: string; resolvedFrom: string; components: Record<string, SyncComponent> }
45
45
  export interface SyncProfile { modelConfigIdentity: string; resolvedFrom: string; production: SyncMaterial | null }
46
- export interface SyncSet { name: string; slug?: string; description?: string | null; shape: string[]; default: SyncMaterial; profiles: SyncProfile[]; filteredTo?: string[] }
46
+ export interface SyncSet {
47
+ projectId?: string
48
+ instructionSetId?: string
49
+ name: string
50
+ slug?: string
51
+ description?: string | null
52
+ shape: string[]
53
+ default: SyncMaterial
54
+ profiles: SyncProfile[]
55
+ filteredTo?: string[]
56
+ }
47
57
  function safeSegment(value: string) { if (!SAFE_SEGMENT.test(value) || value === '.' || value === '..' || value.startsWith('.')) throw new Error('instruction_set_path_unsafe') }
58
+ function isSafeSegment(value: string) {
59
+ try { safeSegment(value); return true } catch { return false }
60
+ }
48
61
  function slug(identity: string) { return identity.replaceAll('/', '__').replace(/[^A-Za-z0-9._-]/gu, '_') }
49
62
  function stable(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n` }
50
63
  function sha256(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex') }
51
64
 
52
- export interface SyncFileOps { writeFileSync: typeof writeFileSync; renameSync?: typeof renameSync }
65
+ export interface SyncFileOps {
66
+ writeFileSync: typeof writeFileSync
67
+ renameSync?: typeof renameSync
68
+ rmSync?: typeof rmSync
69
+ }
53
70
 
54
71
  function writeMaterial(root: string, material: SyncMaterial, shape: string[], manifestRoot: string, fileOps: SyncFileOps = { writeFileSync }) {
55
72
  const files: Record<string, string> = {}
@@ -77,16 +94,76 @@ function removeOwnScratch(paths: Array<string | null>) {
77
94
  for (const path of paths) if (path && existsSync(path)) rmSync(path, { recursive: true, force: true })
78
95
  }
79
96
 
80
- function existingDestinationIsSynced(destination: string, name: string) {
97
+ function existingDestinationIsSynced(
98
+ destination: string,
99
+ set: Pick<SyncSet, 'projectId' | 'instructionSetId' | 'name' | 'slug'>
100
+ ) {
101
+ if (
102
+ set.slug &&
103
+ set.projectId === undefined &&
104
+ set.instructionSetId === undefined
105
+ ) {
106
+ return existingDestinationIsLegacySynced(destination, set.name, set.slug)
107
+ }
108
+ try {
109
+ if (!statSync(destination).isDirectory()) return false
110
+ const manifest = JSON.parse(readFileSync(join(destination, 'manifest.json'), 'utf8')) as {
111
+ manifestVersion?: number
112
+ projectId?: string
113
+ instructionSetId?: string
114
+ name?: string
115
+ slug?: string
116
+ }
117
+ return manifest.manifestVersion === 1 && (set.slug
118
+ ? manifest.slug === set.slug &&
119
+ manifest.projectId === set.projectId &&
120
+ manifest.instructionSetId === set.instructionSetId
121
+ : manifest.name === set.name)
122
+ } catch { return false }
123
+ }
124
+
125
+ function existingDestinationIsLegacySynced(
126
+ destination: string,
127
+ name: string,
128
+ slug?: string
129
+ ) {
81
130
  try {
82
131
  if (!statSync(destination).isDirectory()) return false
83
- const manifest = JSON.parse(readFileSync(join(destination, 'manifest.json'), 'utf8')) as { manifestVersion?: number; name?: string }
84
- return manifest.manifestVersion === 1 && manifest.name === name
132
+ const manifest = JSON.parse(readFileSync(join(destination, 'manifest.json'), 'utf8')) as {
133
+ manifestVersion?: number
134
+ name?: string
135
+ slug?: string
136
+ }
137
+ return manifest.manifestVersion === 1 &&
138
+ manifest.name === name &&
139
+ manifest.slug === slug
85
140
  } catch { return false }
86
141
  }
87
142
 
143
+ function removePublishedBackups(
144
+ paths: string[],
145
+ remove: typeof rmSync
146
+ ) {
147
+ let firstError: unknown = null
148
+ for (const path of paths) {
149
+ if (!existsSync(path)) continue
150
+ try {
151
+ remove(path, { recursive: true, force: true })
152
+ } catch (error) {
153
+ firstError ??= error
154
+ }
155
+ }
156
+ if (firstError) throw new Error('instruction_set_sync_cleanup_failed')
157
+ }
158
+
88
159
  export function syncToDisk(out: string, set: SyncSet, fileOps: SyncFileOps = { writeFileSync }) {
89
- safeSegment(set.name)
160
+ const directoryName = set.slug || set.name
161
+ safeSegment(directoryName)
162
+ const hasProjectId = set.projectId !== undefined
163
+ const hasInstructionSetId = set.instructionSetId !== undefined
164
+ if (set.slug && hasProjectId !== hasInstructionSetId) {
165
+ throw new Error('instruction_set_sync_identity_missing')
166
+ }
90
167
  for (const key of set.shape) safeSegment(key)
91
168
  const profileSlugs = new Set<string>()
92
169
  for (const profile of set.profiles) {
@@ -96,11 +173,28 @@ export function syncToDisk(out: string, set: SyncSet, fileOps: SyncFileOps = { w
96
173
  profileSlugs.add(value)
97
174
  }
98
175
  mkdirSync(out, { recursive: true })
99
- const destination = join(out, set.name)
100
- if (existsSync(destination) && !existingDestinationIsSynced(destination, set.name)) throw new Error('instruction_set_sync_destination_not_synced')
101
- const temp = join(out, `.${set.name}.${randomUUID()}.tmp`)
176
+ const destination = join(out, directoryName)
177
+ const legacyDestination = set.slug && set.name !== set.slug && isSafeSegment(set.name)
178
+ ? join(out, set.name)
179
+ : null
180
+ if (existsSync(destination)) {
181
+ if (set.slug && existingDestinationIsLegacySynced(destination, set.name)) {
182
+ throw new Error('instruction_set_sync_legacy_identity_required')
183
+ }
184
+ if (!existingDestinationIsSynced(destination, set)) {
185
+ throw new Error('instruction_set_sync_destination_not_synced')
186
+ }
187
+ }
188
+ if (legacyDestination && existsSync(legacyDestination)) {
189
+ if (existingDestinationIsLegacySynced(legacyDestination, set.name)) {
190
+ throw new Error('instruction_set_sync_legacy_identity_required')
191
+ }
192
+ throw new Error('instruction_set_sync_destination_not_synced')
193
+ }
194
+ const priorDestinations = [destination]
195
+ const temp = join(out, `.${directoryName}.${randomUUID()}.tmp`)
102
196
  const move = fileOps.renameSync || renameSync
103
- let backup: string | null = null
197
+ const backups: Array<{ original: string; backup: string }> = []
104
198
  mkdirSync(temp)
105
199
  try {
106
200
  const defaultMaterial = writeMaterial(temp, set.default, set.shape, 'default', fileOps)
@@ -114,18 +208,40 @@ export function syncToDisk(out: string, set: SyncSet, fileOps: SyncFileOps = { w
114
208
  ? Object.entries(profile.production.files).map(([key, path]) => ({ key, modelConfig: profile.modelConfigIdentity, path, syncedContentSha256: profile.production!.syncedContentSha256[key] }))
115
209
  : []),
116
210
  ]
117
- const manifest = { manifestVersion: 1, name: set.name, ...(Object.hasOwn(set, 'description') ? { description: set.description ?? null } : {}), shape: set.shape, components, default: defaultMaterial, profiles, ...(set.filteredTo ? { filteredTo: [...set.filteredTo].sort() } : {}) }
211
+ const manifest = {
212
+ manifestVersion: 1,
213
+ ...(set.projectId ? { projectId: set.projectId } : {}),
214
+ ...(set.instructionSetId ? { instructionSetId: set.instructionSetId } : {}),
215
+ name: set.name,
216
+ ...(set.slug ? { slug: set.slug } : {}),
217
+ ...(Object.hasOwn(set, 'description') ? { description: set.description ?? null } : {}),
218
+ shape: set.shape,
219
+ components,
220
+ default: defaultMaterial,
221
+ profiles,
222
+ ...(set.filteredTo ? { filteredTo: [...set.filteredTo].sort() } : {}),
223
+ }
118
224
  fileOps.writeFileSync(join(temp, 'manifest.json'), stable(manifest))
119
- backup = join(out, `.${set.name}.${randomUUID()}.bak`)
120
- if (existsSync(destination)) move(destination, backup)
121
- try { move(temp, destination) } catch (error) { if (existsSync(backup)) move(backup, destination); throw error }
122
- removeOwnScratch([backup])
225
+ for (const prior of priorDestinations) {
226
+ if (!existsSync(prior)) continue
227
+ const backup = join(out, `.${directoryName}.${randomUUID()}.bak`)
228
+ move(prior, backup)
229
+ backups.push({ original: prior, backup })
230
+ }
231
+ move(temp, destination)
123
232
  } catch (error) {
124
233
  removeOwnScratch([temp])
125
- if (backup && existsSync(backup) && !existsSync(destination)) move(backup, destination)
126
- removeOwnScratch([backup])
234
+ for (const { original, backup } of [...backups].reverse()) {
235
+ if (existsSync(backup) && !existsSync(original)) move(backup, original)
236
+ }
237
+ removeOwnScratch(backups.map(entry => entry.backup))
127
238
  throw error
128
239
  }
240
+ removePublishedBackups(
241
+ backups.map(entry => entry.backup),
242
+ fileOps.rmSync || rmSync
243
+ )
244
+ return destination
129
245
  }
130
246
 
131
247
  export async function instructionSetsCommand(args: string[], io: InstructionSetsCommandIo): Promise<number> {
@@ -143,6 +259,44 @@ export async function instructionSetsCommand(args: string[], io: InstructionSets
143
259
  writeManifest = loadInstructionSetManifest(reference)
144
260
  }
145
261
  const project = await io.resolveProjectSlug(argValue(args, '--project'))
262
+ if (subcommand === 'scorers') {
263
+ const action = reference
264
+ const set = positionals[2]
265
+ const componentKey = argValue(args, '--key')
266
+ const scorerVersionId = argValue(args, '--scorer-version')
267
+ if (
268
+ (action !== 'set-headline' && action !== 'add') ||
269
+ !set ||
270
+ !componentKey ||
271
+ !scorerVersionId
272
+ ) {
273
+ throw new Error(
274
+ 'Usage: orizu instructions scorers set-headline|add <set> --key <component-key> --scorer-version <id> --project <team/project> [--dataset-version <id> --split-set <id> --split <name>] [--json]'
275
+ )
276
+ }
277
+ const payload = await responsePayload(await authedFetch(
278
+ `/api/cli/instruction-sets/${encodeURIComponent(set)}/scorers?project=${encodeURIComponent(project)}`,
279
+ {
280
+ method: 'POST',
281
+ headers: { 'content-type': 'application/json' },
282
+ body: JSON.stringify({
283
+ componentKey,
284
+ scorerVersionId,
285
+ role: action === 'set-headline' ? 'headline' : 'tracked',
286
+ datasetVersionId: argValue(args, '--dataset-version') || undefined,
287
+ splitSetId: argValue(args, '--split-set') || undefined,
288
+ splitName: argValue(args, '--split') || undefined,
289
+ }),
290
+ }
291
+ ), `Instruction sets scorers ${action}`)
292
+ if (io.json) io.print(JSON.stringify(payload))
293
+ else {
294
+ io.print(
295
+ `${action === 'set-headline' ? 'Set headline' : 'Added'} scorer ${sanitizeTerminalText(scorerVersionId)} for ${sanitizeTerminalText(set)} / ${sanitizeTerminalText(componentKey)}`
296
+ )
297
+ }
298
+ return 0
299
+ }
146
300
  if (subcommand === 'default') {
147
301
  const operation = reference; const set = positionals[2]; const identity = argValue(args, '--model-config'); const version = argValue(args, '--version')
148
302
  if ((operation !== 'show' && operation !== 'move') || !set) throw new Error('Usage: orizu instructions default show|move <set> --project <team/project> [--model-config <identity> --version <n>] [--json]')
@@ -270,8 +424,8 @@ export async function instructionSetsCommand(args: string[], io: InstructionSets
270
424
  if (!set || (set.slug !== reference && set.name !== reference) || (modelConfig && (!Array.isArray(set.filteredTo) || set.filteredTo.length !== 1 || set.filteredTo[0] !== modelConfig))) {
271
425
  throw new Error('instruction_set_sync_response_mismatch')
272
426
  }
273
- syncToDisk(output, set)
274
- if (io.json) io.print(JSON.stringify(payload)); else io.print(`Synced ${set.name} to ${join(output, set.name)}`)
427
+ const destination = syncToDisk(output, set)
428
+ if (io.json) io.print(JSON.stringify(payload)); else io.print(`Synced ${set.name} to ${destination}`)
275
429
  return 0
276
430
  }
277
431
  if (subcommand === 'show' && (!reference || reference.startsWith('--'))) throw new Error('Instruction set name is required')