From 8e04ef9e2b7f3797acea458def5f5d3e6289f884 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 28 Jul 2026 16:38:05 +0000 Subject: [PATCH] Introduce version 4 PromptKit configuration --- docs/roadmap/implementation.md | 6 +- examples/dnd-complete.config.yml | 2 +- examples/dnd-minimal.config.yml | 2 +- internal/cli/catalog.go | 4 +- internal/cli/command_contract_test.go | 4 +- internal/cli/npc_registry_contract_test.go | 2 +- internal/cli/production_contract_test.go | 4 +- ...rium_profiles.go => promptkit_profiles.go} | 14 +- .../cli/recompute_execution_contract_test.go | 2 +- internal/cli/reference_contract_test.go | 2 +- internal/cli/run.go | 4 +- internal/cli/run_contract_test.go | 6 +- internal/cli/state_hardening_test.go | 2 +- internal/core/config/config.go | 6 +- .../config/effective_config_contract_test.go | 23 +++ internal/core/config/env_contract_test.go | 12 +- internal/core/config/file_config.go | 25 ++-- .../core/config/file_config_contract_test.go | 139 ++++++++++++++---- internal/core/config/validation.go | 6 +- .../core/config/validation_contract_test.go | 6 +- .../testdata/dnd_npc_grounded_pipeline.yml | 2 +- .../dnd_npc_interactions_pipeline.yml | 2 +- .../testdata/dnd_npcs_pipeline.yml | 2 +- .../modules/integration/testdata/pipeline.yml | 2 +- 24 files changed, 196 insertions(+), 83 deletions(-) rename internal/cli/{scriptorium_profiles.go => promptkit_profiles.go} (73%) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 09878d3..52bd1d3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -2,9 +2,9 @@ ## Status -In progress. The dependency and framework adapter replacement is implemented; -the configuration, module asset terminology, and remaining provenance alignment -are still planned. +In progress. The dependency, framework adapter, and version 4 PromptKit +configuration migration are implemented; module asset terminology and remaining +provenance alignment are still planned. ## Objective diff --git a/examples/dnd-complete.config.yml b/examples/dnd-complete.config.yml index 3162d29..03f125d 100644 --- a/examples/dnd-complete.config.yml +++ b/examples/dnd-complete.config.yml @@ -1,4 +1,4 @@ -version: 3 +version: 4 concurrency: total_llm: 2 stage_workers: diff --git a/examples/dnd-minimal.config.yml b/examples/dnd-minimal.config.yml index 0f74bd0..f96ff84 100644 --- a/examples/dnd-minimal.config.yml +++ b/examples/dnd-minimal.config.yml @@ -1,4 +1,4 @@ -version: 3 +version: 4 pipelines: dnd-session: input: seriatim diff --git a/internal/cli/catalog.go b/internal/cli/catalog.go index 256d53d..3e90ea8 100644 --- a/internal/cli/catalog.go +++ b/internal/cli/catalog.go @@ -169,8 +169,8 @@ func buildProductionLLMClient(ctx context.Context, cfg config.Config, profileID } recorder := llm.NewLLMProfileRecorder() client, err := llm.NewPromptKitClient(llm.PromptKitClientConfig{ - ProfileDir: cfg.Scriptorium.ProfileDir, - ProfileFile: cfg.Scriptorium.ProfileFile, + ProfileDir: cfg.PromptKit.ProfileDir, + ProfileFile: cfg.PromptKit.ProfileFile, Assets: assets, Recorder: recorder, }) diff --git a/internal/cli/command_contract_test.go b/internal/cli/command_contract_test.go index f25db6e..8475958 100644 --- a/internal/cli/command_contract_test.go +++ b/internal/cli/command_contract_test.go @@ -214,13 +214,13 @@ func commandContractOptionsWithLookup(t *testing.T, lookup func(string) (string, func writeCommandConfig(t *testing.T, firstID, secondID string) string { t.Helper() - content := fmt.Sprintf("version: 3\npipelines:\n %q:\n input: seriatim\n %q:\n input: seriatim\n", firstID, secondID) + content := fmt.Sprintf("version: 4\npipelines:\n %q:\n input: seriatim\n %q:\n input: seriatim\n", firstID, secondID) return writeCommandConfigContent(t, content) } func writeResolvableCommandConfig(t *testing.T) string { t.Helper() - return writeCommandConfigContent(t, `version: 3 + return writeCommandConfigContent(t, `version: 4 pipelines: demo: input: seriatim diff --git a/internal/cli/npc_registry_contract_test.go b/internal/cli/npc_registry_contract_test.go index 6d102c8..a891e01 100644 --- a/internal/cli/npc_registry_contract_test.go +++ b/internal/cli/npc_registry_contract_test.go @@ -23,7 +23,7 @@ func TestOversizedNPCRegistryFailsBeforeRuntimeAndCheckpointConstruction(t *test t.Fatal(err) } checkpointRoot := filepath.Join(t.TempDir(), "checkpoints") - content := fmt.Sprintf(`version: 3 + content := fmt.Sprintf(`version: 4 cache: chunk_plans: mode: bypass diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index 2b25661..d0fc422 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -583,7 +583,7 @@ func maintainedExampleFiles(t *testing.T) []maintainedExample { func productionSpellCatalogContractConfig(t *testing.T) string { t.Helper() - return fmt.Sprintf(`version: 3 + return fmt.Sprintf(`version: 4 cache: chunk_plans: mode: bypass @@ -680,7 +680,7 @@ func productionRunOptions(t *testing.T, fake *productionFakeLLMClient) Options { } func productionRunConfig(outputRoot, chunkModule string) string { - return fmt.Sprintf(`version: 3 + return fmt.Sprintf(`version: 4 output: directory: %q cache: diff --git a/internal/cli/scriptorium_profiles.go b/internal/cli/promptkit_profiles.go similarity index 73% rename from internal/cli/scriptorium_profiles.go rename to internal/cli/promptkit_profiles.go index d962d00..518a238 100644 --- a/internal/cli/scriptorium_profiles.go +++ b/internal/cli/promptkit_profiles.go @@ -29,13 +29,13 @@ output: `)}, } -func validateExplicitScriptoriumProfiles(ctx context.Context, cfg config.Config, profileIDs []string) error { +func validateExplicitPromptKitProfiles(ctx context.Context, cfg config.Config, profileIDs []string) error { if len(profileIDs) == 0 { return nil } engine, err := newProfileValidationEngine(cfg) if err != nil { - return fmt.Errorf("load Scriptorium profiles: %w", err) + return fmt.Errorf("load PromptKit profiles: %w", err) } for _, profileID := range profileIDs { if _, err := engine.Prepare(ctx, promptkit.RunRequest{ @@ -46,9 +46,9 @@ func validateExplicitScriptoriumProfiles(ctx context.Context, cfg config.Config, }, }); err != nil { if errors.Is(err, promptkit.ErrProfileNotFound) { - return fmt.Errorf("Scriptorium profile %q is not configured", profileID) + return fmt.Errorf("PromptKit profile %q is not configured", profileID) } - return fmt.Errorf("validate Scriptorium profile %q: %w", profileID, err) + return fmt.Errorf("validate PromptKit profile %q: %w", profileID, err) } } return nil @@ -58,11 +58,11 @@ func newProfileValidationEngine(cfg config.Config) (*promptkit.Engine, error) { opts := []promptkit.Option{ promptkit.WithPromptFS(profileCheckPromptFS, "prompts"), } - if cfg.Scriptorium.ProfileFile != "" { - opts = append(opts, promptkit.WithProfileFile(cfg.Scriptorium.ProfileFile)) + if cfg.PromptKit.ProfileFile != "" { + opts = append(opts, promptkit.WithProfileFile(cfg.PromptKit.ProfileFile)) } return promptkit.NewEngine(promptkit.Config{ PromptDir: "unused", - ProfileDir: cfg.Scriptorium.ProfileDir, + ProfileDir: cfg.PromptKit.ProfileDir, }, opts...) } diff --git a/internal/cli/recompute_execution_contract_test.go b/internal/cli/recompute_execution_contract_test.go index 4ade3df..112c98d 100644 --- a/internal/cli/recompute_execution_contract_test.go +++ b/internal/cli/recompute_execution_contract_test.go @@ -194,7 +194,7 @@ func (recomputeTestOutput) Encode(_ context.Context, req contracts.OutputRequest func newRecomputeTestRoots(t *testing.T) stateTestRoots { t.Helper() roots := newStateTestRoots(t) - config := fmt.Sprintf(`version: 3 + config := fmt.Sprintf(`version: 4 output: directory: %q cache: diff --git a/internal/cli/reference_contract_test.go b/internal/cli/reference_contract_test.go index 4cf5f62..1d8a192 100644 --- a/internal/cli/reference_contract_test.go +++ b/internal/cli/reference_contract_test.go @@ -217,7 +217,7 @@ func TestReferenceMaterializationSeparatesCLIAndConfigPathOrigins(t *testing.T) workingDir := t.TempDir() cfg := referenceContractConfig() configPath := filepath.Join(configDir, "config.yml") - if err := os.WriteFile(configPath, []byte("version: 3\n"), 0o600); err != nil { + if err := os.WriteFile(configPath, []byte("version: 4\n"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(configDir, "required.txt"), []byte("config reference"), 0o600); err != nil { diff --git a/internal/cli/run.go b/internal/cli/run.go index 90afe1e..b92163f 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -315,7 +315,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i return failPipelineCommand(stderr, commandState, terminalWriter, err) } profileIDs := effectiveLLMProfileIDs(effective.ResolvedPipeline) - if err := validateExplicitScriptoriumProfiles(context.Background(), effective.Config, profileIDs); err != nil { + if err := validateExplicitPromptKitProfiles(context.Background(), effective.Config, profileIDs); err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, err) } workingDir, err := os.Getwd() @@ -979,7 +979,7 @@ func runConfigValidate(args []string, stdout, stderr io.Writer, opts Options) in fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } - if err := validateExplicitScriptoriumProfiles(context.Background(), effective.Config, effectiveLLMProfileIDs(effective.ResolvedPipeline)); err != nil { + if err := validateExplicitPromptKitProfiles(context.Background(), effective.Config, effectiveLLMProfileIDs(effective.ResolvedPipeline)); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } diff --git a/internal/cli/run_contract_test.go b/internal/cli/run_contract_test.go index 8708eb7..f2cb489 100644 --- a/internal/cli/run_contract_test.go +++ b/internal/cli/run_contract_test.go @@ -241,7 +241,7 @@ func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) { t.Run("one effective profile reaches the factory and modules", func(t *testing.T) { roots := newStateTestRoots(t) profileDir := writeRunContractProfiles(t, "override-profile") - prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir)) + prependRunContractConfig(t, roots, fmt.Sprintf("promptkit:\n profile_dir: %q\n", profileDir)) harness := newStateTestHarness() var factoryProfiles []string opts := harness.options() @@ -273,7 +273,7 @@ func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) { t.Run("validator profile remains distinct", func(t *testing.T) { roots := newStateTestRoots(t) profileDir := writeRunContractProfiles(t, "override-profile", "validator-profile") - prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir)) + prependRunContractConfig(t, roots, fmt.Sprintf("promptkit:\n profile_dir: %q\n", profileDir)) harness := newStateTestHarness() var validatorProfiles []string opts := harness.options() @@ -299,7 +299,7 @@ func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) { t.Run("unknown profile is rejected without factory access", func(t *testing.T) { roots := newStateTestRoots(t) profileDir := writeRunContractProfiles(t, "override-profile") - prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir)) + prependRunContractConfig(t, roots, fmt.Sprintf("promptkit:\n profile_dir: %q\n", profileDir)) factoryCalls := 0 opts := newStateTestHarness().options() opts.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) { diff --git a/internal/cli/state_hardening_test.go b/internal/cli/state_hardening_test.go index f44a5a0..bf9e301 100644 --- a/internal/cli/state_hardening_test.go +++ b/internal/cli/state_hardening_test.go @@ -635,7 +635,7 @@ func newStateTestRoots(t *testing.T) stateTestRoots { t.Fatal(err) } roots.config = filepath.Join(base, "config.yml") - config := fmt.Sprintf("version: 3\noutput:\n directory: %q\ncache:\n chunk_plans:\n directory: %q\n mode: auto\n checkpoints:\n enabled: true\n directory: %q\ndebug:\n directory: %q\npipelines:\n sample:\n input: test/input\n chunk: test/chunk\n artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n output: test/output\n", roots.output, roots.plans, roots.checkpoints, roots.debug) + config := fmt.Sprintf("version: 4\noutput:\n directory: %q\ncache:\n chunk_plans:\n directory: %q\n mode: auto\n checkpoints:\n enabled: true\n directory: %q\ndebug:\n directory: %q\npipelines:\n sample:\n input: test/input\n chunk: test/chunk\n artifacts:\n items:\n extract: test/extract\n merge: test/merge\n normalize: test/normalize\n output: test/output\n", roots.output, roots.plans, roots.checkpoints, roots.debug) if err := os.WriteFile(roots.config, []byte(config), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/core/config/config.go b/internal/core/config/config.go index 1cdc616..9361bf4 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -4,10 +4,10 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) -const SupportedFileConfigVersion = 3 +const SupportedFileConfigVersion = 4 type Config struct { - Scriptorium ScriptoriumConfig `json:"scriptorium,omitempty"` + PromptKit PromptKitConfig `json:"promptkit,omitempty"` Pipelines map[string]pipeline.PipelineProfile `json:"pipelines"` Concurrency ConcurrencyConfig `json:"concurrency"` Output OutputConfig `json:"output"` @@ -15,7 +15,7 @@ type Config struct { Debug DebugConfig `json:"debug"` } -type ScriptoriumConfig struct { +type PromptKitConfig struct { ProfileDir string `json:"profile_dir,omitempty"` ProfileFile string `json:"profile_file,omitempty"` } diff --git a/internal/core/config/effective_config_contract_test.go b/internal/core/config/effective_config_contract_test.go index 48af195..1a1602d 100644 --- a/internal/core/config/effective_config_contract_test.go +++ b/internal/core/config/effective_config_contract_test.go @@ -84,6 +84,29 @@ func TestEffectiveConfigMaterializesDefaultBindingsThroughCatalog(t *testing.T) } } +func TestEffectiveConfigPreservesPromptKitProfileSource(t *testing.T) { + tests := []struct { + name string + profileSource PromptKitConfig + }{ + {name: "profile directory", profileSource: PromptKitConfig{ProfileDir: "./profiles"}}, + {name: "profile file", profileSource: PromptKitConfig{ProfileFile: "./profiles.yml"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := configForEffectiveTests(t, effectiveProfile()) + cfg.PromptKit = tt.profileSource + effective, err := cfg.Resolve(ResolveInput{PipelineID: "main", Catalog: effectiveCatalog(t)}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if effective.Config.PromptKit != cfg.PromptKit { + t.Fatalf("effective PromptKit config = %#v, want %#v", effective.Config.PromptKit, cfg.PromptKit) + } + }) + } +} + func TestEffectiveConfigResolutionFailuresRetainContext(t *testing.T) { tests := []struct { name string diff --git a/internal/core/config/env_contract_test.go b/internal/core/config/env_contract_test.go index 6d55ff1..666673e 100644 --- a/internal/core/config/env_contract_test.go +++ b/internal/core/config/env_contract_test.go @@ -10,7 +10,7 @@ import ( ) func TestPrecedenceFileValuesOverrideBuiltInDefaults(t *testing.T) { - cfg := applyFileConfig(t, `version: 3 + cfg := applyFileConfig(t, `version: 4 concurrency: total_llm: 4 stage_workers: @@ -35,7 +35,7 @@ debug: } func TestPrecedenceOperationalEnvironmentOverridesFileValues(t *testing.T) { - cfg := applyFileConfig(t, `version: 3 + cfg := applyFileConfig(t, `version: 4 concurrency: total_llm: 2 stage_workers: @@ -81,21 +81,21 @@ func TestPrecedenceExtractWorkersFollowEffectiveConcurrencyUnlessExplicit(t *tes }{ { name: "default follows environment total", - file: "version: 3\n", + file: "version: 4\n", env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "5"}, wantTotal: 5, wantWorker: 5, }, { name: "file worker is retained", - file: "version: 3\nconcurrency:\n total_llm: 3\n stage_workers:\n extract: 2\n", + file: "version: 4\nconcurrency:\n total_llm: 3\n stage_workers:\n extract: 2\n", env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6"}, wantTotal: 6, wantWorker: 2, }, { name: "environment worker is retained", - file: "version: 3\nconcurrency:\n total_llm: 2\n", + file: "version: 4\nconcurrency:\n total_llm: 2\n", env: map[string]string{ "NOTARIUS_TOTAL_LLM_CONCURRENCY": "6", "NOTARIUS_STAGE_WORKERS_EXTRACT": "4", @@ -118,7 +118,7 @@ func TestPrecedenceExtractWorkersFollowEffectiveConcurrencyUnlessExplicit(t *tes } func TestPrecedenceEmptyFileCacheDirectoriesDeferPerUserResolution(t *testing.T) { - cfg := applyFileConfig(t, `version: 3 + cfg := applyFileConfig(t, `version: 4 cache: chunk_plans: directory: "" diff --git a/internal/core/config/file_config.go b/internal/core/config/file_config.go index cebb6ba..7c3d3d9 100644 --- a/internal/core/config/file_config.go +++ b/internal/core/config/file_config.go @@ -14,7 +14,7 @@ import ( type FileConfig struct { Version int `yaml:"version"` - Scriptorium *FileScriptoriumConfig `yaml:"scriptorium,omitempty"` + PromptKit *FilePromptKitConfig `yaml:"promptkit,omitempty"` Pipelines map[string]FilePipelineProfile `yaml:"pipelines,omitempty"` Concurrency *FileConcurrencyConfig `yaml:"concurrency,omitempty"` Output *FileOutputConfig `yaml:"output,omitempty"` @@ -22,7 +22,7 @@ type FileConfig struct { Debug *FileDebugConfig `yaml:"debug,omitempty"` } -type FileScriptoriumConfig struct { +type FilePromptKitConfig struct { ProfileDir *string `yaml:"profile_dir,omitempty"` ProfileFile *string `yaml:"profile_file,omitempty"` } @@ -325,6 +325,9 @@ func ParseFileConfigYAML(data []byte) (FileConfig, error) { if header.Version == 2 { return FileConfig{}, fmt.Errorf("config version 2 is no longer supported; migrate the file using the version 2-to-3 migration in docs/config.md") } + if header.Version == 3 { + return FileConfig{}, fmt.Errorf("config version 3 is no longer supported; change \"version: 3\" to \"version: 4\" and rename \"scriptorium:\" to \"promptkit:\"") + } if header.Version != SupportedFileConfigVersion { return FileConfig{}, fmt.Errorf("unsupported config version %d (supported version is %d)", header.Version, SupportedFileConfigVersion) } @@ -450,20 +453,20 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin } } - if fileCfg.Scriptorium != nil { - if fileCfg.Scriptorium.ProfileDir != nil { - value := strings.TrimSpace(*fileCfg.Scriptorium.ProfileDir) + if fileCfg.PromptKit != nil { + if fileCfg.PromptKit.ProfileDir != nil { + value := strings.TrimSpace(*fileCfg.PromptKit.ProfileDir) if value == "" { - return fmt.Errorf("scriptorium.profile_dir must not be empty when set") + return fmt.Errorf("promptkit.profile_dir must not be empty when set") } - c.Scriptorium.ProfileDir = value + c.PromptKit.ProfileDir = value } - if fileCfg.Scriptorium.ProfileFile != nil { - value := strings.TrimSpace(*fileCfg.Scriptorium.ProfileFile) + if fileCfg.PromptKit.ProfileFile != nil { + value := strings.TrimSpace(*fileCfg.PromptKit.ProfileFile) if value == "" { - return fmt.Errorf("scriptorium.profile_file must not be empty when set") + return fmt.Errorf("promptkit.profile_file must not be empty when set") } - c.Scriptorium.ProfileFile = value + c.PromptKit.ProfileFile = value } } diff --git a/internal/core/config/file_config_contract_test.go b/internal/core/config/file_config_contract_test.go index 5ebc8b4..a8a0bd3 100644 --- a/internal/core/config/file_config_contract_test.go +++ b/internal/core/config/file_config_contract_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "os" "path/filepath" "reflect" @@ -34,8 +35,8 @@ func TestDefaultReturnsDocumentedValuesAndIndependentMaps(t *testing.T) { } } -func TestFileConfigMinimalVersion3AppliesOverDefaults(t *testing.T) { - file := parseFileConfig(t, "version: 3\n") +func TestFileConfigMinimalVersion4AppliesOverDefaults(t *testing.T) { + file := parseFileConfig(t, "version: 4\n") cfg := Default() if err := cfg.ApplyFileConfig(file); err != nil { t.Fatal(err) @@ -48,6 +49,78 @@ func TestFileConfigMinimalVersion3AppliesOverDefaults(t *testing.T) { } } +func TestFilePromptKitProfileSourcesSurviveConfigBoundaries(t *testing.T) { + tests := []struct { + name string + yaml string + want PromptKitConfig + }{ + { + name: "profile directory", + yaml: "version: 4\npromptkit:\n profile_dir: ' ./profiles '\n", + want: PromptKitConfig{ProfileDir: "./profiles"}, + }, + { + name: "profile file", + yaml: "version: 4\npromptkit:\n profile_file: ' ./profiles.yml '\n", + want: PromptKitConfig{ProfileFile: "./profiles.yml"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := applyFileConfig(t, tt.yaml) + if cfg.PromptKit != tt.want { + t.Fatalf("PromptKit config = %#v, want %#v", cfg.PromptKit, tt.want) + } + if got := cloneConfig(cfg).PromptKit; got != tt.want { + t.Fatalf("cloned PromptKit config = %#v, want %#v", got, tt.want) + } + if got := cfg.Redacted().PromptKit; got != tt.want { + t.Fatalf("redacted PromptKit config = %#v, want %#v", got, tt.want) + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if _, ok := payload["promptkit"]; !ok { + t.Fatalf("runtime JSON keys = %v, want promptkit", payload) + } + if _, ok := payload["scriptorium"]; ok { + t.Fatalf("runtime JSON keys = %v, must not contain removed section", payload) + } + }) + } +} + +func TestFilePromptKitExplicitEmptyProfileSourcesAreRejected(t *testing.T) { + for _, field := range []string{"profile_dir", "profile_file"} { + t.Run(field, func(t *testing.T) { + file := parseFileConfig(t, "version: 4\npromptkit:\n "+field+": ''\n") + cfg := Default() + err := cfg.ApplyFileConfig(file) + if err == nil || !strings.Contains(err.Error(), "promptkit."+field+" must not be empty") { + t.Fatalf("ApplyFileConfig() error = %v, want explicit-empty rejection", err) + } + }) + } +} + +func TestFilePromptKitProfileSourcesRemainMutuallyExclusive(t *testing.T) { + cfg := applyFileConfig(t, `version: 4 +promptkit: + profile_dir: ./profiles + profile_file: ./profiles.yml +`) + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "promptkit profile_dir and profile_file are mutually exclusive") { + t.Fatalf("Validate() error = %v, want mutually exclusive profile sources", err) + } +} + func TestFileConfigMissingVersionIsReportedBeforeFieldDecoding(t *testing.T) { _, err := ParseFileConfigYAML([]byte("workspace:\n directory: /tmp/old\n")) if err == nil || !strings.Contains(err.Error(), "config version is required") { @@ -55,6 +128,15 @@ func TestFileConfigMissingVersionIsReportedBeforeFieldDecoding(t *testing.T) { } } +func TestFileConfigVersion3ReportsPromptKitMigration(t *testing.T) { + _, err := ParseFileConfigYAML([]byte("version: 3\nscriptorium:\n profile_dir: ./profiles\n")) + if err == nil || + !strings.Contains(err.Error(), `change "version: 3" to "version: 4"`) || + !strings.Contains(err.Error(), `rename "scriptorium:" to "promptkit:"`) { + t.Fatalf("version 3 error = %v, want actionable version and section migration", err) + } +} + func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) { tests := []struct { name string @@ -63,14 +145,19 @@ func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) { }{ { name: "removed diagnostics", - yaml: "version: 3\ndiagnostics: {}\n", + yaml: "version: 4\ndiagnostics: {}\n", want: "field diagnostics not found", }, { name: "removed llm profiles", - yaml: "version: 3\nllm_profiles: {}\n", + yaml: "version: 4\nllm_profiles: {}\n", want: "field llm_profiles not found", }, + { + name: "removed scriptorium section", + yaml: "version: 4\nscriptorium: {}\n", + want: "field scriptorium not found", + }, { name: "version 2 migration", yaml: "version: 2\nworkspace:\n directory: /tmp/old\n", @@ -78,27 +165,27 @@ func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) { }, { name: "pipeline field", - yaml: "version: 3\npipelines:\n main:\n unknown: true\n", + yaml: "version: 4\npipelines:\n main:\n unknown: true\n", want: "field unknown not found", }, { name: "lane field", - yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells:\n unknown: true\n", + yaml: "version: 4\npipelines:\n main:\n artifacts:\n spells:\n unknown: true\n", want: "field unknown not found", }, { name: "module binding field", - yaml: "version: 3\npipelines:\n main:\n input:\n module: seriatim\n unknown: true\n", + yaml: "version: 4\npipelines:\n main:\n input:\n module: seriatim\n unknown: true\n", want: "field unknown not found in module binding", }, { name: "checkpoint field", - yaml: "version: 3\ncache:\n checkpoints:\n unknown: true\n", + yaml: "version: 4\ncache:\n checkpoints:\n unknown: true\n", want: "field unknown not found", }, { name: "checkpoint enabled type", - yaml: "version: 3\ncache:\n checkpoints:\n enabled: definitely\n", + yaml: "version: 4\ncache:\n checkpoints:\n enabled: definitely\n", want: "cannot unmarshal", }, } @@ -113,7 +200,7 @@ func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) { } func TestFileConfigModuleBindingsPreserveFormsAndValidatorPresence(t *testing.T) { - cfg := applyFileConfig(t, `version: 3 + cfg := applyFileConfig(t, `version: 4 pipelines: main: input: seriatim @@ -160,7 +247,7 @@ pipelines: } func TestFileConfigReferencePrecedenceIsRetained(t *testing.T) { - cfg := applyFileConfig(t, `version: 3 + cfg := applyFileConfig(t, `version: 4 pipelines: main: input: seriatim @@ -224,7 +311,7 @@ pipelines: } func TestFileConfigStageLocalValidatorsPreserveOrderAndFields(t *testing.T) { - cfg := applyFileConfig(t, `version: 3 + cfg := applyFileConfig(t, `version: 4 pipelines: main: input: seriatim @@ -277,8 +364,8 @@ pipelines: } func TestFileConfigStateSectionsApplyIndependently(t *testing.T) { - cfg := applyFileConfig(t, `version: 3 -scriptorium: + cfg := applyFileConfig(t, `version: 4 +promptkit: profile_dir: ./profiles concurrency: total_llm: 7 @@ -294,15 +381,15 @@ cache: debug: directory: ./debug `) - if cfg.Scriptorium.ProfileDir != "./profiles" || cfg.Scriptorium.ProfileFile != "" { - t.Fatalf("scriptorium = %#v", cfg.Scriptorium) + if cfg.PromptKit.ProfileDir != "./profiles" || cfg.PromptKit.ProfileFile != "" { + t.Fatalf("promptkit = %#v", cfg.PromptKit) } if cfg.Concurrency.TotalLLM != 7 || cfg.Concurrency.StageWorkers["extract"] != 7 { t.Fatalf("concurrency = %#v", cfg.Concurrency) } if cfg.Output.Directory != "./output" || cfg.Cache.ChunkPlans.Directory != "plans" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass || !cfg.Cache.Checkpoints.Enabled || cfg.Cache.Checkpoints.Directory != "checkpoints" || cfg.Debug.Directory != "./debug" { - t.Fatalf("state sections = %#v, %#v, %#v, %#v", cfg.Output, cfg.Cache, cfg.Debug, cfg.Scriptorium) + t.Fatalf("state sections = %#v, %#v, %#v, %#v", cfg.Output, cfg.Cache, cfg.Debug, cfg.PromptKit) } if cfg.Output.Directory == cfg.Cache.ChunkPlans.Directory || cfg.Cache.ChunkPlans.Directory == cfg.Cache.Checkpoints.Directory || cfg.Cache.Checkpoints.Directory == cfg.Debug.Directory { t.Fatal("state roots were coupled") @@ -310,11 +397,11 @@ debug: } func TestFileConfigCheckpointEnabledCanBeExplicitlyDisabled(t *testing.T) { - cfg := applyFileConfig(t, "version: 3\ncache:\n checkpoints:\n enabled: true\n") + cfg := applyFileConfig(t, "version: 4\ncache:\n checkpoints:\n enabled: true\n") if !cfg.Cache.Checkpoints.Enabled || !cloneConfig(cfg).Cache.Checkpoints.Enabled { t.Fatalf("enabled checkpoint config was not retained: %#v", cfg.Cache.Checkpoints) } - file := parseFileConfig(t, "version: 3\ncache:\n checkpoints:\n enabled: false\n") + file := parseFileConfig(t, "version: 4\ncache:\n checkpoints:\n enabled: false\n") if err := cfg.ApplyFileConfig(file); err != nil { t.Fatal(err) } @@ -331,17 +418,17 @@ func TestFileConfigRejectsTrimmedKeyCollisions(t *testing.T) { }{ { name: "pipeline ids", - yaml: "version: 3\npipelines:\n main: {}\n ' main ': {}\n", + yaml: "version: 4\npipelines:\n main: {}\n ' main ': {}\n", want: "pipeline id \"main\" is duplicated after trimming", }, { name: "lane ids", - yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells: {}\n ' spells ': {}\n", + yaml: "version: 4\npipelines:\n main:\n artifacts:\n spells: {}\n ' spells ': {}\n", want: "artifact lane id \"spells\" is duplicated after trimming", }, { name: "reference slots", - yaml: "version: 3\npipelines:\n main:\n references:\n slot: ./one.txt\n ' slot ': ./two.txt\n", + yaml: "version: 4\npipelines:\n main:\n references:\n slot: ./one.txt\n ' slot ': ./two.txt\n", want: "reference slot \"slot\" is duplicated after trimming", }, } @@ -358,7 +445,7 @@ func TestFileConfigRejectsTrimmedKeyCollisions(t *testing.T) { } func TestFileConfigParsesOrderedStepsAndReferenceSources(t *testing.T) { - file := parseFileConfig(t, `version: 3 + file := parseFileConfig(t, `version: 4 pipelines: session: input: seriatim @@ -397,7 +484,7 @@ func TestFileConfigRejectsAmbiguousReferenceSourceForms(t *testing.T) { "artifact: {step: 1, lane: b}", "1", } { - _, err := ParseFileConfigYAML([]byte("version: 3\npipelines:\n p:\n input: text\n references:\n slot: " + source + "\n")) + _, err := ParseFileConfigYAML([]byte("version: 4\npipelines:\n p:\n input: text\n references:\n slot: " + source + "\n")) if err == nil { t.Fatalf("ParseFileConfigYAML(%q) error = nil", source) } @@ -412,12 +499,12 @@ func TestFileConfigRejectsEmptyAndAmbiguousPipelineShapes(t *testing.T) { }{ { name: "empty steps", - yaml: "version: 3\npipelines:\n p:\n input: text\n steps: []\n", + yaml: "version: 4\npipelines:\n p:\n input: text\n steps: []\n", want: "at least one ordered step", }, { name: "both forms", - yaml: "version: 3\npipelines:\n p:\n input: text\n artifacts: {}\n steps: []\n", + yaml: "version: 4\npipelines:\n p:\n input: text\n artifacts: {}\n steps: []\n", want: "both artifacts and steps", }, } diff --git a/internal/core/config/validation.go b/internal/core/config/validation.go index a54dc73..152b8b1 100644 --- a/internal/core/config/validation.go +++ b/internal/core/config/validation.go @@ -10,7 +10,7 @@ import ( func (c Config) Validate() error { c.Concurrency.recomputeStageWorkerDefaults() - if err := validateScriptorium(c.Scriptorium); err != nil { + if err := validatePromptKit(c.PromptKit); err != nil { return err } if err := validateStateSurfaces(c); err != nil { @@ -49,9 +49,9 @@ func validateStageWorkers(cfg ConcurrencyConfig) error { return nil } -func validateScriptorium(cfg ScriptoriumConfig) error { +func validatePromptKit(cfg PromptKitConfig) error { if strings.TrimSpace(cfg.ProfileDir) != "" && strings.TrimSpace(cfg.ProfileFile) != "" { - return fmt.Errorf("scriptorium profile_dir and profile_file are mutually exclusive") + return fmt.Errorf("promptkit profile_dir and profile_file are mutually exclusive") } return nil } diff --git a/internal/core/config/validation_contract_test.go b/internal/core/config/validation_contract_test.go index a0c16d9..ff49358 100644 --- a/internal/core/config/validation_contract_test.go +++ b/internal/core/config/validation_contract_test.go @@ -87,10 +87,10 @@ func TestValidateConcurrencyRules(t *testing.T) { } } -func TestValidateScriptoriumSourcesAreMutuallyExclusive(t *testing.T) { +func TestValidatePromptKitSourcesAreMutuallyExclusive(t *testing.T) { cfg := Default() - cfg.Scriptorium = ScriptoriumConfig{ProfileDir: "./profiles", ProfileFile: "./profile.yml"} - assertValidationContains(t, cfg, "scriptorium profile_dir and profile_file are mutually exclusive") + cfg.PromptKit = PromptKitConfig{ProfileDir: "./profiles", ProfileFile: "./profile.yml"} + assertValidationContains(t, cfg, "promptkit profile_dir and profile_file are mutually exclusive") } func TestValidateStateSurfaceRules(t *testing.T) { diff --git a/internal/modules/integration/testdata/dnd_npc_grounded_pipeline.yml b/internal/modules/integration/testdata/dnd_npc_grounded_pipeline.yml index 9dfc0f8..d614351 100644 --- a/internal/modules/integration/testdata/dnd_npc_grounded_pipeline.yml +++ b/internal/modules/integration/testdata/dnd_npc_grounded_pipeline.yml @@ -1,4 +1,4 @@ -version: 3 +version: 4 output: directory: ./notarius-output cache: diff --git a/internal/modules/integration/testdata/dnd_npc_interactions_pipeline.yml b/internal/modules/integration/testdata/dnd_npc_interactions_pipeline.yml index c68d358..cba9c02 100644 --- a/internal/modules/integration/testdata/dnd_npc_interactions_pipeline.yml +++ b/internal/modules/integration/testdata/dnd_npc_interactions_pipeline.yml @@ -1,4 +1,4 @@ -version: 3 +version: 4 output: directory: ./notarius-output cache: diff --git a/internal/modules/integration/testdata/dnd_npcs_pipeline.yml b/internal/modules/integration/testdata/dnd_npcs_pipeline.yml index d595a75..f03f9b2 100644 --- a/internal/modules/integration/testdata/dnd_npcs_pipeline.yml +++ b/internal/modules/integration/testdata/dnd_npcs_pipeline.yml @@ -1,4 +1,4 @@ -version: 3 +version: 4 output: directory: ./notarius-output cache: diff --git a/internal/modules/integration/testdata/pipeline.yml b/internal/modules/integration/testdata/pipeline.yml index 85f4a20..58e3cc2 100644 --- a/internal/modules/integration/testdata/pipeline.yml +++ b/internal/modules/integration/testdata/pipeline.yml @@ -1,4 +1,4 @@ -version: 3 +version: 4 output: directory: ./notarius-output cache: