[object Object]

← back to Cli Printing Press

fix(cli): publish skill registry format and manuscript resolution (#106)

b037ac015cf20ce5eb645655b484fc501929f598 · 2026-04-01 18:23:59 -0700 · Trevin Chow

* fix(skills): fix publish skill registry.json format documentation

The skill documented registry.json as a flat array of objects with
cli_name/api_name fields, but the actual format is {schema_version,
entries: [{name, category, api, description, path}]}. This caused
the agent to iterate top-level dict values as objects, hitting
AttributeError on string keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): resolve manuscripts when api_name differs from archive slug

The generator derives api_name from the spec title (e.g., "steam-web")
but the skill archives manuscripts under a shorter slug (e.g., "steam").
The publish command now tries common suffix stripping (-web, -api, etc.)
and prefix matching when the exact directory doesn't exist.

Fixes manuscripts not being included in published CLIs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): key manuscripts by CLI name instead of API slug

Manuscripts were archived under the API slug (e.g., "steam") but looked
up by API name from the manifest (e.g., "steam-web"). Now:

- Skill archives under CLI name (e.g., "steam-web-pp-cli") — unambiguous
- Publish looks for CLI name first, then API name, then fuzzy resolve
- ArchivedManuscriptDir parameter renamed from apiName to key (generic)
- Backwards compatible: old manuscripts under API slugs still found

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): add hyphen-boundary prefix matching and comprehensive tests

Prefix matching in resolveManuscriptDir now requires a hyphen boundary:
"steam" matches "steam-web" but NOT "steamgames". Prevents false
positives on unrelated APIs that share a prefix.

17 tests covering: exact match, suffix stripping, prefix matching,
empty dirs, no matches, priority chain (CLI name > API name > fuzzy),
most-recent-run selection, and two adversarial prefix collision cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b037ac015cf20ce5eb645655b484fc501929f598
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Wed Apr 1 18:23:59 2026 -0700

    fix(cli): publish skill registry format and manuscript resolution (#106)
    
    * fix(skills): fix publish skill registry.json format documentation
    
    The skill documented registry.json as a flat array of objects with
    cli_name/api_name fields, but the actual format is {schema_version,
    entries: [{name, category, api, description, path}]}. This caused
    the agent to iterate top-level dict values as objects, hitting
    AttributeError on string keys.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): resolve manuscripts when api_name differs from archive slug
    
    The generator derives api_name from the spec title (e.g., "steam-web")
    but the skill archives manuscripts under a shorter slug (e.g., "steam").
    The publish command now tries common suffix stripping (-web, -api, etc.)
    and prefix matching when the exact directory doesn't exist.
    
    Fixes manuscripts not being included in published CLIs.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): key manuscripts by CLI name instead of API slug
    
    Manuscripts were archived under the API slug (e.g., "steam") but looked
    up by API name from the manifest (e.g., "steam-web"). Now:
    
    - Skill archives under CLI name (e.g., "steam-web-pp-cli") — unambiguous
    - Publish looks for CLI name first, then API name, then fuzzy resolve
    - ArchivedManuscriptDir parameter renamed from apiName to key (generic)
    - Backwards compatible: old manuscripts under API slugs still found
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): add hyphen-boundary prefix matching and comprehensive tests
    
    Prefix matching in resolveManuscriptDir now requires a hyphen boundary:
    "steam" matches "steam-web" but NOT "steamgames". Prevents false
    positives on unrelated APIs that share a prefix.
    
    17 tests covering: exact match, suffix stripping, prefix matching,
    empty dirs, no matches, priority chain (CLI name > API name > fuzzy),
    most-recent-run selection, and two adversarial prefix collision cases.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/cli/publish.go                |  81 +++++++++++++-
 internal/cli/publish_resolve_test.go   | 198 +++++++++++++++++++++++++++++++++
 internal/pipeline/paths.go             |   7 +-
 skills/printing-press-publish/SKILL.md |  20 ++--
 skills/printing-press/SKILL.md         |  17 +--
 5 files changed, 302 insertions(+), 21 deletions(-)

diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 43dbb96a..6f25db9f 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -225,17 +225,36 @@ func newPublishPackageCmd() *cobra.Command {
 				ModulePath: modulePath,
 			}
 
+			// Look for manuscripts: try CLI name first (new convention),
+			// then API name, then fuzzy resolve (backwards compatibility).
 			apiName := vResult.APIName
 			if apiName == "" {
 				apiName = naming.TrimCLISuffix(cliName)
 			}
 
 			msRoot := pipeline.PublishedManuscriptsRoot()
