[object Object]

← back to Cli Printing Press

feat(cli): printing-press machine fixes from food52 retro (#337) (#342)

5fa694a07b8f39becc53189c5c9765d98c1c27c7 · 2026-04-27 10:23:54 -0700 · Trevin Chow

* fix(cli): dogfood examples parser tolerates indent variants

extractExamplesSection previously broke at the first unindented line,
which meant Example fields built with `strings.TrimSpace(\`...\`)` were
silently scored 0/10 — TrimSpace strips the leading 2-space indent, so
the first example line is unindented and the parser treated it as a
section boundary.

Switch to a closed set of canonical Cobra section headers (Usage:,
Aliases:, Available Commands:, Examples:, Flags:, Global Flags:,
Additional help topics:, plus the literal "Use \"" trailing-line
prefix). Anything else is treated as continuation. Lose-by-default is
safer than misclassify.

SKILL.md Phase 3's Agent Build Checklist now points future hand-authored
commands at strings.Trim(\`...\`, "\n") so the natural Go idiom doesn't
trip the scorer even with the tolerant parser.

Test cases added for the regression (TrimSpace-style first-unindented
line), the trailing Use-for-more line, and example lines that legitimately
start with the word "use" (continuation, not section boundary).

Refs #337 (U1)

* fix(cli): traffic-analysis schema accepts browser_http and string evidence

The Go consumer code in browsersniff.ApplyReachabilityDefaults already
accepts the browser_http reachability mode and routes it to the
HTTPTransportBrowserChrome transport — but the JSON Schema printed by
`printing-press schema traffic-analysis` omitted browser_http from the
mode enum. Hand-authored traffic-analysis.json files declaring that
mode failed schema validation even though the consumer would have
accepted them.

EvidenceRef previously required object-shaped JSON (entry_index +
optional method/host/path/etc.). That shape only makes sense when the
analysis came from `printing-press browser-sniff` parsing a HAR;
hand-authored discovery reports carry prose evidence that doesn't map
to a HAR entry index. Generate would reject them with "cannot unmarshal
string into Go struct field".

Two changes:

  - schema.go: add browser_http to the reachability mode enum; change
    every evidence-array site to oneOf:[evidence_ref, string].

  - analysis.go EvidenceRef: implement custom Marshal/UnmarshalJSON.
    Accept either a JSON object (object-form, EntryIndex >= 0,
    HAR-derived) or a JSON string (string-form, EntryIndex == -1
    sentinel, prose-derived). Round-trip is symmetric: string in →
    string out, object in → object out. Mixed arrays in the same
    traffic-analysis.json work as expected.

The sentinel EntryIndex == -1 disambiguates string-derived from
HAR-derived entries for downstream consumers; previously a
hand-authored entry would have collided with EntryIndex == 0 ("first
HAR entry"). Tests cover the round-trip in both shapes plus a mixed
array embedded in a TrafficAnalysis struct.

Refs #337 (U3)

* fix(cli): lock promote populates manifest novel_features fully

writeCLIManifestForPublish previously only loaded research.json from
state.PipelineDir() and only used NovelFeaturesBuilt. Two gaps:

  1. When dogfood didn't run (or wrote an empty NovelFeaturesBuilt),
     the planned NovelFeatures list was ignored, so publish-validate's
     transcendence check failed for first-publish CLIs that genuinely
     shipped novel features.

  2. The skill-driven flow writes research.json at the run root, not
     under pipeline/research.json — so loading from
     state.PipelineDir() returned ENOENT for plan-driven CLIs even
     though the file existed.

  3. lock promote called via NewMinimalState (no pre-existing runstate)
     left state.RunID empty, so PipelineDir() resolved to a useless
     path and the manifest's novel_features field stayed empty for
     every plan-driven promote.

Fix: refactor the load into loadResearchForPromote which tries
canonical pipeline/research.json first, falls back to run-root
research.json, then for empty-RunID minimal state globs the scoped
runstate root for any research.json matching the API name (most recent
by mtime). pickNovelFeaturesForManifest prefers NovelFeaturesBuilt
when populated and falls back to NovelFeatures otherwise.

Tests cover the three load paths plus the no-match and missing-
runstate-root edge cases. A one-line stderr note tells the user when
research came from a non-canonical source so the source is visible
when debugging.

Refs #337 (U2)

* feat(cli): add embedded-json HTML extractor mode for SSR-React sites

Adds `mode: embedded-json` to the html_extract spec block, dispatching to
a new extractor that parses HTML, locates a script tag via simple "tag"
or "tag#id" selector (default `script#__NEXT_DATA__`), parses its text
as JSON, and walks an optional dot-notation `json_path` into the result.

Motivation: Next.js (pages-router), Nuxt, and other SSR-React frameworks
ship serialized page state in `__NEXT_DATA__`. Hitting these sites from
a printed CLI previously required hand-written extraction. This makes
the extraction declarative in the spec.

Mode dispatch is now at the top of `extractHTMLResponse` so embedded-json
skips the page-mode parse + walk + challenge detection — those would
both waste work on every call and risk false "challenge page" rejection
on Next.js pages with generic <title> elements.

`script_selector` is parameterized rather than hardcoding `__NEXT_DATA__`
so sites embedding state under different ids (e.g., `<script id="ARTICLE_DATA">`)
work with the same extractor.

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

* feat(cli): add canonicalargs registry + side-effect command convention

Three coordinated machine improvements that landed together:

1. canonicalargs subpackage. Tiny shared registry of cross-domain
   positional placeholder names (since/until/tag/vertical) consulted
   as one step in a new lookup chain: spec.Param.Default →
   canonicalargs.Lookup → legacy syntheticArgValue switch →
   "mock-value". Verify mock-mode and the SKILL template share this
   chain so a spec author setting `default: "4"` on a `servings`
   positional is honored everywhere it matters, without baking
   recipe-domain names into the generic registry. AGENTS.md anti-
   pattern guard: never change the machine for one CLI's edge case.

2. Side-effect command classifier in verify. Heuristic check that
   scans a command's --help output for keywords like "browser" /
   "shells out to" and the printed CLI's source under internal/cli/
   for shell-out markers (exec.Command("open"), pkg/browser, etc.).
   Mock-mode dispatch routes flagged commands through a wrapper that
   runs --help and --dry-run but skips the Execute test, so verify no
   longer pops browser tabs (the food52 retro motivation).

3. cliutil.IsVerifyEnv() helper + PRINTING_PRESS_VERIFY=1 env var.
   The verifier now sets this env var in every mock-mode subprocess.
   Generated commands check the helper to short-circuit before any
   visible action; this is the floor that catches anything the
   heuristic classifier misses. Documented in SKILL.md Phase 3 as
   principle 9 and AGENTS.md glossary.

Spec.Param.Default flows through to verify via a new ParamDefaults
map populated only for internal-format specs (collected at spec-load
time in spec_detect.go).

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

* refactor(cli): per-mode gating in html_extract.go template

A printed CLI that uses html_extract in only one mode no longer drags
in helpers from the other modes. The page+links family
(htmlExtractedPage, htmlLink, walkHTML walkers, applyMeta,
extractHTMLLink, normalizeHTMLURL, htmlLinkMatchesPrefixes,
htmlPathMatchesPrefix, htmlLinkSlug, nodeTextSuppressing,
firstImageSrc, firstSrcsetURL, cleanHTMLText, splitRankedHTMLLinkText,
looksLikeHTMLChallenge) now lives behind a {{- if or "page" "links" }}
gate; the embedded-json family (extractEmbeddedJSON, parseSimpleSelector,
findScriptByTagAndID, walkJSONDotPath) lives behind a {{- if "embedded-json" }}
gate. The mode-dispatch switch and conditional imports (regexp,
strconv, html) are gated to match.

walkHTML, attrValue, nodeText, and htmlExtractionRequestURL stay
unconditional — they're used by both branches (or, for
htmlExtractionRequestURL, called from command_endpoint/promoted
templates regardless of mode).

A new APISpec.HasHTMLExtractMode(mode) helper introspects whether any
endpoint declares the named mode (matched by EffectiveMode so an unset
Mode counts as page).

This eliminates the dead-helper count flagged by the food52 retro for
embedded-json-only printed CLIs without changing emitted output for
single-mode page/links specs.

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

* feat(cli): SKILL examples thread positional values via lookup chain

firstCommandExample now appends each endpoint's positional values to
the example invocation, sourced from the same lookup chain used by
verify mock-mode dispatch (Param.Default → canonicalargs.Lookup →
"mock-value"). Spec authors who set realistic defaults on positional
params see them surface verbatim in SKILL.md and README.md examples;
specs without defaults fall through to the cross-domain registry, then
the catch-all.

Motivation: the food52 retro shipped a SKILL.md example referencing
"recipes get mock-slug" because the helper had no way to know what a
realistic slug looks like. With this change, food52's spec setting
`default: "sarah-fennel-s-best-lunch-lady-brownie-recipe"` on the
recipes-get slug param surfaces that real value in every doc the
generator emits, and verify-skill exits 0 on first generation rather
than failing on synthetic placeholders.

Templates (skill.md.tmpl, readme.md.tmpl) need no changes — they
already splice the helper's return value verbatim.

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 5fa694a07b8f39becc53189c5c9765d98c1c27c7
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon Apr 27 10:23:54 2026 -0700

    feat(cli): printing-press machine fixes from food52 retro (#337) (#342)
    
    * fix(cli): dogfood examples parser tolerates indent variants
    
    extractExamplesSection previously broke at the first unindented line,
    which meant Example fields built with `strings.TrimSpace(\`...\`)` were
    silently scored 0/10 — TrimSpace strips the leading 2-space indent, so
    the first example line is unindented and the parser treated it as a
    section boundary.
    
    Switch to a closed set of canonical Cobra section headers (Usage:,
    Aliases:, Available Commands:, Examples:, Flags:, Global Flags:,
    Additional help topics:, plus the literal "Use \"" trailing-line
    prefix). Anything else is treated as continuation. Lose-by-default is
    safer than misclassify.
    
    SKILL.md Phase 3's Agent Build Checklist now points future hand-authored
    commands at strings.Trim(\`...\`, "\n") so the natural Go idiom doesn't
    trip the scorer even with the tolerant parser.
    
    Test cases added for the regression (TrimSpace-style first-unindented
    line), the trailing Use-for-more line, and example lines that legitimately
    start with the word "use" (continuation, not section boundary).
    
    Refs #337 (U1)
    
    * fix(cli): traffic-analysis schema accepts browser_http and string evidence
    
    The Go consumer code in browsersniff.ApplyReachabilityDefaults already
    accepts the browser_http reachability mode and routes it to the
    HTTPTransportBrowserChrome transport — but the JSON Schema printed by
    `printing-press schema traffic-analysis` omitted browser_http from the
    mode enum. Hand-authored traffic-analysis.json files declaring that
    mode failed schema validation even though the consumer would have
    accepted them.
    
    EvidenceRef previously required object-shaped JSON (entry_index +
    optional method/host/path/etc.). That shape only makes sense when the
    analysis came from `printing-press browser-sniff` parsing a HAR;
    hand-authored discovery reports carry prose evidence that doesn't map
    to a HAR entry index. Generate would reject them with "cannot unmarshal
    string into Go struct field".
    
    Two changes:
    
      - schema.go: add browser_http to the reachability mode enum; change
        every evidence-array site to oneOf:[evidence_ref, string].
    
      - analysis.go EvidenceRef: implement custom Marshal/UnmarshalJSON.
        Accept either a JSON object (object-form, EntryIndex >= 0,
        HAR-derived) or a JSON string (string-form, EntryIndex == -1
        sentinel, prose-derived). Round-trip is symmetric: string in →
        string out, object in → object out. Mixed arrays in the same
        traffic-analysis.json work as expected.
    
    The sentinel EntryIndex == -1 disambiguates string-derived from
    HAR-derived entries for downstream consumers; previously a
    hand-authored entry would have collided with EntryIndex == 0 ("first
    HAR entry"). Tests cover the round-trip in both shapes plus a mixed
    array embedded in a TrafficAnalysis struct.
    
    Refs #337 (U3)
    
    * fix(cli): lock promote populates manifest novel_features fully
    
    writeCLIManifestForPublish previously only loaded research.json from
    state.PipelineDir() and only used NovelFeaturesBuilt. Two gaps:
    
      1. When dogfood didn't run (or wrote an empty NovelFeaturesBuilt),
         the planned NovelFeatures list was ignored, so publish-validate's
         transcendence check failed for first-publish CLIs that genuinely
         shipped novel features.
    
      2. The skill-driven flow writes research.json at the run root, not
         under pipeline/research.json — so loading from
         state.PipelineDir() returned ENOENT for plan-driven CLIs even
         though the file existed.
    
      3. lock promote called via NewMinimalState (no pre-existing runstate)
         left state.RunID empty, so PipelineDir() resolved to a useless
         path and the manifest's novel_features field stayed empty for
         every plan-driven promote.
    
    Fix: refactor the load into loadResearchForPromote which tries
    canonical pipeline/research.json first, falls back to run-root
    research.json, then for empty-RunID minimal state globs the scoped
    runstate root for any research.json matching the API name (most recent
    by mtime). pickNovelFeaturesForManifest prefers NovelFeaturesBuilt
    when populated and falls back to NovelFeatures otherwise.
    
    Tests cover the three load paths plus the no-match and missing-
    runstate-root edge cases. A one-line stderr note tells the user when
    research came from a non-canonical source so the source is visible
    when debugging.
    
    Refs #337 (U2)
    
    * feat(cli): add embedded-json HTML extractor mode for SSR-React sites
    
    Adds `mode: embedded-json` to the html_extract spec block, dispatching to
    a new extractor that parses HTML, locates a script tag via simple "tag"
    or "tag#id" selector (default `script#__NEXT_DATA__`), parses its text
    as JSON, and walks an optional dot-notation `json_path` into the result.
    
    Motivation: Next.js (pages-router), Nuxt, and other SSR-React frameworks
    ship serialized page state in `__NEXT_DATA__`. Hitting these sites from
    a printed CLI previously required hand-written extraction. This makes
    the extraction declarative in the spec.
    
    Mode dispatch is now at the top of `extractHTMLResponse` so embedded-json
    skips the page-mode parse + walk + challenge detection — those would
    both waste work on every call and risk false "challenge page" rejection
    on Next.js pages with generic <title> elements.
    
    `script_selector` is parameterized rather than hardcoding `__NEXT_DATA__`
    so sites embedding state under different ids (e.g., `<script id="ARTICLE_DATA">`)
    work with the same extractor.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): add canonicalargs registry + side-effect command convention
    
    Three coordinated machine improvements that landed together:
    
    1. canonicalargs subpackage. Tiny shared registry of cross-domain
       positional placeholder names (since/until/tag/vertical) consulted
       as one step in a new lookup chain: spec.Param.Default →
       canonicalargs.Lookup → legacy syntheticArgValue switch →
       "mock-value". Verify mock-mode and the SKILL template share this
       chain so a spec author setting `default: "4"` on a `servings`
       positional is honored everywhere it matters, without baking
       recipe-domain names into the generic registry. AGENTS.md anti-
       pattern guard: never change the machine for one CLI's edge case.
    
    2. Side-effect command classifier in verify. Heuristic check that
       scans a command's --help output for keywords like "browser" /
       "shells out to" and the printed CLI's source under internal/cli/
       for shell-out markers (exec.Command("open"), pkg/browser, etc.).
       Mock-mode dispatch routes flagged commands through a wrapper that
       runs --help and --dry-run but skips the Execute test, so verify no
       longer pops browser tabs (the food52 retro motivation).
    
    3. cliutil.IsVerifyEnv() helper + PRINTING_PRESS_VERIFY=1 env var.
       The verifier now sets this env var in every mock-mode subprocess.
       Generated commands check the helper to short-circuit before any
       visible action; this is the floor that catches anything the
       heuristic classifier misses. Documented in SKILL.md Phase 3 as
       principle 9 and AGENTS.md glossary.
    
    Spec.Param.Default flows through to verify via a new ParamDefaults
    map populated only for internal-format specs (collected at spec-load
    time in spec_detect.go).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): per-mode gating in html_extract.go template
    
    A printed CLI that uses html_extract in only one mode no longer drags
    in helpers from the other modes. The page+links family
    (htmlExtractedPage, htmlLink, walkHTML walkers, applyMeta,
    extractHTMLLink, normalizeHTMLURL, htmlLinkMatchesPrefixes,
    htmlPathMatchesPrefix, htmlLinkSlug, nodeTextSuppressing,
    firstImageSrc, firstSrcsetURL, cleanHTMLText, splitRankedHTMLLinkText,
    looksLikeHTMLChallenge) now lives behind a {{- if or "page" "links" }}
    gate; the embedded-json family (extractEmbeddedJSON, parseSimpleSelector,
    findScriptByTagAndID, walkJSONDotPath) lives behind a {{- if "embedded-json" }}
    gate. The mode-dispatch switch and conditional imports (regexp,
    strconv, html) are gated to match.
    
    walkHTML, attrValue, nodeText, and htmlExtractionRequestURL stay
    unconditional — they're used by both branches (or, for
    htmlExtractionRequestURL, called from command_endpoint/promoted
    templates regardless of mode).
    
    A new APISpec.HasHTMLExtractMode(mode) helper introspects whether any
    endpoint declares the named mode (matched by EffectiveMode so an unset
    Mode counts as page).
    
    This eliminates the dead-helper count flagged by the food52 retro for
    embedded-json-only printed CLIs without changing emitted output for
    single-mode page/links specs.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): SKILL examples thread positional values via lookup chain
    
    firstCommandExample now appends each endpoint's positional values to
    the example invocation, sourced from the same lookup chain used by
    verify mock-mode dispatch (Param.Default → canonicalargs.Lookup →
    "mock-value"). Spec authors who set realistic defaults on positional
    params see them surface verbatim in SKILL.md and README.md examples;
    specs without defaults fall through to the cross-domain registry, then
    the catch-all.
    
    Motivation: the food52 retro shipped a SKILL.md example referencing
    "recipes get mock-slug" because the helper had no way to know what a
    realistic slug looks like. With this change, food52's spec setting
    `default: "sarah-fennel-s-best-lunch-lady-brownie-recipe"` on the
    recipes-get slug param surfaces that real value in every doc the
    generator emits, and verify-skill exits 0 on first generation rather
    than failing on synthetic placeholders.
    
    Templates (skill.md.tmpl, readme.md.tmpl) need no changes — they
    already splice the helper's return value verbatim.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 AGENTS.md                                          |   4 +-
 ...-feat-printing-press-food52-retro-fixes-plan.md | 574 +++++++++++++++++++++
 internal/browsersniff/analysis.go                  |  61 +++
 internal/browsersniff/analysis_test.go             | 100 ++++
 internal/canonicalargs/canonicalargs.go            |  43 ++
 internal/canonicalargs/canonicalargs_test.go       |  64 +++
 internal/cli/schema.go                             |  10 +-
 internal/generator/first_command_example.go        |  95 +++-
 internal/generator/first_command_example_test.go   | 125 +++++
 internal/generator/generator.go                    |  45 +-
 internal/generator/generator_test.go               | 290 ++++++++++-
 .../generator/templates/cliutil_verifyenv.go.tmpl  |  30 ++
 .../generator/templates/command_endpoint.go.tmpl   |   2 +
 .../generator/templates/command_promoted.go.tmpl   |   2 +
 internal/generator/templates/html_extract.go.tmpl  | 154 +++++-
 internal/pipeline/dogfood.go                       |  56 +-
 internal/pipeline/dogfood_test.go                  |  25 +
 internal/pipeline/publish.go                       | 187 ++++++-
 internal/pipeline/publish_novelfeatures_test.go    | 192 +++++++
 internal/pipeline/runtime.go                       |  77 ++-
 internal/pipeline/runtime_commands.go              | 179 ++++++-
 internal/pipeline/runtime_test.go                  | 134 +++++
 internal/pipeline/spec_detect.go                   |  64 +++
 internal/spec/spec.go                              | 103 +++-
 internal/spec/spec_test.go                         |  69 +++
 skills/printing-press/SKILL.md                     |  14 +-
 26 files changed, 2605 insertions(+), 94 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 055fbdf0..c17abdf0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -110,7 +110,9 @@ Key terms used throughout this repo. Several have overloaded meanings — the gl
 | **quality gates** | 7 mechanical static checks every printed CLI must pass: go mod tidy, go vet, go build, binary build, `--help`, version, doctor. These are build-time checks — see **verify** for runtime testing. |
 | **verify** | Runtime behavioral testing of a printed CLI — runs every command against the real API (read-only) or a mock server. Produces PASS/WARN/FAIL verdicts. Has `--fix` mode for auto-patching. Distinct from quality gates (static) and dogfood (structural). |
 | **dogfood** | Generation-time structural validation of a printed CLI against its source spec. Catches dead flags, invalid API paths, auth mismatches. Subcommand: `printing-press dogfood`. Compare with **doctor** (shipped in the CLI for end-users) and **verify** (runtime behavioral). |
-| **cliutil** | The generator-owned Go package emitted into every printed CLI at `internal/cliutil/`. Houses shared helpers meant for agent-authored novel code to import: `cliutil.FanoutRun` for aggregation commands (per-source error collection, bounded concurrency, source-order output), `cliutil.CleanText` for HTML/JSON-LD text normalization. **Generator-reserved namespace** — agents authoring novel code in Phase 3 must not put their code in `internal/cliutil/` or name their own helpers that collide with cliutil's exports. |
+| **cliutil** | The generator-owned Go package emitted into every printed CLI at `internal/cliutil/`. Houses shared helpers meant for agent-authored novel code to import: `cliutil.FanoutRun` for aggregation commands (per-source error collection, bounded concurrency, source-order output), `cliutil.CleanText` for HTML/JSON-LD text normalization, `cliutil.IsVerifyEnv()` for the side-effect short-circuit (see **side-effect command convention**). **Generator-reserved namespace** — agents authoring novel code in Phase 3 must not put their code in `internal/cliutil/` or name their own helpers that collide with cliutil's exports. |
+| **side-effect command convention** | Two-part rule for hand-written novel commands that perform visible actions (open browser tabs, send notifications, dial out to OS handlers). (1) Print by default; require explicit opt-in (`--launch`, `--send`, `--play`) to actually act. (2) Short-circuit when `cliutil.IsVerifyEnv()` is true — the verifier sets `PRINTING_PRESS_VERIFY=1` in every mock-mode subprocess, and the env-var check is the floor that catches any command the verifier's heuristic side-effect classifier misses. Documented in `skills/printing-press/SKILL.md` Phase 3 (principle 9). |
+| **canonicalargs** | Tiny generator subpackage at `internal/canonicalargs/` exporting `Lookup(name) (string, bool)` for cross-domain positional placeholder names (`since`, `until`, `tag`, `vertical`). Both verify mock-mode dispatch and the SKILL template consult this registry as one step in the lookup chain `spec.Param.Default → canonicalargs → legacy syntheticArgValue switch → "mock-value"`. **Domain-specific names belong in the spec author's `Param.Default`, not here** — anti-pattern: "Never change the machine for one CLI's edge case." |
 | **shipcheck** | The three-part verification block that gates publishing: dogfood + verify + scorecard, run together. All three must pass before a printed CLI ships. |
 | **scorecard** / **scoring** | Two-tier quality assessment with a 50/50 weighted composite. Tier 1: infrastructure (16 string-matching dimensions, raw max 160, normalized to 0-50). Tier 2: domain correctness (7 semantic dimensions, raw max 60 when live verify ran, normalized to 0-50). Total /100 with letter grades. Source of truth: `internal/pipeline/scorecard.go` (tier1Max / tier2Max). Subcommand: `printing-press scorecard`. |
 | **machine-owned freshness** | Opt-in freshness contract for store-backed printed CLIs using `cache.enabled`. Covered command paths map to syncable resources; in `--data-source auto` they may run a bounded pre-read refresh before serving local data. `--data-source local` never refreshes, `--data-source live` must not mutate the local store, and env opt-out only disables the freshness hook. This is current-cache freshness, not a guarantee of full historical backfill or API-specific enrichment. |
