[object Object]

← back to Cli Printing Press

feat(cli,skills): three retro WUs from postman-explore — POST-as-query detection, novel-command --select, scorecard YAML (#424)

8ceea6ad65a0b27784f0134e20cb55827e21b645 · 2026-04-30 01:53:52 -0700 · Trevin Chow

* docs: plan three retro WUs from postman-explore session

Plans implementation of three Printing Press defects surfaced in
retro #423: scorecard YAML support (U1), novel-command --select
plumbing (U2), and POST-as-query detection (U3). Acceptance examples
carry verbatim from retro AE1-AE4.

* fix(cli): scorecard accepts OpenAPI YAML specs

loadOpenAPISpec previously read the file, ran an internal-YAML detector,
then fell through to json.Unmarshal — rejecting OpenAPI YAML files even
though OpenAPI YAML is the more common format on apis-guru and swagger.io.
Required users to convert YAML→JSON before scoring.

Adds a content-sniffing branch: trim leading whitespace, check the first
byte. `{` routes to the existing JSON path (preserving its error message);
anything else routes to a new `yaml.Unmarshal` branch with a YAML-specific
error message. Keeps the two branches separate so a malformed JSON spec
reports as 'parsing spec JSON' and a malformed YAML spec reports as
'parsing OpenAPI YAML spec'.

Six new test cases under TestLoadOpenAPISpec_OpenAPIYAML cover the YAML
branch happy path, JSON/YAML equivalence, format-specific error messages,
the internal-YAML short-circuit (still works), and leading-whitespace
detection.

From retro #423 (WU-3 / F4).

* feat(cli,skills): novel commands honor --select and --compact

The audit in retro #423 confirmed hand-written novel commands across the
library silently drop --select and --compact: recipe-goat (14 files),
dub (16), espn (1), yahoo-finance (2), postman-explore (was 8 before
session-time fix). Agents kept hitting the trap because the SKILL never
named the right helper.

Two parts:

1. Generator emits printJSONFiltered into every CLI's
   internal/cli/helpers.go alongside the existing filterFields and
   compactFields. The helper marshals the typed value, applies
   filterFields when --select is set or compactFields when --compact is
   set, then prints with two-space indent.

2. SKILL Phase 3 Agent Build Checklist gains principle 11 documenting
   the helper as the required path for novel commands' JSON output.
   Principle 2 also gets a one-line cross-reference. The header bumped
   from '9 principles' (already stale — 10 are present) to '11 principles'.

A regression test in internal/generator/print_json_filtered_test.go
builds a fixture CLI and asserts the helper is emitted with the right
signature, the --select branch, and the --compact branch.

Existing endpoint-mirror commands stay on printOutputWithFlags
(unchanged); only hand-written novel commands need the new helper.
Backfilling existing library CLIs is deferred to /printing-press-polish
on next invocation per CLI.

From retro #423 (WU-2 / F3).

* fix(cli): generator-aware POST-as-query detection

Two coordinated changes resolve retro #423 F1 + F2.

F1 — methodIsWrite was a pure HTTP-verb classifier: POST/PUT/PATCH/DELETE
all returned true. APIs that use POST for queries (Postman /search-all,
GraphQL POST /graphql, RPC-style search APIs) got the wrong README
boilerplate (Retryable / Confirmable / Piped-input bullets) because
hasWriteCommands flipped true.

F2 — command_promoted.go.tmpl hardcoded c.Get(path, params) for every
promoted endpoint. Promoting a POST-only endpoint produced a runtime
HTTP 400 from the wrong-verb mismatch (postman-explore search-all
shipped Hidden:true as a per-CLI workaround).

Fix:

- New endpointIsWriteCommand(endpoint, name) bool in generator.go
  combines four signals to classify writes correctly:
    1. mcp:read-only annotation (highest precedence)
    2. HTTP verb (GET/HEAD/OPTIONS always read)
    3. operationId prefix (get/list/search/find/query/count/describe/fetch)
    4. body shape (filter-only params signal a query, not a mutation)

- resourceHasWriteCommand routes through endpointIsWriteCommand. The
  old methodIsWrite stays as a thin verb-only fallback for callers that
  don't have an Endpoint in hand.

- command_promoted.go.tmpl gains a verb branch in the no-store fallback:
  GET keeps c.Get(path, params) byte-identically; DELETE emits c.Delete;
  POST/PUT/PATCH emit c.{Method}(path, params) so the proxy receives the
  correct verb. Body-flag plumbing for promoted shortcuts is tracked as
  a follow-up; users with rich body shapes use the typed
  '<resource> <endpoint>' command.

- HasStore + non-GET still routes through resolveRead (which is GET-only
  internally) — the resolver doesn't have a POST-aware variant yet, and
  building one is a separate concern. The template carries a comment
  documenting this scope explicitly.

Tests:

- TestEndpointIsWriteCommand covers 19 classification cases including
  GraphQL queries, Postman /search-all, fetch/find/count/describe-style
  reads, genuine POST creates/PUT updates/PATCHes/DELETEs, the
  fail-closed default, mcp:read-only short-circuit, case-insensitive
  prefix matching, and mixed filter+write body shapes.

- TestHasWriteCommands_PostAsQueryFlipsHasWriteFalse and
  TestHasWriteCommands_GenuineMutationsStayTrue are the integration
  guards confirming the classifier propagates through to the
  HasWriteCommands template signal.

- TestPromotedCommand_PostEndpointEmitsPost builds a fixture CLI with
  a single POST endpoint and asserts the generated promoted command
  contains c.Post(. TestPromotedCommand_GetEndpointStillEmitsGet is
  the negative guard ensuring GET endpoints don't suddenly emit POST.

Smoke against postman-explore: regenerated README now emits 'Read-only
by default' instead of the Retryable/Confirmable/Piped-input bullets.

From retro #423 (WU-1 / F1 + F2).

* refactor(cli,skills): simplify retro-WU helpers per /simplify review

Six surface-level cleanups, no behavioral change:

- printJSONFiltered now composes printOutputWithFlags instead of
  re-implementing the --select / --compact branching. Novel commands
  pick up --csv and --quiet for free, and the helper stays aligned with
  the endpoint-mirror path automatically as printOutputWithFlags evolves.
  Signature simplifies from interface{ OutOrStdout() io.Writer } to
  io.Writer, matching the helper it now wraps.

- endpointIsWriteCommand factored into a body-shape sub-helper
  (bodyIsAllFilterShape) and trailing if/return collapsed to a single
  return. Doc comments tightened to describe behavior, not narrate the
  task that produced them.

- Two near-duplicate promoted-command tests (POST and GET) merged into
  one table-driven TestPromotedCommandVerbBranching with a glob-based
  promoted-file lookup. The candidate-path search loop is gone.

- print_json_filtered_test asserts the helper delegates to
  printOutputWithFlags rather than asserting on filterFields/compactFields
  literals — drift-resistant when printOutputWithFlags evolves.

- SKILL principle 2 trimmed to a one-line cross-reference; principle 11
  is the single source of truth for the helper rule.

- Comments throughout the new code stripped of retro/incident references
  and 'previously this was broken' narration per AGENTS.md hygiene rules.

All tests still green, golangci-lint reports 0 issues.

* refactor(cli,skills): apply ce-code-review safe fixes

Fixes from ce-code-review on PR #424:

- HEAD/OPTIONS verb fallback in command_promoted.go.tmpl so unknown HTTP
  verbs route to c.Get instead of emitting an undefined c.Head/c.Options.
- Empty / whitespace-only spec files now return an explicit "empty" error
  from loadOpenAPISpec instead of falling through to a confusing parse
  failure.
- endpointIsWriteCommand switched from strings.HasPrefix to whole-word
  camelCase token matching with a write-verb override on subsequent
  tokens, so getOrCreate/fetchAndUpdate stay writes and "getter" no
  longer false-positives as a read.
- UTF-8 BOM stripping at the top of loadOpenAPISpec so editors that emit
  one don't break content sniffing or json.Unmarshal.
- methodIsWrite doc comment trimmed; SKILL principles 2 and 11 folded
  into a single principle (10 total).

Test coverage: getOrCreate/fetchAndUpdate/listAndDelete write-override
cases, "getter" whole-word non-match, HEAD-falls-back-to-Get template
branch, empty-file / whitespace-only / BOM-prefixed-JSON loader paths.
2436 tests across 28 packages pass.

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

---------

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

Files touched

Diff

commit 8ceea6ad65a0b27784f0134e20cb55827e21b645
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Thu Apr 30 01:53:52 2026 -0700

    feat(cli,skills): three retro WUs from postman-explore — POST-as-query detection, novel-command --select, scorecard YAML (#424)
    
    * docs: plan three retro WUs from postman-explore session
    
    Plans implementation of three Printing Press defects surfaced in
    retro #423: scorecard YAML support (U1), novel-command --select
    plumbing (U2), and POST-as-query detection (U3). Acceptance examples
    carry verbatim from retro AE1-AE4.
    
    * fix(cli): scorecard accepts OpenAPI YAML specs
    
    loadOpenAPISpec previously read the file, ran an internal-YAML detector,
    then fell through to json.Unmarshal — rejecting OpenAPI YAML files even
    though OpenAPI YAML is the more common format on apis-guru and swagger.io.
    Required users to convert YAML→JSON before scoring.
    
    Adds a content-sniffing branch: trim leading whitespace, check the first
    byte. `{` routes to the existing JSON path (preserving its error message);
    anything else routes to a new `yaml.Unmarshal` branch with a YAML-specific
    error message. Keeps the two branches separate so a malformed JSON spec
    reports as 'parsing spec JSON' and a malformed YAML spec reports as
    'parsing OpenAPI YAML spec'.
    
    Six new test cases under TestLoadOpenAPISpec_OpenAPIYAML cover the YAML
    branch happy path, JSON/YAML equivalence, format-specific error messages,
    the internal-YAML short-circuit (still works), and leading-whitespace
    detection.
    
    From retro #423 (WU-3 / F4).
    
    * feat(cli,skills): novel commands honor --select and --compact
    
    The audit in retro #423 confirmed hand-written novel commands across the
    library silently drop --select and --compact: recipe-goat (14 files),
    dub (16), espn (1), yahoo-finance (2), postman-explore (was 8 before
    session-time fix). Agents kept hitting the trap because the SKILL never
    named the right helper.
    
    Two parts:
    
    1. Generator emits printJSONFiltered into every CLI's
       internal/cli/helpers.go alongside the existing filterFields and
       compactFields. The helper marshals the typed value, applies
       filterFields when --select is set or compactFields when --compact is
       set, then prints with two-space indent.
    
    2. SKILL Phase 3 Agent Build Checklist gains principle 11 documenting
       the helper as the required path for novel commands' JSON output.
       Principle 2 also gets a one-line cross-reference. The header bumped
       from '9 principles' (already stale — 10 are present) to '11 principles'.
    
    A regression test in internal/generator/print_json_filtered_test.go
    builds a fixture CLI and asserts the helper is emitted with the right
    signature, the --select branch, and the --compact branch.
    
    Existing endpoint-mirror commands stay on printOutputWithFlags
    (unchanged); only hand-written novel commands need the new helper.
    Backfilling existing library CLIs is deferred to /printing-press-polish
    on next invocation per CLI.
    
    From retro #423 (WU-2 / F3).
    
    * fix(cli): generator-aware POST-as-query detection
    
    Two coordinated changes resolve retro #423 F1 + F2.
    
    F1 — methodIsWrite was a pure HTTP-verb classifier: POST/PUT/PATCH/DELETE
    all returned true. APIs that use POST for queries (Postman /search-all,
    GraphQL POST /graphql, RPC-style search APIs) got the wrong README
    boilerplate (Retryable / Confirmable / Piped-input bullets) because
    hasWriteCommands flipped true.
    
    F2 — command_promoted.go.tmpl hardcoded c.Get(path, params) for every
    promoted endpoint. Promoting a POST-only endpoint produced a runtime
    HTTP 400 from the wrong-verb mismatch (postman-explore search-all
    shipped Hidden:true as a per-CLI workaround).
    
    Fix:
    
    - New endpointIsWriteCommand(endpoint, name) bool in generator.go
      combines four signals to classify writes correctly:
        1. mcp:read-only annotation (highest precedence)
        2. HTTP verb (GET/HEAD/OPTIONS always read)
        3. operationId prefix (get/list/search/find/query/count/describe/fetch)
        4. body shape (filter-only params signal a query, not a mutation)
    
    - resourceHasWriteCommand routes through endpointIsWriteCommand. The
      old methodIsWrite stays as a thin verb-only fallback for callers that
      don't have an Endpoint in hand.
    
    - command_promoted.go.tmpl gains a verb branch in the no-store fallback:
      GET keeps c.Get(path, params) byte-identically; DELETE emits c.Delete;
      POST/PUT/PATCH emit c.{Method}(path, params) so the proxy receives the
      correct verb. Body-flag plumbing for promoted shortcuts is tracked as
      a follow-up; users with rich body shapes use the typed
      '<resource> <endpoint>' command.
    
    - HasStore + non-GET still routes through resolveRead (which is GET-only
      internally) — the resolver doesn't have a POST-aware variant yet, and
      building one is a separate concern. The template carries a comment
      documenting this scope explicitly.
    
    Tests:
    
    - TestEndpointIsWriteCommand covers 19 classification cases including
      GraphQL queries, Postman /search-all, fetch/find/count/describe-style
      reads, genuine POST creates/PUT updates/PATCHes/DELETEs, the
      fail-closed default, mcp:read-only short-circuit, case-insensitive
      prefix matching, and mixed filter+write body shapes.
    
    - TestHasWriteCommands_PostAsQueryFlipsHasWriteFalse and
      TestHasWriteCommands_GenuineMutationsStayTrue are the integration
      guards confirming the classifier propagates through to the
      HasWriteCommands template signal.
    
    - TestPromotedCommand_PostEndpointEmitsPost builds a fixture CLI with
      a single POST endpoint and asserts the generated promoted command
      contains c.Post(. TestPromotedCommand_GetEndpointStillEmitsGet is
      the negative guard ensuring GET endpoints don't suddenly emit POST.
    
    Smoke against postman-explore: regenerated README now emits 'Read-only
    by default' instead of the Retryable/Confirmable/Piped-input bullets.
    
    From retro #423 (WU-1 / F1 + F2).
    
    * refactor(cli,skills): simplify retro-WU helpers per /simplify review
    
    Six surface-level cleanups, no behavioral change:
    
    - printJSONFiltered now composes printOutputWithFlags instead of
      re-implementing the --select / --compact branching. Novel commands
      pick up --csv and --quiet for free, and the helper stays aligned with
      the endpoint-mirror path automatically as printOutputWithFlags evolves.
      Signature simplifies from interface{ OutOrStdout() io.Writer } to
      io.Writer, matching the helper it now wraps.
    
    - endpointIsWriteCommand factored into a body-shape sub-helper
      (bodyIsAllFilterShape) and trailing if/return collapsed to a single
      return. Doc comments tightened to describe behavior, not narrate the
      task that produced them.
    
    - Two near-duplicate promoted-command tests (POST and GET) merged into
      one table-driven TestPromotedCommandVerbBranching with a glob-based
      promoted-file lookup. The candidate-path search loop is gone.
    
    - print_json_filtered_test asserts the helper delegates to
      printOutputWithFlags rather than asserting on filterFields/compactFields
      literals — drift-resistant when printOutputWithFlags evolves.
    
    - SKILL principle 2 trimmed to a one-line cross-reference; principle 11
      is the single source of truth for the helper rule.
    
    - Comments throughout the new code stripped of retro/incident references
      and 'previously this was broken' narration per AGENTS.md hygiene rules.
    
    All tests still green, golangci-lint reports 0 issues.
    
    * refactor(cli,skills): apply ce-code-review safe fixes
    
    Fixes from ce-code-review on PR #424:
    
    - HEAD/OPTIONS verb fallback in command_promoted.go.tmpl so unknown HTTP
      verbs route to c.Get instead of emitting an undefined c.Head/c.Options.
    - Empty / whitespace-only spec files now return an explicit "empty" error
      from loadOpenAPISpec instead of falling through to a confusing parse
      failure.
    - endpointIsWriteCommand switched from strings.HasPrefix to whole-word
      camelCase token matching with a write-verb override on subsequent
      tokens, so getOrCreate/fetchAndUpdate stay writes and "getter" no
      longer false-positives as a read.
    - UTF-8 BOM stripping at the top of loadOpenAPISpec so editors that emit
      one don't break content sniffing or json.Unmarshal.
    - methodIsWrite doc comment trimmed; SKILL principles 2 and 11 folded
      into a single principle (10 total).
    
    Test coverage: getOrCreate/fetchAndUpdate/listAndDelete write-override
    cases, "getter" whole-word non-match, HEAD-falls-back-to-Get template
    branch, empty-file / whitespace-only / BOM-prefixed-JSON loader paths.
    2436 tests across 28 packages pass.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .../2026-04-30-001-feat-postman-retro-wus-plan.md  | 316 ++++++++++++++++++
 internal/generator/endpoint_is_write_test.go       | 371 +++++++++++++++++++++
 internal/generator/generator.go                    | 120 ++++++-
 internal/generator/print_json_filtered_test.go     |  33 ++
 .../generator/templates/command_promoted.go.tmpl   |  15 +
 internal/generator/templates/helpers.go.tmpl       |  12 +
 internal/pipeline/scorecard.go                     |  25 +-
 internal/pipeline/scorecard_tier2_test.go          | 174 ++++++++++
 skills/printing-press/SKILL.md                     |   4 +-
 9 files changed, 1064 insertions(+), 6 deletions(-)

diff --git a/docs/plans/2026-04-30-001-feat-postman-retro-wus-plan.md b/docs/plans/2026-04-30-001-feat-postman-retro-wus-plan.md
new file mode 100644
index 00000000..197d0a50
--- /dev/null
+++ b/docs/plans/2026-04-30-001-feat-postman-retro-wus-plan.md
@@ -0,0 +1,316 @@
+---
+title: "feat: Three retro work units from postman-explore — POST-as-query detection, novel-command --select, scorecard YAML"
+type: feat
+status: active
+date: 2026-04-30
+origin: https://github.com/mvanhorn/cli-printing-press/issues/423
+---
+
+# feat: Three retro work units from postman-explore — POST-as-query detection, novel-command --select, scorecard YAML
+
+## Summary
+
+Implement three of the eight work units filed in retro issue #423 from the postman-explore generation session — the small/medium complexity wins with broad cross-CLI impact. Land them in dependency-light order (scorecard YAML → novel-command `--select` → POST-as-query detection) so each PR is reviewable on its own and the higher-blast-radius change comes last when the easier wins have already validated the testing posture.
+
+---
+
+## Problem Frame
+
+Three independent Printing Press defects surfaced during the postman-explore v3 regeneration. Each one was confirmed via cross-API audit to affect more than just postman-explore:
+
+- **POST-as-query is misclassified as a write across the generator.** Every API with a `POST /search`-style endpoint, every GraphQL CLI, and every proxy-envelope CLI gets the wrong README boilerplate, and any promoted POST endpoint additionally fails at runtime because the promoted-command template hardcodes `c.Get(...)`.
+- **Hand-written novel commands silently drop `--select` and `--compact`.** Confirmed broken in recipe-goat (14 files), dub (16), espn (1), yahoo-finance (2), and postman-explore (was 8 before the session-time fix). Agents keep falling into this trap because the SKILL never tells them how to plumb the filters through.
+- **`scorecard --spec foo.yaml` rejects OpenAPI YAML.** Forces every user with a YAML spec to convert to JSON before scoring. Most OpenAPI specs in the wild are YAML.
+
+The retro classifies F1/F2 (POST-as-query) and F3 (novel-command `--select`) as P1 because the audit named three or more concrete APIs that would benefit. F4 (scorecard YAML) is P2.
+
+---
+
+## Requirements
+
+- **R1.** `printing-press scorecard --spec foo.yaml` works for OpenAPI 3.x YAML files without conversion. (Origin: retro F4 / WU-3.)
+- **R2.** Hand-written novel commands honor `--select` and `--compact` for JSON output via a generator-emitted helper. The Phase 3 SKILL build instructions require novel commands to use the helper. (Origin: retro F3 / WU-2.)
+- **R3.** `methodIsWrite` (or its replacement) returns `false` for POST endpoints that are semantically queries — search, GraphQL, RPC-style — based on operation-id prefix, request-body shape, or annotations. (Origin: retro F1 / WU-1.)
+- **R4.** Promoted endpoints emit the correct HTTP verb in the generated command. POST-only endpoints that get promoted run successfully against the live endpoint. (Origin: retro F2 / WU-1.)
+
+**Origin actors:** the agent running `/printing-press` for any future API; the agent running `/printing-press-polish` against any existing CLI; users who pass an OpenAPI YAML spec to `scorecard` directly.
+
+**Origin acceptance examples** (carried verbatim from retro):
+- AE1 (covers R1): `scorecard --dir <cli> --spec foo.yaml` against an OpenAPI 3.x YAML spec returns scores byte-equivalent to running it against the same spec converted to JSON. Existing JSON specs and internal-YAML specs continue to work byte-identically.
+- AE2 (covers R2): a fresh CLI with a hand-written novel command running `<cli> <novel> --json --select foo,bar` returns only `foo` and `bar` fields. Endpoint-mirror commands continue to apply filters byte-identically. The same call via MCP `tools/call` honors the `select` argument.
+- AE3 (covers R3): a spec with `POST /search-all` (operation id `searchAll`) flips `HasWriteCommands` to false; the generated README emits "Read-only by default". A spec with `POST /graphql` (operation id `query*`) similarly classifies as read. A spec with `POST /users` (operation id `createUser`) keeps `HasWriteCommands` true.
+- AE4 (covers R4): a spec with `POST /search-all` promoted emits `c.Post(path, body)` and runs successfully against the live endpoint. A spec with `GET /resources` promoted continues emitting `c.Get(path, params)` byte-identically.
+
+---
+
+## Scope Boundaries
+
+- Does not refactor the existing 33+ library CLIs to use the new `printJSONFiltered` helper. The SKILL update + generator emit fix the next regeneration; existing CLIs catch up via `/printing-press-polish` and individual runs (out of scope here).
+- Does not change MCP `destructiveHint` annotations on POST endpoints. The retro flags this as a separate concern; revisit if the README/HasWriteCommands fix surfaces cross-impact.
+- Does not address the four other retro work units (WU-4 narrative-command verification, WU-5 browser-sniff direct-probe fallback, WU-6 proxy-envelope subclass fixes, WU-7 store-migration concurrency, WU-8 traffic-analysis version doc). Those land separately.
+- Does not change verify or dogfood spec loading. WU-3 fixes only `loadOpenAPISpec` in `internal/pipeline/scorecard.go`.
+
+### Deferred to Follow-Up Work
+
+- Backfilling existing library CLIs with `printJSONFiltered` calls in their hand-written novel commands: tracked by the polish skill on next invocation per CLI.
+- A separate sweep that flips `MCP destructiveHint` for POST-as-query endpoints once the new write-detection signals are in place.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/generator/generator.go` — current `methodIsWrite` (line search: `func methodIsWrite(method string) bool`) and the two callers `hasWriteCommands` / `resourceHasWriteCommand` that propagate it. Endpoint struct in `internal/spec/spec.go` carries `Method`, `Path`, `Description`, `Body []Param`, `Meta map[string]string`. The map key in `Resource.Endpoints` is the operation id (used elsewhere as the canonical name).
+- `internal/generator/templates/command_promoted.go.tmpl` — currently emits `data, err := c.Get(path, params)` unconditionally for the no-store branch. The `HasStore` branch already calls `resolveRead`/`resolvePaginatedRead`. Verb branching needs to slot in alongside both.
+- `internal/generator/templates/helpers.go.tmpl` — the existing emission point for `filterFields` (line 451), `compactFields` (line 601), `wrapWithProvenance` (line 1242), and `printOutputWithFlags`. Add `printJSONFiltered` here so it's emitted into every CLI's `internal/cli/helpers.go` alongside the helpers it composes.
+- `internal/pipeline/scorecard.go:loadOpenAPISpec` — current shape: read file → `isInternalYAMLSpec` check → fall through to `json.Unmarshal`. The pipeline package already imports YAML elsewhere (`fullrun.go`, `dogfood.go`, `workflow_manifest.go`).
+- `skills/printing-press/SKILL.md` — Phase 3 "Agent Build Checklist" section is where the new principle for `--select` plumbing belongs. Existing principles 1-10 cover non-interactive, structured output, progressive help, etc. Add a new principle (or extend principle 2 "Structured output") that requires `printJSONFiltered`.
+
+### Institutional Learnings
+
+- AGENTS.md "Code & Comment Hygiene" guidance: prefer mechanical fixes over instructional fixes. Both WU-1 (generator) and WU-2 (generator + skill) fit this — the skill instruction alone wouldn't reach existing CLIs, but the generator emit + skill instruction together do for regeneration.
+- AGENTS.md "Updating dependent verifiers in the same change": F1's fix touches `HasWriteCommands` which influences README emission. The README template branching (`{{- if .HasWriteCommands}}{{else}}Read-only by default{{end}}`) is already in place, so no template change required — only the detector logic. Confirmed during retro audit.
+
+### External References
+
+None needed — all three changes are internal to the printing-press repo with no external API contracts to preserve.
+
+---
+
+## Key Technical Decisions
+
+- **U1 (scorecard YAML) lands first.** Smallest surface, isolated to one function in the pipeline package, no cross-template implications. Establishes the test pattern for the next two units. Lowest risk for the build/test infrastructure.
+- **U2 (novel-command `--select`) lands second.** Self-contained: one new helper in `helpers.go.tmpl` plus one new principle in SKILL.md. No dependency on U1, but lands second because U3 may want to reference the build-checklist style established by U2's SKILL update.
+- **U3 (POST-as-query detection) lands last.** Highest blast radius — touches `methodIsWrite` (called from `hasWriteCommands`), the `command_promoted.go.tmpl` template (used by every CLI with a promoted endpoint), and indirectly the README/SKILL emission. Lands last so the simpler fixes have validated the test approach and any cross-template regression caught by golden tests is investigated in isolation.
+- **Don't change `methodIsWrite`'s signature.** Replace `methodIsWrite(method string) bool` with a new `endpointIsWriteCommand(endpoint spec.Endpoint, name string) bool` that takes the operation id (the map key). Keep `methodIsWrite` around as a thin wrapper for any non-endpoint callers (e.g., raw method-string contexts) so we don't ripple through unrelated code.
+- **Use the operation id as the primary semantic signal.** It's the most reliable cross-API signal — every spec parser and every endpoint mirror uses it. Body shape (request body present but only filter-shaped) is a weaker secondary signal. Annotations (`mcp:read-only` is the existing flag; check whether endpoints can carry it) are a tertiary signal.
+- **YAML loader: use `yaml.Unmarshal` from the same package the rest of the pipeline uses.** Don't add a new dependency. The `gopkg.in/yaml.v3` (or equivalent) module is already in `go.mod` per `fullrun.go`/`dogfood.go`.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should `printJSONFiltered` go in `helpers.go.tmpl` or a new `cliutil_*.go.tmpl`?** Resolved: `helpers.go.tmpl`. Same package as the helpers it composes (`filterFields`, `compactFields`); no need for a new emitted file when an existing one already owns the JSON-output helper surface. Existing `cliutil/` templates (`cliutil_fanout.go.tmpl`, `cliutil_text.go.tmpl`, etc.) hold cross-cutting utilities for novel-feature client code; the JSON output helpers are CLI-formatting concerns and belong with the rest of `helpers.go`.
+- **Should `methodIsWrite` change its signature or be replaced?** Resolved: introduce `endpointIsWriteCommand(endpoint, name)` as a new function; have `resourceHasWriteCommand` call the new function. Keep `methodIsWrite` exported as a thin shim so any cross-package callers continue to work. The semantic upgrade lives in the new function; the old one becomes a fallback for callers that only have a verb string.
+- **Should the YAML loader consolidate JSON parsing through `yaml.Unmarshal`?** Resolved: keep the YAML and JSON branches separate. YAML is a JSON superset and `yaml.Unmarshal` would accept JSON input, but separate branches preserve clear, format-specific error messages (a JSON syntax error reports as "parsing spec JSON" with the JSON parser's error; a YAML syntax error reports as "parsing OpenAPI YAML spec" with the YAML parser's error). The shrinkage from consolidation isn't worth the diagnostic ambiguity.
+
+### Deferred to Implementation
+
+- **Exact list of operation-id prefixes that signal "read".** Plan baseline: `get*, list*, search*, find*, query*, count*, describe*, fetch*`. The implementer should verify this against the catalog's existing specs (grep `operationId` patterns) and add anything that's clearly read-shaped before locking the list. The list is encoded as a slice of prefixes for testability.
+- **Whether to short-circuit `endpointIsWriteCommand` on `mcp:read-only` annotation.** Plan baseline: yes, check `endpoint.Meta["mcp:read-only"] == "true"` first. Confirm during implementation whether `Endpoint.Meta` is the right map (vs. a sibling annotation field) by reading current usages.
+- **Body-shape signal for "POST-as-query".** Plan baseline: when `endpoint.Body` contains only filter-style params (e.g., `query`, `filter`, `limit`, `offset`, `from`, `size`, `cursor`, `page`), classify as read. Implementer can refine the keyword list once they see how `Body []Param` is populated for the postman-explore search-all and any GraphQL CLI in the catalog.
+
+---
+
+## 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.*
+
+```
+                  Spec parser
+                       │
+                       ▼
+              Endpoint{ Method, Body, Meta, ... }
+                       │
+                       │  (called per endpoint, with operation id)
+                       ▼
+   ┌────────────────────────────────────────────────────────────┐
+   │  endpointIsWriteCommand(endpoint, name)                    │
+   │  ────────────────────────────────────────────────────────  │
+   │  1. mcp:read-only annotation set? → read (false)           │
+   │  2. Method ∈ {GET, HEAD, OPTIONS}? → read (false)          │
+   │  3. operationId prefix in {get,list,search,find,query,...} │
+   │     → read (false)                                         │
+   │  4. Body shape: only filter-style params? → read (false)   │
+   │  5. otherwise → write (true)                               │
+   └────────────────────────────────────────────────────────────┘
+                       │
+                       ▼
+              hasWriteCommands → README template
+                       │
+                       ▼
+        command_promoted.go.tmpl: branch on .Endpoint.Method
+        ┌─────────────────────────┬────────────────────────────┐
+        │ Method == "GET"         │ Method != "GET"            │
+        │  data, err := c.Get(    │  data, _, err := c.Post(   │
+        │    path, params)        │    path, body)             │
+        └─────────────────────────┴────────────────────────────┘
+```
+
+---
+
+## Implementation Units
+
+- U1. **Scorecard accepts OpenAPI YAML specs**
+
+**Goal:** `printing-press scorecard --spec foo.yaml` succeeds for OpenAPI 3.x YAML files without manual conversion. Existing JSON specs and internal-YAML specs continue to work byte-identically.
+
+**Requirements:** R1 (AE1).
+
+**Dependencies:** None.
+
+**Files:**
+- Modify: `internal/pipeline/scorecard.go` (the `loadOpenAPISpec` function only)
+- Test: `internal/pipeline/scorecard_tier2_test.go` (extend the existing test surface)
+
+**Approach:**
+- Add a `yaml.Unmarshal` fallback to `loadOpenAPISpec`: after the internal-YAML check fails and before the JSON fallback, attempt YAML decoding. The YAML branch produces the same `map[string]any` shape that the JSON branch consumes, so downstream code (paths/security extraction) doesn't change.
+- Match the existing import style for YAML in `internal/pipeline/` (whichever yaml package is already used in `fullrun.go`/`dogfood.go`).
+- Preserve the existing internal-YAML detection (`isInternalYAMLSpec`) — internal YAML and OpenAPI YAML are distinguishable by top-level keys. Internal YAML has `kind:` / `resources:` / `auth:` top-levels; OpenAPI has `openapi:` / `paths:` / `components:`.
+- Decision: keep YAML and JSON branches separate (don't consolidate via `yaml.Unmarshal` for both) to preserve clear error messages when a JSON spec has a syntax error vs. a YAML spec has one.
+
+**Patterns to follow:**
+- `internal/pipeline/fullrun.go` and `internal/pipeline/dogfood.go` for the existing YAML import in the package.
+- The `isInternalYAMLSpec` detection pattern already in `loadOpenAPISpec` for short-circuiting on top-level keys.
+
+**Test scenarios:**
+- Covers AE1. Happy path: feeding an OpenAPI 3.x YAML spec to `loadOpenAPISpec` returns a populated `*openAPISpecInfo` with the same `Paths` and `SecuritySchemes` as feeding the same spec converted to JSON.
+- Happy path: an internal-YAML spec (with `kind: yaml-internal` or whatever the current sentinel is) continues to short-circuit through the internal branch — verified by reading the round-tripped APISpec.
+- Happy path: an OpenAPI 3.x JSON spec continues to load via the existing JSON branch — verified by an unchanged paths/security output.
+- Error path: a YAML file with malformed top-level (e.g., a tab-indented `paths:`) returns a clear "parsing OpenAPI YAML spec" error message that includes the file path.
+- Error path: a JSON file with a syntax error returns the existing "parsing spec JSON" message — no regression in diagnostic clarity.
+- Edge case: an empty file (zero bytes) returns a non-nil error referencing the empty input. Confirm behavior matches whatever the existing JSON-fallback does with empty input.
+
+**Verification:**
+- `printing-press scorecard --dir <fixture-cli> --spec testdata/openapi-yaml-spec.yaml` runs to completion and produces the same total score as running it with the JSON form of the same spec.
+- All existing scorecard tests pass without changes.
+
+---
+
+- U2. **Hand-written novel commands honor `--select` and `--compact`**
+
+**Goal:** Generator emits a `printJSONFiltered` helper into every CLI's `internal/cli/helpers.go`, and the SKILL Phase 3 build checklist requires novel commands to use it. After this lands, the next regeneration of any CLI with novel features produces commands that respect `--select` and `--compact` on JSON output.
+
+**Requirements:** R2 (AE2).
+
+**Dependencies:** U1 (none functionally — but landing U1 first establishes the test pattern).
+
+**Files:**
+- Modify: `internal/generator/templates/helpers.go.tmpl` (add `printJSONFiltered` near the existing `filterFields`/`compactFields` functions — line vicinity 451-700)
+- Modify: `skills/printing-press/SKILL.md` (Phase 3 "Agent Build Checklist" section — add a new principle or extend principle 2 "Structured output")
+- Test: `internal/generator/helpers_template_test.go` (add or extend a golden-style test that builds a CLI from a fixture spec and asserts the emitted helpers.go contains `printJSONFiltered` with the right signature)
+
+**Approach:**
+- Add `printJSONFiltered(cmd interface{ OutOrStdout() io.Writer }, v any, flags *rootFlags) error` to `helpers.go.tmpl`. Marshal `v`, apply `filterFields(raw, flags.selectFields)` if `flags.selectFields != ""`, else apply `compactFields(raw)` if `flags.compact`, then encode with two-space indent.
+- The `cmd` parameter takes the minimal interface required (just `OutOrStdout()`) so the helper stays testable without importing cobra in test files.
+- Update `SKILL.md` Phase 3 "Agent Build Checklist" with a new principle (call it principle 11 or a sub-bullet under principle 2): "Novel commands MUST use `printJSONFiltered(cmd, v, flags)` for JSON output. Direct `flags.printJSON(cmd, v)` calls drop `--select` and `--compact`. Verify with `<cli> <novel-cmd> --json --select <field> | jq 'keys'` returning only the requested fields."
+- Don't refactor the existing 33+ library CLIs in this PR. Their next regeneration picks up the helper; their next polish run picks up the SKILL guidance for novel-feature additions.
+
+**Patterns to follow:**
+- The existing `filterFields` / `compactFields` / `wrapWithProvenance` style in `helpers.go.tmpl` — same indentation, same comment density, same parameter ordering convention.
+- Existing build-checklist principles 1-10 in `SKILL.md` for the SKILL.md addition's prose style.
+
+**Test scenarios:**
+- Covers AE2. Happy path: build a CLI from a small fixture spec that has a novel-feature command emitted via the new helper. Run `<cli> <novel> --json --select foo,bar` against the binary; assert the output JSON has exactly the keys `foo` and `bar` and nothing else.
+- Happy path: same fixture with `--json --compact` returns the trimmed field set per `compactFields`'s known blocklist.
+- Happy path: same fixture with `--json` (no select, no compact) returns the full field set.
+- Edge case: `--json --select foo,nonexistent_field` returns a JSON object containing `foo` and omits `nonexistent_field` (matches existing `filterFields` semantics).
+- Edge case: empty result set with `--json --select foo` returns `[]` (matches the empty-array path the helper takes).
+- Integration: emit the helper into a CLI fixture and grep the generated `internal/cli/helpers.go` for the function signature to assert the template produced it. This proves the generator emit, not just the SKILL doc.
+- Negative: existing endpoint-mirror commands' JSON output is unchanged (they use `printOutputWithFlags`, not `printJSONFiltered`). Confirmed by running an existing fixture's endpoint mirror with `--json --select` before and after the change and asserting byte-equivalence.
+
+**Verification:**
+- `go test ./internal/generator/...` passes including any new test that builds a fixture CLI and asserts `printJSONFiltered` is emitted.
+- A regenerated postman-explore CLI (run as a manual smoke test, not committed) shows `canonical stripe --json --select name,publisherHandle,forkCount` returning only those three fields.
+- `skills/printing-press/SKILL.md` diff renders cleanly and the new principle is in the Agent Build Checklist with the same numbering style as 1-10.
+
+---
+
+- U3. **Generator-aware POST-as-query detection**
+
+**Goal:** Stop the generator from treating POST endpoints as writes when they're queries (search, GraphQL, RPC). Fix the promoted-command template to emit the correct HTTP verb. Both changes are needed together to fully resolve postman-explore-style breakage; landing only one is a half-fix.
+
+**Requirements:** R3 (AE3), R4 (AE4).
+
+**Dependencies:** U2 (no functional dep; ordering is for review/test posture only).
+
+**Files:**
+- Modify: `internal/generator/generator.go` (introduce `endpointIsWriteCommand(endpoint spec.Endpoint, name string) bool` and route `resourceHasWriteCommand` through it; keep `methodIsWrite(method string) bool` as a thin shim for verb-only callers)
+- Modify: `internal/generator/templates/command_promoted.go.tmpl` (add a verb branch: `{{- if eq .Endpoint.Method "GET" }}c.Get(...){{else}}c.Post(...){{end}}`; gracefully handle the `Pagination` / `HasStore` matrix that already exists)
+- Test: `internal/generator/generator_test.go` (existing — add cases for `endpointIsWriteCommand` against contrived Endpoint values)
+- Test: `internal/generator/generator_promoted_test.go` (or wherever promoted-command emission is tested — add a fixture with a POST-promoted endpoint and assert the emitted code calls `c.Post`)
+
+**Approach:**
+- New function `endpointIsWriteCommand(endpoint spec.Endpoint, name string) bool` checks signals in this order:
+  1. If `endpoint.Meta["mcp:read-only"] == "true"`, return false. (Verify `Meta` is the right field by reading current usages.)
+  2. If `methodIsWrite(endpoint.Method)` is false (i.e., GET/HEAD/OPTIONS), return false.
+  3. If `name` (the operation id, which is the map key) starts with any of `get`, `list`, `search`, `find`, `query`, `count`, `describe`, `fetch`, return false. (Match case-insensitively to match Postman's `searchAll` and any `Search*` etc.)
+  4. If `endpoint.Body` is non-empty but every param has a name in `{query, queryText, filter, limit, offset, from, size, cursor, page, pageSize, sort, sortBy}`, return false. (Filter-shaped body = query semantics.)
+  5. Otherwise, return true (genuine write).
+- Refactor `resourceHasWriteCommand` to iterate `for name, endpoint := range resource.Endpoints` and call `endpointIsWriteCommand(endpoint, name)`. The function in `generator.go` is the only caller of the existing `methodIsWrite` for write detection; keep `methodIsWrite` exported for any out-of-package callers.
+- Update `command_promoted.go.tmpl` to add a verb branch in the no-store fallback (line ~bottom of the no-pagination, no-store path):
+  ```
+  {{- if eq .Endpoint.Method "GET" }}
+              data, err := c.Get(path, params)
+  {{- else }}
+              data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, body)
+  {{- end }}
+  ```
+  The `body` value comes from the same Body marshalling that the underlying typed POST command uses (read the equivalent typed-command template for the pattern).
+- The `HasStore` and `Pagination` branches in the same template should also gain verb awareness if they hit POST endpoints. Audit during implementation whether `resolveRead` / `resolvePaginatedRead` already handle non-GET, or if they need a sibling. This is the most uncertain part of the implementation; budget time for it.
+
+**Execution note:** Add the test cases first. The semantic-signal logic in `endpointIsWriteCommand` is the most novel part; building tests against `Endpoint` fixtures up front locks the intended behavior before refactoring the dispatcher.
+
+**Patterns to follow:**
+- `internal/generator/generator.go` existing `hasWriteCommands` / `resourceHasWriteCommand` for the iteration shape.
+- The existing `{{- if .HasStore}}{{else}}{{end}}` branching style in `command_promoted.go.tmpl` for the verb branch.
+- `internal/generator/generator_test.go` for the test fixture setup pattern (build a `spec.Endpoint` literal, assert on classification).
+- The typed POST endpoint template (likely `command.go.tmpl` or similar) for the `c.Post(path, body)` call shape and how `body` is constructed from `endpoint.Body` params.
+
+**Test scenarios:**
+- Covers AE3. Happy path: `POST /search-all` with operation id `searchAll`, body `[{queryText, size, from}]` classifies as read. `HasWriteCommands` for a resource containing only this endpoint returns false. README emits "Read-only by default" for a fixture spec with only this endpoint.
+- Covers AE3. Happy path: `POST /graphql` with operation id `query` or `queryRoot` and body `[{query, variables}]` classifies as read.
+- Covers AE3. Happy path: `POST /users` with operation id `createUser` and body `[{name, email, role}]` (no filter-shaped names) classifies as write.
+- Edge case: GET endpoint with operation id `deleteUser` (nonsensical but possible from poor specs) classifies as read because verb is GET. Document the behavior — verb wins over name when verb is read-shaped.
+- Edge case: POST endpoint with no body and no operation id signal classifies as write (fail-closed).
+- Edge case: endpoint with `Meta["mcp:read-only"] = "true"` classifies as read regardless of verb or name.
+- Covers AE4. Integration: a fixture spec with `POST /search-all` promoted and emitted by the generator produces a Cobra command whose RunE calls `c.Post("/search-all", body)` — verified by greping the generated command file. The emitted code compiles (`go build`).
+- Covers AE4. Integration: a fixture spec with `GET /resources` promoted continues to emit `c.Get("/resources", params)` byte-identically. Confirms no regression in the GET path.
+- Negative: existing fixtures used by golden-style tests in `internal/generator/` that include POST endpoints (CRUD APIs) continue to classify them as writes — confirmed by re-running the existing test suite without changes to its expectations.
+
+**Verification:**
+- `go test ./internal/generator/...` passes including new endpoint-classification tests.
+- Manually regenerate postman-explore (in a scratch dir) and confirm: README shows "Read-only by default", and the promoted `search-all` command (now unhidden, since it works) calls `c.Post` and returns real data against the live endpoint.
+- Existing CRUD-shape fixtures (the golden-test specs) still classify their POST/PUT/DELETE endpoints as writes; their generated READMEs still emit Retryable bullets.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph (U3 only):** `endpointIsWriteCommand` feeds `hasWriteCommands` which feeds `HasWriteCommands` in template data which gates the README's Retryable section AND drives any other consumer of `HasWriteCommands` (audit during implementation — at minimum the README template, possibly SKILL prose). The promoted-command template change has no upstream effects but is gated on `.Endpoint.Method` which is already part of the template data.
+- **Error propagation:** No changes to error handling. `loadOpenAPISpec` (U1) returns the same error type from the YAML branch as the JSON branch.
+- **State lifecycle risks:** None — all three changes are pure code generation / classification. No runtime state involved.
+- **API surface parity:** U2's `printJSONFiltered` is a new exported helper in every regenerated CLI's `internal/cli/helpers.go`. It composes existing exports; no API surface is removed.
+- **Integration coverage:** U3's verb-branch fix needs an end-to-end smoke against a real POST-promoted endpoint (postman-explore is the natural fixture; postman is no-auth so the smoke is cheap). U2's helper needs a regenerate-and-run smoke against a fixture novel command. U1's YAML support is unit-testable in isolation.
+- **Unchanged invariants:** Existing internal-YAML specs continue to load via the same code path. Existing OpenAPI JSON specs continue to load via the same code path. The `methodIsWrite` function continues to exist for any cross-package callers. The `flags.printJSON` helper is not removed; existing endpoint-mirror commands continue to use `printOutputWithFlags`.
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| U3's verb-branch fix in `command_promoted.go.tmpl` interacts with the existing `HasStore`/`Pagination` matrix in non-obvious ways. The store branches use `resolveRead` / `resolvePaginatedRead` which may not have POST equivalents. | Audit the template and the resolver helpers during U3 implementation. Budget time to either add POST equivalents or scope the fix to the no-store branch and explicitly defer the HasStore+POST combination as a follow-up. |
+| The operation-id prefix list in U3 misses prefixes used by some specs in the catalog, causing legitimate write commands to be misclassified as reads. | Test against the catalog's existing specs (grep for `operationId:` in `catalog/specs/*.yaml`) before locking the prefix list. Lean toward fail-closed: when in doubt, classify as write. |
+| Golden tests in `internal/generator/` lock in specific generated output for fixture specs. U3's emit changes (verb branch in promoted template) may break golden tests that include promoted POST endpoints. | Run `scripts/golden.sh verify` before and after each unit. If a fixture's emitted code legitimately changes for the better, run `scripts/golden.sh update` and review the diff per AGENTS.md guidance. If a fixture breaks because of an unintended side effect, fix the code. |
+| YAML import collision: pipeline package may import a different YAML package than the spec parser. | Check `go.mod` and `go list -m all` before the U1 implementation; use whichever YAML package the pipeline already imports rather than introducing a new dependency. |
+
+---
+
+## Documentation / Operational Notes
+
+- **CHANGELOG / commit messages:** Each unit lands as a separate commit (or PR) with the conventional-commit prefix `fix(cli):` for U1 and U3, `feat(cli,skills):` for U2. The retro issue #423 is referenced in the body. Per `AGENTS.md`, a `fix:` triggers a patch bump and a `feat:` triggers a minor bump in the next release.
+- **Polish skill follow-up:** After U2 lands, the next `/printing-press-polish` invocation per CLI in the library should detect existing `flags.printJSON` calls in hand-written novel commands and offer to swap them. That's tracked separately and not part of this plan; the deferred-work section already names it.
+- **Docs/PIPELINE.md and docs/SKILLS.md:** Neither needs edits for this plan. The phase contract isn't changing; the SKILL update is a single principle addition.
+
+---
+
+## Sources & References
+
+- **Origin retro issue:** https://github.com/mvanhorn/cli-printing-press/issues/423
+- **Origin retro doc (full findings):** https://files.catbox.moe/84v2w8.md (also at `manuscripts/postman-explore/20260429-230407/proofs/20260430-004209-retro-postman-explore-pp-cli.md`)
+- **Postman-explore PR:** https://github.com/mvanhorn/printing-press-library/pull/159 (where the `--select` fix shipped as a printed-CLI workaround; this plan generalizes the fix to the generator)
+- **Related code:** `internal/generator/generator.go:methodIsWrite`, `internal/generator/templates/command_promoted.go.tmpl`, `internal/generator/templates/helpers.go.tmpl`, `internal/pipeline/scorecard.go:loadOpenAPISpec`, `skills/printing-press/SKILL.md` Phase 3 Agent Build Checklist.
diff --git a/internal/generator/endpoint_is_write_test.go b/internal/generator/endpoint_is_write_test.go
new file mode 100644
index 00000000..818d3798
--- /dev/null
+++ b/internal/generator/endpoint_is_write_test.go
@@ -0,0 +1,371 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestEndpointIsWriteCommand covers the four classification signals: HTTP
+// verb, operationId prefix, body shape, and the mcp:read-only annotation.
+// POST endpoints used as queries (search, GraphQL, RPC-style) must classify
+// as reads; genuine mutations must classify as writes regardless of body
+// shape coincidence.
+func TestEndpointIsWriteCommand(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name     string
+		opName   string
+		endpoint spec.Endpoint
+		want     bool
+	}{
+		{
+			name:     "GET endpoint is read",
+			opName:   "listUsers",
+			endpoint: spec.Endpoint{Method: "GET", Path: "/users"},
+			want:     false,
+		},
+		{
+			name:     "HEAD endpoint is read",
+			opName:   "headStatus",
+			endpoint: spec.Endpoint{Method: "HEAD", Path: "/status"},
+			want:     false,
+		},
+		{
+			name:   "POST search endpoint is read (operationId prefix searchAll)",
+			opName: "searchAll",
+			endpoint: spec.Endpoint{
+				Method: "POST",
+				Path:   "/search-all",
+				Body: []spec.Param{
+					{Name: "queryText", Type: "string"},
+					{Name: "size", Type: "integer"},
+					{Name: "from", Type: "integer"},
+				},
+			},
+			want: false,
+		},
+		{
+			name:   "POST GraphQL is read (operationId prefix query)",
+			opName: "query",
+			endpoint: spec.Endpoint{
+				Method: "POST",
+				Path:   "/graphql",
+				Body: []spec.Param{
+					{Name: "query", Type: "string"},
+					{Name: "variables", Type: "object"},
+				},
+			},
+			want: false,
+		},
+		{
+			name:   "POST list-style is read (operationId prefix list)",
+			opName: "listFilteredItems",
+			endpoint: spec.Endpoint{
+				Method: "POST",
+				Path:   "/items/list",
+				Body: []spec.Param{
+					{Name: "filter", Type: "object"},
+				},
+			},
+			want: false,
+		},
+		{
+			name:     "POST find-style is read",
+			opName:   "findCustomers",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/customers/find"},
+			want:     false,
+		},
+		{
+			name:     "POST count-style is read",
+			opName:   "countOrders",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/orders/count"},
+			want:     false,
+		},
+		{
+			name:     "POST fetch-style is read",
+			opName:   "fetchEvents",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/events/fetch"},
+			want:     false,
+		},
+		{
+			name:     "POST describe-style is read",
+			opName:   "describeWorkspace",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/workspaces/describe"},
+			want:     false,
+		},
+		{
+			name:   "POST create endpoint stays write",
+			opName: "createUser",
+			endpoint: spec.Endpoint{
+				Method: "POST",
+				Path:   "/users",
+				Body: []spec.Param{
+					{Name: "name", Type: "string"},
+					{Name: "email", Type: "string"},
+					{Name: "role", Type: "string"},
+				},
+			},
+			want: true,
+		},
+		{
+			name:     "POST add endpoint stays write",
+			opName:   "addItemToCart",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/cart/items"},
+			want:     true,
+		},
+		{
+			name:     "PUT update endpoint stays write",
+			opName:   "updateUser",
+			endpoint: spec.Endpoint{Method: "PUT", Path: "/users/{id}"},
+			want:     true,
+		},
+		{
+			name:     "PATCH partial-update endpoint stays write",
+			opName:   "patchOrder",
+			endpoint: spec.Endpoint{Method: "PATCH", Path: "/orders/{id}"},
+			want:     true,
+		},
+		{
+			name:     "DELETE endpoint stays write",
+			opName:   "deleteUser",
+			endpoint: spec.Endpoint{Method: "DELETE", Path: "/users/{id}"},
+			want:     true,
+		},
+		{
+			name:     "POST endpoint with no body and no semantic signal is write (fail-closed)",
+			opName:   "doSomething",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/something"},
+			want:     true,
+		},
+		{
+			name:   "POST endpoint with mcp:read-only annotation is read regardless of name",
+			opName: "doMutation",
+			endpoint: spec.Endpoint{
+				Method: "POST",
+				Path:   "/widgets",
+				Meta:   map[string]string{"mcp:read-only": "true"},
+			},
+			want: false,
+		},
+		{
+			name:     "operationId prefix matching is case-insensitive",
+			opName:   "SearchCollections",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/search/collections"},
+			want:     false,
+		},
+		{
+			name:   "POST with only filter-shape body params is read",
+			opName: "doQuery",
+			endpoint: spec.Endpoint{
+				Method: "POST",
+				Path:   "/widgets/query",
+				Body: []spec.Param{
+					{Name: "filter", Type: "object"},
+					{Name: "limit", Type: "integer"},
+					{Name: "offset", Type: "integer"},
+					{Name: "sort", Type: "string"},
+				},
+			},
+			want: false,
+		},
+		{
+			name:   "POST with mixed filter and write-shape body params stays write",
+			opName: "doStuff",
+			endpoint: spec.Endpoint{
+				Method: "POST",
+				Path:   "/widgets",
+				Body: []spec.Param{
+					{Name: "filter", Type: "object"},
+					{Name: "name", Type: "string"}, // write-shape
+				},
+			},
+			want: true,
+		},
+		{
+			name:     "POST getOrCreate flips back to write (read-shaped leading token, mutation in tail)",
+			opName:   "getOrCreateUser",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/users"},
+			want:     true,
+		},
+		{
+			name:     "POST fetchAndUpdate flips back to write (mutation token in tail)",
+			opName:   "fetchAndUpdateProfile",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/profile"},
+			want:     true,
+		},
+		{
+			name:     "POST listAndDelete flips back to write",
+			opName:   "listAndDeleteOrphans",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/orphans"},
+			want:     true,
+		},
+		{
+			name:     "leading-token match is whole-word, not prefix substring (getter is not get)",
+			opName:   "getter",
+			endpoint: spec.Endpoint{Method: "POST", Path: "/getter"},
+			want:     true, // single-token "getter" — not the literal "get" verb, fail-closed
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := endpointIsWriteCommand(tc.endpoint, tc.opName)
+			assert.Equal(t, tc.want, got, "endpointIsWriteCommand(%q) returned wrong classification", tc.opName)
+		})
+	}
+}
+
+// TestHasWriteCommands_PostAsQueryFlipsHasWriteFalse verifies the
+// classifier propagates through resourceHasWriteCommand and hasWriteCommands
+// so a resource containing only a POST search endpoint flips HasWriteCommands
+// to false — that signal drives the README's read-only branching.
+func TestHasWriteCommands_PostAsQueryFlipsHasWriteFalse(t *testing.T) {
+	t.Parallel()
+
+	resources := map[string]spec.Resource{
+		"search": {
+			Description: "Search the public network",
+			Endpoints: map[string]spec.Endpoint{
+				"searchAll": {
+					Method: "POST",
+					Path:   "/search-all",
+					Body: []spec.Param{
+						{Name: "queryText", Type: "string"},
+					},
+				},
+			},
+		},
+	}
+
+	assert.False(t, hasWriteCommands(resources),
+		"a resource with only POST search endpoints should classify as read-only")
+}
+
+// TestPromotedCommandVerbBranching covers the integration path: the
+// rendered promoted command emits the same HTTP verb the spec declared,
+// so a POST-only endpoint hits c.Post and a GET-only endpoint stays on
+// c.Get/resolveRead.
+func TestPromotedCommandVerbBranching(t *testing.T) {
+	cases := []struct {
+		name         string
+		apiName      string
+		resourceName string
+		endpointName string
+		endpoint     spec.Endpoint
+		mustContain  []string
+		mustNotHave  []string
+	}{
+		{
+			name:         "POST endpoint emits c.Post",
+			apiName:      "post-promoted",
+			resourceName: "queries",
+			endpointName: "searchAll",
+			endpoint: spec.Endpoint{
+				Method:      "POST",
+				Path:        "/search-all",
+				Description: "Search collections by free text",
+				Body:        []spec.Param{{Name: "queryText", Type: "string"}},
+			},
+			mustContain: []string{"c.Post("},
+			mustNotHave: []string{"c.Get(path, params)"},
+		},
+		{
+			name:         "GET endpoint keeps c.Get / resolveRead",
+			apiName:      "get-promoted",
+			resourceName: "items",
+			endpointName: "listItems",
+			endpoint: spec.Endpoint{
+				Method:      "GET",
+				Path:        "/items",
+				Description: "List items",
+			},
+			mustNotHave: []string{"c.Post(", "c.Put(", "c.Patch("},
+		},
+		{
+			// HEAD / OPTIONS aren't supported by the generated client.
+			// Falling back to c.Get keeps generation compileable; the only
+			// alternative would be emitting an undefined method like c.Head.
+			name:         "HEAD endpoint falls back to c.Get",
+			apiName:      "head-promoted",
+			resourceName: "probes",
+			endpointName: "headStatus",
+			endpoint: spec.Endpoint{
+				Method:      "HEAD",
+				Path:        "/status",
+				Description: "Probe status",
+			},
+			mustContain: []string{"c.Get(path, params)"},
+			mustNotHave: []string{"c.Head(", "c.Options("},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			apiSpec := minimalSpec(tc.apiName)
+			apiSpec.Resources = map[string]spec.Resource{
+				tc.resourceName: {
+					Description: tc.resourceName,
+					Endpoints:   map[string]spec.Endpoint{tc.endpointName: tc.endpoint},
+				},
+			}
+
+			outputDir := filepath.Join(t.TempDir(), tc.apiName+"-pp-cli")
+			require.NoError(t, New(apiSpec, outputDir).Generate())
+
+			src := readPromotedCommandFile(t, outputDir)
+			for _, want := range tc.mustContain {
+				require.Contains(t, src, want)
+			}
+			for _, banned := range tc.mustNotHave {
+				require.NotContains(t, src, banned)
+			}
+		})
+	}
+}
+
+// readPromotedCommandFile finds the single promoted_*.go file the generator
+// emits for a fixture spec with one resource. Naming varies (resource name
+// vs. kebabed endpoint name vs. camelCase), so the lookup glob-matches.
+func readPromotedCommandFile(t *testing.T, outputDir string) string {
+	t.Helper()
+	matches, err := filepath.Glob(filepath.Join(outputDir, "internal", "cli", "promoted_*.go"))
+	require.NoError(t, err)
+	require.Len(t, matches, 1, "expected exactly one promoted command file in internal/cli/")
+	content, err := os.ReadFile(matches[0])
+	require.NoError(t, err)
+	return string(content)
+}
+
+// TestHasWriteCommands_GenuineMutationsStayTrue is the negative guard: a
+// POST createUser endpoint with a write-shape body must still classify as a
+// write so the README keeps emitting the mutation-aware Agent Usage bullets.
+func TestHasWriteCommands_GenuineMutationsStayTrue(t *testing.T) {
+	t.Parallel()
+
+	resources := map[string]spec.Resource{
+		"users": {
+			Description: "User accounts",
+			Endpoints: map[string]spec.Endpoint{
+				"createUser": {
+					Method: "POST",
+					Path:   "/users",
+					Body: []spec.Param{
+						{Name: "name", Type: "string"},
+						{Name: "email", Type: "string"},
+					},
+				},
+			},
+		},
+	}
+
+	assert.True(t, hasWriteCommands(resources),
+		"a POST endpoint with a write-shape operationId and body should classify as write")
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 4f273083..243fbb82 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -760,8 +760,8 @@ func hasWriteCommands(resources map[string]spec.Resource) bool {
 }
 
 func resourceHasWriteCommand(resource spec.Resource) bool {
-	for _, endpoint := range resource.Endpoints {
-		if methodIsWrite(endpoint.Method) {
+	for name, endpoint := range resource.Endpoints {
+		if endpointIsWriteCommand(endpoint, name) {
 			return true
 		}
 	}
@@ -773,6 +773,8 @@ func resourceHasWriteCommand(resource spec.Resource) bool {
 	return false
 }
 
+// methodIsWrite is the verb-only fallback. Prefer endpointIsWriteCommand
+// when an Endpoint is in hand.
 func methodIsWrite(method string) bool {
 	switch strings.ToUpper(strings.TrimSpace(method)) {
 	case "POST", "PUT", "PATCH", "DELETE":
@@ -782,6 +784,120 @@ func methodIsWrite(method string) bool {
 	}
 }
 
+// readOperationIDPrefixes signal a read regardless of HTTP verb. Matched
+// against the leading camelCase token of the operation id, case-insensitive.
+// Whole-token (not substring) matching avoids false reads on names like
+// "getter" or "listenerStart" while still catching "getUser", "listOrders".
+var readOperationIDPrefixes = map[string]bool{
+	"get":      true,
+	"list":     true,
+	"search":   true,
+	"find":     true,
+	"query":    true,
+	"count":    true,
+	"describe": true,
+	"fetch":    true,
+}
+
+// writeOperationIDFragments name mutations. When a read-shaped leading token
+// is followed by one of these (e.g. getOrCreate, fetchAndUpdate), the
+// classifier flips back to write — the leading verb was misleading.
+var writeOperationIDFragments = map[string]bool{
+	"create": true,
+	"update": true,
+	"delete": true,
+	"remove": true,
+	"add":    true,
+	"insert": true,
+	"set":    true,
+	"upsert": true,
+	"save":   true,
+}
+
+// readBodyParamNames are filter-shape body field names. A POST whose body
+// params are entirely drawn from this set is acting as a query, not a
+// mutation; mixing in any unknown name flips the endpoint back to write.
+var readBodyParamNames = map[string]bool{
+	"query":     true,
+	"querytext": true,
+	"q":         true,
+	"filter":    true,
+	"filters":   true,
+	"limit":     true,
+	"offset":    true,
+	"from":      true,
+	"size":      true,
+	"cursor":    true,
+	"page":      true,
+	"pagesize":  true,
+	"sort":      true,
+	"sortby":    true,
+	"orderby":   true,
+}
+
+// endpointIsWriteCommand returns true when the endpoint mutates external
+// state. Read signals are checked in cost order: annotation, verb, name
+// token, body shape. Fail-closed when none fire so unknown shapes stay
+// classified as writes.
+//
+// opName is the map key from Resource.Endpoints (the operation id).
+func endpointIsWriteCommand(endpoint spec.Endpoint, opName string) bool {
+	if v, ok := endpoint.Meta["mcp:read-only"]; ok && strings.EqualFold(strings.TrimSpace(v), "true") {
+		return false
+	}
+	if !methodIsWrite(endpoint.Method) {
+		return false
+	}
+	tokens := camelCaseTokens(strings.TrimSpace(opName))
+	if len(tokens) > 0 && readOperationIDPrefixes[strings.ToLower(tokens[0])] {
+		for _, tok := range tokens[1:] {
+			if writeOperationIDFragments[strings.ToLower(tok)] {
+				return true
+			}
+		}
+		return false
+	}
+	return !bodyIsAllFilterShape(endpoint.Body)
+}
+
+// camelCaseTokens splits "getOrCreate" → ["get", "Or", "Create"] and
+// "searchAll" → ["search", "All"]. Non-letter runes (digits, separators)
+// stay attached to the preceding token.
+func camelCaseTokens(s string) []string {
+	if s == "" {
+		return nil
+	}
+	var tokens []string
+	var cur []rune
+	for _, r := range s {
+		if unicode.IsUpper(r) && len(cur) > 0 {
+			tokens = append(tokens, string(cur))
+			cur = []rune{r}
+			continue
+		}
+		cur = append(cur, r)
+	}
+	if len(cur) > 0 {
+		tokens = append(tokens, string(cur))
+	}
+	return tokens
+}
+
+// bodyIsAllFilterShape reports whether every body param's name is in
+// readBodyParamNames. Returns false for empty bodies so a POST with no body
+// (the fail-closed default) stays classified as a write.
+func bodyIsAllFilterShape(body []spec.Param) bool {
+	if len(body) == 0 {
+		return false
+	}
+	for _, p := range body {
+		if !readBodyParamNames[strings.ToLower(strings.TrimSpace(p.Name))] {
+			return false
+		}
+	}
+	return true
+}
+
 func (g *Generator) templateData() *generatorTemplateData {
 	return &generatorTemplateData{
 		APISpec:         g.Spec,
diff --git a/internal/generator/print_json_filtered_test.go b/internal/generator/print_json_filtered_test.go
new file mode 100644
index 00000000..7c223faa
--- /dev/null
+++ b/internal/generator/print_json_filtered_test.go
@@ -0,0 +1,33 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+// TestPrintJSONFiltered_EmittedIntoHelpers verifies the generator emits the
+// printJSONFiltered helper into every CLI's internal/cli/helpers.go. The
+// helper composes printOutputWithFlags so novel commands honor --select,
+// --compact, --csv, and --quiet identically to endpoint-mirror commands;
+// asserting it delegates rather than re-implements keeps the two paths
+// aligned when printOutputWithFlags evolves.
+func TestPrintJSONFiltered_EmittedIntoHelpers(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("filtered-print")
+	outputDir := filepath.Join(t.TempDir(), "filtered-print-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	helpersPath := filepath.Join(outputDir, "internal", "cli", "helpers.go")
+	content, err := os.ReadFile(helpersPath)
+	require.NoError(t, err)
+	src := string(content)
+
+	require.Contains(t, src, "func printJSONFiltered(",
+		"helpers.go must export printJSONFiltered for novel commands")
+	require.Contains(t, src, "printOutputWithFlags(w, json.RawMessage(raw), flags)",
+		"printJSONFiltered must delegate to printOutputWithFlags so flag handling stays unified")
+}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index d139e444..8f14db68 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -138,8 +138,23 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- end}}
 {{- end}}
 {{- if .HasStore}}
+			// resolveRead is GET-only internally, so HasStore + non-GET should
+			// be marked Hidden in the printed CLI and reached via the typed
+			// `<resource> <endpoint>` form instead.
 			data, prov, err := resolveRead(c, flags, "{{lower .ResourceName}}", {{if .Endpoint.Pagination}}true{{else}}false{{end}}, path, params, nil)
+{{- else if eq (upper .Endpoint.Method) "GET"}}
+			data, err := c.Get(path, params)
+{{- else if eq (upper .Endpoint.Method) "DELETE"}}
+			data, _, err := c.Delete(path)
+{{- else if or (eq (upper .Endpoint.Method) "POST") (eq (upper .Endpoint.Method) "PUT") (eq (upper .Endpoint.Method) "PATCH")}}
+			// params (built from query/path Params) is sent as the request body.
+			// Rich body shapes should use the typed `<resource> <endpoint>`
+			// command, which declares each body field as a flag.
+			data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, params)
 {{- else}}
+			// HEAD/OPTIONS and unknown verbs fall back to GET so generation
+			// stays compileable. Spec authors who genuinely need these verbs
+			// should add a typed client method and a dedicated template branch.
 			data, err := c.Get(path, params)
 {{- end}}
 {{- end}}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index d9577bb2..02b91c31 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -445,6 +445,18 @@ func paginatedGet(c interface {
 	return json.RawMessage(result), nil
 }
 
+// printJSONFiltered marshals a Go-typed value through the same output
+// pipeline endpoint-mirror commands use. Hand-written novel commands that
+// build a typed slice/struct call this so --select, --compact, --csv, and
+// --quiet all behave the same way as on generator-emitted commands.
+func printJSONFiltered(w io.Writer, v any, flags *rootFlags) error {
+	raw, err := json.Marshal(v)
+	if err != nil {
+		return err
+	}
+	return printOutputWithFlags(w, json.RawMessage(raw), flags)
+}
+
 // filterFields keeps only the specified fields (comma-separated) from JSON objects/arrays.
 // Supports dotted paths like "events.shortName" to descend into nested structures.
 // Arrays are traversed element-wise: "events.shortName" keeps shortName on each event.
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 2d623659..cc1667f8 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -1,6 +1,7 @@
 package pipeline
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
 	"os"
@@ -11,6 +12,7 @@ import (
 	"strings"
 
 	apispec "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+	"gopkg.in/yaml.v3"
 )
 
 // infraCoreFiles are CLI infrastructure files excluded from workflow/insight scoring.
@@ -1515,9 +1517,28 @@ func loadOpenAPISpec(specPath string) (*openAPISpecInfo, error) {
 		return internalSpecToOpenAPISpecInfo(internal), nil
 	}
 
+	// Strip a UTF-8 BOM if present so editors that emit one (Windows
+	// Notepad, some VS Code locales) don't fail content sniffing or
+	// json.Unmarshal, which both reject leading BOM bytes.
+	data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
+
+	// Distinguish OpenAPI JSON from OpenAPI YAML by content sniffing the
+	// first non-whitespace byte. JSON objects start with `{`; YAML mappings
+	// start with letters/quotes. yaml.Unmarshal would accept either format,
+	// but separate branches preserve format-specific error messages.
 	var raw map[string]any
-	if err := json.Unmarshal(data, &raw); err != nil {
-		return nil, fmt.Errorf("parsing spec JSON: %w", err)
+	trimmed := bytes.TrimLeft(data, " \t\r\n")
+	if len(trimmed) == 0 {
+		return nil, fmt.Errorf("spec file %s is empty", specPath)
+	}
+	if trimmed[0] == '{' {
+		if err := json.Unmarshal(data, &raw); err != nil {
+			return nil, fmt.Errorf("parsing spec JSON: %w", err)
+		}
+	} else {
+		if err := yaml.Unmarshal(data, &raw); err != nil {
+			return nil, fmt.Errorf("parsing OpenAPI YAML spec: %w", err)
+		}
 	}
 
 	info := &openAPISpecInfo{
diff --git a/internal/pipeline/scorecard_tier2_test.go b/internal/pipeline/scorecard_tier2_test.go
index 194a5186..297d2192 100644
--- a/internal/pipeline/scorecard_tier2_test.go
+++ b/internal/pipeline/scorecard_tier2_test.go
@@ -1533,6 +1533,180 @@ func TestLoadOpenAPISpec_Swagger20SecurityDefinitions(t *testing.T) {
 	})
 }
 
+// TestLoadOpenAPISpec_OpenAPIYAML covers the YAML branch that lets
+// scorecard --spec foo.yaml work without converting to JSON first.
+// The function prefers JSON when the spec begins with `{` and falls
+// back to yaml.Unmarshal otherwise so the JSON branch's error message
+// stays diagnostic for malformed JSON inputs.
+func TestLoadOpenAPISpec_OpenAPIYAML(t *testing.T) {
+	t.Run("OpenAPI YAML produces same paths and security as JSON form", func(t *testing.T) {
+		dir := t.TempDir()
+		yamlPath := filepath.Join(dir, "openapi.yaml")
+		writeScorecardFixture(t, dir, "openapi.yaml", `openapi: "3.0.3"
+info:
+  title: Test API
+  version: "1.0.0"
+paths:
+  /users:
+    get:
+      operationId: listUsers
+      responses:
+        "200":
+          description: ok
+  /widgets:
+    post:
+      operationId: createWidget
+      responses:
+        "201":
+          description: created
+components:
+  securitySchemes:
+    bearerAuth:
+      type: http
+      scheme: bearer
+`)
+
+		info, err := loadOpenAPISpec(yamlPath)
+		assert.NoError(t, err)
+		assert.Equal(t, []string{"/users", "/widgets"}, info.Paths)
+		scheme := info.SecuritySchemes["bearerAuth"]
+		assert.Equal(t, "http", scheme.Type)
+		assert.Equal(t, "bearer", scheme.Scheme)
+	})
+
+	t.Run("equivalent JSON and YAML specs produce equivalent info", func(t *testing.T) {
+		dir := t.TempDir()
+		jsonPath := filepath.Join(dir, "spec.json")
+		yamlPath := filepath.Join(dir, "spec.yaml")
+
+		writeScorecardFixture(t, dir, "spec.json", `{
+  "openapi": "3.0.3",
+  "paths": {
+    "/items": { "get": { "responses": { "200": { "description": "ok" } } } }
+  },
+  "components": {
+    "securitySchemes": {
+      "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" }
+    }
+  }
+}`)
+		writeScorecardFixture(t, dir, "spec.yaml", `openapi: "3.0.3"
+paths:
+  /items:
+    get:
+      responses:
+        "200":
+          description: ok
+components:
+  securitySchemes:
+    apiKey:
+      type: apiKey
+      in: header
+      name: X-API-Key
+`)
+
+		jsonInfo, err := loadOpenAPISpec(jsonPath)
+		assert.NoError(t, err)
+		yamlInfo, err := loadOpenAPISpec(yamlPath)
+		assert.NoError(t, err)
+
+		assert.Equal(t, jsonInfo.Paths, yamlInfo.Paths)
+		assert.Equal(t, jsonInfo.SecuritySchemes, yamlInfo.SecuritySchemes)
+	})
+
+	t.Run("malformed YAML returns OpenAPI YAML error", func(t *testing.T) {
+		dir := t.TempDir()
+		specPath := filepath.Join(dir, "broken.yaml")
+		// Mixing tabs and spaces is a classic YAML structural failure.
+		writeScorecardFixture(t, dir, "broken.yaml", "openapi: \"3.0.3\"\npaths:\n\t/users:\n  get: {}\n")
+
+		_, err := loadOpenAPISpec(specPath)
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "parsing OpenAPI YAML spec")
+	})
+
+	t.Run("malformed JSON keeps the JSON-specific error message", func(t *testing.T) {
+		dir := t.TempDir()
+		specPath := filepath.Join(dir, "broken.json")
+		// Trailing comma is invalid JSON; whitespace-leading `{` routes to JSON branch.
+		writeScorecardFixture(t, dir, "broken.json", `{ "paths": { "/x": {} }, }`)
+
+		_, err := loadOpenAPISpec(specPath)
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "parsing spec JSON")
+		assert.NotContains(t, err.Error(), "parsing OpenAPI YAML spec")
+	})
+
+	t.Run("internal YAML spec still routes through internal branch", func(t *testing.T) {
+		dir := t.TempDir()
+		specPath := filepath.Join(dir, "internal.yaml")
+		writeScorecardFixture(t, dir, "internal.yaml", `name: example
+display_name: Example API
+description: Test fixture for internal-YAML routing
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars:
+    - EXAMPLE_TOKEN
+resources:
+  users:
+    description: User accounts
+    endpoints:
+      list:
+        method: GET
+        path: /users
+`)
+
+		info, err := loadOpenAPISpec(specPath)
+		assert.NoError(t, err)
+		assert.NotNil(t, info, "internal-YAML branch should produce a non-nil info")
+	})
+
+	t.Run("leading whitespace before { still detects JSON", func(t *testing.T) {
+		dir := t.TempDir()
+		specPath := filepath.Join(dir, "indented.json")
+		writeScorecardFixture(t, dir, "indented.json", "\n\n  {\"paths\": {\"/x\": {}}}")
+
+		info, err := loadOpenAPISpec(specPath)
+		assert.NoError(t, err)
+		assert.Equal(t, []string{"/x"}, info.Paths)
+	})
+
+	t.Run("empty file returns explicit error", func(t *testing.T) {
+		dir := t.TempDir()
+		specPath := filepath.Join(dir, "empty.yaml")
+		writeScorecardFixture(t, dir, "empty.yaml", "")
+
+		_, err := loadOpenAPISpec(specPath)
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "empty")
+	})
+
+	t.Run("whitespace-only file returns explicit error", func(t *testing.T) {
+		dir := t.TempDir()
+		specPath := filepath.Join(dir, "blank.yaml")
+		writeScorecardFixture(t, dir, "blank.yaml", "   \n\n\t  \n")
+
+		_, err := loadOpenAPISpec(specPath)
+		assert.Error(t, err)
+		assert.Contains(t, err.Error(), "empty")
+	})
+
+	t.Run("UTF-8 BOM-prefixed JSON still detects JSON branch", func(t *testing.T) {
+		dir := t.TempDir()
+		specPath := filepath.Join(dir, "bom.json")
+		// Editors on Windows occasionally emit a UTF-8 BOM at the head of
+		// JSON files. Without the BOM strip it would route to the YAML
+		// branch and the JSON-specific error message would be lost.
+		bom := string([]byte{0xEF, 0xBB, 0xBF})
+		writeScorecardFixture(t, dir, "bom.json", bom+`{"paths": {"/y": {}}}`)
+
+		info, err := loadOpenAPISpec(specPath)
+		assert.NoError(t, err)
+		assert.Equal(t, []string{"/y"}, info.Paths)
+	})
+}
+
 func writeScorecardFixture(t *testing.T, root, relPath, content string) {
 	t.Helper()
 
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index c7a0fd74..b4e997ea 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1738,10 +1738,10 @@ Priority 3 (polish):
 
 ### Agent Build Checklist (per command)
 
-After building each command in Priority 1 and Priority 2, verify these 9 principles are met. These map 1:1 to what Phase 4.9's agent readiness reviewer will check - apply them now so the review becomes a confirmation, not a catch-all.
+After building each command in Priority 1 and Priority 2, verify these 10 principles are met. These map 1:1 to what Phase 4.9's agent readiness reviewer will check - apply them now so the review becomes a confirmation, not a catch-all.
 
 1. **Non-interactive**: No TTY prompts, no `bufio.Scanner(os.Stdin)`, works in CI without a terminal
-2. **Structured output**: `--json` produces valid JSON, `--select` filters fields correctly
+2. **Structured output**: `--json` produces valid JSON, `--select` filters fields correctly. Hand-written novel commands that build a Go-typed slice/struct and emit JSON MUST call `printJSONFiltered(cmd.OutOrStdout(), v, flags)` instead of `flags.printJSON(cmd, v)`. The helper marshals the value and routes the bytes through `printOutputWithFlags`, picking up `--select`, `--compact`, `--csv`, and `--quiet` for free; direct `flags.printJSON` drops every one of those flags silently. Endpoint-mirror commands already use `printOutputWithFlags`; this rule covers hand-written novel commands. Verify with `<cli> <novel> --json --select <field> | jq 'keys'` returning only the requested fields.
 3. **Progressive help**: `--help` shows realistic examples with domain-specific values (not "abc123"). **Use `Example: strings.Trim(\`...\`, "\n")` (preserves leading 2-space indent) NOT `strings.TrimSpace(\`...\`)` (strips it).** TrimSpace makes the first example line unindented; dogfood's example-detection parser is tolerant of this in current versions, but the indented form renders correctly across every Cobra version and is the convention used by every generated command.
 4. **Actionable errors**: Error messages name the specific flag/arg that's wrong and the correct usage
 5. **Safe retries**: Mutation commands support `--dry-run`, idempotent where possible

← 337840c8 fix(skills): sharpen retro cardinal rule to name over-fitted  ·  back to Cli Printing Press  ·  fix(ci): disable golangci-lint remote schema verify (#432) b6c70893 →