[object Object]

← back to Cli Printing Press

feat(cli): Hermes/OpenClaw frontmatter alignment for printed CLIs (#655)

fd7fa6eaec3cd5dfa5fbd1fc357def843b356125 · 2026-05-06 19:23:39 -0700 · Trevin Chow

* docs(cli): add Hermes/OpenClaw frontmatter alignment plan

Captures the v1 scope: strip OpenClaw env-var declarations (false-positive
risk on harvested credentials), add Hermes-recognized top-level fields,
move the existing CLI installation section near the top of SKILL.md so
agent hosts read it as a precondition, add Install via Hermes/OpenClaw
README sections.

Cross-repo work tracked: U5 (workflow trigger fix) and U6 (one-time sweep
tool) in printing-press-library; 5 reprints in #654.

Supersedes 2026-05-06-001 after two doc-review rounds.

* feat(cli): plumb pressVersion helper and OwnerName field for Hermes

Adds the two pieces of generator infrastructure that the Hermes
frontmatter emission (forthcoming U1) needs:

  * `pressVersion` template helper with two-tier resolution. When
    .printing-press.json exists at the output dir (regen / sweep
    path), reads `printing_press_version` so the SKILL.md `version:`
    field stays stable across Press releases that don't touch this
    CLI. When the manifest is absent (fresh-print path — the manifest
    is written during publish, after templates render), falls back
    to the live version.Version constant.

  * `OwnerName` field on spec.APISpec, sourced via
    `resolveOwnerNameForExisting` / `resolveOwnerNameForNew`. Reads
    raw `git config user.name` — no sanitization (preserves "Trevin
    Chow" verbatim, which `resolveOwnerForNew`'s sanitizeOwner would
    have collapsed to "trevin-chow"), no fallback to "USER" (would
    publish an obviously-wrong author). Empty values are rejected
    in Generate() before any file write so a broken `author:` field
    never reaches the public library.

OwnerName is distinct from the existing slug-shaped Owner field,
which continues to drive Go module paths and copyright headers.
The two coexist on the spec: slug for paths, prose for prose.

U4 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md

* feat(cli)!: emit Hermes-recognized top-level fields in SKILL.md, drop OpenClaw env declarations

Strips the OpenClaw env-var declaration shapes from the SKILL.md template:

  * `metadata.openclaw.requires.env` (the conditional env list)
  * `metadata.openclaw.envVars` (the rich envVars block, with all
    its Kind-aware description branching)

The classification problem these blocks tried to solve — distinguishing
user-set credentials (api_key tokens) from harvested ones (cookies, OAuth
refresh tokens) — is asymmetric on failure. False-positive on a harvested
var prompts the user for a value the CLI can't accept. The two tooling
attempts that landed (PR #632/#639's EnvVarSpecs widen, then various
classifier predicates) each surfaced new edge cases at the boundary.
v1 ships no env-var declarations in either format; the existing
auth.Type-branched README content (api_key→env var, cookie/composed→
auth login --chrome, oauth2→auth login, bearer_token→set-token or env
var) carries credential UX without re-introducing the classification
risk.

Adds the Hermes-recognized top-level frontmatter fields:

  * `version: "{{pressVersion}}"`  — two-tier resolver from U4
  * `author: "{{yamlDoubleQuoted .OwnerName}}"`  — prose name
  * `license: "Apache-2.0"`  — constant; printed CLIs are already
    Apache 2.0 via LICENSE.tmpl

The OpenClaw `requires.bins` and `install` blocks stay as-is — they're
the structured install signal OpenClaw consumers depend on. Hermes
ignores unknown keys per its docs; the two formats coexist in one
SKILL.md without a versioned schema gate.

BREAKING CHANGE: SKILL.md frontmatter shape changes for every printed
CLI on next regen. The library-wide sweep that lands the same shape
into already-published library entries is U6 (forthcoming, in
printing-press-library).

U1 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md

* feat(cli)!: move CLI install instructions to Prerequisites section at top of SKILL.md

Real-world signal from a Hermes session: the agent loaded a printed-CLI
skill but didn't install the underlying binary because the install
information sat at line ~348 of the rendered SKILL.md — after Command
Reference, after Auth Setup, after Argument Parsing. LLM-driven agents
read top-down and decided what to run before reaching it. The install
metadata lived in `metadata.openclaw.requires.bins` + `install`, but
Hermes doesn't read OpenClaw metadata, so the only signal an agent
host's LLM had was buried prose.

Moves the existing `## CLI Installation` section to immediately after
the H1, renames it `## Prerequisites: Install the CLI` to signal a
precondition, and rewrites the lead with imperative wording:

  > This skill drives the `<cli>-pp-cli` binary. **You must verify
  > the CLI is installed before invoking any command from this
  > skill.** If it is missing, install it first: ...

The bold "must verify" + explicit failure mode at the bottom ("Do not
proceed with skill commands until verification succeeds") signals it
as instruction, not reference. Plain markdown — any agent host's LLM
reads it regardless of frontmatter format support.

Updates the Argument Parsing routing rule and the Direct Use lookup
to point at "Prerequisites" instead of the now-removed "CLI
Installation" heading. MCP Server Installation stays in its existing
location (different surface, different audience).

BREAKING CHANGE: SKILL.md body shape changes for every printed CLI
on next regen. The library-wide sweep that lands the same shape into
already-published library entries is U6 (forthcoming).

U2 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md

* feat(cli): add Install via Hermes and Install via OpenClaw README sections

Slots two new install sections after Use with Claude Desktop, before
the Manual JSON config <details> block. Both formats use the full
mvanhorn/printing-press-library/cli-skills path — earlier drafts in
the conversation used a shorter mvanhorn/cli-skills form for the
Hermes CLI command that doesn't resolve in practice.

Hermes section emits both forms (CLI install + chat-session /skills)
in bash code fences. OpenClaw section emits the user-confirmed
"Tell your OpenClaw agent (copy this)" wording inside a plain code
fence so the operator can paste the agent instruction verbatim.

A `<!-- pp-hermes-install-anchor -->` HTML comment marks the insertion
point so the cross-repo sweep tool (U6, forthcoming) can locate the
position idempotently when it patches legacy READMEs that predate
this template change. Sweep-tool fallback chain (Use with Claude
Desktop → Use with Claude Code → ## Install → EOF) handles READMEs
without the anchor.

URLs hardcode mvanhorn/printing-press-library; no abstraction layer
for what's currently a single constant.

U3 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md

* chore(cli): retire tools/migrate-skill-metadata

The migrator was a one-shot historical conversion that ran across the
library to convert legacy `metadata: '{...JSON-string...}'` frontmatter
into the nested-YAML shape. Long since landed; the tool has been
dormant.

Its emission shape had drifted from the live skill.md.tmpl template:
still emits `primaryEnv`, requires.bins-before-env ordering, no
envVars block. After U1's changes (strip OpenClaw env declarations,
add Hermes top-level fields), the drift is wider — re-running this
tool would actively regress published SKILL.md files. Delete to
remove the foot-gun.

Verified no internal imports reference the package. Removes the
matching `/migrate-skill-metadata` line from .gitignore (the binary
output that line covered no longer has a source).

If a similar one-shot migration is ever needed in the future, write
a fresh tool then; the precedent (line-targeted text replacement,
idempotency tests) is captured in commit history.

U7 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md

* test(cli): update golden fixtures for Hermes/OpenClaw frontmatter alignment

Regenerates the SKILL.md and README.md fixtures for both
generate-golden-api and generate-golden-api-rich-auth cases. Diffs
are exactly the intentional changes from U1, U2, U3:

  * Top-level Hermes fields (`version`, `author`, `license`)
    appear after `description:` (U1)
  * `requires.env` line and `envVars:` block removed from the
    OpenClaw block (U1)
  * `## Prerequisites: Install the CLI` section appears after H1,
    before the value-prop paragraph (U2)
  * `## CLI Installation` section removed from its old position
    after Argument Parsing (U2)
  * Argument Parsing rule and Direct Use lookup updated to
    reference Prerequisites (U2)
  * `## Install via Hermes` and `## Install via OpenClaw` sections
    appear after Use with Claude Desktop, with the
    `<!-- pp-hermes-install-anchor -->` HTML comment marker (U3)

The fixtures use `printing-press-golden` and `printing-press-rich-auth`
as the YAML `author:` value because the golden harness sets both
`github.user` and `user.name` to the same slug-shaped value. Real
publishes resolve OwnerName via `git config user.name` (display name);
the harness's deterministic value is just a fixture detail.

* refactor(cli): collapse three near-identical manifest readers into one helper

readManifestOwner, readManifestOwnerName, and readManifestPressVersion
were 12-line near-clones differing only in the JSON field they
extracted from .printing-press.json. Replace with a single
readManifestField(outputDir, key) helper plus three thin wrappers.

Net: 30 lines collapsed to 12. AGENTS.md "Pre-commit: scan the diff"
rule explicitly calls out near-identical functions as something to
share — three call sites is the threshold.

Also strips two plan-step references ("(post-U2)", "(U6)") from test
comments per AGENTS.md "No dates, incidents, or ticket numbers in
code comments". The surrounding wording carries the same intent.

No behavior change. 825 tests in internal/generator + internal/spec
still pass; go vet clean.

Surfaces from /simplify review.

* fix(cli): soften empty-OwnerName validation to a warning + slug fallback

Generate() previously hard-errored on `spec.OwnerName == ""`. That broke
~50 tests in CI that construct APISpec inline (not via minimalSpec) and
don't set the field, because GitHub Actions runners don't have
`git config user.name` set so the resolution chain returns empty.

The validation was over-strict for a library that's reused by many
callers (tests, mcp-sync, regen-merge — none of which want to plumb
ownership through). Soften to a stderr warning + fallback to the
slug-shaped Owner, so emission is non-empty and visibly degraded
(`author: "trevin-chow"` instead of `author: "Trevin Chow"`).

Production paths (a real `/printing-press` invocation by a user with
git config set) never hit the fallback — their git user.name resolves
correctly. The library-wide sweep tool's per-CLI authorship mapping in
printing-press-library/tools/sweep-frontmatter overrides this code path
entirely. Only failure mode is: a user without git config running a
fresh print, who sees a stderr warning AND a slug-shaped author in
their output. Loud enough to catch.

* feat(cli)!: prefer npx-based installer over raw go install in Prerequisites

Switches the Prerequisites section's install command from a raw
`go install ...@latest` to `npx -y @mvanhorn/printing-press install
<api> --cli-only`, with the raw Go install retained as a documented
fallback.

Why: the `@mvanhorn/printing-press` installer is already shipped as a
centralized install path for these CLIs (the README's `## Use with
Claude Code` section uses the same package for skill installs). Routing
binary installs through it gives us aggregate npmjs download stats —
imperfect but meaningfully better than the zero usage signal raw
`go install` provides today. Same on-disk result, same verification
step, same `$PATH` guidance — just a shorter, more readable command
that flows through tracked install infrastructure.

The npx form takes the api-name (`mercury`) instead of the long module
path (`github.com/mvanhorn/printing-press-library/library/payments/
mercury/cmd/mercury-pp-cli`). The Go fallback below it preserves the
escape hatch for offline environments or systems without Node.

BREAKING CHANGE: Prerequisites section content changes for every
printed CLI on next regen. The library-wide sweep is shipping the
same shape across the existing 49 entries.

Goldens regenerated for both `generate-golden-api` and
`generate-golden-api-rich-auth`. Full test suite (578 generator tests)
passes.

* docs(cli): apply review polish — plan accuracy, AGENTS note, fallback test

Items the full-PR re-review surfaced:

  * Plan doc: 4 references to "fails generation early" / "errors
    clearly" updated to reflect the soft-fallback behavior actually
    landed in ae25d568 (warning + slug fallback, not hard error).
  * Plan doc: 2 references to tools/migrate-skill-metadata updated to
    note the file was retired in U7 of this same plan.
  * AGENTS.md: succinct "Owner (slug) vs OwnerName (display)" note
    added to the existing Naming and Disambiguation section. Captures
    the resolution-path divergence, the YAML-escape requirement, and
    the soft-fallback behavior. Future contributors hit this in
    AGENTS.md before they hit the diff.
  * scripts/golden.sh: one-line comment near the GIT_CONFIG injection
    explaining that the slug-shaped author: field in golden fixtures
    is a deterministic-fixture artifact, not the production shape.
    Pre-empts the "is this a regression?" question on regen.
  * Test: TestGenerateSoftFallsBackOnEmptyOwnerName — covers the
    soft-fallback path explicitly. Asserts no error, OwnerName gets
    populated from Owner, rendered SKILL.md emits author: "<slug>"
    rather than empty.

No behavior change. 579 generator tests pass, go vet clean, goldens
verify cleanly.

* docs(cli): capture 5 learnings from Hermes/OpenClaw frontmatter alignment

Compounds knowledge from this session into docs/solutions/. Each
captures a generalizable pattern, not just the specific fix.

Three learnings about generator/library design (apply repo-wide):

  * design-patterns/dual-key-identity-fields-2026-05-06.md
    Owner (slug) vs OwnerName (display) — the structural pattern of
    keeping path-shaped and prose-shaped representations of one
    conceptual identity as separate fields with separate resolution
    paths. Generalizable beyond the owner case.

  * conventions/soft-validation-in-reusable-library-packages-2026-05-06.md
    When a library package serves many callers (production CLI, tests,
    mcp-sync, regen-merge), hard validation can break the test/tool
    callers without producing correctness value. Stderr warn + degraded-
    but-visible fallback lets the production path catch real
    misconfigurations while keeping the library reusable.

  * design-patterns/avoid-classification-when-failure-is-asymmetric-2026-05-06.md
    The biggest lesson from two rounds of plan review: when a
    classifier's false-positive cost is much greater than its false-
    negative cost, even a 99%-accurate classifier may be wrong to
    ship. Specific application: env-var hoisting in agent-host
    frontmatter; general principle applies anywhere classification
    routes user-visible behavior.

Two learnings clearly noting cross-repo coupling with mvanhorn/
printing-press-library (the lesson lives here because it's our
template/generator changes that introduce the coupling):

  * conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md
    A sweep tool over multi-author content should source authorship
    from primary evidence (copyright headers, git first-committer),
    not from the operator's git config. Captures the curated-mapping
    approach used in printing-press-library's sweep-frontmatter tool.

  * conventions/external-tool-flags-need-validator-allowlist-2026-05-06.md
    SKILL.md install instructions embed flags from external tools
    (npx, hermes, claude mcp). Doc validators that scope to the file's
    "owning" CLI's source can't tell those apart from real CLI flags.
    Allowlist mechanism + cross-repo coordination required.

  * best-practices/auto-mirror-workflow-trigger-coverage-2026-05-06.md
    Source/mirror invariants where the mirror regenerates on push to
    main but verifies on PR — PR authors need to regenerate locally
    before commit. Documents the upstream-template-change ->
    downstream-mirror coupling.

All five docs follow knowledge-track schema with concrete code
examples, applies_when conditions, and "when not to apply" anti-
patterns. Cross-references thread between them and to existing
auth-envvar-rich-model learning.

AGENTS.md already surfaces docs/solutions/ at line 85; no
discoverability edit needed.

* docs(cli): merge two cross-repo learnings into one coordinated-coupling doc

Replaces:
  - best-practices/auto-mirror-workflow-trigger-coverage-2026-05-06.md
  - conventions/external-tool-flags-need-validator-allowlist-2026-05-06.md

With:
  - best-practices/cross-repo-coordination-with-printing-press-library-2026-05-06.md

Both deleted docs covered the same shape — invisible CI couplings
between cli-printing-press template/schema changes and downstream
printing-press-library workflows. Two specific failure modes (mirror
parity and validator allowlist) are now sections within one doc,
fronted by a pre-flight checklist that future template-change authors
can scan at-merge time.

The merged doc keeps both concrete examples but drops the duplication.
The pre-flight checklist section is the operationally-useful piece that
the original two docs didn't surface explicitly.

Files touched

Diff

commit fd7fa6eaec3cd5dfa5fbd1fc357def843b356125
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Wed May 6 19:23:39 2026 -0700

    feat(cli): Hermes/OpenClaw frontmatter alignment for printed CLIs (#655)
    
    * docs(cli): add Hermes/OpenClaw frontmatter alignment plan
    
    Captures the v1 scope: strip OpenClaw env-var declarations (false-positive
    risk on harvested credentials), add Hermes-recognized top-level fields,
    move the existing CLI installation section near the top of SKILL.md so
    agent hosts read it as a precondition, add Install via Hermes/OpenClaw
    README sections.
    
    Cross-repo work tracked: U5 (workflow trigger fix) and U6 (one-time sweep
    tool) in printing-press-library; 5 reprints in #654.
    
    Supersedes 2026-05-06-001 after two doc-review rounds.
    
    * feat(cli): plumb pressVersion helper and OwnerName field for Hermes
    
    Adds the two pieces of generator infrastructure that the Hermes
    frontmatter emission (forthcoming U1) needs:
    
      * `pressVersion` template helper with two-tier resolution. When
        .printing-press.json exists at the output dir (regen / sweep
        path), reads `printing_press_version` so the SKILL.md `version:`
        field stays stable across Press releases that don't touch this
        CLI. When the manifest is absent (fresh-print path — the manifest
        is written during publish, after templates render), falls back
        to the live version.Version constant.
    
      * `OwnerName` field on spec.APISpec, sourced via
        `resolveOwnerNameForExisting` / `resolveOwnerNameForNew`. Reads
        raw `git config user.name` — no sanitization (preserves "Trevin
        Chow" verbatim, which `resolveOwnerForNew`'s sanitizeOwner would
        have collapsed to "trevin-chow"), no fallback to "USER" (would
        publish an obviously-wrong author). Empty values are rejected
        in Generate() before any file write so a broken `author:` field
        never reaches the public library.
    
    OwnerName is distinct from the existing slug-shaped Owner field,
    which continues to drive Go module paths and copyright headers.
    The two coexist on the spec: slug for paths, prose for prose.
    
    U4 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md
    
    * feat(cli)!: emit Hermes-recognized top-level fields in SKILL.md, drop OpenClaw env declarations
    
    Strips the OpenClaw env-var declaration shapes from the SKILL.md template:
    
      * `metadata.openclaw.requires.env` (the conditional env list)
      * `metadata.openclaw.envVars` (the rich envVars block, with all
        its Kind-aware description branching)
    
    The classification problem these blocks tried to solve — distinguishing
    user-set credentials (api_key tokens) from harvested ones (cookies, OAuth
    refresh tokens) — is asymmetric on failure. False-positive on a harvested
    var prompts the user for a value the CLI can't accept. The two tooling
    attempts that landed (PR #632/#639's EnvVarSpecs widen, then various
    classifier predicates) each surfaced new edge cases at the boundary.
    v1 ships no env-var declarations in either format; the existing
    auth.Type-branched README content (api_key→env var, cookie/composed→
    auth login --chrome, oauth2→auth login, bearer_token→set-token or env
    var) carries credential UX without re-introducing the classification
    risk.
    
    Adds the Hermes-recognized top-level frontmatter fields:
    
      * `version: "{{pressVersion}}"`  — two-tier resolver from U4
      * `author: "{{yamlDoubleQuoted .OwnerName}}"`  — prose name
      * `license: "Apache-2.0"`  — constant; printed CLIs are already
        Apache 2.0 via LICENSE.tmpl
    
    The OpenClaw `requires.bins` and `install` blocks stay as-is — they're
    the structured install signal OpenClaw consumers depend on. Hermes
    ignores unknown keys per its docs; the two formats coexist in one
    SKILL.md without a versioned schema gate.
    
    BREAKING CHANGE: SKILL.md frontmatter shape changes for every printed
    CLI on next regen. The library-wide sweep that lands the same shape
    into already-published library entries is U6 (forthcoming, in
    printing-press-library).
    
    U1 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md
    
    * feat(cli)!: move CLI install instructions to Prerequisites section at top of SKILL.md
    
    Real-world signal from a Hermes session: the agent loaded a printed-CLI
    skill but didn't install the underlying binary because the install
    information sat at line ~348 of the rendered SKILL.md — after Command
    Reference, after Auth Setup, after Argument Parsing. LLM-driven agents
    read top-down and decided what to run before reaching it. The install
    metadata lived in `metadata.openclaw.requires.bins` + `install`, but
    Hermes doesn't read OpenClaw metadata, so the only signal an agent
    host's LLM had was buried prose.
    
    Moves the existing `## CLI Installation` section to immediately after
    the H1, renames it `## Prerequisites: Install the CLI` to signal a
    precondition, and rewrites the lead with imperative wording:
    
      > This skill drives the `<cli>-pp-cli` binary. **You must verify
      > the CLI is installed before invoking any command from this
      > skill.** If it is missing, install it first: ...
    
    The bold "must verify" + explicit failure mode at the bottom ("Do not
    proceed with skill commands until verification succeeds") signals it
    as instruction, not reference. Plain markdown — any agent host's LLM
    reads it regardless of frontmatter format support.
    
    Updates the Argument Parsing routing rule and the Direct Use lookup
    to point at "Prerequisites" instead of the now-removed "CLI
    Installation" heading. MCP Server Installation stays in its existing
    location (different surface, different audience).
    
    BREAKING CHANGE: SKILL.md body shape changes for every printed CLI
    on next regen. The library-wide sweep that lands the same shape into
    already-published library entries is U6 (forthcoming).
    
    U2 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md
    
    * feat(cli): add Install via Hermes and Install via OpenClaw README sections
    
    Slots two new install sections after Use with Claude Desktop, before
    the Manual JSON config <details> block. Both formats use the full
    mvanhorn/printing-press-library/cli-skills path — earlier drafts in
    the conversation used a shorter mvanhorn/cli-skills form for the
    Hermes CLI command that doesn't resolve in practice.
    
    Hermes section emits both forms (CLI install + chat-session /skills)
    in bash code fences. OpenClaw section emits the user-confirmed
    "Tell your OpenClaw agent (copy this)" wording inside a plain code
    fence so the operator can paste the agent instruction verbatim.
    
    A `<!-- pp-hermes-install-anchor -->` HTML comment marks the insertion
    point so the cross-repo sweep tool (U6, forthcoming) can locate the
    position idempotently when it patches legacy READMEs that predate
    this template change. Sweep-tool fallback chain (Use with Claude
    Desktop → Use with Claude Code → ## Install → EOF) handles READMEs
    without the anchor.
    
    URLs hardcode mvanhorn/printing-press-library; no abstraction layer
    for what's currently a single constant.
    
    U3 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md
    
    * chore(cli): retire tools/migrate-skill-metadata
    
    The migrator was a one-shot historical conversion that ran across the
    library to convert legacy `metadata: '{...JSON-string...}'` frontmatter
    into the nested-YAML shape. Long since landed; the tool has been
    dormant.
    
    Its emission shape had drifted from the live skill.md.tmpl template:
    still emits `primaryEnv`, requires.bins-before-env ordering, no
    envVars block. After U1's changes (strip OpenClaw env declarations,
    add Hermes top-level fields), the drift is wider — re-running this
    tool would actively regress published SKILL.md files. Delete to
    remove the foot-gun.
    
    Verified no internal imports reference the package. Removes the
    matching `/migrate-skill-metadata` line from .gitignore (the binary
    output that line covered no longer has a source).
    
    If a similar one-shot migration is ever needed in the future, write
    a fresh tool then; the precedent (line-targeted text replacement,
    idempotency tests) is captured in commit history.
    
    U7 of docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md
    
    * test(cli): update golden fixtures for Hermes/OpenClaw frontmatter alignment
    
    Regenerates the SKILL.md and README.md fixtures for both
    generate-golden-api and generate-golden-api-rich-auth cases. Diffs
    are exactly the intentional changes from U1, U2, U3:
    
      * Top-level Hermes fields (`version`, `author`, `license`)
        appear after `description:` (U1)
      * `requires.env` line and `envVars:` block removed from the
        OpenClaw block (U1)
      * `## Prerequisites: Install the CLI` section appears after H1,
        before the value-prop paragraph (U2)
      * `## CLI Installation` section removed from its old position
        after Argument Parsing (U2)
      * Argument Parsing rule and Direct Use lookup updated to
        reference Prerequisites (U2)
      * `## Install via Hermes` and `## Install via OpenClaw` sections
        appear after Use with Claude Desktop, with the
        `<!-- pp-hermes-install-anchor -->` HTML comment marker (U3)
    
    The fixtures use `printing-press-golden` and `printing-press-rich-auth`
    as the YAML `author:` value because the golden harness sets both
    `github.user` and `user.name` to the same slug-shaped value. Real
    publishes resolve OwnerName via `git config user.name` (display name);
    the harness's deterministic value is just a fixture detail.
    
    * refactor(cli): collapse three near-identical manifest readers into one helper
    
    readManifestOwner, readManifestOwnerName, and readManifestPressVersion
    were 12-line near-clones differing only in the JSON field they
    extracted from .printing-press.json. Replace with a single
    readManifestField(outputDir, key) helper plus three thin wrappers.
    
    Net: 30 lines collapsed to 12. AGENTS.md "Pre-commit: scan the diff"
    rule explicitly calls out near-identical functions as something to
    share — three call sites is the threshold.
    
    Also strips two plan-step references ("(post-U2)", "(U6)") from test
    comments per AGENTS.md "No dates, incidents, or ticket numbers in
    code comments". The surrounding wording carries the same intent.
    
    No behavior change. 825 tests in internal/generator + internal/spec
    still pass; go vet clean.
    
    Surfaces from /simplify review.
    
    * fix(cli): soften empty-OwnerName validation to a warning + slug fallback
    
    Generate() previously hard-errored on `spec.OwnerName == ""`. That broke
    ~50 tests in CI that construct APISpec inline (not via minimalSpec) and
    don't set the field, because GitHub Actions runners don't have
    `git config user.name` set so the resolution chain returns empty.
    
    The validation was over-strict for a library that's reused by many
    callers (tests, mcp-sync, regen-merge — none of which want to plumb
    ownership through). Soften to a stderr warning + fallback to the
    slug-shaped Owner, so emission is non-empty and visibly degraded
    (`author: "trevin-chow"` instead of `author: "Trevin Chow"`).
    
    Production paths (a real `/printing-press` invocation by a user with
    git config set) never hit the fallback — their git user.name resolves
    correctly. The library-wide sweep tool's per-CLI authorship mapping in
    printing-press-library/tools/sweep-frontmatter overrides this code path
    entirely. Only failure mode is: a user without git config running a
    fresh print, who sees a stderr warning AND a slug-shaped author in
    their output. Loud enough to catch.
    
    * feat(cli)!: prefer npx-based installer over raw go install in Prerequisites
    
    Switches the Prerequisites section's install command from a raw
    `go install ...@latest` to `npx -y @mvanhorn/printing-press install
    <api> --cli-only`, with the raw Go install retained as a documented
    fallback.
    
    Why: the `@mvanhorn/printing-press` installer is already shipped as a
    centralized install path for these CLIs (the README's `## Use with
    Claude Code` section uses the same package for skill installs). Routing
    binary installs through it gives us aggregate npmjs download stats —
    imperfect but meaningfully better than the zero usage signal raw
    `go install` provides today. Same on-disk result, same verification
    step, same `$PATH` guidance — just a shorter, more readable command
    that flows through tracked install infrastructure.
    
    The npx form takes the api-name (`mercury`) instead of the long module
    path (`github.com/mvanhorn/printing-press-library/library/payments/
    mercury/cmd/mercury-pp-cli`). The Go fallback below it preserves the
    escape hatch for offline environments or systems without Node.
    
    BREAKING CHANGE: Prerequisites section content changes for every
    printed CLI on next regen. The library-wide sweep is shipping the
    same shape across the existing 49 entries.
    
    Goldens regenerated for both `generate-golden-api` and
    `generate-golden-api-rich-auth`. Full test suite (578 generator tests)
    passes.
    
    * docs(cli): apply review polish — plan accuracy, AGENTS note, fallback test
    
    Items the full-PR re-review surfaced:
    
      * Plan doc: 4 references to "fails generation early" / "errors
        clearly" updated to reflect the soft-fallback behavior actually
        landed in ae25d568 (warning + slug fallback, not hard error).
      * Plan doc: 2 references to tools/migrate-skill-metadata updated to
        note the file was retired in U7 of this same plan.
      * AGENTS.md: succinct "Owner (slug) vs OwnerName (display)" note
        added to the existing Naming and Disambiguation section. Captures
        the resolution-path divergence, the YAML-escape requirement, and
        the soft-fallback behavior. Future contributors hit this in
        AGENTS.md before they hit the diff.
      * scripts/golden.sh: one-line comment near the GIT_CONFIG injection
        explaining that the slug-shaped author: field in golden fixtures
        is a deterministic-fixture artifact, not the production shape.
        Pre-empts the "is this a regression?" question on regen.
      * Test: TestGenerateSoftFallsBackOnEmptyOwnerName — covers the
        soft-fallback path explicitly. Asserts no error, OwnerName gets
        populated from Owner, rendered SKILL.md emits author: "<slug>"
        rather than empty.
    
    No behavior change. 579 generator tests pass, go vet clean, goldens
    verify cleanly.
    
    * docs(cli): capture 5 learnings from Hermes/OpenClaw frontmatter alignment
    
    Compounds knowledge from this session into docs/solutions/. Each
    captures a generalizable pattern, not just the specific fix.
    
    Three learnings about generator/library design (apply repo-wide):
    
      * design-patterns/dual-key-identity-fields-2026-05-06.md
        Owner (slug) vs OwnerName (display) — the structural pattern of
        keeping path-shaped and prose-shaped representations of one
        conceptual identity as separate fields with separate resolution
        paths. Generalizable beyond the owner case.
    
      * conventions/soft-validation-in-reusable-library-packages-2026-05-06.md
        When a library package serves many callers (production CLI, tests,
        mcp-sync, regen-merge), hard validation can break the test/tool
        callers without producing correctness value. Stderr warn + degraded-
        but-visible fallback lets the production path catch real
        misconfigurations while keeping the library reusable.
    
      * design-patterns/avoid-classification-when-failure-is-asymmetric-2026-05-06.md
        The biggest lesson from two rounds of plan review: when a
        classifier's false-positive cost is much greater than its false-
        negative cost, even a 99%-accurate classifier may be wrong to
        ship. Specific application: env-var hoisting in agent-host
        frontmatter; general principle applies anywhere classification
        routes user-visible behavior.
    
    Two learnings clearly noting cross-repo coupling with mvanhorn/
    printing-press-library (the lesson lives here because it's our
    template/generator changes that introduce the coupling):
    
      * conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md
        A sweep tool over multi-author content should source authorship
        from primary evidence (copyright headers, git first-committer),
        not from the operator's git config. Captures the curated-mapping
        approach used in printing-press-library's sweep-frontmatter tool.
    
      * conventions/external-tool-flags-need-validator-allowlist-2026-05-06.md
        SKILL.md install instructions embed flags from external tools
        (npx, hermes, claude mcp). Doc validators that scope to the file's
        "owning" CLI's source can't tell those apart from real CLI flags.
        Allowlist mechanism + cross-repo coordination required.
    
      * best-practices/auto-mirror-workflow-trigger-coverage-2026-05-06.md
        Source/mirror invariants where the mirror regenerates on push to
        main but verifies on PR — PR authors need to regenerate locally
        before commit. Documents the upstream-template-change ->
        downstream-mirror coupling.
    
    All five docs follow knowledge-track schema with concrete code
    examples, applies_when conditions, and "when not to apply" anti-
    patterns. Cross-references thread between them and to existing
    auth-envvar-rich-model learning.
    
    AGENTS.md already surfaces docs/solutions/ at line 85; no
    discoverability edit needed.
    
    * docs(cli): merge two cross-repo learnings into one coordinated-coupling doc
    
    Replaces:
      - best-practices/auto-mirror-workflow-trigger-coverage-2026-05-06.md
      - conventions/external-tool-flags-need-validator-allowlist-2026-05-06.md
    
    With:
      - best-practices/cross-repo-coordination-with-printing-press-library-2026-05-06.md
    
    Both deleted docs covered the same shape — invisible CI couplings
    between cli-printing-press template/schema changes and downstream
    printing-press-library workflows. Two specific failure modes (mirror
    parity and validator allowlist) are now sections within one doc,
    fronted by a pre-flight checklist that future template-change authors
    can scan at-merge time.
    
    The merged doc keeps both concrete examples but drops the duplication.
    The pre-flight checklist section is the operationally-useful piece that
    the original two docs didn't surface explicitly.
---
 .gitignore                                         |   1 -
 AGENTS.md                                          |   2 +
 ...-06-001-feat-hermes-agent-compatibility-plan.md | 757 +++++++++++++++++++++
 ...t-hermes-openclaw-frontmatter-alignment-plan.md | 431 ++++++++++++
 ...ation-with-printing-press-library-2026-05-06.md | 192 ++++++
 ...horship-in-multi-author-retrofits-2026-05-06.md | 191 ++++++
 ...tion-in-reusable-library-packages-2026-05-06.md | 180 +++++
 ...cation-when-failure-is-asymmetric-2026-05-06.md | 183 +++++
 .../dual-key-identity-fields-2026-05-06.md         | 179 +++++
 internal/generator/generator.go                    |  43 ++
 internal/generator/plan_generate.go                |  65 +-
 internal/generator/plan_generate_test.go           | 117 ++++
 internal/generator/readme_test.go                  |  45 +-
 internal/generator/skill_test.go                   | 137 +++-
 internal/generator/templates/readme.md.tmpl        |  23 +
 internal/generator/templates/skill.md.tmpl         |  70 +-
 internal/spec/spec.go                              |   1 +
 scripts/golden.sh                                  |   4 +
 .../printing-press-rich-auth/README.md             |  23 +
 .../printing-press-rich-auth/SKILL.md              |  56 +-
 .../printing-press-golden/README.md                |  23 +
 .../printing-press-golden/SKILL.md                 |  41 +-
 tools/migrate-skill-metadata/main.go               | 516 --------------
 tools/migrate-skill-metadata/main_test.go          | 519 --------------
 24 files changed, 2635 insertions(+), 1164 deletions(-)

diff --git a/.gitignore b/.gitignore
index 31e3fa9d..5792f3d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,6 @@ catalog/specs/*.graphql
 /refactor/artifacts/
 /tests/artifacts/perf/
 /printing-press
-/migrate-skill-metadata
 library/
 dist/
 claude-upgrade-progress.json
diff --git a/AGENTS.md b/AGENTS.md
index 2359874d..18324b4b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -92,6 +92,8 @@ Use canonical terms in your responses so intent stays unambiguous. In skills and
 - "catalog" -> embedded `catalog/` unless "public library catalog" is stated
 See [`docs/GLOSSARY.md`](docs/GLOSSARY.md) for the full term table and disambiguation cases.
 
+**`Owner` (slug) vs `OwnerName` (display) — keep them straight.** `APISpec` carries two related but semantically distinct fields. `Owner` is path-safe (e.g. `trevin-chow`) and drives Go module paths and `// Copyright YYYY <slug>.` headers — sanitized in `New()`. `OwnerName` is prose-shaped (e.g. `Trevin Chow`) and flows into Hermes `author:`, README byline, and other human-facing surfaces — preserved verbatim, YAML-escaped at template emission. Resolution paths are deliberately different: `OwnerName` reads raw `git config user.name` only (no `github.user` fallback, no slug sanitization, no `"USER"` default). When `OwnerName` is unset at `Generate()` time, the generator emits a stderr warning and falls back to the slug — non-fatal so the package stays reusable by tests and `mcp-sync`/`regen-merge`. The library-wide sweep tool overrides this code path with its own per-CLI authorship mapping. Don't conflate the two fields when authoring helpers, templates, or test fixtures.
+
 ## Commit Style
 Format: `type(scope): description`. Scope is always required.
 - `cli` covers the Go binary, commands, flags, embedded catalog, and docs.
diff --git a/docs/plans/2026-05-06-001-feat-hermes-agent-compatibility-plan.md b/docs/plans/2026-05-06-001-feat-hermes-agent-compatibility-plan.md
new file mode 100644
index 00000000..5ca7273b
--- /dev/null
+++ b/docs/plans/2026-05-06-001-feat-hermes-agent-compatibility-plan.md
@@ -0,0 +1,757 @@
+---
+title: feat: Hermes agent skill compatibility for printed CLIs
+type: feat
+status: superseded
+superseded_by: docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md
+date: 2026-05-06
+deepened: 2026-05-06
+---
+
+> **SUPERSEDED.** This plan was substantially scoped down after round-2 doc review surfaced architectural P0s that pushed it past the "minimum viable Hermes support" goal. The replacement at `docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md` drops env-var hoisting from both Hermes and OpenClaw (the source of the round-2 P0s), descopes the dual-key owner identity model, descopes the Press license migration to its own plan, descopes internal-skill parity, and reuses `tools/migrate-skill-metadata/main.go` patterns rather than building a new audit-and-apply subcommand.
+
+# feat: Hermes agent skill compatibility for printed CLIs
+
+## Summary
+
+Emit Hermes-shaped YAML frontmatter alongside the existing OpenClaw block in printed-CLI SKILL.md files, add `Install via Hermes` and `Install via OpenClaw` sections to printed-CLI README templates, decouple per-CLI human-readable `owner_name` from path-safe `owner_slug` so Hermes `author:` carries a real name, fix the `cli-skills/pp-<api>/` drift across the published library so they're true copies of `library/<cat>/<api>/SKILL.md`, and ship a `printing-press hermes-audit` retrofit tool that uses an explicit `auth.type`-aware classifier (NOT the OpenAPI parser path) and line-targeted file edits (NOT yaml.Marshal round-trips) to patch `spec.yaml` + `SKILL.md` + `README.md` for the 46 already-published CLIs without clobbering hand-edits.
+
+The Press repo's own license migration (MIT → Apache 2.0) is deferred to a separate plan — it doesn't unblock any Hermes work and carries unrelated contributor-consent requirements.
+
+---
+
+## Problem Frame
+
+Hermes (`hermes-agent.nousresearch.com`) is a new agent host that consumes skills with a different YAML frontmatter shape than the Anthropic-style fields printed CLIs emit today. Hermes reads top-level `version`, `author`, `license`, `metadata.hermes.tags`, `metadata.hermes.config`, and `required_environment_variables` — with explicit `Kind`/`Required` filtering for env vars so harvested credentials never appear as user-set requirements. The current generator emits `name`, `description`, `argument-hint`, `allowed-tools`, and a nested `metadata.openclaw.*` block that Hermes doesn't recognize. Printed-CLI READMEs document install for Claude Code and Claude Desktop but not for Hermes or OpenClaw. Without these surfaces, Hermes users have no clean install path for any printed CLI, and OpenClaw users only have an informal one.
+
+The work is asymmetric on failure: a false-positive (claiming a harvested var like `DOMINOS_TOKEN` is `required_environment_variables`) breaks the install — Hermes prompts the user for a value the CLI later overwrites. A false-negative just means a CLI fails informatively the first time it's used. Conservative classification — explicit `auth.type`-aware filter, never the legacy "back-derive everything as PerCall+Required" path — guards the asymmetric failure mode.
+
+A second problem surfaced during planning: 40 of 46 published CLIs had drifted between `library/<cat>/<api>/SKILL.md` (the canonical content) and `cli-skills/pp-<api>/SKILL.md` (which is meant to be a copy used by `npx skills add` and Hermes install paths). The drift was traced to `tools/generate-skills/main.go`'s `injectStaleBuildFallback` function in the `printing-press-library` repo — it was appending a `GOPRIVATE='github.com/mvanhorn/*' GOFLAGS=-mod=mod` install workaround block after every `go install ...@latest` line at copy time, present only in the cli-skills mirror. **Resolved upstream by `printing-press-library#265`**, which removed `injectStaleBuildFallback` and patched all CLIs back to byte-identical copies of their library counterparts. The cli-skills-as-copy invariant now holds; this plan's job is to maintain it through the Hermes work, not to fix legacy drift.
+
+---
+
+## Requirements
+
+- R1. Printed-CLI SKILL.md emits Hermes-recognized frontmatter (`name`, `description`, `version`, `author`, `license`, `metadata.hermes.tags`, `metadata.hermes.config`, `required_environment_variables`) alongside existing OpenClaw fields. Hermes ignores unknown keys; the two formats coexist in one frontmatter block.
+- R2. Printed-CLI README emits two new install sections: `Install via Hermes` (CLI form `hermes skills install mvanhorn/cli-skills/pp-<api> --force` and chat form `/skills install mvanhorn/printing-press-library/cli-skills/pp-<api> --force`) and `Install via OpenClaw` (free-text instruction pointing at the GitHub URL).
+- R3. `required_environment_variables` filters env vars conservatively via a new `IsHermesRequiredEnv()` predicate: `(EffectiveKind() == PerCall || EffectiveKind() == AuthFlowInput) && Required && !Sensitive`. The `!Sensitive` guard prevents OAuth `CLIENT_SECRET`-style values from appearing in the public skill metadata. `Harvested` and unrequired vars never appear. The new predicate replaces the misleading "reuse `IsRequestCredential()`" advice — `IsRequestCredential()` returns true only for `PerCall` and is unchanged.
+- R4. For legacy specs without `EnvVarSpecs`, the retrofit uses an explicit auth.type-aware classifier (new function `ClassifyLegacyEnvVars` in the spec package), NOT the OpenAPI parser path. Mapping: `api_key`/`bearer_token` → user-set (PerCall, Required); `oauth2`/`cookie`/`composed`/`session_handshake` → harvested (excluded); `none` or empty → no env vars; legacy specs that already have `EnvVarSpecs` populated are left as-is. The classifier never calls `NormalizeEnvVarSpecs`'s blanket "PerCall, Required: true, Sensitive: true" back-derivation, which would mis-classify cookie auth.
+- R5. Per-CLI owner identity decouples display name (`owner_name`, e.g. "Trevin Chow") from path-safe slug (`owner_slug`, e.g. "trevin-chow"). Both are captured at print time, stored in `.printing-press.json`, and survive publish. `owner_name` flows into Hermes `author:`, README author lines, and other prose surfaces. `owner_slug` continues to drive Go module paths and `// Copyright` headers (the existing regex `[A-Za-z0-9_-]+` in `RewriteOwner` does not match prose names with spaces). Per-CLI authorship varies — sometimes Trevin, sometimes others — so values come from each generation's runtime context, not a Press-level constant.
+- R6. At print time, `owner_name` defaults to `git config user.name`; `owner_slug` continues to default to `git config github.user` or sanitized `user.name`. Generator flags `--owner-name` and `--owner` permit explicit override. No new user-level config file is required (no `~/.printing-press/config.yaml`, no XDG resolver, no `internal/userconfig/` package).
+- R7. For legacy library CLIs whose `.printing-press.json` lacks `owner_name`, the retrofit consults a small slug-to-name lookup file (`~/.printing-press/authors.yaml`, populated once by the retrofit operator with the known contributors). When a CLI's slug is not in the lookup, the retrofit warns and skips that CLI rather than emitting `author: trevin-chow` (slug as display name) or operator-impersonation.
+- R8. `cli-skills/pp-<api>/SKILL.md` is a byte-identical copy of `library/<cat>/<api>/SKILL.md`. The legacy drift (40 of 46 CLIs differing by one GOPRIVATE-block fragment) was fixed upstream in `printing-press-library#265` by removing `tools/generate-skills/main.go`'s `injectStaleBuildFallback` function. This plan's responsibility is to maintain the invariant — the retrofit's apply step (U9) must copy `library/<cat>/<api>/SKILL.md` to `cli-skills/pp-<api>/SKILL.md` after patching, and `hermes-audit` must report any future drift as a finding.
+- R9. `printing-press hermes-audit` walks the public library at the path provided by `--library-path` (with `PRINTING_PRESS_LIBRARY_PATH` env-var override and a sensible default; not a hardcoded `~/Code/...` operator path) and reports a per-CLI classification table covering all `auth.type` values present (including empty/missing). Read-only by default.
+- R10. `printing-press hermes-audit --apply` performs line-targeted text edits — never full template re-renders. The frontmatter region in each `SKILL.md` is replaced surgically; the `Install via Hermes`/`Install via OpenClaw` sections are inserted into each `README.md` at a defined anchor; `spec.yaml` is updated by editing only the `auth.env_var_specs` block (yaml.v3 Node-aware) without round-tripping the whole document and clobbering comments. `cli-skills/pp-<api>/SKILL.md` is rewritten via direct copy from the patched per-CLI SKILL.md. Snapshot-restore safety per the validation-must-not-mutate convention. Idempotent — second run produces zero diff.
+- R11. Before the library retrofit sweep mutates anything (R10 apply step), generate sample outputs for **at least one CLI per distinct `auth.type` value** present in the live library (api_key, bearer_token, cookie, composed, oauth2, session_handshake, none, empty). Surface to the user. The user must perform a real Hermes install of at least one sample (`hermes skills install ...`) to verify the loader actually accepts the frontmatter — not just that the YAML is shape-correct against the documented schema. Sweep does not proceed until that verification confirms acceptance.
+
+---
+
+## Scope Boundaries
+
+- Reprinting binaries during the library retrofit. The sweep patches metadata only (`spec.yaml`, `SKILL.md`, `cli-skills/pp-<api>/SKILL.md`, `README.md`); the published binaries are untouched. Spec-vs-binary metadata drift is bounded by sourcing the Hermes `version:` field from `.printing-press.json`'s `printing_press_version` (the version that produced this CLI), NOT from the current Press version — this prevents version churn invalidating Hermes skill caches on every Press release.
+- Hermes optional fields beyond the chosen subset: `platforms`, `metadata.hermes.related_skills`, `metadata.hermes.requires_toolsets`, `metadata.hermes.requires_tools`, `metadata.hermes.fallback_for_toolsets`, `metadata.hermes.fallback_for_tools`, `required_credential_files`. These don't fit the printed-CLI shape today; reconsider when there's a real use case.
+- LLM-generated `required_for` text per CLI. Static per-Kind value (`"API access"` for `PerCall`, `"Initial auth setup"` for `AuthFlowInput`) is sufficient for the conservative-filter case where almost always one entry emits.
+- Reshaping existing Anthropic-style frontmatter conventions (`allowed-tools`, `argument-hint`, `context: fork`, `min-binary-version`). These continue to work in the OpenClaw-block coexistence model.
+- Tool permission/safety annotation changes (`mcp:read-only`, `mcp:hidden`, etc.). Out of scope for this plan.
+- The catalog's MCPB `manifest.json` (`library/<cat>/<api>/manifest.json`). It already declares `license: Apache-2.0` and `author.name: "CLI Printing Press"`. Leave alone unless a separate authorship-policy decision changes it.
+- Internal Press skills (`skills/printing-press*/SKILL.md`) Hermes parity. Descoped — these skills wrap the printing-press binary for development of the Press itself; they're not candidates for the Hermes registry, and adding Hermes frontmatter to them does not advance the goal of "Hermes users can install printed CLIs." If a real Hermes consumer surfaces for Press authoring skills, file as follow-up.
+
+### Deferred to Follow-Up Work
+
+- **Press repo MIT → Apache 2.0 license migration**: descoped from this plan. The motivation in the prior draft was "so Hermes frontmatter can declare `license: Apache-2.0` consistently," but printed CLIs already declare Apache 2.0 via `LICENSE.tmpl` and Hermes `license:` describes the printed CLI's license, not the Press's. A `licenseSPDX` template helper sourcing `"Apache-2.0"` from a single canonical value is sufficient for Hermes work without changing the Press's own license. The migration carries real contributor-consent requirements (LICENSE attributes Matt Van Horn and Trevin Chow; `git log` shows additional contributors Cathryn Lavery and Dinakar Sarbada whose consent must be explicitly obtained) and deserves its own plan with a clean justification on the merits (patent grant, Apache-ecosystem alignment).
+- **Phase 2 auth-enrichment `auth.key_url` backfill**: ~27% of legacy specs have `key_url` populated. Improving coverage to ~90% would lift Hermes `help` field quality across the board, plus README "Setup" sections and doctor messages. Tracked as a separate plan after this work lands.
+- **Catalog enrichment for legacy specs with `Category=""`**: ~80% of library specs lack a catalog entry and fall back to `category: other`. The Hermes sweep emits `metadata.hermes.tags: [other]` for these. A separate effort to backfill catalog entries would lift tag quality.
+- **Capturing this work as a `/ce-compound` learning**: library-wide cli-skills realignment + Hermes coexistence + auth-classifier retrofit is exactly the cross-cutting precedent future Press changes will want.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- **Skill template (existing frontmatter emission point)**: `internal/generator/templates/skill.md.tmpl` — currently emits `metadata.openclaw.*` nested block; adding Hermes parity extends this same file. Tests at `internal/generator/skill_test.go` (`TestSkillFrontmatterMetadataIsClawHubCompliantNestedYAML`, `TestSkillFrontmatterEnvVarsOmitsHarvestedAuthEnvVars`) already assert frontmatter shape.
+- **README template**: `internal/generator/templates/readme.md.tmpl` — `Use with Claude Code` section near line 297 is the existing install-section pattern. New `Install via Hermes`/`Install via OpenClaw` sections slot alongside.
+- **Auth env-var rich model**: `internal/spec/spec.go` (`AuthEnvVar` at L467, `AuthEnvVarKind` at L476-482, `IsRequestCredential()` at L493 — returns true only for `PerCall`, NOT `AuthFlowInput`). New predicate `IsHermesRequiredEnv()` lives alongside.
+- **Conservative classifier (new)**: `ClassifyLegacyEnvVars(authConfig) []AuthEnvVar` lives in `internal/spec/` (or a new `internal/spec/hermes/` subpackage). Reads `auth.type` and `auth.env_vars` (legacy flat list); returns explicit `[]AuthEnvVar` with correct `Kind`. Replaces the misleading "reuse parser idempotency" approach.
+- **Owner resolution chain**: `internal/generator/plan_generate.go:289-369` (`resolveOwnerForExisting`, `resolveOwnerForNew`, `sanitizeOwner`, `parseCopyrightOwner`). Decouple here — `resolveOwnerForNew` returns both `OwnerName` and `OwnerSlug` from `git config user.name` + `git config github.user`/`sanitizeOwner(user.name)`.
+- **Generator owner re-sanitization**: `internal/generator/generator.go:158-169` — `New()` re-applies sanitizer to slug only after decouple. `OwnerName` passes through unchanged but is YAML-escaped via `yamlDoubleQuoted` at template emission.
+- **Owner rewrite**: `internal/pipeline/owner_rewrite.go` (`RewriteOwner`, `RewriteModulePath`) — regex anchored on `[A-Za-z0-9_-]+`. Stays slug-only, no widening needed.
+- **Manifest field**: `internal/pipeline/climanifest.go:378` (`CLIManifest.Owner`) — extend to `OwnerName` and `OwnerSlug`. Backwards-compat: legacy `.printing-press.json` files with only `owner` map to `OwnerSlug`; `OwnerName` is filled via authors lookup at retrofit time.
+- **Authors lookup (new)**: `~/.printing-press/authors.yaml` — small file mapping `slug → display_name`, populated by the retrofit operator. Used by retrofit only, not at fresh-print time. Lookup helper lives in `internal/cli/hermes_audit.go` (or a small shared package).
+- **Migration tool precedent (line-targeted edits)**: `tools/migrate-skill-metadata/main.go:23-25` explicitly states "this tool does no yaml.v3 round-trip of the full frontmatter, only line-targeted text replacement of the metadata region." This is the precedent the retrofit follows — NOT full template re-rendering, NOT yaml.Marshal round-trips.
+- **Audit subcommand precedent**: `internal/cli/mcp_audit.go` + `internal/cli/mcp_audit_test.go` — closest pattern for `hermes-audit` (walk library, JSON or table output, never exits non-zero in dry-run mode). Default path uses `PressHome()` + override env var, NOT a hardcoded `~/Code/...` path.
+- **Apply-with-rollback precedent**: `internal/pipeline/regenmerge/apply.go` — tempdir + atomic moves. The hermes-audit `--apply` follows this with snapshot-restore for non-git directories per the validation-must-not-mutate solution.
+- **Catalog**: `internal/catalog/catalog.go:19-38` — 17 valid categories + `other`, kebab-cased, tag-suitable as-is.
+- **Golden harness**: `scripts/golden.sh` (lines 124-128) already passes `GIT_CONFIG_KEY_0=github.user` AND `GIT_CONFIG_KEY_1=user.name`, both set to the same `$golden_owner` value. The change for owner-decouple is to differentiate the values (e.g., `VALUE_0=printing-press-golden`, `VALUE_1=Printing Press Golden`) so priority-inversion bugs surface in tests.
+- **Library structure**: `~/Code/printing-press-library/library/<category>/<api>/` (per-CLI rich tree) and `~/Code/printing-press-library/cli-skills/pp-<api>/SKILL.md` (used by `npx skills add` and Hermes install). 46 entries on each side, alignment confirmed pairwise.
+- **README scoring (verified)**: `internal/pipeline/scorecard.go:478` — `scoreREADME` only checks for Quick Start / Agent Usage / Doctor / Health Check / Troubleshooting / Cookbook / Recipes section names. Adding `Install via Hermes` and `Install via OpenClaw` does not affect Tier 1 scoring.
+
+### Institutional Learnings
+
+- **`docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md`** — direct precedent for the auth-model surface set this work touches. SKILL frontmatter is named as one of the surfaces that drifted when downstream consumers re-derived semantics; the new `IsHermesRequiredEnv()` predicate is the deterministic selector that prevents future drift.
+- **`docs/solutions/logic-errors/mcp-handler-conflates-path-and-query-positional-params-2026-05-05.md`** — closest precedent for the multi-CLI backport pattern. The Movie Goat / Shopify / Linear / Archive.is / Weather Goat / Cal.com sweep via `printing-press-library#232` and `#233` is the precedent for organizing the Hermes retrofit. Calls out the recurrence pattern where a fix lands in one template but doesn't propagate to a sibling — applies to skill template + cli-skills alignment.
+- **`docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`** — the both-keys-present test pattern (`TestManuscriptLookupPriority`) applies to the new `owner_name`/`owner_slug` dual-key shape. Test with both populated, divergent, and one-only fixtures so a priority inversion can't silently invert.
+- **`docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md`** — the snapshot-compare-restore pattern is the safety guardrail for `hermes-audit --apply` over `~/Code/printing-press-library/library/<api>/` directories that may not be git repos.
+- **`tools/migrate-skill-metadata/main.go`** (precedent, not solutions doc) — the line-targeted edit pattern with idempotency tests. The retrofit follows this shape exactly.
+
+### External References
+
+- Hermes frontmatter docs: `https://hermes-agent.nousresearch.com/docs/developer-guide/creating-skills` — required fields (`name`, `description`, `version`, `author`, `license`), optional `platforms`/`metadata.hermes.*`/`required_environment_variables`/`required_credential_files`, with sub-schemas for `config` (key/description/default/prompt) and `required_environment_variables` (name/prompt/help/required_for).
+
+---
+
+## Key Technical Decisions
+
+- **Hermes frontmatter coexists with OpenClaw block.** Hermes ignores unknown keys per its docs; one SKILL.md serves both hosts. The maintenance debt of multi-host coexistence is a real but accepted tradeoff — the alternative (host-specific SKILL.md emission) is significantly more template surface for unclear future-host benefit.
+- **New `IsHermesRequiredEnv()` predicate; `IsRequestCredential()` unchanged.** The prior plan's "reuse `IsRequestCredential()`" instruction was wrong — `IsRequestCredential()` returns true only for `PerCall`. The new predicate is `(EffectiveKind() == PerCall || EffectiveKind() == AuthFlowInput) && Required && !Sensitive`, with the `!Sensitive` guard preventing OAuth `CLIENT_SECRET`-style values from leaking into public skill metadata.
+- **Explicit `ClassifyLegacyEnvVars()` for the retrofit, not OpenAPI parser reuse.** The prior plan claimed the parser path was idempotent and reusable; in fact only ~11/46 library specs are OpenAPI documents, and the cookie→harvested classification only lives in the OpenAPI parser branch. The retrofit needs its own classifier built specifically for the auth.type-aware mapping.
+- **Line-targeted edits, not full template re-renders.** The retrofit follows the `tools/migrate-skill-metadata/main.go` precedent exactly: surgical text replacement of the frontmatter region, anchor-based insertion of new README sections, yaml.v3 Node-aware editing of the `auth.env_var_specs` block in `spec.yaml`. Full re-renders would clobber post-publish hand-edits, narrative copy, retro-driven fixes, comments, and intentional ordering.
+- **`cli-skills/pp-<api>/SKILL.md` is a copy, not a separate template.** The prior plan's U8 was built on a false premise — there is no `skill_focused.md.tmpl`. The publish path's `tools/generate-skills/main.go` (in the `printing-press-library` repo) does the copy. The legacy GOPRIVATE-block drift was fixed in `printing-press-library#265`; this plan's retrofit must preserve the invariant by copying from library to cli-skills as the final step of each per-CLI apply.
+- **Per-CLI owner identity, captured at print time.** Owner identity (both `OwnerName` and `OwnerSlug`) is captured at fresh-print time and stored in `.printing-press.json`. Each printed CLI carries its own author. The retrofit never consults the operator's git config — it reads from each CLI's `.printing-press.json` (legacy slug-only) plus the authors-lookup file for legacy specs. This matches "sometimes it's me, sometimes it's others."
+- **No new user-level config package.** The prior plan introduced `internal/userconfig/`, `~/.printing-press/config.yaml`, and an XDG resolver — over-engineered for one prose field. Reading `git config user.name` at print time and accepting `--owner-name` flag override is sufficient. The retrofit's authors-lookup file (`~/.printing-press/authors.yaml`) is a small, scoped file used only by the retrofit, not a general user config.
+- **Library publish path is hardcoded `mvanhorn/printing-press-library` in templates.** Don't introduce an abstraction layer for a value that's currently a single constant.
+- **Hermes-audit `--library-path` defaults to PressHome-relative path with env-var override.** Matches `mcp-audit` precedent at `internal/cli/mcp_audit.go:44`. Default: `~/printing-press/library` (or `$PRINTING_PRESS_LIBRARY_PATH` if set). NOT a hardcoded `~/Code/...` operator path.
+- **Hermes `version:` field sources from `.printing-press.json`'s `printing_press_version` (the version that produced the CLI), not the current Press version.** Prevents version churn invalidating Hermes skill caches on every Press release. New prints use the current Press version (which becomes the recorded `printing_press_version`); retrofit reads the recorded value.
+- **`metadata.hermes.config` content is one entry**: `<api-slug>.cache_dir` pointing at the existing CLI cache path. Anything more is YAGNI.
+- **`metadata.hermes.tags` sources from catalog `category`** (single-element array) when present; falls back to `[other]` otherwise.
+- **`required_for` is static per-Kind**: `"API access"` for `PerCall`, `"Initial auth setup"` for `AuthFlowInput`.
+- **`prompt` uses `AuthEnvVar.Description` directly when present.** Synthesized fallback `"Enter your <DisplayName> <noun>"` only when description is empty.
+- **`help` field omitted entirely when `auth.key_url` is empty.** A wrong/generic help string is worse than no help string.
+- **`owner_name` is YAML-escaped at template emission.** A `git config user.name` value containing colons or quotes would otherwise corrupt the frontmatter. Apply `yamlDoubleQuoted` (already registered as a template helper) to `OwnerName` everywhere it lands in YAML output.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Skill emission targets for Hermes frontmatter**: per-CLI library tree (`library/<cat>/<api>/SKILL.md`) is authoritative; cli-skills entries are publish-time copies. No separate cli-skills emission path.
+- **Library retrofit covers spec.yaml + per-CLI SKILL.md + README + cli-skills (via copy)**: yes, all four per CLI.
+- **CHECKPOINT timing and scope**: after generator emission lands; samples cover all `auth.type` variants present in the live library; user must perform a real Hermes install of at least one sample before the sweep starts. This is the CHECKPOINT, not a separate "human review of YAML" step.
+- **Press → Apache 2.0 migration**: deferred to its own plan. Not required for Hermes work since printed CLIs are already Apache 2.0.
+- **Hermes `version:` field**: pinned to `.printing-press.json`'s `printing_press_version` (the binary's actual version), not the current Press version. Resolved during plan revision.
+- **R3 / `IsRequestCredential()` contradiction**: resolved by introducing `IsHermesRequiredEnv()` as a new predicate. The legacy `IsRequestCredential()` stays unchanged.
+- **Empty/missing `auth.type` case**: classified as no env vars (same as `none`).
+- **Owner identity during retrofit**: read from `.printing-press.json`'s `owner_slug` + lookup in `~/.printing-press/authors.yaml`. Operator's git config is NOT consulted during retrofit.
+
+### Deferred to Implementation
+
+- **Where exactly to put `IsHermesRequiredEnv()` and `ClassifyLegacyEnvVars()`**: probably methods on `AuthEnvVar` and `AuthConfig` respectively in `internal/spec/spec.go`, alongside the existing predicates. Decide during U3.
+- **Whether the cli-skills-as-copy mechanism is enforced at publish time or at hermes-audit time**: both are reasonable. Decide during U7.
+- **The exact YAML editing library for `spec.yaml` line-targeted edits**: yaml.v3's Node API supports comment-preserving edits; alternatives include line-range string replacement (matching migrate-skill-metadata exactly). Decide during U10 based on what works.
+
+---
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+### Per-CLI owner identity, captured at print time
+
+```mermaid
+flowchart LR
+  GitName["git config user.name<br/>(Trevin Chow)"] --> ResolveOwner
+  GitGithubUser["git config github.user<br/>(trevin-chow)"] --> ResolveOwner
+  Flag1["--owner-name override"] --> ResolveOwner
+  Flag2["--owner override"] --> ResolveOwner
+  ResolveOwner["resolveOwnerForNew()<br/>returns (OwnerName, OwnerSlug)"]
+  ResolveOwner --> Spec["spec.OwnerName, spec.OwnerSlug"]
+  Spec --> Manifest[".printing-press.json<br/>{owner_name, owner_slug}"]
+  Spec --> Templates
+  Templates --> SkillMd["SKILL.md author: (escaped)"]
+  Templates --> Readme["README byline"]
+  Spec --> Headers["// Copyright YYYY {{owner_slug}}"]
+```
+
+### Retrofit author resolution (legacy CLIs)
+
+```mermaid
+flowchart TD
+  Manifest[".printing-press.json"] --> Check{owner_name present?}
+  Check -- yes --> UseManifest[use manifest values]
+  Check -- no --> Lookup[consult ~/.printing-press/authors.yaml]
+  Lookup --> Found{slug found?}
+  Found -- yes --> ApplyLookup[OwnerName = lookup.<slug>]
+  Found -- no --> Warn[warn + skip CLI]
+  UseManifest --> Emit[emit Hermes author:]
+  ApplyLookup --> Emit
+```
+
+### Auth-type-aware classification (replaces parser-idempotency approach)
+
+```mermaid
+flowchart TD
+  Spec["spec.yaml"] --> HasSpecs{EnvVarSpecs<br/>present?}
+  HasSpecs -- yes --> UseExisting[use existing EnvVarSpecs<br/>idempotent]
+  HasSpecs -- no --> Classify[ClassifyLegacyEnvVars]
+  Classify --> Type{auth.type}
+  Type -- "api_key, bearer_token" --> UserSet["each env_var → PerCall, Required"]
+  Type -- "oauth2, cookie, composed,<br/>session_handshake" --> Harvest["each env_var → Harvested"]
+  Type -- "none, '', missing" --> Empty["no env vars"]
+  UseExisting --> Filter
+  UserSet --> Filter
+  Harvest --> Filter
+  Empty --> Filter
+  Filter[IsHermesRequiredEnv predicate] --> Output["required_environment_variables list"]
+```
+
+### Hermes audit + apply data flow (line-targeted edits)
+
+```mermaid
+flowchart TD
+  Walk[walk --library-path] --> Each[per-CLI directory]
+  Each --> ReadSpec["read spec.yaml<br/>(parse only, no round-trip)"]
+  ReadSpec --> ClassifyEnv[ClassifyLegacyEnvVars or use existing]
+  ClassifyEnv --> ResolveOwner[author lookup]
+  ResolveOwner --> Table[print classification table]
+  Table -.->|--apply| Snapshot[snapshot 4 files]
+  Snapshot --> Patch1["spec.yaml: yaml.v3 Node-aware edit<br/>of auth.env_var_specs only"]
+  Snapshot --> Patch2["library/SKILL.md: line-targeted<br/>frontmatter region replacement"]
+  Snapshot --> Patch3["README.md: anchor-based<br/>insertion of Install sections"]
+  Snapshot --> Patch4["cli-skills/pp-X/SKILL.md:<br/>direct copy from library/SKILL.md"]
+  Patch1 -.->|on error| Restore[restore all 4 from snapshot]
+  Patch2 -.->|on error| Restore
+  Patch3 -.->|on error| Restore
+  Patch4 -.->|on error| Restore
+```
+
+### Implementation sequencing
+
+```mermaid
+flowchart LR
+  U1[U1: licenseSPDX helper] --> U4
+  U2[U2: OwnerName/OwnerSlug decouple] --> U4
+  U3[U3: IsHermesRequiredEnv +<br/>ClassifyLegacyEnvVars] --> U4
+  U4[U4: skill template Hermes block] --> U5[U5: README install sections]
+  U4 --> U6[U6: cli-skills drift fix +<br/>copy mechanism]
+  U5 --> CP
+  U6 --> CP
+  U2 --> U7[U7: authors.yaml lookup]
+  U7 --> U8
+  CP[CHECKPOINT: real Hermes install<br/>covering all auth.type variants]
+  CP --> U8[U8: hermes-audit dry-run]
+  U8 --> U9[U9: hermes-audit --apply]
+  U9 --> U10[U10: library sweep PRs]
+```
+
+---
+
+## Implementation Units
+
+### U1. Add `licenseSPDX` template helper
+
+**Goal:** Single canonical source for `"Apache-2.0"` so subsequent units don't hardcode the string in templates.
+
+**Requirements:** R1
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/generator/generator.go` (register helper)
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Register `licenseSPDX` returning `"Apache-2.0"` alongside existing `currentYear`, `modulePath` helpers at `internal/generator/generator.go:240`.
+- One-line helper. No template changes yet — U4 picks it up.
+
+**Test scenarios:**
+- Happy path: helper returns `"Apache-2.0"` exactly.
+
+**Verification:**
+- `go test ./internal/generator/...` passes.
+
+---
+
+### U2. Decouple `OwnerName` from `OwnerSlug`
+
+**Goal:** Replace the conflated `Owner` field with `OwnerName` (prose) and `OwnerSlug` (path-safe) throughout spec, manifest, generator, and templates. Both captured at print time from git config (or override flags) and stored in `.printing-press.json`.
+
+**Requirements:** R5, R6
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go` (add `OwnerName` field; map legacy `Owner` to `OwnerSlug`)
+- Modify: `internal/generator/plan_generate.go` (`resolveOwnerForExisting`, `resolveOwnerForNew` return both values)
+- Modify: `internal/generator/generator.go` (`New()` re-sanitizes only `OwnerSlug`; `OwnerName` passes through unchanged but is YAML-escaped at emission)
+- Modify: `internal/pipeline/climanifest.go` (`CLIManifest.OwnerName`, `CLIManifest.OwnerSlug`; `WriteManifestForGenerate` populates both)
+- Modify: `internal/cli/root.go` (add `--owner-name` flag alongside existing `--owner`)
+- Modify: `scripts/golden.sh` lines 124-128 (differentiate `VALUE_0` and `VALUE_1` to surface priority-inversion bugs)
+- Test: `internal/generator/plan_generate_test.go`, `internal/spec/spec_test.go`, `internal/pipeline/climanifest_test.go`
+
+**Approach:**
+- Backwards-compat: existing `Owner` field on the spec maps to `OwnerSlug`; legacy `.printing-press.json` files with only `owner` map to `OwnerSlug`. `OwnerName` falls back to empty in legacy reads — the retrofit's authors-lookup (U7) covers that case.
+- New precedence chain for `OwnerName`: `--owner-name` flag > `git config user.name` > empty.
+- New precedence chain for `OwnerSlug`: `--owner` flag > `.printing-press.json` `owner` (legacy) or `owner_slug` > parsed copyright header > `git config github.user` > sanitized `git config user.name` > literal `"USER"`.
+- `internal/generator/generator.go:158-169` re-sanitizes `OwnerSlug` only. `OwnerName` passes through verbatim (YAML escape happens at template-emit time, not in `New()`).
+- All template touchpoints that currently use `{{.Owner}}` for prose surfaces (README author lines, etc.) get updated to `{{yamlDoubleQuoted .OwnerName}}` or `{{.OwnerName}}` as appropriate. Copyright headers continue to use slug.
+
+**Patterns to follow:**
+- Existing `resolveOwnerFor*` chain in `internal/generator/plan_generate.go:289-369`.
+- Both-keys-present test pattern from `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`.
+- `parseCopyrightOwner` regex stays unchanged (still matches slug-shaped strings only).
+
+**Test scenarios:**
+- Happy path: spec with both `OwnerName` and `OwnerSlug` set flows both through generator and manifest.
+- Happy path: spec with only legacy `Owner` field maps to `OwnerSlug`; `OwnerName` is populated from `git config user.name` precedence chain.
+- Edge case: `OwnerName` containing spaces ("Trevin Chow") survives `New()` re-sanitization unchanged.
+- Edge case: `OwnerSlug` containing spaces is sanitized to "trevin-chow"; `OwnerName` remains unchanged.
+- Edge case: missing `git config user.name` → `OwnerName` is empty; downstream Hermes emission either falls back or errors clearly (resolved in U4).
+- Edge case: `OwnerName` containing YAML special characters (e.g., `Foo: Bar`, `"Quote"`) is correctly escaped when emitted via `yamlDoubleQuoted`.
+- Integration: full generate path with both fields populated produces correct copyright header (slug only) and writes both fields to `.printing-press.json`.
+- Integration: regen-merge with diverging fields preserves both correctly.
+- Integration (golden harness): with `VALUE_0=printing-press-golden` and `VALUE_1=Printing Press Golden` (differentiated), the rendered SKILL.md and README correctly use the slug for paths and the prose name for author surfaces.
+
+**Verification:**
+- All `internal/generator` and `internal/pipeline` tests pass.
+- A test with `OwnerName: "Trevin Chow"` and `OwnerSlug: "trevin-chow"` produces a copyright header reading `// Copyright 2026 trevin-chow.` (slug only).
+- Golden fixtures regenerated and reviewed.
+
+---
+
+### U3. Add `IsHermesRequiredEnv()` predicate and `ClassifyLegacyEnvVars()` classifier
+
+**Goal:** Introduce the conservative env-var filter for Hermes emission and the explicit auth.type-aware classifier for the retrofit. Both live alongside the existing predicates in `internal/spec/spec.go`.
+
+**Requirements:** R3, R4
+
+**Dependencies:** None (independent of U1, U2)
+
+**Files:**
+- Modify: `internal/spec/spec.go` (add `IsHermesRequiredEnv` method on `AuthEnvVar`; add `ClassifyLegacyEnvVars` function on `AuthConfig` or a top-level helper)
+- Test: `internal/spec/spec_test.go`
+
+**Approach:**
+- `IsHermesRequiredEnv()` on `AuthEnvVar`:
+  ```
+  func (v AuthEnvVar) IsHermesRequiredEnv() bool {
+      kind := v.EffectiveKind()
+      return (kind == AuthEnvVarKindPerCall || kind == AuthEnvVarKindAuthFlowInput) &&
+             v.Required && !v.Sensitive
+  }
+  ```
+- `ClassifyLegacyEnvVars(c AuthConfig) []AuthEnvVar`:
+  - If `c.EnvVarSpecs` is non-empty, return as-is (idempotent).
+  - Otherwise, derive from `c.Type` + `c.EnvVars`:
+    - `api_key` or `bearer_token` → each var as `Kind: PerCall, Required: true, Sensitive: true, Inferred: true`.
+    - `oauth2`, `cookie`, `composed`, `session_handshake` → each var as `Kind: Harvested, Required: false, Sensitive: true, Inferred: true`.
+    - `none`, `""` (empty), unknown → return empty.
+- Critical: this classifier is the single source of truth for retrofit classification. It does NOT call `NormalizeEnvVarSpecs` (which is the existing back-derivation that mis-classifies cookies as `PerCall+Required`). It does NOT depend on the OpenAPI parser.
+
+**Patterns to follow:**
+- Existing predicate methods on `AuthEnvVar` at `internal/spec/spec.go:485-507`.
+- The auth-envvar-rich-model design pattern's "additive widening" — these are new functions, no existing semantics change.
+
+**Test scenarios:**
+- Happy path: `IsHermesRequiredEnv()` returns true for `Kind: PerCall, Required: true, Sensitive: false`.
+- Happy path: returns true for `Kind: AuthFlowInput, Required: true, Sensitive: false`.
+- Edge case: returns false for `Kind: PerCall, Required: false` (any sensitivity).
+- Edge case: returns false for `Kind: PerCall, Required: true, Sensitive: true` (sensitive guard).
+- Edge case: returns false for `Kind: Harvested` (any combination).
+- Edge case: returns false for `Kind: AuthFlowInput, Required: true, Sensitive: true` (sensitive guard prevents CLIENT_SECRET emission).
+- Happy path: `ClassifyLegacyEnvVars` for `auth.type: api_key, env_vars: [SHOPIFY_ACCESS_TOKEN]` returns `[{Name: "SHOPIFY_ACCESS_TOKEN", Kind: PerCall, Required: true, Sensitive: true, Inferred: true}]`.
+- Happy path: for `auth.type: cookie, env_vars: [ALLRECIPES_COOKIES]` returns `[{Name: "ALLRECIPES_COOKIES", Kind: Harvested, Required: false, Sensitive: true, Inferred: true}]`.
+- Happy path: for `auth.type: oauth2, env_vars: [GOOGLE_PHOTOS_TOKEN]` returns `[{Kind: Harvested, ...}]`.
+- Happy path: for `auth.type: composed, env_vars: [...]` returns all-harvested.
+- Edge case: for `auth.type: none` returns empty.
+- Edge case: for `auth.type: ""` (empty string) returns empty.
+- Edge case: for `auth.type: bearer_token, env_vars: [DOMINOS_TOKEN]` returns `[{Kind: PerCall, Required: true}]` — note this is BEFORE `EnvVarSpecs` is populated; in practice Dominos already has `EnvVarSpecs` so the function returns the existing values and never hits this branch.
+- Edge case: spec with `EnvVarSpecs` populated (Dominos-shape) — returns the existing specs unchanged, ignoring `EnvVars` legacy list.
+- Integration: combined — `ClassifyLegacyEnvVars(shopify.Auth)` filtered through `IsHermesRequiredEnv()` returns `[SHOPIFY_ACCESS_TOKEN]`.
+- Integration: combined — `ClassifyLegacyEnvVars(allrecipes.Auth)` filtered through `IsHermesRequiredEnv()` returns `[]`.
+
+**Verification:**
+- All tests pass with explicit fixtures for every `auth.type` value present in the live library.
+
+---
+
+### U4. Hermes frontmatter emission in printed-CLI skill template
+
+**Goal:** Extend `internal/generator/templates/skill.md.tmpl` to emit Hermes-recognized fields alongside the existing OpenClaw block.
+
+**Requirements:** R1, R3
+
+**Dependencies:** U1 (licenseSPDX helper), U2 (OwnerName), U3 (IsHermesRequiredEnv predicate)
+
+**Files:**
+- Modify: `internal/generator/templates/skill.md.tmpl`
+- Test: `internal/generator/skill_test.go` (extend or new `skill_hermes_test.go`)
+
+**Approach:**
+- Add a `metadata.hermes.*` nested block alongside the existing `metadata.openclaw.*`. Both nest under `metadata`.
+- `version`: sourced from `.printing-press.json`'s `printing_press_version` (the version that produced this CLI), exposed as `{{.PressVersion}}` — register the helper or expose as field in U1's helper-registration step. Pinned to print-time version, NOT the live Press version, to avoid Hermes cache invalidation on every Press release.
+- `author`: `{{yamlDoubleQuoted .OwnerName}}`. If `OwnerName` is empty, hard error during generation rather than emit empty.
+- `license`: `{{licenseSPDX}}` → `"Apache-2.0"`.
+- `metadata.hermes.tags`: `[{{.Category | default "other"}}]` — single-element array.
+- `metadata.hermes.config`: one entry — `<api>.cache_dir` pointing at the existing CLI cache path.
+- `required_environment_variables`: filter via `IsHermesRequiredEnv()`; emit `name`, `prompt`, `help`, `required_for` per entry. Omit the field entirely when no entries qualify.
+
+**Execution note:** Test-first. Existing `TestSkillFrontmatterEnvVarsOmitsHarvestedAuthEnvVars` already locks down the conservative-filter behavior for the OpenClaw block; mirror its shape for new Hermes-block tests before editing the template.
+
+**Patterns to follow:**
+- Existing `metadata.openclaw.*` block in `skill.md.tmpl:8-44`.
+- `IsHermesRequiredEnv()` predicate from U3.
+
+**Test scenarios:**
+- Happy path (Shopify-shape, api_key + 1 required PerCall env var): emits Hermes block with one `required_environment_variables` entry, `help` populated from `key_url`.
+- Happy path (no auth, type=none): emits Hermes block, no `required_environment_variables` field.
+- Edge case (Dominos-shape, all-harvested + auth_flow_input optional): emits Hermes block with no `required_environment_variables` field.
+- Edge case (mixed: one `PerCall+Required+!Sensitive`, one `Harvested`): emits one entry only.
+- Edge case (`auth.type: cookie`, `EnvVarSpecs` from `ClassifyLegacyEnvVars` shows all harvested): no `required_environment_variables`.
+- Edge case (`auth.type: bearer_token` with Sensitive+Required PerCall var): excluded from emission per `!Sensitive` guard.
+- Edge case (`AuthEnvVar.Description` populated): `prompt` uses it directly.
+- Edge case (`AuthEnvVar.Description` empty): `prompt` synthesized from `DisplayName` + auth-type noun.
+- Edge case (`AuthConfig.KeyURL` empty): `help` field omitted entirely.
+- Edge case (`OwnerName` containing YAML special characters): correctly escaped via `yamlDoubleQuoted`.
+- Error path: `OwnerName` is empty → generator returns clear error before writing SKILL.md.
+- Integration: rendered frontmatter parses as valid YAML via `gopkg.in/yaml.v3`.
+
+**Verification:**
+- All `internal/generator/skill_test.go` tests pass.
+- `scripts/golden.sh verify` passes after intentional updates to the four `generate-golden-*` cases.
+
+---
+
+### U5. Hermes Install + OpenClaw Install sections in README template
+
+**Goal:** Add `Install via Hermes` and `Install via OpenClaw` sections to `internal/generator/templates/readme.md.tmpl`, slotting alongside existing `Use with Claude Code` and `Use with Claude Desktop`.
+
+**Requirements:** R2
+
+**Dependencies:** U2 (OwnerName for byline if updated), U4 in parallel
+
+**Files:**
+- Modify: `internal/generator/templates/readme.md.tmpl`
+- Test: `internal/generator/readme_test.go` (or new `readme_hermes_test.go`)
+
+**Approach:**
+- Add two new sections, in the order: `Use with Claude Code` → `Use with Claude Desktop` → `Install via Hermes` → `Install via OpenClaw`. Hardcode `mvanhorn/printing-press-library` and `mvanhorn/cli-skills` directly.
+- Place an HTML comment anchor (e.g., `<!-- pp-hermes-install-anchor -->`) immediately before the new sections. The retrofit (U9) uses this anchor for idempotent insertion.
+- Hermes section emits both forms verbatim; OpenClaw section emits the user-confirmed wording.
+- README scoring: verified at `internal/pipeline/scorecard.go:478` that `scoreREADME` does not key on these section names — no scorer update needed.
+
+**Patterns to follow:**
+- Existing `Use with Claude Code` section at `readme.md.tmpl:297-358`.
+
+**Test scenarios:**
+- Happy path: rendered README contains `## Install via Hermes` heading.
+- Happy path: rendered README contains both Hermes install command forms with `pp-<api>` substituted correctly.
+- Happy path: rendered README contains the OpenClaw section with the GitHub URL substituted correctly.
+- Happy path: rendered README contains the anchor comment for retrofit-time insertion.
+- Edge case: install URLs always say `mvanhorn/...`, never the owner. Catches accidentally templating the publisher.
+- Integration: golden fixtures update for all four `generate-golden-*` cases.
+
+**Verification:**
+- `internal/generator/readme_test.go` passes.
+- `scripts/golden.sh verify` passes after intentional updates.
+
+---
+
+### U6. Document and verify the cli-skills-as-copy invariant
+
+**Goal:** Document the cli-skills-as-copy convention so future contributors don't reintroduce drift, and add an audit check so any future divergence is detected.
+
+**Requirements:** R8
+
+**Dependencies:** None — the legacy drift is already fixed (PR #265 removed `injectStaleBuildFallback` from `tools/generate-skills/main.go` and patched all 46 CLIs back to byte-identical with their library counterparts).
+
+**Files:**
+- Cross-repo: `printing-press-library/AGENTS.md` (document the invariant — `cli-skills/pp-<api>/SKILL.md` is a byte-identical copy of `library/<cat>/<api>/SKILL.md`; do not hand-edit the cli-skills entries)
+- This repo: `internal/cli/hermes_audit.go` (the audit reports any drift as a finding; the `--apply` step's final per-CLI step copies library SKILL.md to cli-skills)
+
+**Approach:**
+- No file mutation needed in this unit — the drift is already fixed via PR #265.
+- Add a brief section to `printing-press-library/AGENTS.md` stating the invariant and pointing at `tools/generate-skills/main.go` as the source of cli-skills generation.
+- The hermes-audit subcommand (U8) reports cli-skills drift as a finding column; under normal operation, the column should always read "no drift" after this plan ships.
+- The `--apply` step (U9) preserves the invariant by copying patched library SKILL.md to cli-skills as the final per-CLI step.
+
+**Patterns to follow:**
+- PR #265 is the precedent for fixing systematic drift at the source generator, not in each printed CLI.
+
+**Test scenarios:**
+- Test expectation: none for code in this repo. Documentation is verified by inspection.
+- Audit-side verification: a fixture library tree with intentional drift in one CLI's cli-skills entry causes `hermes-audit` to report that drift.
+
+**Verification:**
+- `printing-press-library/AGENTS.md` documents the invariant.
+- After the U9 sweep, `diff -r library/<cat>/<api>/SKILL.md cli-skills/pp-<api>/SKILL.md` is empty for every CLI.
+
+---
+
+### U7. Authors lookup file (`~/.printing-press/authors.yaml`)
+
+**Goal:** Provide a small slug-to-display-name lookup file for the retrofit, so legacy `.printing-press.json` files (which only have `owner` slug) can resolve `OwnerName` for the Hermes `author:` field.
+
+**Requirements:** R7
+
+**Dependencies:** U2 (OwnerName/OwnerSlug decouple)
+
+**Files:**
+- Create: `internal/cli/authors_lookup.go` (loader; can live in the hermes_audit package)
+- Create: `internal/cli/authors_lookup_test.go`
+- Document: a sample `authors.yaml` in `docs/HERMES.md` or inline in the hermes-audit `--help` output
+
+**Approach:**
+- File format:
+  ```yaml
+  trevin-chow: Trevin Chow
+  matt-van-horn: Matt Van Horn
+  cathryn-lavery: Cathryn Lavery
+  dinakar-sarbada: Dinakar Sarbada
+  ```
+- `LoadAuthors() (map[string]string, error)` reads `~/.printing-press/authors.yaml` (or `$PRINTING_PRESS_AUTHORS` env override). Returns empty map (no error) when file is absent — first-run convenience.
+- Used only by hermes-audit. New prints capture `OwnerName` directly from git config and don't need the lookup.
+- No XDG resolver, no userconfig package. Single file, single function.
+
+**Patterns to follow:**
+- Lightweight YAML loader, similar to `internal/catalog/catalog.go` shape but simpler.
+
+**Test scenarios:**
+- Happy path: load with one entry returns the expected map.
+- Edge case: missing file returns empty map and no error.
+- Edge case: malformed YAML returns clear error.
+- Edge case: `$PRINTING_PRESS_AUTHORS` env var override resolves a different path.
+
+**Verification:**
+- All tests pass.
+
+---
+
+### CHECKPOINT: real Hermes loader verification
+
+**Goal:** Before any retrofit `--apply` runs, generate sample outputs covering all `auth.type` variants present in the live library, and verify Hermes actually loads at least one of them via real install (not just shape-correctness against the documented schema).
+
+**Requirements:** R11
+
+**Dependencies:** U4, U5, U6 all merged
+
+**Files:**
+- None (this is a process gate)
+
+**Approach:**
+- For each distinct `auth.type` value present in the live library (verified set: `api_key`, `bearer_token`, `oauth2`, `cookie`, `composed`, `none`, empty/missing — `session_handshake` may or may not be present, include if so), pick one representative CLI:
+  - `api_key`: Shopify
+  - `bearer_token`: Dominos (already has `EnvVarSpecs`)
+  - `oauth2`: Google Photos
+  - `cookie`: eBay or Allrecipes
+  - `composed`: Pagliacci
+  - `none`: Wikipedia
+  - empty/missing: TikTok-Shop (or whichever is in the live library)
+- Run `printing-press generate` (or the equivalent local-only render) for each, producing a sample `SKILL.md` and `README.md`.
+- Surface to the user: the full Hermes frontmatter block from each sample SKILL.md, the new install sections from each sample README.
+- The user performs a real Hermes install of at least one sample: `hermes skills install mvanhorn/cli-skills/pp-<api> --force` (after the sample is staged in a test branch of the public library, or via local file install if Hermes supports it).
+- Sweep does not proceed until the user confirms the install succeeded.
+
+**Test scenarios:**
+- Test expectation: none — this is a human verification gate.
+
+**Verification:**
+- Explicit user confirmation that real Hermes loaded at least one sample.
+
+---
+
+### U8. `printing-press hermes-audit` subcommand (read-only)
+
+**Goal:** New top-level subcommand that walks the public library, classifies env vars via `ClassifyLegacyEnvVars`, resolves authors via the lookup, and prints a per-CLI table showing what Hermes frontmatter would emit. Read-only.
+
+**Requirements:** R9
+
+**Dependencies:** U3, U7
+
+**Files:**
+- Create: `internal/cli/hermes_audit.go`
+- Create: `internal/cli/hermes_audit_test.go`
+- Modify: `internal/cli/root.go` (register the new command)
+
+**Approach:**
+- Take a `--library-path` flag defaulting to `~/printing-press/library` with `PRINTING_PRESS_LIBRARY_PATH` env override (matches `mcp-audit` precedent at `internal/cli/mcp_audit.go:44`).
+- Validate that `--library-path` resolves to a directory that looks like a library (has both `library/` and `cli-skills/` subdirectories) before walking. Reject paths that don't match.
+- For each per-CLI directory, read `spec.yaml` (parse, no round-trip), apply `ClassifyLegacyEnvVars`, look up the author via `LoadAuthors()`, and build a `HermesAuditFinding` struct.
+- Output modes: human table (default) and JSON (`--json`).
+- Never exit non-zero in dry-run mode.
+- Surface drift between `library/<cat>/<api>/SKILL.md` and `cli-skills/pp-<api>/SKILL.md` as a separate report column ("cli-skills drift: yes/no").
+
+**Patterns to follow:**
+- `internal/cli/mcp_audit.go` (walker, finding struct, JSON/table output).
+- `IsHermesRequiredEnv()` predicate from U3.
+- Authors lookup from U7.
+
+**Test scenarios:**
+- Happy path: walks a fixture library tree with samples for every `auth.type` and prints expected classification.
+- Happy path: `--json` mode emits stable JSON field names suitable for downstream parsing.
+- Edge case: CLI directory missing `spec.yaml` is reported as error in the table, doesn't crash the walk.
+- Edge case: malformed `spec.yaml` is reported as error, doesn't crash.
+- Edge case: CLI with `auth.type: cookie` classifies as Harvested, emits no env vars in the proposed Hermes block.
+- Edge case: CLI with `auth.type: bearer_token` and existing `EnvVarSpecs` (Dominos-shape) uses the existing specs.
+- Edge case: CLI whose slug is not in `~/.printing-press/authors.yaml` is reported with a "missing author" warning column; not crashed.
+- Edge case: cli-skills drift detected and reported.
+- Edge case: `--library-path` pointing at an arbitrary non-library directory is rejected before any walk happens.
+- Integration: end-to-end run against a fixture library tree produces a non-empty table.
+
+**Verification:**
+- `printing-press hermes-audit` produces a clear table.
+- Tests pass.
+
+---
+
+### U9. `printing-press hermes-audit --apply` mutating sweep
+
+**Goal:** Add `--apply` flag that mutates four target files per CLI via line-targeted edits (not full re-renders): `spec.yaml` (auth.env_var_specs region only), `library/<cat>/<api>/SKILL.md` (frontmatter region only), `library/<cat>/<api>/README.md` (anchor-based insertion of new sections), `cli-skills/pp-<api>/SKILL.md` (direct copy from patched library SKILL.md to preserve the post-#265 invariant). Snapshot-restore on failure. Idempotent.
+
+**Requirements:** R10, R8 (cli-skills drift fix happens here)
+
+**Dependencies:** U8
+
+**Files:**
+- Modify: `internal/cli/hermes_audit.go` (add `--apply` flag, mutator path)
+- Create: `internal/cli/hermes_audit_apply.go` (or keep in same file)
+- Modify: `internal/cli/hermes_audit_test.go`
+- Create: `internal/cli/hermes_audit_apply_test.go`
+
+**Approach:**
+- For each CLI:
+  1. Snapshot all four target files into memory (`os.ReadFile` per validation-must-not-mutate).
+  2. **`spec.yaml`**: yaml.v3 Node-aware edit of the `auth.env_var_specs` block only. Use `yaml.Node.Decode`/`Encode` on a single mapping node, NOT the whole document. Comments and unrelated fields are preserved byte-for-byte. (Alternative: line-range string replacement matching the migrate-skill-metadata pattern exactly. Decide during implementation based on what works cleanly.)
+  3. **`library/<cat>/<api>/SKILL.md`**: line-targeted text replacement of the frontmatter region (between the leading `---` and the closing `---`). Render the new frontmatter using the `skill.md.tmpl` engine but extract only the frontmatter portion; replace lines in place. Body content untouched.
+  4. **`library/<cat>/<api>/README.md`**: anchor-based insertion. Find the `<!-- pp-hermes-install-anchor -->` comment (added in U5 to the template) — for legacy CLIs that don't have it yet, insert it after `Use with Claude Desktop` section, then insert the new sections after the anchor. Subsequent runs find the anchor and replace the section block between it and the next `## ` heading.
+  5. **`cli-skills/pp-<api>/SKILL.md`**: direct file copy from the (now-patched) `library/<cat>/<api>/SKILL.md`. No edits, no template rendering — just `cp`.
+  6. On any error during steps 2-5, restore all four files from snapshot.
+- Add `--dry-run` flag (in addition to default read-only mode) that shows the diff per CLI without mutating.
+- Idempotency: running `--apply` twice produces zero textual diff on the second run. Test directly using SHA256 comparison or `cmp -s`.
+- Authors lookup: skip CLIs whose slug isn't in `~/.printing-press/authors.yaml` with a clear warning. Don't fall back to slug-as-name or operator git config.
+
+**Patterns to follow:**
+- `tools/migrate-skill-metadata/main.go` for the legacy-shape detection + line-targeted transform pattern.
+- Snapshot-compare-restore from `docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md`.
+
+**Test scenarios:**
+- Happy path: `--apply` against a fixture library mutates all four files per CLI correctly.
+- Happy path: idempotent — running `--apply` twice produces zero textual diff on the second run (verified via SHA256 or `cmp -s`).
+- Happy path: `spec.yaml` round-trip preserves all comments, field ordering, quoting style, trailing newlines.
+- Happy path: `SKILL.md` body content (everything after the second `---`) is byte-identical before and after.
+- Happy path: `README.md` content outside the inserted sections is byte-identical before and after.
+- Happy path: `cli-skills/pp-<api>/SKILL.md` matches `library/<cat>/<api>/SKILL.md` byte-for-byte after apply.
+- Edge case: `--dry-run` produces the same diff `--apply` would produce, but mutates nothing.
+- Edge case: a CLI whose author slug isn't in the lookup is skipped with a warning; other CLIs proceed.
+- Edge case: a CLI directory missing `cli-skills/pp-<api>/SKILL.md` (drift case) creates the file from `library/<cat>/<api>/SKILL.md`; conversely, a `cli-skills/` entry without a per-CLI library tree is reported and skipped.
+- Edge case: a CLI with no `auth.env_var_specs` block in `spec.yaml` (currently 45 of 46) gets the block inserted under `auth:` cleanly.
+- Error path: filesystem write failure restores from snapshot — verified by injecting a write failure mid-sweep and checking files are unchanged.
+- Error path: malformed yaml.v3 Node parse on `spec.yaml` is reported and the CLI is skipped without partial writes.
+- Integration: full sweep against fixture library produces deterministic output that round-trips through `printing-press hermes-audit` (the read-only command should report all CLIs as "compliant" after `--apply`).
+
+**Verification:**
+- Tests pass including idempotency.
+- A fixture run produces files that re-parse cleanly.
+
+---
+
+### U10. Execute the library sweep and land changes via PR(s)
+
+**Goal:** Run `printing-press hermes-audit --apply` against the public library, review the diff, and ship one or more PRs to `printing-press-library`.
+
+**Requirements:** R10
+
+**Dependencies:** U9, CHECKPOINT confirmed
+
+**Files:**
+- N/A in this repo (cross-repo work)
+- Outputs: PR(s) against `mvanhorn/printing-press-library`
+
+**Approach:**
+- Run `--dry-run` against the live library; review the diff per CLI.
+- Spot-check the auth-type variant set: Shopify (api_key), Dominos (bearer_token + EnvVarSpecs), Google Photos (oauth2), eBay (cookie), Pagliacci (composed), Wikipedia (none), TikTok-Shop (empty type).
+- Verify cli-skills drift is fixed: `diff -r library/<cat>/<api>/SKILL.md cli-skills/pp-<api>/SKILL.md` empty for every CLI.
+- Group PRs by category or batch-size. Precedent: Movie Goat / Shopify / Linear etc. backports were split as one umbrella issue plus a multi-CLI batch (`printing-press-library#232`, `#233`).
+- Stage the sweep: 1-CLI PR → spot-check → 5-CLI batch → spot-check → full sweep. Avoids the all-46-then-discover-it's-broken failure mode.
+
+**Test scenarios:**
+- Test expectation: none for this repo.
+
+**Verification:**
+- All target CLIs in the public library have Hermes frontmatter on their SKILL.md and Install sections in their README.
+- `printing-press hermes-audit` against the swept library reports "compliant" for every CLI.
+- `cli-skills/pp-<api>/SKILL.md` byte-identical to `library/<cat>/<api>/SKILL.md` for every CLI.
+- A spot-check Hermes install (`hermes skills install mvanhorn/cli-skills/pp-<api> --force`) loads the skill without error for at least the Shopify, Dominos, Wikipedia, and Pagliacci samples.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph:** No runtime callbacks change. Generator templates (skill, README) gain new content; the OpenAPI parser path is unchanged. The retrofit reads `spec.yaml` directly, applies its own classifier (no parser dependency), and writes via line-targeted edits.
+- **Error propagation:** New `OwnerName` empty-check fails generation early (before any file writes) rather than emitting an empty `author:`. Hermes-audit `--apply` rolls back per-CLI file sets atomically via snapshot-restore. Authors-lookup miss skips the CLI with a warning rather than producing wrong output.
+- **State lifecycle risks:** The four-file mutation in U9 is the highest risk. Snapshot-restore guards partial-write. Line-targeted edits guard against clobbering hand-edits. Idempotency test guards re-run safety. SHA256-based byte-comparison test makes regressions visible.
+- **API surface parity:** SKILL.md (per-CLI library) and `cli-skills/pp-<api>/SKILL.md` are now provably identical via the copy mechanism. No drift possible.
+- **Integration coverage:** The `--apply` sweep crosses generator, classifier, filesystem, and yaml.v3 layers. Mock-only unit tests will not prove the round-trip; one fixture-based integration test that runs `--apply` on a fake library tree and re-parses the output (plus byte-comparison for idempotency) is required.
+- **Unchanged invariants:**
+  - The `// Copyright YYYY <slug>.` header format (regex anchored on `[A-Za-z0-9_-]+`) is unchanged. `owner_slug` continues to drive headers; `owner_name` only flows into prose surfaces.
+  - The MCPB `manifest.json` author/license fields are unchanged.
+  - The existing `metadata.openclaw.*` block is unchanged. Hermes coexists alongside.
+  - The OpenAPI parser's auth-classification logic is unchanged. The retrofit uses a separate, explicit classifier.
+  - `IsRequestCredential()` is unchanged. The new `IsHermesRequiredEnv()` predicate lives alongside.
+  - Printed-CLI binaries are not regenerated by the sweep.
+  - The Press repo's MIT license is unchanged (migration deferred to a separate plan).
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| False-positive on `required_environment_variables` (claiming a harvested var is user-set) breaks Hermes installs | Explicit `auth.type`-aware classifier + `IsHermesRequiredEnv()` predicate with `!Sensitive` guard; explicit unit tests for cookie/oauth2/composed/empty cases; CHECKPOINT real Hermes install before sweep |
+| Hermes documentation accuracy diverges from runtime loader | CHECKPOINT requires real Hermes install of at least one sample BEFORE U9 sweep starts, NOT after; sweep staged 1→5→full to catch divergence early |
+| `OwnerName` regex collision with existing `// Copyright` parser | Decoupled fields — slug stays in headers, name only in prose. Regex unchanged |
+| `OwnerName` containing YAML special characters corrupts frontmatter | `yamlDoubleQuoted` template helper applied at every emission point |
+| Library retrofit fails partway and leaves CLIs in inconsistent state | Snapshot-restore per CLI on any failure; idempotent design verifies via SHA256-based second-run-clean test |
+| Line-targeted spec.yaml edit clobbers comments or reorders fields | yaml.v3 Node-aware edit on the `auth.env_var_specs` block only, preserving the rest of the document; if Node-aware proves unreliable, fall back to literal line-range string replacement matching the migrate-skill-metadata precedent |
+| Anchor-based README insertion fails on legacy READMEs without the anchor | First sweep inserts the anchor after `Use with Claude Desktop`; subsequent runs find the anchor and replace surgically. Falls back to a defined location if the anchor section is absent |
+| Author slug not in `~/.printing-press/authors.yaml` | Retrofit skips with warning rather than producing wrong output. Operator populates the file with known contributors before the sweep |
+| `cli-skills/` entry exists without matching `library/<cat>/<api>/` tree | Reported as a separate finding in `hermes-audit`; skipped by `--apply`. The plan does not create new library trees |
+
+---
+
+## Phased Delivery
+
+### Phase 1 — Foundation (one PR)
+- U1: `licenseSPDX` helper
+- U2: `OwnerName` / `OwnerSlug` decouple
+- U3: `IsHermesRequiredEnv()` + `ClassifyLegacyEnvVars()`
+
+### Phase 2 — Generator emission (one or two PRs)
+- U4: Skill template Hermes block
+- U5: README install sections
+
+### Phase 3 — Cli-skills mechanism (small PR)
+- U6: cli-skills as copy + drift documentation
+- U7: Authors lookup
+
+### Phase 4 — CHECKPOINT
+
+Generate samples; surface; user performs real Hermes install; explicit go/no-go.
+
+### Phase 5 — Retrofit (one PR for U8+U9, then cross-repo work for U10)
+- U8: `hermes-audit` dry-run
+- U9: `--apply`
+- U10: Library sweep PRs (1 → 5 → full, staged)
+
+---
+
+## Documentation Plan
+
+- **AGENTS.md (this repo)**: Add a section on the Hermes compatibility model — the env-var filter rules, `IsHermesRequiredEnv` predicate, the dual-key owner identity model, and the cli-skills-as-copy convention.
+- **`printing-press-library/AGENTS.md`**: Document the cli-skills-as-copy mechanism so contributors know cli-skills entries are derived, not hand-edited.
+- **`docs/GLOSSARY.md`**: Add `owner_name` and `owner_slug` as canonical terms with disambiguation (currently "owner" is ambiguous).
+- **`docs/HERMES.md` (new, optional)**: A concise reference for the Hermes compatibility model — frontmatter shape, env-var classification rules, authors-lookup file format, and the retrofit flow.
+
+---
+
+## Sources & References
+
+- Hermes documentation: `https://hermes-agent.nousresearch.com/docs/developer-guide/creating-skills`
+- Auth env-var rich model design pattern: [`docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md`](../solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md)
+- Multi-CLI backport precedent: [`docs/solutions/logic-errors/mcp-handler-conflates-path-and-query-positional-params-2026-05-05.md`](../solutions/logic-errors/mcp-handler-conflates-path-and-query-positional-params-2026-05-05.md)
+- Both-keys-present test pattern: [`docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`](../solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md)
+- Snapshot-restore for non-git dirs: [`docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md`](../solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md)
+- Migration tool reference (line-targeted edits): `tools/migrate-skill-metadata/main.go`
+- Audit subcommand reference: `internal/cli/mcp_audit.go`
+- Apply-with-rollback reference: `internal/pipeline/regenmerge/apply.go`
+- Recent auth model PRs: #632, #639, #645
+- Public library: `https://github.com/mvanhorn/printing-press-library`
diff --git a/docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md b/docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md
new file mode 100644
index 00000000..1b7be44d
--- /dev/null
+++ b/docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md
@@ -0,0 +1,431 @@
+---
+title: feat: Hermes + OpenClaw frontmatter alignment
+type: feat
+status: active
+date: 2026-05-06
+supersedes: docs/plans/2026-05-06-001-feat-hermes-agent-compatibility-plan.md
+---
+
+# feat: Hermes + OpenClaw frontmatter alignment
+
+## Summary
+
+Make printed CLIs installable via Hermes by adding Hermes-recognized frontmatter fields and READMEs install sections. Strip the OpenClaw `requires.env` and `envVars` blocks (false-positive risk on harvested credentials, no real consumers yet) so both formats stay honest. Add a `Prerequisites: Install the CLI` section to the SKILL.md body so any agent host — not just OpenClaw — knows the underlying binary must exist before commands run. Sweep the existing public library to apply the same shape to already-published CLIs via line-targeted edits.
+
+---
+
+## Problem Frame
+
+The Press's current `skill.md.tmpl` emits OpenClaw-shaped `metadata.openclaw.requires.env` and `envVars` blocks meant to declare which env vars the user must set. For specs without rich `EnvVarSpecs`, those blocks emit harvested credentials (cookies, OAuth-flow tokens) as if the user must enter them — confusing for agents and impossible for users to satisfy. We've avoided the burn so far only because OpenClaw isn't launched.
+
+A separate experience report: a Hermes session loaded a printed-CLI skill and didn't install the underlying CLI binary because the install signal lives only in OpenClaw-shaped metadata that Hermes doesn't read. The skill body has no plain-prose "install the CLI before invoking commands" instruction.
+
+`printing-press-library#266` (merged) made the public library a verbatim mirror of `library/<cat>/<api>/SKILL.md`, so the Press is now the single source of truth for SKILL.md content. The fix surface is here.
+
+---
+
+## Requirements
+
+- R1. Strip `metadata.openclaw.requires.env`, `metadata.openclaw.envVars`, and `metadata.openclaw.primaryEnv` from `skill.md.tmpl`. Keep `requires.bins` and `install`. Both formats avoid declaring user-must-set env vars in v1.
+- R2. Add Hermes-recognized top-level fields to `skill.md.tmpl` frontmatter: `version`, `author`, `license`. (Note: `metadata.hermes.tags` is not added — most printed-CLI specs lack a populated catalog category and the field would emit `[other]` for the majority. Defer until catalog enrichment work makes it useful.)
+- R3. Move the existing `## CLI Installation` section in `skill.md.tmpl` (currently around line 379) to immediately after the H1 title and rewrite it with direct imperative wording so any host's LLM-driven agent reads it as instruction: "**You must verify the CLI is installed before invoking any command.**" Failure mode explicit ("do not proceed with skill commands until verification succeeds"). Title becomes `## Prerequisites: Install the CLI` to signal precondition rather than reference. No duplication — the existing section is moved, not copied.
+- R4. Add `## Install via Hermes` and `## Install via OpenClaw` sections to `readme.md.tmpl`. Hermes section emits both CLI form and chat form in code fences. OpenClaw section emits the operator-copyable instruction in a code fence.
+- R5. `version` sources via two-tier resolution: read `.printing-press.json`'s `printing_press_version` if the manifest exists at template-render time (regen / sweep paths); fall back to `version.Version` otherwise (fresh-print path, where the manifest hasn't been written yet — `.printing-press.json` is written during publish, after templates render). The value tracks regenerations of the CLI; on Press releases that don't touch a given CLI, the field stays stable.
+- R6. `author` reads from `git config user.name` at template-render time via a new `resolveOwnerNameForRender()` helper (NOT a reuse of `resolveOwnerForNew`, which sanitizes to slug form, prefers `github.user`, and falls back to `"USER"` — wrong shape for a display name). YAML-escaped via the existing `yamlDoubleQuoted` helper. No manifest decouple, no flag, no userconfig package, no authors lookup file.
+- R7. `printing-press-library`'s `.github/workflows/generate-skills.yml` triggers on `library/**/SKILL.md` and `library/**/README.md` changes so future hand-edits auto-mirror to `cli-skills/`.
+- R8. A one-time sweep tool in `printing-press-library/tools/` line-targets the same shape onto every existing `library/<cat>/<api>/SKILL.md` and `library/<cat>/<api>/README.md`. Idempotent, snapshot-restore safe, follows the `tools/migrate-skill-metadata/main.go` precedent. Sweep covers all 49 CLIs (including the 5 from issue #654 — Phase 3 reprints supersede the sweep's edits for those 5). Sweep derives `<category>` for any URL-templated string from the directory path (`filepath.Base(filepath.Dir(skillMdPath))`), NOT from `.printing-press.json`'s `category` field (which is `omitempty` and present in only ~22% of legacy manifests).
+- R9. The 5 synthesis-only CLIs (Linear, HubSpot, Trigger.dev, Slack, Steam Web) are reprinted to the canonical shape, tracked in [#654](https://github.com/mvanhorn/cli-printing-press/issues/654). Independent of this plan; runs in parallel.
+
+---
+
+## Scope Boundaries
+
+- `required_environment_variables` (Hermes) and `requires.env` / `envVars` (OpenClaw) — both removed in v1. Re-add only when there's signal from real users.
+- `metadata.hermes.config` — not introduced. The non-secret config field doesn't earn its keep until there's a real consumer.
+- `metadata.hermes.required_credential_files`, `platforms`, `related_skills`, `requires_toolsets`, `fallback_for_*` — out of scope.
+- Owner identity dual-key model (`owner_name` / `owner_slug` decouple, manifest storage, `--owner-name` flag, golden-harness differentiation, backwards-compat alias). Read git config at render time, that's it.
+- Press repo MIT → Apache 2.0 license migration. Separate plan with proper contributor-consent process for Matt Van Horn, Trevin Chow, Cathryn Lavery, Dinakar Sarbada.
+- Internal Press skills (`skills/printing-press*/SKILL.md`) Hermes parity. No Hermes consumer for development tooling.
+- LLM-generated `required_for` text per CLI.
+- `auth.key_url` backfill across legacy specs.
+- Catalog enrichment for specs lacking `Category`.
+
+### Deferred to Follow-Up Work
+
+- Re-add env-var hoisting (Hermes `required_environment_variables` and/or OpenClaw `requires.env` + `envVars`) once there's user signal on what the install-time prompt UX should look like.
+- Press → Apache 2.0 migration as its own plan.
+- `auth.key_url` backfill (improves any future Hermes `help` field quality).
+- Catalog enrichment for specs without `Category` (improves any future Hermes `tags` quality).
+- Pin `go install` invocations across all install instructions (Prerequisites section, README install sections, MCP register commands) — supply-chain hardening that should be addressed library-wide, not in one section. Per-CLI release-tag plumbing doesn't currently exist; pinning to per-CLI goreleaser tags requires a lookup mechanism that's its own scope.
+
+---
+
+## Context & Research
+
+### Repos involved
+
+This plan touches two repos. Each implementation unit is labeled with `**Repo:** <repo>` so the file paths and dependencies are unambiguous.
+
+| Repo | GitHub | Local path | What lives here |
+|---|---|---|---|
+| `cli-printing-press` | [`mvanhorn/cli-printing-press`](https://github.com/mvanhorn/cli-printing-press) | `~/Code/cli-printing-press` | The Press generator, templates, this plan, the Press's own tooling. **Default repo for this plan.** |
+| `printing-press-library` | [`mvanhorn/printing-press-library`](https://github.com/mvanhorn/printing-press-library) | `~/Code/printing-press-library` | Public library of generated CLIs (49 entries), `cli-skills/` mirror, `tools/generate-skills/main.go`, the `generate-skills.yml` workflow. |
+
+All file paths in the plan are repo-relative to the unit's labeled repo.
+
+### Relevant Code
+
+- **Skill template**: `internal/generator/templates/skill.md.tmpl` — currently emits the OpenClaw block including `requires.env` and `envVars`. R1, R2, R3 all touch this file.
+- **README template**: `internal/generator/templates/readme.md.tmpl` — `Use with Claude Code` section near line 297 is the existing install-section pattern. R4 slots two new sections alongside.
+- **Generator helpers**: `internal/generator/generator.go:240` is where helpers register (`currentYear`, `modulePath`). R5, R6 add `licenseSPDX`, `pressVersion`, plus the `OwnerName` field exposure.
+- **Existing escape helper**: `yamlDoubleQuoted` (already registered) — apply to `OwnerName` at template emission.
+- **Sweep precedent**: `tools/migrate-skill-metadata/main.go` (formerly in this repo; retired in U7 of this same plan). Line-targeted, idempotent transform pattern. R8's sweep tool followed the same shape; git history is the canonical reference for the precedent.
+- **Public library workflow**: `printing-press-library/.github/workflows/generate-skills.yml` — currently triggers on `registry.json`, `library/**/.printing-press.json`, `tools/generate-skills/**`. R7 adds two more paths.
+- **Manifest source**: `.printing-press.json` carries `cli_name`, `category`, `printing_press_version`. Sweep + template both read from here. All 49 library CLIs have this file.
+
+### Public library state (as of 2026-05-06)
+
+- 49 CLIs total, all with `library/<cat>/<api>/SKILL.md` and `.printing-press.json`
+- 35 with `spec.yaml`; 14 without (synthesis-only or hand-curated origins)
+- 5 of the 14 are issue #654's reprint candidates
+- PR #266 merged: public library is now a verbatim mirror, no synthesis path
+
+---
+
+## Key Technical Decisions
+
+- **Coexistence**: Hermes top-level fields and OpenClaw nested block live in the same frontmatter. Hermes ignores unknown keys per its docs; one SKILL.md serves both hosts.
+- **No env-var declarations in either format**: false-positive on harvested vars is asymmetrically worse than no declaration. The existing `readme.md.tmpl` already branches its install instructions by `auth.Type` (api_key emits "set the env var" with the canonical name; cookie/composed/oauth2 emit "run `auth login` (variant)"; bearer_token similar). That branching IS the conservative classification we want — applied at the install-instruction level instead of the metadata level. The plan must not break this existing logic, and does not add a new generic "Authentication" section that would risk re-introducing the harvested-vs-user-set classification problem at a different surface.
+- **Prerequisites section is plain markdown, not host-specific metadata**: any LLM-driven agent reads it regardless of frontmatter format support. Direct imperative ("you must verify") and explicit failure mode ("do not proceed") signal it as instruction.
+- **`version` tracks Press version that produced the current SKILL.md**: two-tier resolution (manifest if present, else `version.Version`). Bumps on regen, stable on Press releases that don't touch a given CLI. Honest framing — it doesn't promise more than that.
+- **`author` reads git config at render time via a new helper**: not a reuse of `resolveOwnerForNew`, which sanitizes to slug form. New helper reads raw `git config user.name`, no sanitization. Empty value falls back to the slug-shaped `Owner` with a loud stderr warning rather than hard-failing — the generator package is reused by tests, mcp-sync, and regen-merge where setting `OwnerName` is awkward, so a hard error was over-strict. The sweep tool's per-CLI authorship mapping overrides this code path entirely; the soft-fallback only fires for fresh prints by users who haven't set `git config user.name`.
+- **Library publish path hardcoded `mvanhorn/printing-press-library`** in templates. Single constant; no abstraction layer.
+- **Sweep is line-targeted, not template re-rendering**: follows `tools/migrate-skill-metadata/main.go` precedent. Frontmatter region replaced surgically; Prerequisites section inserted via anchor; README sections inserted via anchor. Body content untouched.
+- **Sweep covers all 49 CLIs** including the 5 from #654: Phase 3 reprints supersede the sweep's edits for those 5 with full regenerated content. Phases 3 and 4 are independent and can run in parallel.
+- **`go install ...@latest` retained for v1**: matches the pattern in every existing library README. Pinning would require per-CLI release-tag plumbing that doesn't exist (the Press version isn't the printed CLI's release version — those are independent). The supply-chain risk applies equally to all install instructions; addressing it for the Prerequisites section without addressing it across the README would be inconsistent. Tracked in Risks; revisit when a broader install-pinning effort is justified.
+- **`metadata.hermes.tags` not emitted in v1**: catalog `category` is `omitempty` in `.printing-press.json` and present in only ~22% of legacy manifests. Even with directory-path derivation, only ~14 of 49 CLIs have a non-`other` catalog category. Shipping `tags: [other]` for the majority is worse than no tags. Defer until catalog enrichment makes the field meaningful.
+
+---
+
+## Implementation Units
+
+### U1. Update `skill.md.tmpl` frontmatter
+
+**Repo:** `cli-printing-press`
+
+**Goal:** Strip OpenClaw `requires.env`, `envVars`, and `primaryEnv`; add Hermes top-level fields (`version`, `author`, `license`).
+
+**Requirements:** R1, R2, R5, R6
+
+**Dependencies:** U4 (helpers must exist first)
+
+**Files:**
+- Modify: `internal/generator/templates/skill.md.tmpl`
+- Test: `internal/generator/skill_test.go`
+- Update: `testdata/golden/cases/generate-golden-*` (4 fixtures)
+
+**Approach:**
+- Remove the `requires.env` line, the entire `envVars:` block, and any `primaryEnv:` line (legacy synthesized CLIs may have it; the live template doesn't emit it but the canonical post-strip shape needs to be unambiguous about absence). Lines 9-44 of the current template are the relevant region.
+- Add top-level `version: "{{.PressVersion}}"`, `author: "{{yamlDoubleQuoted .OwnerName}}"`, `license: "Apache-2.0"` after the existing `description:` field. License is a literal string, no helper needed.
+- Keep `metadata.openclaw.requires.bins` and `metadata.openclaw.install`.
+
+**Test scenarios:**
+- Happy path: rendered frontmatter has `version`, `author`, `license` populated correctly for an api_key CLI (Shopify-shape); no `requires.env`, no `envVars`, no `primaryEnv` anywhere.
+- Happy path: rendered frontmatter for a no-auth CLI (Wikipedia-shape) emits Hermes fields, no env-var blocks of any kind.
+- Edge case: `OwnerName` containing YAML special characters (colon, quote) survives `yamlDoubleQuoted` round-trip.
+- Edge case: `OwnerName` empty (no git config) falls back to the slug-shaped `Owner` and emits a loud stderr warning. Generation does not fail.
+- Edge case: regenerating a CLI that previously had `primaryEnv` in its template output produces output without `primaryEnv`.
+- Integration: rendered frontmatter parses as valid YAML via `gopkg.in/yaml.v3`.
+
+**Verification:**
+- `internal/generator/skill_test.go` passes.
+- `scripts/golden.sh verify` passes after intentional fixture updates.
+
+---
+
+### U2. Move and rewrite `## CLI Installation` as `## Prerequisites: Install the CLI`
+
+**Repo:** `cli-printing-press`
+
+**Goal:** The existing `## CLI Installation` section in `skill.md.tmpl` (around line 379) is too far down the body — agents read top-down and decide what command to run before reaching it. Move it to immediately after the H1 and rewrite with imperative wording so any agent host's LLM reads it as a precondition, not a reference.
+
+**Requirements:** R3
+
+**Dependencies:** None (independent of U1)
+
+**Files:**
+- Modify: `internal/generator/templates/skill.md.tmpl`
+- Test: `internal/generator/skill_test.go`
+
+**Approach:**
+- Locate the existing `## CLI Installation` section (currently around line 379) and remove it from its current position.
+- Insert it immediately after the H1 (`# {{.ProseName}} — Printing Press CLI`), before the value-prop paragraph.
+- Rename heading to `## Prerequisites: Install the CLI` so it signals a precondition.
+- Rewrite the prose with direct imperative language. Use bold for the must-verify clause so the LLM-driven agent doesn't skim past. No duplication — there is one section, in one place, with stronger wording:
+
+```markdown
+## Prerequisites: Install the CLI
+
+This skill drives the `{{.Name}}-pp-cli` binary. **You must verify the CLI is installed before invoking any command from this skill.** If it is missing, install it first:
+
+​```bash
+go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}/cmd/{{.Name}}-pp-cli@latest
+​```
+
+Verify: `{{.Name}}-pp-cli --version`
+
+If `--version` reports "command not found" after install, ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`. Do not proceed with skill commands until verification succeeds.
+```
+
+**Test scenarios:**
+- Happy path: rendered SKILL.md contains the section after the H1 and before the value-prop paragraph.
+- Happy path: `go install` URL substitutes `{{.Name}}` and `{{.Category}}` correctly.
+- Edge case: empty `Category` falls back to `other` in the install URL.
+
+**Verification:**
+- Tests pass; visual inspection of one rendered fixture confirms placement.
+
+---
+
+### U3. Add `Install via Hermes` and `Install via OpenClaw` sections to `readme.md.tmpl`
+
+**Repo:** `cli-printing-press`
+
+**Goal:** Two new install sections in the README so users (not agents) have copy-pasteable install instructions for the two new agent hosts.
+
+**Requirements:** R4
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/generator/templates/readme.md.tmpl`
+- Test: `internal/generator/readme_test.go`
+
+**Approach:**
+- Insert after the existing `Use with Claude Desktop` section. Place a stable anchor comment (e.g., `<!-- pp-hermes-install-anchor -->`) immediately before the new sections so the sweep tool (U7) can locate the insertion point idempotently in legacy READMEs that were generated before this change.
+- Both Hermes commands go in `bash` code fences. OpenClaw goes in a plain code fence (no language tag — it's prose for the agent to read, not a shell command).
+
+```markdown
+<!-- pp-hermes-install-anchor -->
+## Install via Hermes
+
+From the Hermes CLI:
+
+​```bash
+hermes skills install mvanhorn/printing-press-library/cli-skills/pp-{{.Name}} --force
+​```
+
+Inside a Hermes chat session:
+
+​```bash
+/skills install mvanhorn/printing-press-library/cli-skills/pp-{{.Name}} --force
+​```
+
+## Install via OpenClaw
+
+Tell your OpenClaw agent (copy this):
+
+​```
+Install the pp-{{.Name}} skill from https://github.com/mvanhorn/printing-press-library/tree/main/cli-skills/pp-{{.Name}}. The skill defines how its required CLI can be installed.
+​```
+```
+
+**Test scenarios:**
+- Happy path: rendered README contains both new section headings and the anchor comment.
+- Happy path: install URLs always say `mvanhorn/...` regardless of CLI's owner — catches accidentally templating the publisher.
+- Integration: golden fixtures update for all four `generate-golden-*` cases.
+
+**Verification:**
+- Tests pass; `scripts/golden.sh verify` passes after fixture updates.
+
+---
+
+### U4. Plumbing: `pressVersion` helper and `OwnerName` field
+
+**Repo:** `cli-printing-press`
+
+**Goal:** Register the template helper and field that U1 consumes. License is a literal `"Apache-2.0"` in the template, no helper needed (zero variability).
+
+**Requirements:** R5, R6
+
+**Dependencies:** None — lands first or alongside U1.
+
+**Files:**
+- Modify: `internal/generator/generator.go` (register `pressVersion` helper)
+- Modify: `internal/spec/spec.go` (add `OwnerName` field on `APISpec`)
+- Modify: `internal/generator/plan_generate.go` (add new `resolveOwnerNameForRender()` — does NOT reuse `resolveOwnerForNew`)
+- Test: `internal/generator/generator_test.go`, `internal/spec/spec_test.go`
+
+**Approach:**
+- `pressVersion` helper: two-tier resolution.
+  1. If `<outputDir>/.printing-press.json` exists, read `printing_press_version` from it (regen / sweep path: preserves the version that produced the current SKILL.md).
+  2. Otherwise (fresh-print path: manifest hasn't been written yet — that happens during publish, after templates render), fall back to `version.Version` (the Press binary's own version constant).
+  - Mirrors the existing `resolveOwnerForExisting` / `resolveOwnerForNew` split shape. Reading the manifest must happen via a local helper to avoid the existing `pipeline → generator` import direction.
+- `OwnerName`: new helper `resolveOwnerNameForRender()` in `plan_generate.go`.
+  - Reads raw `git config user.name`.
+  - Does NOT fall back to `github.user`, does NOT sanitize via `sanitizeOwner` (which would mangle "Trevin Chow" → "trevin-chow"), does NOT default to `"USER"`.
+  - If empty: `Generate()` falls back to the slug-shaped `Owner` and emits a stderr warning. Soft-fallback (not a hard failure) so the generator package stays reusable by tests, mcp-sync, and regen-merge without forcing them to plumb `OwnerName`.
+  - Exposed as `.OwnerName` field on the spec (set during resolution, before template render).
+- `OwnerSlug` plumbing stays unchanged — the existing `Owner` field continues to drive copyright headers and module paths via the existing `resolveOwnerForNew`.
+
+**Test scenarios:**
+- Happy path (fresh print): `pressVersion` falls back to `version.Version` when `.printing-press.json` is absent.
+- Happy path (regen): `pressVersion` reads `printing_press_version` from existing `.printing-press.json`.
+- Happy path: `OwnerName` populated from `git config user.name` flows to `.OwnerName` in the template context.
+- Edge case: `OwnerName` empty → generation returns clear error mentioning git config.
+- Edge case: `OwnerName` containing spaces ("Trevin Chow") survives unchanged into the template (no sanitization).
+- Edge case: `OwnerName` and `OwnerSlug` co-exist with diverging values ("Trevin Chow" vs "trevin-chow"); each lands in its correct surface (display name in author / copyright header, slug in module path).
+
+**Verification:**
+- Tests pass.
+
+---
+
+### U5. Workflow trigger fix in `printing-press-library`
+
+**Repo:** `printing-press-library` (cross-repo from this plan's home)
+
+**Goal:** Add `library/**/SKILL.md` and `library/**/README.md` to `.github/workflows/generate-skills.yml` triggers so future hand-edits to library content auto-mirror to `cli-skills/`.
+
+**Requirements:** R7
+
+**Dependencies:** None — independent, lands in the public library repo.
+
+**Files:**
+- Modify: `.github/workflows/generate-skills.yml`
+
+**Approach:**
+- Add the two new paths under `on.push.paths`. One PR, two-line change.
+
+**Test scenarios:**
+- Test expectation: none — workflow change is verified by inspection plus a one-off test commit after merge.
+
+**Verification:**
+- After merge, a no-op commit touching one library SKILL.md triggers the workflow and produces a no-op `cli-skills/` diff.
+
+---
+
+### U6. One-time sweep tool in `printing-press-library/tools/`
+
+**Repo:** `printing-press-library` (cross-repo from this plan's home)
+
+**Goal:** Walk all 49 library entries, line-target-edit `SKILL.md` (frontmatter + Prerequisites section) and `README.md` (install sections) in place. Idempotent, snapshot-restore safe.
+
+**Requirements:** R8
+
+**Dependencies:** U1, U2, U3 (so the canonical shape the sweep applies is defined — these live in `cli-printing-press` and must merge before this unit runs). U5 helpful but not blocking — the workflow is the auto-mirror path; if not landed yet, the sweep PR can manually run `tools/generate-skills/main.go` once at the end.
+
+**Files:**
+- Create: `tools/sweep-frontmatter/main.go`
+- Create: `tools/sweep-frontmatter/main_test.go`
+- The sweep PR also commits the patched `library/**/SKILL.md` and `library/**/README.md` files.
+
+**Approach:**
+- Walk `library/*/*/`. For each:
+  1. Read `.printing-press.json` for `cli_name` and `printing_press_version`. Derive `<category>` from the directory path (`filepath.Base(filepath.Dir(skillMdPath))`), NOT from the manifest's `category` field — only ~22% of legacy manifests have it populated, but every CLI lives at `library/<cat>/<api>/` so the path is authoritative.
+  2. Read existing `SKILL.md`. Strip OpenClaw `requires.env`, `envVars` block, and any `primaryEnv` line — handle all four legacy shapes: `env: []` (empty inline), `env: ["FOO"]` (inline with value), `env:` followed by block-style `- FOO` continuation lines, and absent. Replace the frontmatter region (between the leading `---` and closing `---`) with the canonical-shape frontmatter built from the manifest data plus operator's `git config user.name` for `author:`. Move the existing `## CLI Installation` section (if present) to immediately after the H1 and rename to `## Prerequisites: Install the CLI` with imperative wording. **Idempotency check for SKILL.md**: presence of the literal heading `## Prerequisites: Install the CLI` near the top of the body — if found, skip the move/rewrite step (the sweep already ran for this file).
+  3. Read existing `README.md`. Insert `## Install via Hermes` and `## Install via OpenClaw` sections at the anchor `<!-- pp-hermes-install-anchor -->`. For legacy READMEs without the anchor, fall back: insert after the last `## Use with Claude Desktop`, or `## Use with Claude Code`, or `## Install`, in that order. If none match, append at end of file. **Idempotency check for README.md**: presence of the anchor comment OR the literal heading `## Install via Hermes` — either signals the sweep already ran.
+  4. Snapshot all touched files first; restore on any per-CLI error.
+- Sweep is line-targeted (matching the `tools/migrate-skill-metadata/main.go` precedent), not yaml-round-trip, so existing comments and formatting in non-frontmatter content are byte-preserved.
+
+**Test scenarios:**
+- Happy path: sweep produces canonical-shape SKILL.md and README for an api_key CLI fixture (e.g., Shopify-shape).
+- Happy path: sweep is idempotent — running twice produces zero textual diff on the second run, verified via `cmp -s`.
+- Edge case: legacy README without the anchor falls back to the last `## Use with Claude Desktop`; absence of all named fallbacks appends at EOF.
+- Edge case: legacy SKILL.md without the H1 yet — sweep skips the Prerequisites insertion with a warning rather than corrupting structure.
+- Edge case: per-CLI write failure restores all touched files for that CLI from snapshot.
+- Edge case: synthesis-only CLI (no spec.yaml) is processed identically — sweep doesn't read spec.yaml.
+- Integration: full sweep against a fixture library tree produces files that re-parse cleanly through yaml.v3.
+
+**Verification:**
+- Sweep tests pass.
+- After running against the live library: every `library/<cat>/<api>/SKILL.md` parses cleanly, has the new frontmatter shape, has the Prerequisites section. Every `README.md` has the two new install sections.
+- After workflow runs (Phase 1b's trigger fix), `cli-skills/pp-<api>/SKILL.md` is byte-identical to its library counterpart for every CLI.
+
+---
+
+### U7. Retire `tools/migrate-skill-metadata/main.go`
+
+**Repo:** `cli-printing-press`
+
+**Goal:** Delete the dormant migrator. It was a one-shot historical conversion (legacy `metadata: '{...JSON...}'` string → nested YAML) that long since ran across the library. Its emission shape has drifted from the current template (still emits `primaryEnv`, `bins`-before-`env` ordering, no `envVars` block). After U1 lands, the drift is wider; the file is a foot-gun if anyone ever re-runs it.
+
+**Requirements:** none load-bearing; cleanup hygiene that pairs with U1's frontmatter-shape changes.
+
+**Dependencies:** U1.
+
+**Files:**
+- Delete: `tools/migrate-skill-metadata/main.go`
+- Delete: `tools/migrate-skill-metadata/main_test.go`
+
+**Approach:**
+- Delete both files.
+- Verify no internal imports reference the package (`grep -r "migrate-skill-metadata" .` — should only match removed files and possibly historical commit references in docs, which are fine).
+
+**Test scenarios:**
+- No tests needed; deletion is mechanical.
+- If updating: existing tests adjusted to the new shape.
+
+**Verification:**
+- `go build ./...` and `go test ./...` pass.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph:** No runtime callbacks change. Generator templates gain content; emission paths unchanged.
+- **Error propagation:** Empty `OwnerName` triggers a stderr warning + slug-shaped fallback (loud but non-fatal). Sweep rolls back per-CLI file sets via snapshot-restore on failure.
+- **State lifecycle:** The four-file mutation per CLI in U6 is the highest-risk path. Snapshot-restore + idempotency test cover partial-failure and re-run cases.
+- **API surface parity:** SKILL.md and `cli-skills/pp-<api>/SKILL.md` stay byte-identical via the workflow mirror (after U5 lands the trigger fix).
+- **Unchanged invariants:**
+  - Copyright header format (`// Copyright YYYY <slug>.`) — unchanged. `OwnerSlug` continues to drive headers; `OwnerName` only flows into prose surfaces.
+  - MCPB `manifest.json` author/license fields — unchanged.
+  - OpenAPI parser, auth-classification logic, `IsRequestCredential()` — unchanged.
+  - Press repo's MIT license — unchanged (migration deferred).
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Sweep clobbers hand-edits in non-frontmatter content of legacy SKILL.md / README | Line-targeted edits; body and non-anchored README content byte-preserved. Snapshot-restore on failure. Idempotency test catches regressions. |
+| Empty `git config user.name` during sweep blocks the entire run | Sweep checks once at start, fails fast with clear error. Operator sets git config and re-runs. |
+| Anchor-based README insertion lands in the wrong place for legacy READMEs without any named install section | Documented fallback chain (Use with Claude Desktop → Use with Claude Code → Install → EOF). Sweep's dry-run prints which fallback fires per CLI so operator reviews before merging the sweep PR. |
+| Workflow trigger fix not landed before the sweep | Sweep PR can manually run `tools/generate-skills/main.go` once at end of the sweep run, committing the cli-skills updates alongside. Or land U5 first (recommended). |
+| Supply-chain risk: `go install ...@latest` in agent-executable Prerequisites prose | Accepted-for-v1. Same risk applies to all existing READMEs; addressing it requires per-CLI release-tag plumbing that's out of scope. Tracked in Deferred. Mitigated by Go module proxy + checksum DB once a version has been previously fetched. |
+
+---
+
+## Phased Delivery
+
+```
+Phase 1 (DONE) — printing-press-library#266 merged: verbatim mirror live
+        │
+        ▼
+Phase 2 — Press changes (cli-printing-press): U1, U2, U3, U4, U7
+        │  + small cross-repo prep PR: workflow trigger fix (U5) in
+        │    printing-press-library, lands before or alongside Phase 2
+        ▼
+Phase 3 — issue #654: 5 reprints (cross-repo, parallel with Phase 4, supersedes sweep edits for those 5)
+        │
+        ▼
+Phase 4 — sweep PR (printing-press-library): U6
+```
+
+- **Workflow trigger fix (U5)** is a small standalone PR that lands before or alongside Phase 2. Folded into the prep work — not a separate phase since it's two YAML lines and unblocks nothing on its own.
+- **Phase 2** lands the canonical shape in the Press generator. New prints from this point use it.
+- **Phase 3** is tracked in [#654](https://github.com/mvanhorn/cli-printing-press/issues/654), runs in parallel with Phase 4. Independent.
+- **Phase 4** sweeps existing library entries to the canonical shape via line-targeted edits. Includes all 49 CLIs (Phase 3 reprints later supersede for those 5).
+
+Each phase ships as its own PR; nothing batched.
+
+---
+
+## Sources & References
+
+- Hermes documentation: `https://hermes-agent.nousresearch.com/docs/developer-guide/creating-skills`
+- Public library verbatim-mirror PR: [`mvanhorn/printing-press-library#266`](https://github.com/mvanhorn/printing-press-library/pull/266) (merged)
+- Phase 3 reprint tracking: [`mvanhorn/cli-printing-press#654`](https://github.com/mvanhorn/cli-printing-press/issues/654)
+- Sweep precedent (line-targeted edits): `tools/migrate-skill-metadata/main.go` (retired in U7 of this plan; see git history for the source)
+- Round-2 superseded plan: `docs/plans/2026-05-06-001-feat-hermes-agent-compatibility-plan.md` (status: superseded)
diff --git a/docs/solutions/best-practices/cross-repo-coordination-with-printing-press-library-2026-05-06.md b/docs/solutions/best-practices/cross-repo-coordination-with-printing-press-library-2026-05-06.md
new file mode 100644
index 00000000..7767893c
--- /dev/null
+++ b/docs/solutions/best-practices/cross-repo-coordination-with-printing-press-library-2026-05-06.md
@@ -0,0 +1,192 @@
+---
+title: "Cross-repo coordination with printing-press-library: invisible CI couplings when generator templates or schemas change"
+date: 2026-05-06
+category: best-practices
+module: cli-printing-press-generator
+problem_type: best_practice
+component: tooling
+severity: medium
+applies_when:
+  - "Changing internal/generator/templates/skill.md.tmpl, readme.md.tmpl, or other agent-readable templates"
+  - "Adding new install/usage instructions that embed commands from external tools (npm, hermes, claude mcp, go install, brew, etc.)"
+  - "Changing the canonical shape of library/<cat>/<api>/SKILL.md or library/<cat>/<api>/README.md content"
+  - "Working in printing-press-library and seeing the cli-skills mirror parity check fail or the verify-skill script fail across multiple CLIs at once"
+tags:
+  - cross-repo
+  - generator-templates
+  - mirror
+  - validator
+  - allowlist
+  - ci-coupling
+related_components:
+  - tooling
+  - documentation
+  - templates
+---
+
+# Cross-repo coordination with printing-press-library: invisible CI couplings when generator templates or schemas change
+
+## Context
+
+> **Cross-repo coupling.** The validator and mirror-regeneration workflows referenced here live in [`mvanhorn/printing-press-library`](https://github.com/mvanhorn/printing-press-library). The lesson is filed in `cli-printing-press` (this repo) because it's *our* template / schema changes that break those downstream workflows — the failure is invisible from this side until the next library regen runs.
+
+This repo (`cli-printing-press`) is the upstream generator for printed CLIs. The output flows downstream to `printing-press-library` where the CLIs are published. The library runs CI checks and an auto-regeneration workflow that depend on assumptions about the upstream's output shape. When we change those assumptions without coordinating, downstream CI fails — often across every CLI at once — and the failure messages don't point at the upstream change as the cause.
+
+Two known coupling points have produced this failure mode. Both surfaced during the Hermes / OpenClaw frontmatter alignment work. Future template / schema changes are likely to produce more, and the pattern is what to remember.
+
+## The two known couplings
+
+### 1. cli-skills mirror parity
+
+The library has two trees that must stay byte-identical:
+
+- `library/<cat>/<api>/SKILL.md` — canonical content
+- `cli-skills/pp-<api>/SKILL.md` — flat-namespace mirror, used by `npx skills add` and Hermes / OpenClaw install paths
+
+`tools/generate-skills/main.go` (in printing-press-library) is a verbatim mirror tool that copies the source to the mirror. `.github/workflows/generate-skills.yml` runs it on push to main. **It does not run on pull request.**
+
+Separately, a `cli-skills mirror parity` check runs on every PR to verify that the committed `cli-skills/` matches what the generator would produce from the current `library/`. This catches drift.
+
+The asymmetry: PRs verify, but don't regenerate. So a PR that mutates `library/<cat>/<api>/SKILL.md` (intentionally — e.g., the Hermes-alignment sweep) without also regenerating the mirror fails parity. The PR author has to run the mirror generator locally and commit the result.
+
+The Hermes-alignment sweep (`printing-press-library#267`) hit this directly:
+
+```
+##[error]cli-skills/ is out of sync with what tools/generate-skills produces from library/<cat>/<slug>/SKILL.md.
+##[error]Run `go run ./tools/generate-skills/main.go` locally and commit the result.
+
+Out-of-sync entries:
+ M cli-skills/pp-agent-capture/SKILL.md
+ M cli-skills/pp-ahrefs/SKILL.md
+ ... (47 more)
+```
+
+### 2. Flag-validator allowlist
+
+`printing-press-library/.github/scripts/verify-skill/verify_skill.py` runs on every PR that touches `library/**/SKILL.md`. One of its checks: every `--flag` mentioned in a SKILL.md should be declared in that CLI's `internal/cli/*.go` source. This catches typos in real CLI flag mentions.
+
+Our SKILL.md template embeds install commands from other tools:
+
+```bash
+npx -y @mvanhorn/printing-press install mercury --cli-only
+hermes skills install mvanhorn/printing-press-library/cli-skills/pp-mercury --force
+```
+
+`--cli-only` is a flag of the npm installer, not of `mercury-pp-cli`. `--force` is a flag of the hermes CLI. The validator can't distinguish these from real CLI flag mentions, so it fails — across every CLI in the library — when a new external-tool flag appears in the template.
+
+The validator has a `COMMON_FLAGS` allowlist for "flags that are OK to mention without source declaration." Adding a new install instruction that uses a new external flag means adding that flag to the allowlist in the same change.
+
+The Hermes-alignment template change (`cli-printing-press#655`) added `--cli-only` and didn't allowlist it; the next library regen failed all 49 CLIs:
+
+```
+=== shopify ===
+  ✘ 1 error(s), 0 likely false-positive(s)
+    [flag-names] (any): --cli-only is referenced in SKILL.md but not declared in any internal/cli/*.go
+```
+
+## Pre-flight checklist before merging a generator template change
+
+When changing templates in this repo:
+
+1. **Did the change add or modify install / usage commands?**
+   Scan the new commands for `--flag` tokens. For each flag belonging to a tool other than the printed CLI:
+   - Verify it's in `printing-press-library/.github/scripts/verify-skill/verify_skill.py`'s `COMMON_FLAGS` set
+   - If not, add it in the same change (or a coordinated companion PR that lands first)
+   - Currently allowlisted: `cli-only`, `skill-only`, `registry-url` (npm installer), `force` (hermes)
+
+2. **Did the change alter the shape of `library/<cat>/<api>/SKILL.md` or `library/<cat>/<api>/README.md` content?**
+   When library content gets regenerated downstream (via a sweep tool, manual reprint, or normal regen), the regeneration must include `tools/generate-skills/main.go` running once and the resulting `cli-skills/` updates committed in the same PR. The PR-side parity check verifies committed state; the auto-mirror workflow runs only on push to main and won't fix the PR for you.
+
+3. **Did the change alter the schema of `.printing-press.json` or `registry.json`?**
+   Downstream tools that read the manifest may need updates. Audit any references in:
+   - `printing-press-library/tools/` (mirror tool, registry generator, sweep tools)
+   - `printing-press-library/.github/scripts/` (verify-skill validator, others)
+   - The `npm/` package's resolver code (it reads the catalog)
+
+4. **Did the change touch a verifier rule (golden, scorecard, dogfood)?**
+   These are caught by this repo's CI, not downstream's. Run `scripts/golden.sh verify` locally and update fixtures explicitly if the change is intentional.
+
+## Why these couple
+
+The pattern is "upstream generates, downstream publishes / mirrors / validates." The downstream's CI checks are calibrated to the upstream's current output shape. When the shape evolves, the calibration drifts.
+
+Two architectural choices make the drift invisible until late:
+
+1. **The downstream CI runs on PRs only when the PR touches files in the downstream repo.** Changes here don't trigger checks there. The drift surfaces on the next downstream content change — usually unrelated to the upstream template change.
+2. **The downstream's auto-regen workflow runs only on push to main, not on PR.** The verify check on PRs validates committed state, expecting the PR author to have already regenerated. This is the right shape — auto-regen on PR would create churn — but the gap between "verify on PR" and "regen on push" is the place where uncoordinated changes fail.
+
+The fix shape is consistent: when changing something here that the downstream reads, identify which downstream workflows are calibrated to it, and update both sides in the same logical change. The pre-flight checklist above is the operational form of that.
+
+## When to apply
+
+Always, when:
+
+- Changing files in `internal/generator/templates/`
+- Changing the schema of `.printing-press.json` (manifest fields read by downstream tools)
+- Changing the schema of catalog entries (read by `printing-press-library` for registry building)
+- Changing what flags or sections the printed CLI's `--help` produces in ways that propagate into SKILL.md (the validator's `flag-commands` check reads both)
+
+Skip the cross-repo coordination check when:
+
+- Changing internal generator code that doesn't affect output shape (refactor, simplify, performance)
+- Changing tests or test fixtures
+- Changing docs in this repo only
+
+## Examples
+
+### Anti-pattern — silent CI gap
+
+Template change in `cli-printing-press` merges. Library regen workflow runs on next push to main. CI fails for every CLI. Investigation:
+
+```
+##[error]library/marketing/ahrefs/
+=== ahrefs ===
+  ✘ 1 error(s)
+    [flag-names] (any): --cli-only is referenced in SKILL.md but not declared in any internal/cli/*.go
+```
+
+Author of the original template change isn't around. Error message points at `internal/cli/*.go` (a CLI source-tree path) rather than at the template that introduced the flag. Triage takes hours.
+
+### Pattern — coordinated change
+
+Template change in `cli-printing-press` includes:
+
+```diff
+ # internal/generator/templates/skill.md.tmpl
++1. Install via the Printing Press installer:
++   ```bash
++   npx -y @mvanhorn/printing-press install {{.Name}} --cli-only
++   ```
+```
+
+Same logical change includes (in `printing-press-library`, possibly a coordinated companion PR):
+
+```diff
+ # .github/scripts/verify-skill/verify_skill.py
+ COMMON_FLAGS = {
+     "help", "version", "json", ...
++    "cli-only",   # @mvanhorn/printing-press install ... --cli-only
+ }
+```
+
+Order: validator-allowlist update lands FIRST, template change lands SECOND. If they land out of order, the gap window has every library regen failing.
+
+### Pattern — local mirror regen before PR
+
+```bash
+# In printing-press-library, after making any change to library/<cat>/<api>/SKILL.md or README.md:
+go run ./tools/generate-skills/main.go
+git add library/ cli-skills/
+git commit -m "..."
+git push
+```
+
+One commit, one CI cycle. Without the local regen, you get three commits (source change + mirror catch-up + maybe an additional fixup) and two CI cycles minimum.
+
+## Related
+
+- `printing-press-library/.github/workflows/generate-skills.yml` (cross-repo) — the auto-mirror workflow + its `paths:` triggers
+- `printing-press-library/tools/generate-skills/main.go` (cross-repo) — the verbatim mirror generator
+- `printing-press-library/.github/scripts/verify-skill/verify_skill.py` (cross-repo) — the validator + `COMMON_FLAGS` allowlist
+- `printing-press-library/AGENTS.md` (cross-repo) — has matching pre-flight notes for the validator-allowlist coupling
+- `docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md` — the plan that surfaced both couplings
diff --git a/docs/solutions/conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md b/docs/solutions/conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md
new file mode 100644
index 00000000..5cdf8a2d
--- /dev/null
+++ b/docs/solutions/conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md
@@ -0,0 +1,191 @@
+---
+title: "Preserve original authorship in multi-author retrofits: don't trust the operator's git config when sweeping content others created"
+date: 2026-05-06
+category: conventions
+module: cli-printing-press-generator
+problem_type: convention
+component: tooling
+severity: high
+applies_when:
+  - "Writing a one-shot or recurring sweep tool that mutates content across many entries created by different people"
+  - "The natural-feeling default would be to attribute every entry to whoever's running the sweep (via `git config user.name`, `whoami`, etc.)"
+  - "Original authorship is recoverable from existing artifacts (copyright headers, git history, README bylines) — and getting it wrong burns trust with the actual authors"
+  - "The published output ships with an `author:` field, copyright notice, or other attribution surface that downstream consumers will see"
+tags:
+  - retrofit
+  - authorship
+  - sweep-tool
+  - cross-repo
+  - attribution
+  - cross-repo-lesson
+related_components:
+  - tooling
+  - documentation
+---
+
+# Preserve original authorship in multi-author retrofits: don't trust the operator's git config when sweeping content others created
+
+## Context
+
+> **Cross-repo note.** The sweep tool that triggered this lesson lives in [`mvanhorn/printing-press-library`](https://github.com/mvanhorn/printing-press-library) at `tools/sweep-frontmatter/main.go`. The lesson itself applies anywhere `cli-printing-press` (this repo) ships generation or rewriting tooling that touches multi-author content — primarily the published library, but also any future cross-CLI sweep we add to the generator's surface.
+
+The Hermes / OpenClaw frontmatter alignment work needed to add an `author:` field to every existing CLI's published `library/<cat>/<api>/SKILL.md`. The sweep tool's first cut resolved the field from the operator's `git config user.name` — a pragmatic default that worked for fresh prints (whoever runs `/printing-press` is the author) and that mirrored how every other field was being populated.
+
+The result, after running the sweep across all 49 published CLIs:
+
+```yaml
+# library/commerce/shopify/SKILL.md (originally created by Cathryn)
+author: "Trevin Chow"
+
+# library/commerce/instacart/SKILL.md (originally created by Matt)
+author: "Trevin Chow"
+
+# library/payments/coingecko/SKILL.md (originally created by Hiten)
+author: "Trevin Chow"
+
+# library/marketing/scrape-creators/SKILL.md (originally created by Adrian)
+author: "Trevin Chow"
+```
+
+The operator running the sweep (Trevin) had not created 29 of the 49 CLIs. The default silently flipped attribution to him for every entry. From the user's reaction:
+
+> "for the printing-press-library you added Author as 'Trevin Chow' on all of them, but they should match whoever is in the copyright as i didn't create them all. As is, it sounds like I've taken the copyright authorship ownership over, and I don't want that to be the case."
+
+The signal isn't subtle. Attribution belongs to the person who did the work. A retrofit tool that overwrites attribution silently is doing real damage even when the published artifacts haven't shipped to a wider audience yet.
+
+## Guidance
+
+For a sweep over multi-author content, build a curated per-entry authorship map sourced from primary evidence (copyright headers, git history, manifest fields), not from the operator's environment. Apply explicit corrections for cases where the primary evidence is wrong or missing. Treat the operator's git config as a last-resort fallback only for entries the map doesn't cover.
+
+```go
+// printing-press-library/tools/sweep-frontmatter/main.go (the actual fix)
+
+// cliAuthorByAPIName is the canonical author display name for every
+// existing per-CLI library entry, keyed by api_name (the directory
+// basename: dominos, linear, etc.). Entries derived from each CLI's
+// `// Copyright YYYY <slug>.` header where present, with per-CLI
+// corrections applied for cases where the slug doesn't reflect actual
+// authorship — generator-fallback "user" headers (5 CLIs originally
+// generated before git config was set), missing copyright headers
+// (2 legacy CLIs), and one slug-vs-actual mismatch (espn).
+var cliAuthorByAPIName = map[string]string{
+    "agent-capture":   "Matt Van Horn",
+    "ahrefs":          "Cathryn Lavery",
+    "airbnb":          "Matt Van Horn",
+    // ... 46 more entries
+    "yahoo-finance":   "Trevin Chow",
+}
+
+// Resolution order in the sweep:
+//   1. cliAuthorByAPIName — source of truth for the existing 49
+//   2. manifest's owner_name — set by future fresh prints under the
+//      post-Hermes-alignment generator template; lets a future regen
+//      preserve attribution
+//   3. operator's git config user.name — last-resort fallback for new
+//      CLIs added to the library without an entry in the map above
+authorName := cliAuthorByAPIName[mf.APIName]
+if authorName == "" {
+    authorName = mf.OwnerName
+}
+if authorName == "" {
+    authorName = ownerName  // operator's git config
+}
+```
+
+The map's contents come from a deliberate audit, not heuristics:
+
+1. **Extract the slug from each CLI's copyright header.** `find . -name "*.go" | xargs grep "// Copyright YYYY"` against the published library produces a `(api_name, slug)` table. For CLIs missing the header entirely (legacy generator output predating the convention), use `git log --format="%an" --reverse | head -1` against the per-CLI directory to identify the first committer.
+2. **Map slugs to display names.** For most slugs the mapping is mechanical (`trevin-chow` → `Trevin Chow`). Some require GitHub-profile lookup or domain knowledge. Document the few that aren't obvious (e.g., `rderwin` had no public name set).
+3. **Apply explicit corrections.** Some copyright slugs are wrong (`user` from a generator-fallback path; `trevin-chow` on a CLI that was actually Matt's work). Override with manual entries; document the override reason in a comment.
+4. **Surface the table to a human reviewer before the sweep mutates anything.** A 49-row table is easy to eyeball; the audit catches misclassifications before they ship.
+
+The sweep tool's last-resort fallback to the operator's git config is intentional — it's the right answer for a NEW CLI added to the library after this work, where the operator IS the author. The map handles the historical content; the fallback handles future content.
+
+## Why This Matters
+
+Three failure modes the curated-map approach prevents:
+
+1. **Attribution flip on every regen.** Without the map, every operator running the sweep silently overwrites authorship with their own identity. Attribution is wrong AT BEST temporarily (until the next sweep with the right operator) and wrong PERMANENTLY in the worst case (operator forgets to undo, the wrong attribution ships to a public registry).
+2. **Burn trust with actual authors.** The CLIs in this library represent real work by real people. A tool that silently rewrites that work's attribution to the operator implies a claim of ownership that wasn't earned. Even if no one notices for months, the eventual discovery is a credibility hit that's hard to recover from.
+3. **Compounding error in derivative artifacts.** Hermes / OpenClaw skill registries cache the `author:` field. Once a wrong value ships, retracting it requires another sweep + a re-publish. If users have already cached the wrong attribution locally, retraction may not even reach them.
+
+The "use the operator's identity" default isn't malicious — it's the path of least resistance when you're writing the tool. The lesson is to recognize when the path of least resistance produces a class of bug (silent attribution flip) that's worse than the alternative (a 30-minute audit + 49-line lookup table).
+
+## When to Apply
+
+- Sweeping content across many entries with multiple original authors
+- Attribution will be persisted in a way that downstream consumers see (`author:` in skill metadata, copyright header rewrites, README bylines, contributor lists)
+- Original authorship is recoverable from primary evidence (copyright headers, git history, an existing manifest field)
+- The cost of getting it wrong is non-trivial (public registry, branded asset, user-facing field)
+
+Don't apply when:
+
+- The content is genuinely owned by the sweeping operator (e.g., your personal dotfiles, a single-author repo where everyone running the sweep is the author)
+- The attribution surface is private / debug-only and won't be visible to the original authors or to downstream consumers
+- The original authorship is genuinely unrecoverable AND the operator's identity is the most honest default available (rare; usually some primary evidence exists)
+
+## Examples
+
+### Anti-pattern — implicit operator identity
+
+```go
+// Naive sweep tool — what we shipped first
+func sweepCLI(cliDir string) error {
+    operatorName, _ := exec.Command("git", "config", "user.name").Output()
+    skill := readSkill(cliDir)
+    skill.Author = strings.TrimSpace(string(operatorName))  // wrong for ~60% of entries
+    return writeSkill(cliDir, skill)
+}
+```
+
+Result on a multi-author corpus: every `author:` field flips to the sweep operator. Discoverable only by manual inspection. Already-published artifacts may be cached downstream.
+
+### Pattern — curated map with audit-friendly structure
+
+```go
+// Sweep tool with curated per-entry mapping
+var cliAuthorByAPIName = map[string]string{
+    "agent-capture":   "Matt Van Horn",      // no copyright header; from git first-commit
+    "ahrefs":          "Cathryn Lavery",     // copyright slug "user"; from git first-commit
+    "airbnb":          "Matt Van Horn",      // copyright slug matches
+    "espn":            "Matt Van Horn",      // copyright slug "trevin-chow" — corrected per author
+    // ... etc.
+}
+
+func sweepCLI(cliDir string, mf manifest, operatorName string) error {
+    authorName := cliAuthorByAPIName[mf.APIName]
+    if authorName == "" {
+        authorName = mf.OwnerName  // future fresh prints carry this
+    }
+    if authorName == "" {
+        authorName = operatorName  // last-resort fallback
+    }
+    skill := readSkill(cliDir)
+    skill.Author = authorName
+    return writeSkill(cliDir, skill)
+}
+```
+
+Result: every `author:` field carries the actual author's name. Audit friction during the initial map construction (~30 minutes) prevents months of "is this actually Trevin's CLI?" downstream questions.
+
+### Pattern — surface the map for human review before applying
+
+When the table is large enough that manual review at-merge is risky, pre-build the table and surface it for review before the sweep runs:
+
+```
+| CLI            | Copyright slug   | Proposed `author:`        |
+|----------------|------------------|---------------------------|
+| agent-capture  | (none)           | **Matt Van Horn** *(git)* |
+| ahrefs         | `user`           | **Cathryn Lavery** *(git)* |
+| airbnb         | `matt-van-horn`  | Matt Van Horn             |
+| espn           | `trevin-chow`    | **Matt Van Horn** *(corrected)* |
+| ...            | ...              | ...                        |
+```
+
+The user reviewing this caught two slug-vs-actual mismatches that the heuristic would have shipped wrong.
+
+## Related
+
+- `printing-press-library/tools/sweep-frontmatter/main.go` (cross-repo) — `cliAuthorByAPIName` map declaration
+- `docs/solutions/design-patterns/dual-key-identity-fields-2026-05-06.md` — the dual-key model `OwnerName` / `Owner` (slug); this learning is about how to populate `OwnerName` when retrofitting content where the operator isn't the author
+- `docs/solutions/conventions/soft-validation-in-reusable-library-packages-2026-05-06.md` — the in-generator soft-fallback that fires when `OwnerName` is empty; the sweep's curated map overrides the soft-fallback for retrofitted content
diff --git a/docs/solutions/conventions/soft-validation-in-reusable-library-packages-2026-05-06.md b/docs/solutions/conventions/soft-validation-in-reusable-library-packages-2026-05-06.md
new file mode 100644
index 00000000..48ae8910
--- /dev/null
+++ b/docs/solutions/conventions/soft-validation-in-reusable-library-packages-2026-05-06.md
@@ -0,0 +1,180 @@
+---
+title: "Soft validation in reusable library packages: warn-and-fallback over hard-fail when the package serves multiple callers"
+date: 2026-05-06
+category: conventions
+module: cli-printing-press-generator
+problem_type: convention
+component: tooling
+severity: medium
+applies_when:
+  - "A library package's Generate / Run / Apply entry point is called from multiple contexts (production CLI, internal tooling, test fixtures, regen-merge, mcp-sync)"
+  - "A field is required for production correctness but awkward to plumb through every caller's test fixture"
+  - "Hard-failing the entry point breaks tests on CI runners that don't have the field's source available (e.g., `git config user.name` unset)"
+  - "A degraded-but-visible fallback is preferable to either (a) blocking generation entirely or (b) silently emitting an empty / wrong value"
+tags:
+  - validation
+  - library-design
+  - test-friendly
+  - fail-soft
+  - stderr-warning
+related_components:
+  - generator
+  - testing-framework
+  - tooling
+---
+
+# Soft validation in reusable library packages: warn-and-fallback over hard-fail when the package serves multiple callers
+
+## Context
+
+`internal/generator` is reused by at least four callers in this repo: the `/printing-press` CLI command, `internal/pipeline/mcpsync` (rebuilds the MCP surface against a target tree), `internal/pipeline/regenmerge` (regen-merges existing CLIs), and the test suite (over 500 tests construct synthetic `APISpec` values directly). When a new required field is added to `APISpec`, the question of how to validate it is shaped by which callers must set it.
+
+We added `OwnerName` (a prose-shaped display name for Hermes `author:` and other prose surfaces). The first cut hard-failed `Generate()` if `OwnerName` was empty:
+
+```go
+// First cut — hard error. Looked correct in isolation.
+if g.Spec.OwnerName == "" {
+    return fmt.Errorf("spec.OwnerName is empty: set `git config user.name` so the generator can populate Hermes `author:`")
+}
+```
+
+That fired across ~50 tests on CI because GitHub Actions runners don't have `git config user.name` set, and most test fixtures construct `APISpec` inline without setting `OwnerName`. Updating every test fixture to set the field (mechanical but tedious — six files, dozens of constructions) was one option. The other was to recognize that the validation, while correct for the production path, was over-strict for a library package that intentionally serves many callers.
+
+## Guidance
+
+When a library package's entry point is called from many contexts, validate fields that production cares about but that tests / internal tooling can't reasonably set, with a **stderr warning + degraded-but-visible fallback** rather than an error return. The warning is the signal a real-print operator catches; the fallback keeps tests and tooling unbroken.
+
+```go
+// internal/generator/generator.go
+func (g *Generator) Generate() error {
+    if g.Spec.OwnerName == "" {
+        // OwnerName flows into Hermes `author:` and other prose
+        // surfaces. We don't hard-fail on an empty value because the
+        // generator package is reused by many callers (tests,
+        // mcp-sync, regen-merge) where setting it is awkward. Instead,
+        // fall back to the slug-shaped Owner so emission is non-empty,
+        // and warn loudly so a real-print operator catches the
+        // misconfiguration. The library-wide sweep tool overrides this
+        // via its own per-CLI authorship mapping, so this fallback only
+        // ever lands on fresh prints by users who haven't set
+        // `git config user.name`.
+        fmt.Fprintf(os.Stderr,
+            "WARNING: spec.OwnerName is empty; falling back to slug-shaped Owner (%q) for `author:` field. "+
+                "Set `git config user.name` (display name, e.g. \"Trevin Chow\") to populate this correctly.\n",
+            g.Spec.Owner,
+        )
+        g.Spec.OwnerName = g.Spec.Owner
+    }
+    // ... rest of Generate()
+}
+```
+
+The fallback's three properties matter:
+
+1. **Visible in output.** Falling back to the slug means the rendered SKILL.md ships `author: "trevin-chow"` instead of `author: ""`. A reviewer or downstream consumer can spot the slug-shape and know something's miscofigured. Empty would be silently broken.
+2. **Loud at generation time.** The stderr warning fires every print where the fallback triggers. A user running `/printing-press` once sees the warning, sets git config, and re-runs. Tests and CI silently swallow the warning (it's stderr, not an error return) — exactly the asymmetric handling the use case calls for.
+3. **Cheap to short-circuit later.** A different code path that owns the correct value (in our case: the library-wide sweep tool's per-CLI authorship mapping) overrides `OwnerName` before `Generate()` runs, so the fallback never fires for the path that genuinely needs accuracy.
+
+Add a test that pins the soft-fallback behavior so a future refactor doesn't accidentally restore the hard-error:
+
+```go
+func TestGenerateSoftFallsBackOnEmptyOwnerName(t *testing.T) {
+    t.Parallel()
+
+    apiSpec := minimalSpec("ownerless")
+    apiSpec.Owner = "trevin-chow"
+    apiSpec.OwnerName = ""
+    gen := New(apiSpec, t.TempDir())
+
+    require.NoError(t, gen.Generate())
+    assert.Equal(t, "trevin-chow", apiSpec.OwnerName,
+        "soft-fallback should set OwnerName to slug-shaped Owner when empty")
+
+    skill, _ := os.ReadFile(filepath.Join(gen.OutputDir, "SKILL.md"))
+    assert.Contains(t, string(skill), `author: "trevin-chow"`,
+        "author field should fall back to the slug rather than be empty")
+}
+```
+
+## Why This Matters
+
+The instinct to hard-fail is right when the package has a single caller. With multiple callers, hard-failing forces every caller to learn about every required field — a coupling the library was supposed to abstract over. Worse, it pushes the validation upstream into the test fixtures, where the validation has zero correctness value (a test doesn't care that `author:` is right; a published SKILL.md does).
+
+Stderr warnings are the right channel because:
+
+- **Asymmetric audience by default.** Production CLI users see stderr in their terminal. CI systems route stderr to logs that are ignored unless something fails. Test runners discard stderr entirely. The signal lands where it should without explicit per-context handling.
+- **Non-fatal by construction.** The library's contract stays unchanged — `Generate()` still returns `nil` on a successful render. Callers that want to escalate the warning to an error (e.g., a publish workflow) can wrap and check stderr themselves.
+- **Surfaces the problem, not the workaround.** "WARNING: spec.OwnerName is empty; falling back..." names the misconfiguration directly. A developer sees the warning and fixes git config, not the symptom of slug-shaped authors.
+
+The risk being managed: a user without git config publishes 49 CLIs with `author: "trevin-chow"`. Mitigation comes from a different layer (the sweep tool's per-CLI mapping, which overrides `OwnerName` before `Generate()` runs), not from a hard error in the library. That layered defense is the architecturally honest answer — the library should be reusable; the publish path should be strict.
+
+## When to Apply
+
+- The library package's entry point is called from at least three distinct contexts (production CLI, internal tooling, tests are the typical trio)
+- Every test author setting the field is the only alternative to soft-fallback, and the field is awkward to construct in test fixtures
+- A degraded fallback exists that is **visibly wrong** (not silently empty or syntactically valid-looking-but-incorrect)
+- A higher-level path (orchestrator, publish workflow, sweep tool) already enforces the strict invariant before the library is invoked
+
+Don't apply this pattern when:
+
+- The library has a single caller (just hard-fail; the caller is the right place to validate)
+- The fallback would be silent or invisible (e.g., emitting `author: ""` is silent corruption — fail instead)
+- There's no reasonable fallback (e.g., a missing API key has no degraded-but-visible default)
+
+## Examples
+
+### Anti-pattern — hard error breaks unrelated callers
+
+```go
+// internal/generator/generator.go (rejected)
+func (g *Generator) Generate() error {
+    if g.Spec.OwnerName == "" {
+        return fmt.Errorf("spec.OwnerName is empty: ...")
+    }
+    // ...
+}
+```
+
+CI failure on PR #655: 50+ tests broke because:
+
+```
+generator_test.go:1502: Generate: spec.OwnerName is empty: set `git config user.name` ...
+session_handshake_test.go:286: Generate: spec.OwnerName is empty: set `git config user.name` ...
+session_handshake_test.go:259: Generate: spec.OwnerName is empty: set `git config user.name` ...
+... (47 more)
+```
+
+The fix shape "update every test fixture" is mechanical but doesn't capture the lesson — the library's contract was wrong, not the tests.
+
+### Pattern — warn + fallback
+
+```go
+// internal/generator/generator.go
+func (g *Generator) Generate() error {
+    if g.Spec.OwnerName == "" {
+        fmt.Fprintf(os.Stderr,
+            "WARNING: spec.OwnerName is empty; falling back to slug-shaped Owner (%q) ...\n",
+            g.Spec.Owner,
+        )
+        g.Spec.OwnerName = g.Spec.Owner
+    }
+    // ...
+}
+```
+
+CI passes. Production users without git config see:
+
+```
+$ printing-press generate ...
+WARNING: spec.OwnerName is empty; falling back to slug-shaped Owner ("trevin-chow") for `author:` field. Set `git config user.name` (display name, e.g. "Trevin Chow") to populate this correctly.
+✓ Generated cli at ~/printing-press/library/.../trevin-chow/...
+```
+
+The user sees the warning, sets `git config user.name "Trevin Chow"`, regenerates, and the output is correct. Tests and CI silently ignore the warning. The library remains reusable.
+
+## Related
+
+- `docs/solutions/design-patterns/dual-key-identity-fields-2026-05-06.md` — the dual-field pattern that introduced `OwnerName` in the first place; this convention complements it by handling the "what if the prose-shaped field is unset" edge case without hard-failing reusable callers
+- `internal/generator/generator.go` — `Generate()` empty-OwnerName check (post-soft-fallback shape)
+- `internal/generator/skill_test.go` — `TestGenerateSoftFallsBackOnEmptyOwnerName` pins the behavior
+- `AGENTS.md` — Naming and Disambiguation section flags both the dual-key pattern and the soft-fallback behavior to future contributors
diff --git a/docs/solutions/design-patterns/avoid-classification-when-failure-is-asymmetric-2026-05-06.md b/docs/solutions/design-patterns/avoid-classification-when-failure-is-asymmetric-2026-05-06.md
new file mode 100644
index 00000000..aadd5a83
--- /dev/null
+++ b/docs/solutions/design-patterns/avoid-classification-when-failure-is-asymmetric-2026-05-06.md
@@ -0,0 +1,183 @@
+---
+title: "Avoid classification when the failure mode is asymmetric: prefer prose over metadata for credentials and similar high-stakes routing decisions"
+date: 2026-05-06
+category: design-patterns
+module: cli-printing-press-generator
+problem_type: design_pattern
+component: authentication
+severity: high
+applies_when:
+  - "Designing a metadata field that an external tool (agent host, package manager, install loader) reads to decide what to ask the user for"
+  - "The data being classified contains both 'user must supply' and 'system fills in automatically' values, and the spec authoring data alone doesn't reliably distinguish them"
+  - "Mis-classification has asymmetric cost: a false positive (telling the user to enter a value the system overwrites) burns user trust; a false negative (omitting a real prompt) just produces an informative runtime error the user can recover from"
+  - "The same information could instead be surfaced in human-readable prose (README, setup instructions, agent-readable docstrings) where the agent / user judges in context rather than mechanically"
+tags:
+  - classification
+  - asymmetric-failure
+  - env-vars
+  - hermes
+  - openclaw
+  - frontmatter-design
+related_components:
+  - authentication
+  - templates
+  - generator
+---
+
+# Avoid classification when the failure mode is asymmetric: prefer prose over metadata for credentials and similar high-stakes routing decisions
+
+## Context
+
+Agent hosts (Hermes, OpenClaw, future hosts) read a SKILL.md frontmatter field that lists "env vars the user must set" and prompt the user for each at install time. The natural impulse when adding support for a new host is to populate the field from the existing auth metadata: walk the spec's `auth.EnvVarSpecs` (which carry `Kind` ∈ `{per_call, auth_flow_input, harvested}` and `Required` flags), filter to "user-set required," emit the survivors.
+
+Two rounds of plan review surfaced that this is a trap. The asymmetry of the failure mode makes it not worth shipping:
+
+- **False positive** — emit `DOMINOS_TOKEN` (a session token harvested from a Chrome login flow) as `required_environment_variables`. The user sees the install-time prompt, types in a value, the agent host writes it to its config, the CLI's `auth login` flow later overwrites it with the real harvested cookie. The user wonders why their typed value doesn't work, can't possibly produce a value the harvested-flow would accept, and concludes the install is broken. **Trust burned, recovery is non-obvious.**
+- **False negative** — omit `SHOPIFY_ACCESS_TOKEN` from the metadata. The user installs, runs the first command, gets a clear `error: SHOPIFY_ACCESS_TOKEN is required` message with the canonical name. They set the env var and re-run. **Recovery is obvious and idempotent.**
+
+The data needed to classify reliably (PerCall vs AuthFlowInput vs Harvested + Required + Sensitive) was added to the spec model in a prior iteration. But the data isn't *enough* — many legacy specs only have a flat `auth.env_vars: [...]` list with no kind annotation, and even on rich specs the `Sensitive` flag has two semantically distinct meanings (redact-in-logs vs don't-publish-in-public-metadata) that collide when you try to use it as a public-emission gate.
+
+After two rewrites trying to make the classifier safe, we shipped v1 with **no env-var declarations in either Hermes or OpenClaw frontmatter**. The same information lives in the README's `## Use with Claude Code` and similar sections, branched by `auth.Type`, in human-readable prose. Agent hosts that drive credential setup do so via the README, not via a structured field.
+
+## Guidance
+
+When you're about to design a metadata field for an external consumer to act on programmatically, ask three questions:
+
+1. **What's the failure mode if the data is wrong?** If false positives and false negatives have asymmetric cost, the symmetric "best-effort classifier" approach loses most of its value — your filter has to be tuned to the dominant cost, and the other failure mode (now untreated) leaks through.
+2. **Can the spec authoring data reliably distinguish the cases?** If your classifier requires fields that aren't always populated (or that have ambiguous semantics across legacy specs), the classifier silently produces wrong output for the unannotated half of the corpus. Annotation backfill is its own multi-step project.
+3. **Is there a prose surface that already handles this correctly?** Often the README, the SKILL body, or the help text already branches on the same conditions you're trying to encode in metadata, but in human-readable form. Routing the consumer to the prose is more honest than mechanizing a partial classifier.
+
+If the answers are "asymmetric," "no," and "yes," skip the structured field. Document the credentials in prose. Re-add structured emission later if and when (a) real consumers signal demand and (b) the classification problem has been independently solved.
+
+```yaml
+# What v1 ships (after stripping the env-var hoist):
+metadata:
+  openclaw:
+    requires:
+      bins:
+        - mercury-pp-cli
+    install:
+      - kind: go
+        bins: [mercury-pp-cli]
+        module: github.com/mvanhorn/printing-press-library/library/payments/mercury/cmd/mercury-pp-cli
+  # No requires.env, no envVars, no required_environment_variables
+```
+
+The README carries the credential UX, branched by `auth.Type`:
+
+```markdown
+{{- if eq .Auth.Type "api_key"}}
+Set the API key:
+```bash
+export {{$canonicalEnvVar.Name}}="<your-key>"
+```
+{{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
+This CLI uses a browser session. Log in to {{.Auth.CookieDomain}} in Chrome, then:
+```bash
+{{.Name}}-pp-cli auth login --chrome
+```
+{{- else if eq .Auth.Type "oauth2"}}
+Authenticate via the browser:
+```bash
+{{.Name}}-pp-cli auth login
+```
+{{- end}}
+```
+
+The `auth.Type` branching IS the conservative classification — applied where the failure mode is benign (a user reading the README) instead of where it's hostile (an install loader prompting for a value the user can't supply).
+
+## Why This Matters
+
+The asymmetric-failure-mode framing is the load-bearing concept. Both round-1 and round-2 plan reviews kept producing new edge cases at the boundary of the classifier:
+
+- The `Sensitive` flag's redact-in-logs vs don't-publish-in-public-metadata semantics collided. The intuitive guard `!Sensitive` excluded legitimate user-set API keys (which spec authors correctly mark `Sensitive: true` so they're redacted in `--debug` output). Removing the guard let OAuth `CLIENT_SECRET` leak.
+- The `Kind`-based filter (`per_call` only) excluded `auth_flow_input` env vars that users genuinely supply once during initial setup (OAuth `CLIENT_ID`).
+- Legacy specs without `EnvVarSpecs` populated would have to be classified by `auth.type` heuristic alone, with no signal for whether a flat `env_vars` list is "user-set keys" vs "harvested cookie names."
+- Even with a perfect classifier, the consumer (Hermes / OpenClaw) might interpret the field differently than we expect. We had no real-loader test until very late.
+
+Each fix introduced a new edge case. The fundamental issue: classification has to be *more* reliable than the failure-cost ratio justifies. With ~1000:1 cost asymmetry, even a 99%-accurate classifier ships an unacceptable false-positive rate.
+
+The prose-instead-of-metadata answer trades a worse install UX (user reads README, sets env vars manually, runs first command, possibly hits a friendly missing-creds error) for a much smaller failure surface. That trade is correct when the structured-emission risk is "ship 49 CLIs to a public registry with one wrong env var prompt" and the prose risk is "user reads slightly more text."
+
+## When to Apply
+
+- Designing public-facing metadata that drives external-tool behavior (agent host install prompts, package manager dependency declarations, IDE auto-config)
+- The data the metadata is computed from has ambiguity at boundaries (legacy data, multi-mode fields, semantic overlaps between flags)
+- A wrong emission is hard to retract once distributed (published to a registry, baked into install scripts, cached by intermediaries)
+- The same information has a well-trodden human-readable form somewhere in your existing artifacts
+
+Don't apply this pattern when:
+
+- The classifier is reliable enough — every case has unambiguous source data, the failure mode is symmetric, the consumer has a clear contract for what to do with each value
+- The prose alternative doesn't exist or would be inconsistent with the metadata (in which case fix the prose first, then optionally add metadata)
+- The cost of mechanizing the data is justified by automation savings (e.g., CI dependency declarations where humans don't read the field)
+
+## Examples
+
+### Bad-instinct progression (why this took two plan rewrites to land)
+
+**Round 1 plan**: emit `required_environment_variables` filtered by `Kind+Required`, reuse the existing `IsRequestCredential()` predicate.
+
+  - Found in review: `IsRequestCredential()` returns true only for `PerCall`. Excludes `AuthFlowInput` env vars users genuinely set once.
+
+**Round 2 plan**: introduce new `IsHermesRequiredEnv()` predicate: `(PerCall || AuthFlowInput) && Required && !Sensitive`.
+
+  - Found in review: the `!Sensitive` guard collides with the legitimate use of `Sensitive: true` on user-set API keys (for log redaction). Removing the guard reintroduces the `CLIENT_SECRET` leak. There's no consistent semantics for `Sensitive` that satisfies both the redaction and the publish-to-public-metadata use cases.
+
+**Final plan**: don't emit `required_environment_variables` at all. Strip the equivalent OpenClaw fields too for symmetry. Lean on the existing `auth.Type`-branched README content for credential UX.
+
+The lesson isn't "we picked the wrong predicate twice" — it's "the data structure isn't fit for the public-metadata use case, and forcing fit produces increasingly subtle bugs." Recognizing that earlier saves multiple plan-review rounds.
+
+### Healthy alternative — prose carries the same routing logic
+
+```markdown
+# README.md
+
+## Use with Claude Code
+
+Install the focused skill:
+```bash
+npx skills add mvanhorn/printing-press-library/cli-skills/pp-shopify -g
+```
+
+Set up authentication (api_key auth):
+```bash
+export SHOPIFY_ACCESS_TOKEN="<your-token>"
+```
+```
+
+vs.
+
+```markdown
+## Use with Claude Code (cookie auth example — pp-allrecipes)
+
+Install the focused skill:
+```bash
+npx skills add mvanhorn/printing-press-library/cli-skills/pp-allrecipes -g
+```
+
+This CLI uses a browser session. Log in to allrecipes.com in Chrome, then:
+```bash
+allrecipes-pp-cli auth login --chrome
+```
+```
+
+The same conditional logic that would have driven a metadata classifier (api_key → "set env var", cookie → "browser session + auth login --chrome") lives in the template's auth.Type branching. The user reads it, the agent reads it, neither mis-prompts for a value that can't be supplied.
+
+## When to Re-evaluate
+
+Re-add structured env-var emission when at least two of these are true:
+
+- Real users (not just plan reviewers) report that the install-time prompt UX is missing
+- Spec authoring annotates `auth.env_var_specs` reliably across the corpus (no legacy-flat-list cases left)
+- The consumer agent host has a documented contract for the field (not just "Hermes docs say X" — verified against the actual loader's behavior)
+- A reversible publish path exists: if the classifier is wrong, you can update or unpublish without 49 manual PRs
+
+Until then, prose UX is the honest answer.
+
+## Related
+
+- `docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md` — the data model that motivated the classifier attempts; this learning is about *not* using that model for public emission, not about the model itself
+- `internal/generator/templates/skill.md.tmpl` — frontmatter shape post-strip (no `requires.env`, no `envVars`, no `primaryEnv`)
+- `internal/generator/templates/readme.md.tmpl` — the prose alternative, branched by `auth.Type`
+- `docs/plans/2026-05-06-002-feat-hermes-openclaw-frontmatter-alignment-plan.md` — full design rationale + cross-references to the round-1 / round-2 review findings that surfaced the asymmetric-failure problem
diff --git a/docs/solutions/design-patterns/dual-key-identity-fields-2026-05-06.md b/docs/solutions/design-patterns/dual-key-identity-fields-2026-05-06.md
new file mode 100644
index 00000000..3e3df894
--- /dev/null
+++ b/docs/solutions/design-patterns/dual-key-identity-fields-2026-05-06.md
@@ -0,0 +1,179 @@
+---
+title: "Dual-key identity fields: keep the slug and the display name as separate spec fields with separate resolution paths"
+date: 2026-05-06
+category: design-patterns
+module: cli-printing-press-generator
+problem_type: design_pattern
+component: tooling
+severity: medium
+applies_when:
+  - "A single conceptual identity (an owner, an author, a tenant) needs to flow into both path-shaped surfaces (module paths, copyright headers, file slugs) and prose-shaped surfaces (display names, README bylines, author metadata)"
+  - "The path-shaped form would corrupt the prose-shaped form if reused (e.g., sanitizing 'Trevin Chow' to 'trevin-chow' is right for a Go module path and wrong for a SKILL.md `author:` field)"
+  - "Conflating the two has historically led to slug-shaped values landing in published prose surfaces — visible to users, hard to retract once distributed"
+tags:
+  - identity
+  - naming
+  - slug-vs-display
+  - generator-fields
+  - resolution-paths
+related_components:
+  - generator
+  - templates
+  - manifest
+---
+
+# Dual-key identity fields: keep the slug and the display name as separate spec fields with separate resolution paths
+
+## Context
+
+`internal/spec/spec.go`'s `APISpec` originally carried a single `Owner` field that drove both Go module paths (`github.com/<owner>/...`) and prose surfaces (copyright headers, README bylines). The resolver sanitized whatever it found from `git config` so the value was safe to embed in a path: lowercase, hyphenate spaces, strip non-`[a-z0-9_-]` characters. That worked fine when both surfaces wanted the same shape — a slug like `trevin-chow` made the module path correct.
+
+Once printed CLIs needed a Hermes-recognized `author:` field in their SKILL.md frontmatter, the conflation broke. The slug-shaped value (`trevin-chow`) is fine for a path component but visibly wrong as a prose attribution in a public skill registry. The instinct to "just stop sanitizing" is also wrong — the path surface depends on the sanitization. There's no single value that satisfies both surfaces.
+
+## Guidance
+
+Carry two fields, not one. Resolve each via a path-appropriate chain. Apply a path-appropriate transformation to each. Land each in its own template surface. Do not let either resolver fall through to the other's value.
+
+```go
+// internal/spec/spec.go
+type APISpec struct {
+    Owner     string `yaml:"owner,omitempty"`      // path-safe slug, e.g. "trevin-chow"
+    OwnerName string `yaml:"owner_name,omitempty"` // display name, e.g. "Trevin Chow"
+    // ...
+}
+```
+
+```go
+// internal/generator/plan_generate.go
+
+// Slug resolver: tries github.user first (already path-shaped),
+// falls back to sanitizing user.name, defaults to "USER".
+func resolveOwnerForNew() string {
+    if out, err := exec.Command("git", "config", "github.user").Output(); err == nil && len(out) > 0 {
+        return strings.TrimSpace(string(out))
+    }
+    if out, err := exec.Command("git", "config", "user.name").Output(); err == nil && len(out) > 0 {
+        return sanitizeOwner(strings.TrimSpace(string(out)))
+    }
+    return "USER"
+}
+
+// Display-name resolver: reads raw user.name, no sanitization, no
+// fallback to a slug-shaped default. Empty value is the caller's
+// problem to handle — the resolver does not synthesize.
+func resolveOwnerNameForNew() string {
+    out, err := exec.Command("git", "config", "user.name").Output()
+    if err != nil {
+        return ""
+    }
+    return strings.TrimSpace(string(out))
+}
+```
+
+The `resolveOwnerNameForNew` deliberately does NOT reuse `resolveOwnerForNew`. Three things in the slug resolver would corrupt a display name if reused:
+
+1. **`github.user` first.** GitHub usernames are slug-shaped (`mvanhorn`), not display-shaped (`Matt Van Horn`). Wrong source for prose.
+2. **`sanitizeOwner` collapses spaces and casing.** `"Trevin Chow"` becomes `"trevin-chow"`. The very thing we wanted to avoid.
+3. **`"USER"` default.** Better to surface empty + warn than ship `author: "USER"` to a public skill registry.
+
+At the template emission layer, escape the prose value:
+
+```yaml
+# internal/generator/templates/skill.md.tmpl
+---
+name: pp-{{.Name}}
+description: "..."
+author: "{{yamlDoubleQuoted .OwnerName}}"  # prose, escaped
+license: "Apache-2.0"
+---
+
+# {{.ProseName}} — Printing Press CLI
+# (note: copyright header still uses the slug)
+```
+
+```go
+// internal/generator/templates/copyright.go.tmpl
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+```
+
+## Why This Matters
+
+The two values flow into surfaces with different correctness criteria:
+
+- **Path surface** (Go module paths, file directories, copyright headers' regex anchor): must be path-safe. A space, quote, or non-ASCII character breaks `go install`, breaks `git ls-files`, breaks the existing `RewriteOwner` regex (`^//\s*Copyright\s+\d+\s+([A-Za-z0-9_-]+)\.`). Sanitization is required.
+- **Prose surface** (`author:` in skill registries, README bylines, MCPB manifest `author.name`): humans read this. Slug-shaped values are visibly broken. YAML-quoting is required (so colons or quotes in the name don't corrupt the file).
+
+A single field forces the conflict to land somewhere. Sanitize and the prose surface ships ugly. Don't sanitize and the path surface breaks. The dual-key model resolves the conflict structurally rather than per-call-site.
+
+The cost is small: one extra spec field, one extra resolver, one extra template variable. The benefit compounds: every future surface that needs identity attribution can now ask "do I want path-shaped or prose-shaped?" and reach for the right field. New code stays correct by default.
+
+## When to Apply
+
+- Identity needs to flow into both path-shaped AND prose-shaped surfaces in the same artifact (this is the trigger; if all surfaces are one shape, one field is enough)
+- Sanitization is required to make the path-shaped surface work
+- The prose surface is published or persisted somewhere users will see (a SKILL.md `author:`, a README byline, a manifest field — not just a debug log)
+- Resolving from `git config` or similar untrusted-shape source where the same input can be either path-suitable or prose-suitable depending on what the user has configured
+
+If a future field falls into the same shape (e.g., team name, organization, contributor list), follow the same split. Don't synthesize a third resolver that "tries to figure out which shape we need" — that's the conflation we're avoiding.
+
+## Examples
+
+### Before — single conflated field
+
+```go
+// In Generator.New(), Owner gets sanitized whether the consumer
+// wanted a path or a prose value. There's no way to recover the
+// original prose form once sanitization runs.
+func New(s *spec.APISpec, outputDir string) *Generator {
+    if s.Owner == "" {
+        s.Owner = resolveOwnerForNew()  // returns slug-shaped
+    }
+    s.Owner = sanitizeOwner(s.Owner)    // re-sanitizes anyway
+    // ...
+}
+```
+
+```yaml
+# Result in generated SKILL.md (wrong shape for author):
+---
+name: pp-shopify
+author: "trevin-chow"   # slug-shaped, ugly
+---
+```
+
+### After — dual fields, dual resolvers
+
+```go
+func New(s *spec.APISpec, outputDir string) *Generator {
+    if s.Owner == "" {
+        s.Owner = resolveOwnerForExisting(outputDir)
+    }
+    s.Owner = sanitizeOwner(s.Owner)  // path surface
+
+    if s.OwnerName == "" {
+        s.OwnerName = resolveOwnerNameForExisting(outputDir)  // prose, no sanitization
+    }
+    // ...
+}
+```
+
+```yaml
+# Result in generated SKILL.md (correct prose):
+---
+name: pp-shopify
+author: "Trevin Chow"   # display-shaped
+---
+```
+
+```go
+// And the copyright header stays slug-shaped (path-shape correctness preserved):
+// Copyright 2026 trevin-chow. Licensed under Apache-2.0. See LICENSE.
+```
+
+## Related
+
+- `docs/solutions/conventions/soft-validation-in-reusable-library-packages-2026-05-06.md` — the empty-value behavior for `OwnerName` (soft warn + slug fallback) so the generator stays reusable from tests / mcp-sync / regen-merge without forcing every caller to set the field
+- `docs/solutions/conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md` — when `OwnerName` flows through a sweep over published content (vs. a fresh print), the resolver must NOT trust the operator's git config; lessons from the public-library retrofit
+- `internal/generator/plan_generate.go` — `resolveOwnerForNew` / `resolveOwnerNameForNew` split
+- `internal/spec/spec.go:103` — `OwnerName` field declaration with comment cross-referencing the slug field
+- `AGENTS.md` — Naming and Disambiguation section pins the lesson for future contributors
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 3fa5ebf8..db4094ec 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -21,6 +21,7 @@ import (
 	"github.com/mvanhorn/cli-printing-press/v3/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/v3/internal/profiler"
 	"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+	"github.com/mvanhorn/cli-printing-press/v3/internal/version"
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )
@@ -167,6 +168,16 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		}
 		return -1
 	}, s.Owner)
+
+	// OwnerName is the prose-shaped display name (e.g. "Trevin Chow") that
+	// flows into Hermes author:, README byline, and other human-facing
+	// surfaces. Distinct from s.Owner (the slug) above. No sanitization —
+	// the value is preserved verbatim and must be YAML-escaped at template
+	// emission time. Empty values are validated in Generate() before any
+	// file writes.
+	if s.OwnerName == "" {
+		s.OwnerName = resolveOwnerNameForExisting(outputDir)
+	}
 	g := &Generator{
 		Spec:      s,
 		OutputDir: outputDir,
@@ -244,6 +255,21 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 			}
 			return naming.CLI(s.Name)
 		},
+		// pressVersion returns the Press version that produced the SKILL.md
+		// being rendered. Two-tier: read .printing-press.json's
+		// printing_press_version when the manifest exists (regen / sweep
+		// path — preserves the version that originally produced the file
+		// on Press releases that don't touch this CLI), else fall back to
+		// the live version.Version (fresh-print path — manifest is written
+		// during publish, after templates render). The value bumps on
+		// regen of the CLI, stays stable on Press releases that leave it
+		// alone.
+		"pressVersion": func() string {
+			if v := readManifestPressVersion(g.OutputDir); v != "" {
+				return v
+			}
+			return version.Version
+		},
 		"graphqlQueryField": graphqlQueryField,
 		"graphqlFieldSelection": func(typeName string, types map[string]spec.TypeDef) []string {
 			return graphqlFieldSelection(typeName, types)
@@ -1451,6 +1477,23 @@ func (g *Generator) renderOptionalSupportFiles() error {
 
 func (g *Generator) Generate() error {
 	warnUnenrichedLargeMCPSurface(g.Spec, os.Stderr)
+	if g.Spec.OwnerName == "" {
+		// OwnerName flows into Hermes `author:` and other prose
+		// surfaces. We don't hard-fail on an empty value because the
+		// generator package is reused by many callers (tests, mcp-sync,
+		// regen-merge) where setting it is awkward. Instead, fall back
+		// to the slug-shaped Owner so emission is non-empty, and warn
+		// loudly so a real-print operator catches the misconfiguration.
+		// The library-wide sweep tool overrides this via its own per-CLI
+		// authorship mapping, so this fallback only ever lands on fresh
+		// prints by users who haven't set `git config user.name`.
+		fmt.Fprintf(os.Stderr,
+			"WARNING: spec.OwnerName is empty; falling back to slug-shaped Owner (%q) for `author:` field. "+
+				"Set `git config user.name` (display name, e.g. \"Trevin Chow\") to populate this correctly.\n",
+			g.Spec.Owner,
+		)
+		g.Spec.OwnerName = g.Spec.Owner
+	}
 	if err := g.prepareOutput(); err != nil {
 		return err
 	}
diff --git a/internal/generator/plan_generate.go b/internal/generator/plan_generate.go
index c7e4915f..39fe35d7 100644
--- a/internal/generator/plan_generate.go
+++ b/internal/generator/plan_generate.go
@@ -307,21 +307,30 @@ func resolveOwnerForExisting(outputDir string) string {
 	return resolveOwnerForNew()
 }
 
-// readManifestOwner returns the `owner` field from
-// outputDir/.printing-press.json, or "" if the file is absent, malformed,
-// or the field is empty/whitespace.
-func readManifestOwner(outputDir string) string {
+// readManifestField returns the trimmed string value at key from
+// outputDir/.printing-press.json, or "" when the file is absent,
+// malformed, the key is missing, or the value is empty/whitespace.
+//
+// Reads the manifest directly rather than calling
+// pipeline.ReadCLIManifest because the pipeline package already
+// imports generator — adding the reverse direction would create a
+// cycle.
+func readManifestField(outputDir, key string) string {
 	data, err := os.ReadFile(filepath.Join(outputDir, ".printing-press.json"))
 	if err != nil {
 		return ""
 	}
-	var m struct {
-		Owner string `json:"owner"`
-	}
+	var m map[string]any
 	if err := json.Unmarshal(data, &m); err != nil {
 		return ""
 	}
-	return strings.TrimSpace(m.Owner)
+	s, _ := m[key].(string)
+	return strings.TrimSpace(s)
+}
+
+// readManifestOwner returns the `owner` slug from the manifest.
+func readManifestOwner(outputDir string) string {
+	return readManifestField(outputDir, "owner")
 }
 
 // resolveOwnerForNew returns the owner attribution for a brand-new project
@@ -357,6 +366,46 @@ func parseCopyrightOwner(outputDir string) string {
 	return ""
 }
 
+// resolveOwnerNameForExisting returns the human-readable owner display
+// name for a regen against an existing tree. Tiered:
+//  1. .printing-press.json's `owner_name` field, if present and non-empty
+//  2. resolveOwnerNameForNew() (raw `git config user.name`)
+//
+// Distinct from resolveOwnerForExisting, which returns a slug-shaped string
+// for module paths and copyright headers. OwnerName flows into prose
+// surfaces (Hermes author:, README byline) and must not be sanitized.
+func resolveOwnerNameForExisting(outputDir string) string {
+	if name := readManifestOwnerName(outputDir); name != "" {
+		return name
+	}
+	return resolveOwnerNameForNew()
+}
+
+// readManifestOwnerName returns the `owner_name` display-name field
+// from the manifest.
+func readManifestOwnerName(outputDir string) string {
+	return readManifestField(outputDir, "owner_name")
+}
+
+// resolveOwnerNameForNew returns the raw `git config user.name` for a fresh
+// print. Returns "" when the value is unset — the caller is responsible for
+// erroring on that case so the empty value never reaches a published
+// SKILL.md or README. No sanitization (display-name shape preserved); no
+// fallback to "USER" (would publish an obviously-wrong author).
+func resolveOwnerNameForNew() string {
+	out, err := exec.Command("git", "config", "user.name").Output()
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(string(out))
+}
+
+// readManifestPressVersion returns the `printing_press_version` field
+// from the manifest.
+func readManifestPressVersion(outputDir string) string {
+	return readManifestField(outputDir, "printing_press_version")
+}
+
 // sanitizeOwner cleans up an owner string for use in Go module paths.
 func sanitizeOwner(s string) string {
 	s = strings.ToLower(s)
diff --git a/internal/generator/plan_generate_test.go b/internal/generator/plan_generate_test.go
index 4403871a..93a445a0 100644
--- a/internal/generator/plan_generate_test.go
+++ b/internal/generator/plan_generate_test.go
@@ -137,6 +137,123 @@ func TestGenerateFromPlan_EmptyName(t *testing.T) {
 	assert.True(t, strings.Contains(err.Error(), "no CLI name"))
 }
 
+func TestReadManifestOwnerName(t *testing.T) {
+	t.Parallel()
+
+	t.Run("returns value when manifest has owner_name", func(t *testing.T) {
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, ".printing-press.json"),
+			[]byte(`{"owner":"trevin-chow","owner_name":"Trevin Chow"}`),
+			0o644,
+		))
+		assert.Equal(t, "Trevin Chow", readManifestOwnerName(dir))
+	})
+
+	t.Run("preserves names with spaces and casing verbatim", func(t *testing.T) {
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, ".printing-press.json"),
+			[]byte(`{"owner_name":"Cathryn Lavery"}`),
+			0o644,
+		))
+		// No sanitization. Spaces preserved, casing preserved.
+		assert.Equal(t, "Cathryn Lavery", readManifestOwnerName(dir))
+	})
+
+	t.Run("returns empty when manifest absent", func(t *testing.T) {
+		assert.Equal(t, "", readManifestOwnerName(t.TempDir()))
+	})
+
+	t.Run("returns empty when owner_name field missing", func(t *testing.T) {
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, ".printing-press.json"),
+			[]byte(`{"owner":"trevin-chow"}`),
+			0o644,
+		))
+		// Legacy manifests with only `owner` (slug) return empty
+		// — caller falls through to git config.
+		assert.Equal(t, "", readManifestOwnerName(dir))
+	})
+
+	t.Run("returns empty on malformed JSON", func(t *testing.T) {
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, ".printing-press.json"),
+			[]byte(`{not json`),
+			0o644,
+		))
+		assert.Equal(t, "", readManifestOwnerName(dir))
+	})
+
+	t.Run("trims whitespace", func(t *testing.T) {
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, ".printing-press.json"),
+			[]byte(`{"owner_name":"  Trevin Chow  "}`),
+			0o644,
+		))
+		assert.Equal(t, "Trevin Chow", readManifestOwnerName(dir))
+	})
+}
+
+func TestReadManifestPressVersion(t *testing.T) {
+	t.Parallel()
+
+	t.Run("returns version when manifest present", func(t *testing.T) {
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, ".printing-press.json"),
+			[]byte(`{"printing_press_version":"3.10.0"}`),
+			0o644,
+		))
+		assert.Equal(t, "3.10.0", readManifestPressVersion(dir))
+	})
+
+	t.Run("returns empty on fresh-print path (no manifest yet)", func(t *testing.T) {
+		// Manifest is written by the publish step, after templates render.
+		// Fresh-print render must fall back to version.Version, not error.
+		assert.Equal(t, "", readManifestPressVersion(t.TempDir()))
+	})
+
+	t.Run("returns empty when field missing", func(t *testing.T) {
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, ".printing-press.json"),
+			[]byte(`{"owner":"x"}`),
+			0o644,
+		))
+		assert.Equal(t, "", readManifestPressVersion(dir))
+	})
+}
+
+func TestResolveOwnerNameForExisting(t *testing.T) {
+	t.Parallel()
+
+	t.Run("prefers manifest owner_name over git config", func(t *testing.T) {
+		dir := t.TempDir()
+		require.NoError(t, os.WriteFile(
+			filepath.Join(dir, ".printing-press.json"),
+			[]byte(`{"owner_name":"Original Author"}`),
+			0o644,
+		))
+		// Even if git config user.name is set, manifest wins for regen
+		// — preserves original attribution rather than silently flipping
+		// to whoever's running the regen.
+		assert.Equal(t, "Original Author", resolveOwnerNameForExisting(dir))
+	})
+
+	t.Run("falls through to git config when manifest absent", func(t *testing.T) {
+		// Without a manifest, the helper falls through to
+		// resolveOwnerNameForNew, which reads `git config user.name`.
+		// In the test environment, that may be empty or the test
+		// runner's identity — we assert only that the call doesn't
+		// panic. resolveOwnerNameForNew has its own coverage below.
+		_ = resolveOwnerNameForExisting(t.TempDir())
+	})
+}
+
 func TestPartitionCommands(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/readme_test.go b/internal/generator/readme_test.go
index 8c638283..fbf1247f 100644
--- a/internal/generator/readme_test.go
+++ b/internal/generator/readme_test.go
@@ -205,9 +205,11 @@ func TestReadmeHandlesMarkdownUnsafeNarrativeFields(t *testing.T) {
 
 func minimalSpec(name string) *spec.APISpec {
 	return &spec.APISpec{
-		Name:    name,
-		Version: "0.1.0",
-		BaseURL: "https://api.example.com",
+		Name:      name,
+		Version:   "0.1.0",
+		BaseURL:   "https://api.example.com",
+		Owner:     "test-owner",
+		OwnerName: "Test Author",
 		Auth: spec.AuthConfig{
 			Type:    "api_key",
 			Header:  "Authorization",
@@ -271,6 +273,43 @@ func TestReadmeUsesExplicitDisplayNameForProse(t *testing.T) {
 	assert.NotContains(t, content, "# Producthunt CLI")
 }
 
+// TestReadmeEmitsHermesAndOpenClawInstallSections asserts the new install
+// sections render with the correct hardcoded mvanhorn paths and the
+// hermes-install anchor for sweep-tool idempotency. CLI form and chat form
+// both use mvanhorn/printing-press-library/cli-skills/pp-<api> (verified
+// against tested install behavior — earlier draft of the chat form used a
+// shorter mvanhorn/cli-skills path that doesn't resolve).
+func TestReadmeEmitsHermesAndOpenClawInstallSections(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("hermes-install")
+	outputDir := filepath.Join(t.TempDir(), "hermes-install-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+
+	// Anchor enables the cross-repo sweep tool to insert sections
+	// idempotently into legacy READMEs that predate this template change.
+	assert.Contains(t, content, "<!-- pp-hermes-install-anchor -->",
+		"sweep-tool anchor must be present so retrofit can locate the insertion point")
+
+	// Hermes section: both forms (CLI + chat) use the full
+	// mvanhorn/printing-press-library/cli-skills path.
+	assert.Contains(t, content, "## Install via Hermes")
+	assert.Contains(t, content, "hermes skills install mvanhorn/printing-press-library/cli-skills/pp-hermes-install --force",
+		"Hermes CLI form must use mvanhorn/printing-press-library/cli-skills (the short mvanhorn/cli-skills form was wrong)")
+	assert.Contains(t, content, "/skills install mvanhorn/printing-press-library/cli-skills/pp-hermes-install --force",
+		"Hermes chat form must use mvanhorn/printing-press-library/cli-skills")
+
+	// OpenClaw section: copyable code-fenced agent instruction.
+	assert.Contains(t, content, "## Install via OpenClaw")
+	assert.Contains(t, content, "https://github.com/mvanhorn/printing-press-library/tree/main/cli-skills/pp-hermes-install",
+		"OpenClaw URL must point at the cli-skills directory")
+}
+
 // TestReadmeFallsBackWhenNarrativeAbsent asserts the generic description
 // is used when Narrative is nil — no breakage for specs without absorb data.
 func TestReadmeFallsBackWhenNarrativeAbsent(t *testing.T) {
diff --git a/internal/generator/skill_test.go b/internal/generator/skill_test.go
index e98084f7..58e7d732 100644
--- a/internal/generator/skill_test.go
+++ b/internal/generator/skill_test.go
@@ -79,11 +79,22 @@ func TestSkillRendersFrontmatterAndCapabilities(t *testing.T) {
 	assert.True(t, strings.Contains(content, "finance-pp-cli digest --watchlist tech"),
 		"Recipes should include runnable commands")
 
-	// Installation
-	assert.True(t, strings.Contains(content, "## CLI Installation"),
-		"SKILL should include CLI install instructions")
+	// Installation — CLI install lives at the top under Prerequisites
+	// so agents read it before deciding to run a command. MCP install
+	// stays in its existing location.
+	assert.True(t, strings.Contains(content, "## Prerequisites: Install the CLI"),
+		"SKILL should include Prerequisites section near the top so agents install the CLI before invoking commands")
 	assert.True(t, strings.Contains(content, "## MCP Server Installation"),
 		"SKILL should include MCP install instructions")
+	// Sanity: Prerequisites must precede first command-reference section,
+	// not be buried near the bottom (where the previous "## CLI Installation"
+	// section lived — too far down for agents to read top-down).
+	prereqIdx := strings.Index(content, "## Prerequisites: Install the CLI")
+	cmdRefIdx := strings.Index(content, "## Command Reference")
+	require.GreaterOrEqual(t, prereqIdx, 0)
+	require.GreaterOrEqual(t, cmdRefIdx, 0)
+	assert.Less(t, prereqIdx, cmdRefIdx,
+		"Prerequisites must appear before Command Reference so agents read install instructions before deciding to run a command")
 	assert.True(t, strings.Contains(content, "| 10 | Config error"),
 		"Exit codes table should render")
 }
@@ -408,7 +419,96 @@ func TestSkillFrontmatterMetadataIsClawHubCompliantNestedYAML(t *testing.T) {
 		"metadata must not be a JSON-string blob anymore")
 }
 
-func TestSkillFrontmatterEnvVarsOmitsHarvestedAuthEnvVars(t *testing.T) {
+// TestGenerateSoftFallsBackOnEmptyOwnerName asserts the empty-OwnerName
+// path doesn't fail generation. When OwnerName is unset and the resolution
+// chain returns empty (e.g., CI without git config), Generate() falls back
+// to the slug-shaped Owner — non-fatal so the generator package stays
+// reusable by tests, mcp-sync, and regen-merge. The library-wide sweep
+// tool overrides this code path with its own per-CLI authorship mapping;
+// this fallback only fires for fresh prints by users who haven't set git
+// config user.name.
+func TestGenerateSoftFallsBackOnEmptyOwnerName(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("ownerless")
+	apiSpec.Owner = "trevin-chow"
+	apiSpec.OwnerName = ""
+	outputDir := filepath.Join(t.TempDir(), "ownerless-pp-cli")
+	gen := New(apiSpec, outputDir)
+
+	// Stub the resolution path's git config lookup by ensuring the
+	// outputDir has no .printing-press.json — readManifestOwnerName
+	// returns "", and resolveOwnerNameForNew runs `git config user.name`.
+	// In the test environment this may resolve to whatever the runner
+	// has set, so we re-clear the field after New() to deterministically
+	// hit the fallback path.
+	apiSpec.OwnerName = ""
+
+	// Generation must not error on the empty-OwnerName path.
+	require.NoError(t, gen.Generate())
+
+	// After Generate(), the soft-fallback should have populated
+	// OwnerName from the slug.
+	assert.Equal(t, "trevin-chow", apiSpec.OwnerName,
+		"soft-fallback should set OwnerName to the slug-shaped Owner when empty")
+
+	// And the rendered SKILL.md should reflect that — author lands as
+	// the slug, ugly but visible (vs. a hard-error blocking generation).
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	assert.Contains(t, string(skill), `author: "trevin-chow"`,
+		"author field should fall back to the slug rather than be empty")
+}
+
+// TestSkillFrontmatterEmitsHermesTopLevelFields asserts the post-alignment
+// frontmatter carries the Hermes-recognized top-level fields (`version`,
+// `author`, `license`) so Hermes can install the skill. The OpenClaw block
+// continues to coexist alongside; Hermes ignores unknown keys per its docs.
+func TestSkillFrontmatterEmitsHermesTopLevelFields(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("hermes-test")
+	apiSpec.OwnerName = "Trevin Chow"
+	outputDir := filepath.Join(t.TempDir(), "hermes-test-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	content := string(skill)
+
+	require.True(t, strings.HasPrefix(content, "---\n"))
+	end := strings.Index(content[4:], "\n---\n")
+	require.NotEqual(t, -1, end)
+	body := strings.TrimSuffix(strings.TrimPrefix(content[:4+end+5], "---\n"), "---\n")
+
+	var parsed struct {
+		Name        string `yaml:"name"`
+		Description string `yaml:"description"`
+		Version     string `yaml:"version"`
+		Author      string `yaml:"author"`
+		License     string `yaml:"license"`
+	}
+	require.NoError(t, yaml.Unmarshal([]byte(body), &parsed),
+		"frontmatter must parse as nested YAML; content was:\n%s", body)
+
+	assert.Equal(t, "Trevin Chow", parsed.Author,
+		"author must be the prose-shaped OwnerName, not the slug — yamlDoubleQuoted preserves spaces and casing")
+	assert.Equal(t, "Apache-2.0", parsed.License,
+		"license is a constant for printed CLIs (LICENSE.tmpl is Apache 2.0)")
+	assert.NotEmpty(t, parsed.Version,
+		"version must be populated from .printing-press.json or version.Version fallback")
+}
+
+// TestSkillFrontmatterOmitsAllEnvVarDeclarations asserts the post-Hermes-
+// alignment shape: neither OpenClaw `requires.env` nor `envVars`, nor the
+// legacy `primaryEnv`, appears in printed-CLI SKILL.md frontmatter. The
+// classification problem (user-set vs harvested) is asymmetric on failure
+// — a false-positive on a harvested var (e.g., a session cookie) prompts
+// the user for a value the CLI can't accept. v1 ships no env-var
+// declarations in either format; the existing auth.Type-branched README
+// content carries credential UX.
+func TestSkillFrontmatterOmitsAllEnvVarDeclarations(t *testing.T) {
 	t.Parallel()
 
 	apiSpec := minimalSpec("clawauth")
@@ -435,25 +535,18 @@ func TestSkillFrontmatterEnvVarsOmitsHarvestedAuthEnvVars(t *testing.T) {
 	require.NotEqual(t, -1, end)
 	body := strings.TrimSuffix(strings.TrimPrefix(content[:4+end+5], "---\n"), "---\n")
 
-	var parsed struct {
-		Metadata struct {
-			Openclaw struct {
-				EnvVars []struct {
-					Name string `yaml:"name"`
-				} `yaml:"envVars"`
-			} `yaml:"openclaw"`
-		} `yaml:"metadata"`
-	}
-	require.NoError(t, yaml.Unmarshal([]byte(body), &parsed),
-		"frontmatter must parse as nested YAML; content was:\n%s", body)
-
-	var names []string
-	for _, envVar := range parsed.Metadata.Openclaw.EnvVars {
-		names = append(names, envVar.Name)
+	// None of the env-var declaration shapes should appear anywhere in
+	// the frontmatter — neither the canonical nor the legacy forms.
+	assert.NotContains(t, body, "envVars:", "envVars block must not appear in v1 frontmatter")
+	assert.NotContains(t, body, "      env:", "requires.env line must not appear in v1 frontmatter")
+	assert.NotContains(t, body, "primaryEnv", "primaryEnv (legacy synthesis-shape) must not appear")
+
+	// And specifically: no env-var name from the spec leaks into the
+	// frontmatter even when EnvVarSpecs is fully populated.
+	for _, name := range []string{"CLAW_API_TOKEN", "CLAW_CLIENT_ID", "CLAW_SESSION_COOKIE"} {
+		assert.NotContains(t, body, name,
+			"env var %q must not appear in v1 frontmatter (no env-var hoisting)", name)
 	}
-	assert.ElementsMatch(t, []string{"CLAW_API_TOKEN", "CLAW_CLIENT_ID"}, names)
-	assert.NotContains(t, body, "CLAW_SESSION_COOKIE",
-		"harvested env vars are populated by auth login and must not be user-facing OpenClaw envVars")
 }
 
 // TestSkillFrontmatterMetadataDefaultsCategoryToOther asserts that when the
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 40440dcc..96bf8863 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -393,6 +393,29 @@ To install:
 
 Requires Claude Desktop 1.0.0 or later. Pre-built bundles ship for macOS Apple Silicon (`darwin-arm64`) and Windows (`amd64`, `arm64`); for other platforms, use the manual config below.
 
+<!-- pp-hermes-install-anchor -->
+## Install via Hermes
+
+From the Hermes CLI:
+
+```bash
+hermes skills install mvanhorn/printing-press-library/cli-skills/pp-{{.Name}} --force
+```
+
+Inside a Hermes chat session:
+
+```bash
+/skills install mvanhorn/printing-press-library/cli-skills/pp-{{.Name}} --force
+```
+
+## Install via OpenClaw
+
+Tell your OpenClaw agent (copy this):
+
+```
+Install the pp-{{.Name}} skill from https://github.com/mvanhorn/printing-press-library/tree/main/cli-skills/pp-{{.Name}}. The skill defines how its required CLI can be installed.
+```
+
 <details>
 <summary>Manual JSON config (advanced)</summary>
 
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index d7905eff..de540273 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -1,47 +1,16 @@
 ---
 name: pp-{{.Name}}
 description: "{{if and .Narrative .Narrative.Headline}}{{yamlDoubleQuoted .Narrative.Headline}}{{else}}Printing Press CLI for {{yamlDoubleQuoted .ProseName}}. {{yamlDoubleQuoted (oneline .Description)}}{{end}}{{if and .Narrative .Narrative.TriggerPhrases}} Trigger phrases: {{range $i, $p := .Narrative.TriggerPhrases}}{{if $i}}, {{end}}`{{yamlDoubleQuoted $p}}`{{end}}.{{end}}"
+version: "{{pressVersion}}"
+author: "{{yamlDoubleQuoted .OwnerName}}"
+license: "Apache-2.0"
 argument-hint: "<command> [args] | install cli|mcp"
 allowed-tools: "Read Bash"
 metadata:
   openclaw:
     requires:
-{{- if and (ne .Auth.Type "none") (or .Auth.EnvVarSpecs .Auth.EnvVars)}}
-{{- $wroteRequiredEnv := false}}
-      env: [{{- if .Auth.EnvVarSpecs}}{{range .Auth.EnvVarSpecs}}{{if and (eq .Kind "per_call") .Required}}{{if $wroteRequiredEnv}}, {{end}}{{printf "%q" .Name}}{{- $wroteRequiredEnv = true}}{{end}}{{end}}{{else}}{{range .Auth.EnvVars}}{{if $wroteRequiredEnv}}, {{end}}{{printf "%q" .}}{{- $wroteRequiredEnv = true}}{{end}}{{end}}]
-{{- end}}
       bins:
         - {{.Name}}-pp-cli
-{{- if and (ne .Auth.Type "none") (or .Auth.EnvVarSpecs .Auth.EnvVars)}}
-    envVars:
-{{- if .Auth.EnvVarSpecs}}
-{{- range .Auth.EnvVarSpecs}}
-{{- if ne .Kind "harvested"}}
-      - name: {{.Name}}
-        required: {{.Required}}
-{{- if and (eq .Kind "per_call") .Required}}
-        description: "{{if .Description}}{{yamlDoubleQuoted .Description}}{{else}}{{.Name}} credential.{{end}}"
-{{- else if and (eq .Kind "per_call") (not .Required)}}
-        description: "{{yamlDoubleQuoted (printf "Set this OR another listed env var. %s" .Description)}}"
-{{- else if and (eq .Kind "auth_flow_input") (not .Sensitive)}}
-        description: "{{yamlDoubleQuoted (printf "Only needed during `auth login`; not required for normal use. %s" .Description)}}"
-{{- else if and (eq .Kind "auth_flow_input") .Sensitive}}
-        description: "Set during application setup."
-{{- else if eq .Kind "harvested"}}
-        description: "Populated automatically by `auth login`."
-{{- else}}
-        description: "{{if .Description}}{{yamlDoubleQuoted .Description}}{{else}}{{.Name}} credential.{{end}}"
-{{- end}}
-{{- end}}
-{{- end}}
-{{- else}}
-{{- range .Auth.EnvVars}}
-      - name: {{.}}
-        required: true
-        description: "{{.}} credential."
-{{- end}}
-{{- end}}
-{{- end}}
     install:
       - kind: go
         bins: [{{.Name}}-pp-cli]
@@ -49,6 +18,25 @@ metadata:
 ---
 
 # {{.ProseName}} — Printing Press CLI
+
+## Prerequisites: Install the CLI
+
+This skill drives the `{{.Name}}-pp-cli` binary. **You must verify the CLI is installed before invoking any command from this skill.** If it is missing, install it first:
+
+1. Install via the Printing Press installer:
+   ```bash
+   npx -y @mvanhorn/printing-press install {{.Name}} --cli-only
+   ```
+2. Verify: `{{.Name}}-pp-cli --version`
+3. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
+
+If the `npx` install fails (no Node, offline, etc.), fall back to a direct Go install (requires {{if .UsesBrowserHTTPTransport}}Go 1.25+{{else}}Go 1.23+{{end}}):
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}/cmd/{{.Name}}-pp-cli@latest
+```
+
+If `--version` reports "command not found" after install, the install step did not put the binary on `$PATH`. Do not proceed with skill commands until verification succeeds.
 {{- if and .Narrative .Narrative.ValueProp}}
 
 {{.Narrative.ValueProp}}
@@ -373,19 +361,9 @@ Use async submission without `--wait` when you want to fire-and-forget; use `--w
 Parse `$ARGUMENTS`:
 
 1. **Empty, `help`, or `--help`** → show `{{.Name}}-pp-cli --help` output
-2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → CLI installation
+2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → see Prerequisites above
 3. **Anything else** → Direct Use (execute as CLI command with `--agent`)
 
-## CLI Installation
-
-1. Check Go is installed: `go version` (requires {{if .UsesBrowserHTTPTransport}}Go 1.25+{{else}}Go 1.23+{{end}})
-2. Install:
-   ```bash
-   go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}/cmd/{{.Name}}-pp-cli@latest
-   ```
-3. Verify: `{{.Name}}-pp-cli --version`
-4. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
-
 ## MCP Server Installation
 
 1. Install the MCP server:
@@ -401,7 +379,7 @@ Parse `$ARGUMENTS`:
 ## Direct Use
 
 1. Check if installed: `which {{.Name}}-pp-cli`
-   If not found, offer to install (see CLI Installation above).
+   If not found, offer to install (see Prerequisites at the top of this skill).
 2. Match the user query to the best command from the Unique Capabilities and Command Reference above.
 3. Execute with the `--agent` flag:
    ```bash
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7990bd19..afc09acb 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -100,6 +100,7 @@ type APISpec struct {
 	// as plumbing only; PR-2 wires the runtime substitution.
 	EndpointTemplateVars []string            `yaml:"endpoint_template_vars,omitempty" json:"endpoint_template_vars,omitempty"`
 	Owner                string              `yaml:"owner,omitempty" json:"owner,omitempty"`                   // GitHub owner for import paths and Homebrew tap
+	OwnerName            string              `yaml:"owner_name,omitempty" json:"owner_name,omitempty"`         // Display name (e.g. "Trevin Chow") for prose surfaces — Hermes author:, README byline. Distinct from Owner (slug) which drives module paths and copyright headers.
 	Kind                 string              `yaml:"kind,omitempty" json:"kind,omitempty"`                     // "rest" (default) or "synthetic" — synthetic CLIs aggregate multiple sources beyond the spec; dogfood's path-validity check is relaxed accordingly
 	SpecSource           string              `yaml:"spec_source,omitempty" json:"spec_source,omitempty"`       // official, community, sniffed, docs — affects generated client defaults
 	ClientPattern        string              `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
diff --git a/scripts/golden.sh b/scripts/golden.sh
index 00493ec8..ca7f863c 100755
--- a/scripts/golden.sh
+++ b/scripts/golden.sh
@@ -120,6 +120,10 @@ run_case() {
   mkdir -p "$out_dir"
   command_text="$(cat "$case_dir/command.txt")"
 
+  # Both git config keys use the same slug-shaped fixture value so goldens
+  # are deterministic. In production, `user.name` resolves to a prose
+  # display name (e.g. "Trevin Chow"); golden output's slug-shaped
+  # `author:` field is a fixture artifact, not the production shape.
   BINARY="$binary" CASE_ACTUAL_DIR="$out_dir" REPO_ROOT="$repo_root" \
     GIT_CONFIG_COUNT=2 \
     GIT_CONFIG_KEY_0=github.user \
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md
index 94b4c3ba..3a137ddc 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md
@@ -129,6 +129,29 @@ To install:
 
 Requires Claude Desktop 1.0.0 or later. Pre-built bundles ship for macOS Apple Silicon (`darwin-arm64`) and Windows (`amd64`, `arm64`); for other platforms, use the manual config below.
 
+<!-- pp-hermes-install-anchor -->
+## Install via Hermes
+
+From the Hermes CLI:
+
+```bash
+hermes skills install mvanhorn/printing-press-library/cli-skills/pp-printing-press-rich --force
+```
+
+Inside a Hermes chat session:
+
+```bash
+/skills install mvanhorn/printing-press-library/cli-skills/pp-printing-press-rich --force
+```
+
+## Install via OpenClaw
+
+Tell your OpenClaw agent (copy this):
+
+```
+Install the pp-printing-press-rich skill from https://github.com/mvanhorn/printing-press-library/tree/main/cli-skills/pp-printing-press-rich. The skill defines how its required CLI can be installed.
+```
+
 <details>
 <summary>Manual JSON config (advanced)</summary>
 
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md
index 38add1b7..9f751ff2 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/SKILL.md
@@ -1,33 +1,16 @@
 ---
 name: pp-printing-press-rich
 description: "Printing Press CLI for Printing Press Rich. Purpose-built fixture for rich auth env-var model coverage."
+version: "3.10.0"
+author: "printing-press-golden"
+license: "Apache-2.0"
 argument-hint: "<command> [args] | install cli|mcp"
 allowed-tools: "Read Bash"
 metadata:
   openclaw:
     requires:
-      env: ["RICH_AUTH_API_KEY"]
       bins:
         - printing-press-rich-pp-cli
-    envVars:
-      - name: RICH_AUTH_API_KEY
-        required: true
-        description: "Rich Auth API key."
-      - name: RICH_AUTH_CLIENT_ID
-        required: false
-        description: "Only needed during `auth login`; not required for normal use. OAuth application client identifier."
-      - name: RICH_AUTH_CLIENT_SECRET
-        required: false
-        description: "Set during application setup."
-      - name: RICH_AUTH_OPTIONAL_TOKEN
-        required: false
-        description: "Set this OR another listed env var. Optional token for elevated read limits."
-      - name: RICH_AUTH_BOT_TOKEN
-        required: false
-        description: "Set this OR another listed env var. Set this OR RICH_AUTH_USER_TOKEN for workspace access."
-      - name: RICH_AUTH_USER_TOKEN
-        required: false
-        description: "Set this OR another listed env var. Set this OR RICH_AUTH_BOT_TOKEN for workspace access."
     install:
       - kind: go
         bins: [printing-press-rich-pp-cli]
@@ -36,6 +19,25 @@ metadata:
 
 # Printing Press Rich — Printing Press CLI
 
+## Prerequisites: Install the CLI
+
+This skill drives the `printing-press-rich-pp-cli` binary. **You must verify the CLI is installed before invoking any command from this skill.** If it is missing, install it first:
+
+1. Install via the Printing Press installer:
+   ```bash
+   npx -y @mvanhorn/printing-press install printing-press-rich --cli-only
+   ```
+2. Verify: `printing-press-rich-pp-cli --version`
+3. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
+
+If the `npx` install fails (no Node, offline, etc.), fall back to a direct Go install (requires Go 1.23+):
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/other/printing-press-rich/cmd/printing-press-rich-pp-cli@latest
+```
+
+If `--version` reports "command not found" after install, the install step did not put the binary on `$PATH`. Do not proceed with skill commands until verification succeeds.
+
 Purpose-built fixture for rich auth env-var model coverage.
 
 ## When Not to Use This CLI
@@ -155,19 +157,9 @@ Explicit flags always win over profile values; profile values win over defaults.
 Parse `$ARGUMENTS`:
 
 1. **Empty, `help`, or `--help`** → show `printing-press-rich-pp-cli --help` output
-2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → CLI installation
+2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → see Prerequisites above
 3. **Anything else** → Direct Use (execute as CLI command with `--agent`)
 
-## CLI Installation
-
-1. Check Go is installed: `go version` (requires Go 1.23+)
-2. Install:
-   ```bash
-   go install github.com/mvanhorn/printing-press-library/library/other/printing-press-rich/cmd/printing-press-rich-pp-cli@latest
-   ```
-3. Verify: `printing-press-rich-pp-cli --version`
-4. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
-
 ## MCP Server Installation
 
 1. Install the MCP server:
@@ -183,7 +175,7 @@ Parse `$ARGUMENTS`:
 ## Direct Use
 
 1. Check if installed: `which printing-press-rich-pp-cli`
-   If not found, offer to install (see CLI Installation above).
+   If not found, offer to install (see Prerequisites at the top of this skill).
 2. Match the user query to the best command from the Unique Capabilities and Command Reference above.
 3. Execute with the `--agent` flag:
    ```bash
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
index a3b0a6be..0cd4cca7 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
@@ -150,6 +150,29 @@ To install:
 
 Requires Claude Desktop 1.0.0 or later. Pre-built bundles ship for macOS Apple Silicon (`darwin-arm64`) and Windows (`amd64`, `arm64`); for other platforms, use the manual config below.
 
+<!-- pp-hermes-install-anchor -->
+## Install via Hermes
+
+From the Hermes CLI:
+
+```bash
+hermes skills install mvanhorn/printing-press-library/cli-skills/pp-printing-press-golden --force
+```
+
+Inside a Hermes chat session:
+
+```bash
+/skills install mvanhorn/printing-press-library/cli-skills/pp-printing-press-golden --force
+```
+
+## Install via OpenClaw
+
+Tell your OpenClaw agent (copy this):
+
+```
+Install the pp-printing-press-golden skill from https://github.com/mvanhorn/printing-press-library/tree/main/cli-skills/pp-printing-press-golden. The skill defines how its required CLI can be installed.
+```
+
 <details>
 <summary>Manual JSON config (advanced)</summary>
 
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
index 80a176b3..cac2d518 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
@@ -1,18 +1,16 @@
 ---
 name: pp-printing-press-golden
 description: "Printing Press CLI for Printing Press Golden. Purpose-built fixture for golden generation coverage."
+version: "3.10.0"
+author: "printing-press-golden"
+license: "Apache-2.0"
 argument-hint: "<command> [args] | install cli|mcp"
 allowed-tools: "Read Bash"
 metadata:
   openclaw:
     requires:
-      env: ["PRINTING_PRESS_GOLDEN_API_KEY"]
       bins:
         - printing-press-golden-pp-cli
-    envVars:
-      - name: PRINTING_PRESS_GOLDEN_API_KEY
-        required: true
-        description: "PRINTING_PRESS_GOLDEN_API_KEY credential."
     install:
       - kind: go
         bins: [printing-press-golden-pp-cli]
@@ -21,6 +19,25 @@ metadata:
 
 # Printing Press Golden — Printing Press CLI
 
+## Prerequisites: Install the CLI
+
+This skill drives the `printing-press-golden-pp-cli` binary. **You must verify the CLI is installed before invoking any command from this skill.** If it is missing, install it first:
+
+1. Install via the Printing Press installer:
+   ```bash
+   npx -y @mvanhorn/printing-press install printing-press-golden --cli-only
+   ```
+2. Verify: `printing-press-golden-pp-cli --version`
+3. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
+
+If the `npx` install fails (no Node, offline, etc.), fall back to a direct Go install (requires Go 1.23+):
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/other/printing-press-golden/cmd/printing-press-golden-pp-cli@latest
+```
+
+If `--version` reports "command not found" after install, the install step did not put the binary on `$PATH`. Do not proceed with skill commands until verification succeeds.
+
 Purpose-built fixture for golden generation coverage.
 
 ## Command Reference
@@ -149,19 +166,9 @@ Explicit flags always win over profile values; profile values win over defaults.
 Parse `$ARGUMENTS`:
 
 1. **Empty, `help`, or `--help`** → show `printing-press-golden-pp-cli --help` output
-2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → CLI installation
+2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → see Prerequisites above
 3. **Anything else** → Direct Use (execute as CLI command with `--agent`)
 
-## CLI Installation
-
-1. Check Go is installed: `go version` (requires Go 1.23+)
-2. Install:
-   ```bash
-   go install github.com/mvanhorn/printing-press-library/library/other/printing-press-golden/cmd/printing-press-golden-pp-cli@latest
-   ```
-3. Verify: `printing-press-golden-pp-cli --version`
-4. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
-
 ## MCP Server Installation
 
 1. Install the MCP server:
@@ -177,7 +184,7 @@ Parse `$ARGUMENTS`:
 ## Direct Use
 
 1. Check if installed: `which printing-press-golden-pp-cli`
-   If not found, offer to install (see CLI Installation above).
+   If not found, offer to install (see Prerequisites at the top of this skill).
 2. Match the user query to the best command from the Unique Capabilities and Command Reference above.
 3. Execute with the `--agent` flag:
    ```bash
diff --git a/tools/migrate-skill-metadata/main.go b/tools/migrate-skill-metadata/main.go
deleted file mode 100644
index f88d5786..00000000
--- a/tools/migrate-skill-metadata/main.go
+++ /dev/null
@@ -1,516 +0,0 @@
-// Migrate SKILL.md frontmatter metadata: from JSON-encoded string to
-// ClawHub-compliant nested YAML. One-time migration tool.
-//
-// Operates on a public-library tree containing library/*/*/SKILL.md and
-// cli-skills/pp-*/SKILL.md files. For each file:
-//
-//   - If the frontmatter contains a single-line `metadata: '{"openclaw":...}'`,
-//     parse the embedded JSON for env/primaryEnv, derive the canonical Go
-//     module path from the file's actual filesystem location and sibling
-//     `.printing-press.json` provenance, and replace that line with a
-//     multi-line nested YAML block. The legacy `command:` field's path is
-//     intentionally NOT preserved — it can be stale (CLIs moved between
-//     categories without their SKILL.md being updated). The filesystem
-//     location plus provenance is the canonical truth.
-//
-//   - If the frontmatter has no `metadata:` line at all (the instacart case),
-//     synthesize a block from the same provenance lookup and insert it
-//     before the closing `---`.
-//
-//   - If the frontmatter is already in the nested-YAML form, skip without
-//     writing (idempotent).
-//
-// All non-metadata frontmatter content stays byte-identical: this tool does
-// no yaml.v3 round-trip of the full frontmatter, only line-targeted text
-// replacement of the metadata region.
-package main
-
-import (
-	"encoding/json"
-	"errors"
-	"flag"
-	"fmt"
-	"io/fs"
-	"os"
-	"path/filepath"
-	"regexp"
-	"sort"
-	"strings"
-)
-
-func main() {
-	dryRun := flag.Bool("dry-run", false, "Print what would change without writing")
-	verbose := flag.Bool("verbose", false, "Print per-file action lines")
-	flag.Usage = func() {
-		fmt.Fprintf(os.Stderr, "usage: migrate-skill-metadata [flags] <library-root>\n\n")
-		fmt.Fprintf(os.Stderr, "  library-root  path to a tree containing library/*/*/SKILL.md\n")
-		fmt.Fprintf(os.Stderr, "                and cli-skills/pp-*/SKILL.md\n\n")
-		flag.PrintDefaults()
-	}
-	flag.Parse()
-
-	if flag.NArg() != 1 {
-		flag.Usage()
-		os.Exit(2)
-	}
-
-	root := flag.Arg(0)
-	// Resolve symlinks on the root too. Each per-file path is run through
-	// filepath.EvalSymlinks before the prefix check; if the root itself
-	// is a symlink (common on macOS where /var -> /private/var, so any
-	// mktemp-created tree under /var/folders/... resolves to /private/...
-	// after EvalSymlinks), an unresolved root would fail the prefix check
-	// for every file.
-	absRoot, err := filepath.EvalSymlinks(root)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "error: cannot resolve root path: %v\n", err)
-		os.Exit(1)
-	}
-	absRoot, err = filepath.Abs(absRoot)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "error: cannot absolutize root path: %v\n", err)
-		os.Exit(1)
-	}
-	info, err := os.Stat(absRoot)
-	if err != nil || !info.IsDir() {
-		fmt.Fprintf(os.Stderr, "error: %s is not a directory\n", absRoot)
-		os.Exit(1)
-	}
-
-	report, err := run(absRoot, *dryRun, *verbose)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "error: %v\n", err)
-		os.Exit(1)
-	}
-
-	report.print(os.Stdout, *dryRun)
-	if report.errored > 0 {
-		os.Exit(1)
-	}
-}
-
-type report struct {
-	migrated    int      // JSON-string -> nested YAML
-	synthesized int      // no metadata: line -> synthesized block
-	skipped     int      // already nested
-	errored     int      // refused or failed
-	errors      []string // file paths with reason
-}
-
-func (r *report) print(w *os.File, dryRun bool) {
-	verb := "applied"
-	if dryRun {
-		verb = "would apply"
-	}
-	fmt.Fprintf(w, "Migration summary (%s):\n", verb)
-	fmt.Fprintf(w, "  migrated:    %d\n", r.migrated)
-	fmt.Fprintf(w, "  synthesized: %d\n", r.synthesized)
-	fmt.Fprintf(w, "  skipped:     %d (already in nested YAML form)\n", r.skipped)
-	fmt.Fprintf(w, "  errored:     %d\n", r.errored)
-	for _, e := range r.errors {
-		fmt.Fprintf(w, "    %s\n", e)
-	}
-}
-
-func run(absRoot string, dryRun, verbose bool) (*report, error) {
-	files, err := discoverSkillFiles(absRoot)
-	if err != nil {
-		return nil, err
-	}
-	if verbose {
-		fmt.Printf("Discovered %d SKILL.md files under %s\n", len(files), absRoot)
-	}
-
-	r := &report{}
-	for _, path := range files {
-		// Path-traversal guard: every discovered path must resolve under absRoot
-		// even after symlink evaluation. filepath.Abs alone Cleans `..` segments
-		// but does not follow symlinks; a SKILL.md symlinked to a target outside
-		// the root would slip through the prefix check without EvalSymlinks.
-		abs, err := filepath.EvalSymlinks(path)
-		if err != nil {
-			r.errored++
-			r.errors = append(r.errors, fmt.Sprintf("%s: cannot resolve path: %v", path, err))
-			continue
-		}
-		abs, err = filepath.Abs(abs)
-		if err != nil {
-			r.errored++
-			r.errors = append(r.errors, fmt.Sprintf("%s: cannot absolutize path: %v", path, err))
-			continue
-		}
-		if !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) {
-			r.errored++
-			r.errors = append(r.errors, fmt.Sprintf("%s: resolved path escapes root", path))
-			continue
-		}
-
-		action, err := migrateFile(abs, absRoot, dryRun)
-		switch {
-		case err != nil:
-			r.errored++
-			r.errors = append(r.errors, fmt.Sprintf("%s: %v", relpath(abs, absRoot), err))
-		case action == "migrated":
-			r.migrated++
-		case action == "synthesized":
-			r.synthesized++
-		case action == "skipped":
-			r.skipped++
-		}
-		if verbose && err == nil {
-			fmt.Printf("  %s: %s\n", relpath(abs, absRoot), action)
-		}
-	}
-	return r, nil
-}
-
-func relpath(abs, root string) string {
-	rel, err := filepath.Rel(root, abs)
-	if err != nil {
-		return abs
-	}
-	return rel
-}
-
-// discoverSkillFiles returns absolute paths to all SKILL.md files under
-// library/*/*/ and cli-skills/pp-*/ inside the root. Output is sorted for
-// deterministic processing.
-func discoverSkillFiles(absRoot string) ([]string, error) {
-	var out []string
-	libraryGlob := filepath.Join(absRoot, "library", "*", "*", "SKILL.md")
-	libMatches, err := filepath.Glob(libraryGlob)
-	if err != nil {
-		return nil, fmt.Errorf("globbing library: %w", err)
-	}
-	out = append(out, libMatches...)
-
-	skillsGlob := filepath.Join(absRoot, "cli-skills", "pp-*", "SKILL.md")
-	skillsMatches, err := filepath.Glob(skillsGlob)
-	if err != nil {
-		return nil, fmt.Errorf("globbing cli-skills: %w", err)
-	}
-	out = append(out, skillsMatches...)
-
-	sort.Strings(out)
-	return out, nil
-}
-
-// migrateFile applies the migration to one SKILL.md, returning the action
-// taken: "migrated", "synthesized", "skipped", or "" plus an error. The
-// install module path always comes from the file's actual filesystem
-// location and sibling `.printing-press.json` provenance — never from the
-// legacy JSON-string `command:` field, which can be stale (CLIs moved
-// between categories without their SKILL.md being updated). This makes
-// the tool produce correct output by default with no flag to remember.
-func migrateFile(absPath, absRoot string, dryRun bool) (string, error) {
-	content, err := os.ReadFile(absPath)
-	if err != nil {
-		return "", fmt.Errorf("read: %w", err)
-	}
-
-	frontStart, frontEnd, err := frontmatterBounds(content)
-	if err != nil {
-		return "", err
-	}
-	front := string(content[frontStart:frontEnd])
-
-	jsonLineRe := regexp.MustCompile(`(?m)^metadata: '(.+)'\s*$`)
-	if m := jsonLineRe.FindStringSubmatchIndex(front); m != nil {
-		// Legacy JSON-string form. Parse for env/primaryEnv (which the legacy
-		// JSON is the source of truth for) but always derive the module from
-		// provenance + filesystem.
-		jsonValue := front[m[2]:m[3]]
-		lineStart := frontStart + m[0]
-		lineEnd := frontStart + m[1]
-
-		env, primaryEnv, err := parseLegacyOpenclawJSON(jsonValue)
-		if err != nil {
-			return "", fmt.Errorf("parse legacy JSON: %w", err)
-		}
-		cliName, category, dirName, err := lookupProvenance(absPath, absRoot)
-		if err != nil {
-			return "", fmt.Errorf("provenance lookup: %w", err)
-		}
-		newBlock := emitMetadataBlock(cliName, buildModulePath(category, dirName, cliName), env, primaryEnv)
-
-		updated := append([]byte{}, content[:lineStart]...)
-		updated = append(updated, []byte(newBlock)...)
-		updated = append(updated, content[lineEnd:]...)
-		if !dryRun {
-			if err := atomicWrite(absPath, updated); err != nil {
-				return "", err
-			}
-		}
-		return "migrated", nil
-	}
-
-	// Idempotency: if the frontmatter already has a `metadata:` block
-	// (multi-line nested YAML form), the regex won't match and we can skip.
-	if strings.Contains(front, "\nmetadata:\n") || strings.HasPrefix(front, "metadata:\n") {
-		return "skipped", nil
-	}
-
-	// No metadata field at all -- synthesis path.
-	cliName, category, dirName, err := lookupProvenance(absPath, absRoot)
-	if err != nil {
-		return "", fmt.Errorf("synthesis lookup: %w", err)
-	}
-	block := buildMetadataBlock(cliName, category, dirName, nil, "")
-	updated := append([]byte{}, content[:frontEnd]...)
-	updated = append(updated, []byte(block)...)
-	updated = append(updated, content[frontEnd:]...)
-	if !dryRun {
-		if err := atomicWrite(absPath, updated); err != nil {
-			return "", err
-		}
-	}
-	return "synthesized", nil
-}
-
-// frontmatterBounds returns the byte offsets of the start and end of the
-// frontmatter region (between the first two `---` markers). The start
-// includes the opening line, the end is the byte position of the closing
-// `---` line (not past its newline).
-func frontmatterBounds(content []byte) (int, int, error) {
-	if !strings.HasPrefix(string(content), "---\n") {
-		return 0, 0, fmt.Errorf("file does not begin with frontmatter delimiter")
-	}
-	rest := content[4:]
-	idx := strings.Index(string(rest), "\n---\n")
-	if idx == -1 {
-		return 0, 0, fmt.Errorf("frontmatter has no closing delimiter")
-	}
-	// frontmatter region: from byte 0 (start of opening `---`) through end of
-	// the line just before the closing `---`. We return the inner bounds:
-	// start = 4 (past opening `---\n`), end = 4 + idx + 1 (past the newline
-	// before the closing `---`).
-	return 4, 4 + idx + 1, nil
-}
-
-// openclawJSON is the subset of the legacy `metadata:` JSON we extract.
-// Only env and primaryEnv are used; the rest of the JSON (bins, install,
-// kind, command, id, label) is read by encoding/json but discarded —
-// module path comes from filesystem + provenance instead.
-type openclawJSON struct {
-	Requires struct {
-		Env []string `json:"env,omitempty"`
-	} `json:"requires"`
-	PrimaryEnv string `json:"primaryEnv,omitempty"`
-}
-
-// parseLegacyOpenclawJSON extracts env/primaryEnv from a legacy metadata
-// JSON string. Only malformed JSON is an error; structurally-unusual but
-// parseable JSON returns whatever env/primaryEnv it carries (or zero
-// values if absent). The migration is structurally robust without strict
-// validation: module path comes from provenance, env-absence emits a
-// no-auth block, and the loop never blocks on shape variation.
-func parseLegacyOpenclawJSON(jsonStr string) (env []string, primaryEnv string, err error) {
-	var meta struct {
-		Openclaw openclawJSON `json:"openclaw"`
-	}
-	if err := json.Unmarshal([]byte(jsonStr), &meta); err != nil {
-		return nil, "", fmt.Errorf("parse JSON: %w", err)
-	}
-	return meta.Openclaw.Requires.Env, meta.Openclaw.PrimaryEnv, nil
-}
-
-// buildMetadataBlock is the synthesis-path emitter: derives the module
-// from category + library directory name + cliName before delegating to
-// emitMetadataBlock. Used when a SKILL.md has no metadata field at all and
-// we synthesize one from .printing-press.json provenance.
-//
-// dirName is the slug-keyed directory the CLI lives in under library/<category>/.
-// It can differ from cliName when the directory uses the slug-only convention
-// (e.g., library/commerce/instacart/ vs cli_name "instacart-pp-cli") or the
-// older binary-suffix convention (e.g., library/commerce/dominos-pp-cli/).
-// Module path follows library/<category>/<dirName>/cmd/<cliName>.
-func buildMetadataBlock(cliName, category, dirName string, env []string, primaryEnv string) string {
-	module := buildModulePath(category, dirName, cliName)
-	return emitMetadataBlock(cliName, module, env, primaryEnv)
-}
-
-// buildModulePath returns the canonical Go module path for an install
-// entry. dirName is the library directory basename (slug-only or
-// binary-suffix); cliName is the CLI binary name; both are needed because
-// the directory and binary names diverge for some CLIs.
-func buildModulePath(category, dirName, cliName string) string {
-	if category == "" {
-		category = "other"
-	}
-	if dirName == "" {
-		dirName = cliName
-	}
-	return "github.com/mvanhorn/printing-press-library/library/" + category +
-		"/" + dirName + "/cmd/" + cliName
-}
-
-// emitMetadataBlock returns the canonical multi-line YAML metadata block.
-// Indentation uses 2 spaces. The block always ends with a newline so it can
-// splice cleanly into a frontmatter. When env or primaryEnv are supplied,
-// they are emitted under openclaw in the order: requires.bins, requires.env,
-// primaryEnv, install.
-//
-// The single source of canonical formatting -- both the migration path
-// (transformMetadataJSON) and synthesis path (buildMetadataBlock) route
-// through here so the byte-shape stays identical.
-func emitMetadataBlock(cliName, module string, env []string, primaryEnv string) string {
-	var b strings.Builder
-	b.WriteString("metadata:\n")
-	b.WriteString("  openclaw:\n")
-	b.WriteString("    requires:\n")
-	b.WriteString("      bins:\n")
-	b.WriteString("        - ")
-	b.WriteString(cliName)
-	b.WriteString("\n")
-	if len(env) > 0 {
-		b.WriteString("      env:\n")
-		for _, e := range env {
-			b.WriteString("        - ")
-			b.WriteString(e)
-			b.WriteString("\n")
-		}
-	}
-	if primaryEnv != "" {
-		b.WriteString("    primaryEnv: ")
-		b.WriteString(primaryEnv)
-		b.WriteString("\n")
-	}
-	b.WriteString("    install:\n")
-	b.WriteString("      - kind: go\n")
-	b.WriteString("        bins: [")
-	b.WriteString(cliName)
-	b.WriteString("]\n")
-	b.WriteString("        module: ")
-	b.WriteString(module)
-	b.WriteString("\n")
-	return b.String()
-}
-
-// lookupProvenance resolves cli_name, category, and the library directory
-// basename for the SKILL.md at skillPath.
-//
-// Category and dirName always come from the file's filesystem position
-// (library/<category>/<dirName>/SKILL.md or cli-skills/pp-<slug>/SKILL.md
-// after pivoting through the registry). cli_name comes from the sibling
-// `.printing-press.json` because the binary name and the directory name
-// can diverge — older CLIs use library/<dir>/ where dir is binary-suffixed,
-// newer CLIs use slug-only directories. .printing-press.json's `category`
-// field is unreliable (often absent in older provenance files), so it's
-// not consulted; the filesystem path is the canonical truth for category.
-func lookupProvenance(skillPath, absRoot string) (cliName, category, dirName string, err error) {
-	dir := filepath.Dir(skillPath)
-	rel, err := filepath.Rel(absRoot, skillPath)
-	if err != nil {
-		return "", "", "", fmt.Errorf("relpath: %w", err)
-	}
-	parts := strings.Split(rel, string(filepath.Separator))
-
-	// Library file: library/<category>/<dir>/SKILL.md
-	if len(parts) == 4 && parts[0] == "library" && parts[3] == "SKILL.md" {
-		category = parts[1]
-		dirName = parts[2]
-		manifestPath := filepath.Join(dir, ".printing-press.json")
-		data, readErr := os.ReadFile(manifestPath)
-		if readErr != nil {
-			if errors.Is(readErr, fs.ErrNotExist) {
-				return "", "", "", fmt.Errorf("missing sibling .printing-press.json (need cli_name)")
-			}
-			return "", "", "", fmt.Errorf("read sibling provenance: %w", readErr)
-		}
-		cn, perr := parseProvenanceCLIName(data)
-		if perr != nil {
-			return "", "", "", perr
-		}
-		return cn, category, dirName, nil
-	}
-
-	// cli-skills mirror: cli-skills/pp-<slug>/SKILL.md. Pivot through the
-	// library tree to find the corresponding library entry whose category
-	// and directory basename are the canonical source.
-	if len(parts) == 3 && parts[0] == "cli-skills" && parts[2] == "SKILL.md" && strings.HasPrefix(parts[1], "pp-") {
-		slug := strings.TrimPrefix(parts[1], "pp-")
-		candidates := []string{
-			filepath.Join(absRoot, "library", "*", slug, ".printing-press.json"),
-			filepath.Join(absRoot, "library", "*", slug+"-pp-cli", ".printing-press.json"),
-		}
-		for _, pattern := range candidates {
-			matches, globErr := filepath.Glob(pattern)
-			if globErr != nil {
-				return "", "", "", fmt.Errorf("glob %s: %w", pattern, globErr)
-			}
-			for _, m := range matches {
-				data, readErr := os.ReadFile(m)
-				if readErr != nil {
-					continue
-				}
-				cn, perr := parseProvenanceCLIName(data)
-				if perr != nil {
-					return "", "", "", perr
-				}
-				libDir := filepath.Dir(m)
-				libRel, err := filepath.Rel(absRoot, libDir)
-				if err != nil {
-					return "", "", "", fmt.Errorf("relpath libDir: %w", err)
-				}
-				libParts := strings.Split(libRel, string(filepath.Separator))
-				if len(libParts) != 3 || libParts[0] != "library" {
-					return "", "", "", fmt.Errorf("library entry at unexpected path: %s", libRel)
-				}
-				return cn, libParts[1], libParts[2], nil
-			}
-		}
-		return "", "", "", fmt.Errorf("could not resolve provenance for cli-skills mirror pp-%s (no matching library/<cat>/%s or library/<cat>/%s-pp-cli)", slug, slug, slug)
-	}
-
-	return "", "", "", fmt.Errorf("path is neither library/<cat>/<dir>/SKILL.md nor cli-skills/pp-<slug>/SKILL.md: %s", rel)
-}
-
-func parseProvenanceCLIName(data []byte) (string, error) {
-	var pp struct {
-		CLIName string `json:"cli_name"`
-	}
-	if err := json.Unmarshal(data, &pp); err != nil {
-		return "", fmt.Errorf("parse .printing-press.json: %w", err)
-	}
-	if pp.CLIName == "" {
-		return "", fmt.Errorf(".printing-press.json missing cli_name")
-	}
-	return pp.CLIName, nil
-}
-
-// atomicWrite writes data to path via tmp + rename. Preserves the original
-// file's mode so a 0644 SKILL.md doesn't silently become 0600 (CreateTemp's
-// default) after rename. fsyncs the data before close so a crash between
-// rename and dirty-page flush can't leave a zero-length file behind.
-func atomicWrite(path string, data []byte) error {
-	origInfo, err := os.Stat(path)
-	if err != nil {
-		return fmt.Errorf("stat original: %w", err)
-	}
-	dir := filepath.Dir(path)
-	tmp, err := os.CreateTemp(dir, ".migrate-skill-metadata-*.tmp")
-	if err != nil {
-		return fmt.Errorf("create tmp: %w", err)
-	}
-	tmpName := tmp.Name()
-	defer func() { _ = os.Remove(tmpName) }()
-	if _, err := tmp.Write(data); err != nil {
-		_ = tmp.Close()
-		return fmt.Errorf("write tmp: %w", err)
-	}
-	if err := tmp.Chmod(origInfo.Mode().Perm()); err != nil {
-		_ = tmp.Close()
-		return fmt.Errorf("chmod tmp: %w", err)
-	}
-	if err := tmp.Sync(); err != nil {
-		_ = tmp.Close()
-		return fmt.Errorf("fsync tmp: %w", err)
-	}
-	if err := tmp.Close(); err != nil {
-		return fmt.Errorf("close tmp: %w", err)
-	}
-	if err := os.Rename(tmpName, path); err != nil {
-		return fmt.Errorf("rename tmp: %w", err)
-	}
-	return nil
-}
diff --git a/tools/migrate-skill-metadata/main_test.go b/tools/migrate-skill-metadata/main_test.go
deleted file mode 100644
index 0ef7ac09..00000000
--- a/tools/migrate-skill-metadata/main_test.go
+++ /dev/null
@@ -1,519 +0,0 @@
-package main
-
-import (
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-)
-
-// --- Unit tests for derivation helpers ---
-
-func TestEmitMetadataBlock(t *testing.T) {
-	want := "metadata:\n" +
-		"  openclaw:\n" +
-		"    requires:\n" +
-		"      bins:\n" +
-		"        - foo-pp-cli\n" +
-		"    install:\n" +
-		"      - kind: go\n" +
-		"        bins: [foo-pp-cli]\n" +
-		"        module: github.com/example/foo/cmd/foo-pp-cli\n"
-	got := emitMetadataBlock("foo-pp-cli", "github.com/example/foo/cmd/foo-pp-cli", nil, "")
-	if got != want {
-		t.Errorf("emitMetadataBlock no-auth mismatch:\nwant:\n%s\ngot:\n%s", want, got)
-	}
-}
-
-func TestEmitMetadataBlockWithAuth(t *testing.T) {
-	want := "metadata:\n" +
-		"  openclaw:\n" +
-		"    requires:\n" +
-		"      bins:\n" +
-		"        - foo-pp-cli\n" +
-		"      env:\n" +
-		"        - FOO_TOKEN\n" +
-		"        - FOO_OPTIONAL\n" +
-		"    primaryEnv: FOO_TOKEN\n" +
-		"    install:\n" +
-		"      - kind: go\n" +
-		"        bins: [foo-pp-cli]\n" +
-		"        module: github.com/example/foo/cmd/foo-pp-cli\n"
-	got := emitMetadataBlock("foo-pp-cli", "github.com/example/foo/cmd/foo-pp-cli",
-		[]string{"FOO_TOKEN", "FOO_OPTIONAL"}, "FOO_TOKEN")
-	if got != want {
-		t.Errorf("emitMetadataBlock api_key mismatch:\nwant:\n%s\ngot:\n%s", want, got)
-	}
-}
-
-func TestBuildMetadataBlockDefaultsCategoryToOther(t *testing.T) {
-	got := buildMetadataBlock("foo-pp-cli", "", "foo", nil, "")
-	if !strings.Contains(got, "library/other/foo/cmd/foo-pp-cli") {
-		t.Errorf("expected fallback to 'other' category in module path; got:\n%s", got)
-	}
-}
-
-func TestBuildMetadataBlockWithCategoryAndSlugDir(t *testing.T) {
-	// Slug-only directory convention: directory is "dub", binary is "dub-pp-cli".
-	got := buildMetadataBlock("dub-pp-cli", "marketing", "dub", nil, "")
-	if !strings.Contains(got, "library/marketing/dub/cmd/dub-pp-cli") {
-		t.Errorf("expected slug-only directory in module path; got:\n%s", got)
-	}
-}
-
-func TestBuildMetadataBlockBinarySuffixDirConvention(t *testing.T) {
-	// Older binary-suffix convention: directory is "fedex-pp-cli".
-	got := buildMetadataBlock("fedex-pp-cli", "commerce", "fedex-pp-cli", nil, "")
-	if !strings.Contains(got, "library/commerce/fedex-pp-cli/cmd/fedex-pp-cli") {
-		t.Errorf("expected binary-suffix directory in module path; got:\n%s", got)
-	}
-}
-
-func TestBuildMetadataBlockEmptyDirNameFallsBackToCliName(t *testing.T) {
-	// Defensive fallback when caller passes empty dirName.
-	got := buildMetadataBlock("x-pp-cli", "other", "", nil, "")
-	if !strings.Contains(got, "library/other/x-pp-cli/cmd/x-pp-cli") {
-		t.Errorf("expected fallback to cliName when dirName empty; got:\n%s", got)
-	}
-}
-
-// --- parseLegacyOpenclawJSON tests ---
-
-func TestParseLegacyOpenclawJSON_NoAuth(t *testing.T) {
-	jsonStr := `{"openclaw":{"requires":{"bins":["dub-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/x/y/cmd/dub-pp-cli@latest","bins":["dub-pp-cli"],"label":"Install via go install"}]}}`
-	env, primaryEnv, err := parseLegacyOpenclawJSON(jsonStr)
-	if err != nil {
-		t.Fatalf("unexpected error: %v", err)
-	}
-	if len(env) != 0 {
-		t.Errorf("expected no env, got %v", env)
-	}
-	if primaryEnv != "" {
-		t.Errorf("expected empty primaryEnv, got %q", primaryEnv)
-	}
-}
-
-func TestParseLegacyOpenclawJSON_WithAuthEnv(t *testing.T) {
-	jsonStr := `{"openclaw":{"requires":{"bins":["kalshi-pp-cli"],"env":["KALSHI_TOKEN","KALSHI_KEY"]},"primaryEnv":"KALSHI_TOKEN","install":[{"id":"go","kind":"shell","command":"go install x@latest","bins":["kalshi-pp-cli"]}]}}`
-	env, primaryEnv, err := parseLegacyOpenclawJSON(jsonStr)
-	if err != nil {
-		t.Fatalf("unexpected error: %v", err)
-	}
-	want := []string{"KALSHI_TOKEN", "KALSHI_KEY"}
-	if len(env) != 2 || env[0] != want[0] || env[1] != want[1] {
-		t.Errorf("env mismatch: got %v want %v", env, want)
-	}
-	if primaryEnv != "KALSHI_TOKEN" {
-		t.Errorf("primaryEnv mismatch: got %q", primaryEnv)
-	}
-}
-
-func TestParseLegacyOpenclawJSON_MalformedJSON(t *testing.T) {
-	_, _, err := parseLegacyOpenclawJSON(`{not valid json`)
-	if err == nil {
-		t.Fatalf("expected error on malformed JSON")
-	}
-}
-
-// --- frontmatterBounds tests ---
-
-func TestFrontmatterBounds(t *testing.T) {
-	content := []byte("---\nname: foo\ndescription: bar\n---\n\nbody")
-	start, end, err := frontmatterBounds(content)
-	if err != nil {
-		t.Fatalf("unexpected error: %v", err)
-	}
-	got := string(content[start:end])
-	want := "name: foo\ndescription: bar\n"
-	if got != want {
-		t.Errorf("frontmatter region mismatch:\nwant: %q\ngot:  %q", want, got)
-	}
-}
-
-func TestFrontmatterBoundsRejectsMissingOpening(t *testing.T) {
-	_, _, err := frontmatterBounds([]byte("name: foo\n---\nbody"))
-	if err == nil {
-		t.Fatalf("expected error on missing opening delimiter")
-	}
-}
-
-func TestFrontmatterBoundsRejectsMissingClosing(t *testing.T) {
-	_, _, err := frontmatterBounds([]byte("---\nname: foo\nbody"))
-	if err == nil {
-		t.Fatalf("expected error on missing closing delimiter")
-	}
-}
-
-// --- migrateFile end-to-end tests ---
-
-func TestMigrateFile_LegacyJSONStringConversion(t *testing.T) {
-	root := t.TempDir()
-	dir := filepath.Join(root, "library", "other", "dub")
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	if err := os.WriteFile(dir+"/.printing-press.json",
-		[]byte(`{"cli_name":"dub-pp-cli","category":"other"}`), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	skill := dir + "/SKILL.md"
-	original := `---
-name: pp-dub
-description: "Dub CLI."
-argument-hint: "<command> [args] | install cli|mcp"
-allowed-tools: "Read Bash"
-metadata: '{"openclaw":{"requires":{"bins":["dub-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/mvanhorn/printing-press-library/library/other/dub/cmd/dub-pp-cli@latest","bins":["dub-pp-cli"],"label":"Install via go install"}]}}'
----
-
-# Body content with backticks ` + "`x`" + ` and quotes "y" stays untouched.
-`
-	if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	action, err := migrateFile(skill, root, false)
-	if err != nil {
-		t.Fatalf("migrateFile error: %v", err)
-	}
-	if action != "migrated" {
-		t.Errorf("expected action=migrated, got %s", action)
-	}
-	got, err := os.ReadFile(skill)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if !strings.Contains(string(got), "kind: go") {
-		t.Error("migrated file should contain kind: go")
-	}
-	if strings.Contains(string(got), `'{"openclaw":`) {
-		t.Error("migrated file should no longer contain JSON-string metadata")
-	}
-	// Body byte-equal: every line of the original after the metadata line
-	// must still be present verbatim.
-	if !strings.Contains(string(got), "# Body content with backticks `x` and quotes \"y\" stays untouched.") {
-		t.Error("body content with special chars should be preserved byte-equal")
-	}
-	// The other frontmatter fields stay byte-equal too.
-	if !strings.Contains(string(got), `description: "Dub CLI."`) {
-		t.Error("description with double quotes should be preserved byte-equal")
-	}
-}
-
-func TestMigrateFile_Idempotent(t *testing.T) {
-	root := t.TempDir()
-	dir := filepath.Join(root, "library", "other", "dub")
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	skill := dir + "/SKILL.md"
-	alreadyMigrated := `---
-name: pp-dub
-description: "Dub CLI."
-argument-hint: "<command> [args]"
-allowed-tools: "Read Bash"
-metadata:
-  openclaw:
-    requires:
-      bins:
-        - dub-pp-cli
-    install:
-      - kind: go
-        bins: [dub-pp-cli]
-        module: github.com/x/y/cmd/dub-pp-cli
----
-
-body
-`
-	if err := os.WriteFile(skill, []byte(alreadyMigrated), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	action, err := migrateFile(skill, root, false)
-	if err != nil {
-		t.Fatalf("migrateFile error: %v", err)
-	}
-	if action != "skipped" {
-		t.Errorf("expected action=skipped on already-nested file, got %s", action)
-	}
-	got, _ := os.ReadFile(skill)
-	if string(got) != alreadyMigrated {
-		t.Error("file should be byte-identical after idempotent skip")
-	}
-}
-
-func TestMigrateFile_DryRunDoesNotWrite(t *testing.T) {
-	root := t.TempDir()
-	dir := filepath.Join(root, "library", "other", "dub")
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	if err := os.WriteFile(dir+"/.printing-press.json",
-		[]byte(`{"cli_name":"dub-pp-cli","category":"other"}`), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	skill := dir + "/SKILL.md"
-	original := `---
-name: pp-dub
-description: "x"
-argument-hint: "<command>"
-allowed-tools: "Read Bash"
-metadata: '{"openclaw":{"requires":{"bins":["dub-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/x/y/cmd/dub-pp-cli@latest","bins":["dub-pp-cli"],"label":"Install via go install"}]}}'
----
-
-body
-`
-	if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	action, err := migrateFile(skill, root, true) // dryRun=true
-	if err != nil {
-		t.Fatalf("migrateFile error: %v", err)
-	}
-	if action != "migrated" {
-		t.Errorf("expected action=migrated even in dry-run, got %s", action)
-	}
-	got, _ := os.ReadFile(skill)
-	if string(got) != original {
-		t.Error("dry-run should not modify the file")
-	}
-}
-
-func TestMigrateFile_SynthesisPath(t *testing.T) {
-	root := t.TempDir()
-	libDir := filepath.Join(root, "library", "commerce", "instacart")
-	if err := os.MkdirAll(libDir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	provenance := `{"cli_name": "instacart-pp-cli", "category": "commerce"}`
-	if err := os.WriteFile(libDir+"/.printing-press.json", []byte(provenance), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	original := `---
-name: pp-instacart
-description: "Instacart CLI."
-argument-hint: "<command>"
-allowed-tools: "Read Bash"
----
-
-body
-`
-	skill := libDir + "/SKILL.md"
-	if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	action, err := migrateFile(skill, root, false)
-	if err != nil {
-		t.Fatalf("migrateFile error: %v", err)
-	}
-	if action != "synthesized" {
-		t.Errorf("expected action=synthesized, got %s", action)
-	}
-	got, _ := os.ReadFile(skill)
-	if !strings.Contains(string(got), "metadata:\n  openclaw:") {
-		t.Error("synthesized file should have nested metadata block")
-	}
-	// Module path uses the slug-only directory ("instacart"), not the cli_name.
-	if !strings.Contains(string(got), "module: github.com/mvanhorn/printing-press-library/library/commerce/instacart/cmd/instacart-pp-cli") {
-		t.Errorf("synthesized module path wrong; got:\n%s", string(got))
-	}
-	if !strings.Contains(string(got), "name: pp-instacart") {
-		t.Error("original frontmatter fields should be preserved")
-	}
-	if !strings.Contains(string(got), "\n\nbody\n") {
-		t.Error("body content should be preserved")
-	}
-}
-
-func TestMigrateFile_SynthesisCliSkillsMirror(t *testing.T) {
-	root := t.TempDir()
-	// Create the library entry that the cli-skills mirror points back to.
-	libDir := filepath.Join(root, "library", "commerce", "instacart")
-	if err := os.MkdirAll(libDir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	provenance := `{"cli_name": "instacart-pp-cli", "category": "commerce"}`
-	if err := os.WriteFile(libDir+"/.printing-press.json", []byte(provenance), 0o644); err != nil {
-		t.Fatal(err)
-	}
-
-	mirrorDir := filepath.Join(root, "cli-skills", "pp-instacart")
-	if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	original := `---
-name: pp-instacart
-description: "Instacart CLI mirror."
-argument-hint: "<command>"
-allowed-tools: "Read Bash"
----
-
-body
-`
-	skill := mirrorDir + "/SKILL.md"
-	if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	action, err := migrateFile(skill, root, false)
-	if err != nil {
-		t.Fatalf("migrateFile error: %v", err)
-	}
-	if action != "synthesized" {
-		t.Errorf("expected action=synthesized for cli-skills mirror, got %s", action)
-	}
-	got, _ := os.ReadFile(skill)
-	// Module path uses the library directory basename ("instacart"), not the cli_name or pp-instacart.
-	if !strings.Contains(string(got), "module: github.com/mvanhorn/printing-press-library/library/commerce/instacart/cmd/instacart-pp-cli") {
-		t.Errorf("mirror synthesis should resolve via library lookup; got:\n%s", string(got))
-	}
-}
-
-func TestMigrateFile_SynthesisFailsWithoutProvenance(t *testing.T) {
-	root := t.TempDir()
-	libDir := filepath.Join(root, "library", "commerce", "orphan")
-	if err := os.MkdirAll(libDir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	original := `---
-name: pp-orphan
-description: "no metadata, no provenance"
-argument-hint: "<command>"
-allowed-tools: "Read Bash"
----
-
-body
-`
-	skill := libDir + "/SKILL.md"
-	if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	_, err := migrateFile(skill, root, false)
-	if err == nil {
-		t.Fatalf("expected error when synthesis cannot find provenance")
-	}
-}
-
-// TestMigrateFile_RerouteCorrectsStalePath confirms that when the legacy
-// JSON's command field points at a stale path (e.g., a CLI moved between
-// categories without its SKILL.md being updated), the migrated module
-// reflects the file's actual filesystem location, not the stale path.
-func TestMigrateFile_RerouteCorrectsStalePath(t *testing.T) {
-	root := t.TempDir()
-	// CLI lives under library/payments/kalshi but the legacy JSON's
-	// command points at library/other/kalshi (stale category).
-	dir := filepath.Join(root, "library", "payments", "kalshi")
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	if err := os.WriteFile(dir+"/.printing-press.json",
-		[]byte(`{"cli_name":"kalshi-pp-cli","category":"payments"}`), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	skill := dir + "/SKILL.md"
-	original := `---
-name: pp-kalshi
-description: "Kalshi CLI."
-argument-hint: "<command>"
-allowed-tools: "Read Bash"
-metadata: '{"openclaw":{"requires":{"bins":["kalshi-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/mvanhorn/printing-press-library/library/other/kalshi/cmd/kalshi-pp-cli@latest","bins":["kalshi-pp-cli"],"label":"Install via go install"}]}}'
----
-
-body
-`
-	if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	if _, err := migrateFile(skill, root, false); err != nil {
-		t.Fatalf("migrateFile error: %v", err)
-	}
-	got, _ := os.ReadFile(skill)
-	if !strings.Contains(string(got),
-		"module: github.com/mvanhorn/printing-press-library/library/payments/kalshi/cmd/kalshi-pp-cli") {
-		t.Errorf("rerouted module path missing; got:\n%s", string(got))
-	}
-	if strings.Contains(string(got), "library/other/kalshi") {
-		t.Errorf("stale path should have been replaced; got:\n%s", string(got))
-	}
-}
-
-// TestRun_RejectsSymlinkEscapingRoot covers the path-traversal guard. A
-// SKILL.md inside the root that's a symlink to a file outside the root
-// must fail the EvalSymlinks-then-prefix check. This test fixture also
-// exercises the run() orchestrator's error-aggregation path.
-func TestRun_RejectsSymlinkEscapingRoot(t *testing.T) {
-	root := t.TempDir()
-	outside := t.TempDir()
-	outsideSkill := outside + "/SKILL.md"
-	if err := os.WriteFile(outsideSkill, []byte("---\nname: out\n---\nbody\n"), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	libDir := filepath.Join(root, "library", "evil", "x")
-	if err := os.MkdirAll(libDir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	insideSymlink := libDir + "/SKILL.md"
-	if err := os.Symlink(outsideSkill, insideSymlink); err != nil {
-		t.Skipf("symlink not supported on this filesystem: %v", err)
-	}
-
-	report, err := run(root, true, false)
-	if err != nil {
-		t.Fatalf("run returned error: %v", err)
-	}
-	if report.errored == 0 {
-		t.Errorf("expected at least one errored file, got none")
-	}
-	foundEscape := false
-	for _, msg := range report.errors {
-		if strings.Contains(msg, "escapes root") {
-			foundEscape = true
-			break
-		}
-	}
-	if !foundEscape {
-		t.Errorf("expected an 'escapes root' error in report; got: %v", report.errors)
-	}
-}
-
-func TestMigrateFile_BodyJSONShapeIsNotTouched(t *testing.T) {
-	// Negative-space test: body content contains JSON-shaped strings; only
-	// the frontmatter `metadata:` line should be touched.
-	root := t.TempDir()
-	dir := filepath.Join(root, "library", "other", "x")
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	if err := os.WriteFile(dir+"/.printing-press.json",
-		[]byte(`{"cli_name":"x-pp-cli","category":"other"}`), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	skill := dir + "/SKILL.md"
-	original := `---
-name: pp-x
-description: "x"
-argument-hint: "<command>"
-allowed-tools: "Read Bash"
-metadata: '{"openclaw":{"requires":{"bins":["x-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/x/y/cmd/x-pp-cli@latest","bins":["x-pp-cli"],"label":"Install via go install"}]}}'
----
-
-# A recipe block
-
-` + "```bash" + `
-echo '{"foo": "bar", "kind": "shell"}'
-` + "```" + `
-
-End.
-`
-	if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	if _, err := migrateFile(skill, root, false); err != nil {
-		t.Fatalf("migrateFile error: %v", err)
-	}
-	got, _ := os.ReadFile(skill)
-	// The body's `kind: shell` (inside the bash block) must NOT be changed.
-	if !strings.Contains(string(got), `"kind": "shell"`) {
-		t.Error("body's literal JSON shape must be preserved byte-equal; do not touch body content")
-	}
-	// The frontmatter must use kind: go now.
-	if !strings.Contains(string(got), "      - kind: go\n") {
-		t.Error("frontmatter should be migrated")
-	}
-}

← c7574bd4 fix(dogfood): accept quick live passes with skips (#646)  ·  back to Cli Printing Press  ·  fix(cli): omit version field from SKILL.md frontmatter (#656 e6aa0326 →