Skip to content

feat(marketing): voice lint, [BRIAN:] markers, and missed-slot hold - #1422

Merged
blove merged 3 commits into
mainfrom
blove/marketing-voice-and-slot-hold
Nov 2, 2026
Merged

blove merged 3 commits into
mainfrom
blove/marketing-voice-and-slot-hold

Conversation

@blove

@blove blove commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Three pieces adapted from Hashbrown's marketing automation, reimplemented in Threadplane's queue (no shared code):

  • Voice lint. lint now also warns on machine-sounding words (leverage, seamless, delve, "here's the thing"…), the "not X but Y" cadence, and more than one emoji on X/LinkedIn. Curly apostrophes are normalized first. All warnings, no new blockers.
  • [BRIAN: ...] markers. The /week drafter writes these for facts only Brian has. Lint raises a blocking brian_marker flag that confirm refuses, so the row can't be approved until the marker is filled in with edit.
  • Missed-slot hold. An X or LinkedIn row reaching a tick more than 6h after publishAt goes back to draft unsent, with a missed_slot alert. list shows it as HELD: missed its slot by N hours, and you send it with reschedule + approve --id. This covers resuming after MARKETING_POSTING_ENABLED was off or lifecycle was down. dev.to and blog rows are evergreen and still go out late.
  • Docs: the /week skill (new flags, marker usage, held-post handling, a no-invented-askers rule), docs/gtm/voice.md, and the runbook's rollback section.

Deliberately not ported: Hashbrown's required SHA-pinned images (Threadplane uploads media to Blob at add time, so the bytes are already fixed), and its 3x retry (Threadplane never retries after post(), which avoids double posts).

Verification

  • npx nx run-many -t test,lint,build -p marketing-queue: 129 tests pass.
  • npx nx test lifecycle passes. It wraps the dispatcher.
  • The lint CLI flags a sample draft as brian_marker (blocking), banned_phrase, cadence and emoji. Yesterday's live X post still lints clean.
  • One existing spec changed: the daily credentials re-alert test used a LinkedIn row that waits a full day, which the hold now drafts. It uses a dev.to row instead.

🤖 Generated with Claude Code

Ideas adapted from Hashbrown's marketing service, reimplemented here.

- lint warns on machine-sounding words (leverage, seamless, delve...),
  the "not X but Y" cadence, and more than one emoji on social; curly
  apostrophes are normalized before matching.
- A [BRIAN: ...] marker is a blocking brian_marker flag that confirm
  refuses, so a draft waiting on Brian's input can never be approved.
- An X or LinkedIn row that reaches a tick more than 6 hours after its
  slot goes back to draft unsent with a missed_slot alert, so resuming
  after a pause or outage doesn't post a stale backlog. dev.to and blog
  rows still go out late. list shows held drafts.
- /week skill, voice guide, and runbook document the new flags and
  the held-post recovery; the skill also forbids invented askers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
threadplane Ready Ready Preview Nov 2, 2026 4:14pm UTC

Request Review

blove added a commit to liveloveapp/hashbrown that referenced this pull request Nov 2, 2026
This brings over lessons from Threadplane's production rollout of the
same queue design (cacheplane/threadplane#1422 went the other way).

## Fixes

- **Double-post on a 2xx without an id (X, dev.to).** `postTweet` read
`response.data.id` and `postDevTo` read `response.id`/`url` without
checking them. An empty or partial 2xx body threw a plain `TypeError`,
and the dispatcher retried it with `markRetry`, so a post that was
already live could go out again. Both adapters now throw
`OutcomeUnknownError`, which fails the row with `post_failed` and never
retries. On an X thread, a first tweet with no id stops the thread
there.
- **Rotated X token lost on an image-upload timeout.** `prepareMedia`
races the request against a 20s deadline. If the deadline fired during
`auth.refresh()`, the adapter threw, the tick could finish, and the
function could freeze before the rotated refresh token was saved, which
forces `x-auth` again. The upload now waits for any in-flight refresh to
settle before it fails.
- **Approve accepted drafts that dispatch rejects.** `check`, `import`,
`edit` and `approve` all used the same zod shapes, but the adapter's
`validateDraft` length limits only ran at dispatch, so an over-length
post would fail three times. `approve` now runs `assertApprovable`: the
shape, then the adapter's own rules. A `length` voice issue can no
longer be accepted, and the skill says so.

## Runbook

- Vercel runs crons only on production, so the dry-run week runs against
production and preview isn't exercised.
- The preview growth store can lag on migrations. Check
`growth_schema_migrations` before trusting it.
- You can create a deploy hook with `vercel api`.
- www rejects any alert kind or result key it doesn't know, with a 503
on every tick. Update `marketing-client.ts` in the same PR.
- Which failures are retried: only ones that happened before anything
was sent.

## Checked and already fine

- An X thread that fails after part 1 is already `OutcomeUnknownError`.
The queue's X draft schema also rejects `threadParts`, so queued threads
can't happen today.
- Creates never retry on 5xx. A 4xx is a definite rejection, and the
`on401` refresh comes before the resend.
- LinkedIn: image upload failures happen before the post is created, a
missing `x-restli-id` is outcome-unknown, and a failed first comment is
a warning.
- A row still `publishing` after its lease expires is failed with
"publish outcome unknown" and never re-posted. The lease (5 min) is
longer than `maxDuration` (60s).
- X persists the rotated pair before the new access token is used. No
abort signal reaches `refresh()` apart from the timeout fixed above.
- The four alert codes match between `state.ts` and www's
`OPERATOR_ALERTS`, and tests pin both.

## Verification

- `npx nx test marketing`: 336 passed. The 11 new specs fail against the
old source.
- `npx nx lint marketing`: passed. `tsc --noEmit` on
`tools/marketing/tsconfig.json` and `tsconfig.spec.json`: clean.
- Not run: integration specs (they need a database) and the workspace
`tsc:typecheck` chain. The `core` typecheck fails locally because of an
older `@b4run/sdk` in the shared `node_modules`, which this change
doesn't touch.

Nothing was posted and no production config was changed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@blove
blove merged commit 015c48d into main Nov 2, 2026
36 checks passed
@blove
blove deleted the blove/marketing-voice-and-slot-hold branch November 2, 2026 16:15

This branch was successfully deployed

1 active deployment
Preview – threadplane — 13e3ab26 Deployed Nov 2, 2026 by vercel[bot]
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