Remove the deprecated narratio resume command

This commit is contained in:
2026-05-23 11:25:08 -05:00
parent 03eac70881
commit 30b905765c
14 changed files with 57 additions and 840 deletions

View File

@@ -2,7 +2,7 @@
Narratio is a stage-driven Go orchestrator for turning D&D session audio into polished transcripts and generated artifacts.
It runs a deterministic workflow across `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `analyze`, and `publish`, with manifest-driven resume and restore support.
It runs a deterministic workflow across `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `analyze`, and `publish`, with manifest-driven continuation and restore support.
```bash
narratio run 2026-04-04

View File

@@ -13,7 +13,6 @@ This runs the canonical full pipeline for session `2026-04-04`.
Top-level commands:
- `run <session_id>`: run full stage order.
- `resume <session_id>`: continue from first non-succeeded stage.
- `run-stage <stage> <session_id>`: run one stage.
- `analyze <session_id>`: force-run analyze.
- `publish <session_id>`: force-run publish.
@@ -77,19 +76,9 @@ Behavior:
- evaluates full stage order;
- skips already-succeeded stages unless `--force` is set;
- continues interrupted or partially completed sessions by running non-succeeded stages;
- writes session and run manifests.
### `resume`
```bash
narratio resume <session_id> [--force] [--artifacts <name[,name...]>] [...common config flags]
```
Behavior:
- when not forced, starts at first non-succeeded stage in manifest order;
- with `--force`, reevaluates the selected stage list as runnable.
### `run-stage`
```bash
@@ -253,7 +242,7 @@ Behavior:
## `--artifacts` Selection Rules
- accepted on `run`, `resume`, `run-stage`, `analyze`, and `publish`;
- accepted on `run`, `run-stage`, `analyze`, and `publish`;
- names must exist in `pipeline.scriptorium.artifacts`;
- empty entries are invalid;
- repeated names are deduplicated.

View File

@@ -63,7 +63,7 @@ narratio session init 2026-04-04 --remote --force
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
## Stage Execution and Resume Behavior
## Stage Execution and Continuation Behavior
Canonical stage order:
@@ -80,7 +80,7 @@ Canonical stage order:
Execution rules:
- succeeded stages are skipped unless `--force` is set;
- `resume` starts at first non-succeeded stage;
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
- force rerunning a succeeded upstream stage marks succeeded downstream stages as `stale`.
Single-stage execution:
@@ -91,7 +91,7 @@ narratio run-stage normalize 2026-04-04 --force
## Artifact Selection
`--artifacts` can be used on `run`, `resume`, `run-stage`, `analyze`, and `publish`.
`--artifacts` can be used on `run`, `run-stage`, `analyze`, and `publish`.
Selection behavior:

View File

@@ -6,7 +6,7 @@ Canonical contributor workflow and engineering conventions for implemented Narra
## Repository layout
- `cmd/narratio/`: CLI entrypoint.
- `internal/app/`: command handlers, plan/run/resume orchestration, cleanup gates, secrets loading.
- `internal/app/`: command handlers, run/stage orchestration, cleanup gates, secrets loading.
- `internal/config/`: strict YAML loading, defaults, and validation.
- `internal/stage/`: stage implementations and stage registry/order.
- `internal/adapters/`: external boundary adapters (WhisperX, Seriatim, Audita, Scriptorium, storage, notify).

View File

