Skip to content

feat(008)!: email-only outreach with per-vertical offer profiles - #6

Merged
anusbutt merged 6 commits into
mainfrom
008-email-only-profiles
Jul 25, 2026
Merged

feat(008)!: email-only outreach with per-vertical offer profiles#6
anusbutt merged 6 commits into
mainfrom
008-email-only-profiles

Conversation

@anusbutt

Copy link
Copy Markdown
Owner

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, the fb_signal channel-fit signal, and the fb_signal/facebook_url note 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 --profile and 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 -q593 passed, from a 532 baseline. Beyond the suite:

  • SC-003/004 live: a brand-new hvac vertical 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.
  • SC-005 live: a profile with fallback.md deleted exits 1 and the vault directory is never created.
  • SC-006 live: re-ran over four real vault notes (2 sent, 2 approved). status, outcome, ## Log, ## Draft and ## Citations came back byte-identical; the only diff was fb_signal dropping and the now-obsolete ddg-search … facebook source entry disappearing — exactly the FR-024 convergence. Vault/ itself was never touched; all live runs used scratch copies.
  • Packaging verified by building a wheel, confirming all six profile files are inside, and loading the profile from a non-editable install.
  • The duct fallback copy renders byte-identical to the pre-refactor template, so the reference vertical's output did not drift.

Checklist

  • Tests added or updated, and pytest passes locally
  • Change is focused (no unrelated refactors)
  • Secrets are not logged, printed, or committed
  • The safety guarantees in CONTRIBUTING.md are preserved

This PR touches the guarantees directly, so: Facebook — the BLOCKED_HOSTS guard is untouched, and the zero-request assertion is now extended to every command (send and dashboard were 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:

  1. tasks.md T036 asked for legacy orphan keys to be left "untouched", but FR-024 specifies the opposite — a re-processed note converges on the current schema, and fb_signal/facebook_url are machine-owned, not human-owned (FR-023 governs what is preserved). The test is written to the spec and the task text was corrected.
  2. T031 was checked off with its packaging half broken: package-data still listed agent/*.md, a directory removed when the instruction files moved into profiles, and the root profiles/ dir was never packaged. An installed copy therefore shipped no profile at all and the search path's bundled tier could never resolve, contradicting contracts/profile.md and 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_PROFILES or ./profiles/ still shadow it), and notes rendered without a profile now default to tags: [outreach, prospector].

Also note prospector/profiles.py and prospector/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

anusbutt and others added 6 commits July 24, 2026 23:55
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>
@anusbutt
anusbutt merged commit f8a411e into main Jul 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant