feat(008)!: email-only outreach with per-vertical offer profiles - #6
Merged
Conversation
Email is now the tool's only outreach channel (Constitution v7.0.0, Principle I).
Removed:
- `prospector dm` (assisted-manual Messenger delivery), the clipboard helper,
and the dedicated DM ledger + PROSPECTOR_DM_LEDGER config.
- The messenger bucket: a blank / "messenger" / Facebook-URL / unparseable email
field now means NO ADDRESS SUPPLIED rather than a second-channel route.
- The deterministic Messenger DM template and its invariants.
- fb_signal classification, variant selection, and FB link/widget/embed evidence
extraction, plus the DDG Facebook-presence search.
- `fb_signal` / `facebook_url` note frontmatter and the Messenger dashboard
queue.
- Channel, Variant, FbSignal enums and the four Dm* dataclasses.
Behaviour changes:
- Possessive channel claims ("your page", "your inbox") are now rejected
unconditionally by the V13 validator. No channel signal is researched any
more, so no recorded evidence could ever justify such a claim.
- Note frontmatter converges on the current schema. merge_notes emits only
FRONTMATTER_KEYS, so legacy fb_signal/facebook_url keys are dropped on the
next re-run. Human-owned content (status, outcome, ## Log, custom sections)
is preserved exactly, as before.
Unchanged, deliberately: the Meta Pixel sourcing filter in `prospector source`
and fetch.py's Facebook host guard. Removing Facebook as a channel does not
remove the guard — it makes it the only thing constraining the pixel heuristic
(Constitution v7.0.0, Principle II).
Tests: 507 passing (was 532; the delta is coverage of deleted features). ~24
test files were reworked rather than deleted, and tests/unit/test_no_facebook_channel.py
now guards the removal so it cannot silently regress.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A company with no supplied address is no longer routed to a second channel. The pages already fetched for research are searched for a published address; if one is found the company becomes an ordinary email prospect, and if not it is skipped with no note and reported by name in the run summary. - extract_public_email/_plausible_email move from source.py to extract.py so discovery and research share one definition of a usable address. - recover_email() ranks pages contact > about/team > homepage, then document order, so identical research always adopts the same address. It reads only pages already in hand — no extra request, no reachable blocked host. - The adopted address is recorded as EMAIL_PUBLISHED evidence naming the page it came from, and rendered in ## Research, so a wrong adoption is auditable. - NoEmailFound is distinct from a processing failure: nothing went wrong, the company simply cannot be reached. RunSummary gains email_recovered, no_email_skipped and skipped_companies; reconciliation widens accordingly. Domain-match guard (found by a live run, not by the tests): website resolution can land on the wrong site. An invented company was resolved via the DuckDuckGo fallback to an unrelated domain, and recovery adopted a stranger's personal Gmail published there — an address we would then have drafted outreach to. An address is now adopted only when its registrable domain matches the host of the page publishing it. A missed address costs a reported skip; a wrong one costs contacting someone who never published themselves as a business. Tests: 529 passing. tests/unit/test_email_recovery.py pins the precedence, determinism, evidence and domain-guard rules (including the exact live case); tests/integration/test_email_recovery_batch.py proves both end-to-end outcomes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The offer becomes selectable content instead of packaged code (Constitution v7.0.0, Principle VI): adding a vertical needs no code change. - prospector/profiles.py: Profile, search_paths(), discover(), load(). Resolution order is $PROSPECTOR_PROFILES -> ./profiles/ -> packaged, so the operator's own directory always wins and adding a vertical never means editing an installed package. - profiles/duct-cleaning/ is the reference profile: the four instruction files move out of prospector/agent/, plus a new fallback.md (locked template and the invariants that prove it was not paraphrased) and profile.toml (tags, signature, product_url, keywords, banned_claims). - instructions.py now loads from the selected profile's directory; there is no package-wide default, because instruction content is per-vertical. - Validation is a startup pre-flight: missing/empty file, malformed fallback.md, unparseable or incomplete profile.toml, or an oversized instruction assembly each raise ConfigError before any company is processed. A profile is never silently substituted — pitching one vertical's offer to another vertical's prospect is exactly the failure this project refuses. - `run` and `source` gain --profile, with an interactive picker when omitted. A non-interactive run (CI, pipe, cron) FAILS with the available names rather than blocking on a prompt nobody can answer. `source --keyword` now defaults to the profile's first keyword instead of a hardcoded "duct cleaning". Known gap, tracked as T029: SIGNATURE, PRODUCT_URL, AD_CLAIM_SUBSTRINGS and TAGS_LINE are still module constants in draft.py/vault.py and are duplicated in profile.toml. Selecting a different profile therefore changes the drafting INSTRUCTIONS (and so the model's copy) but not yet the signature, promotional link, banned-claim list or note tags. Threading those through the drafting validators touches the honesty-critical path and is left as its own change. Tests: 548 passing. tests/unit/test_profiles.py covers resolution order, discovery, parsed content and every validation failure in the contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the profile system. Nothing about a specific offer is hardcoded any more: draft.py loses SIGNATURE, PRODUCT_URL, SUBJECT_TEMPLATE, EMAIL_TEMPLATE, EMAIL_INVARIANTS and AD_CLAIM_SUBSTRINGS, vault.py loses TAGS_LINE, and the values are threaded from the selected profile through assemble_email / validate_email_draft / build_email_draft, agent_draft's validators, and render_note's tags_line. draft.py now only knows how to assemble and validate. fallback.md gains a mandatory ## Subject section so the subject line is profile content too. Also fixes a packaging defect this work uncovered: package-data still listed agent/*.md, a directory removed when the instruction files moved into profiles/, and the root profiles/ dir was never packaged at all. An installed copy therefore shipped no profile and the search path's bundled tier could never resolve, contradicting contracts/profile.md and FR-016. The reference profile moves into the package and is packaged properly; verified by building a wheel and loading the profile from a non-editable install. Tests: 593 passing, from a 532 baseline. - test_profile_validation.py pins the contract's error strings and proves validation precedes all work (the pipeline is never entered, no vault is created, no HTTP is attempted). No production change was needed for the ordering itself -- profile resolution was already the first statement in both run and source -- so the tests now hold it there, including that a broken profile is reported ahead of a missing input file and ahead of the LLM key check. - test_vault_legacy_notes.py covers FR-023/FR-024: a pre-008 note carrying fb_signal/facebook_url and status: approved keeps its status, outcome, ## Log and custom sections byte-identical while converging on the current schema, and a second re-run is a no-op. Verified live against four real vault notes as well. - test_no_facebook_contact_any_command.py freezes the Meta Pixel filter's defaults (marker triple, GTM budget, CSV columns, pixel-only by default) and extends the zero-Facebook-request assertion to send and dashboard, so the "across every command" guarantee is tested as written. - test_profile_switching.py asserts identical input yields different copy, subject, link, signature and tags under two profiles, the second built from files alone. - A regression test keeps prospector/profiles.py from being shadowed as a namespace package by its new sibling prospector/profiles/ data directory -- a break invisible in a source checkout. BREAKING CHANGE: the bundled reference profile moved from profiles/duct-cleaning/ to prospector/profiles/duct-cleaning/. Operator profiles still resolve from $PROSPECTOR_PROFILES and ./profiles/, either of which shadows the bundled copy. Notes rendered without a profile now default to tags [outreach, prospector] rather than a duct-cleaning line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README: remove the assisted-Messenger usage section, the fb_signal scoring table, the messenger review step and the fb_signal note field; add a Profiles section covering the directory layout, selection, resolution order, adding a vertical, and the pre-flight validation gate. The safety-guarantee table gains three rows that 008 made true -- email is the only channel, no company is silently dropped, a broken profile stops the run -- and the Facebook row now states plainly that Meta Pixel markup on a company's own site is read while the URLs inside it are never requested. The input-format section documents that anything which is not a valid address routes to email recovery rather than a bucket. CONTRIBUTING: restate the non-negotiable guarantees against the v7.0.0 principles by number, drop the assisted-Messenger guarantee, and record that Principle V is retired so a channel-fit signal is not reintroduced without amending the constitution first. Adds a section on profiles as reviewed content: they change what is said, never what the tool may do, they carry no secrets, and a new key ships with its validation in the same PR, because a key that silently defaults is a key that silently ships the wrong copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The checklist still asked reviewers to confirm "Messenger delivery stays human-performed", a guarantee this feature removes along with the channel. Replace it with the current set: email is the only channel, and profiles stay content rather than capability. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Two changes, both driven by what the live vault actually showed.
Facebook stops being a communication channel. Of 114 companies in the vault, 59 sat in the Messenger bucket and not one had a usable Facebook target — the bucket was a failed email lookup wearing a costume. So
prospector dm(feature 007), the messenger bucket, thefb_signalchannel-fit signal, and thefb_signal/facebook_urlnote fields are all removed. In their place, a company with no supplied address gets one email recovery pass over pages the run already fetched (no new requests); if that fails it is skipped and named in the run summary. There is no third outcome — nothing is silently bucketed.Removing the signal costs no honesty: the copy became channel-neutral back in 005, so it already made no claim about a prospect's channels. That is why Principle V (Channel Honesty) is retired in constitution v7.0.0 rather than weakened.
The offer becomes content instead of code. It was hardcoded for duct cleaning across five modules, which put an offer change and a code change on the same risk footing. Now each vertical is a profile directory — drafting instructions, locked fallback copy, signature, promotional link, note tags, sourcing keywords, banned-claim vocabulary — selected with
--profileand validated in full before any company is processed.The Meta Pixel sourcing filter is deliberately unchanged. Reading Facebook-owned markup on a company's own page is not contacting Facebook, and it stays a targeting filter that never reaches a draft.
How it was verified
pytest -q→ 593 passed, from a 532 baseline. Beyond the suite:hvacvertical built from files alone produced notes tagged[outreach, hvac, prospector]with no code change, and a no-email company was named under "no email found" with no note written.fallback.mddeleted exits 1 and the vault directory is never created.sent, 2approved).status,outcome,## Log,## Draftand## Citationscame back byte-identical; the only diff wasfb_signaldropping and the now-obsoleteddg-search … facebooksource entry disappearing — exactly the FR-024 convergence.Vault/itself was never touched; all live runs used scratch copies.Checklist
pytestpasses locallyThis PR touches the guarantees directly, so: Facebook — the
BLOCKED_HOSTSguard is untouched, and the zero-request assertion is now extended to every command (sendanddashboardwere previously uncovered), so the README's "across every command" claim is tested as written. Human-approved sending — the send path is unchanged; approval gating, caps, pacing, identity checks and the ledger are all as they were. Nothing fabricated — citation validation is unchanged; a profile cannot disable it, grant the model tools/network/filesystem, or weaken the locked-fallback rule, and a profile missing its fallback is a startup failure precisely because that template is the honesty floor.Notes for reviewers
Two of the remaining tasks turned out to be wrong rather than merely undone, and reading the spec rather than the task list is what caught both:
tasks.mdT036 asked for legacy orphan keys to be left "untouched", but FR-024 specifies the opposite — a re-processed note converges on the current schema, andfb_signal/facebook_urlare machine-owned, not human-owned (FR-023 governs what is preserved). The test is written to the spec and the task text was corrected.package-datastill listedagent/*.md, a directory removed when the instruction files moved into profiles, and the rootprofiles/dir was never packaged. An installed copy therefore shipped no profile at all and the search path's bundled tier could never resolve, contradictingcontracts/profile.mdand FR-016. Hence the profile relocation in this PR.Breaking changes worth a second look: the bundled profile moved to
prospector/profiles/duct-cleaning/(operator profiles in$PROSPECTOR_PROFILESor./profiles/still shadow it), and notes rendered without a profile now default totags: [outreach, prospector].Also note
prospector/profiles.pyandprospector/profiles/now coexist. The module wins because the data directory has no__init__.py; adding one would silently break every import in an installed copy, so there is a regression test pinning it — that failure mode is invisible in a source checkout.No migration is needed or wanted. The 115 existing notes still carry
fb_signal; each converges the next time its company is processed.Follow-up, not in this PR:
/sp.adr profile-system(profile layout, resolution order, startup validation gate) is suggested and awaiting consent.🤖 Generated with Claude Code