diff --git a/docs/plans/2026-04-27-001-feat-printing-press-food52-retro-fixes-plan.md b/docs/plans/2026-04-27-001-feat-printing-press-food52-retro-fixes-plan.md
new file mode 100644
index 00000000..a8cc4316
--- /dev/null
+++ b/docs/plans/2026-04-27-001-feat-printing-press-food52-retro-fixes-plan.md
@@ -0,0 +1,574 @@
+---
+title: "fix: printing-press food52 retro fixes (issue #337)"
+type: fix
+status: active
+date: 2026-04-27
+origin: https://github.com/mvanhorn/cli-printing-press/issues/337
+---
+
+# fix: printing-press food52 retro fixes (issue #337)
+
+## Overview
+
+The food52 retro filed nine findings against the Printing Press machine. Three are P1 (silently breaks every Next.js-style site, silently breaks dogfood Examples coverage to 0/10, and silently spams the user's browser during verify-mock). Four are P2 (publish-validate friction, traffic-analysis schema drift, dead-helper accumulation, mock-mode harness mismatches). Two are P3 cleanups that ride along with their P1 parents.
+
+This plan addresses all nine findings across seven implementation units. Each unit is independently mergeable; sequencing is for review ergonomics, not because units block each other except where noted.
+
+The retro's #335 predecessor (the allrecipes retro #333) already shipped: `doctor` honors `http_transport`, `auth.type:none` correctly skips the auth subcommand, `html_extract` mode-`page` suppresses `<noscript>` subtrees, and the generator emits a `dryRunOK` helper for verify-friendly RunE patterns. Cross-checked against this branch: none of the food52 findings were addressed by that PR — the `html_extract` work was about noscript handling for `mode: page`, not adding `mode: next-data`; the `dryRunOK` work helps `--dry-run` guards but doesn't supply canonical positional values to mock-mode verify; the `auth.type:none` fix correctly didn't touch the food52 path because food52 already shipped without spurious auth scaffolding.
+
+---
+
+## Problem Frame
+
+The food52 generation run did everything the Printing Press currently asks of it and still required substantial hand-work:
+
+- **4 generated handlers replaced** because the `html_extract` template only knew `mode: page` and `mode: links`; food52 — and any other site (commonly Next.js pages-router) that embeds its data in a `<script id="__NEXT_DATA__">` block — gave the page-mode extractor only generic page metadata + nav links to work with. The frequency of this pattern across our browser-sniff backlog is uncalibrated; food52 is the first to surface it concretely. The fix is small enough to be worth shipping on N=1, but the framing in earlier drafts ("every Next.js / Nuxt / Remix site") overstated coverage — the parameterized `script_selector` (see U4) is the path to actually covering Nuxt / Remix without per-framework code paths.
+- **17 source files sed'd** because the natural Go idiom for multi-line `Example:` strings (`strings.TrimSpace(\`...\`)`) silently breaks the dogfood scorer's example detection — leading 2-space indent gets stripped, dogfood's parser sees the first unindented line as a section boundary, captures nothing, scores 0/10.
+- **Hand-patched `.printing-press.json`** to add `novel_features` from `research.json`, because `lock promote` writes the manifest but doesn't merge in the dogfood-verified novel_features array, so publish validate fails on every first publish.
+- **`open` command emitted browser tabs to `https://food52.com/recipes/mock-value`** because verify mock-mode dispatches every command with placeholder positionals, and side-effecting commands have no convention to default-print.
+
+Each of these is a silent failure mode — no error message points the agent at the problem until something downstream surfaces it (dogfood verdict, publish validate, the user noticing browser tabs). The fixes are individually small. The cumulative effect is meaningful: every CLI with side-effecting commands, every first-time publish, and every CLI with hand-authored Examples will behave better; future SSR-React sites that embed pageProps-shaped data in a known script tag get a path that doesn't require hand-replaced handlers.
+
+The food52 retro is the source of truth for findings, evidence, and acceptance criteria. The retro document is reachable from issue #337 and locally at `manuscripts/food52/20260426-230853/proofs/20260427-014521-retro-food52-pp-cli.md` (relative to `~/printing-press`).
+
+---
+
+## Requirements Trace
+
+- R1. F1 — Generator can extract `__NEXT_DATA__` JSON directly from a spec declaration without hand-replaced handlers (covers Next.js / Nuxt / Remix sites generically).
+- R2. F2 — Dogfood detects `Example:` content regardless of indent style; generator templates emit Examples in a format that survives the natural Go idiom.
+- R3. F3 — Side-effect commands (browser launch, printer dispatch, system notification) follow a print-default + explicit-opt-in convention; verify mock mode never triggers visible side effects.
+- R4. F4 — `lock promote` populates the manifest's `novel_features` from the run's `research.json`, so publish validate passes on first publish without manual patching.
+- R5. F5 — `traffic-analysis.json` accepts the `browser_http` reachability mode the consumer code already handles, and accepts string-shaped evidence for hand-authored discovery reports.
+- R6. F6 — Generator emits `helpers.go` content selectively: helpers tied to features the spec doesn't use are not emitted.
+- R7. F7 — Verify mock mode supplies canonical positional values matching `--help` placeholder names; commands requiring positionals are no longer mechanically marked failed.
+- ~~R8. F8 — Generated `mode: next-data` extractor strips literal `undefined` / `null` / `NaN` tokens from extracted strings.~~ **Dropped from this plan in review round 1.** The proposed strip would mangle legitimate content (recipe instructions, programming articles, brand names). The food52 case is Sanity-CMS-specific and handled by food52's own parser. F8 stays open as a future finding if a generic pattern emerges across multiple CLIs.
+- R9. F9 — SKILL template threads positional values through example invocations using the spec-default-first lookup chain (spec.Param.Default → canonicalargs → mock-value), so `verify-skill` exits 0 on first generation AND user-facing examples use real values when the spec author declares them.
+
+**Origin actors:** Implementing agent (the next person/agent that runs `/printing-press` against any API).
+**Origin flows:** F1 — `printing-press generate` → emit handlers; F2 — `printing-press dogfood` → score Examples; F3 — `printing-press verify` (mock mode) → exercise commands; F4 — `printing-press lock promote` → write manifest → publish validate; F5 — `printing-press generate --traffic-analysis` → load + validate.
+
+---
+
+## Scope Boundaries
+
+- **No food52-specific changes.** None of these fixes hardcode Food52, Next.js's specific Sanity backend, Typesense, or any other API-specific detail. Every fix must work across APIs.
+- **No new spec format.** The `mode: embedded-json` addition extends the existing `html_extract` block; it does not introduce a parallel "next.js spec" or replace internal YAML / OpenAPI inputs.
+- **No changes to existing `syntheticArgValue` mappings.** U5 only ADDS new placeholder names via the new `internal/canonicalargs` package and reorders the lookup chain to consult the spec's `Param.Default` first. Existing per-name mappings (`slug`, `query`, `url`, `path`, `category`, `search`, `name`) stay at their current return values; changing them requires a separate audit of every library CLI's mock-mode behavior.
+- **No domain-specific names in `internal/canonicalargs`.** The shared registry stays generic + cross-domain (`since`, `until`, `tag`, `vertical`). Domain-specific names like `servings`, `ingredient`, `cuisine`, `recipe_id`, `airport_code`, `team_abbr` belong in the spec author's `Param.Default`, not in the machine. AGENTS.md anti-pattern: "Never change the machine for one CLI's edge case."
+- **No verify-mode redesign.** R7 fixes the placeholder-arg behavior for required positionals and the side-effect detection for visible-effect commands; it does not redesign verify's overall mock mode or migrate it to live mode.
+- **No conditional-helper-emission rewrite of `helpers.go.tmpl`.** R6 (WU-6) extends the existing `HelperFlags` mechanism (which already gates `HasDelete`, `HasPathParams`, `HasMultiPositional`, `HasDataLayer`); it does not split `helpers.go.tmpl` into separate physical files unless that ends up being the simpler implementation choice during execution.
+- **No backfill of existing library CLIs.** These fixes apply on the next regeneration. CLIs already in `~/printing-press/library/` are not retroactively rewritten.
+
+### Deferred to Follow-Up Work
+
+- **Auto-detect `__NEXT_DATA__` / `__NUXT__` / `window.__remixContext` during browser-sniff and pre-populate `html_extract.mode: embedded-json` + matching `script_selector` in the emitted spec.** WU-1 adds the mode + extractor; the auto-detection from a HAR or browser-use capture is a follow-up because it requires browser-sniff to introspect captured HTML for the Next.js script tag, which is a separate code path. Documented in the retro as part of WU-1's full goal but split out here because the extractor mode is independently shippable and useful.
+- **Spec-level annotation for side-effect commands.** WU-3 uses heuristics (`--help` keywords + AST scan for `exec.Command("open"|"xdg-open"|...)`) to detect side-effecting commands. A spec-level `side_effect: true` annotation would be more declarative but requires its own design discussion and isn't needed to unblock the immediate problem.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/spec/spec.go` — `HTMLExtract` struct (currently `Mode` / `LinkPrefixes` / `Limit`), `HTMLExtractMode*` constants, `EffectiveMode()`. The validation at the bottom of the file enforces the mode enum. WU-1 extends this struct + enum.
+- `internal/generator/templates/html_extract.go.tmpl` — runtime extractor emitted into every CLI. Currently switches on Mode (`page` / `links`). WU-1 adds the `embedded-json` branch here.
+- `internal/generator/generator.go` — `HelperFlags` struct + `computeHelperFlags(spec)`. Already gates emission of `classifyDeleteError`, `replacePathParam`, `usageErr`, provenance helpers. WU-6 extends this pattern with HTML-specific flags.
+- `internal/pipeline/dogfood.go` — `extractExamplesSection`. The bug is the loop body's `if len(line) > 0 && line[0] != ' ' && line[0] != '\t' { break }`, which treats any unindented line as a section boundary. WU-2 fixes this to break only on a recognized Cobra section header.
+- `internal/pipeline/publish.go` — `writeCLIManifestForPublish(state, dir)`. WU-4 extends this to read `state.PipelineDir + "/research.json"` and populate `manifest.NovelFeatures` from `novel_features_built` (preferred) or `novel_features` (fallback).
+- `internal/pipeline/runtime_commands.go` — `syntheticArgValue(name string) string`. Already maps `id` → `12345`, `region` → `mock-city`, `password` → `mock-secret`, default `mock-value`. WU-3 extends this map for common positional placeholder names (`slug`, `query`, `url`, etc.) and adds the side-effect-detection guard.
+- `internal/cli/schema.go` — JSON Schema printer for `traffic-analysis`. Hardcodes the reachability mode enum. WU-5 fixes the enum and the EvidenceRef shape.
+- `internal/browsersniff/analysis.go` — `EvidenceRef` struct (requires `EntryIndex int`). The Go consumer code at `ApplyReachabilityDefaults` already accepts `browser_http` mode. WU-5 makes the Go marshaling tolerant of either object-shaped or string-shaped evidence.
+- `internal/cli/publish.go:checkPublish` — the `transcendence` check that rejects `len(manifest.NovelFeatures) == 0`. WU-4 doesn't change this check; it ensures the manifest is populated upstream so this check passes naturally.
+- `internal/generator/templates/skill.md.tmpl` — `firstCommandExample .Resources` template helper produces the example string for the Agent-mode and Named-Profile sections. WU-7 extends `firstCommandExample` (or the helper that backs it) to thread canonical positional values.
+- `internal/pipeline/runtime_commands.go:classifyCommandKind` — the existing classifier that names commands by intent. WU-3's side-effect detection extends this with a new "side-effect" classification.
+- The food52 CLI's hand-built `internal/food52/nextdata.go`, `recipe.go`, and `cleanIngredientStrings` (in `~/printing-press/library/food52/internal/food52/util.go`) are the canonical proof-of-concept for WU-1's behavior — read them as reference but don't import.
+
+### Institutional Learnings
+
+- The `HelperFlags` pattern in `internal/generator/generator.go` is the existing infrastructure for conditional helper emission (R6 / WU-6). It works, has tests, and is the natural place to extend.
+- The recent `auth.type:none` fix (#335) shows the pattern for conditional generator behavior driven by spec content: detect a condition, route the generator's render path. WU-1 (embedded-json) and WU-6 (per-mode helper gating) follow the same shape.
+- Test fixtures for HTML extraction live under `testdata/` next to the generator tests. The food52 retro left scrubbed real HTML fixtures at `~/printing-press/library/food52/internal/food52/testdata/recipes-chicken.html` and friends — useful as a reference for what `__NEXT_DATA__`-bearing HTML actually looks like, though tests should use minimal handcrafted fixtures.
+
+### External References
+
+- Schema.org Recipe / Article JSON-LD shapes are stable and well-documented. Next.js's `__NEXT_DATA__` shape (`props.pageProps.<route-data>` + `buildId`) is documented in the Next.js source and is consistent across major versions.
+- No external docs needed for the verify mock-mode or schema fixes — the fix is self-contained to this codebase.
+
+---
+
+## Key Technical Decisions
+
+- **Use the existing `HelperFlags` mechanism for conditional helper emission (WU-6), not a file split.** The infrastructure already exists and `helpers.go.tmpl` already gates several helpers conditionally. Extending it is a smaller change than splitting `helpers.go.tmpl` into 5 physical files. If during execution it becomes clear that a file split is cleaner, the implementer can choose that path — the goal (no dead helpers when consumers don't exist) is what matters.
+- **`mode: embedded-json` uses a simple dot-notation `JSONPath` field, not a real JSONPath library.** The path is always `props.pageProps.<something>` for Next.js sites, and dot-notation is enough for every realistic case. Avoids a dependency.
+- **`EvidenceRef` becomes a sum type via custom JSON marshal/unmarshal, not a refactor of every consumer.** Callers reading `Evidence []EvidenceRef` continue to work; the new option is "evidence may be a string, in which case the EvidenceRef carries Reason=<string> and zero EntryIndex". Schema reflects this with `oneOf: [object, string]`.
+- **`syntheticArgValue` extension lives in the existing function, not a new registry.** The function is already a name → value lookup. Extending the lookup is a smaller change than introducing a parallel type-aware system.
+- **Side-effect detection uses heuristics, not a spec annotation.** Heuristics (`--help` keyword scan + AST scan for known exec.Command targets) are good enough to catch the common cases without requiring spec authors to remember an annotation. The spec annotation is the deferred follow-up for cases where heuristics are wrong.
+- **WU-2 fixes both the scorer parser AND the generator template.** Either alone leaves silent footguns: a tolerant parser still lets agents write fragile examples; a fixed template still trips on hand-written commands. Doing both means the natural Go idiom Just Works AND the parser is robust to whatever style developers actually use.
+- **WU-4 reads `novel_features_built` first, falls back to `novel_features`.** Dogfood writes `novel_features_built` after verifying which features were actually built. If dogfood didn't run (rare but possible — `lock promote` doesn't strictly require it), fall back to the planned list rather than emitting an empty array.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should `mode: embedded-json` auto-detect at browser-sniff time (e.g., scan captured HTML for known Next.js / Nuxt script tags)?** Resolved: out of scope for this plan — see Deferred to Follow-Up Work. The mode is independently shippable and useful even when an agent declares it manually.
+- **Should we delete the dead helpers from existing food52 + other library CLIs?** Resolved: no. Backfill is out of scope; the fix applies on next regeneration.
+
+### Deferred to Implementation
+
+- **Exact set of canonical placeholder name → mock value mappings for WU-3.** The minimum set is what food52 surfaced (`slug`, `query`, `url`, `vertical`, `tag`, `ingredient`, `servings`); the right full set is what the existing library's command tree actually uses. Discoverable at implementation time by scanning every published CLI's `--help`.
+- **Whether to gate the `noscript` subtree suppression already in `mode: page` (added by #335) on the same `HasHTMLExtractPage` flag in WU-6.** Likely yes for symmetry, but worth confirming during implementation that the suppression isn't useful in any non-HTML context.
+- **Whether `firstCommandExample` should produce a single canonical command string or expose enough to let the template assemble it.** The current helper returns a string; threading positional values may be cleanest at the helper level rather than in the template. Decide during execution.
+
+---
+
+## 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.*
+
+### `mode: embedded-json` extractor flow (WU-1; WU-8 dropped)
+
+```
+HTML response body
+   │
+   ▼
+parse HTML, find element matching spec.html_extract.script_selector
+(default selector: "script#__NEXT_DATA__")
+   │
+   ├─ no match → return error "no script tag matching <selector> found in page"
+   │
+   └─ match
+       │
+       ▼
+   json.Unmarshal script's text content into map[string]any
+       │
+       ├─ unmarshal error → return error
+       │
+       └─ ok
+           │
+           ▼
+   walk to <spec.html_extract.json_path>
+   (dot-notation; e.g. "props.pageProps.recipesByTag.results" for Next.js,
+    or "data.<route>" for Nuxt; missing key → typed empty)
+           │
+           ▼
+   re-marshal as json.RawMessage
+           │
+           ▼
+   return as the response body
+   (NO post-extraction string sanitization — see U4's plan-revision note)
+```
+
+### Side-effect command detection (WU-3)
+
+| Signal | Source | Action |
+|---|---|---|
+| `--help` body contains "browser", "launch", "send", "play", "open in" (case-insensitive, word-boundary) | Help text scan via `<cli> <command> --help` | Mark as side-effecting |
+| Source AST contains `exec.Command("open"\|"xdg-open"\|"start"\|"lp"\|"notify-send"\|"afplay"\|"say")` | Static scan over `internal/cli/*.go` | Mark as side-effecting |
+| Side-effecting AND command supports `--dry-run` | Verify dispatch | Run with `--dry-run` |
+| Side-effecting AND no `--dry-run` | Verify dispatch | Skip with WARN, not FAIL |
+| Not side-effecting | Verify dispatch | Run normally |
+
+### Examples-section parsing fix (WU-2)
+
+Change the loop break condition in `extractExamplesSection`:
+
+| Current | Fixed |
+|---|---|
+| Break on any unindented non-empty line | Break on a recognized Cobra section header: `Usage:`, `Aliases:`, `Available Commands:`, `Flags:`, `Global Flags:`, `Use "<cmd> [command] --help"...`, or two consecutive blank lines |
+
+### Manifest novel_features hydration (WU-4)
+
+```
+lock promote(state, dir)
+   │
+   ▼
+writeCLIManifestForPublish(state, dir)
+   │
+   ▼
+build CLIManifest from state
+   │
+   ▼
+NEW: read state.PipelineDir + "/research.json" if exists
+   │
+   ├─ has novel_features_built (non-empty) → use it
+   ├─ else has novel_features (non-empty) → use it
+   └─ else → leave manifest.NovelFeatures empty
+   │
+   ▼
+project to []NovelFeatureManifest{Name, Command, Description}
+   │
+   ▼
+WriteCLIManifest(dir, manifest)
+```
+
+---
+
+## Implementation Units
+
+- U1. **Examples robustness + correct generator template (WU-2 / F2)**
+
+**Goal:** Dogfood detects `Example:` content regardless of indent style; generator templates emit examples in a format that survives the natural Go idiom.
+
+**Requirements:** R2
+
+**Dependencies:** None.
+
+**Files:**
+- Modify: `internal/pipeline/dogfood.go` (function `extractExamplesSection`)
+- Modify: `internal/pipeline/dogfood_test.go` (add cases for `strings.TrimSpace`-style and `strings.Trim(..., "\n")`-style examples)
+- Modify: `skills/printing-press/SKILL.md` Phase 3 "Agent Build Checklist" — add a note that hand-authored novel-feature commands should use `Example: strings.Trim(\`...\`, "\n")` (preserves leading indent) NOT `strings.TrimSpace(\`...\`)` (strips it). Reference the dogfood failure mode this prevents.
+
+> **Plan-revision note (review round 1):** Originally proposed an audit-and-replace pass over `internal/generator/templates/*.tmpl` for `strings.TrimSpace(...)` in Example fields. ce-feasibility verified there are zero matches today — the food52 "17 files sed'd" was over hand-authored novel-feature commands written by the absorb agent, not generator templates. Template sweep dropped; SKILL note added so future hand-authored commands get the right pattern from the start.
+
+**Approach:**
+- Replace the loop's "break on first unindented line" with "break on first line that matches a recognized Cobra section header". Use a closed set of canonical Cobra headers ONLY: `Usage:`, `Aliases:`, `Available Commands:`, `Flags:`, `Global Flags:`, plus a literal-prefix match on `Use "` for the trailing "Use \"...\" for more information..." line Cobra emits at the bottom. Any other unindented line is treated as continuation of examples.
+- **Drop the "two consecutive blank lines" fallback proposed in the original revision.** ce-adversarial flagged this as fragile — Cobra's renderer doesn't guarantee double-blank between sections, and authored Examples may legitimately contain blank lines for visual grouping. The closed section-header set + "Use \" prefix" is enough; default behavior on truly-unrecognized content is "treat as example continuation", which lose-by-default is safer than misclassify.
+- Update the existing dogfood test that asserts the old break behavior.
+
+**Patterns to follow:**
+- Existing helper detection logic in `internal/pipeline/dogfood.go` for parsing `--help` output.
+
+**Test scenarios:**
+- Happy path: `--help` output with `Examples:` followed by `food52-pp-cli articles browse food` (no indent) followed by `  food52-pp-cli articles browse life --json` (indented) — dogfood detects both lines, scoring 1/1 for that command.
+- Happy path: `--help` output with the canonical `  food52-pp-cli x --json\n  food52-pp-cli y --json` (both indented) — same detection.
+- Edge case: `--help` output with `Examples:` followed by an empty line then `Flags:` — dogfood correctly captures empty examples and breaks at `Flags:`.
+- Edge case: command with no `Example:` field at all — dogfood correctly reports missing.
+- Negative: a line like `cmd-with-no-leading-space` that's NOT one of the section headers should still be captured as an example, not treated as a section boundary.
+
+**Verification:**
+- Re-running dogfood against the food52 CLI as it currently exists (with `strings.Trim(..., "\n")` examples) reports `Examples: 10/10`.
+- Re-running dogfood against a hypothetical CLI using `strings.TrimSpace(...)` examples ALSO reports `Examples: 10/10`.
+
+---
+
+- U2. **`lock promote` populates manifest novel_features fully (WU-4 / F4)**
+
+> **Plan-revision note (review round 1):** The original U2 description said this code didn't exist. It does — `internal/pipeline/publish.go:265-274` already calls `LoadResearch(state.PipelineDir())` and populates `m.NovelFeatures` from `research.NovelFeaturesBuilt`. This unit is now scoped to the two real gaps in that existing block.
+
+**Goal:** `printing-press lock promote` populates `.printing-press.json`'s `novel_features` for every realistic publish path, including (a) the dogfood-skipped case where only `novel_features` (planned) exists, and (b) the minimal-state case where `lock promote` runs without a pre-existing runstate.
+
+**Requirements:** R4
+
+**Dependencies:** None.
+
+**Files:**
+- Modify: `internal/pipeline/publish.go:265-274` (existing block — add a fallback branch: when `research.NovelFeaturesBuilt` is nil OR points to an empty slice, also try `research.NovelFeatures`)
+- Modify: `internal/pipeline/publish.go` (`writeCLIManifestForPublish`) — when `state.RunID == ""` (minimal-state path called from `lock.go:236-239`'s `NewMinimalState` fallback), discover the most recent runstate directory by globbing `<runstate-root>/runs/*/research.json` keyed on the API slug and use that path; on no match, log + skip (don't fail promote)
+- Modify: `internal/pipeline/state.go` — confirm `NewMinimalState` doesn't need a `RunID` field set (today it doesn't); the discovery happens at the publish boundary, not here
+- Modify: `internal/pipeline/publish_test.go` (add 4 test cases below)
+- Modify: `internal/pipeline/research.go` — verify `LoadResearch` already handles missing research.json gracefully (current behavior: `os.ReadFile` returns an error, caller's `if err == nil` branch skips). Add a comment if the contract needs to be made explicit.
+
+**Approach:**
+- Read the existing block at `publish.go:265-274` first; the fallback is a one-clause addition to its `if`. Don't write a parallel read.
+- For the minimal-state path: `lock.go:236-239` falls back to `NewMinimalState(cliName, dir)` when `FindStateByWorkingDir` fails. That state has empty `RunID`, so `state.PipelineDir()` returns `RunPipelineDir("")` — a path that doesn't contain a run's research.json. Detect `state.RunID == ""` inside `writeCLIManifestForPublish`, and glob the per-scope runstate directory for `runs/*/research.json` files. Pick the most recent by mtime. If the API name is in the manifest (it is — `m.APIName`), filter the glob to runs whose research.json's `api_name` field matches.
+- Failure modes: missing research.json (skip silently — older runs might not have one); malformed research.json (log warning to stderr but don't fail promote); IO errors on the glob (log + skip).
+
+**Patterns to follow:**
+- Existing manifest-write code path in `internal/pipeline/publish.go`. Existing graceful-failure pattern around `writeSmitheryYAML` (logs warning, doesn't fail promote). The runstate path conventions documented in `AGENTS.md` ("`~/printing-press/.runstate/<scope>/runs/<run-id>/`").
+
+**Test scenarios:**
+- Happy path: research.json has both `novel_features` (3 entries) and `novel_features_built` (2 entries — dogfood verified the third didn't build) → manifest gets the 2-entry built list. (Already passes today — assert no regression.)
+- Happy path (NEW behavior): research.json has `novel_features` only (no dogfood run yet) → manifest gets the planned list. Currently fails because `NovelFeaturesBuilt == nil` short-circuits.
+- Happy path (NEW behavior, minimal-state): `lock promote` called via `NewMinimalState` (RunID empty) on a CLI whose runstate exists at `~/printing-press/.runstate/<scope>/runs/<latest>/research.json` with a populated `novel_features` → glob discovers the path, manifest gets the list.
+- Edge case: research.json has neither array → manifest's `novel_features` is empty/omitted; publish validate's transcendence check still fails (correct — the CLI genuinely has no novel features).
+- Error path: research.json file doesn't exist → no error, manifest's novel_features just stays empty.
+- Error path: research.json exists but is malformed JSON → log warning to stderr, don't fail promote; manifest's novel_features stays empty.
+- Error path (minimal-state): no runstate directories exist at all → glob returns empty, log + skip, manifest's novel_features stays empty, promote succeeds.
+- Integration: full promote-then-validate cycle on a fixture pipeline state with a populated research.json → publish validate's transcendence check passes.
+
+**Verification:**
+- After `lock promote --cli <api>-pp-cli --dir <work-dir>`, the library's `.printing-press.json` contains a `novel_features` array matching the dogfood-verified list from research.json (or planned list when dogfood didn't run).
+- `printing-press publish validate --dir <library-dir>` exits 0 with `transcendence: PASS` for any CLI that had novel features in its research.json — including CLIs promoted via the minimal-state path.
+
+---
+
+- U3. **traffic-analysis schema parity (WU-5 / F5)**
+
+**Goal:** `traffic-analysis.json` validates with the `browser_http` mode the consumer code already accepts, and accepts hand-authored string evidence alongside HAR-derived `EvidenceRef` objects.
+
+**Requirements:** R5
+
+**Dependencies:** None.
+
+**Files:**
+- Modify: `internal/cli/schema.go` (the `traffic-analysis` JSON Schema printer — add `browser_http` to the reachability mode enum; change evidence array items from `{ "$ref": "#/$defs/evidence_ref" }` to `{ "oneOf": [{ "$ref": "#/$defs/evidence_ref" }, { "type": "string" }] }`)
+- Modify: `internal/browsersniff/analysis.go` (add custom JSON marshaling/unmarshaling on EvidenceRef — when the JSON value is a string, populate the struct's Reason field with the string and set `EntryIndex = -1` to distinguish string-derived from real-HAR-derived entries; when it's an object, behave as today)
+- Modify: `internal/browsersniff/analysis_test.go` (add roundtrip tests for both shapes; load a traffic-analysis.json with `browser_http` mode + string evidence and verify it succeeds)
+
+**Approach:**
+- The reachability mode enum addition is a one-line change. The schema currently lists `["standard_http", "browser_clearance_http", "browser_required", "blocked", "unknown"]`; add `"browser_http"`.
+- For evidence as a sum type, the cleanest Go approach is implementing `UnmarshalJSON` on `EvidenceRef` that tries `json.Unmarshal` into the struct first, and on type-mismatch error falls back to unmarshaling as a string, populating `Reason`, and setting `EntryIndex = -1`. The `MarshalJSON` either emits object form (real-HAR-derived, `EntryIndex >= 0`) or string form (when `EntryIndex == -1`, emit just the `Reason` value). This makes the round-trip stable and lets downstream consumers distinguish "first HAR entry" (`EntryIndex == 0`) from "string-derived prose evidence" (`EntryIndex == -1`).
+- Update the schema's `evidence_ref` definition to allow either object form (existing) or string form (new). JSON Schema `oneOf` is the right primitive.
+
+**Patterns to follow:**
+- Existing custom JSON marshaling in the codebase if any. Otherwise the Go stdlib pattern: implement `UnmarshalJSON([]byte) error` on the type.
+
+**Test scenarios:**
+- Happy path: traffic-analysis.json with `reachability.mode: browser_http` and an evidence array containing one string ("Surf cleared the challenge; plain curl returned 429") loads cleanly via `LoadTrafficAnalysis`.
+- Happy path: traffic-analysis.json with `reachability.mode: browser_http` and EvidenceRef objects (the existing HAR-derived shape) still loads as before.
+- Happy path: traffic-analysis.json with mixed string + object evidence loads — string entries get Reason populated, object entries get full struct.
+- Edge case: `mode: invalid_made_up_mode` is still rejected by the schema.
+- Roundtrip (string-derived): marshal an EvidenceRef unmarshaled from a string, get back the original string. EntryIndex == -1 distinguishes it from real-HAR-derived entries.
+- Roundtrip (object-derived): marshal an EvidenceRef unmarshaled from object form, get back the same object. EntryIndex >= 0 confirms HAR-derived.
+
+**Verification:**
+- `printing-press generate --traffic-analysis <file-with-browser_http-and-string-evidence> ...` succeeds (currently fails with `cannot unmarshal string into Go struct field`).
+- `printing-press schema traffic-analysis | jq '.properties.reachability.properties.mode.enum'` includes `"browser_http"`.
+
+---
+
+- U4. **`mode: embedded-json` HTML extractor (WU-1 / F1 only — F8 dropped from this plan)**
+
+> **Plan-revision note (review round 1):**
+> - **Mode renamed from `next-data` to `embedded-json` and parameterized.** Original name hard-baked Next.js pages-router; Next.js 14+ app router uses streamed RSC chunks (`__next_f.push`), Nuxt uses `__NUXT__`, Remix uses `window.__remixContext`, Astro has its own serialization. Generic name + `script_selector` field (e.g., `script#__NEXT_DATA__`) covers all of these patterns with one mode.
+> - **F8 (cleanExtractedString helper) dropped.** Reviewers showed the proposed substring strip would mangle legitimate recipe instructions ("undefined custard"), programming articles ("returns null when..."), and brand names ("NaN cookies"). The food52 case is a Sanity-CMS-specific serialization quirk; the right fix lives in food52's parser, not the generator. F8 stays open as a future finding if a generic pattern emerges.
+
+**Goal:** Specs declaring `html_extract: { mode: embedded-json, script_selector: "script#__NEXT_DATA__", json_path: "props.pageProps.<x>" }` (and equivalent shapes for Nuxt / Remix / Astro / future SSR-React frameworks) produce printed CLIs that extract structured data from the named script tag directly, with no hand-replaced handlers.
+
+**Requirements:** R1 (R8 dropped — see plan-revision note)
+
+**Dependencies:** None.
+
+**Files:**
+- Modify: `internal/spec/spec.go` (add `HTMLExtractModeEmbeddedJSON` constant; add `ScriptSelector string` field (defaults to `script#__NEXT_DATA__` for back-compat ergonomics) and `JSONPath string` field to `HTMLExtract`; extend the validation switch; update `EffectiveMode`'s comment)
+- Modify: `internal/spec/spec_test.go` (validation roundtrip cases for the new mode + json_path)
+- Modify: `internal/generator/templates/html_extract.go.tmpl` (add the `case "embedded-json":` branch — parse the configured `script_selector` to find the target `<script>` tag (default selector `script#__NEXT_DATA__` for the common Next.js case), extract its text content, parse JSON, walk dot-notation path, return as RawMessage. NO post-extraction string sanitization — see plan-revision note above.)
+- Modify: `internal/generator/generator_test.go` (golden tests: (a) `mode: embedded-json` with `script_selector: script#__NEXT_DATA__` against a Next.js fixture; (b) `mode: embedded-json` with `script_selector: script#__NUXT__` against a Nuxt fixture, both returning the configured json_path subtree)
+- Modify: `testdata/golden/fixtures/golden-api.yaml` (add a small endpoint demonstrating `mode: embedded-json` to lock the contract in)
+
+**Approach:**
+- The spec extension is small: one constant, two new fields (`ScriptSelector`, `JSONPath`), one validation case. Default `ScriptSelector` to `"script#__NEXT_DATA__"` so the common Next.js case requires only `mode: embedded-json` + `json_path: ...` in the spec.
+- The runtime extractor is ~30 lines: parse HTML and find the matching script element (use the same `golang.org/x/net/html` walker the page mode already imports — selector parsing is `tag#id` or `tag` for v1; expand later if needed), `json.Unmarshal` the script's text content into `map[string]any`, a small `walkDotPath(m, "props.pageProps.foo.bar")` helper that descends and returns the target as `json.RawMessage`, then re-marshal.
+- **Restructure the dispatch in `html_extract.go.tmpl` so mode-selection happens BEFORE the page-mode parse path.** Currently lines 55-103 unconditionally parse the HTML, walk the DOM, and run `looksLikeHTMLChallenge` before switching on `Mode` at the very end. For `mode: embedded-json`, the page-mode-specific work is wasted and `looksLikeHTMLChallenge` could mis-flag a Next.js page with a generic title as a challenge. New shape: switch on `opts.Mode` at the top; each mode owns its own parsing path. This naturally pairs with U6's per-mode gating — symbols only one mode uses live behind that mode's gate.
+- Auto-detection during browser-sniff is deferred (see Scope Boundaries) — for now spec authors declare it manually with the right `script_selector` for their target site.
+
+**Technical design:** *(directional, not implementation specification — the actual generator emits Go code into the printed CLI, this sketches what that emitted code should do)*
+
+The emitted handler for a `mode: embedded-json` endpoint (using the default `script#__NEXT_DATA__` selector) should look like:
+
+```
+extractNextDataPath(html, "props.pageProps.recipesByTag.results")
+  → returns json.RawMessage of the array, or error
+```
+
+with the helper roughly:
+
+```
+nextDataRe = regexp `<script id="__NEXT_DATA__" type="application/json">(.*?)</script>`
+match → parse → walk path → re-marshal → clean strings
+```
+
+**Patterns to follow:**
+- The `mode: page` and `mode: links` branches in the same file. The cliutil package's existing CleanText helper.
+
+**Test scenarios:**
+- Happy path: spec with `mode: embedded-json, json_path: "props.pageProps.recipesByTag.results"` (default `script_selector: script#__NEXT_DATA__`)` against a fixture HTML page containing `__NEXT_DATA__` with that path returns the expected JSON array.
+- Happy path: a different json_path on the same fixture returns the right subtree.
+- Edge case: json_path target is missing from the JSON → returns null or empty (decide which during impl), with no panic.
+- Edge case: json_path is empty string → returns the whole pageProps.
+- Error path: HTML has no `<script id="__NEXT_DATA__">` block → returns clear error "no __NEXT_DATA__ block in page".
+- Error path: `__NEXT_DATA__` block contains malformed JSON → returns parse error.
+- Integration: golden generator test produces the expected handler source code for a `mode: embedded-json` endpoint.
+
+**Verification:**
+- `golden-api.yaml` extended with a `mode: embedded-json` endpoint and golden test passes.
+- An ad-hoc test against the food52 chicken category page (in food52's testdata fixtures) returns the same recipe array the food52 retro's hand-built extractor returns.
+
+---
+
+- U5. **Side-effect command convention + verify mock-mode positional canonicals (WU-3 / F3 + F7)**
+
+> **Plan-revision note (review round 1):** ce-scope-guardian flagged that F3 (side-effect detection) and F7 (positional canonicals) are functionally independent and could split into U5a/U5b. After the B3 + P1.e revisions (slug/query unchanged + env-var convention added), the risk asymmetry shrank. Keeping them as one unit for simplicity, with this note: **the two parts are independently mergeable.** A reviewer can accept the canonicalargs + lookup-chain work (R7) in one commit without blocking on the side-effect detector + env-var convention (R3); the dependency relationship is one-direction (U7 depends on the canonicalargs registry only, not the side-effect logic).
+
+**Goal:** Verify mock mode never triggers visible side effects; commands with required positionals are no longer mechanically marked failed; the convention for side-effecting commands (print-by-default + explicit opt-in + `PRINTING_PRESS_VERIFY` env-var short-circuit) is documented in skill instructions and AGENTS.md.
+
+**Requirements:** R3, R7
+
+**Dependencies:** None. U7 depends on this unit's canonicalargs registry (R7 part); not the side-effect logic (R3 part).
+
+**Files:**
+- Create: `internal/canonicalargs/canonicalargs.go` — new shared subpackage exporting `Lookup(name string) (string, bool)` returning the canonical mock value for a positional placeholder name. Both `internal/pipeline` (verify) and `internal/generator` (SKILL template) import from here.
+- Modify: `internal/pipeline/runtime_commands.go` (`syntheticArgValue`) — add a new lookup chain: (1) caller-supplied spec `Param.Default` if non-empty, (2) `canonicalargs.Lookup`, (3) the existing per-name switch (preserved for back-compat), (4) `"mock-value"` fallback
+- Add to `internal/canonicalargs`: `since`, `until`, `tag`, `vertical` — generic + cross-domain placeholder names. **Do NOT add `servings`, `ingredient`, or other recipe-domain names** — those belong in the spec author's `Param.Default`, not the generic registry. AGENTS.md anti-pattern: "Never change the machine for one CLI's edge case."
+- Modify: `internal/pipeline/runtime_commands.go` — wire the spec.Param.Default lookup into the call sites that pass placeholder names (the dispatch loop has the spec available; thread it through to `syntheticArgValue` if it isn't already)
+- **Do NOT touch the existing `slug`/`query`/`url`/`path`/`category`/`search`/`name` mappings** in the per-name switch; they exist with calibrated return values the existing library depends on
+- Modify: `internal/pipeline/runtime_commands.go` (add a side-effect classifier — function `isSideEffectCommand(cmd *discoveredCommand, sourceDir string) bool` that scans the command's `--help` output for keyword markers AND scans the printed CLI's source tree for known shell-out patterns — `exec.Command(...)` over a known set of OS binaries, `pkg/browser` calls, etc. — using `sourceDir` as the printed CLI's root, NOT the printing-press binary's own internal/cli/)
+- Modify: `internal/pipeline/runtime.go` (set `PRINTING_PRESS_VERIFY=1` in every mock-mode subprocess env; in the dispatch loop, when `isSideEffectCommand` is true, route to `--dry-run` if supported, else skip-WARN with a message naming the heuristic that fired)
+- Modify: `internal/pipeline/runtime_test.go` (test cases for the new placeholder mappings + side-effect classifier)
+- Modify: `skills/printing-press/SKILL.md` Phase 3 — document **two** conventions for side-effect commands:
+  1. Print-by-default + explicit `--launch`/`--send`/`--play` opt-in flag (the food52 `open` command pattern).
+  2. **Generated side-effect commands MUST check `os.Getenv("PRINTING_PRESS_VERIFY") == "1"` before performing any visible action.** When the env var is set, the command prints what it would do and exits 0 instead of shelling out, opening a browser, sending a notification, etc. This is defense-in-depth: even if the heuristic detector in (1) misses a side-effecting command, the generated command itself short-circuits in mock-mode.
+- Modify: `AGENTS.md` — add the same two-part convention as a glossary entry (short).
+- Modify: `internal/generator/templates/cliutil_probe.go.tmpl` (or add a small helper to cliutil) — emit a `cliutil.IsVerifyEnv() bool` helper into every printed CLI so authors of novel side-effect commands have a one-line check (`if cliutil.IsVerifyEnv() { fmt.Println("would launch:", url); return nil }`).
+
+**Approach:**
+- The placeholder lookup is the smallest fix. Add the missing names; let `mock-value` continue as the catch-all default.
+- The side-effect classifier is heuristic. The two checks complement each other: `--help` keyword scan catches commands with descriptive help text (most), AST scan catches commands whose source obviously shells out (the rest). False positives (a command whose `--help` mentions "browser" innocuously) are rare and acceptable — the cost is "skipped in mock mode", not a failure.
+- The skill + AGENTS.md note is short — a paragraph naming the convention. Don't add a new phase or gate.
+
+**Patterns to follow:**
+- Existing `syntheticArgValue` map style. Existing `classifyCommandKind` switch in the same file.
+
+**Test scenarios:**
+- Happy path (NEW canonicalargs entries): `canonicalargs.Lookup("since")` returns an ISO date string (e.g., `"2026-01-01"`); `Lookup("until")` returns a later ISO date; `Lookup("tag")` returns `"mock-tag"`; `Lookup("vertical")` returns `"mock-vertical"`.
+- Happy path (spec-default-first lookup): a spec param with `default: "4"` named `servings` is passed through verify; the lookup chain returns `"4"` from the spec default before hitting canonicalargs (which doesn't have `servings`) or `mock-value`.
+- Happy path (canonicalargs-only): a spec param named `tag` with no `default` set falls through to canonicalargs and returns `"mock-tag"`.
+- Happy path (catch-all): a spec param with no `default` and a name NOT in canonicalargs (e.g., `airport_code`) falls through to `"mock-value"`.
+- Negative (regression): `syntheticArgValue("slug")` STILL returns `"general"` (existing per-name switch arm preserved). `syntheticArgValue("query")` STILL returns `"mock-query"`. `syntheticArgValue("url")` STILL returns `"/mock/path"`.
+- Negative (registry hygiene): `canonicalargs.Lookup("servings")` returns `("", false)` — recipe-domain names are NOT in the generic registry. Same for `ingredient`, `recipe_id`, `cuisine`, etc. The lookup-chain fallback (spec.Param.Default → mock-value) handles these.
+- Integration: re-running food52's verify mock-mode after this lands AND after food52's spec is updated to declare `default: "4"` on the `servings` param: `scale` no longer mock-fails for missing positional.
+- Happy path: a command with `Use: "get <slug>"` is invoked in mock mode as `<cli> get mock-slug` and is NOT classified as failed for missing args.
+- Happy path: a command whose source calls `exec.Command("open", url)` is detected as side-effecting and skip-WARN'd in mock mode.
+- Happy path: a command whose `--help` mentions "browser" is detected and skip-WARN'd in mock mode.
+- Happy path: a command marked side-effecting that supports `--dry-run` is dispatched with `--dry-run` rather than skip-WARN'd.
+- Edge case: a command with `--help` containing "browser" innocuously (e.g., "Output is browser-friendly HTML") — false positive is acceptable; document the boundary.
+- Error path: source file unreadable → AST scan returns no findings; classifier falls back to help-text scan only.
+- Negative: a regular read command (no side effects) is unaffected — exercised normally with placeholder args.
+- Integration: re-running verify mock-mode against a CLI with a side-effecting `open` command no longer launches the browser; the previously-failing `print` / `scale` / `which` commands now pass with canonical positionals.
+
+**Verification:**
+- `printing-press verify --dir <food52-cli> --spec <food52-spec>` does NOT launch any browser tabs even with the `open` command in mock mode.
+- `printing-press verify --dir <food52-cli> --spec <food52-spec>` reports `print PASS` and `scale PASS` (currently FAIL because mock mode supplied no positional).
+- `skills/printing-press/SKILL.md` Phase 3 has a paragraph about the side-effect convention.
+
+---
+
+- U6. **Per-mode gating inside `html_extract.go.tmpl` (WU-6 / F6)**
+
+> **Plan-revision note (review round 1):** The original U6 said `html_extract.go.tmpl` is "emitted unconditionally today" and proposed gating its emission. Reality: `internal/generator/generator.go:991-996` already gates emission on `g.Spec.HasHTMLExtraction()`. The "30 dead helpers" list from the retro included general-purpose helpers (`printOutput`, `filterFields`, `printCSV`, `prioritizeHeaders`, etc.) that are used by ALL generated handlers — gating those would break non-HTML CLIs. The HTML-only helpers (`walkHTML`, `applyMeta`, `htmlExtractedPage`, `htmlLink`, `firstImageSrc`, `nodeTextSuppressing`) all live in `html_extract.go.tmpl`, which is already gated. The remaining gap is per-mode gating WITHIN that file: a CLI with only `mode: next-data` shouldn't emit the page/links sub-helpers.
+
+**Goal:** When a spec uses `html_extract` exclusively in one mode, the generated `html_extract.go` doesn't emit the helpers tied to other modes.
+
+**Requirements:** R6
+
+**Dependencies:** U4 (the embedded-json mode must exist before per-mode gating distinguishes it from page/links).
+
+**Files:**
+- Modify: `internal/generator/generator.go` — extend `Spec.HasHTMLExtraction()` (or add a per-mode helper like `HasHTMLExtractMode(mode string) bool`) so the template can introspect which modes are in use across the spec's resources
+- Modify: `internal/generator/templates/html_extract.go.tmpl` — gate the page-mode helpers (`htmlExtractedPage`, `htmlLink`, `walkHTML`, `applyMeta`, `extractHTMLLink`, `firstImageSrc`, `nodeTextSuppressing`, `looksLikeHTMLChallenge`) behind `{{- if .HasHTMLExtractMode "page" }}`. Same for any links-mode-only helpers behind `{{- if .HasHTMLExtractMode "links" }}`. The `embedded-json` branch (added by U4) is gated similarly.
+- Modify: `internal/generator/generator_test.go` (golden cases: a CLI with only `mode: embedded-json` has no page-mode helpers; a CLI with mixed modes has both)
+
+**Approach:**
+- Don't touch `helpers.go.tmpl` — its helpers are general-purpose and not the source of the dead-helper count.
+- Don't change the file-level gate on `html_extract.go.tmpl` — it's already correct.
+- Audit `html_extract.go.tmpl` for which helpers each mode actually uses. Symbols only the page mode references: `htmlExtractedPage`, `htmlLink`, `walkHTML`, `applyMeta`, `extractHTMLLink`, `firstImageSrc`, `nodeTextSuppressing`, `looksLikeHTMLChallenge`. The challenge-detection (added in #335) is currently called inside the unconditional parse path; per-mode gating means moving the parse + challenge check into the page-mode branch and out of the dispatch prologue (which also fixes one of the U4 sub-issues — see U4's "no story for app-router" P1.d note: structural change to short-circuit before HTML parse).
+- The expected savings: a `mode: embedded-json`-only CLI omits ~7 helpers from the emitted `html_extract.go` (~150 lines). Smaller than the retro's "30 dead helpers" count because most of those weren't HTML-only to begin with.
+
+**Patterns to follow:**
+- Existing conditional gates in `helpers.go.tmpl` (`{{- if .HasDelete }}`, `{{- if .HasPathParams }}`).
+
+**Test scenarios:**
+- Happy path: a spec with `mode: page` only produces an `html_extract.go` containing all current page-mode helpers (no regression).
+- Happy path: a spec with `mode: embedded-json` only produces an `html_extract.go` containing only the embedded-json branch and its dependencies; `walkHTML`, `applyMeta`, `htmlExtractedPage`, etc. are absent.
+- Happy path: a spec with mixed modes (`page` + `embedded-json`) emits both branches.
+- Negative: a spec with no `html_extract` doesn't emit `html_extract.go` at all (existing behavior; assert no regression).
+- Golden: existing golden fixtures still produce the same generated output.
+
+**Verification:**
+- A regenerated CLI with `mode: embedded-json`-only spec has fewer helpers in `html_extract.go` than today.
+- Dogfood's dead-code audit on the same CLI reports fewer dead helpers (target: the page-mode-specific ones disappear). The general-purpose helpers in `helpers.go.tmpl` are out of scope and not expected to change.
+
+---
+
+- U7. **SKILL template threads canonical positional values into examples (WU-7 / F9)**
+
+**Goal:** SKILL.md generated for any CLI includes example invocations with usable positional values, so `verify-skill` exits 0 on first generation. Domain-specific values (food52's `servings`, ESPN's `sport`) come from the spec's own `Param.Default`; generic ones come from the shared `canonicalargs` registry; the catch-all is `mock-value`.
+
+**Requirements:** R9
+
+**Dependencies:** U5 (uses the same `internal/canonicalargs` package and spec-default-first lookup chain).
+
+**Files:**
+- Modify: `internal/generator/first_command_example.go` (or wherever `firstCommandExample` is defined) — extend the helper to assemble positional values using the same lookup chain as U5: (1) spec param's `Default` field if non-empty, (2) `canonicalargs.Lookup(name)`, (3) `"mock-value"` fallback. Returns a complete invocation string instead of just a command path.
+- Modify: `internal/generator/templates/skill.md.tmpl` (the `{{$agentExample := firstCommandExample .Resources}}` blocks) — once the helper returns a complete invocation, the template just substitutes it directly; no per-positional logic in the template itself.
+- Modify: `internal/generator/generator_test.go` (golden case asserting a CLI with `articles get <slug>` produces SKILL examples that include a slug — `mock-value` if no spec default; the spec default if one is set; `general` from the existing per-name switch arm if the param is named `slug` and no default is set, since the lookup falls through to `syntheticArgValue` semantics for back-compat — see U5 lookup chain)
+
+**Approach:**
+- The food52 retro problem (mock-slug in a user-facing SKILL) goes away naturally: when food52's spec is updated to declare `default: "sarah-fennel-s-best-lunch-lady-brownie-recipe"` on the recipes-get slug param (a real Food52 slug), the SKILL example uses that real slug — not `mock-slug`. Spec authors get to control what users see; the generator stops baking in synthetic placeholders.
+- For specs that don't declare defaults: SKILL examples use whatever the lookup chain returns. If `verify-skill` accepts these (it should — they're well-formed invocations), exit 0 on first generation.
+
+**Patterns to follow:**
+- The Quick Start section of the SKILL template, which already builds command invocations with realistic args from the research.json narrative — that's the precedent for "spec/research-derived realistic values beat synthetic placeholders". This unit extends the same principle to the Agent-mode and Named-Profile examples.
+
+**Test scenarios:**
+- Happy path: SKILL.md for a CLI with `Use: "articles get <slug>"` includes `articles get mock-slug --agent --select ...` in both the Agent-mode section (line 176-ish) and the Named Profiles section (line 228-ish).
+- Happy path: SKILL.md for a CLI whose first command takes no positionals continues to render today's behavior (no placeholder injected where none is needed).
+- Edge case: a command with multiple required positionals (`Use: "browse-sub <vertical> <sub>"`) gets both placeholders.
+- Edge case: a command with optional positionals (`Use: "search [<query>]"`) does not get a placeholder injected for the optional.
+- Integration: regenerate the food52 CLI, run `printing-press verify-skill --dir <food52>`. Exit 0 with zero positional-args findings.
+
+**Verification:**
+- Golden test for `skill.md.tmpl` updated with the new example shape.
+- `verify-skill` exits 0 on a freshly regenerated CLI.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph:**
+  - U1 (Examples) — touches dogfood scorer; downstream affects every CLI's published Scorecard. Visible to users via the new (correct) Examples score.
+  - U2 (manifest novel_features) — touches lock promote → publish validate. Visible to users via no-longer-failing first publish.
+  - U3 (schema) — touches generate's traffic-analysis loader. Visible to anyone hand-authoring traffic-analysis.json.
+  - U4 (embedded-json) — adds two new spec fields + new generator template branch. Backward-compatible (existing specs unaffected). Visible via specs that opt in.
+  - U5 (verify safety + positionals) — touches verify mock-mode dispatch. Visible via no browser tabs + higher mock-mode pass rate.
+  - U6 (helper emission) — touches generator output. Visible via cleaner generated source + better Dead Code score.
+  - U7 (SKILL positionals) — touches SKILL template + verify-skill. Visible via no first-publish verify-skill failures.
+- **Error propagation:**
+  - U2's research.json read MUST not fail promote — the entire promote step is end-of-run and a missing/malformed research.json should log + skip, not abort.
+  - U4's `embedded-json` extractor MUST surface clear errors when the configured `script_selector` matches no element OR when the matched script's text isn't valid JSON is absent or the json_path target is missing — silent empty returns would mask broken specs.
+  - U5's side-effect detector MUST not block verify on a false positive — skip-WARN is the right action, not skip-FAIL.
+- **State lifecycle risks:**
+  - U6's conditional helper gating risks breaking existing CLIs if a helper was thought-unused but is referenced from generated code I missed. Mitigation: golden tests and a local regeneration sweep over the existing library before merge.
+- **API surface parity:**
+  - `traffic-analysis.json`'s schema and the consumer code both need the same set of accepted modes — U3 fixes the schema; the Go consumer already accepts `browser_http`. Confirm no other validators (CI, downstream tools) read the schema independently.
+- **Integration coverage:**
+  - U2 → publish validate is a multi-stage flow; an integration test for the full sequence is in the unit's test scenarios.
+  - U4 + U6 interact (embedded-json mode narrows the helper consumer set); golden tests for both confirm the combined behavior.
+  - U5 + U7 share the canonical positional registry; tests should confirm consistency.
+- **Unchanged invariants:**
+  - The spec format remains backward-compatible. Existing specs without `mode: embedded-json` work exactly as today.
+  - The publish PR shape remains unchanged. The only difference is the manifest now has `novel_features` populated by default.
+  - `cliutil.FanoutRun` and `cliutil.CleanText` (mentioned in AGENTS.md) are not modified by this plan.
+  - The agentic SKILL review (Phase 4.8) and Phase 4.85 output review behavior are unchanged.
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| U6's helper gating breaks an existing CLI by hiding a helper that's actually used | Golden tests for the existing library shapes; pre-merge sweep that regenerates a cross-section of the library and runs `go build ./...` on each |
+| U4's embedded-json extractor doesn't handle a Next.js shape it should (e.g., app-router RSC payloads, app router) | Document the supported shape (`__NEXT_DATA__` with `props.pageProps`); explicitly out of scope for this iteration. App router migration is a future concern |
+| U5's side-effect heuristics have a high false-positive rate | Skip-WARN (not FAIL) is the correct action on positive detection — false positives only mean "didn't exercise this command in mock", which is acceptable. Live verify still runs the command |
+| U2's `strings.Trim(..., "\n")` template change conflicts with another in-flight template edit | Confirm no other open PRs touch the Example fields in the templates; the change is mechanical and small enough that conflicts resolve trivially |
+| U3's evidence sum-type unmarshaling subtly changes behavior for downstream consumers expecting strict object form | The new behavior is additive (objects still work as before; strings are a new accepted shape). Add a roundtrip test asserting object-shaped evidence is unchanged |
+
+---
+
+## Documentation / Operational Notes
+
+- **CHANGELOG entries:** each merged unit produces an entry under `cli` scope. U2 / U6 are likely `feat(cli): ...`; the rest are `fix(cli): ...`.
+- **AGENTS.md:** U5 adds a short paragraph on the side-effect-command convention. Glossary doesn't need a new term.
+- **SKILL.md (printing-press):** U5 adds a one-paragraph note in Phase 3 referencing the side-effect convention. No new phase, no new gate.
+- **`docs/PIPELINE.md`:** unchanged. Phase ordering and intent are unaffected.
+- **Rollout:** these are all backward-compatible. Existing library CLIs continue to work as-is. New generations pick up the fixes automatically.
+- **Golden harness:** several units add golden cases. After implementing each, run `scripts/golden.sh verify`; if drift is intentional, run `scripts/golden.sh update` and explain the diffs in the commit.
+
+---
+
+## Sources & References
+
+- **Origin issue:** [Issue #337 — Retro: Food52 — 9 findings, 7 work units](https://github.com/mvanhorn/cli-printing-press/issues/337)
+- **Origin retro doc:** Local at `~/printing-press/manuscripts/food52/20260426-230853/proofs/20260427-014521-retro-food52-pp-cli.md`. Catbox mirror: https://files.catbox.moe/uwrw1e.md
+- **Predecessor plan (allrecipes retro):** [docs/plans/2026-04-26-002-feat-printing-press-p1-machine-fixes-plan.md](2026-04-26-002-feat-printing-press-p1-machine-fixes-plan.md) — shipped via PR #335; cross-checked above so this plan only proposes work that #335 didn't already cover.
+- **Predecessor PR:** [PR #335 — fix(cli): printing-press P1 machine fixes (issue #333)](https://github.com/mvanhorn/cli-printing-press/pull/335)
+- **Food52 publish PR (provides the regenerate target):** [PR #137 — feat(food52): add food52](https://github.com/mvanhorn/printing-press-library/pull/137)
+- **Food52 CLI source (reference for the hand-built embedded-json extractor + cleanIngredientStrings):** `~/printing-press/library/food52/internal/food52/`
+- **Generator templates:** `internal/generator/templates/`
+- **Spec types:** `internal/spec/spec.go`
+- **Pipeline (publish/dogfood/runtime):** `internal/pipeline/`
+- **Browser-sniff schema printer:** `internal/cli/schema.go`
+- **Browser-sniff types:** `internal/browsersniff/analysis.go`
diff --git a/internal/browsersniff/analysis.go b/internal/browsersniff/analysis.go
index 84373e81..5947af5b 100644
--- a/internal/browsersniff/analysis.go
+++ b/internal/browsersniff/analysis.go
@@ -42,6 +42,22 @@ type TrafficAnalysisSummary struct {
 	TimeEnd          string         `json:"time_end,omitempty"`
 }
 
+// EvidenceRef cites a piece of evidence for an observation. Two flavors:
+//
+//   - Object form (HAR-derived): `EntryIndex >= 0` references a specific
+//     entry in the captured HAR; the other fields describe the request.
+//     Produced by the HAR analyzer and serialized as a JSON object.
+//
+//   - String form (prose-derived): `EntryIndex == -1` is the sentinel for
+//     a hand-authored evidence string. The `Reason` field carries the
+//     prose; other fields are zero-valued. Serialized as a JSON string,
+//     not an object — round-trip preserves intent. Used for hand-authored
+//     traffic-analysis.json files where the evidence is observational
+//     prose rather than a HAR entry pointer.
+//
+// Consumers reading evidence can distinguish the two via `EntryIndex`:
+// `>= 0` is HAR-derived and the other fields are usable; `== -1` means
+// only `Reason` carries information.
 type EvidenceRef struct {
 	EntryIndex  int    `json:"entry_index"`
 	Method      string `json:"method,omitempty"`
@@ -52,6 +68,51 @@ type EvidenceRef struct {
 	Reason      string `json:"reason,omitempty"`
 }
 
+// EvidenceRefStringSentinel is the EntryIndex value that marks a string-derived
+// evidence entry. Object-derived entries have EntryIndex >= 0.
+const EvidenceRefStringSentinel = -1
+
+// MarshalJSON emits string form when the sentinel is set, object form
+// otherwise. This keeps round-trips stable: a string in → a string out;
+// an object in → an object out.
+func (e EvidenceRef) MarshalJSON() ([]byte, error) {
+	if e.EntryIndex == EvidenceRefStringSentinel {
+		// String-derived: emit only the Reason value as a JSON string.
+		return json.Marshal(e.Reason)
+	}
+	// Object-derived: use a local alias type so we don't infinite-loop on
+	// MarshalJSON. Standard Go pattern for "marshal me as a struct".
+	type alias EvidenceRef
+	return json.Marshal(alias(e))
+}
+
+// UnmarshalJSON accepts either an object (HAR-derived) or a string
+// (hand-authored prose). On a string input, populates Reason and sets
+// EntryIndex to the sentinel.
+func (e *EvidenceRef) UnmarshalJSON(data []byte) error {
+	// Try string first: cheaper to detect and the empty-data case
+	// returns a clear error rather than a confusing zero-struct.
+	if len(data) > 0 && data[0] == '"' {
+		var s string
+		if err := json.Unmarshal(data, &s); err != nil {
+			return fmt.Errorf("evidence ref string: %w", err)
+		}
+		*e = EvidenceRef{
+			EntryIndex: EvidenceRefStringSentinel,
+			Reason:     s,
+		}
+		return nil
+	}
+	// Object form: standard struct unmarshal via local alias.
+	type alias EvidenceRef
+	var a alias
+	if err := json.Unmarshal(data, &a); err != nil {
+		return fmt.Errorf("evidence ref object: %w", err)
+	}
+	*e = EvidenceRef(a)
+	return nil
+}
+
 type ProtocolObservation struct {
 	Label      string            `json:"label"`
 	Confidence float64           `json:"confidence"`
diff --git a/internal/browsersniff/analysis_test.go b/internal/browsersniff/analysis_test.go
index aa10fbcb..ad132f15 100644
--- a/internal/browsersniff/analysis_test.go
+++ b/internal/browsersniff/analysis_test.go
@@ -539,3 +539,103 @@ func clusterHosts(clusters []EndpointCluster) []string {
 	}
 	return hosts
 }
+
+func TestEvidenceRef_RoundTripObjectForm(t *testing.T) {
+	t.Parallel()
+	in := EvidenceRef{
+		EntryIndex:  3,
+		Method:      "GET",
+		Host:        "example.com",
+		Path:        "/api/v1/users",
+		Status:      200,
+		ContentType: "application/json",
+		Reason:      "200 with JSON body",
+	}
+	data, err := json.Marshal(in)
+	require.NoError(t, err)
+	// Object form should marshal as a JSON object, not a string.
+	assert.True(t, len(data) > 0 && data[0] == '{', "expected object form, got: %s", data)
+
+	var out EvidenceRef
+	require.NoError(t, json.Unmarshal(data, &out))
+	assert.Equal(t, in, out, "object-form roundtrip should be lossless")
+}
+
+func TestEvidenceRef_RoundTripStringForm(t *testing.T) {
+	t.Parallel()
+	in := EvidenceRef{
+		EntryIndex: EvidenceRefStringSentinel,
+		Reason:     "Surf cleared the challenge; plain curl returned 429.",
+	}
+	data, err := json.Marshal(in)
+	require.NoError(t, err)
+	// String form should marshal as a quoted JSON string, not an object.
+	assert.True(t, len(data) > 0 && data[0] == '"', "expected string form, got: %s", data)
+
+	var out EvidenceRef
+	require.NoError(t, json.Unmarshal(data, &out))
+	assert.Equal(t, EvidenceRefStringSentinel, out.EntryIndex, "string roundtrip preserves sentinel")
+	assert.Equal(t, in.Reason, out.Reason, "string roundtrip preserves Reason")
+	assert.Empty(t, out.Method, "string-derived has no Method")
+	assert.Empty(t, out.Host, "string-derived has no Host")
+}
+
+func TestEvidenceRef_UnmarshalAcceptsString(t *testing.T) {
+	t.Parallel()
+	// A bare JSON string should unmarshal cleanly into an EvidenceRef.
+	var out EvidenceRef
+	require.NoError(t, json.Unmarshal([]byte(`"prose evidence"`), &out))
+	assert.Equal(t, EvidenceRefStringSentinel, out.EntryIndex)
+	assert.Equal(t, "prose evidence", out.Reason)
+}
+
+func TestEvidenceRef_UnmarshalAcceptsObject(t *testing.T) {
+	t.Parallel()
+	// Existing object-shaped HAR-derived form continues to work.
+	var out EvidenceRef
+	require.NoError(t, json.Unmarshal([]byte(`{"entry_index": 7, "method": "POST", "host": "x.com"}`), &out))
+	assert.Equal(t, 7, out.EntryIndex)
+	assert.Equal(t, "POST", out.Method)
+	assert.Equal(t, "x.com", out.Host)
+}
+
+func TestEvidenceRef_MixedArrayInTrafficAnalysis(t *testing.T) {
+	t.Parallel()
+	// Traffic-analysis files in the wild may carry mixed object + string
+	// evidence (HAR-derived alongside hand-authored prose). Verify the
+	// reachability evidence array survives a round-trip through the full
+	// TrafficAnalysis struct.
+	raw := []byte(`{
+  "version": "1",
+  "summary": {"entry_count": 0, "api_entry_count": 0, "noise_entry_count": 0},
+  "reachability": {
+    "mode": "browser_http",
+    "confidence": 0.9,
+    "evidence": [
+      "Surf with Chrome impersonation cleared Vercel without cookies.",
+      {"entry_index": 0, "method": "GET", "host": "food52.com", "status": 429}
+    ]
+  },
+  "protocols": [],
+  "auth": {},
+  "endpoint_clusters": []
+}`)
+	var ta TrafficAnalysis
+	require.NoError(t, json.Unmarshal(raw, &ta))
+	require.NotNil(t, ta.Reachability)
+	require.Len(t, ta.Reachability.Evidence, 2)
+	// First entry is string-derived
+	assert.Equal(t, EvidenceRefStringSentinel, ta.Reachability.Evidence[0].EntryIndex)
+	assert.Contains(t, ta.Reachability.Evidence[0].Reason, "Surf")
+	// Second entry is HAR-derived
+	assert.Equal(t, 0, ta.Reachability.Evidence[1].EntryIndex)
+	assert.Equal(t, "GET", ta.Reachability.Evidence[1].Method)
+
+	// Round-trip preserves shapes: string stays string, object stays object.
+	out, err := json.Marshal(ta)
+	require.NoError(t, err)
+	assert.Contains(t, string(out), `"Surf with Chrome impersonation cleared Vercel without cookies."`,
+		"string-form evidence should re-emit as a JSON string")
+	assert.Contains(t, string(out), `"entry_index":0`,
+		"object-form evidence should re-emit as a JSON object")
+}
diff --git a/internal/canonicalargs/canonicalargs.go b/internal/canonicalargs/canonicalargs.go
new file mode 100644
index 00000000..a43794be
--- /dev/null
+++ b/internal/canonicalargs/canonicalargs.go
@@ -0,0 +1,43 @@
+// Package canonicalargs provides shared canonical mock values for positional
+// placeholder names found in printed-CLI Usage strings. Both the verify
+// pipeline (mock-mode dispatch) and the generator's SKILL template consult
+// this registry so generated SKILL examples and verify invocations stay
+// in sync.
+//
+// The registry is intentionally tiny and biased toward generic, cross-domain
+// names. Domain-specific placeholder names (e.g., recipe `servings`,
+// brokerage `ticker`, geo `airport_code`) belong in the spec author's
+// `Param.Default` field, not here. AGENTS.md anti-pattern: "Never change
+// the machine for one CLI's edge case."
+package canonicalargs
+
+import "strings"
+
+// canonical maps lowercase placeholder names to their canonical mock value.
+// Keep entries small in number and clearly cross-domain. If a name only
+// makes sense for one product or industry, it does not belong here — the
+// spec author should set Param.Default instead.
+var canonical = map[string]string{
+	// Time windows used by sync, list, and search endpoints across many
+	// domains (changelog, audit log, listings, articles, tickets).
+	"since": "2026-01-01",
+	"from":  "2026-01-01",
+	"until": "2026-12-31",
+	"to":    "2026-12-31",
+
+	// Filter dimensions common to taxonomy- or section-driven products
+	// (tags on articles/recipes/issues; vertical on classifieds, news,
+	// jobs, sports content).
+	"tag":      "mock-tag",
+	"vertical": "mock-vertical",
+}
+
+// Lookup returns the canonical mock value for a positional placeholder
+// name, or ("", false) if the registry has no entry. Names are matched
+// case-insensitively. Callers should always treat absence as a signal to
+// fall through to the next step in the lookup chain (spec.Param.Default
+// → canonicalargs → caller-specific switch → "mock-value").
+func Lookup(name string) (string, bool) {
+	v, ok := canonical[strings.ToLower(strings.TrimSpace(name))]
+	return v, ok
+}
diff --git a/internal/canonicalargs/canonicalargs_test.go b/internal/canonicalargs/canonicalargs_test.go
new file mode 100644
index 00000000..3dc8110f
--- /dev/null
+++ b/internal/canonicalargs/canonicalargs_test.go
@@ -0,0 +1,64 @@
+package canonicalargs
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestLookup_KnownNames(t *testing.T) {
+	cases := []struct {
+		name string
+		want string
+	}{
+		{"since", "2026-01-01"},
+		{"until", "2026-12-31"},
+		{"tag", "mock-tag"},
+		{"vertical", "mock-vertical"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got, ok := Lookup(c.name)
+			assert.True(t, ok, "Lookup(%q) ok", c.name)
+			assert.Equal(t, c.want, got)
+		})
+	}
+}
+
+func TestLookup_CaseInsensitive(t *testing.T) {
+	got, ok := Lookup("Since")
+	assert.True(t, ok)
+	assert.Equal(t, "2026-01-01", got)
+
+	got, ok = Lookup("  TAG  ")
+	assert.True(t, ok)
+	assert.Equal(t, "mock-tag", got)
+}
+
+// Registry hygiene: domain-specific names must NOT appear in the generic
+// registry. Spec authors set Param.Default for these.
+func TestLookup_DomainSpecificNamesAbsent(t *testing.T) {
+	domain := []string{
+		"servings",     // recipe (food52)
+		"ingredient",   // recipe
+		"recipe_id",    // recipe
+		"cuisine",      // recipe
+		"sport",        // sports (espn)
+		"league",       // sports
+		"airport_code", // travel
+		"ticker",       // finance
+	}
+	for _, name := range domain {
+		t.Run(name, func(t *testing.T) {
+			_, ok := Lookup(name)
+			assert.False(t, ok, "%q must not be in the generic registry — set Param.Default in the spec instead", name)
+		})
+	}
+}
+
+func TestLookup_UnknownReturnsFalse(t *testing.T) {
+	_, ok := Lookup("totally-made-up-name")
+	assert.False(t, ok)
+	_, ok = Lookup("")
+	assert.False(t, ok)
+}
diff --git a/internal/cli/schema.go b/internal/cli/schema.go
index 19d5a494..081e4b60 100644
--- a/internal/cli/schema.go
+++ b/internal/cli/schema.go
@@ -35,10 +35,10 @@ const trafficAnalysisSchemaJSON = `{
       "additionalProperties": false,
       "required": ["mode", "confidence"],
       "properties": {
-        "mode": {"type": "string", "enum": ["standard_http", "browser_clearance_http", "browser_required", "blocked", "unknown"]},
+        "mode": {"type": "string", "enum": ["standard_http", "browser_http", "browser_clearance_http", "browser_required", "blocked", "unknown"]},
         "confidence": {"type": "number", "minimum": 0, "maximum": 1},
         "reasons": {"type": "array", "items": {"type": "string"}},
-        "evidence": {"type": "array", "items": {"$ref": "#/$defs/evidence_ref"}}
+        "evidence": {"type": "array", "items": {"oneOf": [{"$ref": "#/$defs/evidence_ref"}, {"type": "string"}]}}
       }
     },
     "protocols": {"type": "array", "items": {"$ref": "#/$defs/protocol_observation"}},
@@ -79,7 +79,7 @@ const trafficAnalysisSchemaJSON = `{
       "properties": {
         "label": {"type": "string"},
         "confidence": {"type": "number", "minimum": 0, "maximum": 1},
-        "evidence": {"type": "array", "items": {"$ref": "#/$defs/evidence_ref"}},
+        "evidence": {"type": "array", "items": {"oneOf": [{"$ref": "#/$defs/evidence_ref"}, {"type": "string"}]}},
         "details": {"type": "object", "additionalProperties": {"type": "string"}}
       }
     },
@@ -104,7 +104,7 @@ const trafficAnalysisSchemaJSON = `{
       "properties": {
         "label": {"type": "string"},
         "confidence": {"type": "number", "minimum": 0, "maximum": 1},
-        "evidence": {"type": "array", "items": {"$ref": "#/$defs/evidence_ref"}},
+        "evidence": {"type": "array", "items": {"oneOf": [{"$ref": "#/$defs/evidence_ref"}, {"type": "string"}]}},
         "notes": {"type": "array", "items": {"type": "string"}}
       }
     },
@@ -151,7 +151,7 @@ const trafficAnalysisSchemaJSON = `{
       "properties": {
         "label": {"type": "string"},
         "confidence": {"type": "number", "minimum": 0, "maximum": 1},
-        "evidence": {"type": "array", "items": {"$ref": "#/$defs/evidence_ref"}},
+        "evidence": {"type": "array", "items": {"oneOf": [{"$ref": "#/$defs/evidence_ref"}, {"type": "string"}]}},
         "notes": {"type": "array", "items": {"type": "string"}}
       }
     },
diff --git a/internal/generator/first_command_example.go b/internal/generator/first_command_example.go
index 1f67b466..f5c3b9c5 100644
--- a/internal/generator/first_command_example.go
+++ b/internal/generator/first_command_example.go
@@ -1,23 +1,34 @@
 package generator
 
 import (
+	"fmt"
 	"sort"
+	"strings"
 
+	"github.com/mvanhorn/cli-printing-press/v2/internal/canonicalargs"
 	"github.com/mvanhorn/cli-printing-press/v2/internal/spec"
 )
 
-// firstCommandExample returns a runnable "resource [endpoint]" path for docs
-// that need a concrete example. Read-only verbs (list, get, search, query)
-// are preferred to keep examples non-destructive. Returns empty when the
-// spec has no endpoints, so callers can skip the block rather than render
-// nonsense.
+// firstCommandExample returns a runnable "resource [endpoint] <pos1> <pos2>..."
+// invocation for docs that need a concrete example. Read-only verbs (list,
+// get, search, query) are preferred to keep examples non-destructive.
+// Returns empty when the spec has no endpoints, so callers can skip the
+// block rather than render nonsense.
 //
 // For single-endpoint resources that the generator promotes to top-level
-// commands, the returned path is just the resource name (the actual cobra
-// command path), not "resource endpoint" (the pre-promotion path). The
-// SKILL.md verifier in printing-press-library walks command references and
-// rejects pre-promotion paths because they don't exist in the shipped
-// internal/cli/*.go.
+// commands, the returned path starts with just the resource name (the
+// actual cobra command path), not "resource endpoint" (the pre-promotion
+// path). The SKILL.md verifier in printing-press-library walks command
+// references and rejects pre-promotion paths because they don't exist in
+// the shipped internal/cli/*.go.
+//
+// Positional values use the same lookup chain as verify mock-mode in
+// runtime_commands.go: spec.Param.Default → canonicalargs.Lookup →
+// "mock-value". Spec authors who set realistic defaults on positional
+// params get them surfaced in the SKILL example automatically; specs
+// without defaults fall through to the cross-domain registry, then to
+// the mock-value catch-all. This keeps SKILL examples honest enough that
+// verify-skill exits 0 on first generation.
 func firstCommandExample(resources map[string]spec.Resource) string {
 	var resNames []string
 	for name := range resources {
@@ -26,18 +37,22 @@ func firstCommandExample(resources map[string]spec.Resource) string {
 	sort.Strings(resNames)
 	preferredVerbs := []string{"list", "get", "search", "query"}
 
-	pathFor := func(rName string, r spec.Resource, eName string) string {
-		if isPromotableSingleEndpoint(rName, r) {
-			return rName
+	pathFor := func(rName string, r spec.Resource, eName string, ep spec.Endpoint) string {
+		base := rName
+		if !isPromotableSingleEndpoint(rName, r) {
+			base = rName + " " + eName
+		}
+		if args := positionalArgsForExample(ep); args != "" {
+			return base + " " + args
 		}
-		return rName + " " + eName
+		return base
 	}
 
 	for _, rName := range resNames {
 		r := resources[rName]
 		for _, verb := range preferredVerbs {
-			if _, ok := r.Endpoints[verb]; ok {
-				return pathFor(rName, r, verb)
+			if ep, ok := r.Endpoints[verb]; ok {
+				return pathFor(rName, r, verb, ep)
 			}
 		}
 	}
@@ -45,12 +60,58 @@ func firstCommandExample(resources map[string]spec.Resource) string {
 		r := resources[rName]
 		eNames := sortedEndpointNames(r.Endpoints)
 		if len(eNames) > 0 {
-			return pathFor(rName, r, eNames[0])
+			return pathFor(rName, r, eNames[0], r.Endpoints[eNames[0]])
 		}
 	}
 	return ""
 }
 
+// positionalArgsForExample joins the endpoint's required positional
+// arguments into a single space-separated string suitable for splicing
+// into a docs example. Each value comes from skillExamplePositionalValue
+// (spec.Param.Default → canonicalargs → "mock-value"). Returns empty
+// when the endpoint declares no positional params, so the caller can
+// emit the bare command path.
+func positionalArgsForExample(ep spec.Endpoint) string {
+	var parts []string
+	for _, p := range ep.Params {
+		if !p.Positional {
+			continue
+		}
+		parts = append(parts, skillExamplePositionalValue(p))
+	}
+	return strings.Join(parts, " ")
+}
+
+// skillExamplePositionalValue resolves one positional param to the value
+// the SKILL/README example should display. Mirrors the verify mock-mode
+// lookup chain in internal/pipeline/runtime_commands.go so a spec's
+// Param.Default flows through to both verify dispatch and the docs the
+// generator emits.
+func skillExamplePositionalValue(p spec.Param) string {
+	if p.Default != nil {
+		if s := stringifyDefault(p.Default); s != "" {
+			return s
+		}
+	}
+	name := strings.ToLower(strings.TrimSpace(p.Name))
+	if v, ok := canonicalargs.Lookup(name); ok {
+		return v
+	}
+	return "mock-value"
+}
+
+func stringifyDefault(v any) string {
+	switch t := v.(type) {
+	case nil:
+		return ""
+	case string:
+		return t
+	default:
+		return fmt.Sprintf("%v", t)
+	}
+}
+
 // isPromotableSingleEndpoint mirrors buildPromotedCommands's promotion
 // criterion: a resource with exactly one endpoint whose derived command
 // name does not collide with a CLI builtin (version, help, doctor, ...)
diff --git a/internal/generator/first_command_example_test.go b/internal/generator/first_command_example_test.go
index 0b6a6d08..05527250 100644
--- a/internal/generator/first_command_example_test.go
+++ b/internal/generator/first_command_example_test.go
@@ -101,6 +101,131 @@ func TestFirstCommandExampleHonorsPromotion(t *testing.T) {
 			resources: map[string]spec.Resource{},
 			want:      "",
 		},
+		{
+			// recipes has only one endpoint (get) so the resource is
+			// promoted: the cobra path is just "recipes <slug>", not
+			// "recipes get <slug>". Spec author's Param.Default for
+			// the positional wins over canonicalargs.
+			name: "single-endpoint promoted resource with positional spec default",
+			resources: map[string]spec.Resource{
+				"recipes": {
+					Endpoints: map[string]spec.Endpoint{
+						"get": {
+							Method: "GET",
+							Path:   "/recipes/{slug}",
+							Params: []spec.Param{
+								{Name: "slug", Positional: true, Default: "my-best-brownies"},
+							},
+						},
+					},
+				},
+			},
+			want: "recipes my-best-brownies",
+		},
+		{
+			// Two endpoints — no promotion — and `since` is a
+			// canonicalargs entry, so positional value comes from there.
+			name: "multi-endpoint resource positional falls through to canonicalargs",
+			resources: map[string]spec.Resource{
+				"changelog": {
+					Endpoints: map[string]spec.Endpoint{
+						"list": {
+							Method: "GET",
+							Path:   "/changelog",
+							Params: []spec.Param{
+								{Name: "since", Positional: true},
+							},
+						},
+						"reset": {Method: "POST", Path: "/changelog/reset"},
+					},
+				},
+			},
+			want: "changelog list 2026-01-01",
+		},
+		{
+			// Two endpoints — no promotion. The positional has no spec
+			// default and no canonicalargs entry, so falls through to
+			// the mock-value catch-all. Mirrors the lookup chain in
+			// internal/pipeline/runtime_commands.go.
+			name: "multi-endpoint resource positional falls through to mock-value",
+			resources: map[string]spec.Resource{
+				"airports": {
+					Endpoints: map[string]spec.Endpoint{
+						"get": {
+							Method: "GET",
+							Path:   "/airports/{code}",
+							Params: []spec.Param{
+								{Name: "airport_code", Positional: true},
+							},
+						},
+						"create": {Method: "POST", Path: "/airports"},
+					},
+				},
+			},
+			want: "airports get mock-value",
+		},
+		{
+			// articles has two endpoints (browse-sub and list) so the
+			// resource is NOT promoted; example emits "articles browse-sub <pos1> <pos2>".
+			// list is selected over browse-sub because it's a preferred verb,
+			// so to test multi-positional we add a multi-positional list.
+			name: "multiple positionals are joined in declared order",
+			resources: map[string]spec.Resource{
+				"articles": {
+					Endpoints: map[string]spec.Endpoint{
+						"list": {
+							Method: "GET",
+							Path:   "/articles/{vertical}/{sub}",
+							Params: []spec.Param{
+								{Name: "vertical", Positional: true},
+								{Name: "sub", Positional: true, Default: "weeknight"},
+							},
+						},
+						"create": {Method: "POST", Path: "/articles"},
+					},
+				},
+			},
+			want: "articles list mock-vertical weeknight",
+		},
+		{
+			// items has two endpoints — no promotion. No positional
+			// params on the chosen endpoint, so the helper emits the
+			// bare path; non-positional flag params don't appear.
+			name: "non-positional params do not pollute the example",
+			resources: map[string]spec.Resource{
+				"items": {
+					Endpoints: map[string]spec.Endpoint{
+						"list": {
+							Method: "GET",
+							Path:   "/items",
+							Params: []spec.Param{
+								{Name: "limit", Positional: false, Default: 25},
+								{Name: "cursor", Positional: false},
+							},
+						},
+						"create": {Method: "POST", Path: "/items"},
+					},
+				},
+			},
+			want: "items list",
+		},
+		{
+			name: "promoted single-endpoint resource keeps positionals",
+			resources: map[string]spec.Resource{
+				"feed": {
+					Endpoints: map[string]spec.Endpoint{
+						"get-on-this-day": {
+							Method: "GET",
+							Path:   "/feed/{date}",
+							Params: []spec.Param{
+								{Name: "date", Positional: true, Default: "2026-04-27"},
+							},
+						},
+					},
+				},
+			},
+			want: "feed 2026-04-27",
+		},
 	}
 	for _, tc := range tests {
 		t.Run(tc.name, func(t *testing.T) {
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index bd762ebe..a9496516 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -951,28 +951,29 @@ func (g *Generator) prepareOutput() error {
 
 func (g *Generator) renderSingleFiles() error {
 	singleFiles := map[string]string{
-		"main.go.tmpl":           filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
-		"helpers.go.tmpl":        filepath.Join("internal", "cli", "helpers.go"),
-		"doctor.go.tmpl":         filepath.Join("internal", "cli", "doctor.go"),
-		"agent_context.go.tmpl":  filepath.Join("internal", "cli", "agent_context.go"),
-		"profile.go.tmpl":        filepath.Join("internal", "cli", "profile.go"),
-		"deliver.go.tmpl":        filepath.Join("internal", "cli", "deliver.go"),
-		"feedback.go.tmpl":       filepath.Join("internal", "cli", "feedback.go"),
-		"which.go.tmpl":          filepath.Join("internal", "cli", "which.go"),
-		"which_test.go.tmpl":     filepath.Join("internal", "cli", "which_test.go"),
-		"config.go.tmpl":         filepath.Join("internal", "config", "config.go"),
-		"cache.go.tmpl":          filepath.Join("internal", "cache", "cache.go"),
-		"client.go.tmpl":         filepath.Join("internal", "client", "client.go"),
-		"cliutil_fanout.go.tmpl": filepath.Join("internal", "cliutil", "fanout.go"),
-		"cliutil_text.go.tmpl":   filepath.Join("internal", "cliutil", "text.go"),
-		"cliutil_probe.go.tmpl":  filepath.Join("internal", "cliutil", "probe.go"),
-		"cliutil_test.go.tmpl":   filepath.Join("internal", "cliutil", "cliutil_test.go"),
-		"types.go.tmpl":          filepath.Join("internal", "types", "types.go"),
-		"golangci.yml.tmpl":      ".golangci.yml",
-		"readme.md.tmpl":         "README.md",
-		"skill.md.tmpl":          "SKILL.md",
-		"LICENSE.tmpl":           "LICENSE",
-		"NOTICE.tmpl":            "NOTICE",
+		"main.go.tmpl":              filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
+		"helpers.go.tmpl":           filepath.Join("internal", "cli", "helpers.go"),
+		"doctor.go.tmpl":            filepath.Join("internal", "cli", "doctor.go"),
+		"agent_context.go.tmpl":     filepath.Join("internal", "cli", "agent_context.go"),
+		"profile.go.tmpl":           filepath.Join("internal", "cli", "profile.go"),
+		"deliver.go.tmpl":           filepath.Join("internal", "cli", "deliver.go"),
+		"feedback.go.tmpl":          filepath.Join("internal", "cli", "feedback.go"),
+		"which.go.tmpl":             filepath.Join("internal", "cli", "which.go"),
+		"which_test.go.tmpl":        filepath.Join("internal", "cli", "which_test.go"),
+		"config.go.tmpl":            filepath.Join("internal", "config", "config.go"),
+		"cache.go.tmpl":             filepath.Join("internal", "cache", "cache.go"),
+		"client.go.tmpl":            filepath.Join("internal", "client", "client.go"),
+		"cliutil_fanout.go.tmpl":    filepath.Join("internal", "cliutil", "fanout.go"),
+		"cliutil_text.go.tmpl":      filepath.Join("internal", "cliutil", "text.go"),
+		"cliutil_probe.go.tmpl":     filepath.Join("internal", "cliutil", "probe.go"),
+		"cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
+		"cliutil_test.go.tmpl":      filepath.Join("internal", "cliutil", "cliutil_test.go"),
+		"types.go.tmpl":             filepath.Join("internal", "types", "types.go"),
+		"golangci.yml.tmpl":         ".golangci.yml",
+		"readme.md.tmpl":            "README.md",
+		"skill.md.tmpl":             "SKILL.md",
+		"LICENSE.tmpl":              "LICENSE",
+		"NOTICE.tmpl":               "NOTICE",
 	}
 
 	for tmplName, outPath := range singleFiles {
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 7de9eed3..bb59ae70 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -50,6 +50,7 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		"internal/cliutil/fanout.go",
 		"internal/cliutil/text.go",
 		"internal/cliutil/probe.go",
+		"internal/cliutil/verifyenv.go",
 		"internal/cliutil/cliutil_test.go",
 		"internal/client/client.go",
 		"internal/config/config.go",
@@ -64,9 +65,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		// Bump it AND add to mustInclude above when adding always-emitted
 		// templates. Per-spec dynamic files (per-resource command files,
 		// generated tests) account for the difference between fixtures.
-		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 45},
-		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 50},
-		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 47},
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 46},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 51},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 48},
 	}
 
 	for _, tt := range tests {
@@ -843,6 +844,289 @@ func TestGenerateHTMLExtractionEndpoint(t *testing.T) {
 		"first srcset URL should be selected when src is absent; got %v", envelope.Results[1]["image"])
 }
 
+// TestGenerateHTMLExtractionEmbeddedJSONMode exercises the embedded-json mode
+// against an SSR-React-style page where serialized state is embedded in a
+// known script tag. This matches the Food52 retro motivation: extracting
+// `__NEXT_DATA__` JSON and walking a dot-notation path into props.pageProps.
+func TestGenerateHTMLExtractionEmbeddedJSONMode(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		switch r.URL.Path {
+		case "/recipes":
+			// Canonical Next.js __NEXT_DATA__ shape: serialized page state
+			// embedded in a script tag with id="__NEXT_DATA__".
+			_, _ = w.Write([]byte(`<html><head><title>Recipes</title></head><body>
+				<div id="__next">rendered</div>
+				<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"recipes":[{"id":1,"name":"Pasta"},{"id":2,"name":"Soup"}]}},"page":"/recipes"}</script>
+			</body></html>`))
+		case "/articles":
+			// Custom selector + empty json_path returns the entire parsed JSON.
+			_, _ = w.Write([]byte(`<html><body>
+				<script id="ARTICLE_DATA" type="application/json">{"items":[{"slug":"a"},{"slug":"b"}]}</script>
+			</body></html>`))
+		case "/missing":
+			// No matching script tag — should produce an extractor error.
+			_, _ = w.Write([]byte(`<html><body><p>nothing here</p></body></html>`))
+		}
+	}))
+	t.Cleanup(server.Close)
+
+	apiSpec := &spec.APISpec{
+		Name:    "embeddedjson",
+		Version: "0.1.0",
+		BaseURL: server.URL,
+		Auth:    spec.AuthConfig{Type: "none"},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/embeddedjson-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"recipes": {
+				Description: "Browse recipes",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:         "GET",
+						Path:           "/recipes",
+						Description:    "List recipes",
+						ResponseFormat: spec.ResponseFormatHTML,
+						HTMLExtract: &spec.HTMLExtract{
+							Mode:     spec.HTMLExtractModeEmbeddedJSON,
+							JSONPath: "props.pageProps.recipes",
+						},
+						Response: spec.ResponseDef{Type: "array", Item: "object"},
+					},
+				},
+			},
+			"articles": {
+				Description: "Browse articles",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:         "GET",
+						Path:           "/articles",
+						Description:    "List articles via custom selector",
+						ResponseFormat: spec.ResponseFormatHTML,
+						HTMLExtract: &spec.HTMLExtract{
+							Mode:           spec.HTMLExtractModeEmbeddedJSON,
+							ScriptSelector: "script#ARTICLE_DATA",
+						},
+						Response: spec.ResponseDef{Type: "object"},
+					},
+				},
+			},
+			"missing": {
+				Description: "Missing script tag",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:         "GET",
+						Path:           "/missing",
+						Description:    "Page with no embedded JSON",
+						ResponseFormat: spec.ResponseFormatHTML,
+						HTMLExtract: &spec.HTMLExtract{
+							Mode:     spec.HTMLExtractModeEmbeddedJSON,
+							JSONPath: "anything",
+						},
+						Response: spec.ResponseDef{Type: "object"},
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "embeddedjson-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+	require.FileExists(t, filepath.Join(outputDir, "internal", "cli", "html_extract.go"))
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	binaryPath := filepath.Join(outputDir, "embeddedjson-pp-cli")
+	runGoCommand(t, outputDir, "build", "-o", binaryPath, "./cmd/embeddedjson-pp-cli")
+
+	// Default selector + dot-notation path: returns the recipes array.
+	cmd := exec.Command(binaryPath, "recipes", "list", "--json")
+	cmd.Env = append(os.Environ(), "EMBEDDEDJSON_BASE_URL="+server.URL)
+	out, err := cmd.CombinedOutput()
+	require.NoError(t, err, string(out))
+	var recipesEnv struct {
+		Results []map[string]any `json:"results"`
+	}
+	require.NoError(t, json.Unmarshal(out, &recipesEnv), string(out))
+	require.Len(t, recipesEnv.Results, 2)
+	assert.Equal(t, "Pasta", recipesEnv.Results[0]["name"])
+	assert.Equal(t, "Soup", recipesEnv.Results[1]["name"])
+
+	// Custom selector + empty json_path: returns the whole parsed JSON.
+	cmd = exec.Command(binaryPath, "articles", "list", "--json")
+	cmd.Env = append(os.Environ(), "EMBEDDEDJSON_BASE_URL="+server.URL)
+	out, err = cmd.CombinedOutput()
+	require.NoError(t, err, string(out))
+	var articleEnv struct {
+		Results map[string]any `json:"results"`
+	}
+	require.NoError(t, json.Unmarshal(out, &articleEnv), string(out))
+	items, ok := articleEnv.Results["items"].([]any)
+	require.True(t, ok, "expected items array, got %T", articleEnv.Results["items"])
+	require.Len(t, items, 2)
+
+	// Missing script tag: extractor reports an actionable error rather
+	// than silently returning empty data.
+	cmd = exec.Command(binaryPath, "missing", "list", "--json")
+	cmd.Env = append(os.Environ(), "EMBEDDEDJSON_BASE_URL="+server.URL)
+	out, err = cmd.CombinedOutput()
+	require.Error(t, err, string(out))
+	assert.Contains(t, string(out), "embedded-json")
+}
+
+// TestGenerateHTMLExtractionPerModeGating asserts that html_extract.go is
+// emitted with only the helpers that match the mode(s) the spec actually
+// uses. A spec that declares only mode: embedded-json must NOT emit the
+// page-mode DOM walkers (htmlExtractedPage, applyMeta, htmlLink, etc.);
+// a spec that declares only mode: page must NOT emit the embedded-json
+// walker (extractEmbeddedJSON, walkJSONDotPath); a mixed spec emits both.
+// This is the core U6 retro contract: per-mode gating eliminates the
+// dead-helper count in the printed CLI.
+func TestGenerateHTMLExtractionPerModeGating(t *testing.T) {
+	t.Parallel()
+
+	specWithMode := func(name, mode string) *spec.APISpec {
+		s := &spec.APISpec{
+			Name:    name,
+			Version: "0.1.0",
+			BaseURL: "https://example.com",
+			Auth:    spec.AuthConfig{Type: "none"},
+			Config: spec.ConfigSpec{
+				Format: "toml",
+				Path:   "~/.config/" + name + "-pp-cli/config.toml",
+			},
+			Resources: map[string]spec.Resource{
+				"posts": {
+					Description: "Browse posts",
+					Endpoints: map[string]spec.Endpoint{
+						"list": {
+							Method:         "GET",
+							Path:           "/",
+							Description:    "List posts",
+							ResponseFormat: spec.ResponseFormatHTML,
+							HTMLExtract: &spec.HTMLExtract{
+								Mode:     mode,
+								JSONPath: "props.pageProps.posts",
+							},
+							Response: spec.ResponseDef{Type: "array", Item: "object"},
+						},
+					},
+				},
+			},
+		}
+		return s
+	}
+
+	read := func(t *testing.T, dir string) string {
+		t.Helper()
+		data, err := os.ReadFile(filepath.Join(dir, "internal", "cli", "html_extract.go"))
+		require.NoError(t, err)
+		return string(data)
+	}
+
+	// Helpers are matched by their declaration line ("func name(" or
+	// "type name") so a substring mention inside a comment doesn't
+	// cause a false positive — the dispatcher's comment legitimately
+	// names the page-mode walker even when the page-mode branch is
+	// gated out.
+	hasFunc := func(body, name string) bool {
+		return strings.Contains(body, "func "+name+"(")
+	}
+	hasType := func(body, name string) bool {
+		return strings.Contains(body, "type "+name+" ")
+	}
+
+	t.Run("embedded-json-only omits page+links helpers", func(t *testing.T) {
+		dir := filepath.Join(t.TempDir(), "ejonly-pp-cli")
+		require.NoError(t, New(specWithMode("ejonly", spec.HTMLExtractModeEmbeddedJSON), dir).Generate())
+		body := read(t, dir)
+		// embedded-json branch present
+		assert.True(t, hasFunc(body, "extractEmbeddedJSON"))
+		assert.True(t, hasFunc(body, "walkJSONDotPath"))
+		// page+links branch absent
+		assert.False(t, hasFunc(body, "extractHTMLPageOrLinks"))
+		assert.False(t, hasType(body, "htmlExtractedPage"))
+		assert.False(t, hasFunc(body, "applyMeta"))
+		assert.False(t, hasFunc(body, "extractHTMLLink"))
+		assert.False(t, hasFunc(body, "looksLikeHTMLChallenge"))
+		assert.False(t, hasFunc(body, "nodeTextSuppressing"))
+		assert.False(t, hasFunc(body, "firstImageSrc"))
+		assert.False(t, hasFunc(body, "cleanHTMLText"))
+		// Imports that only the page+links branch needs are not emitted
+		assert.NotContains(t, body, `"regexp"`)
+		assert.NotContains(t, body, `"strconv"`)
+		assert.NotContains(t, body, `stdhtml "html"`)
+		// Must still build cleanly even with helpers gated out
+		runGoCommand(t, dir, "mod", "tidy")
+		runGoCommand(t, dir, "build", "./...")
+	})
+
+	t.Run("page-only omits embedded-json helpers", func(t *testing.T) {
+		dir := filepath.Join(t.TempDir(), "pageonly-pp-cli")
+		require.NoError(t, New(specWithMode("pageonly", spec.HTMLExtractModePage), dir).Generate())
+		body := read(t, dir)
+		// page branch present
+		assert.True(t, hasFunc(body, "extractHTMLPageOrLinks"))
+		assert.True(t, hasType(body, "htmlExtractedPage"))
+		assert.True(t, hasFunc(body, "applyMeta"))
+		// embedded-json branch absent
+		assert.False(t, hasFunc(body, "extractEmbeddedJSON"))
+		assert.False(t, hasFunc(body, "walkJSONDotPath"))
+		assert.False(t, hasFunc(body, "parseSimpleSelector"))
+		assert.False(t, hasFunc(body, "findScriptByTagAndID"))
+		// Must still build cleanly
+		runGoCommand(t, dir, "mod", "tidy")
+		runGoCommand(t, dir, "build", "./...")
+	})
+
+	t.Run("mixed modes emit both branches", func(t *testing.T) {
+		// One endpoint per mode in the same spec.
+		mixedSpec := &spec.APISpec{
+			Name:    "mixed",
+			Version: "0.1.0",
+			BaseURL: "https://example.com",
+			Auth:    spec.AuthConfig{Type: "none"},
+			Config:  spec.ConfigSpec{Format: "toml", Path: "~/.config/mixed-pp-cli/config.toml"},
+			Resources: map[string]spec.Resource{
+				"posts": {
+					Description: "Posts",
+					Endpoints: map[string]spec.Endpoint{
+						"list": {
+							Method: "GET", Path: "/", Description: "List",
+							ResponseFormat: spec.ResponseFormatHTML,
+							HTMLExtract:    &spec.HTMLExtract{Mode: spec.HTMLExtractModePage},
+							Response:       spec.ResponseDef{Type: "object"},
+						},
+					},
+				},
+				"data": {
+					Description: "Embedded",
+					Endpoints: map[string]spec.Endpoint{
+						"list": {
+							Method: "GET", Path: "/data", Description: "List from embedded JSON",
+							ResponseFormat: spec.ResponseFormatHTML,
+							HTMLExtract: &spec.HTMLExtract{
+								Mode:     spec.HTMLExtractModeEmbeddedJSON,
+								JSONPath: "props.pageProps.data",
+							},
+							Response: spec.ResponseDef{Type: "object"},
+						},
+					},
+				},
+			},
+		}
+		dir := filepath.Join(t.TempDir(), "mixed-pp-cli")
+		require.NoError(t, New(mixedSpec, dir).Generate())
+		body := read(t, dir)
+		assert.Contains(t, body, "extractHTMLPageOrLinks")
+		assert.Contains(t, body, "extractEmbeddedJSON")
+		runGoCommand(t, dir, "mod", "tidy")
+		runGoCommand(t, dir, "build", "./...")
+	})
+}
+
 func TestGenerateStandardTransportForOfficialAPI(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/cliutil_verifyenv.go.tmpl b/internal/generator/templates/cliutil_verifyenv.go.tmpl
new file mode 100644
index 00000000..2be49a9a
--- /dev/null
+++ b/internal/generator/templates/cliutil_verifyenv.go.tmpl
@@ -0,0 +1,30 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import "os"
+
+// VerifyEnvVar is the env var the printing-press verifier sets in every
+// mock-mode subprocess. Generated commands that perform visible side
+// effects (open browser tabs, send notifications, dial out to OS
+// handlers) MUST short-circuit when this env var is "1" to avoid
+// spamming the user's environment during verify runs.
+const VerifyEnvVar = "PRINTING_PRESS_VERIFY"
+
+// IsVerifyEnv reports whether the current process is running under the
+// printing-press verifier in mock mode. Generated commands with side
+// effects pair this check with print-by-default + explicit opt-in
+// (--launch, --send, --play) so a verify pass on a fresh CLI does not
+// pop browser tabs or fire off real notifications.
+//
+// Defense-in-depth: even if the verifier's heuristic side-effect
+// classifier misses a command, this env-var short-circuit catches it.
+//
+//	if cliutil.IsVerifyEnv() {
+//	    fmt.Fprintln(cmd.OutOrStdout(), "would launch:", url)
+//	    return nil
+//	}
+func IsVerifyEnv() bool {
+	return os.Getenv(VerifyEnvVar) == "1"
+}
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 6167e3e0..723b6c97 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -359,6 +359,8 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- end}}
 					},
 					Limit: {{with .Endpoint.HTMLExtract}}{{.Limit}}{{else}}0{{end}},
+					ScriptSelector: {{with .Endpoint.HTMLExtract}}{{printf "%q" .EffectiveScriptSelector}}{{else}}""{{end}},
+					JSONPath: {{with .Endpoint.HTMLExtract}}{{printf "%q" .JSONPath}}{{else}}""{{end}},
 				})
 				if err != nil {
 					return err
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index dedbc10e..6ef388cb 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -159,6 +159,8 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- end}}
 					},
 					Limit: {{with .Endpoint.HTMLExtract}}{{.Limit}}{{else}}0{{end}},
+					ScriptSelector: {{with .Endpoint.HTMLExtract}}{{printf "%q" .EffectiveScriptSelector}}{{else}}""{{end}},
+					JSONPath: {{with .Endpoint.HTMLExtract}}{{printf "%q" .JSONPath}}{{else}}""{{end}},
 				})
 				if err != nil {
 					return err
diff --git a/internal/generator/templates/html_extract.go.tmpl b/internal/generator/templates/html_extract.go.tmpl
index f22d191c..cf6d6cb6 100644
--- a/internal/generator/templates/html_extract.go.tmpl
+++ b/internal/generator/templates/html_extract.go.tmpl
@@ -6,22 +6,30 @@ package cli
 import (
 	"encoding/json"
 	"fmt"
+{{- if or (.HasHTMLExtractMode "page") (.HasHTMLExtractMode "links") }}
 	stdhtml "html"
+{{- end }}
 	"net/url"
+{{- if or (.HasHTMLExtractMode "page") (.HasHTMLExtractMode "links") }}
 	"regexp"
 	"strconv"
+{{- end }}
 	"strings"
 
 	xhtml "golang.org/x/net/html"
 )
 
 type htmlExtractionOptions struct {
-	Mode         string
-	BaseURL      string
-	LinkPrefixes []string
-	Limit        int
+	Mode           string
+	BaseURL        string
+	LinkPrefixes   []string
+	Limit          int
+	ScriptSelector string // for mode: embedded-json — selector "tag" or "tag#id"
+	JSONPath       string // for mode: embedded-json — dot-notation walk
 }
 
+{{- if or (.HasHTMLExtractMode "page") (.HasHTMLExtractMode "links") }}
+
 type htmlExtractedPage struct {
 	Title        string     `json:"title,omitempty"`
 	Description  string     `json:"description,omitempty"`
@@ -51,8 +59,36 @@ var htmlRawTextTags = map[string]struct{}{
 	"style":    {},
 	"template": {},
 }
+{{- end }}
 
 func extractHTMLResponse(raw []byte, opts htmlExtractionOptions) (json.RawMessage, error) {
+	// Dispatch on Mode at the top so each branch owns its own parsing
+	// path. embedded-json doesn't need DOM walking; running the page
+	// parse + walkHTML + looksLikeHTMLChallenge unconditionally would
+	// (a) waste work on every embedded-json call and (b) risk false
+	// "challenge page" rejection on Next.js / Nuxt pages with generic
+	// titles. Page and links share the page-mode parse.
+	mode := strings.ToLower(strings.TrimSpace(opts.Mode))
+	switch mode {
+{{- if .HasHTMLExtractMode "embedded-json" }}
+	case "embedded-json":
+		return extractEmbeddedJSON(raw, opts)
+{{- end }}
+{{- if or (.HasHTMLExtractMode "page") (.HasHTMLExtractMode "links") }}
+	case "links", "page", "":
+		return extractHTMLPageOrLinks(raw, opts)
+{{- end }}
+	default:
+		return nil, fmt.Errorf("unsupported html_extract mode: %q", opts.Mode)
+	}
+}
+
+{{- if or (.HasHTMLExtractMode "page") (.HasHTMLExtractMode "links") }}
+
+// extractHTMLPageOrLinks handles the page (default) and links modes.
+// Both require parsing the HTML and walking the DOM; the only difference
+// is what they return at the end.
+func extractHTMLPageOrLinks(raw []byte, opts htmlExtractionOptions) (json.RawMessage, error) {
 	doc, err := xhtml.Parse(strings.NewReader(string(raw)))
 	if err != nil {
 		return nil, fmt.Errorf("parsing HTML response: %w", err)
@@ -103,6 +139,104 @@ func extractHTMLResponse(raw []byte, opts htmlExtractionOptions) (json.RawMessag
 	}
 }
 
+{{- end }}
+{{- if .HasHTMLExtractMode "embedded-json" }}
+
+// extractEmbeddedJSON parses HTML, finds the <script> tag matching
+// opts.ScriptSelector (default "script#__NEXT_DATA__"), parses its text
+// content as JSON, walks opts.JSONPath as dot-notation, and re-marshals
+// the result as the response body. Used by Next.js (pages-router),
+// Nuxt, and other SSR-React frameworks that embed serialized page state
+// in a known script tag.
+func extractEmbeddedJSON(raw []byte, opts htmlExtractionOptions) (json.RawMessage, error) {
+	doc, err := xhtml.Parse(strings.NewReader(string(raw)))
+	if err != nil {
+		return nil, fmt.Errorf("parsing HTML for embedded-json: %w", err)
+	}
+	selector := strings.TrimSpace(opts.ScriptSelector)
+	if selector == "" {
+		selector = "script#__NEXT_DATA__"
+	}
+	tag, idFilter := parseSimpleSelector(selector)
+	scriptText, found := findScriptByTagAndID(doc, tag, idFilter)
+	if !found {
+		return nil, fmt.Errorf("embedded-json: no element matching selector %q found in page", selector)
+	}
+	if scriptText == "" {
+		return nil, fmt.Errorf("embedded-json: script element matched %q but has empty content", selector)
+	}
+	var parsed interface{}
+	if err := json.Unmarshal([]byte(scriptText), &parsed); err != nil {
+		return nil, fmt.Errorf("embedded-json: parsing JSON content of %q: %w", selector, err)
+	}
+	target := walkJSONDotPath(parsed, strings.TrimSpace(opts.JSONPath))
+	out, err := json.Marshal(target)
+	if err != nil {
+		return nil, fmt.Errorf("embedded-json: marshaling target at %q: %w", opts.JSONPath, err)
+	}
+	return json.RawMessage(out), nil
+}
+
+// parseSimpleSelector splits a "tag" or "tag#id" selector into its
+// components. Returns ("script", "__NEXT_DATA__") for the canonical
+// Next.js case; ("script", "") for a tag-only selector. Anything more
+// complex (CSS classes, attribute selectors) is out of scope for v1.
+func parseSimpleSelector(selector string) (tag, id string) {
+	if i := strings.Index(selector, "#"); i >= 0 {
+		return selector[:i], selector[i+1:]
+	}
+	return selector, ""
+}
+
+// findScriptByTagAndID walks the parsed HTML document looking for the
+// first element whose tag matches `tag` and (when `id` is non-empty)
+// whose id attribute matches `id`. Returns the element's concatenated
+// text content. Tags are matched case-insensitively; ids are
+// case-sensitive (per HTML5 spec).
+func findScriptByTagAndID(root *xhtml.Node, tag, id string) (string, bool) {
+	var found *xhtml.Node
+	walkHTML(root, func(n *xhtml.Node) {
+		if found != nil || n.Type != xhtml.ElementNode {
+			return
+		}
+		if !strings.EqualFold(n.Data, tag) {
+			return
+		}
+		if id != "" && attrValue(n, "id") != id {
+			return
+		}
+		found = n
+	})
+	if found == nil {
+		return "", false
+	}
+	return nodeText(found), true
+}
+
+// walkJSONDotPath descends a parsed JSON value via dot-notation
+// (e.g., "props.pageProps.recipesByTag.results"). Empty path returns
+// the entire input. Missing intermediate keys yield nil rather than
+// panicking — the marshaling step then emits JSON null. This matches
+// the behavior the food52 retro tested against and avoids surfacing
+// every typo as an error rather than empty data.
+func walkJSONDotPath(v interface{}, path string) interface{} {
+	if path == "" {
+		return v
+	}
+	parts := strings.Split(path, ".")
+	cur := v
+	for _, p := range parts {
+		m, ok := cur.(map[string]interface{})
+		if !ok {
+			return nil
+		}
+		cur = m[p]
+	}
+	return cur
+}
+
+{{- end }}
+
 func walkHTML(n *xhtml.Node, visit func(*xhtml.Node)) {
 	if n == nil {
 		return
@@ -113,6 +247,8 @@ func walkHTML(n *xhtml.Node, visit func(*xhtml.Node)) {
 	}
 }
 
+{{- if or (.HasHTMLExtractMode "page") (.HasHTMLExtractMode "links") }}
+
 func applyMeta(page *htmlExtractedPage, n *xhtml.Node) {
 	name := strings.ToLower(attrValue(n, "name"))
 	property := strings.ToLower(attrValue(n, "property"))
@@ -184,6 +320,8 @@ func normalizeHTMLURL(raw string, base string) string {
 	return baseURL.ResolveReference(ref).String()
 }
 
+{{- end }}
+
 func htmlExtractionRequestURL(base string, path string, params map[string]string) string {
 	baseURL, err := url.Parse(base)
 	if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
@@ -206,6 +344,8 @@ func htmlExtractionRequestURL(base string, path string, params map[string]string
 	return full.String()
 }
 
+{{- if or (.HasHTMLExtractMode "page") (.HasHTMLExtractMode "links") }}
+
 func htmlLinkMatchesPrefixes(path string, prefixes []string) bool {
 	if len(prefixes) == 0 {
 		return true
@@ -256,6 +396,8 @@ func htmlLinkSlug(path string, prefixes []string) string {
 	return parts[len(parts)-1]
 }
 
+{{- end }}
+
 func attrValue(n *xhtml.Node, name string) string {
 	for _, attr := range n.Attr {
 		if strings.EqualFold(attr.Key, name) {
@@ -275,6 +417,8 @@ func nodeText(n *xhtml.Node) string {
 	return strings.Join(parts, " ")
 }
 
+{{- if or (.HasHTMLExtractMode "page") (.HasHTMLExtractMode "links") }}
+
 // nodeTextSuppressing walks n's descendants and concatenates TextNode content,
 // skipping subtrees rooted at noscript/script/style/template elements. The
 // HTML5 spec parses content of those four elements as raw text, so a normal
@@ -442,3 +586,5 @@ func looksLikeHTMLChallenge(title string, raw string) bool {
 	}
 	return false
 }
+
+{{- end }}
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 3c410f26..34e91017 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -174,6 +174,14 @@ type openAPISpec struct {
 	Auth          apispec.AuthConfig
 	Kind          string // see apispec.KindREST / apispec.KindSynthetic
 	HTTPTransport string
+	// ParamDefaults maps a positional placeholder name (lowercase) to its
+	// spec-declared default value, when one is set. Verify mock-mode uses
+	// this as the first step in its lookup chain so spec authors can name
+	// realistic placeholder values without modifying the generator (e.g.,
+	// food52's `servings: 4` rather than the generic `mock-value`). Built
+	// only for internal-format specs; OpenAPI specs leave this nil and
+	// fall through to the generic `canonicalargs` registry.
+	ParamDefaults map[string]string
 }
 
 func (s *openAPISpec) IsSynthetic() bool {
@@ -1606,7 +1614,32 @@ func runDogfoodCmd(binary string, timeout time.Duration, args ...string) (string
 	return "", err
 }
 
+// extractExamplesSection scans Cobra --help output for the Examples block.
+//
+// The original implementation broke on the first unindented line, which
+// failed silently when authors used `Example: strings.TrimSpace(\`...\`)`
+// — TrimSpace strips the leading 2-space indent, so the first example
+// line is unindented and the parser captured nothing.
+//
+// The fix: break only on a closed set of canonical Cobra section headers,
+// not on any unindented line. Cobra emits these headers verbatim (no
+// indentation) when they delimit help output sections; nothing else
+// reliably has the same shape. Anything outside this set is treated as
+// continuation of the Examples block — losing-by-default is safer than
+// misclassifying real example content as a section boundary.
 func extractExamplesSection(helpOutput string) string {
+	// Canonical Cobra section header set. Match on the trimmed line being
+	// exactly equal to one of these (case-sensitive — Cobra's emission is).
+	cobraSectionHeaders := map[string]struct{}{
+		"Usage:":                  {},
+		"Aliases:":                {},
+		"Available Commands:":     {},
+		"Examples:":               {},
+		"Flags:":                  {},
+		"Global Flags:":           {},
+		"Additional help topics:": {},
+	}
+
 	lines := strings.Split(helpOutput, "\n")
 	var inExamples bool
 	var examples []string
@@ -1616,13 +1649,24 @@ func extractExamplesSection(helpOutput string) string {
 			inExamples = true
 			continue
 		}
-		if inExamples {
-			// Section headers in Cobra help are non-indented and non-empty
-			if len(line) > 0 && line[0] != ' ' && line[0] != '\t' {
-				break
-			}
-			examples = append(examples, line)
+		if !inExamples {
+			continue
+		}
+		// Section boundary: a known Cobra section header.
+		if _, ok := cobraSectionHeaders[trimmed]; ok {
+			break
+		}
+		// Cobra emits a "Use \"<root> <subcommand> [command] --help\" for
+		// more information about a command." trailing line at the bottom
+		// of help output. Match the literal `Use "` prefix to avoid
+		// accidentally swallowing example lines that happen to start
+		// with the word "use".
+		if strings.HasPrefix(trimmed, `Use "`) {
+			break
 		}
+		// Otherwise treat as example continuation — preserves indented and
+		// unindented content alike, and tolerates blank lines mid-block.
+		examples = append(examples, line)
 	}
 	return strings.TrimSpace(strings.Join(examples, "\n"))
 }
diff --git a/internal/pipeline/dogfood_test.go b/internal/pipeline/dogfood_test.go
index e5a61539..0ef7ec97 100644
--- a/internal/pipeline/dogfood_test.go
+++ b/internal/pipeline/dogfood_test.go
@@ -279,6 +279,31 @@ func TestExtractExamplesSection(t *testing.T) {
 			help: "",
 			want: "",
 		},
+		{
+			// Regression: examples whose first line is unindented (the result
+			// of the strings.TrimSpace(`...`) idiom, which strips the leading
+			// 2-space indent). Prior parser broke at the first unindented
+			// line and captured nothing. New parser only breaks on canonical
+			// Cobra section headers.
+			name: "first example unindented (TrimSpace idiom)",
+			help: "Examples:\nfood52-pp-cli articles browse food\n  food52-pp-cli articles browse life --limit 10 --json\n\nFlags:\n  --limit int\n",
+			want: "food52-pp-cli articles browse food\n  food52-pp-cli articles browse life --limit 10 --json",
+		},
+		{
+			// Trailing "Use \"...\" for more information..." line is a
+			// section boundary too — match on the literal `Use "` prefix.
+			name: "use-for-more trailing line",
+			help: "Examples:\n  cli do --a 1\n\nUse \"cli [command] --help\" for more information about a command.\n",
+			want: "cli do --a 1",
+		},
+		{
+			// Lines that start with a word like "use" but are NOT the
+			// Cobra trailing line (lowercase, no quote) are example
+			// continuation, not section boundaries.
+			name: "example line starting with word use",
+			help: "Examples:\n  cli widget create  # use the default options\n  cli widget update\n\nFlags:\n  --opts string\n",
+			want: "cli widget create  # use the default options\n  cli widget update",
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index dfb79a00..f065fad0 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -7,6 +7,7 @@ import (
 	"io/fs"
 	"os"
 	"path/filepath"
+	"sort"
 	"strings"
 	"time"
 
@@ -266,40 +267,180 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		m.Description = entry.Description
 	}
 
-	// Load novel features from research.json if available. Looks in both
-	// RunRoot/research.json (printing-press skill convention) and
-	// RunRoot/pipeline/research.json (printing-press print convention) — both
-	// must be attempted because the skill flow and print flow write research
-	// to different locations. When research.json has populated NovelFeaturesBuilt,
-	// it overrides the carry-forward value from the existing manifest because
-	// post-dogfood data is the source of truth. When neither location holds
-	// research.json, emit a debug breadcrumb so the silent dropout from earlier
-	// versions is no longer silent — the breadcrumb names both attempted paths;
-	// the caller (lock promote, publish) shows it but does not treat it as an error.
-	research, researchErr := loadResearchForState(state)
-	switch {
-	case researchErr != nil:
+	// Load novel features from research.json if available, populating the
+	// manifest's NovelFeatures field so publish-validate's transcendence
+	// check passes without manual patching.
+	//
+	// Lookup order:
+	//   1. loadResearchForState — checks <RunRoot>/research.json (skill-flow
+	//      convention) then <state.PipelineDir>/research.json (generate-flow
+	//      convention). Covers both write conventions (cal-com retro #334 F2,
+	//      and food52 retro #337 F4 once #340 recovers RunID in NewMinimalState).
+	//   2. Minimal-state fallback: when loadResearchForState fails AND
+	//      state.RunID is empty (NewMinimalState had no registered runstate
+	//      to recover RunID from), glob the scoped runstate root for any
+	//      run whose research.json names this APIName and pick the most
+	//      recent by mtime. Backstop for orphaned plan-driven promotes.
+	//
+	// Within the loaded ResearchResult, prefer NovelFeaturesBuilt
+	// (dogfood-verified subset) over NovelFeatures (planned list) — if
+	// dogfood ran, only the actually-shipped features should be advertised.
+	// Falling back to the planned list when dogfood didn't run (or wrote
+	// an empty NovelFeaturesBuilt) keeps first-publish from failing the
+	// transcendence check on a CLI that genuinely shipped novel features.
+	research, source := loadResearchForPromote(state)
+	if research != nil {
+		nfs := pickNovelFeaturesForManifest(research)
+		// Override the existing-manifest carry-forward (line 221) with
+		// research.json's view: post-dogfood data is the source of truth.
+		// If pickNovelFeaturesForManifest returned an empty slice (no
+		// Built and no planned), leave the carry-forward in place.
+		if len(nfs) > 0 {
+			m.NovelFeatures = m.NovelFeatures[:0]
+			for _, nf := range nfs {
+				m.NovelFeatures = append(m.NovelFeatures, NovelFeatureManifest{
+					Name:        nf.Name,
+					Command:     nf.Command,
+					Description: nf.Description,
+				})
+			}
+		}
+		if len(m.NovelFeatures) > 0 && source != "" && source != state.PipelineDir() && source != RunRoot(state.RunID) {
+			// Visibility for non-canonical sources — a one-line stderr
+			// note keeps promote silent on the happy path but tells the
+			// user when novel_features came from the glob fallback.
+			fmt.Fprintf(os.Stderr, "publish: hydrated %d novel_features from %s\n", len(m.NovelFeatures), source)
+		}
+	} else {
+		// No research.json found by any lookup path. Emit a debug
+		// breadcrumb naming the canonical paths so the silent dropout
+		// from earlier versions stays observable. Promote still
+		// succeeds with whatever was carried forward from the existing
+		// manifest; publish validate will surface the
+		// transcendence-check failure separately if relevant.
 		fmt.Fprintf(os.Stderr,
 			"debug: research.json not found at %s or %s; skipping novel_features enrichment "+
 				"(state.RunID=%q)\n",
 			filepath.Join(RunRoot(state.RunID), "research.json"),
 			filepath.Join(state.PipelineDir(), "research.json"),
 			state.RunID)
-	case research.NovelFeaturesBuilt != nil && len(*research.NovelFeaturesBuilt) > 0:
-		built := make([]NovelFeatureManifest, 0, len(*research.NovelFeaturesBuilt))
-		for _, nf := range *research.NovelFeaturesBuilt {
-			built = append(built, NovelFeatureManifest{
-				Name:        nf.Name,
-				Command:     nf.Command,
-				Description: nf.Description,
-			})
-		}
-		m.NovelFeatures = built
 	}
 
 	return WriteCLIManifest(dir, m)
 }
 
+// loadResearchForPromote returns the research.json relevant to the
+// current promote/publish call, plus the path it was loaded from (used
+// for non-canonical-source stderr visibility).
+//
+// Lookup order:
+//   - Delegate to loadResearchForState first (RunRoot then PipelineDir).
+//     This is the dominant path; with #340's NewMinimalState recovery,
+//     even plan-driven promotes hit this branch.
+//   - When loadResearchForState fails AND RunID is empty (the registry
+//     recovery had no prior runstate to borrow from), glob the scoped
+//     runstate root for any research.json whose APIName matches and
+//     pick the most recent by mtime. Backstop for orphaned promotes.
+//
+// Returns (nil, "") when neither lookup finds a usable research.json.
+func loadResearchForPromote(state *PipelineState) (*ResearchResult, string) {
+	if r, err := loadResearchForState(state); err == nil {
+		// loadResearchForState is the canonical loader; report the
+		// path it tried first (run-root) when RunID is set, since the
+		// caller's "is this a non-canonical source?" check compares
+		// against state.PipelineDir() and RunRoot(state.RunID).
+		if state.RunID != "" {
+			if _, statErr := os.Stat(filepath.Join(RunRoot(state.RunID), "research.json")); statErr == nil {
+				return r, RunRoot(state.RunID)
+			}
+			return r, state.PipelineDir()
+		}
+		return r, ""
+	}
+
+	if state.RunID != "" {
+		// loadResearchForState already covered both canonical paths
+		// for a populated state — no further fallback to try.
+		return nil, ""
+	}
+
+	// Minimal-state fallback: empty RunID and the canonical loader
+	// found nothing. Glob the scoped runstate root for research.json
+	// files matching this APIName and pick the most recent.
+	candidates, err := globResearchCandidates(ScopedRunstateRoot())
+	if err != nil || len(candidates) == 0 {
+		return nil, ""
+	}
+	sort.SliceStable(candidates, func(i, j int) bool {
+		return candidates[i].mtime.After(candidates[j].mtime)
+	})
+	for _, c := range candidates {
+		r, err := LoadResearch(filepath.Dir(c.path))
+		if err != nil {
+			continue
+		}
+		if state.APIName != "" && r.APIName != "" && r.APIName != state.APIName {
+			continue
+		}
+		return r, c.path
+	}
+	return nil, ""
+}
+
+// researchCandidate is a path + mtime for sorting glob results.
+type researchCandidate struct {
+	path  string
+	mtime time.Time
+}
+
+// globResearchCandidates walks the scoped runstate root for research.json
+// files. Looks under both `<root>/runs/*/research.json` (run-root form,
+// what the skill flow writes) and `<root>/runs/*/pipeline/research.json`
+// (canonical generate-pipeline form). Errors during walk are non-fatal
+// (returns whatever it found so far).
+func globResearchCandidates(scopedRoot string) ([]researchCandidate, error) {
+	runsDir := filepath.Join(scopedRoot, "runs")
+	entries, err := os.ReadDir(runsDir)
+	if err != nil {
+		// Missing runs directory is normal for a brand-new workspace —
+		// not an error worth surfacing.
+		return nil, nil
+	}
+	var out []researchCandidate
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
+		}
+		runDir := filepath.Join(runsDir, entry.Name())
+		// Try run-root research.json (skill-flow shape).
+		for _, candidate := range []string{
+			filepath.Join(runDir, "research.json"),
+			filepath.Join(runDir, "pipeline", "research.json"),
+		} {
+			info, err := os.Stat(candidate)
+			if err != nil {
+				continue
+			}
+			out = append(out, researchCandidate{path: candidate, mtime: info.ModTime()})
+		}
+	}
+	return out, nil
+}
+
+// pickNovelFeaturesForManifest selects the right novel-features list to
+// promote into the manifest. Prefers NovelFeaturesBuilt (dogfood-verified)
+// when non-nil and non-empty; falls back to NovelFeatures (planned) so
+// CLIs whose dogfood didn't run still get a populated manifest.
+func pickNovelFeaturesForManifest(research *ResearchResult) []NovelFeature {
+	if research == nil {
+		return nil
+	}
+	if research.NovelFeaturesBuilt != nil && len(*research.NovelFeaturesBuilt) > 0 {
+		return *research.NovelFeaturesBuilt
+	}
+	return research.NovelFeatures
+}
+
 // smitheryConfig is the marketplace metadata schema for Smithery.
 type smitheryConfig struct {
 	Name         string                    `yaml:"name"`
diff --git a/internal/pipeline/publish_novelfeatures_test.go b/internal/pipeline/publish_novelfeatures_test.go
new file mode 100644
index 00000000..d0f0c3a4
--- /dev/null
+++ b/internal/pipeline/publish_novelfeatures_test.go
@@ -0,0 +1,192 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestPickNovelFeaturesForManifest_PrefersBuilt confirms that when both
+// NovelFeaturesBuilt (dogfood-verified) and NovelFeatures (planned) are
+// populated, the built list wins.
+func TestPickNovelFeaturesForManifest_PrefersBuilt(t *testing.T) {
+	t.Parallel()
+	built := []NovelFeature{
+		{Name: "Built feature", Command: "do-thing", Description: "shipped"},
+	}
+	r := &ResearchResult{
+		NovelFeatures:      []NovelFeature{{Name: "Planned only", Command: "x"}, {Name: "Other", Command: "y"}},
+		NovelFeaturesBuilt: &built,
+	}
+	got := pickNovelFeaturesForManifest(r)
+	require.Len(t, got, 1)
+	assert.Equal(t, "Built feature", got[0].Name)
+}
+
+// TestPickNovelFeaturesForManifest_FallsBackToPlanned confirms the
+// fallback when dogfood didn't run (NovelFeaturesBuilt is nil) — the
+// plan was rebuilt via /printing-press but `dogfood` never ran or never
+// wrote the built list.
+func TestPickNovelFeaturesForManifest_FallsBackToPlanned(t *testing.T) {
+	t.Parallel()
+	r := &ResearchResult{
+		NovelFeatures: []NovelFeature{
+			{Name: "Planned A", Command: "a"},
+			{Name: "Planned B", Command: "b"},
+		},
+	}
+	got := pickNovelFeaturesForManifest(r)
+	require.Len(t, got, 2)
+	assert.Equal(t, "Planned A", got[0].Name)
+}
+
+// TestPickNovelFeaturesForManifest_FallsBackOnEmptyBuilt confirms that
+// an explicitly-empty NovelFeaturesBuilt (dogfood ran but nothing
+// shipped) doesn't suppress the planned list. This is debatable as
+// product policy — an empty Built could mean "ran and verified zero",
+// but in practice agents writing food52-style runs often emit
+// NovelFeaturesBuilt: [] before populating it. Falling back avoids
+// silent novel_features loss.
+func TestPickNovelFeaturesForManifest_FallsBackOnEmptyBuilt(t *testing.T) {
+	t.Parallel()
+	emptyBuilt := []NovelFeature{}
+	r := &ResearchResult{
+		NovelFeatures:      []NovelFeature{{Name: "Planned", Command: "p"}},
+		NovelFeaturesBuilt: &emptyBuilt,
+	}
+	got := pickNovelFeaturesForManifest(r)
+	require.Len(t, got, 1)
+	assert.Equal(t, "Planned", got[0].Name)
+}
+
+// TestLoadResearchForPromote_PipelineDir verifies the canonical path
+// (pipeline/research.json) is consulted first when state.RunID is set.
+func TestLoadResearchForPromote_PipelineDir(t *testing.T) {
+	t.Setenv("PRINTING_PRESS_HOME", t.TempDir())
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+
+	state := &PipelineState{APIName: "myapi", RunID: "run-001"}
+
+	pipelineDir := state.PipelineDir()
+	require.NoError(t, os.MkdirAll(pipelineDir, 0o755))
+	r := ResearchResult{
+		APIName:       "myapi",
+		NovelFeatures: []NovelFeature{{Name: "From pipeline", Command: "cmd"}},
+	}
+	data, _ := json.Marshal(r)
+	require.NoError(t, os.WriteFile(filepath.Join(pipelineDir, "research.json"), data, 0o644))
+
+	got, source := loadResearchForPromote(state)
+	require.NotNil(t, got)
+	assert.Equal(t, "From pipeline", got.NovelFeatures[0].Name)
+	assert.Equal(t, pipelineDir, source)
+}
+
+// TestLoadResearchForPromote_RunRoot verifies the fallback to run-root
+// research.json when pipeline/research.json is absent. This is the
+// shape the skill-driven flow writes today.
+func TestLoadResearchForPromote_RunRoot(t *testing.T) {
+	t.Setenv("PRINTING_PRESS_HOME", t.TempDir())
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+
+	state := &PipelineState{APIName: "myapi", RunID: "run-002"}
+
+	runRoot := RunRoot(state.RunID)
+	require.NoError(t, os.MkdirAll(runRoot, 0o755))
+	// Note: do NOT create pipeline/research.json — the run-root path
+	// is the only source.
+	r := ResearchResult{
+		APIName:       "myapi",
+		NovelFeatures: []NovelFeature{{Name: "From run root", Command: "cmd"}},
+	}
+	data, _ := json.Marshal(r)
+	require.NoError(t, os.WriteFile(filepath.Join(runRoot, "research.json"), data, 0o644))
+
+	got, source := loadResearchForPromote(state)
+	require.NotNil(t, got)
+	assert.Equal(t, "From run root", got.NovelFeatures[0].Name)
+	assert.Equal(t, runRoot, source)
+}
+
+// TestLoadResearchForPromote_MinimalStateGlob verifies the
+// minimal-state path: state.RunID is empty (NewMinimalState), so the
+// loader globs the scoped runstate root and picks the most recent
+// research.json matching the API name.
+func TestLoadResearchForPromote_MinimalStateGlob(t *testing.T) {
+	t.Setenv("PRINTING_PRESS_HOME", t.TempDir())
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+
+	// Two competing runs for myapi; one for otherapi (should be ignored).
+	for _, fixture := range []struct {
+		runID   string
+		apiName string
+		feature string
+		mtime   time.Time
+	}{
+		{"run-old", "myapi", "Old myapi feature", time.Now().Add(-2 * time.Hour)},
+		{"run-recent", "myapi", "Recent myapi feature", time.Now().Add(-30 * time.Minute)},
+		{"run-other", "otherapi", "Other API feature", time.Now()},
+	} {
+		runDir := RunRoot(fixture.runID)
+		require.NoError(t, os.MkdirAll(runDir, 0o755))
+		r := ResearchResult{
+			APIName:       fixture.apiName,
+			NovelFeatures: []NovelFeature{{Name: fixture.feature, Command: "cmd"}},
+		}
+		data, _ := json.Marshal(r)
+		path := filepath.Join(runDir, "research.json")
+		require.NoError(t, os.WriteFile(path, data, 0o644))
+		require.NoError(t, os.Chtimes(path, fixture.mtime, fixture.mtime))
+	}
+
+	// Minimal-state: no RunID set
+	state := NewMinimalState("myapi-pp-cli", "/some/working/dir")
+	require.Empty(t, state.RunID, "NewMinimalState should not set RunID")
+
+	got, source := loadResearchForPromote(state)
+	require.NotNil(t, got, "should find a research.json via glob")
+	assert.Equal(t, "Recent myapi feature", got.NovelFeatures[0].Name,
+		"should pick the most recent matching API")
+	assert.NotEmpty(t, source)
+}
+
+// TestLoadResearchForPromote_MinimalStateNoMatch verifies graceful
+// behavior when no research.json matches the API name in
+// minimal-state — returns (nil, "") rather than crashing or picking a
+// non-matching file.
+func TestLoadResearchForPromote_MinimalStateNoMatch(t *testing.T) {
+	t.Setenv("PRINTING_PRESS_HOME", t.TempDir())
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+
+	runDir := RunRoot("run-other")
+	require.NoError(t, os.MkdirAll(runDir, 0o755))
+	r := ResearchResult{
+		APIName:       "otherapi",
+		NovelFeatures: []NovelFeature{{Name: "Other", Command: "cmd"}},
+	}
+	data, _ := json.Marshal(r)
+	require.NoError(t, os.WriteFile(filepath.Join(runDir, "research.json"), data, 0o644))
+
+	state := NewMinimalState("myapi-pp-cli", "/some/dir")
+	got, source := loadResearchForPromote(state)
+	assert.Nil(t, got, "no match for myapi should return nil")
+	assert.Empty(t, source)
+}
+
+// TestLoadResearchForPromote_NoRunstateRoot verifies graceful behavior
+// when the runstate directory doesn't exist at all (fresh workspace,
+// or PRINTING_PRESS_HOME pointing somewhere with no prior runs).
+func TestLoadResearchForPromote_NoRunstateRoot(t *testing.T) {
+	t.Setenv("PRINTING_PRESS_HOME", t.TempDir())
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+
+	state := NewMinimalState("myapi-pp-cli", "/some/dir")
+	got, source := loadResearchForPromote(state)
+	assert.Nil(t, got)
+	assert.Empty(t, source)
+}
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index 6456b556..926dae1c 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -128,9 +128,17 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
 	// 5. Discover commands
 	commands := discoverCommands(cfg.Dir, binaryPath)
 
-	// 5.5. Infer positional args from --help output
+	// 5.5. Infer positional args from --help output. Pass the spec's
+	// ParamDefaults so positionals named in the spec with a `default:`
+	// (e.g., a real recipe slug for food52, a real symbol for a finance
+	// API) win over the generic canonicalargs registry and the legacy
+	// per-name switch.
+	var paramDefaults map[string]string
+	if spec != nil {
+		paramDefaults = spec.ParamDefaults
+	}
 	for i := range commands {
-		inferPositionalArgs(binaryPath, &commands[i])
+		inferPositionalArgs(binaryPath, &commands[i], paramDefaults)
 	}
 
 	// 6. Classify and run each command
@@ -180,6 +188,15 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
 			for _, ev := range authEnvVars {
 				env = append(env, ev+"=mock-token-for-testing")
 			}
+			// Defense-in-depth: every mock-mode subprocess inherits this
+			// env var. Generated commands that perform visible side
+			// effects (open browser tabs, send notifications) MUST check
+			// cliutil.IsVerifyEnv() and short-circuit when set, so even
+			// if the side-effect classifier misses a command, the
+			// command itself doesn't spam the user's environment during
+			// verify. Documented in skills/printing-press/SKILL.md and
+			// AGENTS.md.
+			env = append(env, "PRINTING_PRESS_VERIFY=1")
 		}
 		return env
 	}
@@ -187,6 +204,17 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
 	// 7. Run tests
 	for i, cmd := range commands {
 		env := buildEnv()
+		// Mock-mode side-effect detection: if the command opens a
+		// browser tab or otherwise performs a visible action that the
+		// PRINTING_PRESS_VERIFY env var alone may not gate, skip its
+		// Execute test to avoid spamming the user's environment during
+		// verify. The DryRun and Help tests still run — those are read-
+		// only by definition.
+		if report.Mode == "mock" && isSideEffectCommand(binaryPath, &commands[i], cfg.Dir) {
+			result := runSideEffectSafeCommandTests(binaryPath, commands[i], env)
+			report.Results = append(report.Results, result)
+			continue
+		}
 		result := runCommandTests(binaryPath, cmd, report.Mode, env)
 		commands[i] = cmd // preserve classification
 		report.Results = append(report.Results, result)
@@ -212,6 +240,51 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
 	return report, nil
 }
 
+// runSideEffectSafeCommandTests is the mock-mode counterpart to
+// runCommandTests for commands the side-effect classifier flagged. It
+// runs --help (read-only) and --dry-run (the command should honor it),
+// then SKIPs the Execute test rather than risk launching a browser tab,
+// sending a notification, etc.
+//
+// PRINTING_PRESS_VERIFY=1 is already set in env, so well-behaved
+// generated commands short-circuit anyway. This wrapper is the
+// belt-and-suspenders complement to that env-var convention.
+func runSideEffectSafeCommandTests(binary string, cmd discoveredCommand, env []string) CommandResult {
+	result := CommandResult{
+		Command: cmd.Name,
+		Kind:    cmd.Kind,
+	}
+
+	result.Help = runCLI(binary, []string{cmd.Name, "--help"}, env, 10*time.Second) == nil
+
+	dryArgs := append([]string{cmd.Name}, cmd.Args...)
+	dryArgs = append(dryArgs, "--dry-run")
+	if err := runCLI(binary, dryArgs, env, 10*time.Second); err == nil || isIntentionalStubExit(err) {
+		result.DryRun = true
+	}
+
+	// SKIP the Execute test: this command was classified as side-effecting
+	// (opens a browser, dials out, etc.) and we don't want to trigger
+	// that during verify even with PRINTING_PRESS_VERIFY=1 set, because
+	// older generated commands may not honor the convention. Score this
+	// as a pass on Execute since "we deliberately did not exercise it"
+	// is not a failure of the CLI under test.
+	result.Execute = true
+
+	score := 0
+	if result.Help {
+		score++
+	}
+	if result.DryRun {
+		score++
+	}
+	if result.Execute {
+		score++
+	}
+	result.Score = score
+	return result
+}
+
 // runCommandTests executes the test suite for a single command.
 func runCommandTests(binary string, cmd discoveredCommand, mode string, env []string) CommandResult {
 	result := CommandResult{
diff --git a/internal/pipeline/runtime_commands.go b/internal/pipeline/runtime_commands.go
index 900fb2df..2393ca4e 100644
--- a/internal/pipeline/runtime_commands.go
+++ b/internal/pipeline/runtime_commands.go
@@ -6,8 +6,11 @@ import (
 	"os/exec"
 	"path/filepath"
 	"regexp"
+	"slices"
 	"strings"
 	"time"
+
+	"github.com/mvanhorn/cli-printing-press/v2/internal/canonicalargs"
 )
 
 func discoverCommands(dir string, binaryPath string) []discoveredCommand {
@@ -112,7 +115,13 @@ type discoveredCommand struct {
 // inferPositionalArgs runs `<binary> <cmd> --help`, parses the Usage line for
 // positional arg placeholders like <region> or [price], and maps them to
 // synthetic values. On any failure, it falls back to no extra args.
-func inferPositionalArgs(binary string, cmd *discoveredCommand) {
+//
+// `paramDefaults` (when non-nil) is consulted first for each placeholder
+// name: if the spec author declared a `default:` on a positional param of
+// that name, the default wins over canonicalargs and the per-name switch.
+// This lets specs supply realistic, domain-correct values (e.g., a real
+// recipe slug) without modifying the generator.
+func inferPositionalArgs(binary string, cmd *discoveredCommand, paramDefaults map[string]string) {
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()
 
@@ -130,8 +139,36 @@ func inferPositionalArgs(binary string, cmd *discoveredCommand) {
 	}
 
 	for _, name := range extractPositionalPlaceholders(string(m[1])) {
-		cmd.Args = append(cmd.Args, syntheticArgValue(name))
+		cmd.Args = append(cmd.Args, resolvePositionalValue(name, paramDefaults))
+	}
+}
+
+// resolvePositionalValue is the canonical lookup chain shared by verify
+// dispatch and any other call site that needs a synthetic value for a
+// positional placeholder. Order:
+//
+//  1. spec author's Param.Default for a positional named `name` (when
+//     the verifier supplied paramDefaults built from the parsed spec)
+//  2. the cross-domain canonicalargs registry (since/until/tag/vertical)
+//  3. the legacy per-name switch in syntheticArgValue (back-compat —
+//     returns calibrated values for slug/query/url/etc.)
+//  4. the "mock-value" catch-all from syntheticArgValue's default arm
+//
+// Domain-specific names (servings, ingredient, sport, ticker, ...) have
+// no entry in the generic registry on purpose; spec authors set them via
+// Param.Default so the value is correct for their API rather than a
+// machine-baked guess.
+func resolvePositionalValue(name string, paramDefaults map[string]string) string {
+	key := strings.ToLower(strings.TrimSpace(name))
+	if paramDefaults != nil {
+		if v, ok := paramDefaults[key]; ok && v != "" {
+			return v
+		}
+	}
+	if v, ok := canonicalargs.Lookup(key); ok {
+		return v
 	}
+	return syntheticArgValue(key)
 }
 
 // flagDescriptorRe matches a bracketed token whose body looks like a flag
@@ -203,6 +240,144 @@ func syntheticArgValue(name string) string {
 	}
 }
 
+// sideEffectHelpKeywords are substrings that, when present in a
+// command's `--help` output, suggest the command performs a visible
+// side effect (opens a browser tab, dials out, etc.). The list
+// intentionally errs toward false-positive: a "browser-friendly"
+// innocuous mention costs us at most a skipped Execute test in mock
+// mode, while a missed true side effect spams the user's tabs or
+// sends a real notification.
+//
+// "browser" alone is broad on purpose — catches "opens in your
+// browser", "default browser", "in the browser", "browser-based",
+// etc. without requiring callers to enumerate every phrasing
+// downstream documentation may use.
+var sideEffectHelpKeywords = []string{
+	"browser",
+	"shells out to",
+}
+
+// sideEffectShellBinaries are OS-level commands that, when invoked via
+// exec.Command from inside a printed CLI's source tree, are strong
+// evidence of a visible side effect. macOS `open`, Linux `xdg-open`,
+// Windows `start` all open URLs/files in the user's default handler.
+var sideEffectShellBinaries = []string{
+	`exec.Command("open"`,
+	`exec.Command("xdg-open"`,
+	`exec.Command("start"`,
+}
+
+// sideEffectGoImports are third-party Go import paths whose presence in
+// the printed CLI's source signals shell-out to a browser or OS handler.
+// pkg/browser is the canonical Go library for "open this URL in the
+// user's browser".
+var sideEffectGoImports = []string{
+	`"github.com/pkg/browser"`,
+}
+
+// isSideEffectCommand returns true when a command looks like it performs
+// a visible side effect (opens a browser tab, sends a notification,
+// shells out to an OS handler) based on its `--help` output and the
+// printed CLI's source under sourceDir. Defense-in-depth: even when this
+// returns false, generated commands should still check
+// cliutil.IsVerifyEnv() before doing anything visible.
+//
+// `sourceDir` is the printed CLI's root (e.g., ~/printing-press/library/<api>)
+// — NOT the printing-press binary's own internal/cli/. The function
+// searches under sourceDir/internal/cli/ for handler files matching the
+// command's name (cmd.Name maps to cmd files like new<Name>Cmd in
+// generator output).
+//
+// Both checks are heuristic. The `--help` scan catches commands with
+// descriptive help text; the source-tree scan catches commands whose
+// source obviously shells out. False positives are rare and acceptable —
+// the cost is "skipped in mock-mode Execute test", not a failure.
+func isSideEffectCommand(binary string, cmd *discoveredCommand, sourceDir string) bool {
+	if helpScanIndicatesSideEffect(binary, cmd) {
+		return true
+	}
+	if sourceScanIndicatesSideEffect(cmd, sourceDir) {
+		return true
+	}
+	return false
+}
+
+func helpScanIndicatesSideEffect(binary string, cmd *discoveredCommand) bool {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	helpCmd := exec.CommandContext(ctx, binary, cmd.Name, "--help")
+	out, err := helpCmd.CombinedOutput()
+	if err != nil {
+		return false
+	}
+	helpLower := strings.ToLower(string(out))
+	for _, kw := range sideEffectHelpKeywords {
+		if strings.Contains(helpLower, kw) {
+			return true
+		}
+	}
+	return false
+}
+
+func sourceScanIndicatesSideEffect(cmd *discoveredCommand, sourceDir string) bool {
+	if sourceDir == "" {
+		return false
+	}
+	cliDir := filepath.Join(sourceDir, "internal", "cli")
+	entries, err := os.ReadDir(cliDir)
+	if err != nil {
+		return false
+	}
+
+	cmdNameVariants := commandSourceNameVariants(cmd.Name)
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+		name := entry.Name()
+		if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
+			continue
+		}
+		if !fileNameMatchesCommand(name, cmdNameVariants) {
+			continue
+		}
+		data, err := os.ReadFile(filepath.Join(cliDir, name))
+		if err != nil {
+			continue
+		}
+		body := string(data)
+		for _, marker := range sideEffectShellBinaries {
+			if strings.Contains(body, marker) {
+				return true
+			}
+		}
+		for _, imp := range sideEffectGoImports {
+			if strings.Contains(body, imp) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// commandSourceNameVariants returns the source-file basenames a command's
+// handler is most likely to live in. Generator emits `new<Camel>Cmd` in
+// files named after the command word; novel-feature commands often use
+// the bare command name as the file basename. Both are checked.
+func commandSourceNameVariants(cmdName string) []string {
+	cleaned := strings.ToLower(strings.TrimSpace(cmdName))
+	if cleaned == "" {
+		return nil
+	}
+	return []string{cleaned + ".go"}
+}
+
+func fileNameMatchesCommand(filename string, variants []string) bool {
+	lower := strings.ToLower(filename)
+	return slices.Contains(variants, lower)
+}
+
 func classifyCommandKind(cmd *discoveredCommand, spec *openAPISpec) {
 	name := cmd.Name
 	switch name {
diff --git a/internal/pipeline/runtime_test.go b/internal/pipeline/runtime_test.go
index a42b09ba..88091ef2 100644
--- a/internal/pipeline/runtime_test.go
+++ b/internal/pipeline/runtime_test.go
@@ -447,6 +447,140 @@ func TestSyntheticArgValue(t *testing.T) {
 	}
 }
 
+func TestResolvePositionalValue_SpecDefaultWins(t *testing.T) {
+	defaults := map[string]string{
+		"servings": "4",
+		"category": "weeknight",        // overrides the per-name switch's "general"
+		"slug":     "real-recipe-slug", // overrides "general"
+		"airport":  "PDX",
+	}
+	assert.Equal(t, "4", resolvePositionalValue("servings", defaults))
+	// Spec default beats canonicalargs (which has no servings entry).
+	assert.Equal(t, "weeknight", resolvePositionalValue("category", defaults))
+	// Spec default beats the legacy syntheticArgValue switch.
+	assert.Equal(t, "real-recipe-slug", resolvePositionalValue("slug", defaults))
+	// Case insensitive.
+	assert.Equal(t, "PDX", resolvePositionalValue("AIRPORT", defaults))
+}
+
+func TestResolvePositionalValue_FallsThroughToCanonicalargs(t *testing.T) {
+	// nil paramDefaults — the next step is canonicalargs.
+	assert.Equal(t, "2026-01-01", resolvePositionalValue("since", nil))
+	assert.Equal(t, "2026-12-31", resolvePositionalValue("until", nil))
+	assert.Equal(t, "mock-tag", resolvePositionalValue("tag", nil))
+	assert.Equal(t, "mock-vertical", resolvePositionalValue("vertical", nil))
+}
+
+func TestResolvePositionalValue_FallsThroughToLegacySwitch(t *testing.T) {
+	// Names not in canonicalargs but present in syntheticArgValue's
+	// per-name switch must keep returning their calibrated values.
+	assert.Equal(t, "mock-query", resolvePositionalValue("query", nil))
+	assert.Equal(t, "/mock/path", resolvePositionalValue("url", nil))
+	assert.Equal(t, "general", resolvePositionalValue("slug", nil))
+	assert.Equal(t, "12345", resolvePositionalValue("id", nil))
+}
+
+func TestResolvePositionalValue_CatchAll(t *testing.T) {
+	assert.Equal(t, "mock-value", resolvePositionalValue("airport_code", nil))
+	assert.Equal(t, "mock-value", resolvePositionalValue("totally-novel-name", nil))
+}
+
+// Spec defaults registered with the same canonical key (lowercase, trimmed)
+// as a canonicalargs entry must still win — verifies the lookup order.
+func TestResolvePositionalValue_SpecDefaultBeatsCanonicalargs(t *testing.T) {
+	defaults := map[string]string{"tag": "real-tag-value"}
+	assert.Equal(t, "real-tag-value", resolvePositionalValue("tag", defaults))
+}
+
+// An empty-string default in the map must NOT short-circuit; the lookup
+// chain continues to canonicalargs / syntheticArgValue. Empty defaults
+// signal "spec author left it blank", not "use blank".
+func TestResolvePositionalValue_EmptyDefaultDoesNotMaskFallback(t *testing.T) {
+	defaults := map[string]string{"tag": ""}
+	assert.Equal(t, "mock-tag", resolvePositionalValue("tag", defaults))
+}
+
+func TestHelpScanIndicatesSideEffect(t *testing.T) {
+	binaryPath := buildHelpScanFixture(t, `Open a recipe in your default browser.
+
+Usage:
+  fixture-pp-cli open <slug>
+
+Examples:
+  fixture-pp-cli open recipes-1`)
+	cmd := &discoveredCommand{Name: "open"}
+	assert.True(t, helpScanIndicatesSideEffect(binaryPath, cmd),
+		"help text mentioning 'browser' should be classified as side-effecting")
+}
+
+func TestHelpScanReturnsFalseForBenignHelp(t *testing.T) {
+	binaryPath := buildHelpScanFixture(t, `List recipes.
+
+Usage:
+  fixture-pp-cli list
+
+Examples:
+  fixture-pp-cli list --json`)
+	cmd := &discoveredCommand{Name: "list"}
+	assert.False(t, helpScanIndicatesSideEffect(binaryPath, cmd),
+		"benign help text should not be classified as side-effecting")
+}
+
+func TestSourceScanIndicatesSideEffect_DetectsExecOpen(t *testing.T) {
+	dir := t.TempDir()
+	cliDir := filepath.Join(dir, "internal", "cli")
+	require.NoError(t, os.MkdirAll(cliDir, 0o755))
+	body := `package cli
+import "os/exec"
+func openHandler(url string) error {
+    return exec.Command("open", url).Run()
+}`
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, "open.go"), []byte(body), 0o644))
+
+	cmd := &discoveredCommand{Name: "open"}
+	assert.True(t, sourceScanIndicatesSideEffect(cmd, dir))
+}
+
+func TestSourceScanIndicatesSideEffect_DetectsPkgBrowserImport(t *testing.T) {
+	dir := t.TempDir()
+	cliDir := filepath.Join(dir, "internal", "cli")
+	require.NoError(t, os.MkdirAll(cliDir, 0o755))
+	body := `package cli
+import "github.com/pkg/browser"
+func openHandler(url string) error {
+    return browser.OpenURL(url)
+}`
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, "open.go"), []byte(body), 0o644))
+
+	cmd := &discoveredCommand{Name: "open"}
+	assert.True(t, sourceScanIndicatesSideEffect(cmd, dir))
+}
+
+func TestSourceScanIndicatesSideEffect_IgnoresBenignHandler(t *testing.T) {
+	dir := t.TempDir()
+	cliDir := filepath.Join(dir, "internal", "cli")
+	require.NoError(t, os.MkdirAll(cliDir, 0o755))
+	body := `package cli
+func listHandler() error { return nil }`
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, "list.go"), []byte(body), 0o644))
+
+	cmd := &discoveredCommand{Name: "list"}
+	assert.False(t, sourceScanIndicatesSideEffect(cmd, dir))
+}
+
+// buildHelpScanFixture writes a tiny shell script that prints the given
+// help text on stdout for any args, builds nothing, and returns its path.
+// helpScanIndicatesSideEffect only cares about CombinedOutput, so a shell
+// stub is enough — no need to compile a Go binary for each fixture.
+func buildHelpScanFixture(t *testing.T, helpText string) string {
+	t.Helper()
+	dir := t.TempDir()
+	scriptPath := filepath.Join(dir, "fixture-pp-cli")
+	script := "#!/bin/sh\ncat <<'EOF'\n" + helpText + "\nEOF\n"
+	require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
+	return scriptPath
+}
+
 func TestIsIntentionalStubExit(t *testing.T) {
 	assert.True(t, isIntentionalStubExit(fmt.Errorf("exit 3: {\"cf_gated\":true,\"message\":\"stub command\"}")))
 	assert.True(t, isIntentionalStubExit(fmt.Errorf("exit 3: {\"cf_gated\": true, \"message\":\"needs manual clearance\"}")))
diff --git a/internal/pipeline/spec_detect.go b/internal/pipeline/spec_detect.go
index 4f52dade..ee046027 100644
--- a/internal/pipeline/spec_detect.go
+++ b/internal/pipeline/spec_detect.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"slices"
+	"strings"
 
 	apispec "github.com/mvanhorn/cli-printing-press/v2/internal/spec"
 )
