[object Object]

← back to Cli Printing Press

feat(pipeline): move mutable runs into scoped runstate (#30)

4120dfcb972513055223b58ffcdd115988b29b8e · 2026-03-28 14:19:51 -0700 · Trevin Chow

* feat(pipeline): move mutable runs into scoped runstate

Separate active run state from published CLI outputs and archived
manuscripts, update generated CLI naming to use -pp-cli, and teach
pipeline helpers and skills to resolve the new layout without breaking
legacy state discovery.

* fix(repo): narrow binary ignore rule

Ignore only the repo-root printing-press build artifact so nested
paths like skills/printing-press no longer trigger confusing add
warnings.

* fix(generator): rename output dir to match derived CLI name

APIs with periods in their name (cal.com, dub.co) normalize differently
than callers expect — cleanSpecName("Cal.com") derives "cal-com" but
the --output path was set manually as "calcom". This caused the output
directory name to diverge from the Go module/binary name inside it.

After Generate() completes, compare the directory basename against
naming.CLI(spec.Name) and rename if they differ.

* docs(agents): document ~/.printing-press/ layout and naming conventions

Explains library/, manuscripts/, and .runstate/ directory purposes,
why API slug vs CLI name differ, the -pp- namespace infix, and the
period-normalization gotcha.

* refactor(paths): move ~/.printing-press to ~/printing-press

The home directory contains both hidden state (.runstate/) and
user-facing deliverables (library/, manuscripts/) that you navigate to
directly. The dotfile prefix is already applied at the right level
inside the directory — .runstate is hidden, library and manuscripts
are visible.

Files touched

Diff

commit 4120dfcb972513055223b58ffcdd115988b29b8e
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Mar 28 14:19:51 2026 -0700

    feat(pipeline): move mutable runs into scoped runstate (#30)
    
    * feat(pipeline): move mutable runs into scoped runstate
    
    Separate active run state from published CLI outputs and archived
    manuscripts, update generated CLI naming to use -pp-cli, and teach
    pipeline helpers and skills to resolve the new layout without breaking
    legacy state discovery.
    
    * fix(repo): narrow binary ignore rule
    
    Ignore only the repo-root printing-press build artifact so nested
    paths like skills/printing-press no longer trigger confusing add
    warnings.
    
    * fix(generator): rename output dir to match derived CLI name
    
    APIs with periods in their name (cal.com, dub.co) normalize differently
    than callers expect — cleanSpecName("Cal.com") derives "cal-com" but
    the --output path was set manually as "calcom". This caused the output
    directory name to diverge from the Go module/binary name inside it.
    
    After Generate() completes, compare the directory basename against
    naming.CLI(spec.Name) and rename if they differ.
    
    * docs(agents): document ~/.printing-press/ layout and naming conventions
    
    Explains library/, manuscripts/, and .runstate/ directory purposes,
    why API slug vs CLI name differ, the -pp- namespace infix, and the
    period-normalization gotcha.
    
    * refactor(paths): move ~/.printing-press to ~/printing-press
    
    The home directory contains both hidden state (.runstate/) and
    user-facing deliverables (library/, manuscripts/) that you navigate to
    directly. The dotfile prefix is already applied at the right level
    inside the directory — .runstate is hidden, library and manuscripts
    are visible.
---
 .gitignore                                         |   2 +-
 AGENTS.md                                          |  12 +
 ONBOARDING.md                                      |  20 +-
 README.md                                          |  23 +-
 ...-printing-press-runstate-publish-layout-plan.md | 484 +++++++++++++++++++++
 ...oped-printing-press-output-layout-2026-03-28.md | 140 ++++++
 internal/cli/dogfood.go                            |   4 +-
 internal/cli/emboss.go                             |  86 +++-
 internal/cli/emboss_test.go                        |  73 ++++
 internal/cli/exitcodes_pipeline_test.go            |   8 +-
 internal/cli/root.go                               |  36 +-
 internal/cli/scorecard.go                          |   4 +-
 internal/cli/verify.go                             |  10 +-
 internal/cli/vision.go                             |   8 +-
 internal/cli/vision_test.go                        |  28 ++
 internal/docspec/docspec.go                        |   4 +-
 internal/docspec/docspec_test.go                   |   2 +-
 internal/generator/generator.go                    |   7 +-
 internal/generator/generator_test.go               |  27 +-
 internal/generator/templates/NOTICE.tmpl           |   2 +-
 internal/generator/templates/analytics.go.tmpl     |  12 +-
 internal/generator/templates/auth.go.tmpl          |   2 +-
 internal/generator/templates/auth_simple.go.tmpl   |  10 +-
 .../generator/templates/channel_workflow.go.tmpl   |  16 +-
 internal/generator/templates/client.go.tmpl        |   6 +-
 internal/generator/templates/config.go.tmpl        |   2 +-
 internal/generator/templates/doctor.go.tmpl        |   2 +-
 internal/generator/templates/export.go.tmpl        |   6 +-
 internal/generator/templates/go.mod.tmpl           |   2 +-
 internal/generator/templates/goreleaser.yaml.tmpl  |  16 +-
 internal/generator/templates/helpers.go.tmpl       |   2 +-
 internal/generator/templates/import.go.tmpl        |   6 +-
 .../templates/insights/health_score.go.tmpl        |  12 +-
 .../generator/templates/insights/similar.go.tmpl   |  14 +-
 internal/generator/templates/main.go.tmpl          |   2 +-
 internal/generator/templates/main_mcp.go.tmpl      |   2 +-
 internal/generator/templates/makefile.tmpl         |   4 +-
 internal/generator/templates/mcp_tools.go.tmpl     |  12 +-
 internal/generator/templates/readme.md.tmpl        |  30 +-
 internal/generator/templates/root.go.tmpl          |  10 +-
 internal/generator/templates/search.go.tmpl        |  14 +-
 internal/generator/templates/store.go.tmpl         |   2 +-
 internal/generator/templates/sync.go.tmpl          |  16 +-
 internal/generator/templates/tail.go.tmpl          |   6 +-
 .../generator/templates/workflows/pm_load.go.tmpl  |  12 +-
 .../templates/workflows/pm_orphans.go.tmpl         |  12 +-
 .../generator/templates/workflows/pm_stale.go.tmpl |  16 +-
 internal/generator/validate.go                     |  11 +-
 internal/graphql/parser.go                         |   2 +-
 internal/llmpolish/vision_test.go                  |   2 +-
 internal/naming/naming.go                          |  60 +++
 internal/naming/naming_test.go                     |  28 ++
 internal/openapi/parser.go                         |   2 +-
 internal/openapi/parser_test.go                    |   7 +-
 internal/pipeline/comparative.go                   |  32 +-
 internal/pipeline/comparative_test.go              |  24 +
 internal/pipeline/contracts_test.go                | 169 +++++++
 internal/pipeline/dogfood.go                       |   3 +-
 internal/pipeline/fullrun.go                       | 142 +++++-
 internal/pipeline/paths.go                         | 192 ++++++++
 internal/pipeline/paths_test.go                    |  35 ++
 internal/pipeline/pipeline.go                      |  19 +-
 internal/pipeline/planner.go                       |  44 +-
 internal/pipeline/planner_artifacts_test.go        |  50 +++
 internal/pipeline/publish.go                       | 221 ++++++++++
 internal/pipeline/runtime.go                       |  72 ++-
 internal/pipeline/runtime_test.go                  |  15 +
 internal/pipeline/scorecard.go                     |  55 ++-
 internal/pipeline/scorecard_artifacts_test.go      |  36 ++
 internal/pipeline/spec_summary.go                  | 164 +++++++
 internal/pipeline/state.go                         | 365 ++++++++++++++--
 internal/pipeline/state_test.go                    |  85 +++-
 skills/printing-press-catalog/SKILL.md             |  57 ++-
 skills/printing-press-score/SKILL.md               |  62 ++-
 skills/printing-press/SKILL.md                     | 105 ++++-
 75 files changed, 2930 insertions(+), 355 deletions(-)

diff --git a/.gitignore b/.gitignore
index d34e9ff2..0fd8a354 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
 .DS_Store
 .cache/
-printing-press
+/printing-press
 library/
diff --git a/AGENTS.md b/AGENTS.md
index e0290469..e3756830 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,3 +43,15 @@ Run `go test ./...` before considering your work done.
 ## Quality Gates
 
 Generated CLIs must pass 7 gates: go mod tidy, go vet, go build, binary build, --help, version, doctor.
+
+## `~/printing-press/` Layout
+
+Generated artifacts live under the user's home directory, not in this repo.
+
+- `library/<cli-name>/` — Published CLIs (e.g., `notion-pp-cli`). Directory name matches the derived CLI name from `naming.CLI()`.
+- `manuscripts/<api-slug>/` — Archived research and verification proofs, keyed by API slug (e.g., `notion`), not CLI name. One API can have multiple runs.
+- `.runstate/<scope>/` — Mutable per-workspace state (current run, sync cursors). Scoped by repo basename + hash.
+
+The API slug is derived by the generator from the spec title (`cleanSpecName`), not manually chosen. The CLI name is `<api-slug>-pp-cli`. Never hardcode an API slug when the generator can derive it — names with periods (cal.com, dub.co) normalize differently than you'd guess.
+
+The `-pp-` infix exists to avoid colliding with official CLIs. `notion-pp-cli` can coexist with whatever `notion-cli` Notion ships themselves.
diff --git a/ONBOARDING.md b/ONBOARDING.md
index 9e79f9fe..69036cfb 100644
--- a/ONBOARDING.md
+++ b/ONBOARDING.md
@@ -4,7 +4,7 @@ You give the printing press an API spec. It gives you back a Go CLI, an MCP serv
 
 The key idea: most API CLI generators stop at wrapping endpoints. The printing press goes further -- it profiles each API, detects its domain archetype (communication, project management, payments, etc.), and generates domain-specific "power user" commands like `sync`, `search`, `stale`, `health`, and `similar` on top of the standard CRUD wrappers.
 
-This is built as a Claude Code skill. You run `/printing-press Discord` inside Claude Code, and it now uses a lean brief -> generate -> build -> shipcheck loop for the normal fast path. The older multi-phase on-disk pipeline still exists behind `printing-press print` when you explicitly want resumable phase plans.
+This is built as a Claude Code skill. You run `/printing-press Discord` inside Claude Code, and it now uses a lean brief -> generate -> build -> shipcheck loop for the normal fast path. The older 9-phase pipeline still exists behind `printing-press print` when you explicitly want resumable phase plans.
 
 ---
 
@@ -40,7 +40,7 @@ cli-printing-press/
     printing-press/          # Main generation skill
     printing-press-catalog/  # Catalog browsing skill
   testdata/                  # Test fixtures
-  docs/plans/                # Phase plan templates
+  docs/plans/                # Project planning docs for this repo itself
 ```
 
 | Module | Responsibility |
@@ -58,7 +58,7 @@ cli-printing-press/
 
 Data flows through the system like this: a spec file (OpenAPI, GraphQL SDL, or internal YAML) gets parsed into an `APISpec` struct. The profiler analyzes that struct to detect domain signals and recommend features. The generator takes both the spec and the profile, selects the right templates, and renders a full Go project to disk.
 
-The pipeline module adds a higher-level orchestration layer on top. When you run `printing-press print Discord`, it creates an 8-phase plan directory with seed documents for a resumable run. The normal skill flow does not require all 8 phases; it uses the faster direct loop unless you explicitly ask for resumable phase plans.
+The pipeline module adds a higher-level orchestration layer on top. When you run `printing-press print Discord`, it creates a 9-phase managed run under `~/printing-press/.runstate/<scope>/runs/<run-id>/` with seed documents and `state.json` for resumability. The normal skill flow does not require all 9 phases; it uses the faster direct loop unless you explicitly ask for resumable phase plans.
 
 This project has no external service dependencies. It's a pure Go binary that reads spec files and writes generated code.
 
@@ -76,7 +76,7 @@ This project has no external service dependencies. It's a pure Go binary that re
 | Quality gates | 7 mechanical checks every generated CLI must pass: `go mod tidy`, `go vet`, `go build`, binary build, `--help`, `version`, `doctor`. |
 | Two-tier scoring | Infrastructure scoring (50 pts: output modes, auth, errors, agent-native flags) + Domain correctness scoring (50 pts: path validity, auth protocol, data pipeline, dead code). |
 | Dogfood validator | Catches dead flags, dead functions, invalid API paths, and auth mismatches by cross-referencing generated code against the source spec. |
-| Pipeline phases | Optional 8-phase resumable pipeline: preflight, research, scaffold, enrich, regenerate, review, comparative, ship. |
+| Pipeline phases | Optional 9-phase resumable pipeline: preflight, research, scaffold, enrich, regenerate, review, agent-readiness, comparative, ship. |
 | Catalog entry | A YAML file in `catalog/` that maps an API name to its spec URL, format, category, and tier. Used by `DiscoverSpec()` to auto-resolve API names. |
 | Creativity ladder | Rung 1-2: API wrappers + output formatting (always generated). Rung 3: local persistence. Rung 4: domain analytics. Rung 5: behavioral insights. |
 
@@ -97,7 +97,7 @@ This is the normal user journey:
    - `printing-press scorecard`
 5. If a token is available and the user opted in, the skill runs a small read-only live smoke test
 
-The important part: the default path does not require creating an 8-phase resumable pipeline.
+The important part: the default path does not require creating a 9-phase resumable pipeline.
 
 ### Flow 2: Direct Generation (`printing-press generate`)
 
@@ -124,8 +124,8 @@ internal/generator/generator.go (New + Generate)
   renders 30+ .tmpl files to output dir
   |
   v
-Generated CLI project at ./library/<name>-cli/
-  cmd/<name>-cli/main.go
+Generated CLI project published at ~/printing-press/library/<name>-pp-cli/
+  cmd/<name>-pp-cli/main.go
   cmd/<name>-mcp/main.go
   internal/cli/   (per-resource commands)
   internal/client/ (HTTP client)
@@ -143,9 +143,9 @@ Use this only when you explicitly want on-disk phase seeds and resumable state:
 
 1. `internal/cli/root.go` (`newPrintCmd`) calls `pipeline.Init()` with the API name
 2. `pipeline.Init()` calls `DiscoverSpec()` which looks up the API in `catalog/` entries
-3. Seeds are written for each of the 8 phases into `docs/plans/<api>-pipeline/`
-4. `state.json` is created to track progress across sessions
-5. The user runs phase work from the generated plan files
+3. A managed run is created under `~/printing-press/.runstate/<scope>/runs/<run-id>/`
+4. Seeds are written into `pipeline/`, research artifacts into `research/`, and scorecard/dogfood evidence into `proofs/`
+5. `state.json` tracks progress across sessions, and completed runs archive to `~/printing-press/manuscripts/<api>/<run-id>/`
 
 ### Flow 4: Docs-to-Spec (`--docs`)
 
diff --git a/README.md b/README.md
index 63a2996e..810980c4 100644
--- a/README.md
+++ b/README.md
@@ -222,16 +222,16 @@ Inspired by Peter Steinberger's [gogcli](https://github.com/steipete/gogcli). Tw
 
 ```bash
 # Runtime verification: tests every command against real API or mock server
-printing-press verify --dir ./discord-cli --spec /tmp/discord-spec.json --api-key $TOKEN
+printing-press verify --dir ./discord-pp-cli --spec /tmp/discord-spec.json --api-key $TOKEN
 
 # Emboss audit: baseline snapshot for improvement cycle
-printing-press emboss --dir ./discord-cli --spec /tmp/discord-spec.json --audit-only
+printing-press emboss --dir ./discord-pp-cli --spec /tmp/discord-spec.json --audit-only
 
 # Quality scorecard: two-tier structural scoring
-printing-press scorecard --dir ./discord-cli --spec /tmp/discord-spec.json
+printing-press scorecard --dir ./discord-pp-cli --spec /tmp/discord-spec.json
 
 # Mechanical dogfood: catches dead flags, invalid paths, auth mismatches
-printing-press dogfood --dir ./discord-cli --spec /tmp/discord-spec.json
+printing-press dogfood --dir ./discord-pp-cli --spec /tmp/discord-spec.json
 ```
 
 ## Quick Start
@@ -245,7 +245,7 @@ printing-press dogfood --dir ./discord-cli --spec /tmp/discord-spec.json
 Then build the binary (needed for scorecard, verify, and dogfood commands):
 
 ```bash
-cd ~/cli-printing-press
+cd "$(git rev-parse --show-toplevel)"
 go build -o ./printing-press ./cmd/printing-press
 ```
 
@@ -259,16 +259,25 @@ go build -o ./printing-press ./cmd/printing-press
 
 Each run produces two binaries (`<api>-pp-cli` + `<api>-pp-mcp`), 8 analysis documents, and a Quality Score.
 
+By default, active and published output are separated:
+
+- Active managed runs work in `~/printing-press/.runstate/<scope>/runs/<run-id>/working/<api>-pp-cli`
+- Published CLIs go to `~/printing-press/library/<api>-pp-cli`
+- Archived manuscripts go to `~/printing-press/manuscripts/<api>/<run-id>/`
+- Manuscripts are split into `research/`, `proofs/`, and `pipeline/`
+
+`<scope>` is derived from the current git checkout path, so parallel worktrees do not stomp on each other. If you pass `--output`, that overrides the generated CLI location for that command.
+
 ## Verification Tools
 
 Four layers of mechanical validation - no vibes, no self-assessment.
 
 ```bash
 # Quality Scorecard: two-tier scoring (infrastructure + domain correctness)
-printing-press scorecard --dir ./my-cli --spec ./openapi.json
+printing-press scorecard --dir ./my-pp-cli --spec ./openapi.json
 
 # Dogfood: catches dead flags, dead functions, auth mismatches, invalid paths
-printing-press dogfood --dir ./my-cli --spec ./openapi.json
+printing-press dogfood --dir ./my-pp-cli --spec ./openapi.json
 ```
 
 ### Proof of Behavior (Phase 4.7)
diff --git a/docs/plans/2026-03-28-refactor-printing-press-runstate-publish-layout-plan.md b/docs/plans/2026-03-28-refactor-printing-press-runstate-publish-layout-plan.md
new file mode 100644
index 00000000..01722234
--- /dev/null
+++ b/docs/plans/2026-03-28-refactor-printing-press-runstate-publish-layout-plan.md
@@ -0,0 +1,484 @@
+---
+title: "refactor: Move printing-press mutable state into .runstate and publish outputs separately"
+type: refactor
+status: proposed
+date: 2026-03-28
+origin: direct user request and design decisions from 2026-03-28 architecture discussion
+---
+
+# refactor: Move printing-press mutable state into .runstate and publish outputs separately
+
+## Overview
+
+Rework printing-press storage so active runs no longer depend on globally shared mutable directories. The new model separates:
+
+- **mutable run state** in `~/printing-press/.runstate/`
+- **published CLIs** in `~/printing-press/library/`
+- **archived manuscripts** in `~/printing-press/manuscripts/`
+
+The goal is to keep the repo location irrelevant, preserve worktree isolation, and stop resume/discovery logic from treating published artifacts as live working state.
+
+## Problem Frame
+
+The press currently mixes two concerns:
+
+1. **Live pipeline state** that must be isolated per checkout and per run
+2. **Published output** that should be globally discoverable and deduped
+
+Flattening everything into global `library/` and `manuscripts/` fixes repo pollution but not correctness. Resume, score, proof, and discovery flows can still read or overwrite artifacts created by another worktree for the same API. Checkout-scoped top-level roots avoid that, but they conflate "where an active run writes" with "where the finished artifact lives."
+
+The cleaner architecture is:
+
+```text
+~/printing-press/
+  .runstate/
+    <scope>/
+      current/
+        <api>.json
+      runs/
+        <run-id>/
+          state.json
+          spec.json
+          working/
+            <api>-pp-cli/
+          research/
+          proofs/
+          pipeline/
+          manifest.json
+  library/
+    <api>-pp-cli[-N]/
+  manuscripts/
+    <api>/
+      <run-id>/
+        research/
+        proofs/
+        pipeline/
+        manifest.json
+```
+
+This makes `.runstate` the source of truth for active work. `library/` and `manuscripts/` become publish/archive destinations.
+
+## Requirements Trace
+
+- R1. The press must work from any clone path, subdirectory, or git worktree.
+- R2. Mutable pipeline state must be isolated per checkout scope and per run.
+- R3. Published CLIs must live in a single global `~/printing-press/library/` namespace with deduped directory claims.
+- R4. Research, proofs, and pipeline records for a finished run must be publishable to a global manuscript archive without becoming the live resume source.
+- R5. Resume, score, and skill discovery must resolve the current run from `.runstate`, not by scanning global `library/` or `manuscripts/`.
+- R6. Every run must have durable metadata linking scope, run ID, git root, API name, spec source, working dir, and published outputs.
+- R7. Existing users must retain compatibility with older repo-local and workspace-scoped state layouts during the migration window.
+- R8. Skill docs, README, and onboarding docs must document the new contract clearly.
+- R9. Contract tests must cover the path/naming/discovery rules so future docs or code changes drift less easily.
+
+## Scope Boundaries
+
+- No changes to scorecard scoring dimensions or verify semantics beyond path resolution.
+- No change to the generated CLI naming contract: the canonical product name remains `<api>-pp-cli`.
+- No migration of existing generated CLIs into new library paths; old outputs only need read compatibility.
+- No cloud sync or multi-machine state sharing.
+- No new interactive UX for run selection beyond the current patterns already used by the skills.
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/pipeline/paths.go` currently centralizes press-home, scope, library, and manuscript path derivation.
+- `internal/pipeline/state.go` currently stores pipeline state in `manuscripts/<api>/pipeline/` with legacy fallback to `docs/plans/<api>-pipeline/state.json`.
+- `internal/pipeline/pipeline.go` owns default output selection, output-dir claiming, and pipeline initialization.
+- `internal/pipeline/fullrun.go` and `internal/pipeline/planner.go` orchestrate run lifecycle and seed/planning files.
+- `internal/cli/root.go` exposes the `print` flow and emits JSON path metadata used by skills.
+- `internal/cli/vision.go` and `internal/cli/emboss.go` are the main artifact-producing helpers outside generation.
+- `internal/pipeline/contracts_test.go` already acts as the main cross-layer contract test surface for naming and path rules.
+- `skills/printing-press/SKILL.md`, `skills/printing-press-score/SKILL.md`, and `skills/printing-press-catalog/SKILL.md` compute path roots and are path-contract consumers, not sources of truth.
+
+### Institutional Learnings
+
+- `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md` captures the main failure modes to preserve:
+  - hardcoded repo paths break immediately
+  - global mutable output roots cross-contaminate worktrees
+  - command-directory discovery must not assume the outer claimed dir name is canonical
+- The recent path migration confirmed that **shared mutable state** is the real problem, not just directory-creation races.
+- Contract tests are worthwhile here because the bugs are cross-layer and mostly invisible to ordinary `go test ./...` coverage.
+
+### External Research Decision
+
+Skipped. This is an internal storage-architecture refactor with strong local patterns and no dependency on external APIs or framework-specific behavior.
+
+## Key Technical Decisions
+
+### 1. Separate mutable state from published artifacts
+
+`.runstate` is the only live source of truth for:
+
+- active pipeline state
+- current-run pointers
+- in-progress research/proofs/pipeline artifacts
+- working CLI trees owned by an active press run
+
+`library/` and `manuscripts/` are publish/archive locations. Active resume and discovery logic must not depend on them by default.
+
+### 2. Keep checkout scoping, but only inside `.runstate`
+
+Checkout scope still matters because active runs are tied to a specific git checkout. The scope derivation pattern remains:
+
+- derive `REPO_ROOT` from `git rev-parse --show-toplevel`
+- sanitize the repo basename
+- suffix with a short hash of the full repo root
+
+That scope moves under `.runstate/<scope>/...` instead of being the top-level output layout.
+
+### 3. Give every managed run a stable run ID and one owner
+
+Each managed `print` run gets a generated `run_id`. That `run_id` is used for:
+
+- `.runstate/<scope>/runs/<run-id>/...`
+- `manuscripts/<api>/<run-id>/...`
+- manifests and publish metadata
+
+The implementation should use a human-sortable UTC timestamp plus a short random suffix rather than a bare UUID so manuscript directories remain inspectable.
+
+Ownership is explicit:
+
+- `pipeline.Init(...)` is responsible for allocating `run_id`, creating the runstate directories, writing the current pointer, and recording the working CLI path in state.
+- The skill-driven phase workflow reads and writes through that state for all subsequent phase work.
+- `MakeBestCLI(...)` must adopt the same run model rather than inventing a parallel layout. In autonomous mode it should either call `Init(...)` directly or a shared helper used by `Init(...)`, then write research, generation output, proofs, and publish results into that same run root.
+
+There should be one run model, not one for `print` and another for `MakeBestCLI`.
+
+### 4. Track “current run” per API, not one global current pointer
+
+Use:
+
+```text
+.runstate/<scope>/current/<api>.json
+```
+
+This avoids ambiguity when the same checkout is actively generating multiple APIs. The current pointer should contain at least:
+
+- `api_name`
+- `run_id`
+- `scope`
+- `git_root`
+- `working_dir`
+- `state_path`
+- `updated_at`
+
+### 5. Treat pipeline-generated CLI trees as working directories until publish
+
+The full press pipeline should generate into:
+
+```text
+.runstate/<scope>/runs/<run-id>/working/<api>-pp-cli/
+```
+
+Only the publish step should claim a global `library/<api>-pp-cli[-N]/` directory and copy/promote the finished CLI there.
+
+This keeps iterative emboss/review/fix loops from mutating the globally published artifact in place.
+
+### 6. Archive manuscripts by API and run ID after publish
+
+When a run reaches ship or an explicit publish boundary, copy the durable artifacts needed for inspection into:
+
+```text
+manuscripts/<api>/<run-id>/
+```
+
+At minimum this archive should contain:
+
+- `research/`
+- `proofs/`
+- `pipeline/`
+- `manifest.json`
+
+The archive is intended for inspection, historical comparison, and later export. It is not the primary resume source, and it is not updated continuously during active execution.
+
+### 7. Preserve direct one-shot CLI generation behavior
+
+`printing-press generate` is a direct artifact-producing command, not a multi-phase managed run. Its default output should remain publish-oriented:
+
+- default path: `~/printing-press/library/<api>-pp-cli[-N]/`
+- explicit `--output` still wins
+
+The `.runstate` layout is primarily for managed `print` runs and the supporting skills that need safe mutable state.
+
+### 7.5. Emboss must treat published CLIs as input, not mutable state
+
+`emboss` currently assumes it can write baseline files into the target CLI directory. Under the new contract, published `library/` artifacts should not be the writable working area.
+
+The rule is:
+
+- If `emboss --dir` points at a `.runstate/.../working/...` directory, emboss operates in place on that active run.
+- If `emboss --dir` points at a published `library/...` directory, emboss starts a new managed run in `.runstate/<scope>/runs/<run-id>/`, copies the published CLI into that run's `working/` directory, writes baseline/proof artifacts there, and publishes a new claimed CLI on success.
+- `emboss` must never mutate a previously published library directory in place.
+
+This keeps published outputs immutable while still supporting iterative second-pass improvement workflows.
+
+### 8. Publish a manifest everywhere state crosses boundaries
+
+Both `.runstate/.../manifest.json` and archived `manuscripts/.../manifest.json` should record:
+
+- `api_name`
+- `run_id`
+- `scope`
+- `git_root`
+- `git_commit` if available
+- `spec_path`
+- `spec_url`
+- `working_dir`
+- `published_cli_dir`
+- `archived_manuscript_dir`
+- timestamps
+
+This gives future tooling a stable contract for lookup, auditing, and possible cleanup.
+
+### 9. Compatibility should be read-first, not migration-first
+
+On initial rollout:
+
+- read from new `.runstate` first
+- fall back to workspace-scoped manuscript state if present
+- finally fall back to legacy repo `docs/plans/*-pipeline/state.json`
+
+Do not attempt an eager filesystem migration of existing outputs. Instead, lazily re-save into the new layout when an old run is resumed.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Do we still need checkout scope?** Yes, but only for active mutable state under `.runstate`.
+- **Can global `library/` stay shared?** Yes. Claimed-dir deduping is enough there because published CLIs are immutable outputs.
+- **Should global `manuscripts/` be shared?** Yes, if manuscripts are archived by run ID and active resume never treats them as the current writable state.
+
+### Deferred to Implementation
+
+- Exact manifest schema versioning strategy
+- Whether future CLI commands should expose `--run-id` for manual resume/debug flows
+
+## Implementation Units
+
+- [ ] **Unit 1: Introduce runstate path helpers and publish/archive path helpers**
+
+  **Goal:** Centralize the new storage contract in one place so code and docs have a canonical source of truth.
+
+  **Requirements:** R1, R2, R3, R4, R6
+
+  **Dependencies:** None
+
+  **Files:**
+  - Modify: `internal/pipeline/paths.go`
+  - Modify: `internal/pipeline/paths_test.go`
+  - Modify: `internal/pipeline/contracts_test.go`
+
+  **Approach:**
+  - Keep `PressHome()` and scope derivation, but add helpers for:
+    - `RunstateRoot()`
+    - `ScopedRunstateRoot()`
+    - `CurrentRunDir()`
+    - `CurrentRunPointerPath(apiName string)`
+    - `RunRoot(runID string)`
+    - `WorkingCLIDir(apiName, runID string)`
+    - `RunResearchDir(apiName, runID string)` or `RunResearchDir(runID string)`
+    - `RunProofsDir(...)`
+    - `RunPipelineDir(...)`
+    - `PublishedLibraryRoot()`
+    - `PublishedManuscriptsRoot()`
+    - `ArchivedManuscriptDir(apiName, runID string)`
+  - Keep naming logic out of path helpers; continue to use `internal/naming/` for `<api>-pp-cli`.
+  - Update contract tests to assert the new canonical layout strings in docs and skills.
+
+  **Patterns to follow:**
+  - Existing `PressHome()` and scope sanitization in `internal/pipeline/paths.go`
+  - Existing cross-layer contract approach in `internal/pipeline/contracts_test.go`
+
+  **Test scenarios:**
+  - Press home override via env var still works
+  - Scope sanitization remains stable
+  - New runstate paths are deterministic for a fixed scope/run ID
+  - Docs contract strings point to `.runstate`, `library/`, and `manuscripts/<api>/<run-id>/`
+
+  **Verification:**
+  - `go test ./internal/pipeline -run 'Test.*Path'`
+
+- [ ] **Unit 2: Move pipeline state and current-run discovery into `.runstate`**
+
+  **Goal:** Make `.runstate` the live source of truth for init, resume, and current-run discovery.
+
+  **Requirements:** R2, R5, R6, R7
+
+  **Dependencies:** Unit 1
+
+  **Files:**
+  - Modify: `internal/pipeline/state.go`
+  - Modify: `internal/pipeline/state_test.go`
+  - Modify: `internal/pipeline/pipeline.go`
+  - Modify: `internal/pipeline/planner.go`
+  - Modify: `internal/pipeline/fullrun.go`
+
+  **Approach:**
+  - Extend `PipelineState` with `RunID`, `Scope`, and runstate-specific directories.
+  - Save state in `.runstate/<scope>/runs/<run-id>/state.json`.
+  - Write/update `.runstate/<scope>/current/<api>.json` on init and after phase transitions.
+  - Update `Init`, `LoadState`, and `StateExists` so they resolve through `.runstate` first.
+  - Preserve lazy fallback loading from older workspace-scoped and repo-local state files.
+  - Ensure plan seed files live under the runstate pipeline dir for the active run.
+  - Make the active working CLI path a first-class field in state so phase execution and autonomous full-run code use the same location contract.
+
+  **Patterns to follow:**
+  - Existing lazy migration pattern in `LoadState`
+  - Existing stable phase filename logic in `PlanFilename`
+
+  **Test scenarios:**
+  - New run creates runstate state and current-pointer files
+  - Resume prefers `.runstate` over legacy locations
+  - Legacy state can still be resumed and re-saved into the new layout
+  - Two different scopes can hold current runs for the same API without collision
+
+  **Verification:**
+  - `go test ./internal/pipeline -run 'Test(State|Init|Resume)'`
+
+- [ ] **Unit 3: Publish finished CLIs and manuscripts from runstate**
+
+  **Goal:** Keep active runs isolated while still producing globally discoverable outputs.
+
+  **Requirements:** R3, R4, R6, R7
+
+  **Dependencies:** Unit 2
+
+  **Files:**
+  - Modify: `internal/pipeline/fullrun.go`
+  - Modify: `internal/pipeline/pipeline.go`
+  - Modify: `internal/cli/root.go`
+  - Test: `internal/pipeline/fullrun_test.go`
+  - Test: `internal/cli/claim_integration_test.go`
+
+  **Approach:**
+  - For managed `print` runs, generate into `.runstate/.../working/<api>-pp-cli/`.
+  - Update `MakeBestCLI(...)` to initialize or accept a managed run context and use that run root for research, generation, dogfood, verification, and scorecard artifacts.
+  - Keep `ClaimOutputDir()` for publish-time claiming into global `library/`.
+  - Add a publish step that copies the finished working tree into `library/<api>-pp-cli[-N]/`.
+  - Archive research/proofs/pipeline artifacts into `manuscripts/<api>/<run-id>/` only at publish time, not continuously during every phase.
+  - Write manifests after publish so downstream tooling can correlate working, published, and archived paths.
+  - Keep `generate` command defaulting directly to global `library/` to avoid changing the one-shot CLI workflow.
+
+  **Patterns to follow:**
+  - Existing deduping behavior in `ClaimOutputDir`
+  - Existing JSON output from `print` in `internal/cli/root.go`
+
+  **Test scenarios:**
+  - Managed pipeline run writes working files to `.runstate`
+  - Publish copies the CLI to deduped global library dir
+  - Re-running the same API publishes to `...-2` without mutating the first published CLI
+  - Manuscript archive includes the expected subdirectories and manifest
+
+  **Verification:**
+  - `go test ./internal/pipeline ./internal/cli -run 'Test.*(Publish|Claim|FullRun)'`
+
+- [ ] **Unit 4: Update helper commands and skills to resolve active runs from `.runstate`**
+
+  **Goal:** Ensure research, scoring, embossing, and skill-driven flows stop inferring “current” from published outputs.
+
+  **Requirements:** R1, R4, R5, R8
+
+  **Dependencies:** Unit 2
+
+  **Files:**
+  - Modify: `internal/cli/vision.go`
+  - Modify: `internal/cli/emboss.go`
+  - Modify: `skills/printing-press/SKILL.md`
+  - Modify: `skills/printing-press-score/SKILL.md`
+  - Modify: `skills/printing-press-catalog/SKILL.md`
+  - Modify: `README.md`
+  - Modify: `ONBOARDING.md`
+
+  **Approach:**
+  - Update skill setup blocks so they compute `REPO_ROOT`, derive scope, and resolve `.runstate/<scope>` as the active home.
+  - Change score/resume/current-run instructions to inspect `.runstate/<scope>/current/` first.
+  - Keep helper commands path-driven, but define default resolution explicitly:
+    - `vision --api <name>` writes into the current runstate research dir for that API when a current pointer exists; otherwise it should fail fast with a message to run `print` first or pass `--output`.
+    - `emboss --dir <path>` accepts explicit dirs, but a published library dir must be copied into a new runstate working dir before emboss writes any baseline or proof artifacts.
+  - Document the distinction between:
+    - active runstate
+    - published library
+    - archived manuscripts
+  - Keep direct path-based commands working when users pass `--dir` or `--output`.
+
+  **Patterns to follow:**
+  - Existing contract markers in skill setup blocks
+  - Existing doc contract tests in `internal/pipeline/contracts_test.go`
+
+  **Test scenarios:**
+  - Skills mention `.runstate/<scope>` for active work
+  - README explains where active vs published artifacts live
+  - No docs instruct users to resume by scanning global manuscripts as the first step
+
+  **Verification:**
+  - `go test ./internal/pipeline -run 'Test.*Contract'`
+
+- [ ] **Unit 5: Harden compatibility and end-to-end contract coverage**
+
+  **Goal:** Make future regressions harder by testing the storage contract across code, docs, and claimed output dirs.
+
+  **Requirements:** R7, R8, R9
+
+  **Dependencies:** Units 1-4
+
+  **Files:**
+  - Modify: `internal/pipeline/contracts_test.go`
+  - Modify: `internal/pipeline/runtime_test.go`
+  - Modify: `internal/pipeline/state_test.go`
+  - Modify: `internal/cli/emboss_test.go`
+  - Modify: `internal/cli/vision_test.go`
+
+  **Approach:**
+  - Extend contract tests to assert:
+    - current-run lookup is `.runstate`-first
+    - docs describe global `library/` plus archived `manuscripts/`
+    - managed runs generate into runstate working dirs
+  - Keep the existing claimed-dir test for `...-pp-cli-2`
+  - Add migration tests that load legacy workspace-scoped and repo-local state files into the new runstate layout
+
+  **Patterns to follow:**
+  - Existing contract test style: assert a few stable strings and behaviors, not full markdown snapshots
+
+  **Test scenarios:**
+  - Generate same API twice, publish twice, and verify both outputs remain valid
+  - Legacy state resume path still works
+  - Current-run resolution never selects a published CLI from another scope as the active run
+
+  **Verification:**
+  - `go test ./internal/pipeline ./internal/cli`
+  - `go test ./...`
+
+## System-Wide Impact
+
+- **Interaction graph:** `print` becomes explicitly run-oriented. It owns a mutable runstate area and publishes outward on success. `generate` remains a direct artifact generator into the global library.
+- **Error propagation:** Publish/copy failures need explicit handling because working output may exist even if archive or library publish fails. The plan should preserve runstate for recovery rather than deleting it on partial publish failure.
+- **State lifecycle risks:** The biggest risk is confusing active and published paths during migration. That is why `.runstate` must be the only default resume/discovery source.
+- **Operational behavior:** Users gain a clearer mental model:
+  - active work lives in `.runstate`
+  - finished CLIs live in `library`
+  - archived research/proofs live in `manuscripts`
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Helper commands or skills still scan global manuscripts for “current” | Add contract tests around current-run lookup and skill setup blocks |
+| Publish step copies a partially broken working tree into library | Publish only after existing verify/ship gates succeed; keep manifest so failures are inspectable |
+| Legacy state becomes unreadable after the refactor | Keep read fallback to workspace-scoped and repo-local layouts; test migration paths explicitly |
+| Users assume `library/` is the active working tree | Document the difference clearly in README, onboarding, and skills |
+| Multiple current runs for the same API in one scope overwrite pointers | Define current pointers as “latest active run for this API” and keep run history under `runs/` |
+
+## Sources & References
+
+- Existing path contract and failure analysis: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`
+- Prior output-dir planning context: `docs/plans/2026-03-27-020-feat-cli-output-to-library-plan.md`
+- Relevant code:
+  - `internal/pipeline/paths.go`
+  - `internal/pipeline/state.go`
+  - `internal/pipeline/pipeline.go`
+  - `internal/pipeline/fullrun.go`
+  - `internal/cli/root.go`
+  - `internal/cli/vision.go`
+  - `internal/cli/emboss.go`
+  - `internal/pipeline/contracts_test.go`
diff --git a/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md b/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md
new file mode 100644
index 00000000..8759da09
--- /dev/null
+++ b/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md
@@ -0,0 +1,140 @@
+---
+title: "Printing-press runstate and publish/archive layout contract"
+date: 2026-03-28
+category: best-practices
+module: printing-press output layout
+problem_type: best_practice
+component: tooling
+symptoms:
+  - "Skills fail when the repo is cloned outside ~/cli-printing-press or run from a worktree"
+  - "Mutable run state collides across parallel workspaces or repeated runs"
+  - "Runtime tooling breaks when a claimed output directory uses a suffix like -pp-cli-2"
+  - "Research and proof artifacts leak into repo-local docs/plans or other source directories"
+root_cause: config_error
+resolution_type: workflow_improvement
+severity: high
+tags:
+  - printing-press
+  - worktrees
+  - output-paths
+  - pp-cli
+  - manuscripts
+  - runstate
+  - workspace-scope
+---
+
+# Printing-press runstate and publish/archive layout contract
+
+## Problem
+
+The press used to assume one clone path and one shared mutable output namespace. That broke as soon as the repo lived somewhere other than `~/cli-printing-press`, ran inside a git worktree, or generated the same API more than once.
+
+## Symptoms
+
+- Skills looked for repo-relative or home-relative paths that did not exist in worktrees.
+- Active runs shared mutable directories, so parallel worktrees could stomp on each other.
+- Manuscript artifacts mixed source code with user output.
+- Verification code failed to build claimed directories like `stripe-pp-cli-2` because it guessed `cmd/stripe` instead of `cmd/stripe-pp-cli`.
+
+## What Didn't Work
+
+- Hardcoding `~/cli-printing-press` in skills or docs.
+- Treating the generated project directory name as the authoritative command directory name.
+- Using published output directories as the source of truth for active mutable runs.
+- Writing research, audit, dogfood, or emboss artifacts into repo `docs/plans/`.
+
+## Solution
+
+Use a checkout-scoped runstate derived from the current git root for active work, then publish finished output into global archive locations:
+
+```text
+~/printing-press/
+  .runstate/<scope>/
+    current/
+      <api>.json
+    runs/
+      <run-id>/
+        state.json
+        manifest.json
+        working/
+          <api>-pp-cli/
+        research/
+        proofs/
+        pipeline/
+  library/
+    <api>-pp-cli[-N]/
+  manuscripts/<api>/<run-id>/
+    research/
+    proofs/
+    pipeline/
+    manifest.json
+```
+
+`<scope>` must be derived from the resolved git root path, not the current shell directory. In Go, the canonical helpers live in `internal/pipeline/paths.go`. In skills, compute the same scope before reading or writing anything:
+
+```bash
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+PRESS_BASE="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
+[ -n "$PRESS_BASE" ] || PRESS_BASE="workspace"
+PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-8)"
+PRESS_HOME="$HOME/printing-press"
+PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"
+```
+
+Keep the naming contract explicit:
+
+- Generated human CLI directory and command: `<api>-pp-cli`
+- Legacy compatibility: accept `<api>-cli` when discovering older projects
+- Claimed reruns: allow outer directories like `<api>-pp-cli-2`, but still resolve the actual command entrypoint from `cmd/<api>-pp-cli`
+
+The runtime verifier should discover the command directory independently of the outer project folder:
+
+```go
+apiName := naming.TrimCLISuffix(filepath.Base(dir))
+cmdDir := filepath.Join(dir, "cmd", naming.CLI(apiName))
+```
+
+If that direct lookup fails, scan `cmd/` for a directory satisfying `naming.IsCLIDirName(...)` before falling back to generic single-entry behavior.
+
+Artifact placement rules:
+
+- Active managed runs write only inside `.runstate/<scope>/runs/<run-id>/...`
+- Published generated code goes under `library/`
+- Archived research documents go under `manuscripts/<api>/<run-id>/research/`
+- Archived scorecard, dogfood, emboss, and similar evidence go under `manuscripts/<api>/<run-id>/proofs/`
+- Archived phase seeds and pipeline records go under `manuscripts/<api>/<run-id>/pipeline/`
+- Resume and current-run discovery should read `.runstate` first, not global `library/` or `manuscripts/`
+
+## Why This Works
+
+The checkout scope isolates parallel worktrees without forcing users to hand-configure output paths. Separating active `.runstate` from published `library/` and archived `manuscripts/` keeps mutable work, distributable binaries, and historical evidence from contaminating each other. The `-pp-cli` contract removes ambiguity with upstream or official CLIs, while compatibility helpers still let older `-cli` outputs be rediscovered.
+
+Most importantly, the runtime and skill layers now share the same assumptions:
+
+- same scope derivation
+- same CLI suffix
+- same current-run lookup model
+- same publish/archive layout
+- same fallback behavior for older state files
+
+That removes the class of bugs where docs say one thing, skills do another, and Go code expects a third layout.
+
+## Prevention
+
+- Never hardcode `~/cli-printing-press` in skills, docs, or code paths. Always resolve `git rev-parse --show-toplevel` first.
+- When adding a new artifact-producing phase, decide first whether it belongs in runstate `research/`, `proofs/`, or `pipeline/`, and whether it must also be archived at publish time. Do not default to repo `docs/plans/`.
+- If a feature writes a file tied to a generated CLI, test both canonical and claimed output dirs, for example `notion-pp-cli` and `notion-pp-cli-2`.
+- Keep naming logic centralized in `internal/naming/` and path logic centralized in `internal/pipeline/paths.go`.
+- Add tests whenever code infers API names or command directories from filesystem paths.
+- Treat published library CLIs as immutable outputs. Workflows like `emboss` should copy them into a new runstate working dir instead of mutating them in place.
+- Update README and onboarding docs whenever default output locations change. Path migrations that only touch code are incomplete by definition.
+
+Recommended verification for future changes:
+
+- `go test ./internal/pipeline ./internal/cli`
+- `go test ./...`
+- Manual spot-check: generate once into `<api>-pp-cli`, then again into `<api>-pp-cli-2`, and confirm `verify`, `scorecard`, and `emboss` still resolve the project correctly.
+
+## Related Issues
+
+- `docs/solutions/best-practices/steinberger-scorecard-scoring-architecture-2026-03-27.md` is related only in that both docs codify “shared rules that must stay in sync.” It does not cover path layout or output scoping.
diff --git a/internal/cli/dogfood.go b/internal/cli/dogfood.go
index be448e57..a156a29b 100644
--- a/internal/cli/dogfood.go
+++ b/internal/cli/dogfood.go
@@ -21,10 +21,10 @@ func newDogfoodCmd() *cobra.Command {
 		Short: "Validate a generated CLI against its source spec",
 		Long:  "Mechanically verify that a generated CLI's commands hit valid API paths, auth matches the spec protocol, no dead flags/functions exist, and the data pipeline is wired correctly.",
 		Example: `  # Evaluate a generated CLI directory
-  printing-press dogfood --dir ./generated/stripe-cli
+  printing-press dogfood --dir ./generated/stripe-pp-cli
 
   # Output as JSON for programmatic use
-  printing-press dogfood --dir ./generated/stripe-cli --json`,
+  printing-press dogfood --dir ./generated/stripe-pp-cli --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			report, err := pipeline.RunDogfood(dir, specPath)
 			if err != nil {
diff --git a/internal/cli/emboss.go b/internal/cli/emboss.go
index 07e730b9..093b9a16 100644
--- a/internal/cli/emboss.go
+++ b/internal/cli/emboss.go
@@ -8,6 +8,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
 	"github.com/spf13/cobra"
 )
@@ -66,17 +67,22 @@ Step 6: REPORT - Output the delta
 Use --audit-only to just get the baseline without making changes.
 The improvement steps (2-4) are driven by the /printing-press emboss skill.`,
 		Example: `  # Full emboss cycle (audit -> improve -> re-verify)
-  # Run the skill: /printing-press emboss ./discord-cli
+  # Run the skill: /printing-press emboss ./discord-pp-cli
   # Or just get the baseline:
-  printing-press emboss --dir ./discord-cli --spec /tmp/spec.json --audit-only
+  printing-press emboss --dir ./discord-pp-cli --spec /tmp/spec.json --audit-only
 
   # Audit with live API testing
-  printing-press emboss --dir ./discord-cli --spec /tmp/spec.json --api-key $TOKEN --audit-only`,
+  printing-press emboss --dir ./discord-pp-cli --spec /tmp/spec.json --api-key $TOKEN --audit-only`,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			baselinePath := filepath.Join(dir, ".emboss-baseline.json")
-			name := filepath.Base(dir)
+			workingDir, baselinePath, _, err := resolveEmbossWorkspace(dir)
+			if err != nil {
+				return err
+			}
+			if workingDir != dir {
+				fmt.Fprintf(os.Stderr, "Emboss working dir: %s\n", workingDir)
+			}
 			report := &EmbossReport{
-				Dir:       dir,
+				Dir:       workingDir,
 				Spec:      specPath,
 				Timestamp: time.Now().Format(time.RFC3339),
 			}
@@ -94,11 +100,11 @@ The improvement steps (2-4) are driven by the /printing-press emboss skill.`,
 				} else {
 					report.Before = baselineReport.Before
 					fmt.Fprintln(os.Stderr, "Existing baseline found. Running fresh audit for delta...")
-					after := runEmbossAudit(dir, specPath, apiKey, envVar, "after")
+					after := runEmbossAudit(workingDir, specPath, apiKey, envVar, "after")
 					report.After = &after
 					report.Delta = computeDelta(report.Before, after)
 					report.Mode = "delta"
-					reportPath, writeErr := writeEmbossDeltaReport(name, report.Before, after, report.Delta)
+					reportPath, writeErr := writeEmbossDeltaReport(workingDir, report.Before, after, report.Delta)
 					if writeErr != nil {
 						fmt.Fprintf(os.Stderr, "warning: failed to write delta report: %v\n", writeErr)
 					} else {
@@ -114,7 +120,7 @@ The improvement steps (2-4) are driven by the /printing-press emboss skill.`,
 			}
 
 			// Step 1: AUDIT - baseline
-			report.Before = runEmbossAudit(dir, specPath, apiKey, envVar, "baseline")
+			report.Before = runEmbossAudit(workingDir, specPath, apiKey, envVar, "baseline")
 
 			if auditOnly {
 				if saveBaseline {
@@ -136,9 +142,9 @@ The improvement steps (2-4) are driven by the /printing-press emboss skill.`,
 			}
 
 			fmt.Fprintln(os.Stderr, "\nBaseline saved. Now run the skill for improvements:")
-			fmt.Fprintf(os.Stderr, "  /printing-press emboss %s\n\n", dir)
+			fmt.Fprintf(os.Stderr, "  /printing-press emboss %s\n\n", workingDir)
 			fmt.Fprintln(os.Stderr, "When done, re-run this command to compute the delta:")
-			fmt.Fprintf(os.Stderr, "  printing-press emboss --dir %s --spec %s\n", dir, specPath)
+			fmt.Fprintf(os.Stderr, "  printing-press emboss --dir %s --spec %s\n", workingDir, specPath)
 			return nil
 		},
 	}
