diff --git a/docs/cli.md b/docs/cli.md index e6b4378..3b1bcbc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,6 +13,7 @@ This runs the canonical full pipeline for session `2026-04-04`. Top-level commands: - `run `: run all or one contiguous range of the canonical stage order. +- `regenerate-artifacts `: force-run extraction through analysis. - `run-stage `: run one stage. - `analyze `: force-run analyze. - `publish `: force-run publish. @@ -95,6 +96,29 @@ Behavior: When `--artifacts` is present, the selected range must contain `analyze` or `publish`. Either consumer is sufficient, including a one-stage range. +### `regenerate-artifacts` + +```bash +narratio regenerate-artifacts [--artifacts ] [...common config flags] +``` + +Exactly equivalent to: + +```bash +narratio run --force --from extract --through analyze [caller options] +``` + +The command always reruns extraction. Analysis rebuilds the selected configured +artifacts and any prerequisites required by those targets; without +`--artifacts`, it uses the normal default analysis selection. Publish and notify +never run. Common session/configuration options and repeatable artifact values +pass through unchanged. + +Because the expansion owns `--force`, `--from`, and `--through`, callers cannot +supply those options. The shared `run` parser reports them as duplicate +singleton flags. The alias has no private execution options or behavior, and +runtime diagnostics may identify the operation as `run`. + ### `run-stage` ```bash diff --git a/docs/operations.md b/docs/operations.md index aa36c32..8f51bc4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -142,6 +142,20 @@ shared session lifecycle. In particular, render does not require Notarius or Scriptorium, extract does not require Scriptorium, and analyze does not require the transcription, Seriatim, Audita, or Notarius adapters. +For the common post-transcript development loop, use: + +```bash +narratio regenerate-artifacts 2026-04-04 +narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout +``` + +This command is a transparent expansion to a forced bounded `run` from +`extract` through `analyze`. Extraction always rebuilds its complete configured +bundle. Analysis rebuilds the selected targets and their required analysis +prerequisites, or uses the normal default selection when no artifact names are +given. The command does not run publish or notify; delivery remains a separate +operator action. + ## Artifact Selection `--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cd2b9b8..4a54b1d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -264,6 +264,8 @@ when excluded upstream work cannot support the selected stages. ## Stage 5 — Exact `regenerate-artifacts` Alias +**Status: Completed** + ### Goal Add the transparent convenience command without creating another orchestration diff --git a/internal/app/commands.go b/internal/app/commands.go index 62fd0b6..466a9b3 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -7,7 +7,9 @@ import ( "strings" ) -var supportedCommands = []string{"run", "run-stage", "analyze", "publish", "clean", "session"} +var supportedCommands = []string{"run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session"} + +var runCommandFn = Run // Execute dispatches CLI commands and returns a process exit code. func Execute(args []string, stdout, stderr io.Writer) int { @@ -23,7 +25,9 @@ func Execute(args []string, stdout, stderr io.Writer) int { var err error switch cmd { case "run": - err = Run(ctx, cmdArgs, stdout) + err = runCommandFn(ctx, cmdArgs, stdout) + case "regenerate-artifacts": + err = RegenerateArtifacts(ctx, cmdArgs, stdout) case "run-stage": err = RunStage(ctx, cmdArgs, stdout) case "analyze": diff --git a/internal/app/regenerate_artifacts.go b/internal/app/regenerate_artifacts.go new file mode 100644 index 0000000..bd8c2ec --- /dev/null +++ b/internal/app/regenerate_artifacts.go @@ -0,0 +1,43 @@ +package app + +import ( + "context" + "fmt" + "io" +) + +// RegenerateArtifacts expands the convenience command into its canonical run +// invocation. The run command remains the sole owner of parsing and execution. +func RegenerateArtifacts(ctx context.Context, args []string, out io.Writer) error { + if containsHelpOption(args) { + printRegenerateArtifactsHelp(out) + return nil + } + + expanded := make([]string, 0, len(args)+5) + if len(args) > 0 { + expanded = append(expanded, args[0]) + args = args[1:] + } + expanded = append(expanded, "--force", "--from", "extract", "--through", "analyze") + expanded = append(expanded, args...) + return runCommandFn(ctx, expanded, out) +} + +func containsHelpOption(args []string) bool { + for _, arg := range args { + if arg == "-h" || arg == "--help" { + return true + } + } + return false +} + +func printRegenerateArtifactsHelp(out io.Writer) { + _, _ = fmt.Fprintln(out, "Usage: narratio regenerate-artifacts [--artifacts ] [common config flags]") + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, "Exactly equivalent to:") + _, _ = fmt.Fprintln(out, " narratio run --force --from extract --through analyze [caller options]") + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, "Extraction always runs; selected analysis artifacts and their required prerequisites are rebuilt. Publish and notify never run.") +} diff --git a/internal/app/regenerate_artifacts_test.go b/internal/app/regenerate_artifacts_test.go new file mode 100644 index 0000000..013ba5a --- /dev/null +++ b/internal/app/regenerate_artifacts_test.go @@ -0,0 +1,106 @@ +package app + +import ( + "bytes" + "context" + "io" + "reflect" + "strings" + "testing" +) + +func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) { + original := runCommandFn + t.Cleanup(func() { runCommandFn = original }) + var captured []string + runCommandFn = func(_ context.Context, args []string, _ io.Writer) error { + captured = append([]string(nil), args...) + return nil + } + + code := Execute([]string{ + "regenerate-artifacts", "2026-05-03", + "--artifacts", "session_recap,player_handout", + "--artifacts=player_handout", + "--config", "pipeline.yml", + "--campaign", "sample-campaign", + }, io.Discard, io.Discard) + if code != 0 { + t.Fatalf("Execute() code = %d, want 0", code) + } + want := []string{ + "2026-05-03", "--force", "--from", "extract", "--through", "analyze", + "--artifacts", "session_recap,player_handout", + "--artifacts=player_handout", + "--config", "pipeline.yml", + "--campaign", "sample-campaign", + } + if !reflect.DeepEqual(captured, want) { + t.Fatalf("forwarded args = %#v, want %#v", captured, want) + } +} + +func TestRegenerateArtifactsHelpDoesNotInvokeRun(t *testing.T) { + original := runCommandFn + t.Cleanup(func() { runCommandFn = original }) + called := false + runCommandFn = func(_ context.Context, _ []string, _ io.Writer) error { + called = true + return nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := Execute([]string{"regenerate-artifacts", "--help"}, &stdout, &stderr); code != 0 { + t.Fatalf("Execute() code = %d, stderr = %q", code, stderr.String()) + } + if called { + t.Fatal("help invoked canonical run handler") + } + for _, detail := range []string{ + "narratio run --force --from extract --through analyze", + "Extraction always runs", + "Publish and notify never run", + } { + if !strings.Contains(stdout.String(), detail) { + t.Fatalf("help = %q, want %q", stdout.String(), detail) + } + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRegenerateArtifactsOwnedOptionsFailThroughRunParser(t *testing.T) { + for _, test := range []struct { + name string + args []string + owned string + }{ + {name: "force", args: []string{"--force"}, owned: "force"}, + {name: "from", args: []string{"--from=render"}, owned: "from"}, + {name: "through", args: []string{"--through", "publish"}, owned: "through"}, + } { + t.Run(test.name, func(t *testing.T) { + args := []string{"regenerate-artifacts", "2026-05-03"} + args = append(args, test.args...) + var stderr bytes.Buffer + if code := Execute(args, io.Discard, &stderr); code == 0 { + t.Fatalf("Execute(%#v) code = 0", args) + } + if !strings.Contains(stderr.String(), "--"+test.owned+" may be specified only once") { + t.Fatalf("stderr = %q, want shared duplicate %s error", stderr.String(), test.owned) + } + }) + } +} + +func TestRegenerateArtifactsRejectsUnknownOptionsThroughRunParser(t *testing.T) { + var stderr bytes.Buffer + if code := Execute([]string{"regenerate-artifacts", "2026-05-03", "--regenerate-only"}, io.Discard, &stderr); code == 0 { + t.Fatal("Execute() code = 0") + } + if !strings.Contains(stderr.String(), "flag provided but not defined") || !strings.Contains(stderr.String(), "regenerate-only") { + t.Fatalf("stderr = %q, want canonical parser unknown-option error", stderr.String()) + } +}