diff --git a/docs/config.md b/docs/config.md index 4725781..5de4611 100644 --- a/docs/config.md +++ b/docs/config.md @@ -249,6 +249,7 @@ Scriptorium artifact-key and dependency rules: Allowed `pipeline.scriptorium.artifacts..inputs..source` values: - `previous_session_artifact` +- `narratio.previous_session.artifact.` - `narratio.transcript.merged` - `narratio.transcript.polished` - `narratio.transcript.full` diff --git a/internal/artifacts/artifact_resolver.go b/internal/artifacts/artifact_resolver.go index eaed279..25ef12e 100644 --- a/internal/artifacts/artifact_resolver.go +++ b/internal/artifacts/artifact_resolver.go @@ -23,6 +23,7 @@ const ( // ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID. var ErrSessionArtifactNotFound = errors.New("session artifact not found") var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.[a-z][a-z0-9_]*$`) +var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) type artifactContentKind string @@ -118,6 +119,21 @@ func IsConfiguredArtifactSource(source string) bool { return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source)) } +// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.. +func IsPreviousSessionArtifactSource(source string) bool { + _, ok := PreviousSessionArtifactName(source) + return ok +} + +// PreviousSessionArtifactName extracts from narratio.previous_session.artifact.. +func PreviousSessionArtifactName(source string) (string, bool) { + matches := previousSessionArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source)) + if len(matches) != 2 { + return "", false + } + return matches[1], true +} + // ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path. // Resolution order is manifest producer outputs first, then canonical session path fallback. func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) { diff --git a/internal/artifacts/artifact_resolver_test.go b/internal/artifacts/artifact_resolver_test.go index 7cbb2f8..1c46c41 100644 --- a/internal/artifacts/artifact_resolver_test.go +++ b/internal/artifacts/artifact_resolver_test.go @@ -45,6 +45,58 @@ func TestNormalizeSessionArtifactSource(t *testing.T) { } } +func TestPreviousSessionArtifactSourceHelpers(t *testing.T) { + tests := []struct { + name string + source string + wantName string + wantMatch bool + }{ + { + name: "valid", + source: "narratio.previous_session.artifact.session_recap", + wantName: "session_recap", + wantMatch: true, + }, + { + name: "valid with surrounding whitespace", + source: " narratio.previous_session.artifact.quest_log ", + wantName: "quest_log", + wantMatch: true, + }, + { + name: "missing name", + source: "narratio.previous_session.artifact.", + wantMatch: false, + }, + { + name: "invalid key characters", + source: "narratio.previous_session.artifact.session-recap", + wantMatch: false, + }, + { + name: "wrong prefix", + source: "narratio.previous.artifact.session_recap", + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, ok := PreviousSessionArtifactName(tt.source) + if ok != tt.wantMatch { + t.Fatalf("PreviousSessionArtifactName(%q) ok = %t, want %t", tt.source, ok, tt.wantMatch) + } + if name != tt.wantName { + t.Fatalf("PreviousSessionArtifactName(%q) name = %q, want %q", tt.source, name, tt.wantName) + } + if got := IsPreviousSessionArtifactSource(tt.source); got != tt.wantMatch { + t.Fatalf("IsPreviousSessionArtifactSource(%q) = %t, want %t", tt.source, got, tt.wantMatch) + } + }) + } +} + func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") diff --git a/internal/config/scriptorium_test.go b/internal/config/scriptorium_test.go index 229f15c..c3c063f 100644 --- a/internal/config/scriptorium_test.go +++ b/internal/config/scriptorium_test.go @@ -116,6 +116,75 @@ func TestScriptoriumLoadAndValidate(t *testing.T) { output_kind: session_recap `, }, + { + name: "canonical previous-session source is accepted", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + session_recap: + enabled: true + prompt_id: dnd.session_recap + output_path: artifacts/session_recap.md + inputs: + transcript: + source: narratio.transcript.polished + required: true + previous_recap: + source: narratio.previous_session.artifact.session_recap + required: false + vars: + session_id: true + output_kind: session_recap +`, + }, + { + name: "canonical previous-session source missing artifact key fails validation", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + session_recap: + enabled: true + prompt_id: dnd.session_recap + output_path: artifacts/session_recap.md + inputs: + previous_recap: + source: narratio.previous_session.artifact. + required: false +`, + wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact." must reference configured artifact key matching ^[a-z][a-z0-9_]*$`, + }, + { + name: "canonical previous-session source invalid artifact key fails validation", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + session_recap: + enabled: true + prompt_id: dnd.session_recap + output_path: artifacts/session_recap.md + inputs: + previous_recap: + source: narratio.previous_session.artifact.session-recap + required: false +`, + wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact.session-recap" must reference configured artifact key matching ^[a-z][a-z0-9_]*$`, + }, + { + name: "canonical previous-session source unknown artifact fails validation", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + session_recap: + enabled: true + prompt_id: dnd.session_recap + output_path: artifacts/session_recap.md + inputs: + previous_recap: + source: narratio.previous_session.artifact.quest_log + required: false +`, + wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact.quest_log" references unknown artifact "quest_log"`, + }, { name: "canonical artifact source is accepted", scriptoriumYAML: `scriptorium: diff --git a/internal/config/validate.go b/internal/config/validate.go index b2fe2de..4bb5812 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -579,12 +579,38 @@ var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`) var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) var narratioArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`) +var narratioPreviousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) { - if isStaticSupportedScriptoriumInputSource(source) { + trimmedSource := strings.TrimSpace(source) + if isStaticSupportedScriptoriumInputSource(trimmedSource) { return "", nil } - matches := narratioArtifactSourceRE.FindStringSubmatch(source) + + if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") { + matches := narratioPreviousSessionArtifactSourceRE.FindStringSubmatch(trimmedSource) + if len(matches) != 2 { + return "", fmt.Errorf( + "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$", + artifactName, + inputName, + source, + ) + } + referenced := matches[1] + if _, ok := configuredArtifacts[referenced]; !ok { + return "", fmt.Errorf( + "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q", + artifactName, + inputName, + source, + referenced, + ) + } + return "", nil + } + + matches := narratioArtifactSourceRE.FindStringSubmatch(trimmedSource) if len(matches) != 2 { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported", @@ -607,7 +633,7 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf } func isStaticSupportedScriptoriumInputSource(source string) bool { - switch strings.TrimSpace(source) { + switch source { case "previous_session_artifact": return true case "narratio.transcript.merged":