@@ -1,465 +0,0 @@
# Roadmap: Code Quality and Deduplication Audit
Status: Draft audit report
This is a pre-1.0 implementation audit. It identifies remaining high-confidence opportunities to simplify, centralize, or clarify Narratio before release. It is report-only: no code refactors are included here.
The current repository keeps architecture and development policy under `docs/policy/`, not at `docs/architecture.md` or `docs/development.md`. This audit used `docs/policy/architecture.md`, `docs/policy/development.md`, `docs/policy/documentation.md`, the current user/operator docs, the internal docs, code, examples, and tests.
## 1. Executive Summary
Overall code quality is strong. Narratio now has clear package boundaries in the important places:
- configuration loading is strict and centralized enough for current commands;
- object-store construction is app-owned and secret-aware;
- storage adapters do not infer campaign/session/run semantics;
- S3 key construction and local workspace paths are mostly centralized in `internal/artifacts`;
- artifact source vocabulary has a dedicated `internal/artifactpolicy` package;
- remote current-state loading is centralized in `internal/artifacts`;
- cleanup path safety and temp object downloads have shared helpers;
- publish terminology has replaced the old archive/promote surface in live code.
There is no major architectural risk and no reason to delay 1.0 for a broad rewrite. The remaining opportunities are targeted cleanup items where a future bug fix could otherwise need changes in several files.
Top three refactoring targets before 1.0:
1. Finish consolidating artifact source policy for Scriptorium inputs and previous-session candidates.
2. Centralize session-relative path conversion and atomic file install/copy mechanics.
3. Extract a small read-only inspection/preflight layer used by `session validate` and `session status`.
The codebase is ready for a limited cleanup pass. Avoid speculative abstractions.
## 2. High-Confidence Deduplication Opportunities
### Complete Artifact Source Policy for Scriptorium Inputs
Affected files/packages:
- `internal/artifactpolicy`
- `internal/config/validate.go`
- `internal/stage/analyze.go`
- `internal/artifacts/artifact_resolver.go`
- `internal/previouscache/previouscache.go`
- tests under `internal/config`, `internal/stage`, `internal/artifacts`, and `internal/previouscache`
Duplicated or near-duplicated behavior:
- `artifactpolicy.ClassifySource` knows built-in, configured, and previous-session source families.
- `config.validateScriptoriumInputSource` still implements Scriptorium-input-specific validation, including previous-session source parsing, configured artifact lookups, and static built-in checks.
- `stage.resolveScriptoriumInput` classifies sources, then maps missing configured/previous/built-in sources to stage-specific required/optional behavior.
- `previouscache.artifactRelativePathCandidates` reconstructs previous-session artifact candidates from manifest outputs, published paths, and configured output paths.
- `artifacts.ResolveSessionArtifactWithCatalog` and `ResolvePreviousSessionArtifactWithCatalog` own runtime resolution, but caller-side policy still reaches into source details.
Why it matters:
Artifact source IDs are public configuration. They are used by Scriptorium inputs, previous-session inputs, publish outputs, locks, status, artifacts listing, restore, and validation. `internal/artifactpolicy` solved much of this, but Scriptorium input validation and previous-session candidate derivation still contain source-vocabulary logic outside the policy layer.
Recommended refactor:
Extend `internal/artifactpolicy` with narrow Scriptorium-input helpers, not a generic artifact engine:
- `ValidateScriptoriumInputSource(source, configuredKeys)` returning a classified source plus any referenced configured artifact key.
- a shared helper for "is this source a built-in runtime input source?"
- a small previous-session source descriptor used by config validation, previous-cache planning, and analyze resolution.
Keep missing/required behavior at call sites. For example, `analyze` should still decide whether a missing optional input is skipped or a missing required input fails with prepare guidance.
Suggested tests:
- `internal/artifactpolicy`: valid/invalid Scriptorium input sources, previous-session source format, unknown configured references.
- `internal/config`: Scriptorium input validation still reports field-specific errors.
- `internal/stage`: analyze required/optional source behavior unchanged.
- `internal/previouscache`: previous-session requirements and candidate ordering unchanged.
Risk level: Medium. This touches public config validation, but the existing tests are good and the policy surface can remain small.
### Centralize Session-Relative Path Conversion and Safe Local Install Mechanics
Affected files/packages:
- `internal/app/restore_plan.go`
- `internal/app/restore_execute.go`
- `internal/stage/run_local.go`
- `internal/stage/prepare.go`
- `internal/previouscache/previouscache.go`
- `internal/artifacts/local.go`
- `internal/audio/s3_audio.go`
- `internal/manifest/store.go`
- `internal/pathsafe`
Duplicated or near-duplicated behavior:
- `restore_plan.joinWithinSessionRoot` validates session-relative restore targets.
- `previouscache.relativeToSession`, `deriveManifestRelativePath`, and `manifestSessionRoot` convert local manifest paths back into session-relative paths.
- `stage.runLocalPathForCanonical` validates that canonical outputs stay within a session root and skips `previous/**`.
- `artifacts.resolveInRoot` validates relative paths under an artifact root.
- Atomic write/copy/install flows appear in `artifacts.LocalStore`, `audio.MaterializeS3Audio`, `restore_execute`, `manifest.LocalStore`, and `prepare` helper functions.
Why it matters:
These helpers are individually careful, but they all express the same safety policy: relative paths must not escape a scoped root, writes should use temp files plus rename, and cleanup should not leave partial outputs. This is exactly the kind of code where drift is costly.
Recommended refactor:
Add narrow helpers without moving application semantics:
- in `internal/pathsafe`, add session/root helpers such as `JoinWithinRoot(root, rel)` and `RelativeWithinRoot(root, absoluteOrRelative)`;
- add a tiny file operation helper package, or extend `artifacts.LocalStore` carefully, for atomic install/copy/write with optional checksum;
- keep restore scope classification in `restore_plan.go`, because the include/exclude roots are command-specific.
Suggested tests:
- `internal/pathsafe`: root escape, absolute path rejection, Windows-style separators, empty paths, valid session-relative joins.
- `internal/app`: restore conflict/force behavior unchanged.
- `internal/stage`: run-local materialization paths unchanged.
- `internal/audio`: cache hit/miss behavior unchanged.
- `internal/manifest`: atomic manifest save behavior unchanged.
Risk level: Low to Medium. The mechanics are well-contained, but file writes are sensitive and should be protected by focused tests.
### Extract Shared Read-Only Session Inspection Checks
Affected files/packages:
- `internal/app/operator_findings.go`
- `internal/app/operator_session_validate.go`
- `internal/app/operator_status.go`
- `internal/stage/prepare.go`
- `internal/previouscache/previouscache.go`
- `internal/audio`
- `internal/artifacts/current_state.go`
Duplicated or near-duplicated behavior:
- `session validate` resolves stable input paths and checks files in a way that mirrors prepare.
- `session validate` checks local audio or remote audio by listing S3 audio objects, while prepare has the materialization path and restore has audio cache materialization.
- `session validate` checks previous-session current state, while previous-cache planning performs a deeper check and restore uses `previouscache.BuildPlan`.
- `status` independently loads local manifest state, remote current state, effective locks, and published output state.
Why it matters:
These commands are read-only, so divergence does not corrupt state. But operator trust depends on them matching runtime behavior. If validate says a session is ready while prepare would fail, or status reports a remote condition differently from restore, operators get confusing guidance.
Recommended refactor:
Create a small app-level inspection layer, not a workflow engine:
- one helper for resolved stable-input file checks;
- one helper for audio presence checks that mirrors prepare's selection rules without downloading bodies;
- one helper that can run previous-cache planning in a "metadata only / no body download" mode where possible;
- one helper result type that status and validate can render differently.
Do not move output formatting into this layer. Keep command-specific text in `internal/app`.
Suggested tests:
- `internal/app`: validate/status agree on local vs remote audio state.
- `internal/app`: previous-session missing current pointer/manifest is rendered consistently.
- `internal/previouscache`: optional and required previous inputs keep existing behavior.
- `internal/stage`: prepare behavior remains the authority for actual materialization.
Risk level: Medium. The semantic differences between status, validate, prepare, and restore are intentional in places, so the helper should centralize checks, not command policy.
### Atomic File Operation Helpers Are Repeated
Affected files/packages:
- `internal/artifacts/local.go`
- `internal/audio/s3_audio.go`
- `internal/app/restore_execute.go`
- `internal/manifest/store.go`
- `internal/stage/prepare.go`
Duplicated or near-duplicated behavior:
- create parent directory;
- create sibling temp file;
- copy/write bytes;
- sync/close;
- chmod;
- rename;
- remove temp file on failure;
- sometimes compute SHA-256 while copying.
Why it matters:
The duplication is not large, but these are correctness-sensitive operations. A future hardening change, such as more consistent `fsync`, permissions, or Windows behavior, would need repeated edits.
Recommended refactor:
Add a small dependency-light file helper, for example `internal/fileops`, with:
- `WriteAtomic(path, data, perm, pattern)`;
- `CopyAtomic(src, dst, perm)`;
- `CopyAtomicWithSHA256(src, dst, perm)`;
- `InstallDownloadedTemp(tmp, dst, perm)`.
Keep manifest JSON marshaling, audio cache policy, restore plan policy, and artifact store semantics in their existing packages.
Suggested tests:
- temp file removed on write/copy failure;
- destination parent creation;
- checksum from copy matches final file;
- restore and audio cache tests continue to pass.
Risk level: Low. This is mechanical but should be done in a small commit.
## 3. Medium-Confidence Opportunities
### CLI Parsing Is Mostly Centralized, With Intentional Special Cases
Affected files/packages:
- `internal/app/session_args.go`
- `internal/app/operator_helpers.go`
- `internal/app/run.go`
- `internal/app/resume.go`
- `internal/app/run_stage.go`
- `internal/app/operator_locks.go`
- `internal/app/restore.go`
Current state:
Common config flags and session ID matching are centralized enough for 1.0. `run-stage`, `locks add`, and `locks remove` still do command-specific positional parsing because they have extra positional arguments.
Recommended refactor:
Do not introduce a CLI framework. If another session command is added, consider a small parser helper for "session id plus one additional positional argument" so `locks add/remove` and any future similar commands share the same mismatch behavior.
Suggested tests:
- existing session-oriented CLI tests;
- positional session ID plus `--session-id` mismatch;
- missing extra positional source for locks add/remove.
Risk level: Low.
### Restore Scope Mapping Should Probably Stay Local
Affected files/packages:
- `internal/app/restore_plan.go`
- `docs/internal/command-restore.md`
Current state:
Restore maps remote keys back to local paths and intentionally includes only `manifest.json`, `transcripts/**`, `artifacts/**`, and optionally `audio/**`. It explicitly excludes current pointers, run history, logs, reports, configs, inputs, and current-session `previous/**`.
Why it is not a high-priority refactor:
This policy is restore-specific. Generalizing it too much would obscure the command contract.
Recommended refactor:
Leave it local unless restore gains another caller. If it grows, extract only the classifier into a table-driven helper with tests.
Risk level: Low.
### Command Output Formatting Is Intentionally Text-Local
Affected files/packages:
- `internal/app/operator_findings.go`
- `internal/app/operator_artifact_rendering.go`
- `internal/app/operator_locks.go`
- `internal/app/restore_report.go`
- `internal/app/clean.go`
- `internal/app/plan.go`
Current state:
Each command renders plain text directly. Validation-style findings share a small renderer. Artifact list/status share artifact rendering.
Recommended refactor:
Do not add a generic renderer before 1.0. Add focused helper functions only if a user-facing inconsistency is found.
Risk level: Low.
### Manifest Transition Logic Is Dense but Correctly Centralized
Affected files/packages:
- `internal/app/runner.go`
- `internal/manifest`
Current state:
The runner owns manifest/run-manifest lifecycle, stage transitions, downstream stale marking, and post-publish cleanup gating. The function is long, but it is one explicit orchestration path.
Recommended refactor:
Avoid a broad manifest abstraction. If desired after 1.0, extract tiny helpers for repeated save/error wrapping inside the runner only.
Risk level: Low.
## 4. Boundary and Responsibility Concerns
Healthy boundaries:
- `internal/adapters/storage` exposes only `ObjectStore`; AWS SDK types stay inside the S3 backend.
- App code owns secret loading and object-store initialization.
- `internal/artifacts` owns local path and S3 key helpers.
- `internal/artifactpolicy` owns most public source vocabulary.
- `internal/audio` owns S3 audio cache materialization.
- Stage code remains explicit and adapter-facing.
Boundary concerns:
- Scriptorium input source validation still lives partly in `internal/config` instead of fully using source policy.
- Previous-cache candidate selection mixes manifest interpretation, publish metadata, and configured artifact output paths in `internal/previouscache`; this is defensible but should share source descriptors with artifact policy.
- Read-only operator checks mirror stage behavior in `internal/app`; if they grow, they should move into a small inspection layer rather than further expanding command handlers.
- File operation mechanics are repeated across packages; a narrow helper would clarify that write safety is shared mechanics, not stage policy.
Recommended homes:
- source vocabulary and validation: `internal/artifactpolicy`;
- concrete runtime artifact lookup: `internal/artifacts`;
- remote current-state mechanics: `internal/artifacts`;
- command orchestration and rendering: `internal/app`;
- file write mechanics: a small dependency-light helper such as `internal/fileops`, or carefully scoped methods on `artifacts.LocalStore`;
- stage execution policy: `internal/stage`.
## 5. Path and Remote Key Construction Review
Remote key construction is centralized enough for 1.0:
- session, run, audio, session config, locks, current-state, published output, and run-relative keys are in `internal/artifacts/s3_keys.go`;
- storage adapters normalize object keys but do not infer Narratio semantics;
- publish identity helpers resolve bucket/session/run/current-state identities without moving layout policy into storage.
Local path construction is mostly centralized:
- session roots, run roots, previous cache paths, spool paths, and S3 audio cache paths are in `internal/artifacts/paths.go`;
- cleanup target validation is in `internal/app/cleanup_targets.go`;
- relative publish destinations use `internal/pathsafe`.
Areas worth cleanup:
- session-relative conversion helpers are repeated in restore, previous-cache, run-local stage code, and artifacts local-store helpers;
- `restore_plan.go` has local remote-key normalization, which is acceptable for restore but should not spread;
- tests still build some S3 keys through string concatenation when fixture readability would not suffer from using helpers.
Recommended action:
Add narrow `pathsafe` helpers for "join/rel within root" and use them where they reduce escape-check duplication. Do not move restore's include/exclude scope policy out of restore unless it gains another caller.
## 6. Artifact/Catalog/Source Resolution Review
Current state is good:
- transcript source IDs live in `internal/artifactmodel` and are exposed through `internal/artifacts`;
- configured and previous-session source formats live in `internal/artifactpolicy`;
- publish output destination derivation is centralized in `artifactpolicy.ResolvePublishedDestination`;
- runtime catalog behavior is in `internal/artifacts`;
- previous-session requirements are collected by `artifacts.CollectPreviousArtifactRequirements`.
Remaining gap:
`artifactpolicy` does not yet cover the full Scriptorium-input validation contract. `internal/config/validate.go` still knows too much about source parsing and built-in source support. `stage/analyze.go` still has source-family-specific missing input messages, which is appropriate, but it should be consuming a richer policy classification rather than re-checking source strings.
Recommendation:
Extend the policy layer one step further, but keep runtime existence checks in `internal/artifacts` and command/stage missing behavior at the call sites.
## 7. Config and Command-Loading Review
Config loading is consistent:
- `loadCommandConfig` handles pipeline, campaign, local session discovery, remote session fallback, and session identity checks;
- `loadPipelineCampaignConfig` correctly supports `session init`, which cannot load an existing session;
- `newCommandObjectStore` loads secret files before constructing storage;
- concrete-only session loading is enforced in `internal/config`.
Intentional differences:
- `session init` loads pipeline and campaign only.
- `clean --all` is pipeline-scoped, not session-scoped.
- `status`, `validate`, `artifacts`, and `locks` render command-specific output after shared loading.
- `restore` has richer help/output behavior and therefore does slightly more local parsing setup.
Likely accidental drift remaining:
- small positional parsing patterns for commands with extra arguments are repeated;
- `session validate` and `status` each decide which remote checks are warnings, errors, or status lines;
- `loadHelperContext` is useful but not used by every helper because some commands need partial failure reporting.
Recommendation:
No broad loader rewrite is needed. Future command work should reuse `commonConfigFlags`, `parseSessionAwareFlags`, `loadCommandConfig`, `loadPipelineCampaignConfig`, and `newCommandObjectStore`.
## 8. Refactors to Avoid Before 1.0
Avoid:
- a generic workflow engine or DAG abstraction;
- a generic CLI framework;
- broad manifest query or transition abstractions;
- moving secret loading into storage adapters;
- making storage adapters infer campaign/session/root-prefix semantics;
- centralizing all command text output into a generic renderer;
- merging audio cache materialization with generic restore downloads;
- compatibility aliases for retired archive/promote, legacy campaign, or legacy transcript names;
- moving restore's command-specific scope policy into storage or artifacts.
These would add risk without solving current release problems.
## 9. Recommended Implementation Sequence
1. Add root-scoped path helpers and optional atomic file helpers.
- Scope: `internal/pathsafe` plus a small file helper if chosen.
- Tests: `internal/pathsafe`, `internal/audio`, `internal/app -run Restore`, `internal/stage -run Prepare`.
2. Extend artifact source policy for Scriptorium inputs.
- Scope: `internal/artifactpolicy`, `internal/config/validate.go`, `internal/stage/analyze.go`, `internal/previouscache`.
- Tests: `internal/artifactpolicy`, `internal/config`, `internal/stage -run Analyze`, `internal/previouscache`.
3. Extract read-only session inspection checks.
- Scope: stable inputs, audio presence, previous-session readiness, locks/current-state checks.
- Tests: `internal/app -run 'SessionValidate|Status'`, `internal/previouscache`, `internal/stage -run Prepare`.
4. Trim command parsing edge duplication only if needed.
- Scope: commands with session ID plus one extra positional argument.
- Tests: `internal/app -run 'Session|Locks|RunStage'`.
5. Final sweep.
- Run focused searches for retired terminology and old source names.
- Run the focused package tests listed below and then `go test ./...`.
## 10. Test Strategy
Focused checks for any cleanup work:
- `go test ./internal/artifactpolicy -v`
- `go test ./internal/artifacts -v`
- `go test ./internal/config -v`
- `go test ./internal/stage -run 'Analyze|Prepare|Publish' -v`
- `go test ./internal/app -run 'SessionValidate|Status|Restore|Locks|RunStage' -v`
- `go test ./internal/previouscache -v`
- `go test ./internal/audio -v`
- `go test ./internal/pathsafe -v`
- `go test ./internal/adapters/storage -v`
- `go test ./internal/manifest -v`
Full validation after each implementation prompt:
- `go test ./...`
Useful final searches:
- `rg -n "archive|promote|promoted|promotion" internal docs examples cmd`
- `rg -n "narratio.transcript.merged|narratio.transcript.full|narratio.transcript.trimmed" internal docs examples`
- `rg -n "previous_session_artifact|promote_artifacts|pipeline.archive" internal docs examples`
- `rg -n "session_id is required|unexpected positional|--artifacts" internal/app`
- `rg -n "CreateTemp|Rename|copyFileAtomic|WriteFileAtomic|DownloadObjectToTemp" internal`
## 11. Appendix: Findings Not Worth Acting On
- Restore's include/exclude scope logic should remain restore-local. It is command policy, not general path policy.
- Direct text rendering in command handlers is acceptable. The output is text-only and command-specific by design.
- Stage-local path joins under run-local directories are acceptable when they use established session/run roots.
- `run-stage`, `locks add`, and `locks remove` deserve explicit positional parsing because their syntax is not identical to simple session commands.
- The runner is long, but it is the right place for explicit stage orchestration and manifest transitions.
- `session init` template rendering should remain separate from ordinary concrete session loading.
- S3 audio cache materialization is intentionally special and should not be folded into generic object download logic.

