From 131ffd9887ec2f473297c58dee685f758168b703 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 10 Aug 2026 22:36:21 +0000 Subject: [PATCH] Make analyze input resolution deterministic --- docs/internal/artifacts.md | 4 +- docs/roadmap/implementation.md | 2 + internal/stage/analyze.go | 258 ++++++++++++++------------- internal/stage/analyze_order_test.go | 44 +++++ internal/stage/analyze_test.go | 91 ++++++++-- 5 files changed, 260 insertions(+), 139 deletions(-) create mode 100644 internal/stage/analyze_order_test.go diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index 63c10c2..244ba8d 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -41,7 +41,7 @@ ID. `ArtifactCatalog` tracks: - `planned`: source registered for run context; -- `executable`: selected and enabled for analyze execution; +- `executable`: included in the effective analyze artifact set; - `available`: local file exists and validates; - `provenance`: availability source. @@ -92,7 +92,7 @@ Validation by content type: `CollectPreviousArtifactRequirements`: -- scans enabled configured artifacts only; +- scans the effective configured artifact set; - extracts only canonical previous-session sources; - deduplicates by artifact key; - merges required and optional references (required wins); diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 0d10fb0..0da2df1 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -985,6 +985,8 @@ semantics, deterministic diagnostics, and executable remediation guidance. mixed custom/built-in artifacts, repeated randomized map insertion, exact guidance, context construction, and final prompt inputs. Run deterministic tests repeatedly. +**Status:** Completed. + ## Stage 30 — Remove misleading contracts and align Audita ownership **Read first:** `audit-findings.md` lines 3198–3228 (ARC-001), 3280–3305 diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 1aae38b..22822ff 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "os" "path/filepath" "sort" "strings" @@ -46,6 +45,31 @@ type analyzeArtifactExecutionResult struct { ReusedArtifacts []map[string]any } +type analyzeExecutionContext struct { + Env *Env + Manifest *manifest.Manifest + Paths artifacts.SessionPaths + RunLayout runStageLayout + SessionID string + TranscriptRefs analyzeTranscriptInputs + Catalog *artifacts.ArtifactCatalog +} + +type analyzeInputResolutionState uint8 + +const ( + analyzeInputPresent analyzeInputResolutionState = iota + analyzeInputAbsent + analyzeInputError +) + +type analyzeInputResolution struct { + State analyzeInputResolutionState + Path string + Artifact *artifacts.ResolvedSessionArtifact + Err error +} + func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) { if env == nil || env.Config == nil { return nil, fmt.Errorf("analyze: stage environment config is required") @@ -118,8 +142,15 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S }}, nil } - transcriptRefs := discoverAnalyzeTranscriptRefs(m, paths) - sessionDir := filepath.Dir(strings.TrimSpace(env.Config.SessionPath)) + execution := analyzeExecutionContext{ + Env: env, + Manifest: m, + Paths: paths, + RunLayout: runLayout, + SessionID: sessionID, + TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths), + Catalog: runtimeCatalog, + } outputs := make([]artifacts.Ref, 0, len(plans)) logs := []string{} @@ -129,18 +160,7 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S reusedSeen := map[string]struct{}{} for _, plan := range plans { - artifactResult, err := executeAnalyzeArtifact( - ctx, - env, - m, - paths, - runLayout, - sessionID, - sessionDir, - plan, - transcriptRefs, - runtimeCatalog, - ) + artifactResult, err := executeAnalyzeArtifact(ctx, execution, plan) if err != nil { return nil, err } @@ -229,14 +249,17 @@ func orderSelectedScriptoriumArtifacts( catalog *artifacts.ArtifactCatalog, ) ([]string, error) { selectedSet := map[string]struct{}{} - for _, key := range effective.Keys() { + selected := effective.Keys() + for _, key := range selected { selectedSet[key] = struct{}{} } - for selectedKey := range selectedSet { + dependencyErrors := []string{} + for _, selectedKey := range selected { cfg, ok := artifactsCfg[selectedKey] if !ok { - return nil, fmt.Errorf("selected artifact %q is not configured", selectedKey) + dependencyErrors = append(dependencyErrors, fmt.Sprintf("selected artifact %q is not configured", selectedKey)) + continue } for _, dep := range cfg.DependsOn { trimmedDep := strings.TrimSpace(dep) @@ -248,21 +271,25 @@ func orderSelectedScriptoriumArtifacts( } sourceID, ok := catalog.SourceIDForConfiguredKey(trimmedDep) if !ok { - return nil, fmt.Errorf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep) + dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep)) + continue } entry, ok := catalog.Lookup(sourceID) if !ok || !entry.Available { - return nil, fmt.Errorf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID) + dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID)) } } } + if len(dependencyErrors) > 0 { + return nil, errors.New(strings.Join(dependencyErrors, "; ")) + } indegree := map[string]int{} edges := map[string][]string{} - for key := range selectedSet { + for _, key := range selected { indegree[key] = 0 } - for key := range selectedSet { + for _, key := range selected { cfg := artifactsCfg[key] for _, dep := range cfg.DependsOn { trimmedDep := strings.TrimSpace(dep) @@ -308,16 +335,14 @@ func orderSelectedScriptoriumArtifacts( func executeAnalyzeArtifact( ctx context.Context, - env *Env, - m *manifest.Manifest, - paths artifacts.SessionPaths, - runLayout runStageLayout, - sessionID string, - sessionDir string, + execution analyzeExecutionContext, plan analyzeArtifactExecutionPlan, - transcriptRefs analyzeTranscriptInputs, - runtimeCatalog *artifacts.ArtifactCatalog, ) (*analyzeArtifactExecutionResult, error) { + env := execution.Env + paths := execution.Paths + runLayout := execution.RunLayout + sessionID := execution.SessionID + transcriptRefs := execution.TranscriptRefs artifactName := plan.Name artifactCfg := plan.Cfg @@ -328,24 +353,27 @@ func executeAnalyzeArtifact( inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs) for _, inputName := range inputNames { inputCfg := artifactCfg.Inputs[inputName] - resolvedPath, resolved, resolvedArtifact, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog) - if resolveErr != nil { - return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolveErr) - } - if !resolved { + resolution := resolveScriptoriumInput(inputCfg, execution) + switch resolution.State { + case analyzeInputError: + return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolution.Err) + case analyzeInputAbsent: if inputCfg.Required { return nil, fmt.Errorf("analyze: required input %q for artifact %q could not be resolved", inputName, artifactName) } omittedOptionalInputs = append(omittedOptionalInputs, inputName) continue + case analyzeInputPresent: + inputPaths[inputName] = resolution.Path + default: + return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: invalid resolution state", inputName, artifactName) } - inputPaths[inputName] = resolvedPath - if resolvedArtifact != nil && resolvedArtifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk { + if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk { reusedArtifacts = append(reusedArtifacts, map[string]any{ - "name": configuredArtifactNameFromSourceID(resolvedArtifact.ID), - "source_id": resolvedArtifact.ID, - "path": resolvedArtifact.Path, - "provenance": resolvedArtifact.Provenance, + "name": configuredArtifactNameFromSourceID(resolution.Artifact.ID), + "source_id": resolution.Artifact.ID, + "path": resolution.Artifact.Path, + "provenance": resolution.Artifact.Provenance, }) } } @@ -603,91 +631,96 @@ func discoverAnalyzeArtifactRef(m *manifest.Manifest, paths artifacts.SessionPat return resolved.Path, resolved.Provenance } -func resolveScriptoriumInput( - inputName string, - inputCfg config.ScriptoriumInputConfig, - m *manifest.Manifest, - paths artifacts.SessionPaths, - sessionDir string, - runtimeCatalog *artifacts.ArtifactCatalog, -) (string, bool, *artifacts.ResolvedSessionArtifact, error) { +func resolveScriptoriumInput(inputCfg config.ScriptoriumInputConfig, execution analyzeExecutionContext) analyzeInputResolution { source := strings.TrimSpace(inputCfg.Source) descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source) if describeErr != nil { - return "", false, nil, describeErr + return analyzeInputFailure(describeErr) } if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput { - resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, paths) + resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths) if err != nil { if inputCfg.Required { - return "", false, nil, err + return analyzeInputFailure(err) } - return "", false, nil, nil + return analyzeInputMissing() } - return resolvedPath, ok, nil, nil + if !ok { + return analyzeInputMissing() + } + return analyzeInputFound(resolvedPath, nil) } if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact { - resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) + resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog) if err == nil { copy := resolved - return resolved.Path, true, ©, nil + return analyzeInputFound(resolved.Path, ©) } if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { if inputCfg.Required { - return "", false, nil, fmt.Errorf( - "required previous-session input source %q is unavailable; run narratio run-stage --force prepare", + return analyzeInputFailure(fmt.Errorf( + "required previous-session input source %q is unavailable; run narratio run-stage prepare %s --force", source, - ) + execution.SessionID, + )) } - return "", false, nil, nil + return analyzeInputMissing() } - return "", false, nil, err + return analyzeInputFailure(err) } - switch source { + + resolved, err := artifacts.ResolveSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog) + if err == nil { + copy := resolved + return analyzeInputFound(resolved.Path, ©) + } + if !errors.Is(err, artifacts.ErrSessionArtifactNotFound) { + return analyzeInputFailure(err) + } + if !inputCfg.Required { + return analyzeInputMissing() + } + + switch descriptor.Source.Kind { + case artifactpolicy.SourceKindExtraction: + return analyzeInputFailure(fmt.Errorf( + "required extraction source %q is unavailable; enable and configure pipeline.notarius output %q, then run narratio run-stage extract %s --force", + source, + descriptor.Source.ConfiguredKey, + execution.SessionID, + )) + case artifactpolicy.SourceKindConfiguredArtifact: + return analyzeInputFailure(fmt.Errorf("configured artifact source %q is unavailable", source)) default: - resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) - if err == nil { - copy := resolved - return resolved.Path, true, ©, nil - } - if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { - if descriptor.Source.Kind == artifactpolicy.SourceKindExtraction { - if inputCfg.Required { - return "", false, nil, fmt.Errorf( - "required extraction source %q is unavailable; enable and configure pipeline.notarius output %q, then rerun extract with --force", - source, - descriptor.Source.ConfiguredKey, - ) - } - return "", false, nil, nil - } - if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact { - if inputCfg.Required { - return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source) - } - return "", false, nil, nil - } - switch descriptor.Source.ID { - case artifacts.ArtifactTranscriptPolished: - return "", false, nil, nil - case artifacts.ArtifactTranscriptFinal: - return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first") - case artifacts.ArtifactTranscriptFinalTrimmed: - return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first") - case artifacts.ArtifactTranscriptFinalMarkdown, artifacts.ArtifactTranscriptFinalTrimmedMarkdown: - return "", false, nil, fmt.Errorf( - "rendered markdown transcript input is unavailable for source %q; run narratio run-stage render %s --force", - descriptor.Source.ID, - paths.SessionID, - ) - default: - return "", false, nil, nil - } - } - return "", false, nil, err + return analyzeInputFailure(requiredBuiltInInputError(descriptor.Source.ID, execution)) } } +func analyzeInputFound(path string, artifact *artifacts.ResolvedSessionArtifact) analyzeInputResolution { + return analyzeInputResolution{State: analyzeInputPresent, Path: path, Artifact: artifact} +} + +func analyzeInputMissing() analyzeInputResolution { + return analyzeInputResolution{State: analyzeInputAbsent} +} + +func analyzeInputFailure(err error) analyzeInputResolution { + return analyzeInputResolution{State: analyzeInputError, Err: err} +} + +func requiredBuiltInInputError(source string, execution analyzeExecutionContext) error { + entry, ok := execution.Catalog.Lookup(source) + if !ok || strings.TrimSpace(entry.ProducerStage) == "" { + return fmt.Errorf("required built-in source %q is unavailable", source) + } + return fmt.Errorf( + "required built-in source %q is unavailable; run narratio run-stage %s %s --force", + source, + entry.ProducerStage, + execution.SessionID, + ) +} + func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) { filename, ok := preparedStableInputFilename(sourceID) if !ok { @@ -760,27 +793,6 @@ func buildAnalyzeRuntimeArtifactCatalog( return catalog, nil } -func resolveInputPathForRead(paths artifacts.SessionPaths, sessionDir, pathValue string) string { - trimmed := strings.TrimSpace(pathValue) - if trimmed == "" { - return "" - } - if filepath.IsAbs(trimmed) { - return filepath.Clean(trimmed) - } - candidates := []string{} - if strings.TrimSpace(sessionDir) != "" { - candidates = append(candidates, filepath.Clean(filepath.Join(sessionDir, trimmed))) - } - candidates = append(candidates, filepath.Clean(artifacts.ResolveSessionLocalPathForRead(paths, trimmed))) - for _, c := range candidates { - if info, err := os.Stat(c); err == nil && !info.IsDir() { - return c - } - } - return candidates[0] -} - func resolveScriptoriumOutputPath(paths artifacts.SessionPaths, configured string) (string, error) { outputPath := strings.TrimSpace(configured) if outputPath == "" { diff --git a/internal/stage/analyze_order_test.go b/internal/stage/analyze_order_test.go new file mode 100644 index 0000000..fd2755f --- /dev/null +++ b/internal/stage/analyze_order_test.go @@ -0,0 +1,44 @@ +package stage + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +func TestOrderSelectedScriptoriumArtifactsReportsUnavailableDependenciesDeterministically(t *testing.T) { + configured := map[string]config.ScriptoriumArtifactConfig{ + "alpha": {Enabled: true, DependsOn: []string{"alpha_dep"}}, + "alpha_dep": {Enabled: false, OutputPath: "artifacts/alpha_dep.md"}, + "zeta": {Enabled: true, DependsOn: []string{"zeta_dep"}}, + "zeta_dep": {Enabled: false, OutputPath: "artifacts/zeta_dep.md"}, + } + effective, err := artifacts.ResolveEffectiveArtifactSet( + artifacts.ConfiguredArtifactDefinitions(configured), + []string{"alpha", "zeta"}, + ) + if err != nil { + t.Fatalf("ResolveEffectiveArtifactSet() error = %v", err) + } + catalog, err := artifacts.BootstrapRuntimeCatalog( + artifacts.ConfiguredArtifactDefinitions(configured), + effective, + nil, + ) + if err != nil { + t.Fatalf("BootstrapRuntimeCatalog() error = %v", err) + } + + for i := 0; i < 100; i++ { + _, err := orderSelectedScriptoriumArtifacts(configured, effective, catalog) + if err == nil { + t.Fatal("orderSelectedScriptoriumArtifacts() error = nil, want unavailable dependency") + } + want := `artifact "alpha" depends on "alpha_dep", but "narratio.artifact.alpha_dep" is unavailable; artifact "zeta" depends on "zeta_dep", but "narratio.artifact.zeta_dep" is unavailable` + if !strings.Contains(err.Error(), want) { + t.Fatalf("attempt %d error = %q, want %q", i, err, want) + } + } +} diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index 74e0d10..c37a36a 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -445,8 +445,8 @@ func TestAnalyzeFailsWhenRequiredCanonicalPreviousRecapMissing(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "run narratio run-stage --force prepare") { - t.Fatalf("error = %q, want guidance to run force prepare", err.Error()) + if !strings.Contains(err.Error(), "narratio run-stage prepare 2026-05-03 --force") { + t.Fatalf("error = %q, want executable prepare guidance", err.Error()) } } @@ -767,8 +767,8 @@ func TestAnalyzeRequiredPreviousSessionArtifactInputGuidesPrepareForce(t *testin if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "run narratio run-stage --force prepare") { - t.Fatalf("error = %q, want guidance to run force prepare", err.Error()) + if !strings.Contains(err.Error(), "narratio run-stage prepare 2026-05-03 --force") { + t.Fatalf("error = %q, want executable prepare guidance", err.Error()) } } @@ -920,11 +920,77 @@ func TestAnalyzeFailsWhenTrimmedTranscriptMissing(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "trimmed transcript input is unavailable") { - t.Fatalf("error = %q, want missing trimmed transcript context", err.Error()) + if !strings.Contains(err.Error(), "narratio.transcript.final_trimmed") || !strings.Contains(err.Error(), "narratio run-stage trim 2026-05-03 --force") { + t.Fatalf("error = %q, want executable trimmed transcript guidance", err.Error()) } - if !strings.Contains(err.Error(), "run trim stage first") { - t.Fatalf("error = %q, want guidance to run trim stage first", err.Error()) +} + +func TestAnalyzeMissingOptionalBuiltInInputsAreOmitted(t *testing.T) { + for _, source := range []string{ + artifacts.ArtifactTranscriptBase, + artifacts.ArtifactTranscriptPolished, + artifacts.ArtifactTranscriptFinal, + artifacts.ArtifactTranscriptFinalTrimmed, + artifacts.ArtifactTranscriptFinalMarkdown, + artifacts.ArtifactTranscriptFinalTrimmedMarkdown, + artifacts.ArtifactBoundsSession, + } { + t.Run(source, func(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + artifact.Inputs = map[string]config.ScriptoriumInputConfig{ + "optional": {Source: source, Required: false}, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + + if _, err := (analyzeStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.RunRequests) != 1 { + t.Fatalf("run requests = %d, want 1", len(fake.RunRequests)) + } + if _, ok := fake.RunRequests[0].InputPaths["optional"]; ok { + t.Fatalf("optional input = %q, want omission", fake.RunRequests[0].InputPaths["optional"]) + } + }) + } +} + +func TestAnalyzeMissingRequiredBuiltInInputsProvideExecutableGuidance(t *testing.T) { + tests := []struct { + source string + stage string + }{ + {source: artifacts.ArtifactTranscriptBase, stage: "merge"}, + {source: artifacts.ArtifactTranscriptPolished, stage: "polish"}, + {source: artifacts.ArtifactTranscriptFinal, stage: "normalize"}, + {source: artifacts.ArtifactTranscriptFinalTrimmed, stage: "trim"}, + {source: artifacts.ArtifactTranscriptFinalMarkdown, stage: "render"}, + {source: artifacts.ArtifactTranscriptFinalTrimmedMarkdown, stage: "render"}, + {source: artifacts.ArtifactBoundsSession, stage: "trim"}, + } + + for _, tt := range tests { + t.Run(tt.source, func(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + artifact.Inputs = map[string]config.ScriptoriumInputConfig{ + "required": {Source: tt.source, Required: true}, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + + _, err := (analyzeStage{}).Run(context.Background(), env, m) + if err == nil { + t.Fatal("Run() error = nil, want required input error") + } + command := "narratio run-stage " + tt.stage + " 2026-05-03 --force" + if !strings.Contains(err.Error(), tt.source) || !strings.Contains(err.Error(), command) { + t.Fatalf("Run() error = %q, want source %q and command %q", err, tt.source, command) + } + if len(fake.RunRequests) != 0 { + t.Fatalf("run requests = %d, want 0", len(fake.RunRequests)) + } + }) } } @@ -1189,11 +1255,8 @@ func TestAnalyzeFailsWhenNormalizedTranscriptMissing(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "normalized transcript input is unavailable") { - t.Fatalf("error = %q, want missing normalized transcript context", err.Error()) - } - if !strings.Contains(err.Error(), "run normalize stage first") { - t.Fatalf("error = %q, want guidance to run normalize stage first", err.Error()) + if !strings.Contains(err.Error(), "narratio.transcript.final") || !strings.Contains(err.Error(), "narratio run-stage normalize 2026-05-03 --force") { + t.Fatalf("error = %q, want executable normalized transcript guidance", err.Error()) } if len(fake.RunRequests) != 0 { t.Fatalf("run requests = %d, want 0 on missing normalized transcript", len(fake.RunRequests)) @@ -1409,7 +1472,7 @@ func TestAnalyzeRequiredUnavailableExtractionFailsWithGuidance(t *testing.T) { env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact _, err := (analyzeStage{}).Run(context.Background(), env, m) - if err == nil || !strings.Contains(err.Error(), "pipeline.notarius output \"encounters\"") || !strings.Contains(err.Error(), "rerun extract with --force") { + if err == nil || !strings.Contains(err.Error(), "pipeline.notarius output \"encounters\"") || !strings.Contains(err.Error(), "narratio run-stage extract 2026-05-03 --force") { t.Fatalf("Run() error = %v, want actionable extraction guidance", err) } if len(fake.RunRequests) != 0 {