[object Object]

← back to Cli Printing Press

fix(skills): enforce sniff gate with marker file contract (#166)

e8aa611cc91077a513328141953567d8677f3489 · 2026-04-11 01:04:21 -0400 · Matt Van Horn

Phase 1.7's soft "you MUST ask" instruction was reasoned around in a recent
combo-CLI run. The model skipped sniff for a user-critical source by citing
"client-rendered needs Playwright", "3-min budget is tight", "we have a
substitute source", and "tooling friction" — all four at once.

Structural fix: Phase 1.7 now writes a sniff-gate.json marker file with one
entry per user-named source. Phase 1.5 reads the marker and HALTs if any
expected source is missing an entry. Skipping the question is no longer
possible from downstream.

Changes:
- Rewrite Phase 1.7 with Enforcement subsection defining the marker file
  contract (location, shape, decision values, mandatory asked_at timestamp)
- Add explicit Banned skip reasons subsection naming the four rationales
  that fired in the recent failure, each with a counter-argument
- Add Combo CLIs subsection for per-source enforcement when the briefing
  names multiple sources; example table shows the flightgoat pattern
- Update "If user approves sniff" and "If user declines sniff" to write
  marker entries as the first step
- Add "Before leaving Phase 1.7" verification step
- Add Phase 1.5 pre-flight check that HARD STOPs on missing marker,
  with legacy-resume leniency for runs started before this change
- Update MANDATORY header before Phase 1.6 to reference the marker contract
- Add pointer note at top of references/sniff-capture.md explaining the
  marker is written by Phase 1.7 before this reference is loaded

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e8aa611cc91077a513328141953567d8677f3489
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date:   Sat Apr 11 01:04:21 2026 -0400

    fix(skills): enforce sniff gate with marker file contract (#166)
    
    Phase 1.7's soft "you MUST ask" instruction was reasoned around in a recent
    combo-CLI run. The model skipped sniff for a user-critical source by citing
    "client-rendered needs Playwright", "3-min budget is tight", "we have a
    substitute source", and "tooling friction" — all four at once.
    
    Structural fix: Phase 1.7 now writes a sniff-gate.json marker file with one
    entry per user-named source. Phase 1.5 reads the marker and HALTs if any
    expected source is missing an entry. Skipping the question is no longer
    possible from downstream.
    
    Changes:
    - Rewrite Phase 1.7 with Enforcement subsection defining the marker file
      contract (location, shape, decision values, mandatory asked_at timestamp)
    - Add explicit Banned skip reasons subsection naming the four rationales
      that fired in the recent failure, each with a counter-argument
    - Add Combo CLIs subsection for per-source enforcement when the briefing
      names multiple sources; example table shows the flightgoat pattern
    - Update "If user approves sniff" and "If user declines sniff" to write
      marker entries as the first step
    - Add "Before leaving Phase 1.7" verification step
    - Add Phase 1.5 pre-flight check that HARD STOPs on missing marker,
      with legacy-resume leniency for runs started before this change
    - Update MANDATORY header before Phase 1.6 to reference the marker contract
    - Add pointer note at top of references/sniff-capture.md explaining the
      marker is written by Phase 1.7 before this reference is loaded
    
    Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 ...26-04-11-001-fix-sniff-gate-enforcement-plan.md | 314 +++++++++++++++++++++
 skills/printing-press/SKILL.md                     | 143 +++++++++-
 skills/printing-press/references/sniff-capture.md  |   2 +
 3 files changed, 453 insertions(+), 6 deletions(-)

diff --git a/docs/plans/2026-04-11-001-fix-sniff-gate-enforcement-plan.md b/docs/plans/2026-04-11-001-fix-sniff-gate-enforcement-plan.md
new file mode 100644
index 00000000..1f0558b9
--- /dev/null
+++ b/docs/plans/2026-04-11-001-fix-sniff-gate-enforcement-plan.md
@@ -0,0 +1,314 @@
+---
+title: "fix: Enforce sniff gate so model always asks before skipping"
+type: fix
+status: completed
+date: 2026-04-11
+---
+
+# fix: Enforce sniff gate so model always asks before skipping
+
+## Overview
+
+The printing-press skill's Phase 1.7 Sniff Gate already says the model MUST ask the user via AskUserQuestion before skipping sniff. The model ignored this in a recent flightgoat run by reasoning around the instruction ("client-rendered needs Playwright, time budget is tight, we have a substitute source"). This plan adds a hard-gate marker file that Phase 1.5 (Absorb Gate) must verify before proceeding, plus per-source enforcement for combo CLIs.
+
+## Problem Frame
+
+Phase 1.7 is a soft rule in prose. The model can rationalize around it, especially when:
+
+1. The target site is client-rendered (needs a real browser)
+2. The 3-minute time budget looks tight before any attempt
+3. Another data source could substitute for the sniff target
+4. The CLI has multiple named sources (combo CLI) and the model decides "one of them is enough"
+
+During the flightgoat run, all four failure modes fired at once. The user explicitly named Kayak's `/direct` matrix as "their magical thing" - the most valuable piece of the whole CLI - and the model unilaterally swapped it out for a FlightAware substitute without asking. This is exactly what the existing MUST-ask language was supposed to prevent.
+
+The current gate has no forcing function. A marker file that Phase 1.5 refuses to proceed without makes the question un-skippable.
+
+## Requirements Trace
+
+- R1. The model cannot proceed to Phase 1.5 (Absorb Gate) without a sniff-gate decision recorded for every user-named source that triggers the gate
+- R2. The decision is recorded in a machine-readable marker file under the run directory
+- R3. Each source gets its own AskUserQuestion when it triggers the gate (one gate per source for combo CLIs)
+- R4. The rationale "too hard to sniff / time budget tight / substitute source exists" is explicitly banned as a skip justification
+- R5. The model cannot write the research brief until every applicable source has a marker entry
+- R6. Skip conditions that DO silently bypass the gate (spec complete, user passed `--har` or `--spec`, `SNIFF_TARGET_URL` set) also write a marker entry so the same check works uniformly
+- R7. Backward compatible with existing runs - new field, not a schema change
+
+## Scope Boundaries
+
+- Fixes the printing-press SKILL.md prose and adds a marker file contract - does NOT change the `printing-press` Go binary
+- Does NOT change Phase 1.6 (Pre-Sniff Auth Intelligence) or Phase 1.8 (Crowd Sniff Gate)
+- Does NOT change what sniffing does once approved - only the decision point
+- Does NOT retroactively fix the flightgoat CLI that already shipped
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `skills/printing-press/SKILL.md` lines 603-677 - current Phase 1.7 Sniff Gate prose
+- `skills/printing-press/SKILL.md` line 557 - existing "MANDATORY" enforcement for Phase 1.5 that this plan will extend
+- `skills/printing-press/SKILL.md` Phase 0 "SNIFF_TARGET_URL" flag pattern - shows how run-state variables flow through phases
+- `skills/printing-press/references/sniff-capture.md` - detailed capture instructions, referenced but not modified by this plan
+- `$PRESS_RUNSTATE/runs/$RUN_ID/` - existing run directory structure where the marker file will live
+
+### Institutional Learnings
+
+- The model already ignores soft "MUST" language when it has plausible-sounding technical reasoning. The fix has to be structural, not another strongly-worded sentence.
+- Marker files work in this codebase - the run directory already holds `state.json`, `research.json`, and lock files. Adding `sniff-gate.json` fits the pattern.
+- Per-source questions for combo CLIs matter because the user's value is often concentrated in one specific source (Kayak /direct was flightgoat's heart)
+
+## Key Technical Decisions
+
+- **Hard gate via marker file**: Write `$PRESS_RUNSTATE/runs/$RUN_ID/sniff-gate.json` with one entry per source. Phase 1.5 reads the file and refuses to proceed if any expected source is missing an entry. Chosen over prose-only because the flightgoat failure shows prose alone is insufficient.
+- **Per-source gate for combo CLIs**: When the user names multiple sources, emit one AskUserQuestion per source with a gap. Chosen over a single multiSelect question because each source has different answers/rationales and a single question is easy to skip uniformly.
+- **Banned-reasons list**: Add an explicit block of reasoning the model may NOT use to skip sniff: client-rendering difficulty, time budget pre-judgment, substitute-source availability, tooling install friction. Strictly additive guardrail that complements the marker file.
+- **Silent-skip paths also write a marker**: When the gate skips silently (spec complete, `--har`, `--spec`, `SNIFF_TARGET_URL`), write a marker with `decision: "skip-silent", reason: "<why>"`. This way Phase 1.5's check is a single uniform read rather than conditional logic.
+- **Marker schema is additive**: If a run doesn't have `sniff-gate.json`, Phase 1.5 treats it as a soft warning on resumes only (doesn't HARD-FAIL on runs that started before this change). New runs always write it.
+
+## Open Questions
+
+### Resolved During Planning
+
+- Q: Should the marker track just decisions or also outcomes? A: Decisions only. Outcomes live in existing proof files.
+- Q: Does crowd sniff (Phase 1.8) also need a marker? A: No, out of scope. Phase 1.8 has different failure modes and is less user-facing.
+- Q: What if the user declines all sniff options for all sources? A: Valid outcome. Marker records "declined" for each, Phase 1.5 proceeds.
+
+### Deferred to Implementation
+
+- Q: Exact JSON schema for `sniff-gate.json` - will be finalized while writing the prose, keeping it minimal (source_name, decision, reason, timestamp)
+- Q: How strictly Phase 1.5 should fail on a missing marker on resumes of old runs - the implementer can tune between warn-and-continue and hard-fail once the prose is written
+
+## 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.*
+
+Gate state machine per source:
+
+```
+research complete for source
+        |
+        v
+gap detected for source?
+        |
+    +---+---+
+    |       |
+    no      yes
+    |       |
+    v       v
+marker    MUST ask user via AskUserQuestion
+write:    (per-source, named options)
+skip-     |
+silent    +---+---+---+
+          |   |   |
+        approve decline alt-source
+          |   |   |
+          v   v   v
+        marker write: decision + reason
+          |
+          v
+all sources have markers?
+          |
+        +-+-+
+        |   |
+        no  yes
+        |   |
+        v   v
+      HALT  proceed to Phase 1.5
+```
+
+Marker file shape (directional):
+
+```json
+{
+  "run_id": "20260411-000903",
+  "sources": [
+    {
+      "source_name": "kayak-direct",
+      "decision": "approved|declined|skip-silent",
+      "reason": "user chose to sniff / user declined / spec already complete",
+      "asked_at": "2026-04-11T00:10:00Z"
+    }
+  ]
+}
+```
+
+## Implementation Units
+
+- [ ] **Unit 1: Rewrite Phase 1.7 sniff gate prose with hard enforcement**
+
+**Goal:** Replace the existing Phase 1.7 prose with an enforced version that requires a marker file entry for every source before Phase 1.5 can proceed
+
+**Requirements:** R1, R2, R4, R5
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+
+**Approach:**
+- Keep the existing "When to offer sniff" decision matrix (it's correct), keep the "If user approves" and "If user declines" branches
+- Add a new subsection at the top of Phase 1.7 titled "Enforcement" that describes the marker file contract
+- Add a banned-reasons list as a subsection after "When to offer sniff"
+- Every path through the phase (approve, decline, skip-silent) must end with a "write marker entry" instruction
+- Update the "MANDATORY" paragraph before Phase 1.6 (currently at line 557 of SKILL.md) to reference the marker file
+
+**Patterns to follow:**
+- Existing `SNIFF_TARGET_URL` pattern for run-state variables
+- Existing "MANDATORY" enforcement prose at the top of Phase 1.6
+
+**Test scenarios:**
+- Happy path: single-source run, user approves sniff -> marker has one entry with `decision: approved`
+- Happy path: single-source run, user declines -> marker has one entry with `decision: declined`
+- Happy path: `--spec` passed, spec appears complete -> marker has one entry with `decision: skip-silent, reason: spec-complete`
+- Edge case: `--har` passed -> marker has one entry with `decision: skip-silent, reason: har-provided`
+- Edge case: `SNIFF_TARGET_URL` set in Phase 0 -> marker has one entry with `decision: pre-approved-in-phase-0`
+- Error path: model tries to proceed to Phase 1.5 without a marker -> HALT with message pointing back to Phase 1.7
+- Error path: model attempts a banned rationale ("too hard", "budget tight", "substitute exists") -> explicit prose instructs to ask anyway
+
+**Verification:**
+- Re-read the full Phase 1.7 section and confirm every branch writes a marker
+- Confirm the banned-reasons list is present and names the flightgoat failure modes explicitly
+- Confirm Phase 1.5's MANDATORY header references the marker contract
+
+- [ ] **Unit 2: Add per-source enforcement for combo CLIs**
+
+**Goal:** When the initial briefing names multiple sources (e.g., "Google Flights + Kayak + FlightAware"), Phase 1.7 must evaluate each source independently and emit one AskUserQuestion per source with a detected gap
+
+**Requirements:** R1, R3
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+
+**Approach:**
+- Add a subsection in Phase 1.7 titled "Combo CLIs (multiple sources)" after "When to offer sniff"
+- Define the identification rule: if Phase 0's briefing captured >1 named source, each is evaluated separately
+- Source names come from the briefing, not from research inference - the user's exact words are the source identifiers
+- Each source gets its own row in the decision matrix using the same criteria
+- The marker file has one entry per source, all required before Phase 1.5
+- Explicit example using the flightgoat pattern: FlightAware has a spec (skip-silent), Google Flights has fli (offer sniff as enrichment), Kayak /direct has no spec (offer sniff as primary)
+
+**Patterns to follow:**
+- The existing single-source decision matrix structure at line 614
+- The briefing phase at line 142 where user arguments are parsed
+
+**Test scenarios:**
+- Happy path: single-source CLI with a spec -> one marker entry, behavior unchanged from before
+- Happy path: combo CLI with 3 sources, all different spec states -> 3 marker entries, 2-3 AskUserQuestion calls (one per gap)
+- Edge case: combo CLI where user only cares about one source -> still asks for all three, user can decline the others
+- Edge case: user names two sources but second is an alias for the first -> implementer note: source dedup happens in the briefing, not here
+
+**Verification:**
+- A combo CLI run with 3 sources writes a marker with 3 entries
+- Phase 1.5 refuses to proceed if any of the 3 is missing
+
+- [ ] **Unit 3: Explicit banned-reasons list with flightgoat callout**
+
+**Goal:** Enumerate the rationales the model may NOT use to skip the sniff gate, named directly so future models can't reason around them with slightly different words
+
+**Requirements:** R4
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+
+**Approach:**
+- Add a subsection titled "Banned skip reasons" inside Phase 1.7, after the "When to offer sniff" matrix
+- List each banned reason with a brief explanation of why it's invalid:
+  - "Client-rendered site needs Playwright" - browser capture tools (browser-use, agent-browser) handle client rendering, that's what they're for
+  - "3-minute time budget looks tight" - the budget applies AFTER the user approves, not before. Pre-judging the budget is not the model's call
+  - "We have a substitute data source" - substitution is the user's call, not the model's. Ask.
+  - "Tooling installation is friction" - the skill already has an install path for capture tools
+  - "Docs look thorough enough" - the existing prose already covers this; the model must still ask when gaps are detected
+- Include a one-paragraph postmortem note referencing the flightgoat failure by scenario (not by user-identifying data): "A combo-CLI run once skipped the gate for a client-rendered site because all four banned reasons fired at once. The marker file exists so this can't happen again."
+
+**Test scenarios:**
+- Test expectation: none -- this is prose documentation, not behavior. Verification is by re-reading the rendered skill.
+
+**Verification:**
+- All four banned reasons are listed with explanations
+- The postmortem paragraph is present
+- The marker file contract is cross-referenced from this subsection
+
+- [ ] **Unit 4: Update Phase 1.5 header to enforce the marker file**
+
+**Goal:** Phase 1.5 (Absorb Gate) must read the marker file and HALT if any expected source is missing an entry, making the gate un-skippable from downstream
+
+**Requirements:** R1, R5
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+
+**Approach:**
+- Update the existing MANDATORY paragraph before Phase 1.6 (line 557) to also reference the marker file
+- Add a new instruction at the very top of Phase 1.5 titled "Pre-flight check: sniff-gate marker"
+- Instruction: read `$PRESS_RUNSTATE/runs/$RUN_ID/sniff-gate.json`. If missing, HALT with "Phase 1.7 Sniff Gate did not record a decision. Return to Phase 1.7." If the file exists but is missing an entry for a source named in the briefing, HALT with "Sniff Gate missing decision for source `<name>`."
+- Do not proceed to Phase 1.5's Step 1.5a until the check passes
+
+**Patterns to follow:**
+- The existing "HARD STOP" pattern in Phase 1.9 (API Reachability Gate) for how to frame blocking checks
+
+**Test scenarios:**
+- Happy path: marker file exists with all expected entries -> Phase 1.5 proceeds
+- Error path: marker file missing -> HALT with clear instruction to return to Phase 1.7
+- Error path: marker file exists but missing an entry for a user-named source -> HALT with specific source name in the error
+- Edge case: marker file exists with an unknown source (one the user named but briefing did not record) -> warn, do not halt (allow user-driven additions)
+
+**Verification:**
+- A dry-run through Phase 1.5 with no marker file produces the HALT message
+- A dry-run with a correctly populated marker proceeds
+
+- [ ] **Unit 5: Update sniff-capture.md reference to mention the marker contract**
+
+**Goal:** The sniff-capture reference file should briefly note that Phase 1.7's gate writes a marker file, so implementers reading the reference understand the control flow
+
+**Requirements:** R2
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `skills/printing-press/references/sniff-capture.md`
+
+**Approach:**
+- Add a short note at the top of sniff-capture.md: "This file documents what happens AFTER Phase 1.7 decides to sniff. The decision itself (approved, declined, skip-silent) is recorded in `$PRESS_RUNSTATE/runs/$RUN_ID/sniff-gate.json` by Phase 1.7 before this reference is used."
+- No behavioral changes, just a pointer
+
+**Test scenarios:**
+- Test expectation: none -- pure documentation pointer, no behavior change.
+
+**Verification:**
+- The note is present at the top of sniff-capture.md
+
+## System-Wide Impact
+
+- **Interaction graph:** Phase 1.7 -> writes marker -> Phase 1.5 reads marker. Phase 1.6 (Pre-Sniff Auth) is unchanged. Phase 1.8 (Crowd Sniff) is unchanged but its behavior stays independent.
+- **Error propagation:** A missing marker file in Phase 1.5 produces an explicit HALT. The model cannot silently skip forward.
+- **State lifecycle risks:** Resumes of runs started before this change won't have a marker. The Phase 1.5 check should warn-and-continue for legacy resumes, HARD-FAIL for new runs. Distinguish via presence of `sniff-gate.json` adjacent files in the run dir.
+- **API surface parity:** No generator or CLI template changes. Only the skill's prose and the marker file contract change. Generated CLIs are not affected.
+- **Integration coverage:** Because this is skill-level prose, the main integration test is a re-run of a combo CLI scenario. Pick a test API with multiple sources, confirm the gate fires per-source, confirm the marker file is written, confirm Phase 1.5 enforces it.
+- **Unchanged invariants:** The sniff capture flow itself (sniff-capture.md), the generator, the CLI templates, the library repo, and all already-shipped CLIs are untouched.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Model rewrites the marker file to fake approvals | The banned-reasons subsection explicitly calls this out as cheating. Marker includes `asked_at` timestamp that Phase 1.5 can sanity-check against the run's start time. |
+| Legacy runs without markers fail on resume | The check distinguishes new-run vs resume. Resumes warn-and-continue; new runs hard-fail. |
+| Per-source matrix creates too many questions for large combo CLIs (say 5+ sources) | In practice combo CLIs are 2-4 sources. Above that, the briefing itself should push back. Out of scope for this plan. |
+| The skill is too long already - adding more prose makes it harder to follow | Offset by the banned-reasons list being short and the marker contract being mostly a 5-line JSON example |
+
+## Documentation / Operational Notes
+
+- After shipping, re-run a combo CLI scenario end-to-end to verify the gate fires correctly
+- Update the printing-press retro template to include a "sniff gate compliance" check
+- No changes needed to published CLIs or the library repo
+
+## Sources & References
+
+- Origin: user session feedback after flightgoat run on 2026-04-11 - model skipped Kayak /direct sniff despite user naming it as the key feature
+- Related code: `skills/printing-press/SKILL.md` Phase 1.7 (lines 603-677), Phase 1.5 MANDATORY header (line 557)
+- Related references: `skills/printing-press/references/sniff-capture.md`
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index ad0710b0..07bd1919 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -556,6 +556,8 @@ Suggested shape:
 
 **MANDATORY: Before proceeding to Phase 1.5 (Absorb Gate), you MUST evaluate Phase 1.6 (Pre-Sniff Auth Intelligence), Phase 1.7 (Sniff Gate), and Phase 1.8 (Crowd Sniff Gate) below.** If no spec source has been resolved yet (no `--spec`, no `--har`, no catalog spec URL), the sniff gate decision matrix MUST be evaluated. Do not skip to Phase 1.5.
 
+**Phase 1.5 will refuse to proceed without a `sniff-gate.json` marker file.** Phase 1.7 writes this file with one entry per source (one entry for single-source CLIs, one entry per named source for combo CLIs). Missing marker = HARD STOP back to Phase 1.7. See Phase 1.7 "Enforcement" below for the contract.
+
 ## Phase 1.6: Pre-Sniff Auth Intelligence
 
 After Phase 1 research completes, analyze findings to proactively assess what auth context the user could provide. This step uses research intelligence to ask the right question before sniffing starts, rather than waiting for the user to volunteer "I logged in."
@@ -602,28 +604,109 @@ Set `AUTH_SESSION_AVAILABLE=true` if the user selects option 1 or 2. The Sniff G
 
 ## Phase 1.7: Sniff Gate
 
-After Phase 1 research, evaluate whether sniffing the live site would improve the spec. Skip this gate entirely if the user already passed `--har` or `--spec` (spec source is already resolved). If `SNIFF_TARGET_URL` is set (user chose "The website itself" in Phase 0), skip the decision matrix and go directly to "If user approves sniff" — the user already approved, and `SNIFF_TARGET_URL` is the starting URL.
+After Phase 1 research, evaluate whether sniffing the live site would improve the spec. This phase MUST produce a decision marker file for every source named in the briefing before Phase 1.5 can proceed.
+
+### Enforcement: the sniff-gate.json marker file
+
+Phase 1.7 is a hard gate. Phase 1.5 reads a marker file and refuses to proceed without it. The model cannot skip this phase by reasoning around it.
+
+**Marker file location:** `$PRESS_RUNSTATE/runs/$RUN_ID/sniff-gate.json`
+
+**Marker file shape:**
+
+```json
+{
+  "run_id": "20260411-000903",
+  "sources": [
+    {
+      "source_name": "<exact name from briefing, e.g., kayak-direct>",
+      "decision": "approved | declined | skip-silent | pre-approved",
+      "reason": "<one-line justification>",
+      "asked_at": "2026-04-11T00:10:00Z"
+    }
+  ]
+}
+```
+
+**Decision values:**
+
+- `approved` — user selected a sniff option via `AskUserQuestion`. Proceed to "If user approves sniff".
+- `declined` — user explicitly declined sniff via `AskUserQuestion`. Proceed to "If user declines sniff".
+- `skip-silent` — gate was silently skipped per the decision matrix (spec complete, `--har` provided, `--spec` provided, or login required with `AUTH_SESSION_AVAILABLE=false`). The `reason` field names which.
+- `pre-approved` — user already chose "The website itself" in Phase 0, so `SNIFF_TARGET_URL` was set and the question was answered there.
+
+**Every path through Phase 1.7 MUST write a marker entry** — approve, decline, and every silent-skip case. There is no code path that proceeds to Phase 1.5 without writing the marker.
+
+**`asked_at` is mandatory.** It must reflect the actual time `AskUserQuestion` was invoked (or the time the silent-skip decision was made). Fabricated timestamps are a plan violation.
+
+### Banned skip reasons
+
+The following rationales are NOT valid reasons to skip the sniff gate. If any of these apply, you MUST still ask the user via `AskUserQuestion` and record their answer in the marker file:
+
+- **"The target is client-rendered and needs Playwright"** — browser capture tools (browser-use, agent-browser) exist specifically to handle client-rendered sites. Hard-to-sniff is not the same as impossible-to-sniff. Ask.
+- **"The 3-minute time budget looks tight"** — the time budget applies AFTER the user approves sniff, not before. You do not pre-judge whether a sniff will fit the budget. Ask. If the budget blows after the user approves, fall back per the Time Budget rules below.
+- **"We have a substitute data source from another API"** — substituting one source for another is the user's call, not yours. If the user named a specific site or feature (e.g., Kayak /direct), they chose it deliberately. Ask about that exact source. Offering a different data source is a separate conversation AFTER the gate, not a reason to skip it.
+- **"Installing browser-use or agent-browser is friction"** — the sniff capture reference already documents the install path. Tooling friction is not a valid skip reason. Ask.
+- **"The documentation looks thorough enough"** — the decision matrix already handles this case explicitly. If research found that competitors or community projects reference more endpoints than the spec covers, that IS a gap and you MUST ask.
+- **"The user said 'let's go' earlier and implicitly approved everything"** — "let's go" at the briefing stage is consent to proceed with research, not standing approval for every future decision. Ask each gate individually.
+
+These banned reasons all fired at once in a past combo-CLI run and caused a user-critical source to be silently swapped out. The marker file exists so this cannot happen again. If you find yourself writing a phrase like "skipping sniff because X" where X is one of the above, stop and call `AskUserQuestion`.
+
+### Combo CLIs: per-source enforcement
+
+When the briefing names multiple sources (e.g., "Google Flights + Kayak + FlightAware"), each named source is evaluated independently. The marker file has one entry per source. All entries must be present before Phase 1.5 can proceed.
+
+**Source identification rule:** source names come from the briefing, verbatim. Use the user's exact wording as the `source_name` (normalized to kebab-case is fine: "Kayak /direct" → `kayak-direct`, "Google Flights" → `google-flights`, "FlightAware" → `flightaware`). Do not merge sources. Do not drop one in favor of another.
+
+**Per-source decision flow:**
+
+For each named source, run the "When to offer sniff" decision matrix independently, using the research findings for THAT source. Each source produces its own `AskUserQuestion` call or its own silent-skip marker entry.
+
+**Combo CLI example** (flightgoat pattern — directional guidance, not prescription):
+
+| Source | Spec state | Expected decision |
+|--------|------------|-------------------|
+| `flightaware` | Documented OpenAPI spec found (53 endpoints, appears complete) | `skip-silent` with reason `spec-complete` |
+| `google-flights` | No official spec, but community wrapper exists (`fli`) | Ask via `AskUserQuestion` → record user's answer |
+| `kayak-direct` | No spec, no wrapper, user named this as a key feature | Ask via `AskUserQuestion` → record user's answer |
+
+The marker file for this run would contain three entries. Phase 1.5 would HALT if any were missing.
+
+**When the user cares about only one source:** you still ask for all sources that trigger the gate. The user can decline the others. Asking is cheap. Skipping silently breaks the contract.
+
+### Skip this gate entirely when
+
+These are the only cases where Phase 1.7 is bypassed as a whole (not just skipped for one source). Even in these cases, a marker file with a single `skip-silent` entry is written to satisfy Phase 1.5's check:
 
-**IMPORTANT:** When the decision matrix below says "Offer sniff", you MUST ask the user via `AskUserQuestion`. Do NOT silently decide to skip sniff because the docs look thorough — the user should make that call. The only case where you skip silently is "spec appears complete" (no gap detected).
+- User passed `--spec` and the spec is the canonical source for every named source → marker: `{ "source_name": "<api>", "decision": "skip-silent", "reason": "user-provided-spec" }`
+- User passed `--har` → marker: `{ "source_name": "<api>", "decision": "skip-silent", "reason": "user-provided-har" }`
+- `SNIFF_TARGET_URL` is set from Phase 0 (user chose "The website itself") → marker: `{ "source_name": "<api>", "decision": "pre-approved", "reason": "phase-0-website-choice" }`, then go directly to "If user approves sniff"
 
-**Time budget:** The sniff gate should complete within 3 minutes of the user approving sniff. If browser automation tooling fails to produce results after 3 minutes of attempts, fall back immediately:
+### Time budget
+
+The sniff gate should complete within 3 minutes of the user approving sniff. If browser automation tooling fails to produce results after 3 minutes of attempts, fall back immediately:
 - If a spec already exists (enrichment mode): "Sniff failed after 3 minutes — proceeding with existing spec."
 - If no spec exists (primary mode): "Sniff failed after 3 minutes — falling back to --docs generation."
+
 Do NOT spend time debugging tool integration issues. The sniff is optional enrichment, not a blocking requirement. If the first approach fails, fall back to the next option — do not retry the same broken approach.
 
+**The time budget applies AFTER the user approves.** Do not use it as a reason to skip the gate before asking.
+
 ### When to offer sniff
 
 | Spec found? | Research shows gaps? | Auth required? | Action |
 |-------------|---------------------|----------------|--------|
 | Yes | Yes — docs or competitors show significantly more endpoints than the spec | No | **MUST offer sniff as enrichment** |
-| Yes | No — spec appears complete | Any | Skip silently |
+| Yes | No — spec appears complete | Any | Skip silently (write marker with `decision: skip-silent`) |
 | No | Community docs exist (e.g., Public-ESPN-API) | No | **MUST offer sniff OR --docs** — present both options so the user decides |
 | No | No docs found either | No | **MUST offer sniff as primary discovery** |
 | No | N/A | Yes (login) + `AUTH_SESSION_AVAILABLE=true` | **Offer authenticated sniff** — the user confirmed a session in Phase 1.6 |
-| No | N/A | Yes (login) + `AUTH_SESSION_AVAILABLE=false` | Skip — fall back to `--docs` |
+| No | N/A | Yes (login) + `AUTH_SESSION_AVAILABLE=false` | Skip — fall back to `--docs` (write marker with `decision: skip-silent`, `reason: login-required-no-session`) |
 
 **Gap detection heuristic:** If Phase 1 research found documentation, competitor tools, or community projects that reference significantly more endpoints or features than the resolved spec covers, that's a gap signal. Example: "The Zuplo OpenAPI spec has 42 endpoints, but the Public-ESPN-API docs describe 370+."
 
+**When the decision matrix says "Offer sniff", you MUST ask the user via `AskUserQuestion`.** Skipping the question and writing a `skip-silent` marker is a contract violation — `skip-silent` is only valid when the matrix says "Skip silently" or one of the Banned Skip Reasons is the only thing holding you back (in which case, you should be asking anyway).
+
 ### Sniff as enrichment (spec exists but has gaps)
 
 Present to the user via `AskUserQuestion`:
@@ -650,6 +733,19 @@ When `AUTH_SESSION_AVAILABLE=false`, show only options 2-4 (the existing 3-optio
 
 ### If user approves sniff
 
+**Before doing anything else, write the marker entry** for this source:
+
+```json
+{
+  "source_name": "<normalized name from briefing>",
+  "decision": "approved",
+  "reason": "<which option they picked, e.g., 'authenticated sniff' or 'sniff and merge'>",
+  "asked_at": "<current ISO8601 timestamp>"
+}
+```
+
+Append it to `$PRESS_RUNSTATE/runs/$RUN_ID/sniff-gate.json` (create the file if it doesn't exist).
+
 #### Step 0: Identify the User Goal
 
 Before building the capture plan, answer one question: **What does the end user of this CLI actually want to do?**
@@ -673,8 +769,25 @@ capture, HAR analysis, and discovery report writing.
 
 ### If user declines sniff
 
+**Write the marker entry** for this source before proceeding:
+
+```json
+{
+  "source_name": "<normalized name from briefing>",
+  "decision": "declined",
+  "reason": "<which option they picked, e.g., 'use existing spec' or 'use docs instead'>",
+  "asked_at": "<current ISO8601 timestamp>"
+}
+```
+
+Append it to `$PRESS_RUNSTATE/runs/$RUN_ID/sniff-gate.json`.
+
 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.
 
+### Before leaving Phase 1.7
+
+Every source named in the briefing must have exactly one entry in `sniff-gate.json`. Before proceeding to Phase 1.8, re-read the marker file and verify the count matches the number of named sources from the briefing. If a source is missing, return to the decision matrix for that source. Phase 1.5 will HALT if this check fails.
+
 ---
 
 ## Phase 1.8: Crowd Sniff Gate
@@ -730,7 +843,25 @@ Proceed with whatever spec source exists. If no spec was found, fall back to `--
 
 THIS IS A MANDATORY STOP GATE. Do not generate until this is complete and approved.
 
-**Pre-check:** If no spec or HAR file has been resolved by this point and Phase 1.7 (Sniff Gate) was not evaluated, STOP. Go back and run the sniff gate decision matrix. The absorb manifest depends on knowing the API surface, which requires a spec.
+### Pre-flight check: sniff-gate marker
+
+Before any absorb work, verify `$PRESS_RUNSTATE/runs/$RUN_ID/sniff-gate.json` exists and contains an entry for every source named in the briefing.
+
+**If the file is missing:** HARD STOP. Print:
+
+> Phase 1.7 Sniff Gate did not record a decision. Return to Phase 1.7 and evaluate the sniff gate for every source named in the briefing.
+
+Do not proceed to Step 1.5a until the file exists.
+
+**If the file exists but is missing an entry for a named source:** HARD STOP. Print:
+
+> Sniff Gate missing decision for source `<name>`. Return to Phase 1.7 and evaluate the decision matrix for that source.
+
+Do not proceed until every briefing source has a marker entry.
+
+**Resume leniency:** If the run was started by an older version of the skill that didn't write markers, warn and continue — do not hard-fail on legacy resumes. Distinguish by checking whether `state.json` predates the marker contract (the marker file didn't exist before 2026-04-11). New runs always hard-fail on a missing marker.
+
+**Pre-check (existing):** If no spec or HAR file has been resolved by this point and Phase 1.7 (Sniff Gate) was not evaluated, STOP. Go back and run the sniff gate decision matrix. The absorb manifest depends on knowing the API surface, which requires a spec.
 
 The GOAT CLI doesn't "find gaps." It absorbs EVERY feature from EVERY tool and then transcends with compound use cases nobody thought of. This phase builds the absorb manifest.
 
diff --git a/skills/printing-press/references/sniff-capture.md b/skills/printing-press/references/sniff-capture.md
index acc7241f..7fb7d567 100644
--- a/skills/printing-press/references/sniff-capture.md
+++ b/skills/printing-press/references/sniff-capture.md
@@ -2,6 +2,8 @@
 
 > **When to read:** This file is referenced by Phase 1.7 of the printing-press skill.
 > Read it when the user approves sniff (browser-use or agent-browser capture of live API traffic).
+>
+> **Context:** This file documents what happens AFTER Phase 1.7 decides to sniff. The decision itself — approve, decline, or silent-skip — is recorded in `$PRESS_RUNSTATE/runs/$RUN_ID/sniff-gate.json` by Phase 1.7 before this reference is loaded. Phase 1.5 refuses to proceed without that marker file. See SKILL.md Phase 1.7 "Enforcement: the sniff-gate.json marker file" for the contract.
 
 ### Cardinal Rules
 

← 32d167a6 chore(main): release 1.3.1 (#165)  ·  back to Cli Printing Press  ·  chore(main): release 1.3.2 (#167) 33d1ba11 →