-			msAPIDir := filepath.Join(msRoot, apiName)
-			runID, err := findMostRecentRun(msAPIDir)
-			if err == nil && runID != "" {
+			var msDir string
+			var runID string
+
+			// 1. Try CLI name (new convention: manuscripts/<cli-name>/<run>/)
+			cliMsDir := filepath.Join(msRoot, cliName)
+			if rid, err := findMostRecentRun(cliMsDir); err == nil && rid != "" {
+				msDir, runID = cliMsDir, rid
+			}
+			// 2. Try API name (old convention: manuscripts/<api-name>/<run>/)
+			if runID == "" {
+				apiMsDir := filepath.Join(msRoot, apiName)
+				if rid, err := findMostRecentRun(apiMsDir); err == nil && rid != "" {
+					msDir, runID = apiMsDir, rid
+				}
+			}
+			// 3. Fuzzy resolve (strip suffixes, prefix match)
+			if runID == "" {
+				msDir, runID = resolveManuscriptDir(msRoot, apiName)
+			}
+			if runID != "" {
 				result.RunID = runID
-				srcMsDir := filepath.Join(msAPIDir, runID)
+				srcMsDir := filepath.Join(msDir, runID)
 				dstMsDir := filepath.Join(stagingCLIDir, ".manuscripts", runID)
 				if err := pipeline.CopyDir(srcMsDir, dstMsDir); err != nil {
 					cleanupTarget()
@@ -374,11 +393,19 @@ func runValidation(dir string) ValidateResult {
 	}
 
 	// 7. Manuscripts check (warn-only)
+	// Try CLI name first (new convention), then API name, then fuzzy resolve
 	apiName := result.APIName
 	if apiName == "" {
 		apiName = naming.TrimCLISuffix(cliName)
 	}
-	msDir := filepath.Join(pipeline.PublishedManuscriptsRoot(), apiName)
+	msRoot := pipeline.PublishedManuscriptsRoot()
+	msDir := filepath.Join(msRoot, cliName)
+	if _, err := os.Stat(msDir); os.IsNotExist(err) {
+		msDir = filepath.Join(msRoot, apiName)
+	}
+	if _, err := os.Stat(msDir); os.IsNotExist(err) {
+		msDir, _ = resolveManuscriptDir(msRoot, apiName)
+	}
 	if _, err := os.Stat(msDir); os.IsNotExist(err) {
 		result.Checks = append(result.Checks, CheckResult{Name: "manuscripts", Passed: true, Warning: "no manuscripts found"})
 	} else {
@@ -565,6 +592,50 @@ func findMostRecentRun(msAPIDir string) (string, error) {
 	return runs[len(runs)-1], nil
 }
 
+// resolveManuscriptDir attempts to find the manuscripts directory for an API
+// when the exact apiName doesn't match a directory. The generator and the skill
+// may use different slugs (e.g., "steam-web" vs "steam"). This function tries:
+//  1. Strip common suffixes: -web, -api, -service
+//  2. Scan directories for prefix matches (e.g., "steam" is a prefix of "steam-web")
+//
+// Returns the resolved directory path and the run ID, or empty strings if not found.
+func resolveManuscriptDir(msRoot, apiName string) (string, string) {
+	// Try stripping common suffixes
+	suffixes := []string{"-web", "-api", "-service", "-public", "-v2", "-v3"}
+	for _, suffix := range suffixes {
+		candidate := strings.TrimSuffix(apiName, suffix)
+		if candidate != apiName {
+			dir := filepath.Join(msRoot, candidate)
+			if runID, err := findMostRecentRun(dir); err == nil && runID != "" {
+				return dir, runID
+			}
+		}
+	}
+
+	// Scan directories for prefix/substring matches
+	entries, err := os.ReadDir(msRoot)
+	if err != nil {
+		return "", ""
+	}
+	for _, e := range entries {
+		if !e.IsDir() {
+			continue
+		}
+		name := e.Name()
+		// Check if either is a prefix of the other WITH a hyphen boundary.
+		// "steam" matches "steam-web" (prefix + hyphen) but NOT "steamgames" (no hyphen).
+		if (strings.HasPrefix(apiName, name+"-") || apiName == name) ||
+			(strings.HasPrefix(name, apiName+"-") || name == apiName) {
+			dir := filepath.Join(msRoot, name)
+			if runID, err := findMostRecentRun(dir); err == nil && runID != "" {
+				return dir, runID
+			}
+		}
+	}
+
+	return "", ""
+}
+
 // hasContent checks if a directory contains at least one non-directory entry,
 // recursively. Returns false for empty directories or directories containing
 // only empty subdirectories.
diff --git a/internal/cli/publish_resolve_test.go b/internal/cli/publish_resolve_test.go
new file mode 100644
index 00000000..f58c7ed5
--- /dev/null
+++ b/internal/cli/publish_resolve_test.go
@@ -0,0 +1,198 @@
+package cli
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// createRunDir creates a manuscripts/<key>/<runID>/research/ structure with a dummy file.
+func createRunDir(t *testing.T, msRoot, key, runID string) {
+	t.Helper()
+	dir := filepath.Join(msRoot, key, runID, "research")
+	require.NoError(t, os.MkdirAll(dir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "brief.md"), []byte("test"), 0o644))
+}
+
+func TestResolveManuscriptDir(t *testing.T) {
+	t.Run("exact match on API name", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam-web", "20260401-120000")
+
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web")
+		assert.Equal(t, filepath.Join(msRoot, "steam-web"), dir)
+		assert.Equal(t, "20260401-120000", runID)
+	})
+
+	t.Run("suffix strip: steam-web from steam-web-api", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam-web", "20260401-120000")
+
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web-api")
+		assert.Equal(t, filepath.Join(msRoot, "steam-web"), dir)
+		assert.Equal(t, "20260401-120000", runID)
+	})
+
+	t.Run("suffix strip: steam from steam-web", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam", "20260401-120000")
+
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web")
+		assert.Equal(t, filepath.Join(msRoot, "steam"), dir)
+		assert.Equal(t, "20260401-120000", runID)
+	})
+
+	t.Run("prefix match: steam dir matches steam-web lookup", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam", "20260401-120000")
+
+		// steam-web-service doesn't strip to "steam" directly,
+		// but "steam" is a prefix of "steam-web-service"
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web-service")
+		assert.Equal(t, filepath.Join(msRoot, "steam"), dir)
+		assert.Equal(t, "20260401-120000", runID)
+	})
+
+	t.Run("no match at all", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "notion", "20260401-120000")
+
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web")
+		assert.Empty(t, dir)
+		assert.Empty(t, runID)
+	})
+
+	t.Run("empty manuscripts root", func(t *testing.T) {
+		msRoot := t.TempDir()
+
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web")
+		assert.Empty(t, dir)
+		assert.Empty(t, runID)
+	})
+
+	t.Run("directory exists but no runs (empty)", func(t *testing.T) {
+		msRoot := t.TempDir()
+		require.NoError(t, os.MkdirAll(filepath.Join(msRoot, "steam"), 0o755))
+
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web")
+		// Directory exists but findMostRecentRun returns "" — no match
+		assert.Empty(t, dir)
+		assert.Empty(t, runID)
+	})
+
+	t.Run("ADVERSARIAL: prefix collision — steam should NOT match steamgames", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam", "20260401-120000")
+
+		// "steamgames" is NOT prefixed by "steam-" — no hyphen boundary
+		dir, runID := resolveManuscriptDir(msRoot, "steamgames")
+		assert.Empty(t, dir, "steam should NOT match steamgames (no hyphen boundary)")
+		assert.Empty(t, runID)
+	})
+
+	t.Run("prefix WITH hyphen boundary works: steam matches steam-web", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam", "20260401-120000")
+
+		// "steam-web" IS prefixed by "steam-" — hyphen boundary present
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web")
+		assert.NotEmpty(t, dir, "steam should match steam-web (hyphen boundary)")
+		assert.Equal(t, "20260401-120000", runID)
+	})
+
+	t.Run("ADVERSARIAL: multiple candidates — picks first alphabetically", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam", "20260401-120000")
+		createRunDir(t, msRoot, "steam-web", "20260401-130000")
+
+		// With API name "steam-web-api", suffix strip finds "steam-web" first
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web-api")
+		assert.Equal(t, filepath.Join(msRoot, "steam-web"), dir)
+		assert.Equal(t, "20260401-130000", runID)
+	})
+
+	t.Run("picks most recent run when multiple exist", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam", "20260331-120000")
+		createRunDir(t, msRoot, "steam", "20260401-120000")
+		createRunDir(t, msRoot, "steam", "20260330-120000")
+
+		dir, runID := resolveManuscriptDir(msRoot, "steam-web")
+		assert.Equal(t, filepath.Join(msRoot, "steam"), dir)
+		assert.Equal(t, "20260401-120000", runID) // most recent by lexicographic sort
+	})
+}
+
+func TestManuscriptLookupPriority(t *testing.T) {
+	// Simulates the full lookup chain: CLI name → API name → fuzzy resolve
+	// This is what the publish package command does.
+
+	t.Run("prefers CLI name over API name", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam-web-pp-cli", "run-cli")
+		createRunDir(t, msRoot, "steam-web", "run-api")
+		createRunDir(t, msRoot, "steam", "run-slug")
+
+		cliName := "steam-web-pp-cli"
+
+		// Step 1: CLI name
+		cliDir := filepath.Join(msRoot, cliName)
+		runID, err := findMostRecentRun(cliDir)
+		assert.NoError(t, err)
+		assert.Equal(t, "run-cli", runID) // CLI name wins
+	})
+
+	t.Run("falls back to API name when CLI name missing", func(t *testing.T) {
+		msRoot := t.TempDir()
+		// No CLI name directory
+		createRunDir(t, msRoot, "steam-web", "run-api")
+		createRunDir(t, msRoot, "steam", "run-slug")
+
+		cliName := "steam-web-pp-cli"
+
+		// Step 1: CLI name — fails
+		cliDir := filepath.Join(msRoot, cliName)
+		cliRunID, _ := findMostRecentRun(cliDir)
+		assert.Empty(t, cliRunID)
+
+		// Step 2: API name
+		apiDir := filepath.Join(msRoot, "steam-web")
+		apiRunID, err := findMostRecentRun(apiDir)
+		assert.NoError(t, err)
+		assert.Equal(t, "run-api", apiRunID) // API name is second priority
+	})
+
+	t.Run("falls back to fuzzy when both CLI and API names missing", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "steam", "run-slug")
+
+		apiName := "steam-web"
+
+		// Steps 1+2 fail, step 3: fuzzy resolve
+		dir, runID := resolveManuscriptDir(msRoot, apiName)
+		assert.Equal(t, filepath.Join(msRoot, "steam"), dir)
+		assert.Equal(t, "run-slug", runID)
+	})
+
+	t.Run("returns empty when nothing matches", func(t *testing.T) {
+		msRoot := t.TempDir()
+		createRunDir(t, msRoot, "notion", "run-notion")
+
+		cliName := "steam-web-pp-cli"
+		apiName := "steam-web"
+
+		cliDir := filepath.Join(msRoot, cliName)
+		runID, _ := findMostRecentRun(cliDir)
+		assert.Empty(t, runID)
+
+		apiDir := filepath.Join(msRoot, apiName)
+		runID, _ = findMostRecentRun(apiDir)
+		assert.Empty(t, runID)
+
+		_, runID = resolveManuscriptDir(msRoot, apiName)
+		assert.Empty(t, runID)
+	})
+}
diff --git a/internal/pipeline/paths.go b/internal/pipeline/paths.go
index 47dc4328..0987c865 100644
--- a/internal/pipeline/paths.go
+++ b/internal/pipeline/paths.go
@@ -105,8 +105,11 @@ func PublishedManuscriptsRoot() string {
 	return filepath.Join(PressHome(), "manuscripts")
 }
 