View File

@@ -1,206 +0,0 @@
# Roadmap: Pre-1.0 Code Cleanup
Status: Implemented
This roadmap turns the remaining findings in `docs/roadmap/audit.md` into decision-complete implementation stages. It follows the policy documents under `docs/policy/` and preserves current public CLI, config, storage layout, manifest, and stage behavior unless a stage explicitly says otherwise.
## Goals
- Reduce repeated path, file, source-policy, and read-only inspection logic before 1.0.
- Keep Narratio explicit, stage-driven, and easy to review.
- Keep storage adapters free of campaign/session/run/root-prefix semantics.
- Keep command output text-only and command-specific.
- Keep implementation changes small enough for focused prompts and focused tests.
## Non-Goals
- Do not introduce a generic workflow engine or DAG abstraction.
- Do not introduce a generic CLI framework.
- Do not introduce a broad manifest abstraction.
- Do not move secret loading into storage adapters.
- Do not make storage adapters infer Narratio path or key semantics.
- Do not add compatibility aliases for retired archive/promote, campaign, transcript, or previous-session source names.
- Do not move restore's remote-key include/exclude policy out of restore unless another caller is added.
## Stage 1: Path and File Mechanics (Implemented)
Add shared mechanics for root-scoped path safety and atomic file operations.
Implementation decisions:
- Add root-scoped helpers in `internal/pathsafe`:
- join a slash-style relative path safely under a root;
- derive a safe slash-style relative path from a path under a root;
- reject empty paths, absolute paths, traversal, and paths outside the root.
- Add a small dependency-light `internal/fileops` package for shared file installation mechanics:
- atomic byte write;
- atomic file copy;
- atomic file copy with SHA-256 checksum;
- install an already-downloaded temp file with permissions.
- Prefer `internal/fileops` over extending `artifacts.LocalStore`, because the same mechanics are used by app, stage, audio, artifacts, and manifest code.
- Keep higher-level semantics local:
- restore decides which remote keys are in scope;
- audio decides cache hit/miss behavior;
- manifest decides JSON marshaling and validation;
- stages decide canonical output materialization.
Implementation targets:
- Replace duplicate root-escape checks in restore planning, run-local output handling, previous-cache path conversion, and artifact local path helpers where doing so keeps behavior identical.
- Replace duplicate temp-write/copy/rename mechanics in artifact local store, audio cache materialization, restore execution, and prepare helpers where the call site can keep its current error context.
- Leave manifest save behavior unchanged if sharing it would obscure manifest-specific validation or error text.
Tests:
- `go test ./internal/pathsafe -v`
- `go test ./internal/audio -v`
- `go test ./internal/app -run Restore -v`
- `go test ./internal/stage -run Prepare -v`
- `go test ./...`
Acceptance criteria:
- Root escape, absolute path, empty path, Windows separator, and valid relative path cases are covered by path-safe tests.
- Atomic helper tests prove temp files are cleaned up on failure and checksums match final file contents.
- Restore, prepare, and audio cache behavior remain unchanged.
## Stage 2: Artifact Source Policy (Implemented)
Finish centralizing artifact source vocabulary and validation in `internal/artifactpolicy`.
Implementation decisions:
- Extend `internal/artifactpolicy` with Scriptorium-input policy helpers:
- classify and validate built-in, configured, and previous-session source IDs;
- validate referenced configured artifact keys against the configured artifact set;
- expose a previous-session source descriptor for callers that need the configured artifact key.
- Keep runtime artifact lookup in `internal/artifacts`.
- Keep missing, required, optional, and operator-guidance behavior at call sites:
- config validation still produces field-specific errors;
- analyze still decides whether missing inputs fail or skip;
- previous-cache planning still decides required vs optional behavior.
- Do not make `artifactpolicy` inspect manifests, files, object storage, or runtime catalogs.
Implementation targets:
- Replace source parsing and static built-in checks in config validation with artifactpolicy helpers.
- Update analyze input resolution to consume the shared classification/descriptors while preserving current error messages and required/optional behavior.
- Update previous-cache planning to use shared previous-session source descriptors where source vocabulary is involved.
- Keep publish output destination derivation through `artifactpolicy.ResolvePublishedDestination`.
Tests:
- `go test ./internal/artifactpolicy -v`
- `go test ./internal/config -v`
- `go test ./internal/stage -run Analyze -v`
- `go test ./internal/previouscache -v`
- `go test ./...`
Acceptance criteria:
- Valid Scriptorium input source cases pass through one shared policy path.
- Invalid source format and unknown configured artifact references keep clear config-field errors.
- Analyze behavior for required/optional built-in, configured, and previous-session sources is unchanged.
- Previous-cache candidate ordering and required/optional behavior are unchanged.
## Stage 3: Read-Only Inspection Layer (Implemented)
Extract shared read-only session inspection checks for `session validate` and `session status`.
Implementation decisions:
- Keep the new inspection helpers in `internal/app`; they are command orchestration helpers, not stage or storage adapter behavior.
- Create small result types for checks, but do not create a generic reporting framework.
- Keep command-specific rendering local:
- `session validate` renders findings and fails on `ERROR`;
- `session status` renders state and does not fail for missing local/remote state unless config loading fails.
- Do not download artifact bodies for inspection unless current behavior already does so.
Implementation targets:
- Extract stable input checks from `operator_findings.go` into a reusable inspection helper.
- Extract local and remote audio presence checks that mirror prepare's selection rules without materializing audio.
- Extract previous-session readiness checks using existing current-state and previous-cache planning mechanics where possible.
- Extract effective lock and remote current-state inspection into reusable command helpers.
- Keep artifact catalog rendering separate from these checks.
Tests:
- `go test ./internal/app -run 'SessionValidate|Status' -v`
- `go test ./internal/previouscache -v`
- `go test ./internal/stage -run Prepare -v`
- `go test ./...`
Acceptance criteria:
- `session validate` and `session status` agree on local/remote audio and previous-session readiness facts.
- Missing current run pointer/manifest behavior remains command-appropriate: validation reports an error; status reports unavailable state.
- Existing lock and remote current-state behavior is unchanged.
## Stage 4: Optional CLI Edge Cleanup (Implemented)
Only implement this stage if Stage 1-3 leave meaningful repeated parser code.
Implementation decisions:
- Add at most one small parser helper for commands with `session_id` plus one additional positional argument.
- Use it for `session locks add` and `session locks remove` if it reduces duplication without obscuring syntax.
- Keep command handlers explicit.
- Do not change public syntax, flag names, help text meaning, or error semantics.
Tests:
- `go test ./internal/app -run 'Session|Locks|RunStage' -v`
- `go test ./...`
Acceptance criteria:
- Positional `session_id` and `--session-id` mismatch errors remain unchanged.
- Missing source arguments for lock add/remove remain clear.
- No new command aliases are introduced.
## Stage 5: Final Sweep (Implemented)
Run final validation after the implementation stages.
Required searches:
- `rg -n "archive|promote|promoted|promotion" internal docs examples cmd`
- `rg -n "narratio.transcript.merged|narratio.transcript.full|narratio.transcript.trimmed" internal docs examples`
- `rg -n "previous_session_artifact|promote_artifacts|pipeline.archive" internal docs examples`
- `rg -n "CreateTemp|Rename|copyFileAtomic|WriteFileAtomic|DownloadObjectToTemp" internal`
Expected search results:
- Retired terminology should remain only where intentionally historical or where fixture names make it unrelated to current behavior.
- Old transcript and old config/source names should not appear in runtime code, tests, examples, or current-behavior docs.
- File-operation searches should show centralized helpers plus acceptable direct uses where package-specific behavior remains intentional.
Required tests:
- `go test ./internal/artifactpolicy -v`
- `go test ./internal/artifacts -v`
- `go test ./internal/config -v`
- `go test ./internal/stage -run 'Analyze|Prepare|Publish' -v`
- `go test ./internal/app -run 'SessionValidate|Status|Restore|Locks|RunStage' -v`
- `go test ./internal/previouscache -v`
- `go test ./internal/audio -v`
- `go test ./internal/pathsafe -v`
- `go test ./internal/adapters/storage -v`
- `go test ./internal/manifest -v`
- `go test ./...`
Roadmap cleanup:
- Mark each completed stage as implemented only after its code, tests, and any internal docs are updated.
- Keep this file as planned work until the implementation stages land.
- Do not update canonical user/operator docs unless implementation changes visible behavior, which this roadmap does not intend.
## Assumptions
- Public CLI and config behavior remains unchanged throughout this cleanup.
- `internal/fileops` is the preferred home for shared atomic file mechanics.
- `internal/artifactpolicy` remains the source-policy home.
- `internal/artifacts` remains the path/key/current-state home.
- `internal/app` remains the command-loading, inspection, and rendering home.
- Stage packages keep stage execution policy and adapter interaction.

