[object Object]

← back to Cli Printing Press

feat(ci): automated releases, linting, and commit conventions (#34)

c779648a6781e28628fbab3d48edffca2406792d · 2026-03-28 21:01:21 -0700 · Trevin Chow

* feat(ci): automated releases, linting, and commit conventions

Add release-please + goreleaser for fully automated releases:
- release-please watches conventional commits, opens release PRs,
  bumps version in plugin.json, marketplace.json, and root.go
- goreleaser builds cross-platform binaries on release (6 targets)
- Version injected via ldflags; debug.ReadBuildInfo fallback for
  go install builds

Add golangci-lint (govet, errcheck, staticcheck, unused, gofmt):
- CI action on every PR via golangci-lint-action@v9
- Claude Code hook runs gofmt on every Go file write
- Pre-existing debt excluded with tracked rules in .golangci.yml
- Fixed: 5 gofmt violations, 1 self-assignment bug in state.go

Add PR title enforcement:
- amannn/action-semantic-pull-request@v6 validates PR titles
- Required scopes: cli, skills, ci
- Documented in AGENTS.md with version bump semantics

Bump CI actions: checkout v4→v6, setup-go v5→v6

Tests added:
- Version consistency across 3 files (catches manual version drift)
- Goreleaser ldflags path matches Go variable (catches renames)
- release-please annotation exists in root.go
- Shared build cache behavior (validates CI perf fix)
- Conventional commit pattern validation

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

* fix(cli): resolve all golangci-lint issues and remove exclusion workarounds

Fix 24 lint issues, delete 248 lines of dead code, slim .golangci.yml
from 68 lines of exclusions to 30 lines with only 2 justified rules:
- errcheck excluded in test files (setup/cleanup noise)
- defer os.Remove/RemoveAll excluded (cleanup failure is harmless)

Fixes applied:
- errcheck: wrap defer Close() with explicit discard, check json.Encode
- SA1019: replace deprecated strings.Title with cases.Title (x/text)
- S1039/QF1012: WriteString(fmt.Sprintf(...)) → fmt.Fprintf
- SA4009: rename overwritten Walk callback param to _
- ineffassign: use _ for unused map lookups, remove dead assignment

Dead code deleted:
- internal/pipeline/spec_summary.go (entire file, 7 unused symbols)
- removeDeadFunctions in remediate.go
- resourceDescription, resourceNameFromPath, firstNonEmpty in parser.go

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

* fix(cli): handle defer cleanup errors, remove os.Remove exclusions

Wrap all defer os.Remove/os.RemoveAll calls with explicit error
discard so errcheck is satisfied without exclusion rules. Removes
the last non-fmt errcheck exclusions from .golangci.yml.

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

* fix(ci): require scope on all commit types and sync test with workflow

Addresses code review findings on PR #34:

1. pr-title.yml: requireScope false → true. Scope is now required
   on all types (feat, fix, docs, chore, etc.), not just documented.
2. conventions_test.go: regex now only accepts cli|skills|ci scopes,
   matching the workflow's scope list. Comment said "keep in sync" —
   now they actually are.
3. AGENTS.md: updated to reflect scope is always required.

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

---------

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

Files touched

Diff

commit c779648a6781e28628fbab3d48edffca2406792d
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Mar 28 21:01:21 2026 -0700

    feat(ci): automated releases, linting, and commit conventions (#34)
    
    * feat(ci): automated releases, linting, and commit conventions
    
    Add release-please + goreleaser for fully automated releases:
    - release-please watches conventional commits, opens release PRs,
      bumps version in plugin.json, marketplace.json, and root.go
    - goreleaser builds cross-platform binaries on release (6 targets)
    - Version injected via ldflags; debug.ReadBuildInfo fallback for
      go install builds
    
    Add golangci-lint (govet, errcheck, staticcheck, unused, gofmt):
    - CI action on every PR via golangci-lint-action@v9
    - Claude Code hook runs gofmt on every Go file write
    - Pre-existing debt excluded with tracked rules in .golangci.yml
    - Fixed: 5 gofmt violations, 1 self-assignment bug in state.go
    
    Add PR title enforcement:
    - amannn/action-semantic-pull-request@v6 validates PR titles
    - Required scopes: cli, skills, ci
    - Documented in AGENTS.md with version bump semantics
    
    Bump CI actions: checkout v4→v6, setup-go v5→v6
    
    Tests added:
    - Version consistency across 3 files (catches manual version drift)
    - Goreleaser ldflags path matches Go variable (catches renames)
    - release-please annotation exists in root.go
    - Shared build cache behavior (validates CI perf fix)
    - Conventional commit pattern validation
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): resolve all golangci-lint issues and remove exclusion workarounds
    
    Fix 24 lint issues, delete 248 lines of dead code, slim .golangci.yml
    from 68 lines of exclusions to 30 lines with only 2 justified rules:
    - errcheck excluded in test files (setup/cleanup noise)
    - defer os.Remove/RemoveAll excluded (cleanup failure is harmless)
    
    Fixes applied:
    - errcheck: wrap defer Close() with explicit discard, check json.Encode
    - SA1019: replace deprecated strings.Title with cases.Title (x/text)
    - S1039/QF1012: WriteString(fmt.Sprintf(...)) → fmt.Fprintf
    - SA4009: rename overwritten Walk callback param to _
    - ineffassign: use _ for unused map lookups, remove dead assignment
    
    Dead code deleted:
    - internal/pipeline/spec_summary.go (entire file, 7 unused symbols)
    - removeDeadFunctions in remediate.go
    - resourceDescription, resourceNameFromPath, firstNonEmpty in parser.go
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): handle defer cleanup errors, remove os.Remove exclusions
    
    Wrap all defer os.Remove/os.RemoveAll calls with explicit error
    discard so errcheck is satisfied without exclusion rules. Removes
    the last non-fmt errcheck exclusions from .golangci.yml.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(ci): require scope on all commit types and sync test with workflow
    
    Addresses code review findings on PR #34:
    
    1. pr-title.yml: requireScope false → true. Scope is now required
       on all types (feat, fix, docs, chore, etc.), not just documented.
    2. conventions_test.go: regex now only accepts cli|skills|ci scopes,
       matching the workflow's scope list. Comment said "keep in sync" —
       now they actually are.
    3. AGENTS.md: updated to reflect scope is always required.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 .claude/settings.json                 |  13 +++
 .github/workflows/lint.yml            |  22 +++++
 .github/workflows/pr-title.yml        |  36 ++++++++
 .github/workflows/release.yml         |  46 ++++++++++
 .gitignore                            |   1 +
 .golangci.yml                         |  33 +++++++
 .goreleaser.yaml                      |  37 ++++++++
 .release-please-manifest.json         |   3 +
 AGENTS.md                             |  44 ++++++++-
 go.mod                                |   3 +-
 go.sum                                |  15 +++-
 internal/cli/release_test.go          |  83 +++++++++++++++++
 internal/cli/root.go                  |  22 +++--
 internal/cli/scorecard.go             |   2 +-
 internal/cli/vision.go                |   6 +-
 internal/docspec/docspec.go           |   2 +-
 internal/generator/entity_mapper.go   |  24 ++---
 internal/generator/generator.go       |   6 +-
 internal/generator/readme_augment.go  |   4 +-
 internal/generator/validate_test.go   |  34 +++++++
 internal/graphql/parser.go            |   4 +-
 internal/llm/llm.go                   |   2 +-
 internal/llmpolish/prompts.go         |  14 +--
 internal/openapi/parser.go            |  39 ++------
 internal/pipeline/comparative.go      |  16 ++--
 internal/pipeline/conventions_test.go |  62 +++++++++++++
 internal/pipeline/dogfood.go          |   2 +-
 internal/pipeline/fullrun.go          |  42 ++++-----
 internal/pipeline/learnings.go        |   6 +-
 internal/pipeline/planner.go          |  58 ++++++------
 internal/pipeline/publish.go          |   8 +-
 internal/pipeline/remediate.go        |  49 ----------
 internal/pipeline/research.go         |  28 +++---
 internal/pipeline/runtime.go          |   2 +-
 internal/pipeline/scorecard.go        |  18 ++--
 internal/pipeline/selfimprove.go      |  14 +--
 internal/pipeline/spec_summary.go     | 164 ----------------------------------
 internal/pipeline/state.go            |   1 -
 internal/pipeline/verify_report.go    |  28 +++---
 internal/profiler/apitype.go          |   2 +-
 internal/profiler/profiler.go         |   6 +-
 internal/vision/report.go             |  38 ++++----
 release-please-config.json            |  25 ++++++
 43 files changed, 641 insertions(+), 423 deletions(-)

diff --git a/.claude/settings.json b/.claude/settings.json
index eca411a1..4dc3775a 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,5 +1,18 @@
 {
   "enabledPlugins": {
     "compound-engineering@every-marketplace": true
+  },
+  "hooks": {
+    "PostToolUse": [
+      {
+        "matcher": "Write|Edit",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "FILE=$(echo \"$CLAUDE_TOOL_INPUT\" | grep -o '\"[^\"]*\\.go\"' | head -1 | tr -d '\"'); if [ -n \"$FILE\" ] && [ -f \"$FILE\" ]; then gofmt -l \"$FILE\" 2>/dev/null | while read f; do echo \"gofmt: $f needs formatting. Run: gofmt -w $f\"; done; fi"
+          }
+        ]
+      }
+    ]
   }
 }
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 00000000..cc85a205
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,22 @@
+name: Lint
+
+on:
+  pull_request:
+
+permissions:
+  contents: read
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: actions/setup-go@v6
+        with:
+          go-version-file: go.mod
+          cache: true
+
+      - uses: golangci/golangci-lint-action@v9
+        with:
+          version: latest
diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml
new file mode 100644
index 00000000..bbbc300d
--- /dev/null
+++ b/.github/workflows/pr-title.yml
@@ -0,0 +1,36 @@
+name: PR Title
+
+on:
+  pull_request:
+    types: [opened, edited, synchronize, reopened]
+
+permissions:
+  pull-requests: read
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: amannn/action-semantic-pull-request@v6
+        with:
+          types: |
+            feat
+            fix
+            docs
+            chore
+            refactor
+            test
+            ci
+            perf
+            build
+            style
+            revert
+          scopes: |
+            cli
+            skills
+            ci
+          requireScope: true
+          subjectPattern: ^.+$
+          subjectPatternError: "PR title must have a description after the colon"
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..e54a6a4d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,46 @@
+name: Release
+
+on:
+  push:
+    branches:
+      - main
+
+permissions:
+  contents: write
+  issues: write
+  pull-requests: write
+
+jobs:
+  release-please:
+    runs-on: ubuntu-latest
+    outputs:
+      release_created: ${{ steps.release.outputs.release_created }}
+      tag_name: ${{ steps.release.outputs.tag_name }}
+    steps:
+      - uses: googleapis/release-please-action@v4
+        id: release
+        with:
+          release-type: go
+
+  goreleaser:
+    needs: release-please
+    if: ${{ needs.release-please.outputs.release_created }}
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+
+      - uses: actions/setup-go@v6
+        with:
+          go-version-file: go.mod
+          cache: true
+
+      - uses: goreleaser/goreleaser-action@v7
+        with:
+          version: latest
+          args: release --clean
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 0fd8a354..188490ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
 .cache/
 /printing-press
 library/