@@ -44,6 +45,69 @@ func internalSpecToDogfoodSpec(s *apispec.APISpec) *openAPISpec {
 		Auth:          s.Auth,
 		Kind:          s.Kind,
 		HTTPTransport: s.EffectiveHTTPTransport(),
+		ParamDefaults: collectInternalSpecParamDefaults(s),
+	}
+}
+
+// collectInternalSpecParamDefaults walks every endpoint param in the spec
+// and records its declared Default (when non-empty) keyed on the lowercase
+// param name. Later wins on duplicate names — that's fine because the
+// verifier matches placeholders by name regardless of which endpoint they
+// appear under, and a spec author defining the same name with different
+// defaults across endpoints is already an inconsistency they'll surface
+// elsewhere. Only positional params contribute (placeholder lookup is the
+// caller).
+func collectInternalSpecParamDefaults(s *apispec.APISpec) map[string]string {
+	if s == nil {
+		return nil
+	}
+	out := map[string]string{}
+	for _, resource := range s.Resources {
+		collectResourceParamDefaults(resource, out)
+	}
+	if len(out) == 0 {
+		return nil
+	}
+	return out
+}
+
+func collectResourceParamDefaults(r apispec.Resource, out map[string]string) {
+	for _, endpoint := range r.Endpoints {
+		for _, param := range endpoint.Params {
+			if !param.Positional {
+				continue
+			}
+			if param.Default == nil {
+				continue
+			}
+			str := stringifyParamDefault(param.Default)
+			if str == "" {
+				continue
+			}
+			key := strings.ToLower(strings.TrimSpace(param.Name))
+			if key == "" {
+				continue
+			}
+			out[key] = str
+		}
+	}
+	for _, sub := range r.SubResources {
+		collectResourceParamDefaults(sub, out)
+	}
+}
+
+// stringifyParamDefault converts a Param.Default (typed as `any` per the
+// spec schema, since YAML can deliver int/bool/string) into the wire-side
+// string the verifier passes on the command line. Returns "" for nil or
+// empty-string defaults; numbers and bools render via fmt.Sprintf("%v",...).
+func stringifyParamDefault(v any) string {
+	switch t := v.(type) {
+	case nil:
+		return ""
+	case string:
+		return t
+	default:
+		return fmt.Sprintf("%v", t)
 	}
 }
 
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 79e697dc..118dcf63 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -31,10 +31,22 @@ const (
 )
 
 const (
-	HTMLExtractModePage  = "page"
-	HTMLExtractModeLinks = "links"
+	HTMLExtractModePage         = "page"
+	HTMLExtractModeLinks        = "links"
+	HTMLExtractModeEmbeddedJSON = "embedded-json"
 )
 
