[object Object]

← back to Cli Printing Press

feat(cli): add discovery/ manuscript directory for sniff provenance (#70)

9bad30affe5ccbeda2a3451d34ffa88fc5e6073a · 2026-03-30 10:14:17 -0700 · Trevin Chow

Sniff-derived CLIs silently dropped discovery evidence during archival
because sniff artifacts (HAR captures, URL lists) were written to the
runstate root while the archive step only copied research/ and proofs/.

Add discovery/ as a peer directory to research/ and proofs/ in the
manuscript archive structure. Wire RunDiscoveryDir/ArchivedDiscoveryDir
path functions, add the discovery pair to ArchiveRunArtifacts, redirect
all sniff artifact paths in the skill to $DISCOVERY_DIR, add HAR body
stripping before archival, and add a structured sniff-report.md step
to the skill's sniff flow.

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

Files touched

Diff

commit 9bad30affe5ccbeda2a3451d34ffa88fc5e6073a
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon Mar 30 10:14:17 2026 -0700

    feat(cli): add discovery/ manuscript directory for sniff provenance (#70)
    
    Sniff-derived CLIs silently dropped discovery evidence during archival
    because sniff artifacts (HAR captures, URL lists) were written to the
    runstate root while the archive step only copied research/ and proofs/.
    
    Add discovery/ as a peer directory to research/ and proofs/ in the
    manuscript archive structure. Wire RunDiscoveryDir/ArchivedDiscoveryDir
    path functions, add the discovery pair to ArchiveRunArtifacts, redirect
    all sniff artifact paths in the skill to $DISCOVERY_DIR, add HAR body
    stripping before archival, and add a structured sniff-report.md step
    to the skill's sniff flow.
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 ...-30-sniff-manuscript-provenance-requirements.md | 118 +++++++++++
 ...01-feat-discovery-manuscript-provenance-plan.md | 224 +++++++++++++++++++++
 ...oped-printing-press-output-layout-2026-03-28.md |   5 +-
 internal/pipeline/climanifest_test.go              |  67 ++++++
 internal/pipeline/paths.go                         |   8 +
 internal/pipeline/paths_test.go                    |   2 +
 internal/pipeline/publish.go                       |   1 +
 internal/pipeline/state.go                         |   4 +
 skills/printing-press/SKILL.md                     |  48 ++++-
 9 files changed, 469 insertions(+), 8 deletions(-)

diff --git a/docs/brainstorms/2026-03-30-sniff-manuscript-provenance-requirements.md b/docs/brainstorms/2026-03-30-sniff-manuscript-provenance-requirements.md
new file mode 100644
index 00000000..39c79550
--- /dev/null
+++ b/docs/brainstorms/2026-03-30-sniff-manuscript-provenance-requirements.md
@@ -0,0 +1,118 @@
+---
+date: 2026-03-30
+topic: discovery-manuscript-provenance
+---
+
+# Discovery Manuscript Provenance
+
+## Problem Frame
+
+When a CLI is generated via a discovery method (browser sniff, HAR import, or the upcoming crowd-sniff from PR #67), the manuscript artifacts fail to preserve the discovery evidence. The research brief captures product context and the shipcheck captures quality gates, but nothing records *how* the API was discovered: what was observed, what was inferred, and what confidence backs each endpoint.
+
+This matters for two reasons:
+- **Reproducibility**: A future maintainer extending the CLI cannot tell what was discovered, what was missed, or how to re-run the process.
+- **Auditability**: A reviewer cannot verify that the generated spec reflects real API behavior rather than hallucinated endpoints.
+
+The root cause is structural: discovery flows write intermediate artifacts directly into `$API_RUN_DIR/`, but the archive step only copies `research/` and `proofs/`. Discovery artifacts are silently dropped.
+
+```
+$API_RUN_DIR/
+  research/               <-- archived to manuscripts
+    brief.md
+    spec.yaml
+  proofs/                 <-- archived to manuscripts
+    shipcheck.md
+  sniff-urls.txt          <-- LOST (browser sniff)
+  sniff-capture.har       <-- LOST (browser sniff)
+  sniff-unique-paths.txt  <-- LOST (browser sniff)
+  state.json              <-- LOST
+```
+
+There are currently two discovery methods with different provenance needs. Browser sniff is shipped (Phase 1.7 in the skill). Crowd-sniff is pending (PR #67, not yet merged).
+
+| Method | Status | Source | Key evidence |
+|--------|--------|--------|-------------|
+| **Browser sniff** | Shipped | One browsing session on the live site | Pages visited, HAR/capture, response samples |
+| **Crowd-sniff** | Pending PR #67 | npm SDKs + GitHub code search | SDKs analyzed, repos searched, source tiers, frequency counts |
+
+Both produce spec YAML that gets archived in `research/`, but the raw evidence that produced each spec is lost.
+
+## Implementation Phases
+
+This work is phased to avoid coupling to the unmerged crowd-sniff PR.
+
+- **Phase 1** (this plan): Sniff provenance — discovery/ directory, archive step, sniff report, raw evidence archival. Lands independently.
+- **Phase 2** (after PR #67 merges): Crowd-sniff provenance — crowd-sniff report, crowd-sniff evidence archival. Reuses the discovery/ infrastructure from Phase 1.
+
+The discovery/ directory structure supports both methods from day one. Phase 2 adds content without restructuring.
+
+## Requirements
+
+**Discovery Directory and Archival**
+
+- R1. Each discovery method must write its artifacts into a dedicated `$API_RUN_DIR/discovery/` directory, peer to `research/` and `proofs/`. Within `discovery/`, artifacts are prefixed by method (e.g., `discovery/sniff-*`, `discovery/crowd-*`). If no discovery method ran, the directory is absent — archival and packaging skip it gracefully.
+- R2. The skill's archive step must copy `discovery/` to `$PRESS_MANUSCRIPTS/<api>/<run-id>/discovery/` alongside `research/` and `proofs/`.
+- R3. The `publish package` command must include `.manuscripts/<run-id>/discovery/` when manuscripts are present and a discovery directory exists. Discovery/ is optional — its absence is normal for non-discovery CLIs and pre-discovery runs, not an error.
+
+**Sniff Discovery Report (Skill-Generated) — Phase 1**
+
+- R4. At the end of the browser sniff flow, the skill must produce a structured sniff report at `$API_RUN_DIR/discovery/sniff-report.md` containing:
+  - Pages visited (URLs and order)
+  - Endpoints discovered (method, path, status code observed)
+  - Coverage analysis: what resource types were exercised, what was likely missed
+  - Proxy pattern detection result (if applicable)
+  - Effective sniff rate and any rate-limit events
+  - Sniff backend used (browser-use, agent-browser, manual HAR)
+- R5. The sniff report must include truncated response samples for each unique response shape so a reviewer can verify generated schemas match actual API behavior. Truncation rules: JSON/text responses include first 2KB or 100 lines (whichever is smaller); binary responses (images, protobuf, etc.) are skipped with a metadata note (content type, size).
+
+**Raw Evidence Archival — Phase 1**
+
+- R6. The raw capture file (HAR or enriched capture JSON) must be archived in `discovery/`. To control size, strip response bodies from the HAR and rely on the truncated samples in the sniff report (R5) for schema evidence.
+- R7. The deduplicated URL/path list (`sniff-unique-paths.txt` or equivalent) must be archived in `discovery/`.
+
+**Crowd-Sniff Discovery Report — Phase 2 (after PR #67)**
+
+- R8. At the end of the crowd-sniff flow, the skill must produce a structured crowd-sniff report at `$API_RUN_DIR/discovery/crowd-sniff-report.md` containing:
+  - npm packages analyzed (name, version, download count, recency)
+  - GitHub repos searched (query used, repos matched, freshness)
+  - Endpoints discovered with source tier and frequency (seen in N sources)
+  - Base URL candidates discovered and which was selected
+  - Auth patterns detected (API key, bearer, OAuth)
+- R9. The binary's `crowd-sniff --json` provenance output must be archived in `discovery/`. Raw SDK tarballs and GitHub responses are NOT archived (too large, reproducible via re-run).
+
+## Success Criteria
+
+- A sniff-derived CLI's manuscripts contain enough information to reproduce the sniff: a new developer can see what pages were visited, what endpoints were found, and re-run or extend the discovery.
+- A reviewer can trace any endpoint in the generated spec back to observed traffic evidence (response sample or HAR entry).
+- `publish validate` manuscripts check (warn-only) passes for discovery-derived CLIs. When discovery/ is present, it is included; when absent (non-discovery CLI or pre-discovery run), the check does not flag it.
+
+## Scope Boundaries
+
+- Not adding provenance retroactively to existing CLIs.
+- Not changing the research brief or shipcheck formats.
+- Not storing full response bodies in the HAR — strip bodies and use truncated samples in the report instead.
+- Not changing `publish validate` manuscripts check from warn-only to required.
+- Not designing for hypothetical future discovery methods beyond sniff and crowd-sniff.
+- Phase 2 (crowd-sniff) does not begin until PR #67 is merged.
+
+## Key Decisions
+
+- **Single `discovery/` directory, not per-method dirs**: Both sniff and crowd-sniff write to `discovery/` with method-prefixed filenames. One archive step handles both. The directory structure is ready for both methods from Phase 1; Phase 2 adds content only.
+- **Phased delivery**: Phase 1 (sniff) lands independently. Phase 2 (crowd-sniff) gates on PR #67. Avoids coupling to unmerged work.
+- **Skill-generated reports, binary provenance deferred**: Phase 1 reports are skill-generated markdown. Binary `--json` provenance output (structured per-endpoint extraction metadata) is deferred to planning — the skill has enough context from orchestrating the sniff to write a useful report without it.
+- **Content-aware truncation for response samples**: JSON/text first 2KB or 100 lines; binary responses skipped with metadata note. Avoids arbitrary line-count truncation that fails for non-text.
+- **HAR archived with bodies stripped**: Raw HAR structure preserved for reproducibility (URLs, methods, status codes, headers) but response bodies removed to control size. Truncated samples in the report serve the auditability need.
+
+## Outstanding Questions
+
+### Deferred to Planning
+
+- [Affects R3][Technical] Does `publish package` already copy the full `.manuscripts/<run-id>/` tree recursively, or does it need code changes to include `discovery/`?
+- [Affects R2][Technical] `ArchiveRunArtifacts` in `publish.go` uses a hardcoded pairs list. Needs a `DiscoveryDir` pair added, plus `RunDiscoveryDir()` and `ArchivedDiscoveryDir()` path functions in `paths.go`.
+- [Affects R6][Technical] What tool or code strips response bodies from HAR before archival? Could be a `jq` one-liner in the skill or a utility in the binary.
+- [Phase 2][Needs research] Does `crowd-sniff --json` (PR #67) already emit enough structured output for the crowd-sniff report, or does it need a provenance mode?
+- [Phase 2][Technical] Binary `--json` provenance output for both sniff and crowd-sniff — schema design deferred until skill-generated reports prove insufficient.
+
+## Next Steps
+
+`/ce:plan` for structured implementation planning (Phase 1: sniff provenance)
diff --git a/docs/plans/2026-03-30-001-feat-discovery-manuscript-provenance-plan.md b/docs/plans/2026-03-30-001-feat-discovery-manuscript-provenance-plan.md
new file mode 100644
index 00000000..1b11ed62
--- /dev/null
+++ b/docs/plans/2026-03-30-001-feat-discovery-manuscript-provenance-plan.md
@@ -0,0 +1,224 @@
+---
+title: "feat: Add discovery/ manuscript directory for sniff provenance"
+type: feat
+status: completed
+date: 2026-03-30
+origin: docs/brainstorms/2026-03-30-sniff-manuscript-provenance-requirements.md
+---
+
+# feat: Add discovery/ manuscript directory for sniff provenance
+
+## Overview
+
+Add a `discovery/` directory to the manuscript archive structure so sniff-derived CLIs preserve the raw evidence of how their API was discovered. Currently, sniff intermediate artifacts (HAR captures, URL lists) are written to the runstate root and silently dropped during archival. This adds the directory, redirects sniff artifacts into it, generates a human-readable sniff report, and updates the Go archive step to copy it.
+
+## Problem Frame
+
+When a CLI is generated via browser sniff, the manuscripts preserve the research brief and shipcheck but not the discovery evidence — what pages were browsed, what traffic was captured, what endpoints were derived. A future maintainer cannot reproduce or extend the sniff. The root cause: sniff artifacts live at `$API_RUN_DIR/` root, but archival only copies `research/` and `proofs/`. (see origin: docs/brainstorms/2026-03-30-sniff-manuscript-provenance-requirements.md)
+
+## Requirements Trace
+
+- R1. Discovery artifacts go to `$API_RUN_DIR/discovery/`, peer to research/ and proofs/
+- R2. Archive step copies discovery/ to manuscripts
+- R3. publish package includes discovery/ (already works — CopyDir copies the full tree)
+- R4. Skill generates sniff-report.md in discovery/
+- R5. Sniff report includes content-aware truncated response samples
+- R6. HAR archived with response bodies stripped
+- R7. Deduplicated URL/path list archived in discovery/
+
+## Scope Boundaries
+
+- Phase 1 only (sniff provenance). Crowd-sniff (R8-R9) deferred to Phase 2 after PR #67 merges
+- No binary `--json` provenance output changes — skill-generated reports only
+- No changes to publish validate manuscripts check (stays warn-only)
+- No retroactive provenance for existing CLIs
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/pipeline/paths.go` — All path functions (RunResearchDir, ArchivedResearchDir, etc.). New discovery paths follow this exact pattern.
+- `internal/pipeline/state.go:561-566` — PipelineState methods (ResearchDir, ProofsDir). New DiscoveryDir follows the one-liner pattern.
+- `internal/pipeline/publish.go:122-162` — ArchiveRunArtifacts iterates a hardcoded `pairs` slice. Missing dirs are silently skipped via `os.IsNotExist -> continue`.
+- `internal/pipeline/paths_test.go:21-27` — Path assertions. New paths get the same test pattern.
+- `internal/cli/publish.go:234-248` — publish package uses `CopyDir` on the entire `manuscripts/<api>/<runID>/` tree. No changes needed — discovery/ is automatically included.
+- `skills/printing-press/SKILL.md:981-989` — Shell-based archive step copies research/ and proofs/ only. Needs discovery/ line added.
+- `skills/printing-press/SKILL.md:507-635` — Sniff flow writes `sniff-urls.txt`, `sniff-unique-paths.txt`, `sniff-capture.har/.json` to `$API_RUN_DIR/` root.
+
+### Institutional Learnings
+
+- `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md` — Authoritative layout contract. Active runs in `.runstate/`, archived in `manuscripts/<api>/<runID>/`. Path logic centralized in `internal/pipeline/paths.go`.
+- `docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md` — Publish validation must not mutate source. Not directly triggered here since we're adding to the archive side, not validation.
+
+## Key Technical Decisions
+
+- **No publish.go changes needed**: Research confirmed `publish package` copies the entire `manuscripts/<api>/<runID>/` tree via `CopyDir`. Once `ArchiveRunArtifacts` creates `discovery/`, it flows through automatically. (see origin: Outstanding Questions on R3, now resolved)
+- **Go-side + skill-side archive**: The Go `ArchiveRunArtifacts` handles the fullrun pipeline path. The skill's shell archive handles the skill-driven path. Both need the discovery/ pair.
+- **HAR body stripping in the skill**: The skill orchestrates the sniff and has the HAR file. A `jq` command in the skill strips response bodies before archiving. No binary utility needed for Phase 1.
+- **Sniff report is skill-generated markdown**: The skill has full context (pages visited, endpoints found, rate events, proxy detection). It writes the report directly — no binary `--json` intermediate needed.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Does publish package need changes for discovery/?** No. CopyDir copies the full tree. Confirmed by reading `internal/cli/publish.go:234-248`.
+- **Report naming convention?** Use `sniff-report.md` (no timestamp). Matches `brief.md` / `shipcheck.md` pattern — one per run, overwritten on re-run.
+
+### Deferred to Implementation
+
+- Exact `jq` expression for stripping HAR response bodies — depends on HAR structure from the specific sniff backend
+- Whether `sniff-urls.txt` (raw) is worth archiving alongside `sniff-unique-paths.txt` (deduped), or if only the deduped list adds value
+
+## Implementation Units
+
+- [ ] **Unit 1: Add discovery/ path functions and archive pair**
+
+  **Goal:** Establish the discovery/ directory in the pipeline's path system and wire it into ArchiveRunArtifacts.
+
+  **Requirements:** R1, R2
+
+  **Dependencies:** None
+
+  **Files:**
+  - Modify: `internal/pipeline/paths.go`
+  - Modify: `internal/pipeline/state.go`
+  - Modify: `internal/pipeline/publish.go`
+  - Test: `internal/pipeline/paths_test.go`
+
+  **Approach:**
+  - Add `RunDiscoveryDir(runID string) string` and `ArchivedDiscoveryDir(apiName, runID string) string` to paths.go, following the RunResearchDir/ArchivedResearchDir pattern
+  - Add `func (s *PipelineState) DiscoveryDir() string` to state.go, following the ResearchDir one-liner pattern
+  - Add the `{src: state.DiscoveryDir(), dst: ArchivedDiscoveryDir(...)}` pair to the `pairs` slice in ArchiveRunArtifacts (publish.go:133-137)
+
+  **Patterns to follow:**
+  - `RunResearchDir` / `ArchivedResearchDir` in paths.go (lines 84-118)
+  - `func (s *PipelineState) ResearchDir()` in state.go (line 561)
+  - The `pairs` slice in ArchiveRunArtifacts (publish.go:133-137)
+
+  **Test scenarios:**
+  - Happy path: `RunDiscoveryDir("run-123")` returns `<runRoot>/run-123/discovery`
+  - Happy path: `ArchivedDiscoveryDir("notion", "run-123")` returns `<manuscripts>/notion/run-123/discovery`
+  - Happy path: ArchiveRunArtifacts copies discovery/ when it exists
+  - Edge case: ArchiveRunArtifacts silently skips discovery/ when it does not exist (existing behavior for missing dirs)
+  - Integration: Full archive cycle — create discovery/ with a test file, run ArchiveRunArtifacts, verify file appears in archived location
+
+  **Verification:**
+  - `go test ./internal/pipeline/...` passes
+  - New path functions return correct paths under test PRESS_HOME
+  - ArchiveRunArtifacts copies discovery/ alongside research/ and proofs/
+
+- [ ] **Unit 2: Redirect sniff artifacts to discovery/ in the skill**
+
+  **Goal:** Update the skill's sniff flow to write all intermediate artifacts into `$API_RUN_DIR/discovery/` instead of the run root.
+
+  **Requirements:** R1, R6, R7
+
+  **Dependencies:** Unit 1 (discovery/ must be a recognized directory)
+
+  **Files:**
+  - Modify: `skills/printing-press/SKILL.md`
+
+  **Approach:**
+  - Add `DISCOVERY_DIR="$API_RUN_DIR/discovery"` to the run init block (near line 163, alongside RESEARCH_DIR and PROOFS_DIR)
+  - Add `mkdir -p "$DISCOVERY_DIR"` to the directory creation (near line 165)
+  - Update Step 2a.2 to write `sniff-urls.txt` to `$DISCOVERY_DIR/sniff-urls.txt` instead of `$API_RUN_DIR/sniff-urls.txt`
+  - Update Step 2a.3 to write `sniff-unique-paths.txt` to `$DISCOVERY_DIR/sniff-unique-paths.txt`
+  - Update Steps 2a.4 / 2b to write `sniff-capture.json` / `sniff-capture.har` to `$DISCOVERY_DIR/`
+  - Update the archive step (lines 986-988) to add: `cp -r "$DISCOVERY_DIR" "$PRESS_MANUSCRIPTS/<api>/$RUN_ID/discovery" 2>/dev/null || true`
+  - Add a HAR body-stripping step before archival: use `jq` to remove `.log.entries[].response.content.text` from the HAR before copying to discovery/
+
+  **Patterns to follow:**
+  - Existing `$RESEARCH_DIR` / `$PROOFS_DIR` variable definitions and mkdir pattern in SKILL.md
+  - Existing archive step pattern (cp -r with 2>/dev/null || true)
+
+  **Test scenarios:**
+  - Not directly testable in Go (skill is markdown). Verified by running a sniff-based generation and confirming discovery/ contains the expected files.
+
+  **Verification:**
+  - After a sniff run, `$API_RUN_DIR/discovery/` contains: `sniff-urls.txt`, `sniff-unique-paths.txt`, `sniff-capture.har` (bodies stripped) or `sniff-capture.json`
+  - After archive, `$PRESS_MANUSCRIPTS/<api>/<run-id>/discovery/` contains the same files
+  - No sniff artifacts remain at the `$API_RUN_DIR/` root level
+
+- [ ] **Unit 3: Generate sniff-report.md in the skill**
+
+  **Goal:** At the end of the sniff flow, the skill produces a structured sniff report summarizing the discovery evidence.
+
+  **Requirements:** R4, R5
+
+  **Dependencies:** Unit 2 (artifacts must be in discovery/)
+
+  **Files:**
+  - Modify: `skills/printing-press/SKILL.md`
+
+  **Approach:**
+  - After Step 4 ("Report and update spec source") and before the generate phase, add a "Write sniff discovery report" step
+  - The skill writes `$DISCOVERY_DIR/sniff-report.md` containing:
+    - **Pages Visited** — list of URLs browsed, in order
+    - **Sniff Configuration** — backend used, pacing settings, proxy pattern detection result
+    - **Endpoints Discovered** — table of method, path, status code, content type
+    - **Coverage Analysis** — what resource types were exercised, what was likely missed based on research brief comparison
+    - **Response Samples** — for each unique response shape, first 2KB or 100 lines (whichever smaller) of JSON/text content. Binary responses get a metadata note (content type, size) instead
+    - **Rate Limiting Events** — any 429s encountered, effective rate achieved
+  - Content-aware truncation: the skill checks content type before including response samples. JSON/text → truncate. Binary → metadata note only.
+
+  **Patterns to follow:**
+  - The skill's existing research brief generation (Phase 1 brief structure)
+  - The skill's existing shipcheck report generation (proofs structure)
+
+  **Test scenarios:**
+  - Not directly testable in Go (skill-generated markdown). Verified by running a sniff-based generation and confirming sniff-report.md exists with expected sections.
+
+  **Verification:**
+  - After a sniff run, `$DISCOVERY_DIR/sniff-report.md` exists
+  - Report contains all 6 required sections (pages, config, endpoints, coverage, samples, rate events)
+  - JSON response samples are truncated to 2KB max
+  - Binary response types show metadata note instead of content
+  - Report is archived to manuscripts alongside other discovery files
+
+- [ ] **Unit 4: Update layout contract documentation**
+
+  **Goal:** Update the authoritative output layout documentation to include discovery/ as a recognized manuscript subdirectory.
+
+  **Requirements:** Keeps docs/solutions/ accurate
+
+  **Dependencies:** Units 1-3
+
+  **Files:**
+  - Modify: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`
+
+  **Approach:**
+  - Add `discovery/` to the manuscripts directory listing alongside research/, proofs/, pipeline/
+  - Note that discovery/ is optional (only present for sniff/crowd-sniff derived CLIs)
+  - Keep the update minimal — just the directory listing and a one-line description
+
+  **Patterns to follow:**
+  - Existing directory listing format in the layout contract doc
+
+  **Test scenarios:**
+  - N/A (documentation)
+
+  **Verification:**
+  - Layout contract mentions discovery/ as an optional manuscript subdirectory
+
+## System-Wide Impact
+
+- **Interaction graph:** ArchiveRunArtifacts is called from fullrun.go after PublishWorkingCLI. The skill's shell archive step runs independently. Both paths now copy discovery/. publish package downstream copies the full tree — no interaction change.
+- **Error propagation:** Missing discovery/ is silently skipped (same as missing research/ or proofs/). No new error paths introduced.
+- **State lifecycle risks:** When sniff flow is re-executed in an existing `$API_RUN_DIR`, prior `discovery/` contents are overwritten. This is expected — each sniff run produces fresh artifacts. Discovery/ is write-once per sniff execution, archived once, then read-only.
+- **API surface parity:** No CLI flags, commands, or public APIs change. Only internal directory structure.
+- **Unchanged invariants:** publish validate manuscripts check stays warn-only. Existing CLIs without discovery/ are unaffected. The binary `sniff` command output is unchanged.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Skill changes are not directly testable in Go | Manual verification via a sniff run. Unit 1 (Go changes) is fully testable. |
+| HAR body stripping via jq may vary by HAR format | The `jq` expression targets the standard HAR 1.2 `.log.entries[].response.content.text` path. Both browser-use and agent-browser produce standard HAR. |
+| Large HAR files even after body stripping | Headers-only HARs are typically <1MB for a 10-15 page sniff session. Acceptable for manuscripts. |
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-03-30-sniff-manuscript-provenance-requirements.md](docs/brainstorms/2026-03-30-sniff-manuscript-provenance-requirements.md)
+- Related code: `internal/pipeline/paths.go`, `internal/pipeline/publish.go`, `internal/pipeline/state.go`
+- Layout contract: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`
+- Skill definition: `skills/printing-press/SKILL.md`
diff --git a/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md b/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md
index 8759da09..9dd751fb 100644
--- a/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md
+++ b/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md
@@ -61,12 +61,14 @@ Use a checkout-scoped runstate derived from the current git root for active work
         research/
         proofs/
         pipeline/
+        discovery/      (optional — sniff/crowd-sniff captures, reports, URL lists)
   library/
     <api>-pp-cli[-N]/
   manuscripts/<api>/<run-id>/
     research/
     proofs/
     pipeline/
+    discovery/          (optional — only for sniff/crowd-sniff derived CLIs)
     manifest.json
 ```
 
@@ -103,6 +105,7 @@ Artifact placement rules:
 - Archived research documents go under `manuscripts/<api>/<run-id>/research/`
 - Archived scorecard, dogfood, emboss, and similar evidence go under `manuscripts/<api>/<run-id>/proofs/`
 - Archived phase seeds and pipeline records go under `manuscripts/<api>/<run-id>/pipeline/`
+- Archived sniff/crowd-sniff discovery evidence goes under `manuscripts/<api>/<run-id>/discovery/` (optional — absent when no discovery method ran)
 - Resume and current-run discovery should read `.runstate` first, not global `library/` or `manuscripts/`
 
 ## Why This Works
@@ -122,7 +125,7 @@ That removes the class of bugs where docs say one thing, skills do another, and
 ## Prevention
 
 - Never hardcode `~/cli-printing-press` in skills, docs, or code paths. Always resolve `git rev-parse --show-toplevel` first.
-- When adding a new artifact-producing phase, decide first whether it belongs in runstate `research/`, `proofs/`, or `pipeline/`, and whether it must also be archived at publish time. Do not default to repo `docs/plans/`.
+- When adding a new artifact-producing phase, decide first whether it belongs in runstate `research/`, `proofs/`, `pipeline/`, or `discovery/`, and whether it must also be archived at publish time. Do not default to repo `docs/plans/`.
 - If a feature writes a file tied to a generated CLI, test both canonical and claimed output dirs, for example `notion-pp-cli` and `notion-pp-cli-2`.
 - Keep naming logic centralized in `internal/naming/` and path logic centralized in `internal/pipeline/paths.go`.
 - Add tests whenever code infers API names or command directories from filesystem paths.
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index 1be0d36d..e2dbc9c4 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -382,6 +382,73 @@ func TestWriteManifestForGenerateNoSpec(t *testing.T) {
 	assert.Empty(t, got.SpecChecksum)
 }
 
+func TestArchiveRunArtifactsCopiesDiscovery(t *testing.T) {
+	home := setPressTestEnv(t)
+
+	workingDir := filepath.Join(home, "working", "disc-test-pp-cli")
+	require.NoError(t, os.MkdirAll(workingDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(workingDir, "main.go"),
+		[]byte("package main\nfunc main() {}"), 0o644))
+
+	state := NewState("disc-test", workingDir)
+	require.NoError(t, os.MkdirAll(filepath.Dir(state.StatePath()), 0o755))
+	require.NoError(t, state.Save())
+
+	// Create research, proofs, and discovery dirs with test content
+	require.NoError(t, os.MkdirAll(state.ResearchDir(), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(state.ResearchDir(), "brief.md"), []byte("brief"), 0o644))
+
+	require.NoError(t, os.MkdirAll(state.DiscoveryDir(), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(state.DiscoveryDir(), "sniff-report.md"), []byte("report"), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(state.DiscoveryDir(), "sniff-unique-paths.txt"), []byte("/api/v1\n/api/v2"), 0o644))
+
+	archiveDir, err := ArchiveRunArtifacts(state)
+	require.NoError(t, err)
+	assert.DirExists(t, archiveDir)
+
+	// Verify discovery/ was copied
+	archivedDiscovery := ArchivedDiscoveryDir(state.APIName, state.RunID)
+	assert.DirExists(t, archivedDiscovery)
+	report, err := os.ReadFile(filepath.Join(archivedDiscovery, "sniff-report.md"))
+	require.NoError(t, err)
+	assert.Equal(t, "report", string(report))
+	paths, err := os.ReadFile(filepath.Join(archivedDiscovery, "sniff-unique-paths.txt"))
+	require.NoError(t, err)
+	assert.Equal(t, "/api/v1\n/api/v2", string(paths))
+
+	// Verify research/ was also copied
+	assert.DirExists(t, ArchivedResearchDir(state.APIName, state.RunID))
+}
+
+func TestArchiveRunArtifactsSkipsMissingDiscovery(t *testing.T) {
+	home := setPressTestEnv(t)
+
+	workingDir := filepath.Join(home, "working", "no-disc-pp-cli")
+	require.NoError(t, os.MkdirAll(workingDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(workingDir, "main.go"),
+		[]byte("package main\nfunc main() {}"), 0o644))
+
+	state := NewState("no-disc", workingDir)
+	require.NoError(t, os.MkdirAll(filepath.Dir(state.StatePath()), 0o755))
+	require.NoError(t, state.Save())
+
+	// Create only research/, no discovery/
+	require.NoError(t, os.MkdirAll(state.ResearchDir(), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(state.ResearchDir(), "brief.md"), []byte("brief"), 0o644))
+
+	archiveDir, err := ArchiveRunArtifacts(state)
+	require.NoError(t, err)
+	assert.DirExists(t, archiveDir)
+
+	// Verify discovery/ was NOT created (silently skipped)
+	archivedDiscovery := ArchivedDiscoveryDir(state.APIName, state.RunID)
+	_, err = os.Stat(archivedDiscovery)
+	assert.True(t, os.IsNotExist(err), "discovery/ should not exist when source is absent")
+
+	// Research should still be archived
+	assert.DirExists(t, ArchivedResearchDir(state.APIName, state.RunID))
+}
+
 func TestDetectSpecFormat(t *testing.T) {
 	tests := []struct {
 		name     string
diff --git a/internal/pipeline/paths.go b/internal/pipeline/paths.go
index 0d35a5dd..47dc4328 100644
--- a/internal/pipeline/paths.go
+++ b/internal/pipeline/paths.go
@@ -93,6 +93,10 @@ func RunPipelineDir(runID string) string {
 	return filepath.Join(RunRoot(runID), "pipeline")
 }
 
+func RunDiscoveryDir(runID string) string {
+	return filepath.Join(RunRoot(runID), "discovery")
+}
+
 func PublishedLibraryRoot() string {
 	return filepath.Join(PressHome(), "library")
 }
@@ -117,6 +121,10 @@ func ArchivedPipelineDir(apiName, runID string) string {
 	return filepath.Join(ArchivedManuscriptDir(apiName, runID), "pipeline")
 }
 
+func ArchivedDiscoveryDir(apiName, runID string) string {
+	return filepath.Join(ArchivedManuscriptDir(apiName, runID), "discovery")
+}
+
 func ArchivedManifestPath(apiName, runID string) string {
 	return filepath.Join(ArchivedManuscriptDir(apiName, runID), "manifest.json")
 }
diff --git a/internal/pipeline/paths_test.go b/internal/pipeline/paths_test.go
index 3f17cbe9..74f8083c 100644
--- a/internal/pipeline/paths_test.go
+++ b/internal/pipeline/paths_test.go
@@ -21,8 +21,10 @@ func TestWorkspacePathsUseScopedPressHome(t *testing.T) {
 	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "runs", "run-123", "research"), RunResearchDir("run-123"))
 	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "runs", "run-123", "proofs"), RunProofsDir("run-123"))
 	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "runs", "run-123", "pipeline"), RunPipelineDir("run-123"))
+	assert.Equal(t, filepath.Join(home, ".runstate", "atlanta-v1-deadbeef", "runs", "run-123", "discovery"), RunDiscoveryDir("run-123"))
 	assert.Equal(t, filepath.Join(home, "library"), PublishedLibraryRoot())
 	assert.Equal(t, filepath.Join(home, "manuscripts", "notion", "run-123"), ArchivedManuscriptDir("notion", "run-123"))
+	assert.Equal(t, filepath.Join(home, "manuscripts", "notion", "run-123", "discovery"), ArchivedDiscoveryDir("notion", "run-123"))
 	assert.Equal(t, filepath.Join(home, "workspaces", "atlanta-v1-deadbeef", "manuscripts", "notion", "research"), ResearchDir("notion"))
 	assert.Equal(t, filepath.Join(home, "workspaces", "atlanta-v1-deadbeef", "manuscripts", "notion", "proofs"), ProofsDir("notion"))
 }
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 16861901..88962ce6 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -134,6 +134,7 @@ func ArchiveRunArtifacts(state *PipelineState) (string, error) {
 		{src: state.ResearchDir(), dst: ArchivedResearchDir(state.APIName, state.RunID)},
 		{src: state.ProofsDir(), dst: ArchivedProofsDir(state.APIName, state.RunID)},
 		{src: state.PipelineDir(), dst: ArchivedPipelineDir(state.APIName, state.RunID)},
+		{src: state.DiscoveryDir(), dst: ArchivedDiscoveryDir(state.APIName, state.RunID)},
 	}
 
 	for _, item := range pairs {
diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
index bb52b235..5b5287bf 100644
--- a/internal/pipeline/state.go
+++ b/internal/pipeline/state.go
@@ -566,6 +566,10 @@ func (s *PipelineState) ProofsDir() string {
 	return RunProofsDir(s.RunID)
 }
 
+func (s *PipelineState) DiscoveryDir() string {
+	return RunDiscoveryDir(s.RunID)
+}
+
 func (s *PipelineState) ManifestPath() string {
 	return RunManifestPath(s.RunID)
 }
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index bcd9ccde..d143dbc5 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -158,6 +158,7 @@ API_RUN_DIR="$PRESS_RUNSTATE/runs/$RUN_ID"
 RESEARCH_DIR="$API_RUN_DIR/research"
 PROOFS_DIR="$API_RUN_DIR/proofs"
 PIPELINE_DIR="$API_RUN_DIR/pipeline"
+DISCOVERY_DIR="$API_RUN_DIR/discovery"
 STAMP="$(date +%Y-%m-%d-%H%M%S)"
 
 mkdir -p "$RESEARCH_DIR" "$PROOFS_DIR" "$PIPELINE_DIR"
@@ -526,7 +527,8 @@ Open a headless browser session, then visit each page and collect API URLs using
 
 ```bash
 # Start collection
-SNIFF_URLS="$API_RUN_DIR/sniff-urls.txt"
+mkdir -p "$DISCOVERY_DIR"
+SNIFF_URLS="$DISCOVERY_DIR/sniff-urls.txt"
 > "$SNIFF_URLS"
 
 # For EACH target page (run this loop in foreground — do NOT use run_in_background):
@@ -554,7 +556,7 @@ Replace `<api-domain-1>`, `<api-domain-2>` etc. with the API domains discovered
 After collecting from all pages:
 ```bash
 # Strip query parameters and deduplicate to find unique API path patterns
-cat "$SNIFF_URLS" | sed 's/\?.*//' | sort -u > "$API_RUN_DIR/sniff-unique-paths.txt"
+cat "$SNIFF_URLS" | sed 's/\?.*//' | sort -u > "$DISCOVERY_DIR/sniff-unique-paths.txt"
 ```
 
 **Step 2a.4: Generate enriched capture**
@@ -607,23 +609,23 @@ If browser-use is not available, use agent-browser with Claude driving the explo
    # Apply sniff pacing between response body fetches
    # These are direct API calls and most likely to trigger rate limits
    ```
-   Combine HAR metadata + response bodies into an enriched capture JSON at `$API_RUN_DIR/sniff-capture.json`.
+   Combine HAR metadata + response bodies into an enriched capture JSON at `$DISCOVERY_DIR/sniff-capture.json`.
 
 4. **Stop HAR recording**:
    ```bash
-   agent-browser network har stop "$API_RUN_DIR/sniff-capture.har"
+   agent-browser network har stop "$DISCOVERY_DIR/sniff-capture.har"
    ```
 
 #### Step 3: Analyze capture
 
 Run websniff on the captured traffic:
 ```bash
-printing-press sniff --har "$API_RUN_DIR/sniff-capture.har" --name <api> --output "$RESEARCH_DIR/<api>-sniff-spec.yaml"
+printing-press sniff --har "$DISCOVERY_DIR/sniff-capture.har" --name <api> --output "$RESEARCH_DIR/<api>-sniff-spec.yaml"
 ```
 
 If using agent-browser's enriched capture format instead:
 ```bash
-printing-press sniff --har "$API_RUN_DIR/sniff-capture.json" --name <api> --output "$RESEARCH_DIR/<api>-sniff-spec.yaml"
+printing-press sniff --har "$DISCOVERY_DIR/sniff-capture.json" --name <api> --output "$RESEARCH_DIR/<api>-sniff-spec.yaml"
 ```
 
 #### Step 4: Report and update spec source
@@ -634,6 +636,27 @@ Update the spec source for Phase 2:
 - **Enrichment mode**: Phase 2 will use `--spec <original> --spec <sniff-spec> --name <api>` to merge both
 - **Primary mode**: Phase 2 will use `--spec <sniff-spec>` directly
 
+#### Step 5: Write sniff discovery report
+
+Write a structured sniff provenance report to `$DISCOVERY_DIR/sniff-report.md`. This report preserves the discovery evidence so a future maintainer can reproduce or extend the sniff.
+
+The report must contain these sections:
+
+1. **Pages Visited** — List every URL browsed during the sniff, in order. Include the page purpose (e.g., "Homepage", "Search results for 'stripe'", "Team detail page").
+
+2. **Sniff Configuration** — Backend used (browser-use, agent-browser, or manual HAR), pacing settings (initial delay, final effective rate), and proxy pattern detection result (proxy-envelope detected / not detected, with the proxy URL if applicable).
+
+3. **Endpoints Discovered** — A markdown table with columns: Method, Path, Status Code, Content-Type. One row per unique endpoint observed.
+
+4. **Coverage Analysis** — What resource types were exercised (e.g., "collections, workspaces, teams, categories") and what was likely missed. Compare against the Phase 1 research brief to identify gaps (e.g., "Brief mentions 'flows' but no flow endpoints were discovered during sniff").
+
+5. **Response Samples** — For each unique response shape (keyed by status code + content-type category), include a truncated sample:
+   - JSON/text responses: first 2KB or 100 lines, whichever is smaller
+   - Binary responses (images, protobuf, etc.): skip content, include a metadata note: `Binary response: <content-type>, <size> bytes`
+   - Aim for one sample per unique shape, not one per endpoint
+
+6. **Rate Limiting Events** — Any 429 responses encountered, delays applied, and effective sniff rate achieved (e.g., "Sniffed 7 endpoints at ~1.5 req/s effective rate, one 429 at request #4").
+
 ### If user declines sniff
 
 Proceed with whatever spec source exists. If no spec was found, fall back to `--docs` or ask the user to provide a spec/HAR manually.
@@ -980,12 +1003,23 @@ Skip this phase entirely if the final shipcheck verdict is `hold`. Only proceed
 
 ### Archive manuscripts
 
-The run's research and proofs are in `$API_RUN_DIR/` (runstate). The `publish package` command looks for them at `$PRESS_MANUSCRIPTS/<api>/<run-id>/`. Archive them now so they're available whether the user publishes immediately or later.
+The run's research, proofs, and discovery artifacts are in `$API_RUN_DIR/` (runstate). The `publish package` command looks for them at `$PRESS_MANUSCRIPTS/<api>/<run-id>/`. Archive them now so they're available whether the user publishes immediately or later.
 
 ```bash
 mkdir -p "$PRESS_MANUSCRIPTS/<api>/$RUN_ID"
 cp -r "$RESEARCH_DIR" "$PRESS_MANUSCRIPTS/<api>/$RUN_ID/research" 2>/dev/null || true
 cp -r "$PROOFS_DIR" "$PRESS_MANUSCRIPTS/<api>/$RUN_ID/proofs" 2>/dev/null || true
+
+# Archive discovery artifacts (sniff captures, URL lists, sniff report).
+# Strip response bodies from HAR before archiving to control size.
+if [ -d "$DISCOVERY_DIR" ]; then
+  for har in "$DISCOVERY_DIR"/sniff-capture.har "$DISCOVERY_DIR"/sniff-capture.json; do
+    if [ -f "$har" ] && command -v jq >/dev/null 2>&1; then
+      jq 'del(.log.entries[].response.content.text)' "$har" > "${har}.stripped" 2>/dev/null && mv "${har}.stripped" "$har" || rm -f "${har}.stripped"
+    fi
+  done
+  cp -r "$DISCOVERY_DIR" "$PRESS_MANUSCRIPTS/<api>/$RUN_ID/discovery" 2>/dev/null || true
+fi
 ```
 
 ### Check for existing PR

← 788a696d fix(skills): add cd to publish-repo before gh pr create/edit  ·  back to Cli Printing Press  ·  feat(skills): implement codex delegation mode in printing-pr 99b0768d →