@@ -155,6 +161,54 @@ The improvement steps (2-4) are driven by the /printing-press emboss skill.`,
 	return cmd
 }
 
+func resolveEmbossWorkspace(dir string) (string, string, *pipeline.PipelineState, error) {
+	absDir, err := filepath.Abs(dir)
+	if err != nil {
+		return "", "", nil, fmt.Errorf("resolving dir: %w", err)
+	}
+
+	if state, err := pipeline.FindStateByWorkingDir(absDir); err == nil {
+		if err := os.MkdirAll(state.ProofsDir(), 0o755); err != nil {
+			return "", "", nil, err
+		}
+		return absDir, filepath.Join(state.ProofsDir(), ".emboss-baseline.json"), state, nil
+	}
+
+	libraryRoot, err := filepath.Abs(pipeline.PublishedLibraryRoot())
+	if err != nil {
+		return "", "", nil, fmt.Errorf("resolving library root: %w", err)
+	}
+
+	if absDir == libraryRoot || strings.HasPrefix(absDir, libraryRoot+string(os.PathSeparator)) {
+		apiName := naming.TrimCLISuffix(filepath.Base(absDir))
+
+		state, err := pipeline.NewManagedState(apiName)
+		if err != nil {
+			return "", "", nil, err
+		}
+		state.PublishedDir = absDir
+		state.ExcludeFromCurrentResolution = true
+		if err := os.MkdirAll(filepath.Dir(state.EffectiveWorkingDir()), 0o755); err != nil {
+			return "", "", nil, err
+		}
+		if err := pipeline.CopyDir(absDir, state.EffectiveWorkingDir()); err != nil {
+			return "", "", nil, fmt.Errorf("copying published CLI into runstate: %w", err)
+		}
+		if err := state.SaveWithoutCurrentPointer(); err != nil {
+			return "", "", nil, err
+		}
+		if err := pipeline.WriteRunManifest(state); err != nil {
+			return "", "", nil, err
+		}
+		if err := os.MkdirAll(state.ProofsDir(), 0o755); err != nil {
+			return "", "", nil, err
+		}
+		return state.EffectiveWorkingDir(), filepath.Join(state.ProofsDir(), ".emboss-baseline.json"), state, nil
+	}
+
+	return absDir, filepath.Join(absDir, ".emboss-baseline.json"), nil, nil
+}
+
 func runEmbossAudit(dir, specPath, apiKey, envVar, label string) EmbossSnapshot {
 	fmt.Fprintf(os.Stderr, "Step 1: AUDIT - Running verify + scorecard for %s...\n", label)
 
@@ -207,9 +261,13 @@ func computeDelta(before, after EmbossSnapshot) *EmbossDelta {
 	}
 }
 
-func writeEmbossDeltaReport(name string, before, after EmbossSnapshot, delta *EmbossDelta) (string, error) {
-	reportDir := filepath.Join("docs", "plans")
-	if err := os.MkdirAll(reportDir, 0755); err != nil {
+func writeEmbossDeltaReport(dir string, before, after EmbossSnapshot, delta *EmbossDelta) (string, error) {
+	name := filepath.Base(dir)
+	reportDir := dir
+	if state, err := pipeline.FindStateByWorkingDir(dir); err == nil {
+		reportDir = state.ProofsDir()
+	}
+	if err := os.MkdirAll(reportDir, 0o755); err != nil {
 		return "", err
 	}
 
diff --git a/internal/cli/emboss_test.go b/internal/cli/emboss_test.go
new file mode 100644
index 00000000..f1f9595d
--- /dev/null
+++ b/internal/cli/emboss_test.go
@@ -0,0 +1,73 @@
+package cli
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWriteEmbossDeltaReportWritesToScopedProofsDir(t *testing.T) {
+	home := t.TempDir()
+	t.Setenv("PRINTING_PRESS_HOME", home)
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", filepath.Join(home, "repo"))
+
+	dir := filepath.Join(t.TempDir(), "sample-pp-cli-2")
+	require.NoError(t, os.MkdirAll(dir, 0o755))
+	state := pipeline.NewStateWithRun("sample", dir, "run-123", "test-scope")
+	require.NoError(t, state.Save())
+	before := EmbossSnapshot{ScorecardTotal: 60, ScorecardGrade: "B", VerifyPassRate: 80, VerifyPassed: 8, VerifyTotal: 10, CommandCount: 10}
+	after := EmbossSnapshot{ScorecardTotal: 66, ScorecardGrade: "B", VerifyPassRate: 90, VerifyPassed: 9, VerifyTotal: 10, CommandCount: 11}
+	delta := &EmbossDelta{ScorecardDelta: 6, VerifyDelta: 10, CommandDelta: 1}
+
+	path, err := writeEmbossDeltaReport(dir, before, after, delta)
+	require.NoError(t, err)
+
+	assert.Contains(t, path, filepath.Join(home, ".runstate", "test-scope", "runs", "run-123", "proofs"))
+	assert.Contains(t, filepath.Base(path), "sample-pp-cli-2")
+
+	data, err := os.ReadFile(path)
+	require.NoError(t, err)
+	assert.True(t, strings.Contains(string(data), "# Emboss Delta Report: sample-pp-cli-2"))
+}
+
+func TestResolveEmbossWorkspaceCreatesFreshRunForPublishedCLI(t *testing.T) {
+	home := t.TempDir()
+	t.Setenv("PRINTING_PRESS_HOME", home)
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", filepath.Join(home, "repo"))
+
+	current := pipeline.NewStateWithRun("sample", filepath.Join(home, ".runstate", "test-scope", "runs", "run-current", "working", "sample-pp-cli"), "run-current", "test-scope")
+	require.NoError(t, current.Save())
+
+	publishedDir := filepath.Join(home, "library", "sample-pp-cli")
+	require.NoError(t, os.MkdirAll(publishedDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(publishedDir, "README.md"), []byte("published"), 0o644))
+
+	existing := pipeline.NewStateWithRun("sample", filepath.Join(home, ".runstate", "test-scope", "runs", "run-old", "working", "sample-pp-cli"), "run-old", "test-scope")
+	existing.PublishedDir = publishedDir
+	require.NoError(t, os.MkdirAll(existing.EffectiveWorkingDir(), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(existing.EffectiveWorkingDir(), "README.md"), []byte("stale working copy"), 0o644))
+	require.NoError(t, existing.SaveWithoutCurrentPointer())
+
+	workingDir, baselinePath, state, err := resolveEmbossWorkspace(publishedDir)
+	require.NoError(t, err)
+	require.NotNil(t, state)
+
+	assert.NotEqual(t, existing.RunID, state.RunID)
+	assert.NotEqual(t, existing.EffectiveWorkingDir(), workingDir)
+	assert.Equal(t, filepath.Join(state.ProofsDir(), ".emboss-baseline.json"), baselinePath)
+
+	data, err := os.ReadFile(filepath.Join(workingDir, "README.md"))
+	require.NoError(t, err)
+	assert.Equal(t, "published", string(data))
+
+	currentState, err := pipeline.LoadCurrentState("sample")
+	require.NoError(t, err)
+	assert.Equal(t, current.RunID, currentState.RunID)
+}
diff --git a/internal/cli/exitcodes_pipeline_test.go b/internal/cli/exitcodes_pipeline_test.go
index 25a13e44..1dd52f6c 100644
--- a/internal/cli/exitcodes_pipeline_test.go
+++ b/internal/cli/exitcodes_pipeline_test.go
@@ -27,10 +27,13 @@ func TestPrintCmd_AlreadyExists_ExitCode(t *testing.T) {
 		t.Fatal(err)
 	}
 	t.Cleanup(func() { os.Chdir(orig) })
+	t.Setenv("PRINTING_PRESS_HOME", filepath.Join(tmp, "printing-press"))
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
 
 	// Create a fake state file so StateExists returns true.
 	apiName := "exitcode-test"
-	pipeDir := filepath.Join("docs", "plans", apiName+"-pipeline")
+	pipeDir := pipeline.PipelineDir(apiName)
 	if err := os.MkdirAll(pipeDir, 0o755); err != nil {
 		t.Fatal(err)
 	}
@@ -88,6 +91,9 @@ func TestPipelineInitDiscoverSpec_ErrorSubstring(t *testing.T) {
 		t.Fatal(err)
 	}
 	t.Cleanup(func() { os.Chdir(orig) })
+	t.Setenv("PRINTING_PRESS_HOME", filepath.Join(tmp, "printing-press"))
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
 
 	// Use a name that won't match any known spec and will fail discovery.
 	_, initErr := pipeline.Init("zzz-nonexistent-api-exitcode-test", pipeline.Options{})
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 8d643c05..347ed9da 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -17,6 +17,7 @@ import (
 	"github.com/mvanhorn/cli-printing-press/internal/graphql"
 	"github.com/mvanhorn/cli-printing-press/internal/llm"
 	"github.com/mvanhorn/cli-printing-press/internal/llmpolish"
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/openapi"
 	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
@@ -152,7 +153,7 @@ func newGenerateCmd() *cobra.Command {
 					}
 				}
 
-				fmt.Fprintf(os.Stderr, "Generated %s-cli at %s (from docs)\n", parsed.Name, absOut)
+				fmt.Fprintf(os.Stderr, "Generated %s at %s (from docs)\n", naming.CLI(parsed.Name), absOut)
 				if asJSON {
 					json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
 						"name":       parsed.Name,
@@ -250,7 +251,22 @@ func newGenerateCmd() *cobra.Command {
 				}
 			}
 
-			fmt.Fprintf(os.Stderr, "Generated %s-cli at %s\n", apiSpec.Name, absOut)
+			// Rename output directory to match the derived CLI name if they differ.
+			// This prevents mismatches when the caller passes a directory name
+			// that doesn't match what the generator derives from the spec title
+			// (e.g., --output .../calcom-pp-cli but spec title "Cal.com" derives "cal-com-pp-cli").
+			derivedDir := naming.CLI(apiSpec.Name)
+			currentBase := filepath.Base(absOut)
+			if currentBase != derivedDir {
+				finalPath := filepath.Join(filepath.Dir(absOut), derivedDir)
+				if err := os.Rename(absOut, finalPath); err != nil {
+					fmt.Fprintf(os.Stderr, "warning: could not rename output dir from %s to %s: %v\n", currentBase, derivedDir, err)
+				} else {
+					absOut = finalPath
+				}
+			}
+
+			fmt.Fprintf(os.Stderr, "Generated %s at %s\n", naming.CLI(apiSpec.Name), absOut)
 			if asJSON {
 				json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
 					"name":       apiSpec.Name,
@@ -266,10 +282,10 @@ func newGenerateCmd() *cobra.Command {
 
 	cmd.Flags().StringSliceVar(&specFiles, "spec", nil, "Path or URL to API spec (can be repeated)")
 	cmd.Flags().StringVar(&cliName, "name", "", "CLI name (required when using multiple specs)")
-	cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: library/<name>-cli)")
+	cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: ~/printing-press/library/<name>-pp-cli)")
 	cmd.Flags().BoolVar(&validate, "validate", true, "Run quality gates on the generated project")
 	cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh cached remote spec before generating")
-	cmd.Flags().BoolVar(&force, "force", false, "Overwrite the base output directory (e.g. library/notion-pp-cli) instead of auto-incrementing")
+	cmd.Flags().BoolVar(&force, "force", false, "Overwrite the base output directory (e.g. ~/printing-press/library/notion-pp-cli) instead of auto-incrementing")
 	cmd.Flags().BoolVar(&lenient, "lenient", false, "Skip validation errors from broken $refs in OpenAPI specs")
 	cmd.Flags().StringVar(&docsURL, "docs", "", "API documentation URL to generate spec from")
 	cmd.Flags().BoolVar(&polish, "polish", false, "Run LLM polish pass on generated CLI (requires claude or codex CLI)")
@@ -301,7 +317,7 @@ func mergeSpecs(specs []*spec.APISpec, name string) *spec.APISpec {
 		Auth:        specs[0].Auth,
 		Config: spec.ConfigSpec{
 			Format: "toml",
-			Path:   fmt.Sprintf("~/.config/%s-cli/config.toml", name),
+			Path:   fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
 		},
 		Resources: map[string]spec.Resource{},
 		Types:     map[string]spec.TypeDef{},
@@ -472,7 +488,7 @@ func newPrintCmd() *cobra.Command {
 
 			fmt.Fprintf(os.Stderr, "Pipeline created for %s\n", apiName)
 			fmt.Fprintf(os.Stderr, "  Spec: %s\n", state.SpecURL)
-			fmt.Fprintf(os.Stderr, "  Output: %s\n", state.OutputDir)
+			fmt.Fprintf(os.Stderr, "  Output: %s\n", state.EffectiveWorkingDir())
 			fmt.Fprintf(os.Stderr, "  Plans:\n")
 			for i, phase := range pipeline.PhaseOrder {
 				fmt.Fprintf(os.Stderr, "    %d. %s\n", i, state.PlanPath(phase))
@@ -482,16 +498,18 @@ func newPrintCmd() *cobra.Command {
 			if asJSON {
 				json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
 					"api_name":         apiName,
-					"pipeline_dir":     state.OutputDir,
+					"pipeline_dir":     state.PipelineDir(),
 					"phases_completed": countCompletedPhases(state),
-					"state_file":       pipeline.StatePath(apiName),
+					"state_file":       state.StatePath(),
+					"working_dir":      state.EffectiveWorkingDir(),
+					"run_id":           state.RunID,
 				})
 			}
 			return nil
 		},
 	}
 
-	cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: library/<api-name>-cli)")
+	cmd.Flags().StringVar(&outputDir, "output", "", "Working directory (default: ~/printing-press/.runstate/<scope>/runs/<run-id>/working/<api-name>-pp-cli)")
 	cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing pipeline")
 	cmd.Flags().BoolVar(&resume, "resume", false, "Resume from existing checkpoint")
 	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
diff --git a/internal/cli/scorecard.go b/internal/cli/scorecard.go
index eb0f35b6..8e983ff1 100644
--- a/internal/cli/scorecard.go
+++ b/internal/cli/scorecard.go
@@ -19,10 +19,10 @@ func newScorecardCmd() *cobra.Command {
 		Use:   "scorecard",
 		Short: "Score a generated CLI against the Steinberger bar",
 		Example: `  # Score a generated CLI directory