+// DefaultEmbeddedJSONScriptSelector is the script-tag selector used when
+// `html_extract.mode: embedded-json` is set without an explicit
+// `script_selector`. Targets Next.js's pages-router `<script id="__NEXT_DATA__">`
+// block — the most common shape and the one the food52 retro surfaced.
+// Other SSR frameworks declare different selectors:
+//   - Nuxt:        script#__NUXT__
+//   - Remix:       script:contains("window.__remixContext") (use selector
+//     with type or id when available)
+//   - Astro:       site-specific; declare per spec
+const DefaultEmbeddedJSONScriptSelector = "script#__NEXT_DATA__"
+
 type APISpec struct {
 	Name string `yaml:"name" json:"name"`
 	// Description describes the API itself ("REST API for ordering pizza").
@@ -154,6 +166,48 @@ func resourceHasHTMLExtraction(resource Resource) bool {
 	return false
 }
 
+// HasHTMLExtractMode reports whether any endpoint in the spec declares
+// html_extract with the given effective mode. Used by the html_extract
+// template to gate per-mode helpers: a CLI that uses only
+// HTMLExtractModeEmbeddedJSON does not need the page-mode DOM walkers
+// or links-mode anchor parsing, and vice versa.
+//
+// `mode` should be one of the HTMLExtractMode* constants. Modes that
+// don't appear in any endpoint return false; modes are matched by their
+// effective value (so an unset Mode counts as page).
+func (s *APISpec) HasHTMLExtractMode(mode string) bool {
+	if s == nil {
+		return false
+	}
+	target := strings.ToLower(strings.TrimSpace(mode))
+	if target == "" {
+		return false
+	}
+	for _, resource := range s.Resources {
+		if resourceHasHTMLExtractMode(resource, target) {
+			return true
+		}
+	}
+	return false
+}
+
+func resourceHasHTMLExtractMode(resource Resource, mode string) bool {
+	for _, endpoint := range resource.Endpoints {
+		if !endpoint.UsesHTMLResponse() {
+			continue
+		}
+		if strings.ToLower(endpoint.HTMLExtract.EffectiveMode()) == mode {
+			return true
+		}
+	}
+	for _, sub := range resource.SubResources {
+		if resourceHasHTMLExtractMode(sub, mode) {
+			return true
+		}
+	}
+	return false
+}
+
 // RequiredHeader represents a non-auth header that the API requires on most
 // requests (e.g., cal-api-version, Stripe-Version, anthropic-version).
 // Detected automatically from OpenAPI specs when a required header parameter
@@ -382,9 +436,23 @@ func (e Endpoint) UsesHTMLResponse() bool {
 }
 
 type HTMLExtract struct {
-	Mode         string   `yaml:"mode,omitempty" json:"mode,omitempty"`                   // page (default) or links
-	LinkPrefixes []string `yaml:"link_prefixes,omitempty" json:"link_prefixes,omitempty"` // URL path prefixes to keep when extracting links
-	Limit        int      `yaml:"limit,omitempty" json:"limit,omitempty"`                 // max links to return; defaults at runtime
+	Mode         string   `yaml:"mode,omitempty" json:"mode,omitempty"`                   // page (default), links, or embedded-json
+	LinkPrefixes []string `yaml:"link_prefixes,omitempty" json:"link_prefixes,omitempty"` // URL path prefixes to keep when extracting links (mode: links)
+	Limit        int      `yaml:"limit,omitempty" json:"limit,omitempty"`                 // max links to return; defaults at runtime (mode: links)
+	// ScriptSelector identifies the <script> tag containing serialized
+	// page state when mode is embedded-json. Defaults to
+	// DefaultEmbeddedJSONScriptSelector ("script#__NEXT_DATA__") when
+	// empty — the most common Next.js pages-router shape. Other SSR
+	// frameworks declare per-site selectors (Nuxt: "script#__NUXT__",
+	// etc.). Selector grammar is the simple "tag" / "tag#id" form
+	// supported by the runtime extractor; expand later if needed.
+	ScriptSelector string `yaml:"script_selector,omitempty" json:"script_selector,omitempty"`
+	// JSONPath is a dot-notation walk into the parsed JSON inside the
+	// matched script tag (mode: embedded-json). For Next.js the typical
+	// value is "props.pageProps.<route-data>"; for Nuxt "data.<route>".
+	// Empty path returns the entire parsed JSON. Missing intermediate
+	// keys yield a typed-empty result rather than an error.
+	JSONPath string `yaml:"json_path,omitempty" json:"json_path,omitempty"`
 }
 
 func (h *HTMLExtract) EffectiveMode() string {
@@ -394,6 +462,16 @@ func (h *HTMLExtract) EffectiveMode() string {
 	return h.Mode
 }
 
+// EffectiveScriptSelector returns the configured script selector, or the
+// default Next.js pages-router selector when unset. Only meaningful when
+// EffectiveMode() == HTMLExtractModeEmbeddedJSON.
+func (h *HTMLExtract) EffectiveScriptSelector() string {
+	if h == nil || strings.TrimSpace(h.ScriptSelector) == "" {
+		return DefaultEmbeddedJSONScriptSelector
+	}
+	return h.ScriptSelector
+}
+
 type Param struct {
 	Name        string   `yaml:"name" json:"name"`
 	Type        string   `yaml:"type" json:"type"`
@@ -832,13 +910,24 @@ func validateEndpointResponseFormat(e Endpoint) error {
 		return nil
 	}
 	switch e.HTMLExtract.Mode {
-	case "", HTMLExtractModePage, HTMLExtractModeLinks:
+	case "", HTMLExtractModePage, HTMLExtractModeLinks, HTMLExtractModeEmbeddedJSON:
 	default:
-		return fmt.Errorf("html_extract.mode must be one of: page, links")
+		return fmt.Errorf("html_extract.mode must be one of: page, links, embedded-json")
 	}
 	if e.HTMLExtract.Limit < 0 {
 		return fmt.Errorf("html_extract.limit must be >= 0")
 	}
+	// embedded-json-specific validation: script_selector defaults to
+	// Next.js's __NEXT_DATA__ when empty, so it's not strictly required;
+	// json_path is also optional (empty path returns the entire parsed
+	// JSON). Both have defaults so embedded-json validates with no extra
+	// fields set. Reject explicit empty json_path strings that contain
+	// only whitespace as a sanity check; trim happens at use time.
+	if e.HTMLExtract.Mode == HTMLExtractModeEmbeddedJSON {
+		if strings.TrimSpace(e.HTMLExtract.ScriptSelector) == "" && e.HTMLExtract.ScriptSelector != "" {
+			return fmt.Errorf("html_extract.script_selector cannot be whitespace-only")
+		}
+	}
 	return nil
 }
 
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 66d53484..65258b02 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -1442,6 +1442,75 @@ func TestHTMLResponseExtractionValidation(t *testing.T) {
 	require.ErrorContains(t, badMethod.Validate(), "html response_format is only supported")
 }
 
+func TestHTMLExtract_EmbeddedJSONMode(t *testing.T) {
+	t.Parallel()
+
+	embeddedJSON := func() APISpec {
+		return APISpec{
+			Name:    "nextapp",
+			BaseURL: "https://www.example.com",
+			Resources: map[string]Resource{
+				"recipes": {
+					Description: "Recipes",
+					Endpoints: map[string]Endpoint{
+						"browse": {
+							Method:         "GET",
+							Path:           "/recipes/{tag}",
+							Description:    "Browse recipes by tag",
+							ResponseFormat: ResponseFormatHTML,
+							HTMLExtract: &HTMLExtract{
+								Mode:           HTMLExtractModeEmbeddedJSON,
+								ScriptSelector: "script#__NEXT_DATA__",
+								JSONPath:       "props.pageProps.recipesByTag.results",
+							},
+							Response: ResponseDef{Type: "array", Item: "recipe"},
+						},
+					},
+				},
+			},
+		}
+	}
+
+	// Happy path: embedded-json with explicit selector + json_path validates.
+	base := embeddedJSON()
+	require.NoError(t, base.Validate())
+	ep := base.Resources["recipes"].Endpoints["browse"]
+	assert.Equal(t, HTMLExtractModeEmbeddedJSON, ep.HTMLExtract.EffectiveMode())
+	assert.Equal(t, "script#__NEXT_DATA__", ep.HTMLExtract.EffectiveScriptSelector())
+
+	// Default selector: empty ScriptSelector resolves to the Next.js
+	// pages-router default.
+	defaults := embeddedJSON()
+	depEP := defaults.Resources["recipes"].Endpoints["browse"]
+	depEP.HTMLExtract.ScriptSelector = ""
+	defaults.Resources["recipes"].Endpoints["browse"] = depEP
+	require.NoError(t, defaults.Validate())
+	assert.Equal(t, DefaultEmbeddedJSONScriptSelector, depEP.HTMLExtract.EffectiveScriptSelector())
+
+	// Empty json_path is valid (returns whole pageProps).
+	emptyPath := embeddedJSON()
+	pep := emptyPath.Resources["recipes"].Endpoints["browse"]
+	pep.HTMLExtract.JSONPath = ""
+	emptyPath.Resources["recipes"].Endpoints["browse"] = pep
+	require.NoError(t, emptyPath.Validate())
+
+	// Whitespace-only ScriptSelector is rejected (catch typos like " ").
+	badSelector := embeddedJSON()
+	bsEP := badSelector.Resources["recipes"].Endpoints["browse"]
+	bsEP.HTMLExtract.ScriptSelector = "   "
+	badSelector.Resources["recipes"].Endpoints["browse"] = bsEP
+	require.ErrorContains(t, badSelector.Validate(), "script_selector cannot be whitespace-only")
+
+	// Unknown mode is still rejected and the error message names embedded-json.
+	unknownMode := embeddedJSON()
+	uEP := unknownMode.Resources["recipes"].Endpoints["browse"]
+	uEP.HTMLExtract.Mode = "rsc-stream"
+	unknownMode.Resources["recipes"].Endpoints["browse"] = uEP
+	err := unknownMode.Validate()
+	require.Error(t, err)
+	require.ErrorContains(t, err, "embedded-json")
+}
+
 func TestEnrichPathParams(t *testing.T) {
 	t.Parallel()
 
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 594f56e0..7654c6b6 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1611,16 +1611,26 @@ Priority 3 (polish):
 
 ### Agent Build Checklist (per command)
 
-After building each command in Priority 1 and Priority 2, verify these 8 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 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.
 
 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
-3. **Progressive help**: `--help` shows realistic examples with domain-specific values (not "abc123")
+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
 6. **Composability**: Exit codes are typed (0/2/3/4/5/7/10 as applicable), output pipes to `jq` cleanly
 7. **Bounded responses**: `--compact` returns only high-gravity fields, list commands have `--limit`
 8. **Verify-friendly RunE**: Hand-written commands MUST NOT use `Args: cobra.MinimumNArgs(N)` or `MarkFlagRequired(...)`. Cobra evaluates both before RunE runs, so a `--dry-run` guard inside RunE cannot reach if those gates fail. Verify probes commands with `--dry-run` and expects exit 0; commands with hard arg/flag gates fail those probes. Instead: validate inside RunE, fall through to `cmd.Help()` for help-only invocations, and short-circuit on `dryRunOK(flags)` before any IO.
+9. **Side-effect commands stay quiet under verify**: Any hand-written command that performs a visible side effect (opens a browser tab, sends a notification, plays audio, dials out to an OS handler) MUST follow both halves of the convention:
+   - **Print by default; opt in to the action.** The default behavior prints what would happen (`would launch: <url>`); a flag like `--launch` / `--send` / `--play` is required to actually do it. food52's `open` command is the reference shape — see `internal/cli/open.go` after retro #337.
+   - **Short-circuit when `cliutil.IsVerifyEnv()` returns true.** The Printing Press verifier sets `PRINTING_PRESS_VERIFY=1` in every mock-mode subprocess; commands that ignore it can spam the user's environment during a verify pass even with the print-by-default flag pattern. The helper is generated into every CLI's `internal/cliutil/verifyenv.go`. Pattern:
+     ```go
+     if cliutil.IsVerifyEnv() {
+         fmt.Fprintln(cmd.OutOrStdout(), "would launch:", url)
+         return nil
+     }
+     ```
+   This is defense-in-depth: the verifier also runs a heuristic side-effect classifier, but it can miss commands whose `--help` text and source don't match the heuristics. The env-var check is the floor.
 
 #### Verify-friendly RunE template
 

← c74dcd45 fix(cli): Cal.com retro #334 — 4 of 5 P1 machine fixes (per-  ·  back to Cli Printing Press  ·  fix(cli): sync dogfood novel features into CLI artifacts (#3 d86d3af1 →