+dist/
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 00000000..19cf079b
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,33 @@
+version: "2"
+
+formatters:
+  enable:
+    - gofmt
+  exclusions:
+    paths:
+      - library
+      - dist
+
+linters:
+  enable:
+    - govet
+    - errcheck
+    - staticcheck
+    - unused
+  settings:
+    errcheck:
+      exclude-functions:
+        # CLI output — failure means stdout is broken, nothing to recover.
+        - fmt.Fprintf
+        - fmt.Fprintln
+        - fmt.Fprint
+        - (io.Writer).Write
+  exclusions:
+    paths:
+      - library
+      - dist
+    rules:
+      # Test files: unchecked errors in setup/cleanup helpers are noise.
+      - path: '_test\.go'
+        linters:
+          - errcheck
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
new file mode 100644
index 00000000..f558fed0
--- /dev/null
+++ b/.goreleaser.yaml
@@ -0,0 +1,37 @@
+version: 2
+
+gomod:
+  proxy: true
+
+builds:
+  - main: ./cmd/printing-press
+    binary: printing-press
+    env:
+      - CGO_ENABLED=0
+    goos:
+      - linux
+      - darwin
+      - windows
+    goarch:
+      - amd64
+      - arm64
+    flags:
+      - -trimpath
+    ldflags:
+      - -s -w -X github.com/mvanhorn/cli-printing-press/internal/cli.version={{.Version}}
+    mod_timestamp: "{{ .CommitTimestamp }}"
+
+archives:
+  - formats:
+      - tar.gz
+    name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
+    format_overrides:
+      - goos: windows
+        formats:
+          - zip
+
+checksum:
+  name_template: checksums.txt
+
+changelog:
+  use: github-native
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
new file mode 100644
index 00000000..2537c1f1
--- /dev/null
+++ b/.release-please-manifest.json
@@ -0,0 +1,3 @@
+{
+  ".": "0.4.0"
+}
diff --git a/AGENTS.md b/AGENTS.md
index e3756830..2bf49f13 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,13 +16,53 @@ go test ./...
 - `internal/openapi/` - OpenAPI 3.0+ parser
 - `internal/generator/` - Template engine + quality gates
 - `internal/catalog/` - Catalog schema validator
-- `catalog/` - API catalog entries (YAML)
+- `catalog/` - API catalog entries (YAML) + Go embed package (`catalog.FS`). Adding a YAML file here requires rebuilding the binary
 - `skills/` - Claude Code skill definitions
 - `testdata/` - Test fixtures (internal + OpenAPI specs)
 
 ## Commit Style
 
-Conventional commits: `feat(scope):`, `fix(scope):`, `docs:`, `refactor:`
+**Format:** `type(scope): description` — scope is always required.
+
+**Scopes** (these appear in changelogs and release notes):
+
+| Scope | Covers | Example |
+|-------|--------|---------|
+| `cli` | Go binary, commands, flags, embedded catalog, docs | `feat(cli): add catalog subcommands` |
+| `skills` | Skill definitions (SKILL.md), references, setup contract | `fix(skills): remove repo checkout requirement` |
+| `ci` | Workflows, release config, goreleaser | `feat(ci): add release-please` |
+
+Every commit and PR title must include one of these scopes. The `PR Title` action enforces this.
+
+**Breaking changes** use `!` after the scope: `feat(cli)!: rename catalog command to registry`. This triggers a major version bump.
+
+**Version bump rules** (release-please reads these from commit prefixes):
+- `fix(scope):` → patch (0.4.0 → 0.4.1)
+- `feat(scope):` → minor (0.4.0 → 0.5.0)
+- `feat(scope)!:` or `BREAKING CHANGE:` footer → major (0.4.0 → 1.0.0)
+- `docs:`, `chore:`, `refactor:`, `test:` → included in next release but don't trigger a bump alone
+
+**PR titles must follow the same format.** GitHub's "Squash and merge" uses the PR title as the squash commit message, so release-please reads PR titles on main. The `PR Title` GitHub Action (`.github/workflows/pr-title.yml`) enforces this — PRs with invalid titles cannot merge.
+
+## Versioning
+
+**Never manually edit version numbers.** Three files carry the version and release-please keeps them in sync:
+- `.claude-plugin/plugin.json` → `version`
+- `.claude-plugin/marketplace.json` → `plugins[0].version`
+- `internal/cli/root.go` → `var version` (annotated with `x-release-please-version`)
+
+`TestVersionConsistencyAcrossFiles` in `internal/cli/release_test.go` will fail if versions drift.
+
+## Release Process
+
+Releases are fully automated. No manual steps required.
+
+1. **Merge PRs to main** with conventional commit messages / PR titles
+2. **release-please opens a release PR** accumulating all changes since the last release, with a generated changelog
+3. **Merge the release PR** when ready to cut a release
+4. **Automated:** release-please bumps all three version files, creates a git tag, and creates a GitHub release
+5. **Automated:** goreleaser builds cross-platform binaries (linux/darwin/windows × amd64/arm64) and attaches them to the release
+6. **Users update** via `go install ...@latest` (picks up the new tag) or download binaries from the release
 
 ## Adding Catalog Entries
 
diff --git a/go.mod b/go.mod
index cb8d3133..a0ddccf9 100644
--- a/go.mod
+++ b/go.mod
@@ -3,14 +3,15 @@ module github.com/mvanhorn/cli-printing-press
 go 1.26.1
 
 require (
+	github.com/getkin/kin-openapi v0.133.0
 	github.com/spf13/cobra v1.10.2
 	github.com/stretchr/testify v1.11.1
+	golang.org/x/text v0.35.0
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/getkin/kin-openapi v0.133.0 // indirect
 	github.com/go-openapi/jsonpointer v0.21.0 // indirect
 	github.com/go-openapi/swag v0.23.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
diff --git a/go.sum b/go.sum
index 8e2e2b25..01ecb2f2 100644
--- a/go.sum
+++ b/go.sum
@@ -7,10 +7,16 @@ github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1
 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
+github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
@@ -23,6 +29,8 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX
 github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -30,10 +38,15 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
+github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
 github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
 github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/cli/release_test.go b/internal/cli/release_test.go
new file mode 100644
index 00000000..b360c107
--- /dev/null
+++ b/internal/cli/release_test.go
@@ -0,0 +1,83 @@
+package cli
+
+import (
+	"encoding/json"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
+)
+
+func TestGoreleaserLdflagsTargetMatchesVersionVar(t *testing.T) {
+	// The goreleaser config injects the version via ldflags into
+	// internal/cli.version. If the variable is renamed or moved,
+	// goreleaser silently injects into nothing and the binary
+	// reports the hardcoded fallback. This test catches that drift.
+
+	// 1. Verify the version variable exists and is settable in this package.
+	//    (If this test compiles, the variable exists. We just confirm it's a string.)
+	assert.IsType(t, "", version)
+
+	// 2. Verify the goreleaser config references the correct ldflags path.
+	data, err := os.ReadFile("../../.goreleaser.yaml")
+	require.NoError(t, err)
+
+	var config struct {
+		Builds []struct {
+			Ldflags []string `yaml:"ldflags"`
+		} `yaml:"builds"`
+	}
+	require.NoError(t, yaml.Unmarshal(data, &config))
+	require.NotEmpty(t, config.Builds)
+
+	ldflags := strings.Join(config.Builds[0].Ldflags, " ")
+	assert.Contains(t, ldflags,
+		"github.com/mvanhorn/cli-printing-press/internal/cli.version",
+		"goreleaser ldflags must target internal/cli.version")
+}
+
+func TestReleasePleaseAnnotationExists(t *testing.T) {
+	// release-please uses the x-release-please-version annotation
+	// to find and bump the hardcoded version in root.go. If the
+	// annotation is removed, release-please silently stops updating it.
+	data, err := os.ReadFile("root.go")
+	require.NoError(t, err)
+
+	assert.Contains(t, string(data), "x-release-please-version",
+		"root.go must have x-release-please-version annotation for automated version bumps")
+}
+
+func TestVersionConsistencyAcrossFiles(t *testing.T) {
+	// All version surfaces should match. release-please keeps them
+	// in sync, but this catches manual edits that drift.
+
+	// Read plugin.json version
+	pluginData, err := os.ReadFile("../../.claude-plugin/plugin.json")
+	require.NoError(t, err)
+
+	var plugin struct {
+		Version string `json:"version"`
+	}
+	require.NoError(t, json.Unmarshal(pluginData, &plugin))
+
+	// Read marketplace.json version
+	marketData, err := os.ReadFile("../../.claude-plugin/marketplace.json")
+	require.NoError(t, err)
+
+	var market struct {
+		Plugins []struct {
+			Version string `json:"version"`
+		} `json:"plugins"`
+	}
+	require.NoError(t, json.Unmarshal(marketData, &market))
+	require.NotEmpty(t, market.Plugins)
+
+	// All three should match
+	assert.Equal(t, plugin.Version, market.Plugins[0].Version,
+		"plugin.json and marketplace.json versions must match")
+	assert.Equal(t, plugin.Version, version,
+		"plugin.json and root.go hardcoded version must match")
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 347ed9da..04ad2200 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -25,7 +25,7 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-var version = "0.1.0"
+var version = "0.4.0" // x-release-please-version
 
 func Execute() error {
 	rootCmd := &cobra.Command{
@@ -155,13 +155,15 @@ func newGenerateCmd() *cobra.Command {
 
 				fmt.Fprintf(os.Stderr, "Generated %s at %s (from docs)\n", naming.CLI(parsed.Name), absOut)
 				if asJSON {
-					json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
+					if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
 						"name":       parsed.Name,
 						"output_dir": absOut,
 						"spec_files": specFiles,
 						"validated":  validate,
 						"polished":   polished,
-					})
+					}); err != nil {
+						return fmt.Errorf("encoding JSON: %w", err)
+					}
 				}
 				return nil
 			}
@@ -268,13 +270,15 @@ func newGenerateCmd() *cobra.Command {
 
 			fmt.Fprintf(os.Stderr, "Generated %s at %s\n", naming.CLI(apiSpec.Name), absOut)
 			if asJSON {
-				json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
+				if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
 					"name":       apiSpec.Name,
 					"output_dir": absOut,
 					"spec_files": specFiles,
 					"validated":  validate,
 					"polished":   polished,
-				})
+				}); err != nil {
+					return fmt.Errorf("encoding JSON: %w", err)
+				}
 			}
 			return nil
 		},
@@ -413,7 +417,7 @@ func fetchOrCacheSpec(specURL string, refresh bool, skipCache bool) ([]byte, err
 	if err != nil {
 		return nil, err
 	}
-	defer resp.Body.Close()
+	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
 		return nil, fmt.Errorf("unexpected response status: %s", resp.Status)
@@ -496,14 +500,16 @@ func newPrintCmd() *cobra.Command {
 			fmt.Fprintf(os.Stderr, "\nStart with: /ce:work %s\n", state.PlanPath(pipeline.PhasePreflight))
 
 			if asJSON {
-				json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
+				if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
 					"api_name":         apiName,
 					"pipeline_dir":     state.PipelineDir(),
 					"phases_completed": countCompletedPhases(state),
 					"state_file":       state.StatePath(),
 					"working_dir":      state.EffectiveWorkingDir(),
 					"run_id":           state.RunID,
-				})
+				}); err != nil {
+					return fmt.Errorf("encoding JSON: %w", err)
+				}
 			}
 			return nil
 		},