-  printing-press scorecard --dir ./generated/stripe-cli
+  printing-press scorecard --dir ./generated/stripe-pp-cli
 
   # Output as JSON
-  printing-press scorecard --dir ./generated/stripe-cli --json`,
+  printing-press scorecard --dir ./generated/stripe-pp-cli --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if dir == "" {
 				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
diff --git a/internal/cli/verify.go b/internal/cli/verify.go
index b9099ef6..d3ae045d 100644
--- a/internal/cli/verify.go
+++ b/internal/cli/verify.go
@@ -34,19 +34,19 @@ Otherwise, a mock server is started from the OpenAPI spec.
 
 Use --fix to auto-patch common failures and re-test (max 3 iterations).`,
 		Example: `  # Test against real API (read-only GETs only)
-  printing-press verify --dir ./github-cli --spec /tmp/spec.json --api-key $GITHUB_TOKEN
+  printing-press verify --dir ./github-pp-cli --spec /tmp/spec.json --api-key $GITHUB_TOKEN
 
   # Test against mock server (no API key needed)
-  printing-press verify --dir ./github-cli --spec /tmp/spec.json
+  printing-press verify --dir ./github-pp-cli --spec /tmp/spec.json
 
   # Auto-fix failures and re-test
-  printing-press verify --dir ./github-cli --spec /tmp/spec.json --fix
+  printing-press verify --dir ./github-pp-cli --spec /tmp/spec.json --fix
 
   # Remove transient build artifacts after the final verification pass
-  printing-press verify --dir ./github-cli --spec /tmp/spec.json --cleanup
+  printing-press verify --dir ./github-pp-cli --spec /tmp/spec.json --cleanup
 
   # Set pass threshold and output JSON
-  printing-press verify --dir ./github-cli --spec /tmp/spec.json --threshold 70 --json`,
+  printing-press verify --dir ./github-pp-cli --spec /tmp/spec.json --threshold 70 --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg := pipeline.VerifyConfig{
 				Dir:       dir,
diff --git a/internal/cli/vision.go b/internal/cli/vision.go
index b83ca6c9..517b0882 100644
--- a/internal/cli/vision.go
+++ b/internal/cli/vision.go
@@ -32,7 +32,11 @@ The vision command produces the structure; Phase 0 fills it with intelligence.`,
 				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--api is required")}
 			}
 			if outputDir == "" {
-				outputDir = pipeline.DefaultOutputDir(apiName)
+				state, err := pipeline.LoadCurrentState(apiName)
+				if err != nil {
+					return &ExitError{Code: ExitInputError, Err: fmt.Errorf("no current run for %s; run `printing-press print %s` first or pass --output", apiName, apiName)}
+				}
+				outputDir = state.ResearchDir()
 			}
 
 			absOut, err := filepath.Abs(outputDir)
@@ -91,7 +95,7 @@ The vision command produces the structure; Phase 0 fills it with intelligence.`,
 	}
 
 	cmd.Flags().StringVar(&apiName, "api", "", "API name to research")
-	cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: library/<api>-cli)")
+	cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: current runstate research dir for the API)")
 	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
 
 	return cmd
diff --git a/internal/cli/vision_test.go b/internal/cli/vision_test.go
new file mode 100644
index 00000000..7c6d0bf2
--- /dev/null
+++ b/internal/cli/vision_test.go
@@ -0,0 +1,28 @@
+package cli
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestVisionCmdDefaultsToScopedResearchDir(t *testing.T) {
+	home := t.TempDir()
+	t.Setenv("PRINTING_PRESS_HOME", home)
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", filepath.Join(home, "repo"))
+
+	state := pipeline.NewStateWithRun("stripe", filepath.Join(home, "work", "stripe-pp-cli"), "run-123", "test-scope")
+	require.NoError(t, state.Save())
+
+	cmd := newVisionCmd()
+	cmd.SetArgs([]string{"--api", "stripe", "--json"})
+
+	require.NoError(t, cmd.Execute())
+
+	expectedFile := filepath.Join(state.ResearchDir(), "visionary-research.md")
+	assert.FileExists(t, expectedFile)
+}
diff --git a/internal/docspec/docspec.go b/internal/docspec/docspec.go
index 637abda2..d9328cfc 100644
--- a/internal/docspec/docspec.go
+++ b/internal/docspec/docspec.go
@@ -60,7 +60,7 @@ func GenerateFromDocs(docsURL, apiName string) (*spec.APISpec, error) {
 		Auth:        auth,
 		Config: spec.ConfigSpec{
 			Format: "toml",
-			Path:   fmt.Sprintf("~/.config/%s-cli/config.toml", apiName),
+			Path:   fmt.Sprintf("~/.config/%s-pp-cli/config.toml", apiName),
 		},
 		Resources: resources,
 	}
@@ -375,7 +375,7 @@ auth:
     - "API_TOKEN"
 config:
   format: "toml"
-  path: "~/.config/%s-cli/config.toml"
+  path: "~/.config/%s-pp-cli/config.toml"
 resources:
   resource_name:
     description: "Operations on resource_name"