View File

@@ -120,7 +120,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
}
}
func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
@@ -135,16 +135,16 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
}
var out bytes.Buffer
err := Resume(
err := Run(
context.Background(),
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&out,
)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "has no remaining stages") {
t.Fatalf("output = %q, want no remaining stages", out.String())
if !strings.Contains(out.String(), "executed=0 skipped=9") {
t.Fatalf("output = %q, want all stages skipped", out.String())
}
}

View File

@@ -7,7 +7,7 @@ import (
"strings"
)
var supportedCommands = []string{"run", "run-stage", "resume", "analyze", "publish", "clean", "session"}
var supportedCommands = []string{"run", "run-stage", "analyze", "publish", "clean", "session"}
// Execute dispatches CLI commands and returns a process exit code.
func Execute(args []string, stdout, stderr io.Writer) int {
@@ -24,8 +24,6 @@ func Execute(args []string, stdout, stderr io.Writer) int {
switch cmd {
case "run":
err = Run(ctx, cmdArgs, stdout)
case "resume":
err = Resume(ctx, cmdArgs, stdout)
case "run-stage":
err = RunStage(ctx, cmdArgs, stdout)
case "analyze":

View File

@@ -34,7 +34,6 @@ func TestExecuteValidCommands(t *testing.T) {
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
{name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
}
@@ -66,7 +65,7 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
{name: "run missing session", args: []string{"run"}, want: "run: session_id is required"},
{name: "plan old top-level removed", args: []string{"plan"}, want: `unknown command: "plan"`},
{name: "status old top-level removed", args: []string{"status"}, want: `unknown command: "status"`},
{name: "resume missing session", args: []string{"resume"}, want: "resume: session_id is required"},
{name: "resume removed", args: []string{"resume"}, want: `unknown command: "resume"`},
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected stage name and session_id"},
{name: "run-stage missing session", args: []string{"run-stage", "polish"}, want: "run-stage: expected stage name and session_id"},
{name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},

View File

@@ -1,102 +0,0 @@
package app
import (
"context"
"flag"
"fmt"
"io"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// Resume continues execution from the first non-succeeded stage in the manifest.
func Resume(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("resume", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags commonConfigFlags
var force bool
var selectedArtifacts artifactSelectionFlag
addCommonConfigFlags(fs, &flags)
fs.BoolVar(&force, "force", false, "force stage execution")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
if err := parseSessionAwareFlags("resume", fs, args, &flags.sessionID); err != nil {
return err
}
if flags.sessionID == "" {
return fmt.Errorf("resume: session_id is required")
}
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil {
return fmt.Errorf("resume: %w", err)
}
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("resume: %w", err)
}
normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil {
return fmt.Errorf("resume: invalid --artifacts: %w", err)
}
if err := validateSelectedArtifacts(cfg, normalizedArtifacts); err != nil {
return fmt.Errorf("resume: %w", err)
}
full := BuildFullPlan()
selected := full
if !force {
m, err := loadManifestIfPresent(ctx, cfg)
if err != nil {
return fmt.Errorf("resume: %w", err)
}
if m != nil {
start := firstNonSucceededIndex(full, m)
if start >= len(full) {
_, err := fmt.Fprintf(out, "narratio resume: session %s has no remaining stages\n", cfg.Session.SessionID)
return err
}
selected = full[start:]
}
}
summary, err := executeStagesFn(ctx, cfg, selected, RunOptions{
Force: force,
SelectedArtifacts: normalizedArtifacts,
})
if err != nil {
return fmt.Errorf("resume: %w", err)
}
_, err = fmt.Fprintf(
out,
"narratio resume: session %s; executed=%d skipped=%d; manifest=%s\n",
summary.SessionID,
len(summary.Executed),
len(summary.Skipped),
summary.ManifestPath,
)
return err
}
func loadManifestIfPresent(ctx context.Context, cfg *config.Config) (*manifest.Manifest, error) {
path := artifacts.SessionManifestPathForCampaign(
cfg.Pipeline.Workspace.Root,
cfg.Session.Campaign,
cfg.Session.SessionID,
)
exists, err := fileExists(path)
if err != nil {
return nil, fmt.Errorf("check manifest %q: %w", path, err)
}
if !exists {
return nil, nil
}
store := &manifest.LocalStore{}
m, err := store.Load(ctx, path)
if err != nil {
return nil, fmt.Errorf("load manifest %q: %w", path, err)
}
return m, nil
}

View File

@@ -1,8 +1,12 @@
package app
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
@@ -43,13 +47,25 @@ func stageSucceeded(m *manifest.Manifest, name string) bool {
return sr != nil && sr.Status == manifest.StatusSucceeded
}
func firstNonSucceededIndex(stages []stage.Stage, m *manifest.Manifest) int {
for i, s := range stages {
if !stageSucceeded(m, s.Name()) {
return i
}
func loadManifestIfPresent(ctx context.Context, cfg *config.Config) (*manifest.Manifest, error) {
path := artifacts.SessionManifestPathForCampaign(
cfg.Pipeline.Workspace.Root,
cfg.Session.Campaign,
cfg.Session.SessionID,
)
exists, err := fileExists(path)
if err != nil {
return nil, fmt.Errorf("check manifest %q: %w", path, err)
}
return len(stages)
if !exists {
return nil, nil
}
store := &manifest.LocalStore{}
m, err := store.Load(ctx, path)
if err != nil {
return nil, fmt.Errorf("load manifest %q: %w", path, err)
}
return m, nil
}
func canonicalStageNames() []string {

View File

@@ -8,18 +8,6 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestFirstNonSucceededIndex(t *testing.T) {
stages := BuildFullPlan()
m := manifest.New("2026-05-03", time.Now().UTC())
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
m.MarkStageSucceeded("transcribe", time.Now().UTC(), nil)
got := firstNonSucceededIndex(stages, m)
if got != 2 {
t.Fatalf("firstNonSucceededIndex() = %d, want 2", got)
}
}
func TestDecideStageActions(t *testing.T) {
stages := BuildFullPlan()[:2]
m := manifest.New("2026-05-03", time.Now().UTC())

View File

@@ -13,7 +13,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestResumeStartsAfterCompletedStages(t *testing.T) {
func TestRunContinuesAfterCompletedStages(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
@@ -32,12 +32,12 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "executed=7 skipped=0") {
t.Fatalf("output = %q, want executed=7 skipped=0", out.String())
if !strings.Contains(out.String(), "executed=7 skipped=2") {
t.Fatalf("output = %q, want executed=7 skipped=2", out.String())
}
loaded, err := store.Load(context.Background(), manifestPath)
@@ -49,7 +49,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
}
}
func TestResumeNoRemainingStages(t *testing.T) {
func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
@@ -64,20 +64,20 @@ func TestResumeNoRemainingStages(t *testing.T) {
}
var out bytes.Buffer
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "has no remaining stages") {
t.Fatalf("output = %q, want no remaining stages", out.String())
if !strings.Contains(out.String(), "executed=0 skipped=9") {
t.Fatalf("output = %q, want executed=0 skipped=9", out.String())
}
}
func TestResumeForceRerunsSucceeded(t *testing.T) {
func TestRunForceRerunsSucceeded(t *testing.T) {
workspaceRoot := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"source":"resume-force-test","segments":[{"speaker":"alice"}]}`))
_, _ = w.Write([]byte(`{"source":"run-force-test","segments":[{"speaker":"alice"}]}`))
}))
defer srv.Close()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
@@ -93,9 +93,9 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
}
var out bytes.Buffer
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "executed=9 skipped=0") {
t.Fatalf("output = %q, want forced full rerun", out.String())
@@ -166,7 +166,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
}
}
func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing.T) {
func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
@@ -203,12 +203,12 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
}
out.Reset()
err = Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
err = Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "executed=5 skipped=0") {
t.Fatalf("output = %q, want resume to execute normalize..notify", out.String())
if !strings.Contains(out.String(), "executed=5 skipped=4") {
t.Fatalf("output = %q, want run to execute stale downstream stages", out.String())
}
}

View File

@@ -162,8 +162,8 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
wantForce bool
}{
{
name: "resume",
args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
name: "run",
args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
wantStage: "prepare",
wantForce: false,
},