diff --git a/internal/cli/scorecard.go b/internal/cli/scorecard.go
index 8e983ff1..ab64a3f0 100644
--- a/internal/cli/scorecard.go
+++ b/internal/cli/scorecard.go
@@ -33,7 +33,7 @@ func newScorecardCmd() *cobra.Command {
 			if err != nil {
 				return fmt.Errorf("creating temp dir: %w", err)
 			}
-			defer os.RemoveAll(pipelineDir)
+			defer func() { _ = os.RemoveAll(pipelineDir) }()
 
 			sc, err := pipeline.RunScorecard(dir, pipelineDir, specPath, nil)
 			if err != nil {
diff --git a/internal/cli/vision.go b/internal/cli/vision.go
index 517b0882..b30b4113 100644
--- a/internal/cli/vision.go
+++ b/internal/cli/vision.go
@@ -85,10 +85,12 @@ The vision command produces the structure; Phase 0 fills it with intelligence.`,
 			fmt.Fprintf(os.Stderr, "Visionary research template written to %s/visionary-research.md\n", absOut)
 			fmt.Fprintf(os.Stderr, "Run Phase 0 (SKILL.md) to fill it with real research.\n")
 			if asJSON {
-				json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
+				if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
 					"api_name":    apiName,
 					"output_file": filepath.Join(absOut, "visionary-research.md"),
-				})
+				}); err != nil {
+					return fmt.Errorf("encoding JSON: %w", err)
+				}
 			}
 			return nil
 		},
diff --git a/internal/docspec/docspec.go b/internal/docspec/docspec.go
index d9328cfc..2001bbc4 100644
--- a/internal/docspec/docspec.go
+++ b/internal/docspec/docspec.go
@@ -78,7 +78,7 @@ func fetchHTML(url string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	defer resp.Body.Close()
+	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
 		return "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
diff --git a/internal/generator/entity_mapper.go b/internal/generator/entity_mapper.go
index 3b8840ab..67cc7428 100644
--- a/internal/generator/entity_mapper.go
+++ b/internal/generator/entity_mapper.go
@@ -24,19 +24,19 @@ type WorkflowTemplateContext struct {
 }
 
 type EntityMapping struct {
-	TableName      string
-	HumanSingular  string
-	HumanPlural    string
+	TableName       string
+	HumanSingular   string
+	HumanPlural     string
 	IdentifierField string
-	TitleField     string
-	UpdatedAtField string
-	AssigneeField  string
-	PriorityField  string
-	DueDateField   string
-	TeamField      string
-	LabelField     string
-	EstimateField  string
-	StateField     string
+	TitleField      string
+	UpdatedAtField  string
+	AssigneeField   string
+	PriorityField   string
+	DueDateField    string
+	TeamField       string
+	LabelField      string
+	EstimateField   string
+	StateField      string
 }
 
 func MapEntities(s *spec.APISpec, domain profiler.DomainSignals) WorkflowTemplateContext {
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 2c7e6b15..6649d26e 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -15,6 +15,8 @@ import (
 	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/profiler"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 )
 
 //go:embed templates
@@ -54,7 +56,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		templates: make(map[string]*template.Template),
 	}
 	g.funcs = template.FuncMap{
-		"title":              strings.Title,
+		"title":              cases.Title(language.English).String,
 		"lower":              strings.ToLower,
 		"upper":              strings.ToUpper,
 		"join":               strings.Join,
@@ -428,7 +430,7 @@ func (g *Generator) renderTemplate(tmplName, outPath string, data any) error {
 	if err != nil {
 		return fmt.Errorf("creating %s: %w", fullPath, err)
 	}
-	defer f.Close()
+	defer func() { _ = f.Close() }()
 
 	if err := tmpl.Execute(f, data); err != nil {
 		return fmt.Errorf("executing template %s: %w", tmplName, err)
diff --git a/internal/generator/readme_augment.go b/internal/generator/readme_augment.go
index 0515d004..7d7fbe7c 100644
--- a/internal/generator/readme_augment.go
+++ b/internal/generator/readme_augment.go
@@ -72,7 +72,7 @@ func AugmentREADME(readmePath, evidenceDir string) error {
 			resName := strings.TrimSuffix(e.Name(), "-help.txt")
 			output, err := os.ReadFile(filepath.Join(resDir, e.Name()))
 			if err == nil && len(output) > 0 {
-				section.WriteString(fmt.Sprintf("### %s\n\n```\n", resName))
+				fmt.Fprintf(&section, "### %s\n\n```\n", resName)
 				section.Write(output)
 				section.WriteString("```\n\n")
 			}
@@ -90,7 +90,7 @@ func AugmentREADME(readmePath, evidenceDir string) error {
 				output, err := os.ReadFile(filepath.Join(tier2Dir, e.Name()))
 				if err == nil && len(output) > 0 {
 					cmdName := strings.TrimSuffix(e.Name(), ".txt")
-					section.WriteString(fmt.Sprintf("#### %s\n\n```json\n", cmdName))
+					fmt.Fprintf(&section, "#### %s\n\n```json\n", cmdName)
 					// Truncate very long output
 					s := string(output)
 					if len(s) > 2000 {
diff --git a/internal/generator/validate_test.go b/internal/generator/validate_test.go
new file mode 100644
index 00000000..5f9498fb
--- /dev/null
+++ b/internal/generator/validate_test.go
@@ -0,0 +1,34 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGoBuildCacheDirIsShared(t *testing.T) {
+	// Two different project directories should get the same cache dir.
+	// This is critical for CI performance — shared cache avoids each
+	// parallel test recompiling the Go standard library from scratch.
+	dir1, err := goBuildCacheDir("/tmp/project-a")
+	require.NoError(t, err)
+
+	dir2, err := goBuildCacheDir("/tmp/project-b")
+	require.NoError(t, err)
+
+	assert.Equal(t, dir1, dir2, "different projects should share the same build cache")
+}
+
+func TestGoBuildCacheDirPath(t *testing.T) {
+	dir, err := goBuildCacheDir("/tmp/any-project")
+	require.NoError(t, err)
+
+	home, err := os.UserHomeDir()
+	require.NoError(t, err)
+
+	expected := filepath.Join(home, ".cache", "printing-press", "go-build")
+	assert.Equal(t, expected, dir)
+}
diff --git a/internal/graphql/parser.go b/internal/graphql/parser.go
index 74780269..64859559 100644
--- a/internal/graphql/parser.go
+++ b/internal/graphql/parser.go
@@ -10,6 +10,8 @@ import (
 	"unicode"
 
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 )
 
 var (
@@ -395,7 +397,7 @@ func mutationDescription(action, entityName string) string {
 	case "delete":
 		return "Delete a " + strings.ToLower(entityName)
 	default:
-		return strings.Title(action) + " " + strings.ToLower(entityName)
+		return cases.Title(language.English).String(action) + " " + strings.ToLower(entityName)
 	}
 }
 
diff --git a/internal/llm/llm.go b/internal/llm/llm.go
index 1ee46610..8ee50201 100644
--- a/internal/llm/llm.go
+++ b/internal/llm/llm.go
@@ -25,7 +25,7 @@ func Run(prompt string) (string, error) {
 	if err := os.WriteFile(tmpFile, []byte(prompt), 0644); err != nil {
 		return "", fmt.Errorf("writing prompt: %w", err)
 	}
-	defer os.Remove(tmpFile)
+	defer func() { _ = os.Remove(tmpFile) }()
 
 	// Try claude first (-p / --print mode, prompt as positional arg)
 	if path, err := exec.LookPath("claude"); err == nil {
diff --git a/internal/llmpolish/prompts.go b/internal/llmpolish/prompts.go
index bb839266..3be7915c 100644
--- a/internal/llmpolish/prompts.go
+++ b/internal/llmpolish/prompts.go
@@ -30,7 +30,7 @@ func buildHelpPrompt(outputDir string) string {
 	b.WriteString("Rules: under 80 chars, starts with a verb, no API jargon.\n\n")
 	b.WriteString("Current:\n")
 	for _, cmd := range commands {
-		b.WriteString(fmt.Sprintf("- %s: %q\n", cmd.Name, cmd.Description))
+		fmt.Fprintf(&b, "- %s: %q\n", cmd.Name, cmd.Description)
 	}
 	b.WriteString("\nReturn ONLY a JSON array, no other text:\n")
 	b.WriteString(`[{"command": "example", "description": "Improved description here"}]`)
@@ -50,7 +50,7 @@ func buildExamplePrompt(outputDir string) string {
 	cliName := filepath.Base(outputDir)
 
 	var b strings.Builder
-	b.WriteString(fmt.Sprintf("Write 2-3 realistic examples for each command in %s.\n", cliName))
+	fmt.Fprintf(&b, "Write 2-3 realistic examples for each command in %s.\n", cliName)
 	b.WriteString("Each example should show a real developer workflow with a comment.\n\n")
 	b.WriteString("Commands:\n")
 	for _, cmd := range commands {
@@ -66,10 +66,10 @@ func buildExamplePrompt(outputDir string) string {
 		if path == "" {
 			path = "/" + strings.ReplaceAll(cmd.Name, " ", "/")
 		}
-		b.WriteString(fmt.Sprintf("- %s (%s %s, flags: %s)\n", cmd.Name, method, path, flags))
+		fmt.Fprintf(&b, "- %s (%s %s, flags: %s)\n", cmd.Name, method, path, flags)
 	}
 	b.WriteString("\nReturn ONLY a JSON array, no other text:\n")
-	b.WriteString(fmt.Sprintf(`[{"command": "example list", "examples": ["# List all examples\n%s example list --limit 10"]}]`, cliName))
+	fmt.Fprintf(&b, `[{"command": "example list", "examples": ["# List all examples\n%s example list --limit 10"]}]`, cliName)
 	b.WriteString("\n")
 
 	return b.String()
@@ -88,9 +88,9 @@ func buildREADMEPrompt(outputDir, apiName string) string {
 	}
 
 	var b strings.Builder
-	b.WriteString(fmt.Sprintf("Rewrite this CLI README to sell the tool to developers.\n\n"))
-	b.WriteString(fmt.Sprintf("API: %s\n", apiName))
-	b.WriteString(fmt.Sprintf("Current README:\n%s\n\n", string(content)))
+	b.WriteString("Rewrite this CLI README to sell the tool to developers.\n\n")
+	fmt.Fprintf(&b, "API: %s\n", apiName)
+	fmt.Fprintf(&b, "Current README:\n%s\n\n", string(content))
 	b.WriteString("Write a README with:\n")
 	b.WriteString("1. One-line hook that makes developers want to install it\n")
 	b.WriteString("2. Why this exists (what gap it fills)\n")
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 64b3f106..74ab49d7 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -13,6 +13,8 @@ import (
 
 	"github.com/getkin/kin-openapi/openapi3"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 )
 
 const (
@@ -423,13 +425,11 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 		var targetEndpoints map[string]spec.Endpoint
 		targetResourceName := primaryName
 		if subName != "" {
-			sub, ok := resource.SubResources[subName]
-			if !ok {
-				sub = spec.Resource{
+			if _, ok := resource.SubResources[subName]; !ok {
+				resource.SubResources[subName] = spec.Resource{
 					Description: tagDescriptions[subName],
 					Endpoints:   map[string]spec.Endpoint{},
 				}
-				resource.SubResources[subName] = sub
 			}
 			targetEndpoints = resource.SubResources[subName].Endpoints
 			targetResourceName = subName
@@ -721,20 +721,6 @@ func mapTagDescriptions(tags openapi3.Tags) map[string]string {
 	return out
 }
 
-func resourceDescription(op *openapi3.Operation, tagDescriptions map[string]string) string {
-	if op == nil {
-		return ""
-	}
-	for _, tag := range op.Tags {
-		for _, key := range tagDescriptionKeys(tag) {
-			if desc := tagDescriptions[key]; desc != "" {
-				return desc
-			}
-		}
-	}
-	return ""
-}
-
 func tagDescriptionKeys(name string) []string {
 	name = strings.TrimSpace(name)
 	if name == "" {
@@ -1358,11 +1344,6 @@ func firstJSONMediaType(content openapi3.Content) *openapi3.MediaType {
 	return nil
 }
 
-func resourceNameFromPath(path, basePath string) string {
-	primary, _ := resourceAndSubFromPath(path, basePath, nil)
-	return primary
-}
-
 func resourceAndSubFromPath(path, basePath string, commonPrefix []string) (string, string) {
 	segments := pathSegmentsAfterBase(path, basePath)
 	if len(commonPrefix) > 0 && hasSegmentPrefix(segments, commonPrefix) {
@@ -1747,7 +1728,6 @@ func toSnakeCase(input string) string {
 		if unicode.IsLetter(r) || unicode.IsDigit(r) {
 			if unicode.IsUpper(r) && i > 0 && (unicode.IsLower(prev) || unicode.IsDigit(prev)) && !lastUnderscore {
 				b.WriteByte('_')
-				lastUnderscore = true
 			}
 			b.WriteRune(unicode.ToLower(r))
 			lastUnderscore = false
@@ -2050,15 +2030,6 @@ func isRequired(required map[string]struct{}, name string) bool {
 	return ok
 }
 
-func firstNonEmpty(values ...string) string {
-	for _, value := range values {
-		if value != "" {
-			return value
-		}
-	}
-	return ""
-}
-
 func selectDescription(summary, description string) string {
 	summaryHasSpaces := summary != "" && strings.ContainsAny(summary, " \t")
 
@@ -2195,7 +2166,7 @@ func humanizeConcatenated(s string) string {
 			rest := lower[len(prefix):]
 			words := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(toSnakeCase(rest), "_", " "), "-", " "))
 			if len(words) > 0 {
-				sentence := strings.Title(prefix) + " " + strings.Join(words, " ")
+				sentence := cases.Title(language.English).String(prefix) + " " + strings.Join(words, " ")
 				return sentence
 			}
 		}
diff --git a/internal/pipeline/comparative.go b/internal/pipeline/comparative.go
index 5cae53e6..1fc58c8a 100644
--- a/internal/pipeline/comparative.go
+++ b/internal/pipeline/comparative.go
@@ -184,18 +184,18 @@ func writeComparativeReport(result *ComparativeResult, research *ResearchResult,
 	var b strings.Builder
 
 	b.WriteString("# Comparative Analysis\n\n")
-	b.WriteString(fmt.Sprintf("**Recommendation: %s**\n\n", strings.ToUpper(result.Recommendation)))
-	b.WriteString(fmt.Sprintf("Our score: %d/100\n\n", result.OurScore))
+	fmt.Fprintf(&b, "**Recommendation: %s**\n\n", strings.ToUpper(result.Recommendation))
+	fmt.Fprintf(&b, "Our score: %d/100\n\n", result.OurScore)
 
 	// Score table
 	if len(result.Alternatives) > 0 {
 		b.WriteString("## Score Table\n\n")
 		b.WriteString("| Tool | Breadth | Install | Auth | Output | Agent | Fresh | Total |\n")
 		b.WriteString("|------|---------|---------|------|--------|-------|-------|-------|\n")
-		b.WriteString(fmt.Sprintf("| **Ours** | 20 | 15 | 15 | 15 | 15 | 15 | **%d** |\n", result.OurScore))
+		fmt.Fprintf(&b, "| **Ours** | 20 | 15 | 15 | 15 | 15 | 15 | **%d** |\n", result.OurScore)
 		for _, a := range result.Alternatives {
-			b.WriteString(fmt.Sprintf("| %s | %d | %d | %d | %d | %d | %d | **%d** |\n",
-				a.Name, a.Breadth, a.InstallFriction, a.AuthUX, a.OutputFormats, a.AgentFriendly, a.Freshness, a.Total))
+			fmt.Fprintf(&b, "| %s | %d | %d | %d | %d | %d | %d | **%d** |\n",
+				a.Name, a.Breadth, a.InstallFriction, a.AuthUX, a.OutputFormats, a.AgentFriendly, a.Freshness, a.Total)
 		}
 		b.WriteString("\n")
 	} else {
@@ -205,20 +205,20 @@ func writeComparativeReport(result *ComparativeResult, research *ResearchResult,
 	// Advantages
 	b.WriteString("## Our Advantages\n\n")
 	for _, a := range result.Advantages {
-		b.WriteString(fmt.Sprintf("- %s\n", a))
+		fmt.Fprintf(&b, "- %s\n", a)
 	}
 	b.WriteString("\n")
 
 	// Gaps
 	b.WriteString("## Gaps to Address\n\n")
 	for _, g := range result.Gaps {
-		b.WriteString(fmt.Sprintf("- %s\n", g))
+		fmt.Fprintf(&b, "- %s\n", g)
 	}
 	b.WriteString("\n")
 
 	// Novelty context from research
 	if research != nil && research.NoveltyScore > 0 {
-		b.WriteString(fmt.Sprintf("## Research Context\n\nNovelty score: %d/10 (%s)\n", research.NoveltyScore, research.Recommendation))
+		fmt.Fprintf(&b, "## Research Context\n\nNovelty score: %d/10 (%s)\n", research.NoveltyScore, research.Recommendation)
 	}
 
 	return os.WriteFile(filepath.Join(pipelineDir, "comparative-analysis.md"), []byte(b.String()), 0o644)
diff --git a/internal/pipeline/conventions_test.go b/internal/pipeline/conventions_test.go
new file mode 100644
index 00000000..69e5052a
--- /dev/null
+++ b/internal/pipeline/conventions_test.go
@@ -0,0 +1,62 @@
+package pipeline
+
+import (
+	"regexp"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// conventionalCommitPattern validates the format used in PR titles and
+// commit messages. This pattern is mirrored by the PR title GitHub Action
+// (.github/workflows/pr-title.yml) — keep them in sync.
+//
+// Scopes: cli, skills, ci (required on all types).
+// Breaking changes: ! after scope.
+var conventionalCommitPattern = regexp.MustCompile(
+	`^(feat|fix|docs|chore|refactor|test|ci|perf|build|style|revert)` +
+		`\((cli|skills|ci)\)` +
+		`!?` +
+		`: .+`)
+
+func TestConventionalCommitPatternAcceptsValid(t *testing.T) {
+	valid := []string{
+		"feat(cli): add catalog subcommands",
+		"fix(skills): remove repo checkout requirement",
+		"feat(ci): add release-please",
+		"feat(cli)!: rename catalog command to registry",
+		"docs(cli): update version flag examples",
+		"chore(ci): bump dependencies",
+		"refactor(cli): extract helper function",
+		"test(cli): add coverage for edge case",
+		"fix(cli)!: breaking change with bang",
+		"ci(ci): update workflow",
+	}
+
+	for _, msg := range valid {
+		t.Run(msg, func(t *testing.T) {
+			assert.Regexp(t, conventionalCommitPattern, msg)
+		})
+	}
+}
+
+func TestConventionalCommitPatternRejectsInvalid(t *testing.T) {
+	invalid := []string{
+		"Add new feature",
+		"updated the readme",
+		"WIP stuff",
+		"FEAT(cli): wrong case",
+		"feat:missing space",
+		"feat(): empty scope",
+		"feat: missing scope",
+		"docs: missing scope",
+		"feat(random): invalid scope",
+		"fix(anything): invalid scope",
+	}
+
+	for _, msg := range invalid {
+		t.Run(msg, func(t *testing.T) {
+			assert.NotRegexp(t, conventionalCommitPattern, msg)
+		})
+	}
+}
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index ca28e653..d971bedb 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -579,7 +579,7 @@ func checkExamples(dir string) ExampleCheckResult {
 		result.Detail = fmt.Sprintf("could not build CLI binary: %v", err)
 		return result
 	}
-	defer os.Remove(binaryPath)
+	defer func() { _ = os.Remove(binaryPath) }()
 
 	// Get global flags from root --help
 	globalOut, err := runDogfoodCmd(binaryPath, 15*time.Second, "--help")
diff --git a/internal/pipeline/fullrun.go b/internal/pipeline/fullrun.go
index 80edab38..15b01f19 100644
--- a/internal/pipeline/fullrun.go
+++ b/internal/pipeline/fullrun.go
@@ -186,7 +186,7 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
 		result.DogfoodError = fmt.Sprintf("build failed: %v", buildErr)
 		result.Errors = append(result.Errors, fmt.Sprintf("dogfood build: %v", buildErr))
 	} else {
-		defer os.Remove(cliBinaryPath)
+		defer func() { _ = os.Remove(cliBinaryPath) }()
 		dogfood, dogErr := RunDogfood(workingDir, qualitySpecPath)
 		if dogErr != nil {
 			result.DogfoodError = dogErr.Error()
@@ -289,7 +289,7 @@ func readSpecBytes(specURL string) ([]byte, error) {
 		if err != nil {
 			return nil, err
 		}
-		defer resp.Body.Close()
+		defer func() { _ = resp.Body.Close() }()
 		if resp.StatusCode != http.StatusOK {
 			return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, specURL)
 		}
@@ -374,9 +374,9 @@ func PrintComparisonTable(results []*FullRunResult) string {
 	b.WriteString("=== CLI Printing Press - Full Run Comparison ===\n\n")
 
 	// Header
-	b.WriteString(fmt.Sprintf("%-25s", "Metric"))
+	fmt.Fprintf(&b, "%-25s", "Metric")
 	for _, r := range results {
-		b.WriteString(fmt.Sprintf("| %-18s", r.APIName+" ("+r.Level+")"))
+		fmt.Fprintf(&b, "| %-18s", r.APIName+" ("+r.Level+")")
 	}
 	b.WriteString("|\n")
 
@@ -532,9 +532,9 @@ func PrintComparisonTable(results []*FullRunResult) string {
 }
 
 func writeRow(b *strings.Builder, label string, results []*FullRunResult, fn func(*FullRunResult) string) {
-	b.WriteString(fmt.Sprintf("%-25s", label))
+	fmt.Fprintf(b, "%-25s", label)
 	for _, r := range results {
-		b.WriteString(fmt.Sprintf("| %-18s", fn(r)))
+		fmt.Fprintf(b, "| %-18s", fn(r))
 	}
 	b.WriteString("|\n")
 }
@@ -548,7 +548,7 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
 
 	var b strings.Builder
 	b.WriteString("# Learnings Plan - CLI Printing Press Full Run\n\n")
-	b.WriteString(fmt.Sprintf("Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
+	fmt.Fprintf(&b, "Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05"))
 
 	// Summarize runs
 	b.WriteString("## Runs\n\n")
@@ -557,8 +557,8 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
 		if len(r.Errors) > 0 {
 			status = fmt.Sprintf("%d errors", len(r.Errors))
 		}
-		b.WriteString(fmt.Sprintf("- **%s** (%s) - Gates %d/7, Duration %s, Status: %s\n",
-			r.APIName, r.Level, r.GatesPassed, r.Duration.Round(time.Second), status))
+		fmt.Fprintf(&b, "- **%s** (%s) - Gates %d/7, Duration %s, Status: %s\n",
+			r.APIName, r.Level, r.GatesPassed, r.Duration.Round(time.Second), status)
 	}
 	b.WriteString("\n")
 
@@ -606,7 +606,7 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
 		avg := t.sum / t.total
 		if t.lowCount > 0 {
 			hasGaps = true
-			b.WriteString(fmt.Sprintf("- **%s** - low in %d/%d runs (avg %d/10)\n", name, t.lowCount, t.total, avg))
+			fmt.Fprintf(&b, "- **%s** - low in %d/%d runs (avg %d/10)\n", name, t.lowCount, t.total, avg)
 		}
 	}
 	if !hasGaps {
@@ -623,12 +623,12 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
 		if t.total == 0 || t.lowCount == 0 {
 			continue
 		}
-		b.WriteString(fmt.Sprintf("%d. **Improve %s templates** - affects %d/%d APIs tested\n",
-			priority, name, t.lowCount, t.total))
+		fmt.Fprintf(&b, "%d. **Improve %s templates** - affects %d/%d APIs tested\n",
+			priority, name, t.lowCount, t.total)
 		if advice, ok := dimensionAdvice[name]; ok {
 			// Include first line of advice
 			lines := strings.SplitN(advice, "\n", 2)
-			b.WriteString(fmt.Sprintf("   - %s\n", strings.TrimSpace(lines[0])))
+			fmt.Fprintf(&b, "   - %s\n", strings.TrimSpace(lines[0]))
 		}
 		priority++
 	}
@@ -641,7 +641,7 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
 	b.WriteString("## Gate Failures\n\n")
 	for _, r := range results {
 		if r.GatesFailed > 0 {
-			b.WriteString(fmt.Sprintf("- **%s** - %d gates failed\n", r.APIName, r.GatesFailed))
+			fmt.Fprintf(&b, "- **%s** - %d gates failed\n", r.APIName, r.GatesFailed)
 		}
 	}
 	allPassed := true
@@ -661,13 +661,13 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
 	for _, r := range results {
 		if r.Dogfood != nil {
 			if r.Dogfood.PathCheck.Tested > 0 {
-				b.WriteString(fmt.Sprintf("- **%s** - %s, path validity %d/%d (%d%%)\n",
-					r.APIName, r.Dogfood.Verdict, r.Dogfood.PathCheck.Valid, r.Dogfood.PathCheck.Tested, r.Dogfood.PathCheck.Pct))
+				fmt.Fprintf(&b, "- **%s** - %s, path validity %d/%d (%d%%)\n",
+					r.APIName, r.Dogfood.Verdict, r.Dogfood.PathCheck.Valid, r.Dogfood.PathCheck.Tested, r.Dogfood.PathCheck.Pct)
 			} else {
-				b.WriteString(fmt.Sprintf("- **%s** - %s\n", r.APIName, r.Dogfood.Verdict))
+				fmt.Fprintf(&b, "- **%s** - %s\n", r.APIName, r.Dogfood.Verdict)
 			}
 		} else {
-			b.WriteString(fmt.Sprintf("- **%s** - dogfood not run (%s)\n", r.APIName, r.DogfoodError))
+			fmt.Fprintf(&b, "- **%s** - dogfood not run (%s)\n", r.APIName, r.DogfoodError)
 		}
 	}
 	b.WriteString("\n")
@@ -676,14 +676,14 @@ func GenerateLearningsPlan(results []*FullRunResult, outputPath string) error {
 	b.WriteString("## Verification Summary\n\n")
 	for _, r := range results {
 		if r.Verification != nil {
-			b.WriteString(fmt.Sprintf("- **%s** - %s (paths:%d flags:%d ghost:%d fts:%d)\n",
+			fmt.Fprintf(&b, "- **%s** - %s (paths:%d flags:%d ghost:%d fts:%d)\n",
 				r.APIName, r.Verification.Verdict,
 				r.Verification.HallucinatedPaths,
 				r.Verification.DeadFlags,
 				r.Verification.GhostTables,
-				r.Verification.OrphanFTS))
+				r.Verification.OrphanFTS)
 		} else {
-			b.WriteString(fmt.Sprintf("- **%s** - not run (%s)\n", r.APIName, r.VerificationError))
+			fmt.Fprintf(&b, "- **%s** - not run (%s)\n", r.APIName, r.VerificationError)
 		}
 	}
 	b.WriteString("\n")
diff --git a/internal/pipeline/learnings.go b/internal/pipeline/learnings.go
index b75e473a..e7547278 100644
--- a/internal/pipeline/learnings.go
+++ b/internal/pipeline/learnings.go
@@ -9,9 +9,9 @@ import (
 
 // PressLearning records what happened during a single pipeline run.
 type PressLearning struct {
-	APIName  string         `json:"api_name"`
-	Date     time.Time      `json:"date"`
-	SpecType string         `json:"spec_type"`
+	APIName  string          `json:"api_name"`
+	Date     time.Time       `json:"date"`
+	SpecType string          `json:"spec_type"`
 	Issues   []LearningIssue `json:"issues"`
 	Fixes    []LearningFix   `json:"fixes"`
 }
diff --git a/internal/pipeline/planner.go b/internal/pipeline/planner.go
index 1af18d1b..bfe4df47 100644
--- a/internal/pipeline/planner.go
+++ b/internal/pipeline/planner.go
@@ -59,7 +59,7 @@ func generateScaffoldPlan(ctx PlanContext) (string, error) {
 	writePlanHeader(&b, ctx.SeedData.APIName, "scaffold", "Generate the CLI with intelligence from research")
 
 	b.WriteString("## Phase Goal\n\n")
-	b.WriteString(fmt.Sprintf("Generate the %s CLI from the validated OpenAPI spec, incorporating research insights.\n\n", ctx.SeedData.APIName))
+	fmt.Fprintf(&b, "Generate the %s CLI from the validated OpenAPI spec, incorporating research insights.\n\n", ctx.SeedData.APIName)
 
 	b.WriteString("## Context\n\n")
 	writePipelineContext(&b, ctx.SeedData)
@@ -67,22 +67,22 @@ func generateScaffoldPlan(ctx PlanContext) (string, error) {
 	// Dynamic section: research insights
 	if ctx.Research != nil {
 		b.WriteString("## Research Insights\n\n")
-		b.WriteString(fmt.Sprintf("- **Novelty score:** %d/10 (%s)\n", ctx.Research.NoveltyScore, ctx.Research.Recommendation))
-		b.WriteString(fmt.Sprintf("- **Alternatives found:** %d\n", len(ctx.Research.Alternatives)))
+		fmt.Fprintf(&b, "- **Novelty score:** %d/10 (%s)\n", ctx.Research.NoveltyScore, ctx.Research.Recommendation)
+		fmt.Fprintf(&b, "- **Alternatives found:** %d\n", len(ctx.Research.Alternatives))
 
 		if ctx.Research.CompetitorInsights != nil {
 			ci := ctx.Research.CompetitorInsights
-			b.WriteString(fmt.Sprintf("- **Command target:** %d (based on competitor analysis)\n", ci.CommandTarget))
+			fmt.Fprintf(&b, "- **Command target:** %d (based on competitor analysis)\n", ci.CommandTarget)
 			if len(ci.UnmetFeatures) > 0 {
 				b.WriteString("- **Unmet features to include:**\n")
 				for _, f := range ci.UnmetFeatures[:min(5, len(ci.UnmetFeatures))] {
-					b.WriteString(fmt.Sprintf("  - %s\n", f))
+					fmt.Fprintf(&b, "  - %s\n", f)
 				}
 			}
 			if len(ci.PainPointsToAvoid) > 0 {
 				b.WriteString("- **Pain points to avoid:**\n")
 				for _, p := range ci.PainPointsToAvoid[:min(3, len(ci.PainPointsToAvoid))] {
-					b.WriteString(fmt.Sprintf("  - %s\n", p))
+					fmt.Fprintf(&b, "  - %s\n", p)
 				}
 			}
 		}
@@ -96,16 +96,16 @@ func generateScaffoldPlan(ctx PlanContext) (string, error) {
 		if len(suggestions) > 0 {
 			b.WriteString("Suggested flags based on past issues:\n")
 			for _, s := range suggestions {
-				b.WriteString(fmt.Sprintf("- `%s`\n", s))
+				fmt.Fprintf(&b, "- `%s`\n", s)
 			}
 			b.WriteString("\n")
 		}
 	}
 
 	b.WriteString("## What This Phase Must Produce\n\n")
-	b.WriteString(fmt.Sprintf("- Generated CLI source tree in %s\n", ctx.SeedData.OutputDir))
+	fmt.Fprintf(&b, "- Generated CLI source tree in %s\n", ctx.SeedData.OutputDir)
 	b.WriteString("- All seven generator quality gates passing\n")
-	b.WriteString(fmt.Sprintf("- Working CLI binary for %s\n\n", ctx.SeedData.APIName))
+	fmt.Fprintf(&b, "- Working CLI binary for %s\n\n", ctx.SeedData.APIName)
 
 	b.WriteString("## Codebase Pointers\n\n")
 	b.WriteString("- Generator entrypoint: printing-press generate --spec <url> --output <dir>\n")
@@ -130,17 +130,17 @@ func generateEnrichPlan(ctx PlanContext) (string, error) {
 		if len(ci.UnmetFeatures) > 0 {
 			b.WriteString("Features that competitors requested but never got (add these):\n")
 			for _, f := range ci.UnmetFeatures {
-				b.WriteString(fmt.Sprintf("- [ ] %s\n", f))
+				fmt.Fprintf(&b, "- [ ] %s\n", f)
 			}
 			b.WriteString("\n")
 		}
 		if ci.CommandTarget > 0 {
-			b.WriteString(fmt.Sprintf("**Target:** Generate at least %d commands (competitors max: %d)\n\n", ci.CommandTarget, int(float64(ci.CommandTarget)/1.2)))
+			fmt.Fprintf(&b, "**Target:** Generate at least %d commands (competitors max: %d)\n\n", ci.CommandTarget, int(float64(ci.CommandTarget)/1.2))
 		}
 	}
 
 	b.WriteString("## What This Phase Must Produce\n\n")
-	b.WriteString(fmt.Sprintf("- overlay.yaml in %s\n", ctx.SeedData.PipelineDir))
+	fmt.Fprintf(&b, "- overlay.yaml in %s\n", ctx.SeedData.PipelineDir)
 	b.WriteString("- At least one verified enrichment\n")
 	b.WriteString("- Overlay valid for downstream merge and regeneration\n\n")
 
@@ -168,9 +168,9 @@ func generateReviewPlan(ctx PlanContext) (string, error) {
 	b.WriteString("5. If any major dimension still fails, generate fix plans\n\n")
 
 	b.WriteString("## What This Phase Must Produce\n\n")
-	b.WriteString(fmt.Sprintf("- dogfood-results.json in %s\n", ctx.SeedData.PipelineDir))
-	b.WriteString(fmt.Sprintf("- scorecard.md in %s\n", ctx.SeedData.PipelineDir))
-	b.WriteString(fmt.Sprintf("- review.md in %s\n", ctx.SeedData.PipelineDir))
+	fmt.Fprintf(&b, "- dogfood-results.json in %s\n", ctx.SeedData.PipelineDir)
+	fmt.Fprintf(&b, "- scorecard.md in %s\n", ctx.SeedData.PipelineDir)
+	fmt.Fprintf(&b, "- review.md in %s\n", ctx.SeedData.PipelineDir)
 	b.WriteString("- Fix plans for any low-scoring dimensions\n")
 
 	return b.String(), nil
@@ -187,18 +187,18 @@ func generateComparativePlan(ctx PlanContext) (string, error) {
 
 	if ctx.Scorecard != nil {
 		b.WriteString("## Current Steinberger Score\n\n")
-		b.WriteString(fmt.Sprintf("- Overall: %d%% (%s)\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+		fmt.Fprintf(&b, "- Overall: %d%% (%s)\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
 		if len(ctx.Scorecard.GapReport) > 0 {
 			b.WriteString("- Gaps:\n")
 			for _, g := range ctx.Scorecard.GapReport {
-				b.WriteString(fmt.Sprintf("  - %s\n", g))
+				fmt.Fprintf(&b, "  - %s\n", g)
 			}
 		}
 		b.WriteString("\n")
 	}
 
 	b.WriteString("## What This Phase Must Produce\n\n")
-	b.WriteString(fmt.Sprintf("- comparative-analysis.md in %s\n\n", ctx.SeedData.PipelineDir))
+	fmt.Fprintf(&b, "- comparative-analysis.md in %s\n\n", ctx.SeedData.PipelineDir)
 
 	return b.String(), nil
 }
@@ -216,17 +216,17 @@ func generateShipPlan(ctx PlanContext) (string, error) {
 	if ctx.Scorecard != nil {
 		b.WriteString("## Ship Decision\n\n")
 		if ctx.Scorecard.Steinberger.Percentage >= 65 {
-			b.WriteString(fmt.Sprintf("**SHIP** - Quality score %d%% (grade %s) meets threshold.\n\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+			fmt.Fprintf(&b, "**SHIP** - Quality score %d%% (grade %s) meets threshold.\n\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
 		} else {
-			b.WriteString(fmt.Sprintf("**HOLD** - Quality score %d%% (grade %s) is below 65%% threshold.\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+			fmt.Fprintf(&b, "**HOLD** - Quality score %d%% (grade %s) is below 65%% threshold.\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade)
 			b.WriteString("Fix the gaps identified in the scorecard before shipping.\n\n")
 		}
 	}
 
 	b.WriteString("## What This Phase Must Produce\n\n")
-	b.WriteString(fmt.Sprintf("- Git repository initialized in %s\n", ctx.SeedData.OutputDir))
+	fmt.Fprintf(&b, "- Git repository initialized in %s\n", ctx.SeedData.OutputDir)
 	b.WriteString("- GoReleaser config validated\n")
-	b.WriteString(fmt.Sprintf("- Morning report in %s\n", ctx.SeedData.PipelineDir))
+	fmt.Fprintf(&b, "- Morning report in %s\n", ctx.SeedData.PipelineDir)
 
 	return b.String(), nil
 }
@@ -235,20 +235,20 @@ func generateShipPlan(ctx PlanContext) (string, error) {
 
 func writePlanHeader(b *strings.Builder, apiName, phase, title string) {
 	b.WriteString("---\n")
-	b.WriteString(fmt.Sprintf("title: \"%s CLI Pipeline - %s\"\n", apiName, title))
+	fmt.Fprintf(b, "title: \"%s CLI Pipeline - %s\"\n", apiName, title)
 	b.WriteString("type: feat\n")
 	b.WriteString("status: seed\n")
-	b.WriteString(fmt.Sprintf("pipeline_phase: %s\n", phase))
-	b.WriteString(fmt.Sprintf("pipeline_api: %s\n", apiName))
-	b.WriteString(fmt.Sprintf("date: %s\n", time.Now().Format("2006-01-02")))
+	fmt.Fprintf(b, "pipeline_phase: %s\n", phase)
+	fmt.Fprintf(b, "pipeline_api: %s\n", apiName)
+	fmt.Fprintf(b, "date: %s\n", time.Now().Format("2006-01-02"))
 	b.WriteString("---\n\n")
 }
 
 func writePipelineContext(b *strings.Builder, sd SeedData) {
 	b.WriteString("## Context\n\n")
-	b.WriteString(fmt.Sprintf("- Pipeline directory: %s\n", sd.PipelineDir))
-	b.WriteString(fmt.Sprintf("- Output directory: %s\n", sd.OutputDir))
-	b.WriteString(fmt.Sprintf("- Spec URL: %s\n\n", sd.SpecURL))
+	fmt.Fprintf(b, "- Pipeline directory: %s\n", sd.PipelineDir)
+	fmt.Fprintf(b, "- Output directory: %s\n", sd.OutputDir)
+	fmt.Fprintf(b, "- Spec URL: %s\n\n", sd.SpecURL)
 }
 
 func loadResearchForPlanState(state *PipelineState) (*ResearchResult, error) {
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 7bcc2f5a..637cb767 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -162,7 +162,7 @@ func CopyDir(src, dst string) error {
 		return err
 	}
 
-	return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
+	return filepath.Walk(src, func(path string, _ os.FileInfo, walkErr error) error {
 		if walkErr != nil {
 			return walkErr
 		}
@@ -176,7 +176,7 @@ func CopyDir(src, dst string) error {
 		}
 		target := filepath.Join(dst, rel)
 
-		info, err = os.Lstat(path)
+		info, err := os.Lstat(path)
 		if err != nil {
 			return err
 		}
@@ -206,13 +206,13 @@ func copyFile(src, dst string, mode os.FileMode) error {
 	if err != nil {
 		return err
 	}
-	defer in.Close()
+	defer func() { _ = in.Close() }()
 
 	out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
 	if err != nil {
 		return err
 	}
-	defer out.Close()
+	defer func() { _ = out.Close() }()
 
 	if _, err := io.Copy(out, in); err != nil {
 		return err
diff --git a/internal/pipeline/remediate.go b/internal/pipeline/remediate.go
index cdfc78f0..9a26f621 100644
--- a/internal/pipeline/remediate.go
+++ b/internal/pipeline/remediate.go
@@ -150,55 +150,6 @@ func removeDeadFlags(dir string, deadFlags []string) error {
 	return os.WriteFile(rootPath, []byte(content), 0o644)
 }
 
-func removeDeadFunctions(dir string, deadFuncs []string) error {
-	helpersPath := filepath.Join(dir, "internal", "cli", "helpers.go")
-	data, err := os.ReadFile(helpersPath)
-	if os.IsNotExist(err) {
-		return nil
-	}
-	if err != nil {
-		return fmt.Errorf("reading helpers.go: %w", err)
-	}
-
-	lines := strings.Split(string(data), "\n")
-
-	for _, funcName := range deadFuncs {
-		funcSig := "func " + funcName + "("
-		startIdx := -1
-		for i, line := range lines {
-			if strings.Contains(line, funcSig) {
-				startIdx = i
-				for startIdx > 0 && strings.HasPrefix(strings.TrimSpace(lines[startIdx-1]), "//") {
-					startIdx--
-				}
-				break
-			}
-		}
-		if startIdx == -1 {
-			continue
-		}
-
-		braceCount := 0
-		endIdx := -1
-		for i := startIdx; i < len(lines); i++ {
-			braceCount += strings.Count(lines[i], "{") - strings.Count(lines[i], "}")
-			if braceCount > 0 || strings.Contains(lines[i], "{") {
-				if braceCount == 0 && i > startIdx {
-					endIdx = i
-					break
-				}
-			}
-		}
-		if endIdx == -1 {
-			continue
-		}
-
-		lines = append(lines[:startIdx], lines[endIdx+1:]...)
-	}
-
-	return os.WriteFile(helpersPath, []byte(strings.Join(lines, "\n")), 0o644)
-}
-
 func removeGhostTables(dir string, ghostTables []string) error {
 	storePath := filepath.Join(dir, "internal", "store", "store.go")
 	data, err := os.ReadFile(storePath)
diff --git a/internal/pipeline/research.go b/internal/pipeline/research.go
index c230236b..6ca34013 100644
--- a/internal/pipeline/research.go
+++ b/internal/pipeline/research.go
@@ -19,14 +19,14 @@ import (
 
 // ResearchResult holds the output of the research phase.
 type ResearchResult struct {
-	APIName             string              `json:"api_name"`
-	NoveltyScore        int                 `json:"novelty_score"` // 1-10
-	Alternatives        []Alternative       `json:"alternatives"`
-	Gaps                []string            `json:"gaps"`           // what alternatives miss
-	Patterns            []string            `json:"patterns"`       // what alternatives do well
-	Recommendation      string              `json:"recommendation"` // "proceed", "proceed-with-gaps", "skip"
-	ResearchedAt        time.Time           `json:"researched_at"`
-	CompetitorInsights  *CompetitorInsights `json:"competitor_insights,omitempty"`
+	APIName            string              `json:"api_name"`
+	NoveltyScore       int                 `json:"novelty_score"` // 1-10
+	Alternatives       []Alternative       `json:"alternatives"`
+	Gaps               []string            `json:"gaps"`           // what alternatives miss
+	Patterns           []string            `json:"patterns"`       // what alternatives do well
+	Recommendation     string              `json:"recommendation"` // "proceed", "proceed-with-gaps", "skip"
+	ResearchedAt       time.Time           `json:"researched_at"`
+	CompetitorInsights *CompetitorInsights `json:"competitor_insights,omitempty"`
 }
 
 // CompetitorAnalysis holds intelligence gathered from a single competitor repo.
@@ -217,7 +217,7 @@ func searchGitHubCLIs(apiName string) ([]Alternative, error) {
 	if err != nil {
 		return nil, err
 	}
-	defer resp.Body.Close()
+	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != 200 {
 		body, _ := io.ReadAll(resp.Body)
@@ -407,8 +407,8 @@ type ghIssue struct {
 
 // ghPull models a GitHub pull request from the API.
 type ghPull struct {
-	Title    string `json:"title"`
-	HTMLURL  string `json:"html_url"`
+	Title    string  `json:"title"`
+	HTMLURL  string  `json:"html_url"`
 	MergedAt *string `json:"merged_at"`
 }
 
@@ -495,7 +495,7 @@ func fetchIssues(client *http.Client, owner, repo, labels string) ([]ghIssue, er
 	if err != nil {
 		return nil, err
 	}
-	defer resp.Body.Close()
+	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != 200 {
 		body, _ := io.ReadAll(resp.Body)
@@ -521,7 +521,7 @@ func fetchReadme(client *http.Client, owner, repo string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	defer resp.Body.Close()
+	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != 200 {
 		return "", fmt.Errorf("GitHub API returned %d", resp.StatusCode)
@@ -555,7 +555,7 @@ func fetchAbandonedPRs(client *http.Client, owner, repo string) ([]ghPull, error
 	if err != nil {
 		return nil, err
 	}
-	defer resp.Body.Close()
+	defer func() { _ = resp.Body.Close() }()
 
 	if resp.StatusCode != 200 {
 		return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index 7454662a..7c9aba3a 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -376,7 +376,7 @@ func runDataPipelineTest(binary, mode string, envFn func() []string) bool {
 	if err != nil {
 		return false
 	}
-	defer os.RemoveAll(tmpDir)
+	defer func() { _ = os.RemoveAll(tmpDir) }()
 
 	dbPath := filepath.Join(tmpDir, "test.db")
 	env = append(env, "HOME="+tmpDir) // so sync uses temp location
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index f81323a2..039915f6 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -1765,10 +1765,10 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
 	}
 
 	var b strings.Builder
-	b.WriteString(fmt.Sprintf("# Scorecard: %s\n\n", sc.APIName))
-	b.WriteString(fmt.Sprintf("**Overall Grade: %s** (%d%%)\n\n", sc.OverallGrade, sc.Steinberger.Percentage))
+	fmt.Fprintf(&b, "# Scorecard: %s\n\n", sc.APIName)
+	fmt.Fprintf(&b, "**Overall Grade: %s** (%d%%)\n\n", sc.OverallGrade, sc.Steinberger.Percentage)
 	if len(sc.UnscoredDimensions) > 0 {
-		b.WriteString(fmt.Sprintf("Unscored dimensions omitted from the total denominator: %s\n\n", strings.Join(sc.UnscoredDimensions, ", ")))
+		fmt.Fprintf(&b, "Unscored dimensions omitted from the total denominator: %s\n\n", strings.Join(sc.UnscoredDimensions, ", "))
 	}
 
 	// Steinberger dimensions table
@@ -1800,11 +1800,11 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
 	}
 	for _, d := range dimensions {
 		if sc.IsDimensionUnscored(d.nameKey) {
-			b.WriteString(fmt.Sprintf("| %s | N/A |\n", d.name))
+			fmt.Fprintf(&b, "| %s | N/A |\n", d.name)
 			continue
 		}
 		bar := strings.Repeat("#", d.score) + strings.Repeat(".", 10-d.score)
-		b.WriteString(fmt.Sprintf("| %s | %d/10 %s |\n", d.name, d.score, bar))
+		fmt.Fprintf(&b, "| %s | %d/10 %s |\n", d.name, d.score, bar)
 	}
 	typeDimensions := []struct {
 		name  string
@@ -1815,9 +1815,9 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
 	}
 	for _, d := range typeDimensions {
 		bar := strings.Repeat("#", d.score) + strings.Repeat(".", 5-d.score)
-		b.WriteString(fmt.Sprintf("| %s | %d/5 %s |\n", d.name, d.score, bar))
+		fmt.Fprintf(&b, "| %s | %d/5 %s |\n", d.name, d.score, bar)
 	}
-	b.WriteString(fmt.Sprintf("| **Total** | **%d/100** |\n\n", s.Total))
+	fmt.Fprintf(&b, "| **Total** | **%d/100** |\n\n", s.Total)
 
 	// Competitor comparison
 	if len(sc.CompetitorScores) > 0 {
@@ -1829,7 +1829,7 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
 			if cs.WeWin {
 				winner = "Us"
 			}
-			b.WriteString(fmt.Sprintf("| %s | %d | %d | %s |\n", cs.Name, cs.OurScore, cs.TheirScore, winner))
+			fmt.Fprintf(&b, "| %s | %d | %d | %s |\n", cs.Name, cs.OurScore, cs.TheirScore, winner)
 		}
 		b.WriteString("\n")
 	}
@@ -1838,7 +1838,7 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
 	if len(sc.GapReport) > 0 {
 		b.WriteString("## Gaps\n\n")
 		for _, g := range sc.GapReport {
-			b.WriteString(fmt.Sprintf("- %s\n", g))
+			fmt.Fprintf(&b, "- %s\n", g)
 		}
 		b.WriteString("\n")
 	}
diff --git a/internal/pipeline/selfimprove.go b/internal/pipeline/selfimprove.go
index a2b5c28c..c26f8b63 100644
--- a/internal/pipeline/selfimprove.go
+++ b/internal/pipeline/selfimprove.go
@@ -126,17 +126,17 @@ func GenerateFixPlans(scorecard *Scorecard, pipelineDir string) ([]string, error
 func buildFixPlan(apiName, dimension string, currentScore int) string {
 	var b strings.Builder
 
-	b.WriteString(fmt.Sprintf("# Fix Plan: %s\n\n", dimension))
-	b.WriteString(fmt.Sprintf("**API:** %s\n", apiName))
-	b.WriteString(fmt.Sprintf("**Current Score:** %d/10\n", currentScore))
-	b.WriteString(fmt.Sprintf("**Target Score:** 8/10\n\n"))
+	fmt.Fprintf(&b, "# Fix Plan: %s\n\n", dimension)
+	fmt.Fprintf(&b, "**API:** %s\n", apiName)
+	fmt.Fprintf(&b, "**Current Score:** %d/10\n", currentScore)
+	b.WriteString("**Target Score:** 8/10\n\n")
 
 	// Templates involved
 	templates := templateMapping[dimension]
 	if len(templates) > 0 {
 		b.WriteString("## Templates to Modify\n\n")
 		for _, t := range templates {
-			b.WriteString(fmt.Sprintf("- `templates/%s`\n", t))
+			fmt.Fprintf(&b, "- `templates/%s`\n", t)
 		}
 	} else {
 		b.WriteString("## Templates to Create\n\n")
@@ -157,9 +157,9 @@ func buildFixPlan(apiName, dimension string, currentScore int) string {
 	b.WriteString("## Verification\n\n")
 	b.WriteString("After applying changes, re-run the scorecard:\n\n")
 	b.WriteString("```bash\n")
-	b.WriteString(fmt.Sprintf("printing-press scorecard --api %s\n", apiName))
+	fmt.Fprintf(&b, "printing-press scorecard --api %s\n", apiName)
 	b.WriteString("```\n\n")
-	b.WriteString(fmt.Sprintf("The %s dimension should score at least 8/10.\n", dimension))
+	fmt.Fprintf(&b, "The %s dimension should score at least 8/10.\n", dimension)
 
 	return b.String()
 }
diff --git a/internal/pipeline/spec_summary.go b/internal/pipeline/spec_summary.go
deleted file mode 100644
index 3d865f26..00000000
--- a/internal/pipeline/spec_summary.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package pipeline
-
-import (
-	"encoding/json"
-	"sort"
-	"strings"
-
-	apispec "github.com/mvanhorn/cli-printing-press/internal/spec"
-)
-
-type specSummary struct {
-	Paths []string
-	Auth  apispec.AuthConfig
-}
-
-func loadSpecSummary(specPath string) (*specSummary, error) {
-	if specPath == "" {
-		return nil, nil
-	}
-
-	data, err := readSpecBytes(specPath)
-	if err != nil {
-		return nil, err
-	}
-
-	if summary, err := summarizeOpenAPILike(data); err == nil && summary != nil {
-		return summary, nil
-	}
-
-	if parsed, err := apispec.ParseBytes(data); err == nil {
-		return summarizeSpec(parsed), nil
-	}
-
-	return nil, nil
-}
-
-func summarizeSpec(parsed *apispec.APISpec) *specSummary {
-	if parsed == nil {
-		return nil
-	}
-
-	paths := collectSpecPaths(parsed.Resources)
-	sort.Strings(paths)
-
-	return &specSummary{
-		Paths: paths,
-		Auth:  parsed.Auth,
-	}
-}
-
-func collectSpecPaths(resources map[string]apispec.Resource) []string {
-	if len(resources) == 0 {
-		return nil
-	}
-
-	paths := make(map[string]struct{})
-	var walk func(map[string]apispec.Resource)
-	walk = func(resources map[string]apispec.Resource) {
-		for _, resource := range resources {
-			for _, endpoint := range resource.Endpoints {
-				if endpoint.Path != "" {
-					paths[endpoint.Path] = struct{}{}
-				}
-			}
-			if len(resource.SubResources) > 0 {
-				walk(resource.SubResources)
-			}
-		}
-	}
-	walk(resources)
-
-	out := make([]string, 0, len(paths))
-	for path := range paths {
-		out = append(out, path)
-	}
-	return out
-}
-
-func summarizeOpenAPILike(data []byte) (*specSummary, error) {
-	data, err := ensureJSON(data)
-	if err != nil {
-		return nil, err
-	}
-
-	var raw map[string]any
-	if err := json.Unmarshal(data, &raw); err != nil {
-		return nil, err
-	}
-
-	pathsRaw, _ := raw["paths"].(map[string]any)
-	paths := make([]string, 0, len(pathsRaw))
-	for path := range pathsRaw {
-		paths = append(paths, path)
-	}
-	sort.Strings(paths)
-
-	auth := summarizeSecuritySchemes(raw)
-	if len(paths) == 0 && auth.Type == "" && auth.Format == "" && auth.Header == "" && auth.Scheme == "" && auth.In == "" && len(auth.EnvVars) == 0 {
-		return nil, nil
-	}
-
-	return &specSummary{
-		Paths: paths,
-		Auth:  auth,
-	}, nil
-}
-
-func summarizeSecuritySchemes(raw map[string]any) apispec.AuthConfig {
-	components, ok := raw["components"].(map[string]any)
-	if !ok {
-		return apispec.AuthConfig{}
-	}
-	schemes, ok := components["securitySchemes"].(map[string]any)
-	if !ok {
-		return apispec.AuthConfig{}
-	}
-
-	for schemeName, value := range schemes {
-		scheme, ok := value.(map[string]any)
-		if !ok {
-			continue
-		}
-
-		auth := apispec.AuthConfig{
-			Scheme: schemeName,
-			Type:   stringValue(scheme["type"]),
-			Header: stringValue(scheme["name"]),
-			In:     stringValue(scheme["in"]),
-		}
-		schemeType := stringValue(scheme["scheme"])
-
-		switch {
-		case strings.EqualFold(auth.Type, "http") && strings.EqualFold(schemeType, "bearer"):
-			auth.Type = "bearer_token"
-			auth.Header = "Authorization"
-			auth.Format = "Bearer "
-			if strings.Contains(strings.ToLower(schemeName), "bot") {
-				auth.Format = "Bot {bot_token}"
-			}
-		case strings.EqualFold(auth.Type, "http") && strings.EqualFold(schemeType, "basic"):
-			auth.Type = "api_key"
-			auth.Header = "Authorization"
-			auth.Format = "Basic "
-		case strings.EqualFold(auth.Type, "apikey"):
-			if auth.Header == "" {
-				auth.Header = "Authorization"
-			}
-			if strings.Contains(strings.ToLower(schemeName), "bot") && strings.EqualFold(auth.Header, "Authorization") {
-				auth.Format = "Bot {bot_token}"
-			}
-		}
-
-		return auth
-	}
-
-	return apispec.AuthConfig{}
-}
-
-func stringValue(value any) string {
-	if s, ok := value.(string); ok {
-		return s
-	}
-	return ""
-}
diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
index 16e1cadf..bb52b235 100644
--- a/internal/pipeline/state.go
+++ b/internal/pipeline/state.go
@@ -304,7 +304,6 @@ func FindStateByWorkingDir(dir string) (*PipelineState, error) {
 func NewState(apiName, outputDir string) *PipelineState {
 	runID, err := newRunID(time.Now())
 	if err != nil {
-		outputDir = outputDir
 		runID = fmt.Sprintf("fallback-%d", time.Now().UnixNano())
 	}
 	return NewStateWithRun(apiName, outputDir, runID, WorkspaceScope())
diff --git a/internal/pipeline/verify_report.go b/internal/pipeline/verify_report.go
index 68ec0546..3822b84a 100644
--- a/internal/pipeline/verify_report.go
+++ b/internal/pipeline/verify_report.go
@@ -10,12 +10,12 @@ import (
 
 // VerificationReport holds the results of a Proof of Behavior verification run.
 type VerificationReport struct {
-	Dir      string              `json:"dir"`
-	SpecPath string              `json:"spec_path,omitempty"`
-	Paths    []PathProofResult   `json:"paths"`
-	Flags    []FlagProofResult   `json:"flags"`
+	Dir      string                `json:"dir"`
+	SpecPath string                `json:"spec_path,omitempty"`
+	Paths    []PathProofResult     `json:"paths"`
+	Flags    []FlagProofResult     `json:"flags"`
 	Pipeline []PipelineProofResult `json:"pipeline"`
-	Auth     AuthProofResult     `json:"auth"`
+	Auth     AuthProofResult       `json:"auth"`
 
 	HallucinatedPaths int  `json:"hallucinated_paths"`
 	DeadFlags         int  `json:"dead_flags"`
@@ -158,7 +158,7 @@ func (r *VerificationReport) Markdown() string {
 	var b strings.Builder
 
 	b.WriteString("# Proof of Behavior Report\n\n")
-	b.WriteString(fmt.Sprintf("**Verdict: %s**\n\n", r.Verdict))
+	fmt.Fprintf(&b, "**Verdict: %s**\n\n", r.Verdict)
 
 	// Path Proof section.
 	validPaths := 0
@@ -168,14 +168,14 @@ func (r *VerificationReport) Markdown() string {
 		}
 	}
 	b.WriteString("## Path Proof\n")
-	b.WriteString(fmt.Sprintf("Tested: %d | Valid: %d | Hallucinated: %d\n\n", len(r.Paths), validPaths, r.HallucinatedPaths))
+	fmt.Fprintf(&b, "Tested: %d | Valid: %d | Hallucinated: %d\n\n", len(r.Paths), validPaths, r.HallucinatedPaths)
 
 	if r.HallucinatedPaths > 0 {
 		b.WriteString("| Path | Status |\n")
 		b.WriteString("|------|--------|\n")
 		for _, p := range r.Paths {
 			if !p.Valid {
-				b.WriteString(fmt.Sprintf("| `%s` | INVALID |\n", p.Path))
+				fmt.Fprintf(&b, "| `%s` | INVALID |\n", p.Path)
 			}
 		}
 		b.WriteString("\n")
@@ -183,12 +183,12 @@ func (r *VerificationReport) Markdown() string {
 
 	// Flag Proof section.
 	b.WriteString("## Flag Proof\n")
-	b.WriteString(fmt.Sprintf("Total: %d | Dead: %d\n\n", len(r.Flags), r.DeadFlags))
+	fmt.Fprintf(&b, "Total: %d | Dead: %d\n\n", len(r.Flags), r.DeadFlags)
 
 	if r.DeadFlags > 0 {
 		for _, f := range r.Flags {
 			if f.References == 0 {
-				b.WriteString(fmt.Sprintf("- `%s` (0 references)\n", f.Flag))
+				fmt.Fprintf(&b, "- `%s` (0 references)\n", f.Flag)
 			}
 		}
 		b.WriteString("\n")
@@ -196,7 +196,7 @@ func (r *VerificationReport) Markdown() string {
 
 	// Pipeline Proof section.
 	b.WriteString("## Pipeline Proof\n")
-	b.WriteString(fmt.Sprintf("Tables: %d | Ghost: %d | Orphan FTS: %d\n\n", len(r.Pipeline), r.GhostTables, r.OrphanFTS))
+	fmt.Fprintf(&b, "Tables: %d | Ghost: %d | Orphan FTS: %d\n\n", len(r.Pipeline), r.GhostTables, r.OrphanFTS)
 
 	if len(r.Pipeline) > 0 {
 		b.WriteString("| Table | WRITE | READ | SEARCH |\n")
@@ -205,20 +205,20 @@ func (r *VerificationReport) Markdown() string {
 			w := boolMark(p.HasWrite)
 			rd := boolMark(p.HasRead)
 			s := boolMark(p.HasSearch)
-			b.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", p.Table, w, rd, s))
+			fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", p.Table, w, rd, s)
 		}
 		b.WriteString("\n")
 	}
 
 	// Auth Proof section.
 	b.WriteString("## Auth Proof\n")
-	b.WriteString(fmt.Sprintf("Spec: %s | Generated: %s | Match: %t\n\n", r.Auth.SpecScheme, r.Auth.GeneratedScheme, !r.Auth.Mismatch))
+	fmt.Fprintf(&b, "Spec: %s | Generated: %s | Match: %t\n\n", r.Auth.SpecScheme, r.Auth.GeneratedScheme, !r.Auth.Mismatch)
 
 	// Issues section.
 	if len(r.Issues) > 0 {
 		b.WriteString("## Issues\n")
 		for _, issue := range r.Issues {
-			b.WriteString(fmt.Sprintf("- %s\n", issue))
+			fmt.Fprintf(&b, "- %s\n", issue)
 		}
 		b.WriteString("\n")
 	}
diff --git a/internal/profiler/apitype.go b/internal/profiler/apitype.go
index 4f7b7d88..1a753199 100644
--- a/internal/profiler/apitype.go
+++ b/internal/profiler/apitype.go
@@ -61,7 +61,7 @@ func readHead(path string, n int) ([]byte, error) {
 	if err != nil {
 		return nil, err
 	}
-	defer f.Close()
+	defer func() { _ = f.Close() }()
 
 	buf := make([]byte, n)
 	nr, err := f.Read(buf)
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 7fc52bb2..2fe39854 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -37,9 +37,9 @@ type DomainSignals struct {
 
 // PaginationProfile describes the detected pagination patterns across the API.
 type PaginationProfile struct {
-	CursorParam     string `json:"cursor_param"`    // most common cursor param name (after, cursor, page_token, offset)
-	PageSizeParam   string `json:"page_size_param"`  // most common page size param (limit, per_page, page_size, first)
-	SinceParam      string `json:"since_param"`      // temporal filter param (since, updated_after, modified_since)
+	CursorParam     string `json:"cursor_param"`      // most common cursor param name (after, cursor, page_token, offset)
+	PageSizeParam   string `json:"page_size_param"`   // most common page size param (limit, per_page, page_size, first)
+	SinceParam      string `json:"since_param"`       // temporal filter param (since, updated_after, modified_since)
 	ItemsKey        string `json:"items_key"`         // response array key (data, results, items, or "" for root array)
 	DefaultPageSize int    `json:"default_page_size"` // detected or default 100
 }
diff --git a/internal/vision/report.go b/internal/vision/report.go
index 42bc109c..634f9dca 100644
--- a/internal/vision/report.go
+++ b/internal/vision/report.go
@@ -14,28 +14,28 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
 
 	var b strings.Builder
 
-	b.WriteString(fmt.Sprintf("# Visionary Research: %s CLI\n\n", plan.APIName))
+	fmt.Fprintf(&b, "# Visionary Research: %s CLI\n\n", plan.APIName)
 
 	// API Identity
 	b.WriteString("## API Identity\n\n")
-	b.WriteString(fmt.Sprintf("- **Domain:** %s\n", plan.Identity.DomainCategory))
-	b.WriteString(fmt.Sprintf("- **Primary users:** %s\n", strings.Join(plan.Identity.PrimaryUsers, ", ")))
-	b.WriteString(fmt.Sprintf("- **Core entities:** %s\n", strings.Join(plan.Identity.CoreEntities, ", ")))
-	b.WriteString(fmt.Sprintf("- **Data profile:** %s volume, %s writes, realtime=%v, search need=%s\n\n",
+	fmt.Fprintf(&b, "- **Domain:** %s\n", plan.Identity.DomainCategory)
+	fmt.Fprintf(&b, "- **Primary users:** %s\n", strings.Join(plan.Identity.PrimaryUsers, ", "))
+	fmt.Fprintf(&b, "- **Core entities:** %s\n", strings.Join(plan.Identity.CoreEntities, ", "))
+	fmt.Fprintf(&b, "- **Data profile:** %s volume, %s writes, realtime=%v, search need=%s\n\n",
 		plan.Identity.DataProfile.Volume,
 		plan.Identity.DataProfile.WritePattern,
 		plan.Identity.DataProfile.Realtime,
-		plan.Identity.DataProfile.SearchNeed))
+		plan.Identity.DataProfile.SearchNeed)
 
 	// Usage Patterns
 	if len(plan.UsagePatterns) > 0 {
 		b.WriteString("## Usage Patterns (by Evidence)\n\n")
 		for i, p := range plan.UsagePatterns {
-			b.WriteString(fmt.Sprintf("### %d. %s (Evidence: %d/10)\n\n", i+1, p.Name, p.EvidenceScore))
+			fmt.Fprintf(&b, "### %d. %s (Evidence: %d/10)\n\n", i+1, p.Name, p.EvidenceScore)
 			b.WriteString(p.Description + "\n\n")
 			if len(p.EvidenceSources) > 0 {
 				for _, src := range p.EvidenceSources {
-					b.WriteString(fmt.Sprintf("- %s\n", src))
+					fmt.Fprintf(&b, "- %s\n", src)
 				}
 				b.WriteString("\n")
 			}
@@ -51,9 +51,9 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
 		b.WriteString("| Tool | Stars | Type | Language | Features |\n")
 		b.WriteString("|------|-------|------|----------|----------|\n")
 		for _, t := range plan.ToolLandscape {
-			b.WriteString(fmt.Sprintf("| [%s](%s) | %d | %s | %s | %s |\n",
+			fmt.Fprintf(&b, "| [%s](%s) | %d | %s | %s | %s |\n",
 				t.Name, t.URL, t.Stars, t.ToolType, t.Language,
-				strings.Join(t.Features, ", ")))
+				strings.Join(t.Features, ", "))
 		}
 		b.WriteString("\n")
 	}
@@ -62,7 +62,7 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
 	if len(plan.Workflows) > 0 {
 		b.WriteString("## Workflows\n\n")
 		for i, w := range plan.Workflows {
-			b.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, w.Name))
+			fmt.Fprintf(&b, "### %d. %s\n\n", i+1, w.Name)
 			if len(w.Steps) > 0 {
 				b.WriteString("**Steps:** ")
 				stepDescs := make([]string, len(w.Steps))
@@ -72,13 +72,13 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
 				b.WriteString(strings.Join(stepDescs, " -> ") + "\n")
 			}
 			if w.Frequency != "" {
-				b.WriteString(fmt.Sprintf("**Frequency:** %s\n", w.Frequency))
+				fmt.Fprintf(&b, "**Frequency:** %s\n", w.Frequency)
 			}
 			if w.PainPoint != "" {
-				b.WriteString(fmt.Sprintf("**Pain:** %s\n", w.PainPoint))
+				fmt.Fprintf(&b, "**Pain:** %s\n", w.PainPoint)
 			}
 			if w.ProposedCLIFeature != "" {
-				b.WriteString(fmt.Sprintf("**Proposed:** `%s`\n", w.ProposedCLIFeature))
+				fmt.Fprintf(&b, "**Proposed:** `%s`\n", w.ProposedCLIFeature)
 			}
 			b.WriteString("\n")
 		}
@@ -90,8 +90,8 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
 		b.WriteString("| Area | Need | Decision | Rationale |\n")
 		b.WriteString("|------|------|----------|----------|\n")
 		for _, a := range plan.Architecture {
-			b.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n",
-				a.Area, a.NeedLevel, a.Decision, a.Rationale))
+			fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
+				a.Area, a.NeedLevel, a.Decision, a.Rationale)
 		}
 		b.WriteString("\n")
 	}
@@ -103,17 +103,17 @@ func WriteReport(plan *VisionaryPlan, outputDir string) error {
 		b.WriteString("|---|---------|-------|----------|--------|----------|\n")
 		for i, f := range plan.Features {
 			f.TotalScore = f.ComputeScore()
-			b.WriteString(fmt.Sprintf("| %d | %s | %d/16 | %d/3 | %d/3 | %s |\n",
+			fmt.Fprintf(&b, "| %d | %s | %d/16 | %d/3 | %d/3 | %s |\n",
 				i+1, f.Name, f.TotalScore,
 				f.EvidenceStrength, f.UserImpact,
-				strings.Join(f.TemplateNames, ", ")))
+				strings.Join(f.TemplateNames, ", "))
 		}
 		b.WriteString("\n")
 
 		// Detail for each feature
 		for i, f := range plan.Features {
 			f.TotalScore = f.ComputeScore()
-			b.WriteString(fmt.Sprintf("### %d. %s (Score: %d/16)\n\n", i+1, f.Name, f.TotalScore))
+			fmt.Fprintf(&b, "### %d. %s (Score: %d/16)\n\n", i+1, f.Name, f.TotalScore)
 			b.WriteString(f.Description + "\n\n")
 			if len(f.TemplateNames) > 0 {
 				b.WriteString("**Templates:** " + strings.Join(f.TemplateNames, ", ") + "\n\n")
diff --git a/release-please-config.json b/release-please-config.json
new file mode 100644
index 00000000..40447b3a
--- /dev/null
+++ b/release-please-config.json
@@ -0,0 +1,25 @@
+{
+  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
+  "packages": {
+    ".": {
+      "release-type": "go",
+      "bump-minor-pre-major": true,
+      "extra-files": [
+        {
+          "type": "json",
+          "path": ".claude-plugin/plugin.json",
+          "jsonpath": "$.version"
+        },
+        {
+          "type": "json",
+          "path": ".claude-plugin/marketplace.json",
+          "jsonpath": "$.plugins[0].version"
+        },
+        {
+          "type": "generic",
+          "path": "internal/cli/root.go"
+        }
+      ]
+    }
+  }
+}

← 03a19223 fix(ci): shared build cache and Go module caching to prevent  ·  back to Cli Printing Press  ·  feat(websniff): add HAR and enriched capture parser 6c6c9cf5 →