fix(linkedin): stop a loose ask match from suppressing pitch-without-ask

Sixth review round. One finding, reproduced first, plus a help-text nit.

The connection branch was fixed earlier this PR to stop scanning a whole note with
the loose ASK_RE, which raised a false premature-ask on "your post on on-call
rotations". The else branch (dm / inmail / followup) had the same defect running
the other way: there a loose match SUPPRESSES a real finding.

    pitch language, no ask, neutral text        -> warning fires
    pitch language, no ask, "on-call rotations" -> warning silently lost
    pitch language, no ask, "chat feature"      -> warning silently lost
    pitch language, no ask, "demo video"        -> warning silently lost

Any note containing one of ASK_RE's words anywhere lost its pitch-without-ask
warning even with no ask in it. An ask is now the --ask field or meeting-request
framing in the note - the same test the connection branch uses.

That left ASK_RE referenced nowhere, so it is removed rather than kept as a dead
pattern; its rationale moves into the comment on MEETING_ASK_RE, which now records
both directions the defect ran in.

Verified: the three suppressed cases now fire, and three controls stay quiet - a
real ask in --ask, a meeting-shaped ask in the note, and a note with no pitch at
all.

Also: cadence_planner's argparse description did not mention NO_POSTS_AFFORDABLE,
which shares exit 3 with over-budget. Help text only, no behaviour change.

Not changed, deliberately: _read_input's duplication across 11 scripts (the
self-contained-package convention's accepted cost), and the two different
shapes of bare-list validation between pattern_miner and post_performance_analyzer
- both are correct, and churning one to match the other adds diff without
changing behaviour.

Blocking gates green - compileall, check_paths, check_frontmatter,
check_dual_publish, check_model_freshness, check_skill_names, check_plugin_json
--all, derive_counters --check, smoke_scripts 696/696. Adversarial battery 18/18.
Pinned samples unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BswsZp5zrJWFAGU6KWNA1s
This commit is contained in:
Claude 2026-08-26 15:12:57 +00:00
parent 8a16de83c1
commit 81176406e3
No known key found for this signature in database
2 changed files with 14 additions and 10 deletions

View file

@ -58,13 +58,13 @@ PITCH_RE = re.compile(
r"schedule a (call|demo)|are you the right person|decision[- ]maker|"
r"i'?d like to show you|free trial|pricing|proposal)\b", re.I)
ASK_RE = re.compile(r"\b(call|chat|meeting|demo|coffee|zoom|15 min|30 min|hop on|jump on)\b", re.I)
# ASK_RE is deliberately loose and is only safe against the --ask field, where every
# word is already an ask. Scanning a whole connection note with it would flag "your
# post on on-call rotations". This one requires meeting-request framing, so the
# premature-ask rule can read the entire note instead of only the --ask field —
# without which the rule was bypassable by putting the ask in --reason.
# There used to be a loose ASK_RE here (call|chat|meeting|demo|coffee|zoom|...). It
# was only ever safe against the --ask field, where every word is already an ask;
# against a whole note it matched "your post on on-call rotations". Both branches
# below scanned the whole note with it, in opposite directions: the connection branch
# raised a false premature-ask, and the else branch SUPPRESSED a real
# pitch-without-ask. This pattern requires meeting-request framing, so both can read
# the entire note - without which premature-ask was bypassable via --reason.
MEETING_ASK_RE = re.compile(
r"\b(hop|jump|get)\s+on\s+(a|an|the)?\s*(quick\s+)?(call|chat|zoom|meeting)\b"
r"|\b(grab|get)\s+(a\s+)?coffee\b"
@ -142,8 +142,8 @@ def validate(text: str, parts: dict, mtype: str, premium: bool) -> list:
"rate drops.",
"One specific line, one reason, one bounded ask. Everything else is for the reply.")
ask = (parts.get("ask") or "").strip()
if mtype == "connection":
ask = (parts.get("ask") or "").strip()
# Read the assembled note, not just the --ask field: the same ask moved into
# --reason or --specific-line used to pass clean, which made the documented
# "refuses an ask in a first-touch note" guarantee bypassable.
@ -160,7 +160,10 @@ def validate(text: str, parts: dict, mtype: str, premium: bool) -> list:
"Delete it. Nobody has ever bought from a connection request, and the request "
"is the only impression you get.")
else:
if PITCH_RE.search(low) and not ASK_RE.search(low):
# An ask is the --ask field, or meeting-request framing in the note. Matching
# loosely here silently lost the warning on any note that happened to contain
# "on-call", "chat feature" or "demo video". See MEETING_ASK_RE above.
if PITCH_RE.search(low) and not (ask or MEETING_ASK_RE.search(low)):
add("warning", "pitch-without-ask",
"Product language with no clear, bounded ask.",
"Either make the ask explicit and small, or cut the product language.")

View file

@ -225,7 +225,8 @@ def render_human(r: dict) -> str:
def main() -> int:
ap = argparse.ArgumentParser(
description="Size a sustainable LinkedIn week (fits=0 / below-floor=2 / over-budget=3).")
description="Size a sustainable LinkedIn week "
"(fits=0 / below-floor=2 / over-budget or no-posts-affordable=3).")
ap.add_argument("--minutes", type=int, help="Minutes per week you will actually protect.")
ap.add_argument("--stage", choices=sorted(STAGES), default="starting")
ap.add_argument("--target-posts", type=int, default=0,