diff --git a/internal/docspec/docspec_test.go b/internal/docspec/docspec_test.go
index c2be082c..a7a1e7c4 100644
--- a/internal/docspec/docspec_test.go
+++ b/internal/docspec/docspec_test.go
@@ -146,7 +146,7 @@ func TestBuildDocSpecLLMPrompt(t *testing.T) {
 	assert.Contains(t, prompt, "GET /v1/charges")
 	assert.Contains(t, prompt, "base_url")
 	assert.Contains(t, prompt, "resources")
-	assert.Contains(t, prompt, "~/.config/stripe-cli/config.toml")
+	assert.Contains(t, prompt, "~/.config/stripe-pp-cli/config.toml")
 }
 
 func TestExtractYAML(t *testing.T) {
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index b1dc827f..2c7e6b15 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -12,6 +12,7 @@ import (
 	"time"
 	"unicode"
 
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/profiler"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
 )
@@ -85,7 +86,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 
 func (g *Generator) Generate() error {
 	dirs := []string{
-		filepath.Join("cmd", g.Spec.Name+"-cli"),
+		filepath.Join("cmd", naming.CLI(g.Spec.Name)),
 		filepath.Join("internal", "cli"),
 		filepath.Join("internal", "cache"),
 		filepath.Join("internal", "client"),
@@ -101,7 +102,7 @@ func (g *Generator) Generate() error {
 
 	// Generate single files
 	singleFiles := map[string]string{
-		"main.go.tmpl":      filepath.Join("cmd", g.Spec.Name+"-cli", "main.go"),
+		"main.go.tmpl":      filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
 		"helpers.go.tmpl":   filepath.Join("internal", "cli", "helpers.go"),
 		"doctor.go.tmpl":    filepath.Join("internal", "cli", "doctor.go"),
 		"config.go.tmpl":    filepath.Join("internal", "config", "config.go"),
@@ -740,7 +741,7 @@ func exampleValue(p spec.Param) string {
 
 func (g *Generator) exampleLine(commandPath, endpointName string, endpoint spec.Endpoint) string {
 	var parts []string
-	parts = append(parts, g.Spec.Name+"-cli")
+	parts = append(parts, naming.CLI(g.Spec.Name))
 	parts = append(parts, strings.Fields(commandPath)...)
 	parts = append(parts, endpointName)
 
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 9dfc3ebb..96c3c223 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -7,6 +7,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/openapi"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
 	"github.com/stretchr/testify/assert"
@@ -31,7 +32,7 @@ func TestGenerateProjectsCompile(t *testing.T) {
 			apiSpec, err := spec.Parse(tt.specPath)
 			require.NoError(t, err)
 
-			outputDir := filepath.Join(t.TempDir(), apiSpec.Name+"-cli")
+			outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
 			gen := New(apiSpec, outputDir)
 			require.NoError(t, gen.Generate())
 
@@ -40,8 +41,8 @@ func TestGenerateProjectsCompile(t *testing.T) {
 			runGoCommand(t, outputDir, "mod", "tidy")
 			runGoCommand(t, outputDir, "build", "./...")
 
-			binaryPath := filepath.Join(outputDir, apiSpec.Name+"-cli")
-			runGoCommand(t, outputDir, "build", "-o", binaryPath, "./cmd/"+apiSpec.Name+"-cli")
+			binaryPath := filepath.Join(outputDir, naming.CLI(apiSpec.Name))
+			runGoCommand(t, outputDir, "build", "-o", binaryPath, "./cmd/"+naming.CLI(apiSpec.Name))
 
 			info, err := os.Stat(binaryPath)
 			require.NoError(t, err)
@@ -61,7 +62,7 @@ func TestGenerateOAuth2AuthTemplateConditionally(t *testing.T) {
 		apiSpec, err := openapi.Parse(data)
 		require.NoError(t, err)
 
-		outputDir := filepath.Join(t.TempDir(), apiSpec.Name+"-cli")
+		outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
 		gen := New(apiSpec, outputDir)
 		require.NoError(t, gen.Generate())
 
@@ -73,7 +74,7 @@ func TestGenerateOAuth2AuthTemplateConditionally(t *testing.T) {
 		apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
 		require.NoError(t, err)
 
-		outputDir := filepath.Join(t.TempDir(), apiSpec.Name+"-cli")
+		outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
 		gen := New(apiSpec, outputDir)
 		require.NoError(t, gen.Generate())
 
@@ -123,7 +124,7 @@ func TestGenerateWithNoAuth(t *testing.T) {
 		},
 		Config: spec.ConfigSpec{
 			Format: "toml",
-			Path:   "~/.config/noauth-cli/config.toml",
+			Path:   "~/.config/noauth-pp-cli/config.toml",
 		},
 		Resources: map[string]spec.Resource{
 			"items": {
@@ -139,11 +140,11 @@ func TestGenerateWithNoAuth(t *testing.T) {
 		},
 	}
 
-	outputDir := filepath.Join(t.TempDir(), "noauth-cli")
+	outputDir := filepath.Join(t.TempDir(), "noauth-pp-cli")
 	gen := New(apiSpec, outputDir)
 	require.NoError(t, gen.Generate())
 	require.NoError(t, gen.Validate())
-	assert.NoFileExists(t, filepath.Join(outputDir, "noauth-cli-validation"))
+	assert.NoFileExists(t, filepath.Join(outputDir, naming.ValidationBinary("noauth")))
 }
 
 func TestGenerateWithOwnerField(t *testing.T) {
@@ -162,7 +163,7 @@ func TestGenerateWithOwnerField(t *testing.T) {
 		},
 		Config: spec.ConfigSpec{
 			Format: "toml",
-			Path:   "~/.config/owned-cli/config.toml",
+			Path:   "~/.config/owned-pp-cli/config.toml",
 		},
 		Resources: map[string]spec.Resource{
 			"things": {
@@ -178,7 +179,7 @@ func TestGenerateWithOwnerField(t *testing.T) {
 		},
 	}
 
-	outputDir := filepath.Join(t.TempDir(), "owned-cli")
+	outputDir := filepath.Join(t.TempDir(), "owned-pp-cli")
 	gen := New(apiSpec, outputDir)
 	require.NoError(t, gen.Generate())
 
@@ -203,7 +204,7 @@ func TestGenerateWithEmptyOwner(t *testing.T) {
 		},
 		Config: spec.ConfigSpec{
 			Format: "toml",
-			Path:   "~/.config/unowned-cli/config.toml",
+			Path:   "~/.config/unowned-pp-cli/config.toml",
 		},
 		Resources: map[string]spec.Resource{
 			"widgets": {
@@ -219,7 +220,7 @@ func TestGenerateWithEmptyOwner(t *testing.T) {
 		},
 	}
 
-	outputDir := filepath.Join(t.TempDir(), "unowned-cli")
+	outputDir := filepath.Join(t.TempDir(), "unowned-pp-cli")
 	gen := New(apiSpec, outputDir)
 	require.NoError(t, gen.Generate())
 
@@ -239,7 +240,7 @@ func generatePetstore(t *testing.T) string {
 	apiSpec, err := openapi.Parse(data)
 	require.NoError(t, err)
 
-	outputDir := filepath.Join(t.TempDir(), apiSpec.Name+"-cli")
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
 	gen := New(apiSpec, outputDir)
 	require.NoError(t, gen.Generate())
 
diff --git a/internal/generator/templates/NOTICE.tmpl b/internal/generator/templates/NOTICE.tmpl
index cda032c5..15540419 100644
--- a/internal/generator/templates/NOTICE.tmpl
+++ b/internal/generator/templates/NOTICE.tmpl
@@ -1,4 +1,4 @@
-{{.Name}}-cli
+{{.Name}}-pp-cli
 Copyright {{currentYear}} {{.Owner}}
 
 This CLI was generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press)
diff --git a/internal/generator/templates/analytics.go.tmpl b/internal/generator/templates/analytics.go.tmpl
index b8b21c74..c5e70716 100644
--- a/internal/generator/templates/analytics.go.tmpl
+++ b/internal/generator/templates/analytics.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"sort"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 	"github.com/spf13/cobra"
 )
 
@@ -26,22 +26,22 @@ func newAnalyticsCmd(flags *rootFlags) *cobra.Command {
 		Long: `Analyze locally synced data with count, group-by, and summary operations.
 Data must be synced first with the sync command.`,
 		Example: `  # Count records by type
-  {{.Name}}-cli analytics --type messages
+  {{.Name}}-pp-cli analytics --type messages
 
   # Group by a field
-  {{.Name}}-cli analytics --type messages --group-by author_id
+  {{.Name}}-pp-cli analytics --type messages --group-by author_id
 
   # Top 10 most frequent values
-  {{.Name}}-cli analytics --type messages --group-by channel_id --limit 10 --json`,
+  {{.Name}}-pp-cli analytics --type messages --group-by channel_id --limit 10 --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if dbPath == "" {
 				home, _ := os.UserHomeDir()
-				dbPath = filepath.Join(home, ".local", "share", "{{.Name}}-cli", "data.db")
+				dbPath = filepath.Join(home, ".local", "share", "{{.Name}}-pp-cli", "data.db")
 			}
 
 			db, err := store.Open(dbPath)
 			if err != nil {
-				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-pp-cli sync' first.", err)
 			}
 			defer db.Close()
 
diff --git a/internal/generator/templates/auth.go.tmpl b/internal/generator/templates/auth.go.tmpl
index e20b5fcd..76a28359 100644
--- a/internal/generator/templates/auth.go.tmpl
+++ b/internal/generator/templates/auth.go.tmpl
@@ -18,7 +18,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/config"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/auth_simple.go.tmpl b/internal/generator/templates/auth_simple.go.tmpl
index b5c90e8c..4c5bef36 100644
--- a/internal/generator/templates/auth_simple.go.tmpl
+++ b/internal/generator/templates/auth_simple.go.tmpl
@@ -9,7 +9,7 @@ import (
 	"os"
 {{- end}}
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/config"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
 	"github.com/spf13/cobra"
 )
 
@@ -30,7 +30,7 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
 	return &cobra.Command{
 		Use:   "status",
 		Short: "Show authentication status",
-		Example: "  {{.Name}}-cli auth status",
+		Example: "  {{.Name}}-pp-cli auth status",
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg, err := config.Load(flags.configPath)
 			if err != nil {
@@ -46,7 +46,7 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
 {{- range .Auth.EnvVars}}
 				fmt.Fprintln(w, "  export {{.}}=\"your-token-here\"")
 {{- end}}
-				fmt.Fprintf(w, "  {{.Name}}-cli auth set-token <token>\n")
+				fmt.Fprintf(w, "  {{.Name}}-pp-cli auth set-token <token>\n")
 				return authErr(fmt.Errorf("no credentials configured"))
 			}
 
@@ -62,7 +62,7 @@ func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
 	return &cobra.Command{
 		Use:   "set-token <token>",
 		Short: "Save an API token to the config file",
-		Example: "  {{.Name}}-cli auth set-token sk_live_abc123",
+		Example: "  {{.Name}}-pp-cli auth set-token sk_live_abc123",
 		Args:  cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg, err := config.Load(flags.configPath)
@@ -85,7 +85,7 @@ func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
 	return &cobra.Command{
 		Use:   "logout",
 		Short: "Clear stored credentials",
-		Example: "  {{.Name}}-cli auth logout",
+		Example: "  {{.Name}}-pp-cli auth logout",
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg, err := config.Load(flags.configPath)
 			if err != nil {
diff --git a/internal/generator/templates/channel_workflow.go.tmpl b/internal/generator/templates/channel_workflow.go.tmpl
index cb01725c..d24caa7b 100644
--- a/internal/generator/templates/channel_workflow.go.tmpl
+++ b/internal/generator/templates/channel_workflow.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 	"github.com/spf13/cobra"
 )
 
@@ -37,10 +37,10 @@ func newWorkflowArchiveCmd(flags *rootFlags) *cobra.Command {
 local SQLite database. Supports incremental sync (only new data since last run)
 and full resync. After archiving, use 'search' for instant full-text search.`,
 		Example: `  # Archive all resources
-  {{.Name}}-cli workflow archive
+  {{.Name}}-pp-cli workflow archive
 
   # Full re-archive (ignore previous sync state)
-  {{.Name}}-cli workflow archive --full`,
+  {{.Name}}-pp-cli workflow archive --full`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			c, err := flags.newClient()
 			if err != nil {
@@ -49,7 +49,7 @@ and full resync. After archiving, use 'search' for instant full-text search.`,
 			c.NoCache = true
 
 			if dbPath == "" {
-				dbPath = defaultDBPath("{{.Name}}-cli")
+				dbPath = defaultDBPath("{{.Name}}-pp-cli")
 			}
 			s, err := store.Open(dbPath)
 			if err != nil {
@@ -137,7 +137,7 @@ and full resync. After archiving, use 'search' for instant full-text search.`,
 		},
 	}
 
-	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-pp-cli/store.db)")
 	cmd.Flags().BoolVar(&full, "full", false, "Full re-archive (ignore previous sync state)")
 
 	return cmd
@@ -150,13 +150,13 @@ func newWorkflowStatusCmd(flags *rootFlags) *cobra.Command {
 		Use:   "status",
 		Short: "Show local archive status and sync state for all resources",
 		Example: `  # Show archive status
-  {{.Name}}-cli workflow status
+  {{.Name}}-pp-cli workflow status
 
   # Show status as JSON
-  {{.Name}}-cli workflow status --json`,
+  {{.Name}}-pp-cli workflow status --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if dbPath == "" {
-				dbPath = defaultDBPath("{{.Name}}-cli")
+				dbPath = defaultDBPath("{{.Name}}-pp-cli")
 			}
 			s, err := store.Open(dbPath)
 			if err != nil {
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index c2e1a022..c814eb77 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -18,7 +18,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/config"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
 )
 
 type Client struct {
@@ -44,7 +44,7 @@ func (e *APIError) Error() string {
 
 func New(cfg *config.Config, timeout time.Duration) *Client {
 	homeDir, _ := os.UserHomeDir()
-	cacheDir := filepath.Join(homeDir, ".cache", "{{.Name}}-cli")
+	cacheDir := filepath.Join(homeDir, ".cache", "{{.Name}}-pp-cli")
 	return &Client{
 		BaseURL:    strings.TrimRight(cfg.BaseURL, "/"),
 		Config:     cfg,
@@ -159,7 +159,7 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
 		if bodyBytes != nil {
 			req.Header.Set("Content-Type", "application/json")
 		}
-		req.Header.Set("User-Agent", "{{.Name}}-cli/{{.Version}}")
+		req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
 
 		if params != nil {
 			q := req.URL.Query()
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index b4bbc529..80150c12 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -44,7 +44,7 @@ func Load(configPath string) (*Config, error) {
 	}
 	if path == "" {
 		home, _ := os.UserHomeDir()
-		path = filepath.Join(home, ".config", "{{.Name}}-cli", "config.{{.Config.Format}}")
+		path = filepath.Join(home, ".config", "{{.Name}}-pp-cli", "config.{{.Config.Format}}")
 	}
 	cfg.Path = path
 
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index cdee375f..8f28faa0 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -12,7 +12,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/config"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
 	"github.com/spf13/cobra"
 )
 
diff --git a/internal/generator/templates/export.go.tmpl b/internal/generator/templates/export.go.tmpl
index ded43186..7d9c3870 100644
--- a/internal/generator/templates/export.go.tmpl
+++ b/internal/generator/templates/export.go.tmpl
@@ -25,13 +25,13 @@ func newExportCmd(flags *rootFlags) *cobra.Command {
 per line, streaming-friendly) and JSON (array). JSONL is recommended for
 large datasets as it has no memory pressure.`,
 		Example: `  # Export all items as JSONL (streaming, recommended for large datasets)
-  {{.Name}}-cli export <resource> --format jsonl --output data.jsonl
+  {{.Name}}-pp-cli export <resource> --format jsonl --output data.jsonl
 
   # Export with limit
-  {{.Name}}-cli export <resource> --format jsonl --limit 1000
+  {{.Name}}-pp-cli export <resource> --format jsonl --limit 1000
 
   # Pipe to another tool
-  {{.Name}}-cli export <resource> --format jsonl | jq '.id'`,
+  {{.Name}}-pp-cli export <resource> --format jsonl | jq '.id'`,
 		Args: cobra.MinimumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			c, err := flags.newClient()
diff --git a/internal/generator/templates/go.mod.tmpl b/internal/generator/templates/go.mod.tmpl
index 1c4329e2..fcd2421c 100644
--- a/internal/generator/templates/go.mod.tmpl
+++ b/internal/generator/templates/go.mod.tmpl
@@ -1,4 +1,4 @@
-module github.com/{{.Owner}}/{{.Name}}-cli
+module github.com/{{.Owner}}/{{.Name}}-pp-cli
 
 go 1.23
 
diff --git a/internal/generator/templates/goreleaser.yaml.tmpl b/internal/generator/templates/goreleaser.yaml.tmpl
index b835035a..b1526a8d 100644
--- a/internal/generator/templates/goreleaser.yaml.tmpl
+++ b/internal/generator/templates/goreleaser.yaml.tmpl
@@ -1,15 +1,15 @@
 version: 2
-project_name: {{.Name}}-cli
+project_name: {{.Name}}-pp-cli
 changelog:
   disable: true
 builds:
-  - id: {{.Name}}-cli
-    main: ./cmd/{{.Name}}-cli
-    binary: {{.Name}}-cli
+  - id: {{.Name}}-pp-cli
+    main: ./cmd/{{.Name}}-pp-cli
+    binary: {{.Name}}-pp-cli
     env:
       - CGO_ENABLED=0
     ldflags:
-      - -s -w -X github.com/{{.Owner}}/{{.Name}}-cli/internal/cli.version={{"{{"}} .Version {{"}}"}}
+      - -s -w -X github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/cli.version={{"{{"}} .Version {{"}}"}}
     targets:
       - darwin_amd64
       - darwin_arm64
@@ -42,14 +42,14 @@ archives:
 checksum:
   name_template: checksums.txt
 brews:
-  - name: {{.Name}}-cli
+  - name: {{.Name}}-pp-cli
     repository:
       owner: {{.Owner}}
       name: homebrew-tap
-    homepage: "https://github.com/{{.Owner}}/{{.Name}}-cli"
+    homepage: "https://github.com/{{.Owner}}/{{.Name}}-pp-cli"
     description: "{{.Description}}"
     install: |
-      bin.install "{{.Name}}-cli"
+      bin.install "{{.Name}}-pp-cli"
 {{- if .VisionSet.MCP}}
       bin.install "{{.Name}}-mcp"
 {{- end}}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 1361d710..2149bb94 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -101,7 +101,7 @@ func classifyAPIError(err error) error {
 		fmt.Fprintln(os.Stderr, "already exists (no-op)")
 		return nil
 	case strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403"):
-		return authErr(fmt.Errorf("%w\nhint: check your API credentials. Run '{{.Name}}-cli doctor' to verify auth, or set the required environment variable", err))
+		return authErr(fmt.Errorf("%w\nhint: check your API credentials. Run '{{.Name}}-pp-cli doctor' to verify auth, or set the required environment variable", err))
 	case strings.Contains(msg, "HTTP 404"):
 		return notFoundErr(fmt.Errorf("%w\nhint: resource not found. Run the 'list' command to see available items", err))
 	case strings.Contains(msg, "HTTP 429"):
diff --git a/internal/generator/templates/import.go.tmpl b/internal/generator/templates/import.go.tmpl
index dbd87b00..6b5249b0 100644
--- a/internal/generator/templates/import.go.tmpl
+++ b/internal/generator/templates/import.go.tmpl
@@ -26,13 +26,13 @@ func newImportCmd(flags *rootFlags) *cobra.Command {
 Each line must be a valid JSON object. Failed records are logged to stderr
 but do not stop the import.`,
 		Example: `  # Import from a JSONL file
-  {{.Name}}-cli import <resource> --input data.jsonl
+  {{.Name}}-pp-cli import <resource> --input data.jsonl
 
   # Dry-run to preview without sending
-  {{.Name}}-cli import <resource> --input data.jsonl --dry-run
+  {{.Name}}-pp-cli import <resource> --input data.jsonl --dry-run
 
   # Import from stdin
-  cat data.jsonl | {{.Name}}-cli import <resource> --input -`,
+  cat data.jsonl | {{.Name}}-pp-cli import <resource> --input -`,
 		Args: cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			c, err := flags.newClient()
diff --git a/internal/generator/templates/insights/health_score.go.tmpl b/internal/generator/templates/insights/health_score.go.tmpl
index c8170a24..47c4eb24 100644
--- a/internal/generator/templates/insights/health_score.go.tmpl
+++ b/internal/generator/templates/insights/health_score.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 	"github.com/spf13/cobra"
 )
 
@@ -27,19 +27,19 @@ func newHealthCmd(flags *rootFlags) *cobra.Command {
 
 A score of 100 means all items are fresh, assigned, and actively worked on.`,
 		Example: `  # Show workspace health score
-  {{.Name}}-cli health
+  {{.Name}}-pp-cli health
 
   # Output as JSON
-  {{.Name}}-cli health --json`,
+  {{.Name}}-pp-cli health --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if dbPath == "" {
 				home, _ := os.UserHomeDir()
-				dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+				dbPath = filepath.Join(home, ".config", "{{.Name}}-pp-cli", "store.db")
 			}
 
 			db, err := store.Open(dbPath)
 			if err != nil {
-				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-pp-cli sync' first.", err)
 			}
 			defer db.Close()
 
@@ -164,7 +164,7 @@ A score of 100 means all items are fresh, assigned, and actively worked on.`,
 		},
 	}
 
-	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-pp-cli/store.db)")
 
 	return cmd
 }
diff --git a/internal/generator/templates/insights/similar.go.tmpl b/internal/generator/templates/insights/similar.go.tmpl
index c7f58fb3..f520da99 100644
--- a/internal/generator/templates/insights/similar.go.tmpl
+++ b/internal/generator/templates/insights/similar.go.tmpl
@@ -9,7 +9,7 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 	"github.com/spf13/cobra"
 )
 
@@ -24,25 +24,25 @@ func newSimilarCmd(flags *rootFlags) *cobra.Command {
 search index to find other items with similar content. Helps identify duplicate
 work items, tickets, or tasks.`,
 		Example: `  # Find items similar to a given item
-  {{.Name}}-cli similar abc123
+  {{.Name}}-pp-cli similar abc123
 
   # Limit results
-  {{.Name}}-cli similar abc123 --limit 5
+  {{.Name}}-pp-cli similar abc123 --limit 5
 
   # Output as JSON
-  {{.Name}}-cli similar abc123 --json`,
+  {{.Name}}-pp-cli similar abc123 --json`,
 		Args: cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			itemID := args[0]
 
 			if dbPath == "" {
 				home, _ := os.UserHomeDir()
-				dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+				dbPath = filepath.Join(home, ".config", "{{.Name}}-pp-cli", "store.db")
 			}
 
 			db, err := store.Open(dbPath)
 			if err != nil {
-				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-pp-cli sync' first.", err)
 			}
 			defer db.Close()
 
@@ -162,7 +162,7 @@ work items, tickets, or tasks.`,
 		},
 	}
 
-	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-pp-cli/store.db)")
 	cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of similar items to show")
 
 	return cmd
diff --git a/internal/generator/templates/main.go.tmpl b/internal/generator/templates/main.go.tmpl
index fd4caab5..49527b8e 100644
--- a/internal/generator/templates/main.go.tmpl
+++ b/internal/generator/templates/main.go.tmpl
@@ -7,7 +7,7 @@ import (
 	"fmt"
 	"os"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/cli"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/cli"
 )
 
 func main() {
diff --git a/internal/generator/templates/main_mcp.go.tmpl b/internal/generator/templates/main_mcp.go.tmpl
index 3d519b93..6ee51c47 100644
--- a/internal/generator/templates/main_mcp.go.tmpl
+++ b/internal/generator/templates/main_mcp.go.tmpl
@@ -8,7 +8,7 @@ import (
 	"os"
 
 	"github.com/mark3labs/mcp-go/server"
-	mcptools "github.com/{{.Owner}}/{{.Name}}-cli/internal/mcp"
+	mcptools "github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/mcp"
 )
 
 func main() {
diff --git a/internal/generator/templates/makefile.tmpl b/internal/generator/templates/makefile.tmpl
index 304a617c..79e1843a 100644
--- a/internal/generator/templates/makefile.tmpl
+++ b/internal/generator/templates/makefile.tmpl
@@ -1,7 +1,7 @@
 .PHONY: build test lint install clean
 
 build:
-	go build -o bin/{{.Name}}-cli ./cmd/{{.Name}}-cli
+	go build -o bin/{{.Name}}-pp-cli ./cmd/{{.Name}}-pp-cli
 
 test:
 	go test ./...
@@ -10,7 +10,7 @@ lint:
 	golangci-lint run
 
 install:
-	go install ./cmd/{{.Name}}-cli
+	go install ./cmd/{{.Name}}-pp-cli
 
 clean:
 	rm -rf bin/
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 1160b1c7..1ef94b3d 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -14,10 +14,10 @@ import (
 
 	mcplib "github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/client"
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/config"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/client"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
 {{- if .VisionSet.Store}}
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 {{- end}}
 )
 
@@ -159,7 +159,7 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 
 func newMCPClient() (*client.Client, error) {
 	home, _ := os.UserHomeDir()
-	cfgPath := filepath.Join(home, ".config", "{{.Name}}-cli", "config.toml")
+	cfgPath := filepath.Join(home, ".config", "{{.Name}}-pp-cli", "config.toml")
 	cfg, err := config.Load(cfgPath)
 	if err != nil {
 		return nil, fmt.Errorf("loading config: %w", err)
@@ -169,13 +169,13 @@ func newMCPClient() (*client.Client, error) {
 
 func dbPath() string {
 	home, _ := os.UserHomeDir()
-	return filepath.Join(home, ".local", "share", "{{.Name}}-cli", "data.db")
+	return filepath.Join(home, ".local", "share", "{{.Name}}-pp-cli", "data.db")
 }
 
 {{- if .VisionSet.Sync}}
 
 func handleSync(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
-	return mcplib.NewToolResultText("sync not yet implemented via MCP - use the CLI: {{.Name}}-cli sync"), nil
+	return mcplib.NewToolResultText("sync not yet implemented via MCP - use the CLI: {{.Name}}-pp-cli sync"), nil
 }
 {{- end}}
 
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index f7b0af2d..a4ea586e 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -1,4 +1,4 @@
-# {{.Name}}-cli
+# {{.Name}}-pp-cli
 
 {{.Description}}
 
@@ -7,18 +7,18 @@
 ### Homebrew
 
 ```
-brew install {{.Owner}}/tap/{{.Name}}-cli
+brew install {{.Owner}}/tap/{{.Name}}-pp-cli
 ```
 
 ### Go
 
 ```
-go install github.com/{{.Owner}}/{{.Name}}-cli/cmd/{{.Name}}-cli@latest
+go install github.com/{{.Owner}}/{{.Name}}-pp-cli/cmd/{{.Name}}-pp-cli@latest
 ```
 
 ### Binary
 
-Download from [Releases](https://github.com/{{.Owner}}/{{.Name}}-cli/releases).
+Download from [Releases](https://github.com/{{.Owner}}/{{.Name}}-pp-cli/releases).
 
 ## Quick Start
 
@@ -29,11 +29,11 @@ export {{index .Auth.EnvVars 0}}="your-key-here"
 {{- end}}
 
 # {{if .Auth.EnvVars}}2{{else}}1{{end}}. Verify everything works
-{{.Name}}-cli doctor
+{{.Name}}-pp-cli doctor
 
 # 3. Start using it
 {{- range $name, $resource := .Resources}}
-{{$.Name}}-cli {{$name}} --help
+{{$.Name}}-pp-cli {{$name}} --help
 {{- break}}
 {{- end}}
 ```
@@ -48,7 +48,7 @@ export {{index .Auth.EnvVars 0}}="your-key-here"
 
 {{$resource.Description}}
 {{range $eName, $endpoint := $resource.Endpoints}}
-- **`{{$.Name}}-cli {{$name}} {{$eName}}`** - {{$endpoint.Description}}
+- **`{{$.Name}}-pp-cli {{$name}} {{$eName}}`** - {{$endpoint.Description}}
 {{- end}}
 {{end}}
 
@@ -56,19 +56,19 @@ export {{index .Auth.EnvVars 0}}="your-key-here"
 
 ```bash
 # Human-readable table (default)
-{{.Name}}-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list
+{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list
 
 # JSON for scripting and agents
-{{.Name}}-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --json
+{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --json
 
 # Filter specific fields
-{{.Name}}-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --json --select id,name,status
+{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --json --select id,name,status
 
 # Plain tab-separated for piping
-{{.Name}}-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --plain
+{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --plain
 
 # Dry run (show request without sending)
-{{.Name}}-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --dry-run
+{{.Name}}-pp-cli {{range $name, $_ := .Resources}}{{$name}}{{break}}{{end}} list --dry-run
 ```
 
 ## Agent Usage
@@ -81,7 +81,7 @@ This CLI is designed for AI agent consumption:
 - **Previewable** - `--dry-run` shows the request without sending
 - **Retryable** - creates return "already exists" on retry, deletes return "already deleted"
 - **Confirmable** - `--yes` for explicit confirmation of destructive actions
-- **Piped input** - `echo '{"key":"value"}' | {{.Name}}-cli <resource> create --stdin`
+- **Piped input** - `echo '{"key":"value"}' | {{.Name}}-pp-cli <resource> create --stdin`
 - **Cacheable** - GET responses cached for 5 minutes, bypass with `--no-cache`
 - **Agent-safe by default** - no colors or formatting unless `--human-friendly` is set
 - **Progress events** - paginated commands emit NDJSON events to stderr in default mode
@@ -91,7 +91,7 @@ Exit codes: `0` success, `2` usage error, `3` not found, `4` auth error, `5` API
 ## Health Check
 
 ```bash
-{{.Name}}-cli doctor
+{{.Name}}-pp-cli doctor
 ```
 
 <!-- DOCTOR_OUTPUT -->
@@ -108,7 +108,7 @@ Environment variables:
 ## Troubleshooting
 
 **Authentication errors (exit code 4)**
-- Run `{{.Name}}-cli doctor` to check credentials
+- Run `{{.Name}}-pp-cli doctor` to check credentials
 {{- if .Auth.EnvVars}}
 - Verify the environment variable is set: `echo ${{index .Auth.EnvVars 0}}`
 {{- end}}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index c0fc2a33..91841519 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -9,8 +9,8 @@ import (
 	"text/tabwriter"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/client"
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/config"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/client"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/config"
 	"github.com/spf13/cobra"
 )
 
@@ -36,13 +36,13 @@ func Execute() error {
 	var flags rootFlags
 
 	rootCmd := &cobra.Command{
-		Use:           "{{.Name}}-cli",
+		Use:           "{{.Name}}-pp-cli",
 		Short:         "{{oneline .Description}}",
 		SilenceUsage:  true,
 		SilenceErrors: true,
 		Version:       version,
 	}
-	rootCmd.SetVersionTemplate("{{.Name}}-cli {{"{{"}} .Version {{"}}"}}\n")
+	rootCmd.SetVersionTemplate("{{.Name}}-pp-cli {{"{{"}} .Version {{"}}"}}\n")
 
 	rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "Output as JSON")
 	rootCmd.PersistentFlags().BoolVar(&flags.compact, "compact", false, "Return only key fields (id, name, status, timestamps) for minimal token usage")
@@ -152,7 +152,7 @@ func newVersionCliCmd() *cobra.Command {
 		Use:   "version",
 		Short: "Print version",
 		Run: func(cmd *cobra.Command, args []string) {
-			fmt.Printf("{{.Name}}-cli %s\n", version)
+			fmt.Printf("{{.Name}}-pp-cli %s\n", version)
 		},
 	}
 }
diff --git a/internal/generator/templates/search.go.tmpl b/internal/generator/templates/search.go.tmpl
index b88bac93..e2d9b53a 100644
--- a/internal/generator/templates/search.go.tmpl
+++ b/internal/generator/templates/search.go.tmpl
@@ -9,7 +9,7 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 	"github.com/spf13/cobra"
 )
 
@@ -25,25 +25,25 @@ func newSearchCmd(flags *rootFlags) *cobra.Command {
 Data must be synced first with the sync command. Searches are instant
 (millisecond-fast) since they query local SQLite, not the remote API.`,
 		Example: `  # Search all synced data
-  {{.Name}}-cli search "error timeout"
+  {{.Name}}-pp-cli search "error timeout"
 
   # Search a specific resource type
-  {{.Name}}-cli search "payment failed" --type transactions
+  {{.Name}}-pp-cli search "payment failed" --type transactions
 
   # JSON output for piping
-  {{.Name}}-cli search "critical" --json --limit 20`,
+  {{.Name}}-pp-cli search "critical" --json --limit 20`,
 		Args: cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			query := args[0]
 
 			if dbPath == "" {
 				home, _ := os.UserHomeDir()
-				dbPath = filepath.Join(home, ".local", "share", "{{.Name}}-cli", "data.db")
+				dbPath = filepath.Join(home, ".local", "share", "{{.Name}}-pp-cli", "data.db")
 			}
 
 			db, err := store.Open(dbPath)
 			if err != nil {
-				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first to populate the local database.", err)
+				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-pp-cli sync' first to populate the local database.", err)
 			}
 			defer db.Close()
 
@@ -82,7 +82,7 @@ Data must be synced first with the sync command. Searches are instant
 
 	cmd.Flags().StringVar(&resourceType, "type", "", "Filter by resource type")
 	cmd.Flags().IntVar(&limit, "limit", 50, "Maximum results to return")
-	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.local/share/{{.Name}}-cli/data.db)")
+	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.local/share/{{.Name}}-pp-cli/data.db)")
 
 	return cmd
 }
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index 6d561fb1..743824c0 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -1,7 +1,7 @@
 // Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
 // Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
 
-// Package store provides local SQLite persistence for {{.Name}}-cli.
+// Package store provides local SQLite persistence for {{.Name}}-pp-cli.
 // Uses modernc.org/sqlite (pure Go, no CGO) for zero-dependency cross-compilation.
 // FTS5 full-text search indexes are created for searchable content.
 package store
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index fcac27e3..746b3efe 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -15,7 +15,7 @@ import (
 	"sync/atomic"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 	"github.com/spf13/cobra"
 )
 
@@ -41,19 +41,19 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 incremental sync (only fetches new data since last sync) and full resync.
 Once synced, use the 'search' command for instant full-text search.`,
 		Example: `  # Sync all resources
-  {{.Name}}-cli sync
+  {{.Name}}-pp-cli sync
 
   # Sync specific resources only
-  {{.Name}}-cli sync --resources channels,messages
+  {{.Name}}-pp-cli sync --resources channels,messages
 
   # Full resync (ignore previous checkpoint)
-  {{.Name}}-cli sync --full
+  {{.Name}}-pp-cli sync --full
 
   # Incremental sync: only records from the last 7 days
-  {{.Name}}-cli sync --since 7d
+  {{.Name}}-pp-cli sync --since 7d
 
   # Parallel sync with 8 workers
-  {{.Name}}-cli sync --concurrency 8`,
+  {{.Name}}-pp-cli sync --concurrency 8`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			c, err := flags.newClient()
 			if err != nil {
@@ -63,7 +63,7 @@ Once synced, use the 'search' command for instant full-text search.`,
 
 			if dbPath == "" {
 				home, _ := os.UserHomeDir()
-				dbPath = filepath.Join(home, ".local", "share", "{{.Name}}-cli", "data.db")
+				dbPath = filepath.Join(home, ".local", "share", "{{.Name}}-pp-cli", "data.db")
 			}
 
 			db, err := store.Open(dbPath)
@@ -154,7 +154,7 @@ Once synced, use the 'search' command for instant full-text search.`,
 	cmd.Flags().BoolVar(&full, "full", false, "Full resync (ignore previous checkpoint)")
 	cmd.Flags().StringVar(&since, "since", "", "Incremental sync duration (e.g. 7d, 24h, 1w, 30m)")
 	cmd.Flags().IntVar(&concurrency, "concurrency", 4, "Number of parallel sync workers")
-	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.local/share/{{.Name}}-cli/data.db)")
+	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.local/share/{{.Name}}-pp-cli/data.db)")
 
 	return cmd
 }
diff --git a/internal/generator/templates/tail.go.tmpl b/internal/generator/templates/tail.go.tmpl
index 9c745254..7c4270ed 100644
--- a/internal/generator/templates/tail.go.tmpl
+++ b/internal/generator/templates/tail.go.tmpl
@@ -29,13 +29,13 @@ Gracefully shuts down on SIGTERM/SIGINT.
 Note: For APIs with WebSocket or SSE support, a future version will use
 native streaming instead of polling.`,
 		Example: `  # Tail all changes every 10 seconds
-  {{.Name}}-cli tail --interval 10s
+  {{.Name}}-pp-cli tail --interval 10s
 
   # Tail a specific resource
-  {{.Name}}-cli tail messages --interval 5s
+  {{.Name}}-pp-cli tail messages --interval 5s
 
   # Pipe to jq for filtering
-  {{.Name}}-cli tail events --interval 30s | jq 'select(.type == "error")'`,
+  {{.Name}}-pp-cli tail events --interval 30s | jq 'select(.type == "error")'`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			c, err := flags.newClient()
 			if err != nil {
diff --git a/internal/generator/templates/workflows/pm_load.go.tmpl b/internal/generator/templates/workflows/pm_load.go.tmpl
index fd0e8852..bc6e8b2b 100644
--- a/internal/generator/templates/workflows/pm_load.go.tmpl
+++ b/internal/generator/templates/workflows/pm_load.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"sort"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 	"github.com/spf13/cobra"
 )
 
@@ -23,19 +23,19 @@ func newLoadCmd(flags *rootFlags) *cobra.Command {
 		Long: `Analyze locally synced data to show how many items are assigned to each
 person. Helps identify overloaded team members and unbalanced workload.`,
 		Example: `  # Show workload distribution
-  {{.Name}}-cli load
+  {{.Name}}-pp-cli load
 
   # Output as JSON
-  {{.Name}}-cli load --json`,
+  {{.Name}}-pp-cli load --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if dbPath == "" {
 				home, _ := os.UserHomeDir()
-				dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+				dbPath = filepath.Join(home, ".config", "{{.Name}}-pp-cli", "store.db")
 			}
 
 			db, err := store.Open(dbPath)
 			if err != nil {
-				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-pp-cli sync' first.", err)
 			}
 			defer db.Close()
 
@@ -112,7 +112,7 @@ person. Helps identify overloaded team members and unbalanced workload.`,
 		},
 	}
 
-	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-pp-cli/store.db)")
 
 	return cmd
 }
diff --git a/internal/generator/templates/workflows/pm_orphans.go.tmpl b/internal/generator/templates/workflows/pm_orphans.go.tmpl
index 49000c7a..65beb1ea 100644
--- a/internal/generator/templates/workflows/pm_orphans.go.tmpl
+++ b/internal/generator/templates/workflows/pm_orphans.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 	"github.com/spf13/cobra"
 )
 
@@ -23,19 +23,19 @@ func newOrphansCmd(flags *rootFlags) *cobra.Command {
 		Long: `Scan locally synced data for items that are missing important fields
 such as assignee, project, priority, or labels. Useful for triaging unowned work.`,
 		Example: `  # Find orphaned items
-  {{.Name}}-cli orphans
+  {{.Name}}-pp-cli orphans
 
   # Output as JSON
-  {{.Name}}-cli orphans --json`,
+  {{.Name}}-pp-cli orphans --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if dbPath == "" {
 				home, _ := os.UserHomeDir()
-				dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+				dbPath = filepath.Join(home, ".config", "{{.Name}}-pp-cli", "store.db")
 			}
 
 			db, err := store.Open(dbPath)
 			if err != nil {
-				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-pp-cli sync' first.", err)
 			}
 			defer db.Close()
 
@@ -139,7 +139,7 @@ such as assignee, project, priority, or labels. Useful for triaging unowned work
 		},
 	}
 
-	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-pp-cli/store.db)")
 
 	return cmd
 }
diff --git a/internal/generator/templates/workflows/pm_stale.go.tmpl b/internal/generator/templates/workflows/pm_stale.go.tmpl
index df29627a..5a67ac06 100644
--- a/internal/generator/templates/workflows/pm_stale.go.tmpl
+++ b/internal/generator/templates/workflows/pm_stale.go.tmpl
@@ -10,7 +10,7 @@ import (
 	"path/filepath"
 	"time"
 
-	"github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+	"github.com/{{.Owner}}/{{.Name}}-pp-cli/internal/store"
 	"github.com/spf13/cobra"
 )
 
@@ -25,25 +25,25 @@ func newStaleCmd(flags *rootFlags) *cobra.Command {
 		Long: `Scan locally synced data for items that have not been updated within
 the specified number of days. Useful for identifying forgotten or blocked work.`,
 		Example: `  # Find items not updated in 30 days (default)
-  {{.Name}}-cli stale
+  {{.Name}}-pp-cli stale
 
   # Find items not updated in 14 days
-  {{.Name}}-cli stale --days 14
+  {{.Name}}-pp-cli stale --days 14
 
   # Filter by team
-  {{.Name}}-cli stale --days 7 --team backend
+  {{.Name}}-pp-cli stale --days 7 --team backend
 
   # Output as JSON
-  {{.Name}}-cli stale --days 30 --json`,
+  {{.Name}}-pp-cli stale --days 30 --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if dbPath == "" {
 				home, _ := os.UserHomeDir()
-				dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+				dbPath = filepath.Join(home, ".config", "{{.Name}}-pp-cli", "store.db")
 			}
 
 			db, err := store.Open(dbPath)
 			if err != nil {
-				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+				return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-pp-cli sync' first.", err)
 			}
 			defer db.Close()
 
@@ -145,7 +145,7 @@ the specified number of days. Useful for identifying forgotten or blocked work.`
 
 	cmd.Flags().IntVar(&days, "days", 30, "Number of days without update to consider stale")
 	cmd.Flags().StringVar(&team, "team", "", "Filter by team identifier")
-	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-pp-cli/store.db)")
 
 	return cmd
 }
diff --git a/internal/generator/validate.go b/internal/generator/validate.go
index 5f8281e6..be9bb74b 100644
--- a/internal/generator/validate.go
+++ b/internal/generator/validate.go
@@ -13,6 +13,7 @@ import (
 	"time"
 
 	"github.com/mvanhorn/cli-printing-press/internal/artifacts"
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 )
 
 type validationGate struct {
@@ -21,7 +22,7 @@ type validationGate struct {
 }
 
 func (g *Generator) Validate() error {
-	binPath := filepath.Join(g.OutputDir, g.Spec.Name+"-cli-validation")
+	binPath := filepath.Join(g.OutputDir, naming.ValidationBinary(g.Spec.Name))
 	if err := artifacts.CleanupGeneratedCLI(g.OutputDir, artifacts.CleanupOptions{
 		RemoveValidationBinaries: true,
 		RemoveRecursiveCopies:    true,
@@ -62,24 +63,24 @@ func (g *Generator) Validate() error {
 		{
 			name: "build runnable binary",
 			run: func() error {
-				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "build", "-o", binPath, "./cmd/"+g.Spec.Name+"-cli")
+				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "build", "-o", binPath, "./cmd/"+naming.CLI(g.Spec.Name))
 				return err
 			},
 		},
 		{
-			name: g.Spec.Name + "-cli --help",
+			name: naming.CLI(g.Spec.Name) + " --help",
 			run: func() error {
 				return validateCommandOutput(g.OutputDir, 15*time.Second, binPath, "--help")
 			},
 		},
 		{
-			name: g.Spec.Name + "-cli version",
+			name: naming.CLI(g.Spec.Name) + " version",
 			run: func() error {
 				return validateCommandOutput(g.OutputDir, 15*time.Second, binPath, "version")
 			},
 		},
 		{
-			name: g.Spec.Name + "-cli doctor",
+			name: naming.CLI(g.Spec.Name) + " doctor",
 			run: func() error {
 				return validateCommandOutput(g.OutputDir, 15*time.Second, binPath, "doctor")
 			},
diff --git a/internal/graphql/parser.go b/internal/graphql/parser.go
index 0f9b5b46..74780269 100644
--- a/internal/graphql/parser.go
+++ b/internal/graphql/parser.go
@@ -86,7 +86,7 @@ func parseSDLContent(source, raw string) (*spec.APISpec, error) {
 		Auth:        auth,
 		Config: spec.ConfigSpec{
 			Format: "toml",
-			Path:   fmt.Sprintf("~/.config/%s-cli/config.toml", name),
+			Path:   fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
 		},
 		Resources: map[string]spec.Resource{},
 		Types:     map[string]spec.TypeDef{},
diff --git a/internal/llmpolish/vision_test.go b/internal/llmpolish/vision_test.go
index 90940dee..54804757 100644
--- a/internal/llmpolish/vision_test.go
+++ b/internal/llmpolish/vision_test.go
@@ -23,7 +23,7 @@ func TestSynthesizeVisionReturnsNilWhenLLMUnavailable(t *testing.T) {
 }
 
 func TestParseVisionCustomization(t *testing.T) {
-	raw := `{"resource_priority":["messages","members","channels"],"fts_fields":{"messages":["content"],"members":["username","bio"]},"workflow_names":{"archive":"archive","audit":"audit-log"},"example_overrides":{"sync":"discord-cli sync --guild 1234567890"},"desc_overrides":{"sync":"Sync guild messages to local SQLite for offline search"},"sync_hints":{"messages":{"direction":"newest_first","batch_size":100,"priority":1}}}`
+	raw := `{"resource_priority":["messages","members","channels"],"fts_fields":{"messages":["content"],"members":["username","bio"]},"workflow_names":{"archive":"archive","audit":"audit-log"},"example_overrides":{"sync":"discord-pp-cli sync --guild 1234567890"},"desc_overrides":{"sync":"Sync guild messages to local SQLite for offline search"},"sync_hints":{"messages":{"direction":"newest_first","batch_size":100,"priority":1}}}`
 
 	var vc VisionCustomization
 	err := json.Unmarshal([]byte(raw), &vc)
diff --git a/internal/naming/naming.go b/internal/naming/naming.go
new file mode 100644
index 00000000..0e973078
--- /dev/null
+++ b/internal/naming/naming.go
@@ -0,0 +1,60 @@
+package naming
+
+import "strings"
+
+const (
+	CurrentCLISuffix = "-pp-cli"
+	LegacyCLISuffix  = "-cli"
+)
+
+func CLI(name string) string {
+	return name + CurrentCLISuffix
+}
+
+func LegacyCLI(name string) string {
+	return name + LegacyCLISuffix
+}
+
+func ValidationBinary(name string) string {
+	return CLI(name) + "-validation"
+}
+
+func DogfoodBinary(name string) string {
+	return CLI(name) + "-dogfood"
+}
+
+func IsCLIDirName(name string) bool {
+	trimmed := trimNumericRunSuffix(name)
+	return strings.HasSuffix(trimmed, CurrentCLISuffix) || strings.HasSuffix(trimmed, LegacyCLISuffix)
+}
+
+func TrimCLISuffix(name string) string {
+	name = trimNumericRunSuffix(name)
+
+	switch {
+	case strings.HasSuffix(name, CurrentCLISuffix):
+		return strings.TrimSuffix(name, CurrentCLISuffix)
+	case strings.HasSuffix(name, LegacyCLISuffix):
+		return strings.TrimSuffix(name, LegacyCLISuffix)
+	default:
+		return name
+	}
+}
+
+func trimNumericRunSuffix(name string) string {
+	idx := strings.LastIndex(name, "-")
+	if idx == -1 {
+		return name
+	}
+
+	suffix := name[idx+1:]
+	if suffix == "" {
+		return name
+	}
+	for _, r := range suffix {
+		if r < '0' || r > '9' {
+			return name
+		}
+	}
+	return name[:idx]
+}
diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go
new file mode 100644
index 00000000..6b81052f
--- /dev/null
+++ b/internal/naming/naming_test.go
@@ -0,0 +1,28 @@
+package naming
+
+import "testing"
+
+func TestTrimCLISuffix(t *testing.T) {
+	tests := map[string]string{
+		"notion-pp-cli":   "notion",
+		"notion-pp-cli-2": "notion",
+		"legacy-cli":      "legacy",
+		"legacy-cli-4":    "legacy",
+		"plain":           "plain",
+	}
+
+	for input, want := range tests {
+		if got := TrimCLISuffix(input); got != want {
+			t.Fatalf("TrimCLISuffix(%q) = %q, want %q", input, got, want)
+		}
+	}
+}
+
+func TestIsCLIDirName(t *testing.T) {
+	if !IsCLIDirName("stripe-pp-cli-3") {
+		t.Fatal("expected suffixed pp-cli directory to be recognized")
+	}
+	if IsCLIDirName("stripe-pp-mcp") {
+		t.Fatal("mcp directories must not be treated as cli directories")
+	}
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index d939941f..64b3f106 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -199,7 +199,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 		Auth:        mapAuth(doc, name),
 		Config: spec.ConfigSpec{
 			Format: "toml",
-			Path:   fmt.Sprintf("~/.config/%s-cli/config.toml", name),
+			Path:   fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
 		},
 		Resources: map[string]spec.Resource{},
 		Types:     map[string]spec.TypeDef{},
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index e1f5ada2..ad4847c3 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/mvanhorn/cli-printing-press/internal/generator"
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -181,15 +182,15 @@ func TestGenerateFromOpenAPICompiles(t *testing.T) {
 			parsed, err := Parse(data)
 			require.NoError(t, err)
 
-			outputDir := filepath.Join(t.TempDir(), parsed.Name+"-cli")
+			outputDir := filepath.Join(t.TempDir(), naming.CLI(parsed.Name))
 			gen := generator.New(parsed, outputDir)
 			require.NoError(t, gen.Generate())
 
 			runGo(t, outputDir, "mod", "tidy")
 			runGo(t, outputDir, "build", "./...")
 
-			binaryPath := filepath.Join(outputDir, parsed.Name+"-cli")
-			runGo(t, outputDir, "build", "-o", binaryPath, "./cmd/"+parsed.Name+"-cli")
+			binaryPath := filepath.Join(outputDir, naming.CLI(parsed.Name))
+			runGo(t, outputDir, "build", "-o", binaryPath, "./cmd/"+naming.CLI(parsed.Name))
 
 			info, err := os.Stat(binaryPath)
 			require.NoError(t, err)
diff --git a/internal/pipeline/comparative.go b/internal/pipeline/comparative.go
index 8bf67c39..5cae53e6 100644
--- a/internal/pipeline/comparative.go
+++ b/internal/pipeline/comparative.go
@@ -10,29 +10,29 @@ import (
 
 // ComparativeResult holds the output of the comparative analysis phase.
 type ComparativeResult struct {
-	OurScore       int                `json:"our_score"`
-	Alternatives   []AltScore         `json:"alternatives"`
-	Gaps           []string           `json:"gaps"`
-	Advantages     []string           `json:"advantages"`
-	Recommendation string             `json:"recommendation"` // "ship", "ship-with-gaps", "hold"
+	OurScore       int        `json:"our_score"`
+	Alternatives   []AltScore `json:"alternatives"`
+	Gaps           []string   `json:"gaps"`
+	Advantages     []string   `json:"advantages"`
+	Recommendation string     `json:"recommendation"` // "ship", "ship-with-gaps", "hold"
 }
 
 // AltScore holds a scored alternative.
 type AltScore struct {
-	Name           string `json:"name"`
-	Breadth        int    `json:"breadth"`
-	InstallFriction int   `json:"install_friction"`
-	AuthUX         int    `json:"auth_ux"`
-	OutputFormats  int    `json:"output_formats"`
-	AgentFriendly  int    `json:"agent_friendly"`
-	Freshness      int    `json:"freshness"`
-	Total          int    `json:"total"`
+	Name            string `json:"name"`
+	Breadth         int    `json:"breadth"`
+	InstallFriction int    `json:"install_friction"`
+	AuthUX          int    `json:"auth_ux"`
+	OutputFormats   int    `json:"output_formats"`
+	AgentFriendly   int    `json:"agent_friendly"`
+	Freshness       int    `json:"freshness"`
+	Total           int    `json:"total"`
 }
 
 // RunComparative reads research and dogfood results, scores everything,
 // and writes comparative-analysis.md.
 func RunComparative(pipelineDir string, ourCommandCount int) (*ComparativeResult, error) {
-	research, err := LoadResearch(pipelineDir)
+	research, err := loadResearchForArtifactsDir(pipelineDir)
 	if err != nil {
 		// Research is optional - produce a minimal report
 		research = &ResearchResult{Alternatives: nil}
@@ -177,6 +177,10 @@ func compareGapsAndAdvantages(result *ComparativeResult) (gaps, advantages []str
 }
 
 func writeComparativeReport(result *ComparativeResult, research *ResearchResult, pipelineDir string) error {
+	if err := os.MkdirAll(pipelineDir, 0o755); err != nil {
+		return err
+	}
+
 	var b strings.Builder
 
 	b.WriteString("# Comparative Analysis\n\n")
diff --git a/internal/pipeline/comparative_test.go b/internal/pipeline/comparative_test.go
index 16bd0307..7371f6c0 100644
--- a/internal/pipeline/comparative_test.go
+++ b/internal/pipeline/comparative_test.go
@@ -1,9 +1,13 @@
 package pipeline
 
 import (
+	"encoding/json"
+	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestScoreAlternative(t *testing.T) {
@@ -55,3 +59,23 @@ func TestRunComparative(t *testing.T) {
 	assert.Equal(t, 95, result.OurScore)
 	assert.Equal(t, "ship", result.Recommendation)
 }
+
+func TestRunComparativeLoadsResearchFromSiblingResearchDir(t *testing.T) {
+	runRoot := t.TempDir()
+	pipelineDir := filepath.Join(runRoot, "pipeline")
+	researchDir := filepath.Join(runRoot, "research")
+
+	require.NoError(t, os.MkdirAll(researchDir, 0o755))
+	research := &ResearchResult{
+		Alternatives: []Alternative{
+			{Name: "competitor/sample-cli", InstallMethod: "binary", CommandCount: 12},
+		},
+	}
+	data, err := json.MarshalIndent(research, "", "  ")
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(researchDir, "research.json"), data, 0o644))
+
+	result, err := RunComparative(pipelineDir, 10)
+	require.NoError(t, err)
+	assert.Len(t, result.Alternatives, 1)
+}
diff --git a/internal/pipeline/contracts_test.go b/internal/pipeline/contracts_test.go
new file mode 100644
index 00000000..25419b11
--- /dev/null
+++ b/internal/pipeline/contracts_test.go
@@ -0,0 +1,169 @@
+package pipeline
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/generator"
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
+	"github.com/mvanhorn/cli-printing-press/internal/openapi"
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGeneratedOutputContractSupportsClaimedDirs(t *testing.T) {
+	setPressTestEnv(t)
+
+	apiSpec := loadContractPetstoreSpec(t)
+	baseDir := DefaultOutputDir(apiSpec.Name)
+
+	firstDir, err := ClaimOutputDir(baseDir)
+	require.NoError(t, err)
+	secondDir, err := ClaimOutputDir(baseDir)
+	require.NoError(t, err)
+
+	assert.Equal(t, baseDir, firstDir)
+	assert.Equal(t, baseDir+"-2", secondDir)
+
+	for _, dir := range []string{firstDir, secondDir} {
+		gen := generator.New(apiSpec, dir)
+		require.NoError(t, gen.Generate())
+		runGoContractCommand(t, dir, "mod", "tidy")
+		assert.DirExists(t, filepath.Join(dir, "cmd", naming.CLI(apiSpec.Name)))
+	}
+
+	report, err := RunVerify(VerifyConfig{Dir: secondDir})
+	require.NoError(t, err)
+	assert.NotEqual(t, "FAIL", report.Verdict)
+	assert.Greater(t, report.Total, 0)
+	assert.FileExists(t, report.Binary)
+}
+
+func TestSkillSetupBlocksMatchWorkspaceContract(t *testing.T) {
+	tests := []struct {
+		path               string
+		expectsManuscripts bool
+	}{
+		{path: filepath.Join("..", "..", "skills", "printing-press", "SKILL.md"), expectsManuscripts: true},
+		{path: filepath.Join("..", "..", "skills", "printing-press-score", "SKILL.md"), expectsManuscripts: true},
+		{path: filepath.Join("..", "..", "skills", "printing-press-catalog", "SKILL.md"), expectsManuscripts: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(filepath.Base(filepath.Dir(tt.path)), func(t *testing.T) {
+			full := readContractFile(t, tt.path)
+			block := extractContractBlock(t, full)
+
+			assert.Contains(t, block, `REPO_ROOT="$(git rev-parse --show-toplevel)"`)
+			assert.Contains(t, block, `PRESS_BASE="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"`)
+			assert.Contains(t, block, `PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-8)"`)
+			assert.Contains(t, block, `PRESS_HOME="$HOME/printing-press"`)
+			assert.Contains(t, block, `PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"`)
+			assert.Contains(t, block, `PRESS_LIBRARY="$PRESS_HOME/library"`)
+			assert.NotContains(t, full, "~/cli-printing-press")
+
+			if tt.expectsManuscripts {
+				assert.Contains(t, block, `PRESS_MANUSCRIPTS="$PRESS_HOME/manuscripts"`)
+			}
+		})
+	}
+}
+
+func TestPrintingPressSkillUsesRunRootStateFile(t *testing.T) {
+	skill := readContractFile(t, filepath.Join("..", "..", "skills", "printing-press", "SKILL.md"))
+
+	assert.Contains(t, skill, `STATE_FILE="$API_RUN_DIR/state.json"`)
+	assert.NotContains(t, skill, `STATE_FILE="$PIPELINE_DIR/state.json"`)
+	assert.Contains(t, skill, `"working_dir": "<absolute cli dir>"`)
+}
+
+func TestPrintingPressSkillExamplesUseCurrentCLINaming(t *testing.T) {
+	skill := readContractFile(t, filepath.Join("..", "..", "skills", "printing-press", "SKILL.md"))
+
+	assert.Contains(t, skill, "/printing-press emboss ./discord-pp-cli")
+	assert.NotContains(t, skill, "/printing-press emboss ./discord-cli")
+	assert.Contains(t, skill, "discord-pp-cli/internal/store/store.go")
+	assert.NotContains(t, skill, "discord-cli/internal/store/store.go")
+	assert.Contains(t, skill, "linear-pp-cli stale --days 30 --team ENG")
+	assert.NotContains(t, skill, "linear-cli stale --days 30 --team ENG")
+	assert.Contains(t, skill, "github.com/mvanhorn/discord-pp-cli")
+	assert.NotContains(t, skill, "github.com/mvanhorn/discord-cli")
+}
+
+func TestREADMEOutputContract(t *testing.T) {
+	readme := readContractFile(t, filepath.Join("..", "..", "README.md"))
+
+	assert.Contains(t, readme, "~/printing-press/.runstate/<scope>/runs/<run-id>/working/<api>-pp-cli")
+	assert.Contains(t, readme, "~/printing-press/library/<api>-pp-cli")
+	assert.Contains(t, readme, "~/printing-press/manuscripts/<api>/<run-id>/")
+	assert.Contains(t, readme, "`research/`, `proofs/`, and `pipeline/`")
+	assert.NotContains(t, readme, "cd ~/cli-printing-press")
+}
+
+func TestGenerateHelpMentionsPublishedLibraryDefault(t *testing.T) {
+	root := readContractFile(t, filepath.Join("..", "..", "internal", "cli", "root.go"))
+
+	assert.Contains(t, root, "Output directory (default: ~/printing-press/library/<name>-pp-cli)")
+	assert.Contains(t, root, "Overwrite the base output directory (e.g. ~/printing-press/library/notion-pp-cli)")
+	assert.NotContains(t, root, "~/printing-press/workspaces/<scope>/library")
+}
+
+func TestOnboardingReflectsCurrentPipelinePhaseCount(t *testing.T) {
+	onboarding := readContractFile(t, filepath.Join("..", "..", "ONBOARDING.md"))
+
+	assert.Contains(t, onboarding, "9-phase pipeline")
+	assert.Contains(t, onboarding, "agent-readiness")
+	assert.Contains(t, onboarding, "~/printing-press/.runstate/<scope>/runs/<run-id>/")
+	assert.Contains(t, onboarding, "~/printing-press/library/<name>-pp-cli/")
+	assert.Contains(t, onboarding, "~/printing-press/manuscripts/<api>/<run-id>/")
+	assert.NotContains(t, onboarding, "8-phase pipeline")
+}
+
+func loadContractPetstoreSpec(t *testing.T) *spec.APISpec {
+	t.Helper()
+
+	data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))
+	require.NoError(t, err)
+
+	apiSpec, err := openapi.Parse(data)
+	require.NoError(t, err)
+	return apiSpec
+}
+
+func readContractFile(t *testing.T, path string) string {
+	t.Helper()
+
+	data, err := os.ReadFile(path)
+	require.NoError(t, err)
+	return string(data)
+}
+
+func extractContractBlock(t *testing.T, content string) string {
+	t.Helper()
+
+	const start = "<!-- PRESS_SETUP_CONTRACT_START -->"
+	const end = "<!-- PRESS_SETUP_CONTRACT_END -->"
+
+	startIdx := strings.Index(content, start)
+	require.NotEqual(t, -1, startIdx, "missing contract start marker")
+	startIdx += len(start)
+
+	endIdx := strings.Index(content[startIdx:], end)
+	require.NotEqual(t, -1, endIdx, "missing contract end marker")
+
+	return content[startIdx : startIdx+endIdx]
+}
+
+func runGoContractCommand(t *testing.T, dir string, args ...string) {
+	t.Helper()
+
+	cmd := exec.Command("go", args...)
+	cmd.Dir = dir
+	cmd.Env = append(os.Environ(), "GOCACHE="+filepath.Join(dir, ".cache", "go-build"))
+	output, err := cmd.CombinedOutput()
+	require.NoError(t, err, string(output))
+}
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 5584f220..ca28e653 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -12,6 +12,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	openapiparser "github.com/mvanhorn/cli-printing-press/internal/openapi"
 	apispec "github.com/mvanhorn/cli-printing-press/internal/spec"
 )
@@ -682,7 +683,7 @@ func findCLIName(dir string) string {
 		return ""
 	}
 	for _, entry := range entries {
-		if entry.IsDir() && strings.HasSuffix(entry.Name(), "-cli") {
+		if entry.IsDir() && naming.IsCLIDirName(entry.Name()) {
 			return entry.Name()
 		}
 	}
diff --git a/internal/pipeline/fullrun.go b/internal/pipeline/fullrun.go
index a9f0129f..80edab38 100644
--- a/internal/pipeline/fullrun.go
+++ b/internal/pipeline/fullrun.go
@@ -1,7 +1,10 @@
 package pipeline
 
 import (
+	"encoding/json"
 	"fmt"
+	"io"
+	"net/http"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -9,6 +12,8 @@ import (
 	"time"
 
 	"github.com/mvanhorn/cli-printing-press/internal/llmpolish"
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
+	"gopkg.in/yaml.v3"
 )
 
 // FullRunResult holds everything the press produced for one API.
@@ -59,17 +64,56 @@ type FullRunResult struct {
 // returns a result summarizing every phase.
 func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary string) *FullRunResult {
 	start := time.Now()
+	runID, err := newRunID(start)
+	if err != nil {
+		return &FullRunResult{
+			APIName: apiName,
+			Level:   level,
+			Errors:  []string{fmt.Sprintf("run id: %v", err)},
+		}
+	}
+	workingDir := WorkingCLIDir(apiName, runID)
+	state := NewStateWithRun(apiName, workingDir, runID, WorkspaceScope())
+	state.SpecURL = specURL
+	if specFlag == "--spec" {
+		state.SpecPath = specURL
+	}
+
 	result := &FullRunResult{
 		APIName:   apiName,
 		Level:     level,
-		OutputDir: outputDir,
+		OutputDir: state.EffectiveWorkingDir(),
 	}
 
-	pipelineDir := PipelineDir(apiName)
-	os.MkdirAll(pipelineDir, 0o755)
+	pipelineDir := state.PipelineDir()
+	researchDir := state.ResearchDir()
+	proofsDir := state.ProofsDir()
+	if err := os.MkdirAll(pipelineDir, 0o755); err != nil {
+		result.Errors = append(result.Errors, fmt.Sprintf("pipeline dir: %v", err))
+		result.Duration = time.Since(start)
+		return result
+	}
+	if err := os.MkdirAll(researchDir, 0o755); err != nil {
+		result.Errors = append(result.Errors, fmt.Sprintf("research dir: %v", err))
+		result.Duration = time.Since(start)
+		return result
+	}
+	if err := os.MkdirAll(proofsDir, 0o755); err != nil {
+		result.Errors = append(result.Errors, fmt.Sprintf("proofs dir: %v", err))
+		result.Duration = time.Since(start)
+		return result
+	}
+	if err := state.Save(); err != nil {
+		result.Errors = append(result.Errors, fmt.Sprintf("state save: %v", err))
+		result.Duration = time.Since(start)
+		return result
+	}
+	if err := WriteRunManifest(state); err != nil {
+		result.Errors = append(result.Errors, fmt.Sprintf("manifest: %v", err))
+	}
 
 	// Step 1: Research
-	research, err := RunResearch(apiName, "catalog", pipelineDir)
+	research, err := RunResearch(apiName, "catalog", researchDir)
 	if err != nil {
 		result.ResearchError = err.Error()
 		result.Errors = append(result.Errors, fmt.Sprintf("research: %v", err))
@@ -81,9 +125,9 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
 	qualitySpecPath := fullRunQualitySpecPath(specFlag, specURL)
 	var genArgs []string
 	if specFlag == "--docs" {
-		genArgs = []string{"generate", "--docs", specURL, "--name", apiName, "--output", outputDir, "--force"}
+		genArgs = []string{"generate", "--docs", specURL, "--name", apiName, "--output", workingDir, "--force"}
 	} else {
-		genArgs = []string{"generate", "--spec", specURL, "--output", outputDir, "--force", "--lenient"}
+		genArgs = []string{"generate", "--spec", specURL, "--output", workingDir, "--force", "--lenient"}
 	}
 
 	cmd := exec.Command(pressBinary, genArgs...)
@@ -107,10 +151,14 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
 		return result
 	}
 
+	// Step 2.1: Copy spec into output dir for standalone scoring
+	if err := copySpecToOutput(specFlag, specURL, workingDir); err != nil {
+		result.Errors = append(result.Errors, fmt.Sprintf("spec copy: %v", err))
+	}
 	// Step 2.5: LLM Polish
 	polishResult, polishErr := llmpolish.Polish(llmpolish.PolishRequest{
 		APIName:   apiName,
-		OutputDir: outputDir,
+		OutputDir: workingDir,
 	})
 	if polishErr != nil {
 		result.Errors = append(result.Errors, "polish: "+polishErr.Error())
@@ -119,7 +167,7 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
 	}
 
 	// Step 3: Count commands and resources
-	resources := listResources(outputDir)
+	resources := listResources(workingDir)
 	result.ResourceCount = len(resources)
 	result.CommandCount = len(resources) + 4 // +4 for root, help, version, doctor
 
@@ -131,25 +179,26 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
 	}
 
 	// Step 5: Dogfood
-	cliBinaryPath := filepath.Join(outputDir, apiName+"-cli")
+	cliBinaryPath := filepath.Join(workingDir, naming.CLI(apiName))
 	buildCmd := exec.Command("go", "build", "-o", cliBinaryPath, "./cmd/...")
-	buildCmd.Dir = outputDir
+	buildCmd.Dir = workingDir
 	if buildErr := buildCmd.Run(); buildErr != nil {
 		result.DogfoodError = fmt.Sprintf("build failed: %v", buildErr)
 		result.Errors = append(result.Errors, fmt.Sprintf("dogfood build: %v", buildErr))
 	} else {
-		dogfood, dogErr := RunDogfood(outputDir, qualitySpecPath)
+		defer os.Remove(cliBinaryPath)
+		dogfood, dogErr := RunDogfood(workingDir, qualitySpecPath)
 		if dogErr != nil {
 			result.DogfoodError = dogErr.Error()
 			result.Errors = append(result.Errors, fmt.Sprintf("dogfood: %v", dogErr))
-		} else if err := writeDogfoodResults(dogfood, pipelineDir); err != nil {
+		} else if err := writeDogfoodResults(dogfood, proofsDir); err != nil {
 			result.Errors = append(result.Errors, fmt.Sprintf("dogfood write: %v", err))
 		}
 		result.Dogfood = dogfood
 	}
 
 	// Step 5.5: Proof of Behavior Verification
-	verReport, verErr := RunVerification(outputDir, qualitySpecPath)
+	verReport, verErr := RunVerification(workingDir, qualitySpecPath)
 	if verErr != nil {
 		result.VerificationError = verErr.Error()
 		result.Errors = append(result.Errors, fmt.Sprintf("verification: %v", verErr))
@@ -158,14 +207,14 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
 
 		// Auto-remediate if WARN or FAIL
 		if verReport.Verdict != "PASS" {
-			remResult, remErr := Remediate(outputDir, verReport)
+			remResult, remErr := Remediate(workingDir, verReport)
 			if remErr != nil {
 				result.Errors = append(result.Errors, fmt.Sprintf("remediation: %v", remErr))
 			} else {
 				result.Remediation = remResult
 
 				// Re-verify after remediation
-				reVerReport, reVerErr := RunVerification(outputDir, qualitySpecPath)
+				reVerReport, reVerErr := RunVerification(workingDir, qualitySpecPath)
 				if reVerErr == nil {
 					result.Verification = reVerReport
 				}
@@ -174,7 +223,7 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
 	}
 
 	// Step 6: Scorecard
-	scorecard, scErr := RunScorecard(outputDir, pipelineDir, qualitySpecPath, nil)
+	scorecard, scErr := RunScorecard(workingDir, proofsDir, qualitySpecPath, nil)
 	if scErr != nil {
 		result.ScorecardError = scErr.Error()
 		result.Errors = append(result.Errors, fmt.Sprintf("scorecard: %v", scErr))
@@ -190,6 +239,16 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
 		result.FixPlans = plans
 	}
 
+	publishedDir, publishErr := PublishWorkingCLI(state, outputDir)
+	if publishErr != nil {
+		result.Errors = append(result.Errors, fmt.Sprintf("publish: %v", publishErr))
+	} else {
+		result.OutputDir = publishedDir
+	}
+	if _, archiveErr := ArchiveRunArtifacts(state); archiveErr != nil {
+		result.Errors = append(result.Errors, fmt.Sprintf("archive: %v", archiveErr))
+	}
+
 	result.Duration = time.Since(start)
 	return result
 }
@@ -201,6 +260,57 @@ func fullRunQualitySpecPath(specFlag, specURL string) string {
 	return ""
 }
 
+// copySpecToOutput reads the spec from a local path or remote URL, converts
+// YAML to JSON if needed, and writes it as <outputDir>/spec.json.
+// Only runs when specFlag is "--spec". Errors are non-fatal.
+func copySpecToOutput(specFlag, specURL, outputDir string) error {
+	if specFlag != "--spec" || specURL == "" {
+		return nil
+	}
+	data, err := readSpecBytes(specURL)
+	if err != nil {
+		return fmt.Errorf("reading spec %s: %w", specURL, err)
+	}
+	data, err = ensureJSON(data)
+	if err != nil {
+		return fmt.Errorf("converting spec to JSON: %w", err)
+	}
+	dst := filepath.Join(outputDir, "spec.json")
+	if err := os.WriteFile(dst, data, 0o644); err != nil {
+		return fmt.Errorf("writing %s: %w", dst, err)
+	}
+	return nil
+}
+
+// readSpecBytes fetches spec content from a URL or reads it from a local file.
+func readSpecBytes(specURL string) ([]byte, error) {
+	if strings.HasPrefix(specURL, "http://") || strings.HasPrefix(specURL, "https://") {
+		resp, err := http.Get(specURL) //nolint:gosec // spec URLs are operator-provided
+		if err != nil {
+			return nil, err
+		}
+		defer resp.Body.Close()
+		if resp.StatusCode != http.StatusOK {
+			return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, specURL)
+		}
+		return io.ReadAll(resp.Body)
+	}
+	return os.ReadFile(specURL)
+}
+
+// ensureJSON converts YAML content to JSON. If the input is already valid
+// JSON, it is returned as-is.
+func ensureJSON(data []byte) ([]byte, error) {
+	if json.Valid(data) {
+		return data, nil
+	}
+	var obj interface{}
+	if err := yaml.Unmarshal(data, &obj); err != nil {
+		return nil, fmt.Errorf("not valid JSON or YAML: %w", err)
+	}
+	return json.Marshal(obj)
+}
+
 // listResources returns the resource names found in the generated CLI's
 // internal/cli directory, excluding infrastructure files.
 func listResources(outputDir string) []string {
diff --git a/internal/pipeline/paths.go b/internal/pipeline/paths.go
new file mode 100644
index 00000000..0d35a5dd
--- /dev/null
+++ b/internal/pipeline/paths.go
@@ -0,0 +1,192 @@
+package pipeline
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
+)
+
+func PressHome() string {
+	if root := os.Getenv("PRINTING_PRESS_HOME"); root != "" {
+		return root
+	}
+
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return filepath.Join(".", "printing-press")
+	}
+	return filepath.Join(home, "printing-press")
+}
+
+func WorkspaceScope() string {
+	if scope := os.Getenv("PRINTING_PRESS_SCOPE"); scope != "" {
+		return scope
+	}
+
+	root := repoRoot()
+	base := sanitizeScopeToken(filepath.Base(root))
+	if base == "" || base == "." {
+		base = "workspace"
+	}
+
+	sum := sha256.Sum256([]byte(root))
+	return base + "-" + hex.EncodeToString(sum[:4])
+}
+
+func WorkspaceRoot() string {
+	return filepath.Join(PressHome(), "workspaces", WorkspaceScope())
+}
+
+func RunstateRoot() string {
+	return filepath.Join(PressHome(), ".runstate")
+}
+
+func ScopedRunstateRoot() string {
+	return filepath.Join(RunstateRoot(), WorkspaceScope())
+}
+
+func CurrentRunDir() string {
+	return filepath.Join(ScopedRunstateRoot(), "current")
+}
+
+func CurrentRunPointerPath(apiName string) string {
+	return filepath.Join(CurrentRunDir(), apiName+".json")
+}
+
+func RunRoot(runID string) string {
+	return filepath.Join(ScopedRunstateRoot(), "runs", runID)
+}
+
+func RunStatePath(runID string) string {
+	return filepath.Join(RunRoot(runID), "state.json")
+}
+
+func RunSpecPath(runID string) string {
+	return filepath.Join(RunRoot(runID), "spec.json")
+}
+
+func RunManifestPath(runID string) string {
+	return filepath.Join(RunRoot(runID), "manifest.json")
+}
+
+func WorkingRoot(runID string) string {
+	return filepath.Join(RunRoot(runID), "working")
+}
+
+func WorkingCLIDir(apiName, runID string) string {
+	return filepath.Join(WorkingRoot(runID), naming.CLI(apiName))
+}
+
+func RunResearchDir(runID string) string {
+	return filepath.Join(RunRoot(runID), "research")
+}
+
+func RunProofsDir(runID string) string {
+	return filepath.Join(RunRoot(runID), "proofs")
+}
+
+func RunPipelineDir(runID string) string {
+	return filepath.Join(RunRoot(runID), "pipeline")
+}
+
+func PublishedLibraryRoot() string {
+	return filepath.Join(PressHome(), "library")
+}
+
+func PublishedManuscriptsRoot() string {
+	return filepath.Join(PressHome(), "manuscripts")
+}
+
+func ArchivedManuscriptDir(apiName, runID string) string {
+	return filepath.Join(PublishedManuscriptsRoot(), apiName, runID)
+}
+
+func ArchivedResearchDir(apiName, runID string) string {
+	return filepath.Join(ArchivedManuscriptDir(apiName, runID), "research")
+}
+
+func ArchivedProofsDir(apiName, runID string) string {
+	return filepath.Join(ArchivedManuscriptDir(apiName, runID), "proofs")
+}
+
+func ArchivedPipelineDir(apiName, runID string) string {
+	return filepath.Join(ArchivedManuscriptDir(apiName, runID), "pipeline")
+}
+
+func ArchivedManifestPath(apiName, runID string) string {
+	return filepath.Join(ArchivedManuscriptDir(apiName, runID), "manifest.json")
+}
+
+func LegacyWorkspaceRoot() string {
+	return filepath.Join(PressHome(), "workspaces", WorkspaceScope())
+}
+
+func LegacyWorkspaceLibraryRoot() string {
+	return filepath.Join(LegacyWorkspaceRoot(), "library")
+}
+
+func LegacyWorkspaceManuscriptsRoot() string {
+	return filepath.Join(LegacyWorkspaceRoot(), "manuscripts")
+}
+
+func LibraryRoot() string {
+	return PublishedLibraryRoot()
+}
+
+func ManuscriptsRoot() string {
+	return PublishedManuscriptsRoot()
+}
+
+func ResearchDir(apiName string) string {
+	return filepath.Join(LegacyWorkspaceManuscriptsRoot(), apiName, "research")
+}
+
+func ProofsDir(apiName string) string {
+	return filepath.Join(LegacyWorkspaceManuscriptsRoot(), apiName, "proofs")
+}
+
+func repoRoot() string {
+	if root := os.Getenv("PRINTING_PRESS_REPO_ROOT"); root != "" {
+		return root
+	}
+
+	cwd, err := os.Getwd()
+	if err != nil {
+		return "."
+	}
+
+	cur := cwd
+	for {
+		if _, err := os.Stat(filepath.Join(cur, ".git")); err == nil {
+			return cur
+		}
+		parent := filepath.Dir(cur)
+		if parent == cur {
+			return cwd
+		}
+		cur = parent
+	}
+}
+
+func sanitizeScopeToken(value string) string {
+	var b strings.Builder
+	for _, r := range value {
+		switch {
+		case r >= 'a' && r <= 'z':
+			b.WriteRune(r)
+		case r >= 'A' && r <= 'Z':
+			b.WriteRune(r + ('a' - 'A'))
+		case r >= '0' && r <= '9':
+			b.WriteRune(r)
+		case r == '-' || r == '_':
+			b.WriteRune(r)
+		default:
+			b.WriteRune('-')
+		}
+	}
+	return strings.Trim(b.String(), "-")
+}
diff --git a/internal/pipeline/paths_test.go b/internal/pipeline/paths_test.go
new file mode 100644
index 00000000..3f17cbe9
--- /dev/null
+++ b/internal/pipeline/paths_test.go
@@ -0,0 +1,35 @@
+package pipeline
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestWorkspacePathsUseScopedPressHome(t *testing.T) {
+	home := t.TempDir()
+	t.Setenv("PRINTING_PRESS_HOME", home)
+	t.Setenv("PRINTING_PRESS_SCOPE", "atlanta-v1-deadbeef")
+
+	assert.Equal(t, filepath.Join(home, ".runstate"), RunstateRoot())
+	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef"), ScopedRunstateRoot())
+	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "current"), CurrentRunDir())
+	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "current", "notion.json"), CurrentRunPointerPath("notion"))
+	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "runs", "run-123"), RunRoot("run-123"))
+	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "runs", "run-123", "working", "notion-pp-cli"), WorkingCLIDir("notion", "run-123"))
+	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "runs", "run-123", "research"), RunResearchDir("run-123"))
+	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "runs", "run-123", "proofs"), RunProofsDir("run-123"))
+	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "runs", "run-123", "pipeline"), RunPipelineDir("run-123"))
+	assert.Equal(t, filepath.Join(home, "library"), PublishedLibraryRoot())
+	assert.Equal(t, filepath.Join(home, "manuscripts", "notion", "run-123"), ArchivedManuscriptDir("notion", "run-123"))
+	assert.Equal(t, filepath.Join(home, "workspaces", "atlanta-v1-deadbeef", "manuscripts", "notion", "research"), ResearchDir("notion"))
+	assert.Equal(t, filepath.Join(home, "workspaces", "atlanta-v1-deadbeef", "manuscripts", "notion", "proofs"), ProofsDir("notion"))
+}
+
+func TestWorkspaceScopeSanitizesRepoBaseName(t *testing.T) {
+	t.Setenv("PRINTING_PRESS_SCOPE", "")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", "/tmp/Atlanta V1!!")
+
+	assert.Contains(t, WorkspaceScope(), "atlanta-v1")
+}
diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go
index 2b87fd8d..94badda4 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -6,12 +6,15 @@ import (
 	"os"
 	"path/filepath"
 	"strconv"
+	"time"
+
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 )
 
 // DefaultOutputDir returns the default output directory for a given API name.
 // All commands should use this when --output is not specified.
 func DefaultOutputDir(apiName string) string {
-	return filepath.Join("library", apiName+"-cli")
+	return filepath.Join(PublishedLibraryRoot(), naming.CLI(apiName))
 }
 
 // ClaimOutputDir atomically claims an output directory. If base already exists,
@@ -62,9 +65,15 @@ func Init(apiName string, opts Options) (*PipelineState, error) {
 		return LoadState(apiName)
 	}
 
+	runID, err := newRunID(time.Now())
+	if err != nil {
+		return nil, err
+	}
+	scope := WorkspaceScope()
+
 	outputDir := opts.OutputDir
 	if outputDir == "" {
-		outputDir = DefaultOutputDir(apiName)
+		outputDir = WorkingCLIDir(apiName, runID)
 	}
 
 	absOutputDir, err := filepath.Abs(outputDir)
@@ -72,9 +81,8 @@ func Init(apiName string, opts Options) (*PipelineState, error) {
 		return nil, fmt.Errorf("resolving output dir: %w", err)
 	}
 
-	pipeDir := PipelineDir(apiName)
 	if StateExists(apiName) && !opts.Force {
-		return nil, fmt.Errorf("pipeline for %q already exists at %s (use --force to overwrite or --resume to continue)", apiName, pipeDir)
+		return nil, fmt.Errorf("pipeline for %q already exists at %s (use --force to overwrite or --resume to continue)", apiName, PipelineDir(apiName))
 	}
 
 	specURL, specSource, err := DiscoverSpec(apiName)
@@ -82,9 +90,10 @@ func Init(apiName string, opts Options) (*PipelineState, error) {
 		return nil, fmt.Errorf("discovering spec: %w", err)
 	}
 
-	state := NewState(apiName, absOutputDir)
+	state := NewStateWithRun(apiName, absOutputDir, runID, scope)
 	state.SpecURL = specURL
 
+	pipeDir := state.PipelineDir()
 	if err := os.MkdirAll(pipeDir, 0o755); err != nil {
 		return nil, fmt.Errorf("creating pipeline dir: %w", err)
 	}
diff --git a/internal/pipeline/planner.go b/internal/pipeline/planner.go
index a5257d08..1af18d1b 100644
--- a/internal/pipeline/planner.go
+++ b/internal/pipeline/planner.go
@@ -19,20 +19,22 @@ type PlanContext struct {
 // all available prior phase outputs. Falls back to static seed if no
 // dynamic generation is available for that phase.
 func GenerateNextPlan(state *PipelineState, nextPhase string) (string, error) {
-	pipeDir := PipelineDir(state.APIName)
+	pipeDir := state.PipelineDir()
 	ctx := PlanContext{
 		SeedData: SeedData{
 			APIName:     state.APIName,
-			OutputDir:   state.OutputDir,
+			OutputDir:   state.EffectiveWorkingDir(),
 			SpecURL:     state.SpecURL,
 			PipelineDir: pipeDir,
 		},
 	}
 
-	// Load all available prior phase outputs (silently ignore missing ones)
-	ctx.Research, _ = LoadResearch(pipeDir)
-	ctx.Dogfood, _ = LoadDogfoodResults(pipeDir)
-	ctx.Scorecard, _ = LoadScorecard(pipeDir)
+	// Load all available prior phase outputs (silently ignore missing ones).
+	// New runstate layout stores research in research/ and audits in proofs/,
+	// with pipeline/ retained as a compatibility fallback.
+	ctx.Research, _ = loadResearchForPlanState(state)
+	ctx.Dogfood, _ = loadDogfoodForPlanState(state)
+	ctx.Scorecard, _ = loadScorecardForPlanState(state)
 	ctx.Learnings, _ = LoadLearnings()
 
 	switch nextPhase {
@@ -248,3 +250,33 @@ func writePipelineContext(b *strings.Builder, sd SeedData) {
 	b.WriteString(fmt.Sprintf("- Output directory: %s\n", sd.OutputDir))
 	b.WriteString(fmt.Sprintf("- Spec URL: %s\n\n", sd.SpecURL))
 }
+
+func loadResearchForPlanState(state *PipelineState) (*ResearchResult, error) {
+	for _, dir := range []string{state.ResearchDir(), state.PipelineDir()} {
+		research, err := LoadResearch(dir)
+		if err == nil {
+			return research, nil
+		}
+	}
+	return nil, fmt.Errorf("research not found")
+}
+
+func loadDogfoodForPlanState(state *PipelineState) (*DogfoodReport, error) {
+	for _, dir := range []string{state.ProofsDir(), state.PipelineDir(), state.EffectiveWorkingDir()} {
+		report, err := LoadDogfoodResults(dir)
+		if err == nil {
+			return report, nil
+		}
+	}
+	return nil, fmt.Errorf("dogfood results not found")
+}
+
+func loadScorecardForPlanState(state *PipelineState) (*Scorecard, error) {
+	for _, dir := range []string{state.ProofsDir(), state.PipelineDir()} {
+		scorecard, err := LoadScorecard(dir)
+		if err == nil {
+			return scorecard, nil
+		}
+	}
+	return nil, fmt.Errorf("scorecard not found")
+}
diff --git a/internal/pipeline/planner_artifacts_test.go b/internal/pipeline/planner_artifacts_test.go
new file mode 100644
index 00000000..279a41bc
--- /dev/null
+++ b/internal/pipeline/planner_artifacts_test.go
@@ -0,0 +1,50 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGenerateNextPlanLoadsArtifactsFromRunstateDirs(t *testing.T) {
+	setPressTestEnv(t)
+
+	state := NewStateWithRun("sample", filepath.Join(t.TempDir(), "sample-pp-cli"), "run-123", "test-scope")
+	require.NoError(t, state.Save())
+
+	research := &ResearchResult{
+		APIName:        "sample",
+		NoveltyScore:   8,
+		Recommendation: "proceed",
+		Alternatives: []Alternative{
+			{Name: "competitor/sample-cli"},
+		},
+	}
+	researchData, err := json.MarshalIndent(research, "", "  ")
+	require.NoError(t, err)
+	require.NoError(t, os.MkdirAll(state.ResearchDir(), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(state.ResearchDir(), "research.json"), researchData, 0o644))
+
+	scorecard := &Scorecard{
+		APIName: "sample",
+		Steinberger: SteinerScore{
+			Percentage: 72,
+		},
+		OverallGrade: "B",
+		GapReport:    []string{"example gap"},
+	}
+	require.NoError(t, writeScorecardJSON(scorecard, state.ProofsDir()))
+
+	scaffoldPlan, err := GenerateNextPlan(state, PhaseScaffold)
+	require.NoError(t, err)
+	assert.Contains(t, scaffoldPlan, "Novelty score:** 8/10 (proceed)")
+
+	comparativePlan, err := GenerateNextPlan(state, PhaseComparative)
+	require.NoError(t, err)
+	assert.Contains(t, comparativePlan, "Overall: 72% (B)")
+	assert.Contains(t, comparativePlan, "example gap")
+}
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
new file mode 100644
index 00000000..7bcc2f5a
--- /dev/null
+++ b/internal/pipeline/publish.go
@@ -0,0 +1,221 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+type RunManifest struct {
+	Version               int       `json:"version"`
+	APIName               string    `json:"api_name"`
+	RunID                 string    `json:"run_id"`
+	Scope                 string    `json:"scope"`
+	GitRoot               string    `json:"git_root"`
+	SpecPath              string    `json:"spec_path,omitempty"`
+	SpecURL               string    `json:"spec_url,omitempty"`
+	WorkingDir            string    `json:"working_dir"`
+	PublishedCLIDir       string    `json:"published_cli_dir,omitempty"`
+	ArchivedManuscriptDir string    `json:"archived_manuscript_dir,omitempty"`
+	CreatedAt             time.Time `json:"created_at"`
+	UpdatedAt             time.Time `json:"updated_at"`
+}
+
+func BuildRunManifest(state *PipelineState) RunManifest {
+	return RunManifest{
+		Version:               1,
+		APIName:               state.APIName,
+		RunID:                 state.RunID,
+		Scope:                 state.Scope,
+		GitRoot:               repoRoot(),
+		SpecPath:              state.SpecPath,
+		SpecURL:               state.SpecURL,
+		WorkingDir:            state.EffectiveWorkingDir(),
+		PublishedCLIDir:       state.PublishedDir,
+		ArchivedManuscriptDir: ArchivedManuscriptDir(state.APIName, state.RunID),
+		CreatedAt:             state.StartedAt,
+		UpdatedAt:             time.Now(),
+	}
+}
+
+func WriteRunManifest(state *PipelineState) error {
+	manifest := BuildRunManifest(state)
+	data, err := json.MarshalIndent(manifest, "", "  ")
+	if err != nil {
+		return fmt.Errorf("marshaling run manifest: %w", err)
+	}
+	if err := os.WriteFile(state.ManifestPath(), data, 0o644); err != nil {
+		return fmt.Errorf("writing run manifest: %w", err)
+	}
+	return nil
+}
+
+func WriteArchivedManifest(state *PipelineState) error {
+	manifest := BuildRunManifest(state)
+	data, err := json.MarshalIndent(manifest, "", "  ")
+	if err != nil {
+		return fmt.Errorf("marshaling archived manifest: %w", err)
+	}
+	if err := os.MkdirAll(ArchivedManuscriptDir(state.APIName, state.RunID), 0o755); err != nil {
+		return fmt.Errorf("creating archived manuscript dir: %w", err)
+	}
+	if err := os.WriteFile(ArchivedManifestPath(state.APIName, state.RunID), data, 0o644); err != nil {
+		return fmt.Errorf("writing archived manifest: %w", err)
+	}
+	return nil
+}
+
+func PublishWorkingCLI(state *PipelineState, targetDir string) (string, error) {
+	workingDir := state.EffectiveWorkingDir()
+	if workingDir == "" {
+		return "", fmt.Errorf("working dir is empty")
+	}
+
+	finalDir := targetDir
+	var err error
+	if finalDir == "" {
+		finalDir, err = ClaimOutputDir(DefaultOutputDir(state.APIName))
+		if err != nil {
+			return "", err
+		}
+	} else {
+		finalDir, err = filepath.Abs(finalDir)
+		if err != nil {
+			return "", fmt.Errorf("resolving publish dir: %w", err)
+		}
+		if _, err := os.Stat(finalDir); err == nil {
+			return "", fmt.Errorf("publish dir already exists: %s", finalDir)
+		} else if !os.IsNotExist(err) {
+			return "", fmt.Errorf("checking publish dir: %w", err)
+		}
+	}
+
+	if err := CopyDir(workingDir, finalDir); err != nil {
+		return "", fmt.Errorf("publishing CLI: %w", err)
+	}
+
+	state.PublishedDir = finalDir
+	if err := state.Save(); err != nil {
+		return "", err
+	}
+	if err := WriteRunManifest(state); err != nil {
+		return "", err
+	}
+	return finalDir, nil
+}
+
+func ArchiveRunArtifacts(state *PipelineState) (string, error) {
+	archiveDir := ArchivedManuscriptDir(state.APIName, state.RunID)
+	if err := os.MkdirAll(archiveDir, 0o755); err != nil {
+		return "", fmt.Errorf("creating archive dir: %w", err)
+	}
+
+	type pair struct {
+		src string
+		dst string
+	}
+
+	pairs := []pair{
+		{src: state.ResearchDir(), dst: ArchivedResearchDir(state.APIName, state.RunID)},
+		{src: state.ProofsDir(), dst: ArchivedProofsDir(state.APIName, state.RunID)},
+		{src: state.PipelineDir(), dst: ArchivedPipelineDir(state.APIName, state.RunID)},
+	}
+
+	for _, item := range pairs {
+		info, err := os.Stat(item.src)
+		if err != nil {
+			if os.IsNotExist(err) {
+				continue
+			}
+			return "", fmt.Errorf("stat %s: %w", item.src, err)
+		}
+		if !info.IsDir() {
+			continue
+		}
+		if err := CopyDir(item.src, item.dst); err != nil {
+			return "", fmt.Errorf("archiving %s: %w", item.src, err)
+		}
+	}
+
+	if err := WriteArchivedManifest(state); err != nil {
+		return "", err
+	}
+	if err := WriteRunManifest(state); err != nil {
+		return "", err
+	}
+	return archiveDir, nil
+}
+
+func CopyDir(src, dst string) error {
+	info, err := os.Stat(src)
+	if err != nil {
+		return err
+	}
+	if !info.IsDir() {
+		return fmt.Errorf("%s is not a directory", src)
+	}
+
+	if err := os.MkdirAll(dst, info.Mode()); err != nil {
+		return err
+	}
+
+	return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
+		if walkErr != nil {
+			return walkErr
+		}
+		if path == src {
+			return nil
+		}
+
+		rel, err := filepath.Rel(src, path)
+		if err != nil {
+			return err
+		}
+		target := filepath.Join(dst, rel)
+
+		info, err = os.Lstat(path)
+		if err != nil {
+			return err
+		}
+
+		if info.IsDir() {
+			return os.MkdirAll(target, info.Mode())
+		}
+
+		if info.Mode()&os.ModeSymlink != 0 {
+			link, err := os.Readlink(path)
+			if err != nil {
+				return err
+			}
+			return os.Symlink(link, target)
+		}
+
+		return copyFile(path, target, info.Mode())
+	})
+}
+
+func copyFile(src, dst string, mode os.FileMode) error {
+	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+		return err
+	}
+
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+
+	out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	if _, err := io.Copy(out, in); err != nil {
+		return err
+	}
+	return out.Close()
+}
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index 4d05ba20..7454662a 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -9,10 +9,12 @@ import (
 	"os/exec"
 	"path/filepath"
 	"regexp"
+	"sort"
 	"strings"
 	"time"
 
 	"github.com/mvanhorn/cli-printing-press/internal/artifacts"
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 )
 
 // VerifyConfig configures a runtime verification run.
@@ -92,8 +94,7 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
 	// 4. Start mock server if needed
 	var mockServer *httptest.Server
 	var baseURLOverride string
-	apiName := filepath.Base(cfg.Dir)
-	apiName = strings.TrimSuffix(apiName, "-cli")
+	apiName := naming.TrimCLISuffix(filepath.Base(cfg.Dir))
 	envVarName := cfg.EnvVar
 	if envVarName == "" {
 		envVarName = strings.ToUpper(strings.ReplaceAll(apiName, "-", "_")) + "_TOKEN"
@@ -176,19 +177,9 @@ func buildCLI(dir string) (string, error) {
 	if err != nil {
 		return "", fmt.Errorf("resolving binary path: %w", err)
 	}
-	cmdDir := filepath.Join(dir, "cmd", name)
-	if _, err := os.Stat(cmdDir); os.IsNotExist(err) {
-		// Try without -cli suffix
-		cmdDir = filepath.Join(dir, "cmd", strings.TrimSuffix(name, "-cli"))
-		if _, err := os.Stat(cmdDir); os.IsNotExist(err) {
-			// Try listing cmd/ subdirectories
-			entries, _ := os.ReadDir(filepath.Join(dir, "cmd"))
-			if len(entries) == 1 {
-				cmdDir = filepath.Join(dir, "cmd", entries[0].Name())
-			} else {
-				return "", fmt.Errorf("cannot find cmd/ entry point in %s", dir)
-			}
-		}
+	cmdDir, err := findCLICommandDir(dir)
+	if err != nil {
+		return "", err
 	}
 
 	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
@@ -202,6 +193,55 @@ func buildCLI(dir string) (string, error) {
 	return binaryPath, nil
 }
 
+func findCLICommandDir(dir string) (string, error) {
+	name := filepath.Base(dir)
+	apiName := naming.TrimCLISuffix(name)
+	candidates := []string{
+		filepath.Join(dir, "cmd", name),
+		filepath.Join(dir, "cmd", naming.CLI(apiName)),
+		filepath.Join(dir, "cmd", naming.LegacyCLI(apiName)),
+		filepath.Join(dir, "cmd", apiName),
+	}
+
+	for _, candidate := range candidates {
+		info, err := os.Stat(candidate)
+		if err == nil && info.IsDir() {
+			return candidate, nil
+		}
+		if err != nil && !os.IsNotExist(err) {
+			return "", fmt.Errorf("stat %s: %w", candidate, err)
+		}
+	}
+
+	entries, err := os.ReadDir(filepath.Join(dir, "cmd"))
+	if err != nil {
+		return "", fmt.Errorf("reading cmd directory: %w", err)
+	}
+
+	var cliEntries []string
+	var dirEntries []string
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
+		}
+		dirEntries = append(dirEntries, entry.Name())
+		if naming.IsCLIDirName(entry.Name()) {
+			cliEntries = append(cliEntries, entry.Name())
+		}
+	}
+
+	sort.Strings(cliEntries)
+	if len(cliEntries) == 1 {
+		return filepath.Join(dir, "cmd", cliEntries[0]), nil
+	}
+
+	if len(dirEntries) == 1 {
+		return filepath.Join(dir, "cmd", dirEntries[0]), nil
+	}
+
+	return "", fmt.Errorf("cannot find CLI cmd entry point in %s", dir)
+}
+
 // discoverCommands parses root.go to find all registered commands.
 func discoverCommands(dir string) []discoveredCommand {
 	rootPath := filepath.Join(dir, "internal", "cli", "root.go")
@@ -224,7 +264,7 @@ func discoverCommands(dir string) []discoveredCommand {
 		seen[name] = true
 		// Skip utility commands
 		switch name {
-		case "version-cli", "version", "completion", "help":
+		case "version-pp-cli", "version-cli", "version", "completion", "help":
 			continue
 		}
 		commands = append(commands, discoveredCommand{Name: name})
diff --git a/internal/pipeline/runtime_test.go b/internal/pipeline/runtime_test.go
index 69b13bc7..4082f178 100644
--- a/internal/pipeline/runtime_test.go
+++ b/internal/pipeline/runtime_test.go
@@ -58,3 +58,18 @@ func main() {
 	assert.Nil(t, report)
 	assert.FileExists(t, existingBinary)
 }
+
+func TestBuildCLI_UsesCanonicalCommandDirForClaimedOutput(t *testing.T) {
+	dir := filepath.Join(t.TempDir(), "sample-pp-cli-2")
+	require.NoError(t, os.MkdirAll(filepath.Join(dir, "cmd", "sample-pp-cli"), 0o755))
+
+	writeTestFile(t, filepath.Join(dir, "go.mod"), "module example.com/sample-pp-cli\n\ngo 1.26.1\n")
+	writeTestFile(t, filepath.Join(dir, "cmd", "sample-pp-cli", "main.go"), `package main
+func main() {}
+`)
+
+	binaryPath, err := buildCLI(dir)
+	require.NoError(t, err)
+	assert.Equal(t, filepath.Join(dir, "sample-pp-cli-2"), binaryPath)
+	assert.FileExists(t, binaryPath)
+}
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index fd10edc4..f81323a2 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -187,10 +187,13 @@ func RunScorecard(outputDir, pipelineDir, specPath string, verifyReport *VerifyR
 	// Competitor comparison from research.json
 	sc.CompetitorScores = buildCompetitorScores(sc.Steinberger.Total, pipelineDir)
 
-	// Write scorecard.md
+	// Write scorecard artifacts
 	if err := writeScorecardMD(sc, pipelineDir); err != nil {
 		return sc, fmt.Errorf("writing scorecard.md: %w", err)
 	}
+	if err := writeScorecardJSON(sc, pipelineDir); err != nil {
+		return sc, fmt.Errorf("writing scorecard.json: %w", err)
+	}
 
 	return sc, nil
 }
@@ -1684,8 +1687,8 @@ func buildGapReport(s SteinerScore, unscored []string) []string {
 	return gaps
 }
 
-func buildCompetitorScores(ourTotal int, pipelineDir string) []CompScore {
-	research, err := LoadResearch(pipelineDir)
+func buildCompetitorScores(ourTotal int, artifactDir string) []CompScore {
+	research, err := loadResearchForArtifactsDir(artifactDir)
 	if err != nil {
 		return nil
 	}
@@ -1702,6 +1705,39 @@ func buildCompetitorScores(ourTotal int, pipelineDir string) []CompScore {
 	return scores
 }
 
+func loadResearchForArtifactsDir(artifactDir string) (*ResearchResult, error) {
+	parent := filepath.Dir(artifactDir)
+	var candidates []string
+	switch filepath.Base(artifactDir) {
+	case "research":
+		candidates = []string{artifactDir, filepath.Join(parent, "pipeline")}
+	case "proofs", "pipeline":
+		candidates = []string{filepath.Join(parent, "research"), artifactDir, filepath.Join(parent, "pipeline")}
+	default:
+		candidates = []string{artifactDir, filepath.Join(artifactDir, "research"), filepath.Join(artifactDir, "pipeline")}
+	}
+
+	var lastErr error
+	seen := make(map[string]struct{}, len(candidates))
+	for _, candidate := range candidates {
+		if _, ok := seen[candidate]; ok {
+			continue
+		}
+		seen[candidate] = struct{}{}
+
+		research, err := LoadResearch(candidate)
+		if err == nil {
+			return research, nil
+		}
+		lastErr = err
+	}
+
+	if lastErr == nil {
+		lastErr = os.ErrNotExist
+	}
+	return nil, lastErr
+}
+
 func estimateCompetitorTotal(alt Alternative) int {
 	score := 0
 	if alt.HasJSON {
@@ -1810,6 +1846,19 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
 	return os.WriteFile(filepath.Join(pipelineDir, "scorecard.md"), []byte(b.String()), 0o644)
 }
 
+func writeScorecardJSON(sc *Scorecard, pipelineDir string) error {
+	if err := os.MkdirAll(pipelineDir, 0o755); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(sc, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(filepath.Join(pipelineDir, "scorecard.json"), data, 0o644)
+}
+
 // LoadScorecard reads a scorecard from a pipeline directory's scorecard.json.
 func LoadScorecard(pipelineDir string) (*Scorecard, error) {
 	data, err := os.ReadFile(filepath.Join(pipelineDir, "scorecard.json"))
diff --git a/internal/pipeline/scorecard_artifacts_test.go b/internal/pipeline/scorecard_artifacts_test.go
new file mode 100644
index 00000000..1384e6d4
--- /dev/null
+++ b/internal/pipeline/scorecard_artifacts_test.go
@@ -0,0 +1,36 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRunScorecardLoadsResearchFromSiblingResearchDir(t *testing.T) {
+	outputDir := t.TempDir()
+	runRoot := t.TempDir()
+	researchDir := filepath.Join(runRoot, "research")
+	proofsDir := filepath.Join(runRoot, "proofs")
+
+	require.NoError(t, os.MkdirAll(researchDir, 0o755))
+
+	research := &ResearchResult{
+		APIName: "sample",
+		Alternatives: []Alternative{
+			{Name: "competitor/sample-cli"},
+		},
+	}
+	data, err := json.MarshalIndent(research, "", "  ")
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(researchDir, "research.json"), data, 0o644))
+
+	scorecard, err := RunScorecard(outputDir, proofsDir, "", nil)
+	require.NoError(t, err)
+
+	assert.Len(t, scorecard.CompetitorScores, 1)
+	assert.FileExists(t, filepath.Join(proofsDir, "scorecard.md"))
+}
diff --git a/internal/pipeline/spec_summary.go b/internal/pipeline/spec_summary.go
new file mode 100644
index 00000000..3d865f26
--- /dev/null
+++ b/internal/pipeline/spec_summary.go
@@ -0,0 +1,164 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"sort"
+	"strings"
+
+	apispec "github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+type specSummary struct {
+	Paths []string
+	Auth  apispec.AuthConfig
+}
+
+func loadSpecSummary(specPath string) (*specSummary, error) {
+	if specPath == "" {
+		return nil, nil
+	}
+
+	data, err := readSpecBytes(specPath)
+	if err != nil {
+		return nil, err
+	}
+
+	if summary, err := summarizeOpenAPILike(data); err == nil && summary != nil {
+		return summary, nil
+	}
+
+	if parsed, err := apispec.ParseBytes(data); err == nil {
+		return summarizeSpec(parsed), nil
+	}
+
+	return nil, nil
+}
+
+func summarizeSpec(parsed *apispec.APISpec) *specSummary {
+	if parsed == nil {
+		return nil
+	}
+
+	paths := collectSpecPaths(parsed.Resources)
+	sort.Strings(paths)
+
+	return &specSummary{
+		Paths: paths,
+		Auth:  parsed.Auth,
+	}
+}
+
+func collectSpecPaths(resources map[string]apispec.Resource) []string {
+	if len(resources) == 0 {
+		return nil
+	}
+
+	paths := make(map[string]struct{})
+	var walk func(map[string]apispec.Resource)
+	walk = func(resources map[string]apispec.Resource) {
+		for _, resource := range resources {
+			for _, endpoint := range resource.Endpoints {
+				if endpoint.Path != "" {
+					paths[endpoint.Path] = struct{}{}
+				}
+			}
+			if len(resource.SubResources) > 0 {
+				walk(resource.SubResources)
+			}
+		}
+	}
+	walk(resources)
+
+	out := make([]string, 0, len(paths))
+	for path := range paths {
+		out = append(out, path)
+	}
+	return out
+}
+
+func summarizeOpenAPILike(data []byte) (*specSummary, error) {
+	data, err := ensureJSON(data)
+	if err != nil {
+		return nil, err
+	}
+
+	var raw map[string]any
+	if err := json.Unmarshal(data, &raw); err != nil {
+		return nil, err
+	}
+
+	pathsRaw, _ := raw["paths"].(map[string]any)
+	paths := make([]string, 0, len(pathsRaw))
+	for path := range pathsRaw {
+		paths = append(paths, path)
+	}
+	sort.Strings(paths)
+
+	auth := summarizeSecuritySchemes(raw)
+	if len(paths) == 0 && auth.Type == "" && auth.Format == "" && auth.Header == "" && auth.Scheme == "" && auth.In == "" && len(auth.EnvVars) == 0 {
+		return nil, nil
+	}
+
+	return &specSummary{
+		Paths: paths,
+		Auth:  auth,
+	}, nil
+}
+
+func summarizeSecuritySchemes(raw map[string]any) apispec.AuthConfig {
+	components, ok := raw["components"].(map[string]any)
+	if !ok {
+		return apispec.AuthConfig{}
+	}
+	schemes, ok := components["securitySchemes"].(map[string]any)
+	if !ok {
+		return apispec.AuthConfig{}
+	}
+
+	for schemeName, value := range schemes {
+		scheme, ok := value.(map[string]any)
+		if !ok {
+			continue
+		}
+
+		auth := apispec.AuthConfig{
+			Scheme: schemeName,
+			Type:   stringValue(scheme["type"]),
+			Header: stringValue(scheme["name"]),
+			In:     stringValue(scheme["in"]),
+		}
+		schemeType := stringValue(scheme["scheme"])
+
+		switch {
+		case strings.EqualFold(auth.Type, "http") && strings.EqualFold(schemeType, "bearer"):
+			auth.Type = "bearer_token"
+			auth.Header = "Authorization"
+			auth.Format = "Bearer "
+			if strings.Contains(strings.ToLower(schemeName), "bot") {
+				auth.Format = "Bot {bot_token}"
+			}
+		case strings.EqualFold(auth.Type, "http") && strings.EqualFold(schemeType, "basic"):
+			auth.Type = "api_key"
+			auth.Header = "Authorization"
+			auth.Format = "Basic "
+		case strings.EqualFold(auth.Type, "apikey"):
+			if auth.Header == "" {
+				auth.Header = "Authorization"
+			}
+			if strings.Contains(strings.ToLower(schemeName), "bot") && strings.EqualFold(auth.Header, "Authorization") {
+				auth.Format = "Bot {bot_token}"
+			}
+		}
+
+		return auth
+	}
+
+	return apispec.AuthConfig{}
+}
+
+func stringValue(value any) string {
+	if s, ok := value.(string); ok {
+		return s
+	}
+	return ""
+}
diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
index 97212be1..16e1cadf 100644
--- a/internal/pipeline/state.go
+++ b/internal/pipeline/state.go
@@ -1,6 +1,7 @@
 package pipeline
 
 import (
+	"crypto/rand"
 	"encoding/json"
 	"fmt"
 	"os"
@@ -70,18 +71,23 @@ const (
 
 // PipelineState tracks which phases are done across sessions.
 type PipelineState struct {
-	Version        int                   `json:"version"`                           // state schema version for migration
-	APIName        string                `json:"api_name"`
-	OutputDir      string                `json:"output_dir"`
-	StartedAt      time.Time             `json:"started_at"`
-	Phases         map[string]PhaseState `json:"phases"`
-	SpecPath       string                `json:"spec_path,omitempty"`
-	SpecURL        string                `json:"spec_url,omitempty"`
-	DogfoodTimeout int                   `json:"dogfood_timeout_seconds,omitempty"` // default 600 (10 min)
-	DogfoodTier    int                   `json:"dogfood_tier,omitempty"`            // max tier to run (1-3, default 1)
+	Version                      int                   `json:"version"` // state schema version for migration
+	APIName                      string                `json:"api_name"`
+	RunID                        string                `json:"run_id,omitempty"`
+	Scope                        string                `json:"scope,omitempty"`
+	OutputDir                    string                `json:"output_dir"`
+	WorkingDir                   string                `json:"working_dir,omitempty"`
+	PublishedDir                 string                `json:"published_dir,omitempty"`
+	ExcludeFromCurrentResolution bool                  `json:"exclude_from_current_resolution,omitempty"`
+	StartedAt                    time.Time             `json:"started_at"`
+	Phases                       map[string]PhaseState `json:"phases"`
+	SpecPath                     string                `json:"spec_path,omitempty"`
+	SpecURL                      string                `json:"spec_url,omitempty"`
+	DogfoodTimeout               int                   `json:"dogfood_timeout_seconds,omitempty"` // default 600 (10 min)
+	DogfoodTier                  int                   `json:"dogfood_tier,omitempty"`            // max tier to run (1-3, default 1)
 }
 
-const currentStateVersion = 2
+const currentStateVersion = 3
 
 // PhaseState tracks a single phase.
 type PhaseState struct {
@@ -90,29 +96,245 @@ type PhaseState struct {
 	PlanStatus string `json:"plan_status,omitempty"`
 }
 
+type CurrentRunPointer struct {
+	APIName    string    `json:"api_name"`
+	RunID      string    `json:"run_id"`
+	Scope      string    `json:"scope"`
+	GitRoot    string    `json:"git_root"`
+	WorkingDir string    `json:"working_dir"`
+	StatePath  string    `json:"state_path"`
+	UpdatedAt  time.Time `json:"updated_at"`
+}
+
 // PipelineDir returns the pipeline state directory path.
 func PipelineDir(apiName string) string {
+	if statePath, ok := resolveStatePath(apiName); ok {
+		if filepath.Base(filepath.Dir(statePath)) == "pipeline" {
+			return filepath.Dir(statePath)
+		}
+		return filepath.Join(filepath.Dir(statePath), "pipeline")
+	}
+	return legacyWorkspacePipelineDir(apiName)
+}
+
+func legacyWorkspacePipelineDir(apiName string) string {
+	return filepath.Join(LegacyWorkspaceManuscriptsRoot(), apiName, "pipeline")
+}
+
+func legacyRepoPipelineDir(apiName string) string {
 	return filepath.Join("docs", "plans", apiName+"-pipeline")
 }
 
 // StatePath returns the state.json path for an API pipeline.
 func StatePath(apiName string) string {
-	return filepath.Join(PipelineDir(apiName), "state.json")
+	if statePath, ok := resolveStatePath(apiName); ok {
+		return statePath
+	}
+	return filepath.Join(legacyWorkspacePipelineDir(apiName), "state.json")
+}
+
+func legacyWorkspaceStatePath(apiName string) string {
+	return filepath.Join(legacyWorkspacePipelineDir(apiName), "state.json")
+}
+
+func legacyRepoStatePath(apiName string) string {
+	return filepath.Join(legacyRepoPipelineDir(apiName), "state.json")
+}
+
+func newRunID(now time.Time) (string, error) {
+	var suffix [4]byte
+	if _, err := rand.Read(suffix[:]); err != nil {
+		return "", fmt.Errorf("generating run id: %w", err)
+	}
+	return fmt.Sprintf("%s-%x", now.UTC().Format("20060102T150405Z"), suffix), nil
+}
+
+func loadCurrentRunPointer(apiName string) (*CurrentRunPointer, error) {
+	data, err := os.ReadFile(CurrentRunPointerPath(apiName))
+	if err != nil {
+		return nil, err
+	}
+
+	var pointer CurrentRunPointer
+	if err := json.Unmarshal(data, &pointer); err != nil {
+		return nil, err
+	}
+	return &pointer, nil
+}
+
+func writeCurrentRunPointer(state *PipelineState) error {
+	if state.ExcludeFromCurrentResolution {
+		return nil
+	}
+
+	pointer := CurrentRunPointer{
+		APIName:    state.APIName,
+		RunID:      state.RunID,
+		Scope:      state.Scope,
+		GitRoot:    repoRoot(),
+		WorkingDir: state.EffectiveWorkingDir(),
+		StatePath:  state.StatePath(),
+		UpdatedAt:  time.Now(),
+	}
+
+	if err := os.MkdirAll(CurrentRunDir(), 0o755); err != nil {
+		return fmt.Errorf("creating current run dir: %w", err)
+	}
+
+	data, err := json.MarshalIndent(pointer, "", "  ")
+	if err != nil {
+		return fmt.Errorf("marshaling current run pointer: %w", err)
+	}
+
+	return os.WriteFile(CurrentRunPointerPath(state.APIName), data, 0o644)
+}
+
+func findRunstateStatePath(apiName string) (string, bool) {
+	if pointer, err := loadCurrentRunPointer(apiName); err == nil && pointer.StatePath != "" {
+		if _, err := os.Stat(pointer.StatePath); err == nil {
+			return pointer.StatePath, true
+		}
+	}
+
+	pattern := filepath.Join(ScopedRunstateRoot(), "runs", "*", "state.json")
+	matches, err := filepath.Glob(pattern)
+	if err != nil {
+		return "", false
+	}
+
+	var newest string
+	var newestTime time.Time
+	for _, candidate := range matches {
+		data, err := os.ReadFile(candidate)
+		if err != nil {
+			continue
+		}
+
+		var state PipelineState
+		if err := json.Unmarshal(data, &state); err != nil {
+			continue
+		}
+		if state.APIName != apiName {
+			continue
+		}
+		if state.ExcludeFromCurrentResolution {
+			continue
+		}
+
+		info, err := os.Stat(candidate)
+		if err != nil {
+			continue
+		}
+		if newest == "" || info.ModTime().After(newestTime) {
+			newest = candidate
+			newestTime = info.ModTime()
+		}
+	}
+
+	if newest == "" {
+		return "", false
+	}
+	return newest, true
+}
+
+func resolveStatePath(apiName string) (string, bool) {
+	if statePath, ok := findRunstateStatePath(apiName); ok {
+		return statePath, true
+	}
+	if _, err := os.Stat(legacyWorkspaceStatePath(apiName)); err == nil {
+		return legacyWorkspaceStatePath(apiName), true
+	}
+	if _, err := os.Stat(legacyRepoStatePath(apiName)); err == nil {
+		return legacyRepoStatePath(apiName), true
+	}
+	return "", false
+}
+
+func LoadCurrentState(apiName string) (*PipelineState, error) {
+	pointer, err := loadCurrentRunPointer(apiName)
+	if err != nil {
+		return nil, fmt.Errorf("no current run for %q", apiName)
+	}
+	if pointer.StatePath == "" {
+		return nil, fmt.Errorf("no current run for %q", apiName)
+	}
+	if _, err := os.Stat(pointer.StatePath); err != nil {
+		return nil, fmt.Errorf("current run for %q is missing state: %w", apiName, err)
+	}
+	return LoadState(apiName)
+}
+
+func FindStateByWorkingDir(dir string) (*PipelineState, error) {
+	absDir, err := filepath.Abs(dir)
+	if err != nil {
+		return nil, fmt.Errorf("resolving working dir: %w", err)
+	}
+
+	pattern := filepath.Join(ScopedRunstateRoot(), "runs", "*", "state.json")
+	matches, err := filepath.Glob(pattern)
+	if err != nil {
+		return nil, fmt.Errorf("scanning runstate: %w", err)
+	}
+
+	for _, candidate := range matches {
+		data, err := os.ReadFile(candidate)
+		if err != nil {
+			continue
+		}
+
+		var state PipelineState
+		if err := json.Unmarshal(data, &state); err != nil {
+			continue
+		}
+		if state.EffectiveWorkingDir() == absDir {
+			if state.RunID == "" {
+				state.RunID = filepath.Base(filepath.Dir(candidate))
+			}
+			if state.Scope == "" {
+				state.Scope = WorkspaceScope()
+			}
+			return &state, nil
+		}
+	}
+
+	return nil, fmt.Errorf("no runstate entry for working dir %s", absDir)
 }
 
 // NewState creates a fresh pipeline state.
 func NewState(apiName, outputDir string) *PipelineState {
+	runID, err := newRunID(time.Now())
+	if err != nil {
+		outputDir = outputDir
+		runID = fmt.Sprintf("fallback-%d", time.Now().UnixNano())
+	}
+	return NewStateWithRun(apiName, outputDir, runID, WorkspaceScope())
+}
+
+func NewManagedState(apiName string) (*PipelineState, error) {
+	runID, err := newRunID(time.Now())
+	if err != nil {
+		return nil, err
+	}
+	return NewStateWithRun(apiName, WorkingCLIDir(apiName, runID), runID, WorkspaceScope()), nil
+}
+
+// NewStateWithRun creates a fresh pipeline state with a preallocated run identity.
+func NewStateWithRun(apiName, outputDir, runID, scope string) *PipelineState {
 	phases := make(map[string]PhaseState, len(PhaseOrder))
+	pipelineDir := RunPipelineDir(runID)
 	for _, name := range PhaseOrder {
 		phases[name] = PhaseState{
 			Status:   StatusPending,
-			PlanPath: filepath.Join(PipelineDir(apiName), PlanFilename(name)),
+			PlanPath: filepath.Join(pipelineDir, PlanFilename(name)),
 		}
 	}
 	state := &PipelineState{
 		Version:        currentStateVersion,
 		APIName:        apiName,
+		RunID:          runID,
+		Scope:          scope,
 		OutputDir:      outputDir,
+		WorkingDir:     outputDir,
 		StartedAt:      time.Now(),
 		Phases:         phases,
 		DogfoodTimeout: 600, // 10 minutes default
@@ -121,9 +343,24 @@ func NewState(apiName, outputDir string) *PipelineState {
 	return state
 }
 
-// Save writes state to disk.
-func (s *PipelineState) Save() error {
-	dir := PipelineDir(s.APIName)
+func (s *PipelineState) save(updateCurrentPointer bool) error {
+	if s.Scope == "" {
+		s.Scope = WorkspaceScope()
+	}
+	if s.RunID == "" {
+		runID, err := newRunID(time.Now())
+		if err != nil {
+			return err
+		}
+		s.RunID = runID
+	}
+	if s.WorkingDir == "" {
+		s.WorkingDir = s.OutputDir
+	}
+	if s.OutputDir == "" {
+		s.OutputDir = s.WorkingDir
+	}
+	dir := s.PipelineDir()
 	if err := os.MkdirAll(dir, 0o755); err != nil {
 		return fmt.Errorf("creating pipeline dir: %w", err)
 	}
@@ -131,19 +368,66 @@ func (s *PipelineState) Save() error {
 	if err != nil {
 		return fmt.Errorf("marshaling state: %w", err)
 	}
-	return os.WriteFile(StatePath(s.APIName), data, 0o644)
+	if err := os.WriteFile(s.StatePath(), data, 0o644); err != nil {
+		return err
+	}
+	if updateCurrentPointer {
+		if err := writeCurrentRunPointer(s); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// Save writes state to disk and updates the current-run pointer for the API.
+func (s *PipelineState) Save() error {
+	return s.save(true)
+}
+
+// SaveWithoutCurrentPointer writes state to disk without taking ownership of
+// the API's current-run pointer.
+func (s *PipelineState) SaveWithoutCurrentPointer() error {
+	return s.save(false)
 }
 
 // LoadState reads existing state from disk, migrating old formats.
 func LoadState(apiName string) (*PipelineState, error) {
-	data, err := os.ReadFile(StatePath(apiName))
+	statePath, ok := resolveStatePath(apiName)
+	if !ok {
+		return nil, fmt.Errorf("reading state: stat %s: no such file or directory", StatePath(apiName))
+	}
+
+	data, err := os.ReadFile(statePath)
 	if err != nil {
 		return nil, fmt.Errorf("reading state: %w", err)
 	}
+	sourcePath := statePath
 	var s PipelineState
 	if err := json.Unmarshal(data, &s); err != nil {
 		return nil, fmt.Errorf("parsing state: %w", err)
 	}
+
+	needsSave := false
+	if s.RunID == "" {
+		runID, err := newRunID(time.Now())
+		if err != nil {
+			return nil, err
+		}
+		s.RunID = runID
+		needsSave = true
+	}
+	if s.Scope == "" {
+		s.Scope = WorkspaceScope()
+		needsSave = true
+	}
+	if s.WorkingDir == "" && s.OutputDir != "" {
+		s.WorkingDir = s.OutputDir
+		needsSave = true
+	}
+	if s.OutputDir == "" && s.WorkingDir != "" {
+		s.OutputDir = s.WorkingDir
+		needsSave = true
+	}
 	// Migrate: add missing phases, update PlanPath to stable-numbered format,
 	// and backfill PlanStatus for completed phases that predate the field.
 	if s.Version < currentStateVersion {
@@ -154,13 +438,13 @@ func LoadState(apiName string) (*PipelineState, error) {
 				s.Phases[name] = PhaseState{
 					Status:     StatusCompleted,
 					PlanStatus: PlanStatusCompleted,
-					PlanPath:   filepath.Join(PipelineDir(apiName), PlanFilename(name)),
+					PlanPath:   filepath.Join(s.PipelineDir(), PlanFilename(name)),
 				}
 			} else {
 				// Migrate existing phases to stable-numbered PlanPath and
 				// backfill PlanStatus for completed phases that predate the field.
 				p := s.Phases[name]
-				p.PlanPath = filepath.Join(PipelineDir(apiName), PlanFilename(name))
+				p.PlanPath = filepath.Join(s.PipelineDir(), PlanFilename(name))
 				if p.Status == StatusCompleted && p.PlanStatus == "" {
 					p.PlanStatus = PlanStatusCompleted
 				}
@@ -168,10 +452,14 @@ func LoadState(apiName string) (*PipelineState, error) {
 			}
 		}
 		s.Version = currentStateVersion
-		// Persist the migration so it doesn't re-run on every load.
-		// Note: plan files on disk keep their old sequential-index names
-		// (00-, 01-, …). Completed phases' plans are not re-read; pending
-		// phases get new seeds written at the stable-numbered paths by Init.
+		needsSave = true
+	}
+
+	if sourcePath != s.StatePath() {
+		needsSave = true
+	}
+
+	if needsSave {
 		if err := s.Save(); err != nil {
 			return nil, fmt.Errorf("saving migrated state: %w", err)
 		}
@@ -181,8 +469,8 @@ func LoadState(apiName string) (*PipelineState, error) {
 
 // StateExists returns true if a state file exists.
 func StateExists(apiName string) bool {
-	_, err := os.Stat(StatePath(apiName))
-	return err == nil
+	_, ok := resolveStatePath(apiName)
+	return ok
 }
 
 // Start marks a phase as executing.
@@ -256,6 +544,33 @@ func (s *PipelineState) Fail(phase string) {
 	s.Phases[phase] = p
 }
 
+func (s *PipelineState) EffectiveWorkingDir() string {
+	if s.WorkingDir != "" {
+		return s.WorkingDir
+	}
+	return s.OutputDir
+}
+
+func (s *PipelineState) StatePath() string {
+	return RunStatePath(s.RunID)
+}
+
+func (s *PipelineState) PipelineDir() string {
+	return RunPipelineDir(s.RunID)
+}
+
+func (s *PipelineState) ResearchDir() string {
+	return RunResearchDir(s.RunID)
+}
+
+func (s *PipelineState) ProofsDir() string {
+	return RunProofsDir(s.RunID)
+}
+
+func (s *PipelineState) ManifestPath() string {
+	return RunManifestPath(s.RunID)
+}
+
 // NextPhase returns the name of the next incomplete phase, or "".
 func (s *PipelineState) NextPhase() string {
 	for _, name := range PhaseOrder {
diff --git a/internal/pipeline/state_test.go b/internal/pipeline/state_test.go
index 3d7709c3..750bd624 100644
--- a/internal/pipeline/state_test.go
+++ b/internal/pipeline/state_test.go
@@ -3,16 +3,31 @@ package pipeline
 import (
 	"encoding/json"
 	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
+func setPressTestEnv(t *testing.T) string {
+	t.Helper()
+
+	home := t.TempDir()
+	t.Setenv("PRINTING_PRESS_HOME", home)
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", filepath.Join(home, "repo"))
+	return home
+}
+
 func TestNewState(t *testing.T) {
+	setPressTestEnv(t)
 	s := NewState("test-api", "/tmp/test-api-cli")
 	assert.Equal(t, "test-api", s.APIName)
 	assert.Equal(t, "/tmp/test-api-cli", s.OutputDir)
+	assert.Equal(t, "/tmp/test-api-cli", s.WorkingDir)
+	assert.Equal(t, "test-scope", s.Scope)
+	assert.NotEmpty(t, s.RunID)
 	assert.Len(t, s.Phases, len(PhaseOrder))
 
 	for _, name := range PhaseOrder {
@@ -22,18 +37,21 @@ func TestNewState(t *testing.T) {
 }
 
 func TestStateRoundTrip(t *testing.T) {
+	setPressTestEnv(t)
 	s := NewState("roundtrip-test", "/tmp/rt-cli")
 	s.SpecPath = "/tmp/spec.yaml"
 	s.Complete(PhasePreflight)
 	s.MarkSeedWritten(PhaseScaffold)
 
 	require.NoError(t, s.Save())
-	defer os.RemoveAll(PipelineDir("roundtrip-test"))
+	defer os.RemoveAll(RunRoot(s.RunID))
 
 	loaded, err := LoadState("roundtrip-test")
 	require.NoError(t, err)
 
 	assert.Equal(t, "roundtrip-test", loaded.APIName)
+	assert.Equal(t, s.RunID, loaded.RunID)
+	assert.Equal(t, s.Scope, loaded.Scope)
 	assert.Equal(t, "/tmp/spec.yaml", loaded.SpecPath)
 	assert.Equal(t, StatusCompleted, loaded.Phases[PhasePreflight].Status)
 	assert.Equal(t, PlanStatusCompleted, loaded.Phases[PhasePreflight].PlanStatus)
@@ -44,6 +62,7 @@ func TestStateRoundTrip(t *testing.T) {
 }
 
 func TestNextPhase(t *testing.T) {
+	setPressTestEnv(t)
 	s := NewState("next-test", "/tmp/test")
 	assert.Equal(t, PhasePreflight, s.NextPhase())
 
@@ -61,6 +80,7 @@ func TestNextPhase(t *testing.T) {
 }
 
 func TestPhaseTransitions(t *testing.T) {
+	setPressTestEnv(t)
 	s := NewState("transition-test", "/tmp/test")
 
 	s.MarkSeedWritten(PhasePreflight)
@@ -83,6 +103,7 @@ func TestPhaseTransitions(t *testing.T) {
 }
 
 func TestMarkExpandedFromPendingMarksPlanned(t *testing.T) {
+	setPressTestEnv(t)
 	s := NewState("expanded-test", "/tmp/test")
 
 	s.MarkExpanded(PhaseScaffold)
@@ -92,6 +113,7 @@ func TestMarkExpandedFromPendingMarksPlanned(t *testing.T) {
 }
 
 func TestIsSeedBackwardCompatible(t *testing.T) {
+	setPressTestEnv(t)
 	s := NewState("seed-test", "/tmp/test")
 	assert.True(t, s.IsSeed(PhasePreflight))
 
@@ -103,13 +125,14 @@ func TestIsSeedBackwardCompatible(t *testing.T) {
 }
 
 func TestDefaultOutputDir(t *testing.T) {
+	home := setPressTestEnv(t)
 	tests := []struct {
 		name     string
 		apiName  string
 		expected string
 	}{
-		{"simple", "stripe", "library/stripe-cli"},
-		{"hyphenated", "my-api", "library/my-api-cli"},
+		{"simple", "stripe", filepath.Join(home, "library", "stripe-pp-cli")},
+		{"hyphenated", "my-api", filepath.Join(home, "library", "my-api-pp-cli")},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -119,6 +142,7 @@ func TestDefaultOutputDir(t *testing.T) {
 }
 
 func TestPhaseAgentReadinessInPhaseOrder(t *testing.T) {
+	setPressTestEnv(t)
 	// PhaseAgentReadiness must be between PhaseReview and PhaseComparative.
 	var reviewIdx, agentIdx, compIdx int
 	for i, name := range PhaseOrder {
@@ -136,6 +160,7 @@ func TestPhaseAgentReadinessInPhaseOrder(t *testing.T) {
 }
 
 func TestNextPhaseReturnsAgentReadiness(t *testing.T) {
+	setPressTestEnv(t)
 	s := NewState("ar-test", "/tmp/test")
 	// Complete through PhaseReview.
 	for _, name := range PhaseOrder {
@@ -148,9 +173,10 @@ func TestNextPhaseReturnsAgentReadiness(t *testing.T) {
 }
 
 func TestNewStatePlanPathStableNumbered(t *testing.T) {
+	setPressTestEnv(t)
 	s := NewState("path-test", "/tmp/test")
 	for _, name := range PhaseOrder {
-		expected := "docs/plans/path-test-pipeline/" + PlanFilename(name)
+		expected := filepath.Join(s.PipelineDir(), PlanFilename(name))
 		assert.Equal(t, expected, s.Phases[name].PlanPath, "PlanPath for %s", name)
 	}
 	// Spot-check a few to verify the numbering scheme.
@@ -160,6 +186,7 @@ func TestNewStatePlanPathStableNumbered(t *testing.T) {
 }
 
 func TestLoadStateMigratesV1ToV2(t *testing.T) {
+	setPressTestEnv(t)
 	apiName := "migrate-v1-test"
 	dir := PipelineDir(apiName)
 	require.NoError(t, os.MkdirAll(dir, 0o755))
@@ -173,8 +200,8 @@ func TestLoadStateMigratesV1ToV2(t *testing.T) {
 		OutputDir: "/tmp/migrate-cli",
 		Phases: map[string]PhaseState{
 			PhasePreflight:   {Status: StatusCompleted, PlanStatus: PlanStatusCompleted, PlanPath: dir + "/00-preflight-plan.md"},
-			PhaseResearch:    {Status: StatusCompleted, PlanPath: dir + "/01-research-plan.md"},  // no PlanStatus (pre-PlanStatus v1)
-			PhaseScaffold:    {Status: StatusCompleted, PlanPath: dir + "/02-scaffold-plan.md"},  // no PlanStatus
+			PhaseResearch:    {Status: StatusCompleted, PlanPath: dir + "/01-research-plan.md"}, // no PlanStatus (pre-PlanStatus v1)
+			PhaseScaffold:    {Status: StatusCompleted, PlanPath: dir + "/02-scaffold-plan.md"}, // no PlanStatus
 			PhaseEnrich:      {Status: StatusCompleted, PlanStatus: PlanStatusCompleted, PlanPath: dir + "/03-enrich-plan.md"},
 			PhaseRegenerate:  {Status: StatusCompleted, PlanStatus: PlanStatusCompleted, PlanPath: dir + "/04-regenerate-plan.md"},
 			PhaseReview:      {Status: StatusCompleted, PlanStatus: PlanStatusCompleted, PlanPath: dir + "/05-review-plan.md"},
@@ -189,8 +216,11 @@ func TestLoadStateMigratesV1ToV2(t *testing.T) {
 	loaded, err := LoadState(apiName)
 	require.NoError(t, err)
 
-	// Version bumped.
-	assert.Equal(t, 2, loaded.Version)
+	// Version bumped and migrated into runstate.
+	assert.Equal(t, currentStateVersion, loaded.Version)
+	assert.NotEmpty(t, loaded.RunID)
+	assert.Equal(t, "test-scope", loaded.Scope)
+	assert.Equal(t, filepath.Join(RunPipelineDir(loaded.RunID), PlanFilename(PhasePreflight)), loaded.Phases[PhasePreflight].PlanPath)
 
 	// New phase was backfilled as completed.
 	ar := loaded.Phases[PhaseAgentReadiness]
@@ -199,7 +229,7 @@ func TestLoadStateMigratesV1ToV2(t *testing.T) {
 
 	// All phases have stable-numbered PlanPaths.
 	for _, name := range PhaseOrder {
-		expected := dir + "/" + PlanFilename(name)
+		expected := filepath.Join(RunPipelineDir(loaded.RunID), PlanFilename(name))
 		assert.Equal(t, expected, loaded.Phases[name].PlanPath, "migrated PlanPath for %s", name)
 	}
 
@@ -217,12 +247,45 @@ func TestLoadStateMigratesV1ToV2(t *testing.T) {
 func TestPhaseStateJSONIncludesPlanStatus(t *testing.T) {
 	state := PhaseState{
 		Status:     StatusPlanned,
-		PlanPath:   "docs/plans/test.md",
+		PlanPath:   "/tmp/test.md",
 		PlanStatus: PlanStatusSeed,
 	}
 
 	data, err := json.Marshal(state)
 	require.NoError(t, err)
 
-	assert.JSONEq(t, `{"status":"planned","plan_path":"docs/plans/test.md","plan_status":"seed"}`, string(data))
+	assert.JSONEq(t, `{"status":"planned","plan_path":"/tmp/test.md","plan_status":"seed"}`, string(data))
+}
+
+func TestResolveStatePathPrefersLegacyOverExcludedRunstateScratch(t *testing.T) {
+	setPressTestEnv(t)
+
+	apiName := "legacy-upgrade"
+	legacyDir := legacyWorkspacePipelineDir(apiName)
+	require.NoError(t, os.MkdirAll(legacyDir, 0o755))
+
+	legacyState := PipelineState{
+		Version:   1,
+		APIName:   apiName,
+		OutputDir: "/tmp/legacy-cli",
+		Phases: map[string]PhaseState{
+			PhasePreflight: {Status: StatusCompleted},
+		},
+	}
+	legacyData, err := json.MarshalIndent(legacyState, "", "  ")
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(legacyDir, "state.json"), legacyData, 0o644))
+
+	scratch := NewStateWithRun(apiName, filepath.Join(t.TempDir(), "scratch-pp-cli"), "run-scratch", "test-scope")
+	scratch.ExcludeFromCurrentResolution = true
+	require.NoError(t, scratch.SaveWithoutCurrentPointer())
+
+	resolved, ok := resolveStatePath(apiName)
+	require.True(t, ok)
+	assert.Equal(t, filepath.Join(legacyDir, "state.json"), resolved)
+
+	loaded, err := LoadState(apiName)
+	require.NoError(t, err)
+	assert.Equal(t, "/tmp/legacy-cli", loaded.OutputDir)
+	assert.NotEqual(t, "run-scratch", loaded.RunID)
 }
diff --git a/skills/printing-press-catalog/SKILL.md b/skills/printing-press-catalog/SKILL.md
index 559de341..c16a1991 100644
--- a/skills/printing-press-catalog/SKILL.md
+++ b/skills/printing-press-catalog/SKILL.md
@@ -27,7 +27,33 @@ Browse and install pre-built Go CLIs for popular APIs.
 ## Prerequisites
 
 - Go 1.21+ installed
-- The printing-press repo at ~/cli-printing-press
+- Running from inside the cli-printing-press repo (or a worktree of it)
+
+## Setup
+
+Before any other commands, resolve and cd to the repo root. This ensures all relative paths work even from subdirectories or worktrees:
+
+<!-- PRESS_SETUP_CONTRACT_START -->
+```bash
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+
+PRESS_BASE="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
+if [ -z "$PRESS_BASE" ]; then
+  PRESS_BASE="workspace"
+fi
+PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-8)"
+PRESS_HOME="$HOME/printing-press"
+PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"
+PRESS_LIBRARY="$PRESS_HOME/library"
+
+mkdir -p "$PRESS_RUNSTATE" "$PRESS_LIBRARY"
+```
+<!-- PRESS_SETUP_CONTRACT_END -->
+
+If `git rev-parse` fails, you are not inside a cli-printing-press checkout. Stop and tell the user.
+
+Generated CLIs are published to `$PRESS_LIBRARY/`, not to the repo.
 
 ## Workflows
 
@@ -35,7 +61,7 @@ Browse and install pre-built Go CLIs for popular APIs.
 
 When invoked with no arguments, list all available CLIs grouped by category.
 
-1. Read all YAML files in ~/cli-printing-press/catalog/ using Glob + Read
+1. Read all YAML files in catalog/ using Glob + Read
 2. Parse each file's name, display_name, description, category fields
 3. Group by category and display:
 
@@ -77,31 +103,38 @@ Install any CLI: /printing-press-catalog install <name>
 
 When invoked with `install <name>`:
 
-1. Read ~/cli-printing-press/catalog/<name>.yaml
+1. Read catalog/<name>.yaml
 2. If file doesn't exist, show error: "No catalog entry for '<name>'. Run /printing-press-catalog to see available CLIs."
 3. Extract spec_url from the catalog entry
 4. Show preview: "Installing <display_name> CLI from <spec_url>"
 5. Build the printing-press binary if needed:
    ```bash
-   cd ~/cli-printing-press && go build -o ./printing-press ./cmd/printing-press
+   go build -o ./printing-press ./cmd/printing-press
    ```
 6. Download the spec and generate:
    ```bash
    curl -sL -o /tmp/catalog-spec-$$.yaml "<spec_url>"
-   cd ~/cli-printing-press && ./printing-press generate \
+   OUTPUT_BASE="$PRESS_LIBRARY/<name>-pp-cli"
+   OUTPUT_DIR="$OUTPUT_BASE"
+   i=2
+   while [ -e "$OUTPUT_DIR" ]; do
+     OUTPUT_DIR="${OUTPUT_BASE}-$i"
+     i=$((i + 1))
+   done
+   ./printing-press generate \
      --spec /tmp/catalog-spec-$$.yaml \
-     --output ./library/<name>-cli \
+     --output "$OUTPUT_DIR" \
      --validate
    ```
 7. If all quality gates pass, present the result:
    ```
-   Generated <name>-cli with X resources.
+   Generated <name>-pp-cli with X resources.
 
    Try it:
-     cd library/<name>-cli
-     go install ./cmd/<name>-cli
-     <name>-cli --help
-     <name>-cli doctor
+     cd "$OUTPUT_DIR"
+     go install ./cmd/<name>-pp-cli
+     <name>-pp-cli --help
+     <name>-pp-cli doctor
    ```
 8. If gates fail, show the error and suggest: "Try /printing-press <display_name> API for a custom generation with retry support."
 
@@ -109,7 +142,7 @@ When invoked with `install <name>`:
 
 When invoked with `search <query>`:
 
-1. Read all YAML files in ~/cli-printing-press/catalog/
+1. Read all YAML files in catalog/
 2. Search name, display_name, description, and category for the query (case-insensitive)
 3. Display matching entries
 
diff --git a/skills/printing-press-score/SKILL.md b/skills/printing-press-score/SKILL.md
index fc9040ac..e28231e0 100644
--- a/skills/printing-press-score/SKILL.md
+++ b/skills/printing-press-score/SKILL.md
@@ -28,6 +28,32 @@ Score generated CLIs against the Steinberger bar. Supports rescoring, scoring by
 - Go 1.21+ installed
 - Running from inside the cli-printing-press repo (or a worktree of it)
 
+## Step 0: Resolve Repo Root
+
+Before any other commands, resolve and cd to the repo root. This ensures all relative paths work even from subdirectories or worktrees:
+
+<!-- PRESS_SETUP_CONTRACT_START -->
+```bash
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+
+PRESS_BASE="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
+if [ -z "$PRESS_BASE" ]; then
+  PRESS_BASE="workspace"
+fi
+PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-8)"
+PRESS_HOME="$HOME/printing-press"
+PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"
+PRESS_CURRENT="$PRESS_RUNSTATE/current"
+PRESS_LIBRARY="$PRESS_HOME/library"
+PRESS_MANUSCRIPTS="$PRESS_HOME/manuscripts"
+```
+<!-- PRESS_SETUP_CONTRACT_END -->
+
+If `git rev-parse` fails, you are not inside a cli-printing-press checkout. Stop and tell the user.
+
+Current-run state is resolved from `$PRESS_RUNSTATE`. Published CLIs are resolved from `$PRESS_LIBRARY`. Archived manuscripts are resolved from `$PRESS_MANUSCRIPTS`.
+
 ## Step 1: Parse Arguments
 
 Read the user's input after `/printing-press-score`. The input is **free-form** — interpret intent, don't enforce syntax.
@@ -49,36 +75,40 @@ Treat it as a path (absolute or relative). Verify the directory exists.
 
 ### If the token is a plain name
 Try these locations in order:
-1. `library/<name>/` — exact match
-2. `library/<name>-cli/` — with -cli suffix
+1. `$PRESS_LIBRARY/<name>/` — exact match
+2. `$PRESS_LIBRARY/<name>-pp-cli/` — with -pp-cli suffix
+3. If neither exists, Glob `$PRESS_LIBRARY/<name>-pp-cli*`
+4. If exactly one glob match exists and is a directory, use it
+5. If multiple glob matches exist, present a numbered menu using AskUserQuestion
 
-If neither exists, scan pipeline state files:
-3. Use Glob to find `docs/plans/*-pipeline/state.json` files
-4. Read each, look for an `output_dir` value whose basename contains the name
-5. If found and the directory exists, use it
+If neither exists, scan current-run and archived state:
+6. Use Glob to find `$PRESS_RUNSTATE/runs/*/state.json` files
+7. Read each, look for an `output_dir` or `working_dir` value whose basename contains the name
+8. If found and the directory exists, use it
 
 If nothing resolves, report the error: "Could not find CLI '<name>'. Provide a path or check the name."
 
 ### Rescore Current (0 tokens)
-1. Use Glob to find all `docs/plans/*-pipeline/state.json` files
-2. Read each to get `api_name` and `output_dir`
-3. Filter to those whose `output_dir` actually exists on disk
-4. If exactly one → use it automatically
-5. If multiple → present a numbered menu using AskUserQuestion:
+1. Use Glob to find all `$PRESS_CURRENT/*.json` files
+2. Read each to get `api_name`, `state_path`, and `working_dir`
+3. Filter to those whose `working_dir` actually exists on disk
+4. If none are found, Glob `$PRESS_LIBRARY/*-pp-cli*` and use those directories instead
+5. If exactly one → use it automatically
+6. If multiple → present a numbered menu using AskUserQuestion:
    ```
    Multiple CLIs found. Which one to score?
-   1. stripe-cli (library/stripe-cli)
-   2. notion-cli (library/notion-cli)
-   3. linear-cli (library/linear-cli)
+   1. stripe-pp-cli ($PRESS_LIBRARY/stripe-pp-cli)
+   2. notion-pp-cli ($PRESS_LIBRARY/notion-pp-cli)
+   3. linear-pp-cli ($PRESS_LIBRARY/linear-pp-cli)
    ```
-6. If none found → report: "No generated CLIs found. Provide a name or path."
+7. If none found → report: "No generated CLIs found. Provide a name or path."
 
 ## Step 3: Find Spec for Tier 2 Scoring
 
 For each resolved CLI directory, find the OpenAPI spec:
 
 1. Check `<cli-dir>/spec.json` — the pipeline converts YAML specs to JSON during generation
-2. If not found, scan `docs/plans/*-pipeline/state.json` files for one matching this CLI's directory. Read its `spec_path` field. If that file exists on disk, use it.
+2. If not found, scan `$PRESS_RUNSTATE/runs/*/state.json` files for one matching this CLI's directory. Read its `spec_path` field. If that file exists on disk, use it.
 3. If no spec found, **proceed without `--spec`**. Note to the user: "No spec found — spec-derived dimensions will be marked N/A and omitted from the denominator. Provide a spec path for full scoring."
 
 ## Step 4: Build the Binary
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 3e4d445b..84231921 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -23,7 +23,8 @@ Generate the best useful CLI for an API without burning an hour on phase theater
 /printing-press Notion
 /printing-press Discord codex
 /printing-press --spec ./openapi.yaml
-/printing-press emboss ./library/notion-cli
+/printing-press emboss ./discord-pp-cli
+/printing-press emboss ~/printing-press/library/notion-pp-cli
 ```
 
 ## What Changed In v2
@@ -70,13 +71,13 @@ If Codex fails 3 times in a row, stop delegating and finish locally.
 If the arguments start with `emboss`, this is a second-pass improvement cycle for an existing generated CLI.
 
 ```bash
-/printing-press emboss ./library/notion-cli
+/printing-press emboss ~/printing-press/library/notion-pp-cli
 ```
 
 Use the built-in audit command:
 
 ```bash
-cd ~/cli-printing-press && ./printing-press emboss --dir <cli-dir> --spec <spec-path> --audit-only
+cd "$REPO_ROOT" && ./printing-press emboss --dir <cli-dir> --spec <spec-path> --audit-only
 ```
 
 Emboss is:
@@ -100,14 +101,72 @@ Do not run emboss automatically.
 - YAML, JSON, local paths, and URLs are all valid spec inputs for the verification tools.
 - Maximum 2 verification fix loops unless the user explicitly asks for more.
 
+## Setup
+
+Before doing anything else:
+
+<!-- PRESS_SETUP_CONTRACT_START -->
+```bash
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+
+PRESS_BASE="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
+if [ -z "$PRESS_BASE" ]; then
+  PRESS_BASE="workspace"
+fi
+
+PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-8)"
+PRESS_HOME="$HOME/printing-press"
+PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"
+PRESS_LIBRARY="$PRESS_HOME/library"
+PRESS_MANUSCRIPTS="$PRESS_HOME/manuscripts"
+PRESS_CURRENT="$PRESS_RUNSTATE/current"
+
+mkdir -p "$PRESS_RUNSTATE" "$PRESS_LIBRARY" "$PRESS_MANUSCRIPTS" "$PRESS_CURRENT"
+```
+<!-- PRESS_SETUP_CONTRACT_END -->
+
+After you know `<api>`, initialize the run-scoped artifact paths:
+
+```bash
+RUN_ID="$(date +%Y%m%d-%H%M%S)"
+API_RUN_DIR="$PRESS_RUNSTATE/runs/$RUN_ID"
+RESEARCH_DIR="$API_RUN_DIR/research"
+PROOFS_DIR="$API_RUN_DIR/proofs"
+PIPELINE_DIR="$API_RUN_DIR/pipeline"
+STAMP="$(date +%Y-%m-%d-%H%M%S)"
+
+mkdir -p "$RESEARCH_DIR" "$PROOFS_DIR" "$PIPELINE_DIR"
+STATE_FILE="$API_RUN_DIR/state.json"
+```
+
+Maintain a lightweight state file at `$STATE_FILE` so `/printing-press-score` can rediscover the current run. It should always contain:
+
+```json
+{
+  "api_name": "<api>",
+  "working_dir": "<absolute cli dir>",
+  "output_dir": "<absolute cli dir>",
+  "spec_path": "<absolute spec path if known>"
+}
+```
+
+Active mutable work lives under `$PRESS_RUNSTATE/`. Published CLIs live under `$PRESS_LIBRARY/`. Archived research and verification evidence live under `$PRESS_MANUSCRIPTS/<api>/<run-id>/`. Do not write mutable run artifacts into the repo checkout.
+
+Examples of the current naming/layout to preserve:
+- `discord-pp-cli/internal/store/store.go`
+- `linear-pp-cli stale --days 30 --team ENG`
+- `github.com/mvanhorn/discord-pp-cli`
+
 ## Outputs
 
-Every run writes up to 4 concise artifacts in `~/cli-printing-press/docs/plans/`:
+Every run writes up to 5 concise artifacts under the current managed run and archives them to `$PRESS_MANUSCRIPTS/<api>/<run-id>/`:
 
-1. `<date>-feat-<api>-cli-brief.md`
-2. `<date>-fix-<api>-cli-build-log.md`
-3. `<date>-fix-<api>-cli-shipcheck.md`
-4. `<date>-fix-<api>-cli-live-smoke.md` (only if live testing runs)
+1. `research/<stamp>-feat-<api>-pp-cli-brief.md`
+2. `research/<stamp>-feat-<api>-pp-cli-absorb-manifest.md`
+3. `proofs/<stamp>-fix-<api>-pp-cli-build-log.md`
+4. `proofs/<stamp>-fix-<api>-pp-cli-shipcheck.md`
+5. `proofs/<stamp>-fix-<api>-pp-cli-live-smoke.md` (only if live testing runs)
 
 These do not need to be 200+ lines. Keep them dense, evidence-backed, and directly useful.
 
@@ -117,8 +176,8 @@ Before new research:
 
 1. Resolve the spec source.
 2. Check for prior research in:
-   - `~/cli-printing-press/docs/plans/*<api>*`
-   - `~/docs/plans/*<api>*`
+   - `$PRESS_MANUSCRIPTS/<api>/*/research/*`
+   - `$REPO_ROOT/docs/plans/*<api>*` (legacy fallback)
 3. Reuse good prior work instead of redoing it.
 4. Detect whether an API key is already available.
 
@@ -162,7 +221,7 @@ Put them in the one brief.
 
 Write:
 
-`~/cli-printing-press/docs/plans/<today>-feat-<api>-cli-brief.md`
+`$RESEARCH_DIR/<stamp>-feat-<api>-pp-cli-brief.md`
 
 Suggested shape:
 
@@ -248,7 +307,7 @@ Minimum 5 transcendence features. These are the NOI commands.
 
 ### Step 1.5d: Write the manifest artifact
 
-Write to `~/cli-printing-press/docs/plans/<today>-feat-<api>-cli-absorb-manifest.md`
+Write to `$RESEARCH_DIR/<stamp>-feat-<api>-pp-cli-absorb-manifest.md`
 
 ### Phase Gate 1.5
 
@@ -267,19 +326,19 @@ Use the resolved spec source and generate immediately.
 OpenAPI / internal YAML:
 
 ```bash
-cd ~/cli-printing-press && ./printing-press generate \
+cd "$REPO_ROOT" && ./printing-press generate \
   --spec <spec-path-or-url> \
-  --output ./library/<api>-cli \
+  --output "$PRESS_LIBRARY/<api>-pp-cli" \
   --force --lenient --validate
 ```
 
 Docs-only:
 
 ```bash
-cd ~/cli-printing-press && ./printing-press generate \
+cd "$REPO_ROOT" && ./printing-press generate \
   --docs <docs-url> \
   --name <api> \
-  --output ./library/<api>-cli \
+  --output "$PRESS_LIBRARY/<api>-pp-cli" \
   --force --validate
 ```
 
@@ -349,7 +408,7 @@ Get Priority 0 and 1 working first (the foundation and absorbed features), pass
 
 Write:
 
-`~/cli-printing-press/docs/plans/<today>-fix-<api>-cli-build-log.md`
+`$PROOFS_DIR/<stamp>-fix-<api>-pp-cli-build-log.md`
 
 Include:
 - what was built
@@ -362,10 +421,10 @@ Include:
 Run one combined verification block.
 
 ```bash
-cd ~/cli-printing-press
-./printing-press dogfood   --dir ./library/<api>-cli --spec <same-spec>
-./printing-press verify    --dir ./library/<api>-cli --spec <same-spec> --fix
-./printing-press scorecard --dir ./library/<api>-cli --spec <same-spec>
+cd "$REPO_ROOT"
+./printing-press dogfood   --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec>
+./printing-press verify    --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec> --fix
+./printing-press scorecard --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec>
 ```
 
 Interpretation:
@@ -389,7 +448,7 @@ Maximum 2 shipcheck loops by default.
 
 Write:
 
-`~/cli-printing-press/docs/plans/<today>-fix-<api>-cli-shipcheck.md`
+`$PROOFS_DIR/<stamp>-fix-<api>-pp-cli-shipcheck.md`
 
 Include:
 - command outputs and scores
@@ -414,7 +473,7 @@ If live smoke finds bugs:
 
 Write:
 
-`~/cli-printing-press/docs/plans/<today>-fix-<api>-cli-live-smoke.md`
+`$PROOFS_DIR/<stamp>-fix-<api>-pp-cli-live-smoke.md`
 
 ## Fast Guidance
 

← 7a1c1646 fix(scorecard): handle unscored auth semantics (#29)  ·  back to Cli Printing Press  ·  fix(skill): require interactive API key consent in Phase 0 d9801c33 →