-func ArchivedManuscriptDir(apiName, runID string) string {
-	return filepath.Join(PublishedManuscriptsRoot(), apiName, runID)
+// ArchivedManuscriptDir returns the path to a manuscript archive.
+// The key is typically the CLI name (e.g., "steam-web-pp-cli") but may be
+// an API slug (e.g., "steam") for backwards compatibility with older archives.
+func ArchivedManuscriptDir(key, runID string) string {
+	return filepath.Join(PublishedManuscriptsRoot(), key, runID)
 }
 
 func ArchivedResearchDir(apiName, runID string) string {
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index f494e6e2..8a68ff78 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -325,19 +325,25 @@ cp -r <staging-dir>/library/* "$PUBLISH_REPO_DIR/library/"
 
 ### Update registry.json
 
-Read `$PUBLISH_REPO_DIR/registry.json`, add or update the entry for this CLI:
+The registry file has this structure:
 
 ```json
 {
-  "cli_name": "<cli-name>",
-  "api_name": "<api-name>",
-  "category": "<category>",
-  "description": "<from manifest or empty>",
-  "printing_press_version": "<from manifest>",
-  "published_date": "<today YYYY-MM-DD>"
+  "schema_version": 1,
+  "entries": [
+    {
+      "name": "<cli-name>",
+      "category": "<category>",
+      "api": "<api-display-name>",
+      "description": "<from manifest or README>",
+      "path": "library/<category>/<cli-name>"
+    }
+  ]
 }
 ```
 
+Read `$PUBLISH_REPO_DIR/registry.json`, parse the `entries` array (not the top-level object), add or update the entry for this CLI. Match on `name` field. Preserve `schema_version` and any other top-level fields.
+
 Write back with `jq` or via the Write tool.
 
 ### Commit and push
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index f7163826..434af4a6 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -286,7 +286,7 @@ Maintain a lightweight state file at `$STATE_FILE` so `/printing-press-score` ca
 }
 ```
 
-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.
+Active mutable work lives under `$PRESS_RUNSTATE/`. Published CLIs live under `$PRESS_LIBRARY/`. Archived research and verification evidence live under `$PRESS_MANUSCRIPTS/<cli-name>/<run-id>/` (keyed by CLI name, e.g., `steam-web-pp-cli`, not the API slug). 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`
@@ -295,7 +295,7 @@ Examples of the current naming/layout to preserve:
 
 ## Outputs
 
-Every run writes up to 5 concise artifacts under the current managed run and archives them to `$PRESS_MANUSCRIPTS/<api>/<run-id>/`:
+Every run writes up to 5 concise artifacts under the current managed run and archives them to `$PRESS_MANUSCRIPTS/<cli-name>/<run-id>/`:
 
 1. `research/<stamp>-feat-<api>-pp-cli-brief.md`
 2. `research/<stamp>-feat-<api>-pp-cli-absorb-manifest.md`
@@ -344,7 +344,7 @@ Before new research:
    - If the user passed `--spec`, use it directly (existing behavior).
    - Otherwise, proceed with normal discovery (catalog, KnownSpecs, apis-guru, web search).
 2. Check for prior research in:
-   - `$PRESS_MANUSCRIPTS/<api>/*/research/*`
+   - `$PRESS_MANUSCRIPTS/<cli-name>/*/research/*` (also check `$PRESS_MANUSCRIPTS/<api>/*/research/*` for backwards compatibility)
    - `$REPO_ROOT/docs/plans/*<api>*` (legacy fallback)
 3. Reuse good prior work instead of redoing it.
 4. **Library Check** — Check if a CLI for this API already exists in the library and present the user with context and options.
@@ -1844,9 +1844,12 @@ future `/printing-press` runs on the same API. Publishing ships the CLI to the
 library repo. A run that isn't ready to publish still produces valuable research.
 
 ```bash
-mkdir -p "$PRESS_MANUSCRIPTS/<api>/$RUN_ID"
-cp -r "$RESEARCH_DIR" "$PRESS_MANUSCRIPTS/<api>/$RUN_ID/research" 2>/dev/null || true
-cp -r "$PROOFS_DIR" "$PRESS_MANUSCRIPTS/<api>/$RUN_ID/proofs" 2>/dev/null || true
+# Archive under CLI name (e.g., steam-web-pp-cli), not API slug (e.g., steam).
+# The CLI name is unambiguous and matches what publish expects.
+CLI_NAME="$(basename "$PRESS_LIBRARY/<api>-pp-cli")"
+mkdir -p "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID"
+cp -r "$RESEARCH_DIR" "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID/research" 2>/dev/null || true
+cp -r "$PROOFS_DIR" "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID/proofs" 2>/dev/null || true
 
 # Archive discovery artifacts (sniff captures, URL lists, sniff report).
 # Remove session state before archiving — contains authentication cookies/tokens.
@@ -1859,7 +1862,7 @@ if [ -d "$DISCOVERY_DIR" ]; then
       jq 'del(.log.entries[].response.content.text)' "$har" > "${har}.stripped" 2>/dev/null && mv "${har}.stripped" "$har" || rm -f "${har}.stripped"
     fi
   done
-  cp -r "$DISCOVERY_DIR" "$PRESS_MANUSCRIPTS/<api>/$RUN_ID/discovery" 2>/dev/null || true
+  cp -r "$DISCOVERY_DIR" "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID/discovery" 2>/dev/null || true
 fi
 ```
 

← fc94557c fix(skills): add secret leak prevention rules (#107)  ·  back to Cli Printing Press  ·  refactor(skills): extract conditional sections to reference 998908b5 →