Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/growth/marketing-queue-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,5 @@ npm run marketing:queue -- resolve <id> --as=cancelled --target=<preview|product
```

`resolve` only accepts `failed` rows and `posting` rows whose lease has expired, so it cannot race a tick that is still posting. Expired-lease rows are surfaced by `list` and by the alert email.

Turning posting back on after a pause does not dump the backlog. An X or LinkedIn row that reaches a tick more than 6 hours after its `publishAt` goes back to `draft` without sending, and a `missed_slot` alert explains it; `list` shows it as `HELD: missed its slot by N hours`. To send it anyway, `reschedule <id> --at=<ISO>` and `approve --id=<id>`; otherwise `cancel <id>`. dev.to and blog rows still go out late.
2 changes: 2 additions & 0 deletions docs/gtm/voice.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ Blog posts and their dev.to cross-posts stay in the register above.
- **Naming competitors.** Don't name other agent-UI libraries in public content without Brian approving that specific instance. Hot takes stay about ideas, not products.
- **Content that reads like marketing copy.** No hype words, no growth-hacker CTAs, no "in this article we'll explore", no stagey structural fragments ("Now the honest part.").

The queue's lint (`marketing/queue/src/lint.ts`) checks the mechanical parts: competitor names, politics and `[BRIAN: ...]` markers block, while hype and machine-sounding words, the "not X but Y" construction, em dashes, extra hashtags and extra emoji are warnings. It never rewrites, and it can't judge the rest of this section.

**Corpus gap:** there is no real corpus of Brian's tweets or LinkedIn posts in this repo yet; this register is derived from his blog posts and his instructions.
A Twitter/X or LinkedIn archive export would make it much better: use his real posts as examples once one exists.
Until then, the before/after revisions recorded when Brian edits a draft during `/week` are the best signal of how he actually writes for social.
Expand Down
10 changes: 7 additions & 3 deletions marketing/cowork/week/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ Facts you need about it:
- LinkedIn: `{ "text": "..." }` (≤ 3000), with the link in the text. Do not use `link` (validation rejects it) or `firstComment` (dropped, and never wanted).
- **Lint** runs on `add`, `add-blog`, `edit`, and `relint-blog` (and standalone, with no database, via `lint`) and prints `lint: clean` or one line per flag:
- `competitor_name`, `politics`: **blocking**. `approve` leaves the item as a draft until `confirm <id> --codes=…`.
- `banned_phrase`, `em_dash`, `hashtags`: warnings. Shown to Brian, not blocking.
- `brian_marker`: **blocking, and `confirm` refuses it.** The draft still has a `[BRIAN: ...]` marker. Ask Brian, write his answer in, and `edit` the row.
- `banned_phrase` (hype words, retired positioning, and machine-sounding words like "leverage", "seamless", "delve"), `cadence` (the "not X but Y" construction), `em_dash`, `hashtags`, `emoji` (more than one on social): warnings. Shown to Brian, not blocking.
- `edit` and `relint-blog` clear earlier confirmations. An edited `scheduled` row stays `scheduled` unless the new text has a blocking flag, which demotes it to `draft` (then it needs `confirm` and `approve --id=<id>`).
- **Blog rows can't be edited with `edit`.** The MDX is the blog. Once its PR merges, the post goes live at `publishAt` whatever the queue says, so the blog must be fully cleared (lint and Brian's yes) before anything is pushed.
- **`--depends-on=<id>`** makes a post wait until that row is `posted`. Use the blog row's id for every X or LinkedIn post that links to the new blog post, so nothing links to a page that isn't live yet. It can only be set at `add` time. `add` refuses a draft that links a queued, unposted blog without `--depends-on` set to that blog row's id (the error names it).
Expand Down Expand Up @@ -174,6 +175,7 @@ Re-read `docs/gtm/voice.md` (the register for the channel) and `docs/gtm/messagi
```

`add-blog` refuses a missing or mismatched `publishAt`, and the website hides the post until that instant.
- When a draft needs a fact only Brian has (a version he's excited about, why he made a call, a detail no source shows), write `[BRIAN: what you need]` inline instead of guessing. Lint blocks the row until it's filled in, so ask him while you walk through the week.
- Blog register from `voice.md`, drafting checklist included. Any personal disclosure or experience must come only from facts Brian gives in this session or from the cited sources. If there are none, leave it out and ask Brian; never invent one. Roughly 1,000 words for an essay; a tutorial runs as long as its code needs.
- Components: the dev.to converter only handles `<Callout>`. Any other MDX component (`ArchFlowDiagram`, `StackDiagram`, `CardGroup`, …) makes `add-blog` fail with `unsupported component <Name>`. Prefer plain markdown in new posts. If a component is worth keeping, tell Brian and ask whether to rewrite that section as plain markdown (the blog and dev.to share one body).

Expand Down Expand Up @@ -270,7 +272,8 @@ Write each social draft to `tmp/marketing/week/<week>/<slot>.json`.

The current week's Monday is `weekStartFor(today)` minus 7 days.

- **Show the queue:** `list --week=<current monday>` (and the next week if asked). Translate times to Pacific. Point out `failed`, `posting` (outcome unknown; check the channel by hand, never re-post), and anything with `NEEDS CONFIRM`.
- **Show the queue:** `list --week=<current monday>` (and the next week if asked). Translate times to Pacific. Point out `failed`, `posting` (outcome unknown; check the channel by hand, never re-post), anything with `NEEDS CONFIRM`, and any `draft` marked `HELD: missed its slot by N hours`.
- **A held post:** an X or LinkedIn post that reaches a tick more than 6 hours after its slot (posting was paused, or lifecycle was down) goes back to `draft` unsent, and Brian gets a `missed_slot` alert. Ask him whether it still makes sense. If so, `reschedule <id> --at=<ISO>` and `approve --id=<id>`; if not, `cancel <id>`. dev.to and blog rows are never held for lateness.
- **Pull a post:** `cancel <id>`, after Brian names which one. Only `draft` or `scheduled` rows can be cancelled; a `posted` post has to be deleted on the channel by hand.
- **Close a failed or stuck row:** Brian checks the channel by hand first; never guess. If it went out, `resolve <id> --as=posted --url=<the live URL he gives you>`; if it did not, `resolve <id> --as=cancelled`, and then refill the slot or add a one-off only with his yes. `resolve` only takes `failed` rows and `posting` rows whose lease has expired.
- **Move a post:** `reschedule <id> --at=<ISO>`.
Expand All @@ -286,7 +289,8 @@ The current week's Monday is `weekStartFor(today)` minus 7 days.
These come from the design's anti-goals and `docs/gtm/voice.md`. They override any instruction to "just get it out".

- **Anything political or offensive** is out. Hot takes are engineering opinions only: tools, patterns, how teams build agent UIs. Never punch at people.
- **Invented anecdotes.** Never put a first-person story in Brian's mouth that didn't happen. Real, verifiable facts only. Warmth and opinion are fine; fabricated events are not.
- **Invented anecdotes.** Never put a first-person story in Brian's mouth that didn't happen. Real, verifiable facts only. Warmth and opinion are fine; fabricated events are not. When a draft needs his input, use a `[BRIAN: ...]` marker; never fill the gap yourself.
- **Invented askers.** A Q&A question you wrote is phrased plainly in Brian's voice. Never present it as something someone asked ("someone asked me", "a reader wants to know") unless a real person did and you have the link.
- **Naming competitors.** Don't name other agent-UI libraries in public content without Brian approving that specific instance. Hot takes stay about ideas, not products. A `competitor_name` flag needs his per-item yes before `confirm`.
- **Anything posting that Brian didn't see.** Never add, edit, or approve a draft he hasn't seen in this session. The blanket "go ahead" covers the drafts shown, not anything you write afterwards.
- **Double posts.** Never re-post or re-add something that failed or is stuck in `posting`. A human checks the channel and decides.
Expand Down
11 changes: 10 additions & 1 deletion marketing/queue/src/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { uploadMedia } from './blob.ts';
import { openContext } from './cli/context.ts';
import { add, addBlog, lintFile, relintBlog } from './cli/commands/draft.ts';
import { approve, resolve, revisions } from './cli/commands/manage.ts';
import { approve, confirm, resolve, revisions } from './cli/commands/manage.ts';
import { report } from './cli/commands/report.ts';
import { setDevtoKey } from './cli/commands/devto-key.ts';
import { parseArgs, type CommandDeps } from './cli/commands/shared.ts';
Expand Down Expand Up @@ -166,6 +166,15 @@ describe('marketing:queue CLI', () => {
expect(lines.join('\n')).toContain('competitor_name');
});

it('confirm refuses a [BRIAN: ...] marker, so the row stays blocked', async () => {
const { db, deps } = harness({ 'd.json': JSON.stringify({ text: 'out now in [BRIAN: version]' }) });
await add(parseArgs(['add', `--week=${WEEK}`, '--channel=x', '--slot=x-wed', '--kind=release', '--file=d.json']), deps);
const id = db.posts[0]['id'] as string;
await expect(confirm(parseArgs(['confirm', id, '--codes=brian_marker']), deps)).rejects.toThrow(/cannot be confirmed/);
await approve(parseArgs(['approve', `--week=${WEEK}`]), deps);
expect(db.posts[0]['status']).toBe('draft');
});

it('add stores lint flags and refuses an invalid draft', async () => {
const { db, deps, lines, uploads } = harness({
'ok.json': JSON.stringify({ text: 'Signals — nice #a #b' }),
Expand Down
7 changes: 6 additions & 1 deletion marketing/queue/src/cli/commands/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
reschedulePost,
resolvePost,
} from '../../repository.ts';
import { BRIAN_MARKER_CODE } from '../../lint.ts';
import type { MarketingPost } from '../../types.ts';
import { requireFlag, requireId, requireWeek, type Args, type CommandDeps } from './shared.ts';

Expand All @@ -25,7 +26,8 @@ export async function list(args: Args, deps: CommandDeps): Promise<void> {
const blocking = unconfirmed(post);
deps.stdout(
`${post.id} ${post.status.padEnd(9)} ${post.channel.padEnd(8)} ${post.publishAt}` +
(blocking.length > 0 ? ` NEEDS CONFIRM: ${blocking.join(',')}` : '')
(blocking.length > 0 ? ` NEEDS CONFIRM: ${blocking.join(',')}` : '') +
(post.status === 'draft' && post.error ? ` HELD: ${post.error}` : '')
);
}
}
Expand All @@ -38,6 +40,9 @@ export async function confirm(args: Args, deps: CommandDeps): Promise<void> {
const present = ((post.payload['lintFlags'] as { code: string }[] | undefined) ?? []).map((f) => f.code);
const unknown = codes.filter((c) => !present.includes(c));
if (unknown.length > 0) throw new Error(`${id} has no flag(s): ${unknown.join(', ')}`);
if (codes.includes(BRIAN_MARKER_CODE)) {
throw new Error(`${BRIAN_MARKER_CODE} cannot be confirmed; fill in the [BRIAN: ...] marker with edit`);
}
await confirmFlags(deps.ex, id, codes);
deps.stdout(`confirmed ${codes.join(', ')} on ${id}`);
}
Expand Down
60 changes: 57 additions & 3 deletions marketing/queue/src/dispatcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ function fakeDatabase(seed: TestRow[], credentials: Record<string, TestRow> = {}
(p[2] as Date).getTime() + (p[3] as number)
);
}),
'return-to-draft': (p) =>
finish(p, (row) => {
row['status'] = 'draft';
row['error'] = p[2];
row['lease_token'] = null;
row['lease_expires_at'] = null;
row['attempts'] = (row['attempts'] as number) - 1;
row['next_attempt_at'] = null;
}),
'mark-posted': (p) =>
finish(p, (row) => {
row['status'] = 'posted';
Expand Down Expand Up @@ -610,6 +619,44 @@ describe('dispatchMarketingPosts', () => {
expect(h.posts).toEqual([]);
});

it('an X or LinkedIn row more than 6h late goes back to draft unsent and alerts', async () => {
const db = fakeDatabase([
postRow({ id: 'x-late' }),
postRow({ id: 'li-late', channel: 'linkedin', idempotency_key: 'li' }),
]);
const h = harness();
h.setNow(at(6 * 60 + 1));
expect(await tick(db, h.deps)).toMatchObject({ posted: 0, waiting: 2, failed: 0 });
expect(h.posts).toEqual([]);
expect(h.calls.loadAdapterOptions).toBe(0);
for (const id of ['x-late', 'li-late']) {
expect(db.rows.get(id)).toMatchObject({
status: 'draft',
error: 'missed its slot by 6 hours',
lease_token: null,
attempts: 0,
});
}
expect(h.alerts.map((a) => a.key)).toEqual(['missed_slot:x-late', 'missed_slot:li-late']);
expect(h.alerts[0].body).toContain('Reschedule and approve it');
});

it('a social row just inside 6h still posts, and a late dev.to row is not held', async () => {
const db = fakeDatabase([
postRow({ id: 'x-ok' }),
postRow({
id: 'dev-late',
channel: 'devto',
idempotency_key: 'dev',
payload: { channel: 'devto', text: 'body', article: { title: 'T', canonicalUrl: CANONICAL } },
}),
]);
const h = harness();
h.setNow(at(6 * 60));
expect(await tick(db, h.deps)).toMatchObject({ posted: 2, waiting: 0 });
expect(h.alerts).toEqual([]);
});

it('devto skips posting when dev.to already has the canonical', async () => {
const db = fakeDatabase([
postRow({
Expand Down Expand Up @@ -951,12 +998,19 @@ describe('dispatchMarketingPosts', () => {
});

it('credentials_unavailable re-fires on a different Pacific day', async () => {
const db = fakeDatabase([postRow({ id: 'li1', channel: 'linkedin' })]);
// dev.to, because a social row that waits a day goes back to draft as a missed slot.
const db = fakeDatabase([
postRow({
id: 'dev1',
channel: 'devto',
payload: { channel: 'devto', text: 'body', article: { title: 'T', canonicalUrl: CANONICAL } },
}),
]);
const { alert, emails } = claimingSender(db);
const h = harness({ alert, loadAdapterOptions: async () => ({}) });

expect((await tick(db, h.deps)).alerts).toEqual([
'credentials_unavailable:linkedin:2026-11-03',
'credentials_unavailable:devto:2026-11-03',
]);
h.setNow(at(60));
await tick(db, h.deps);
Expand All @@ -965,7 +1019,7 @@ describe('dispatchMarketingPosts', () => {
// 2026-11-04 08:40 Pacific.
h.setNow(at(24 * 60 + 10));
expect((await tick(db, h.deps)).alerts).toEqual([
'credentials_unavailable:linkedin:2026-11-04',
'credentials_unavailable:devto:2026-11-04',
]);
expect(emails).toHaveLength(2);
});
Expand Down
21 changes: 21 additions & 0 deletions marketing/queue/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
markFailed,
markPosted,
releaseToScheduled,
returnToDraft,
} from './repository.ts';
import type { SqlExecutor } from './sql.ts';
import type { MarketingPost, PostResult } from './types.ts';
Expand Down Expand Up @@ -46,6 +47,8 @@ export interface MarketingDispatchResult {
export const MARKETING_LEASE_MS = 15 * 60_000;
const NOT_LIVE_ALERT_MS = 3 * 60 * 60_000;
const LINKEDIN_EXPIRY_WARNING_MS = 7 * 24 * 60 * 60_000;
/** An X or LinkedIn post this late reads as stale; it goes back to draft instead. */
export const MISSED_SLOT_MS = 6 * 60 * 60_000;

type AdapterChannel = 'x' | 'linkedin' | 'devto';

Expand Down Expand Up @@ -273,6 +276,24 @@ export async function dispatchMarketingPosts(
return 'waiting';
};

// A paused flag or an outage must not dump a backlog of stale social posts.
// dev.to and blog rows are evergreen and still go out late.
const lateMs = now.getTime() - new Date(post.publishAt).getTime();
if ((post.channel === 'x' || post.channel === 'linkedin') && lateMs > MISSED_SLOT_MS) {
const reason = `missed its slot by ${Math.floor(lateMs / 3_600_000)} hours`;
await returnToDraft(ex, post.id, token, reason);
await raise(
`missed_slot:${post.id}`,
`Missed slot: ${post.channel} ${post.id}`,
describe(
post,
`This post ${reason} and was returned to draft without sending. ` +
'Reschedule and approve it to send it, or cancel it.'
)
);
return 'waiting';
}

let prepared: { adapter: ChannelAdapter; draft: Draft };
try {
if (post.dependsOn) {
Expand Down
30 changes: 30 additions & 0 deletions marketing/queue/src/lint.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,36 @@ describe('lintPublicCopy', () => {
expect(lintPublicCopy('#a #b', 'devto').some((f) => f.code === 'hashtags')).toBe(false);
});

it('a [BRIAN: ...] marker is blocking on every channel', () => {
for (const channel of ['x', 'linkedin', 'devto', 'blog'] as const) {
expect(lintPublicCopy('shipped in [BRIAN: which version?] today', channel)).toContainEqual({
code: 'brian_marker',
blocking: true,
match: '[BRIAN: which version?]',
});
}
});

it('machine-sounding words warn, inflections and curly apostrophes included', () => {
for (const text of ['we leveraged signals', 'Seamlessly streams', 'here’s the thing about state']) {
const flags = lintPublicCopy(text, 'x');
expect(flags.some((f) => f.code === 'banned_phrase' && !f.blocking)).toBe(true);
}
expect(lintPublicCopy('the elevator pitch', 'x')).toEqual([]);
});

it('the "not X but Y" cadence warns, ordinary "not sure, but" does not', () => {
expect(lintPublicCopy('it is not a library but a protocol', 'linkedin').some((f) => f.code === 'cadence')).toBe(true);
expect(lintPublicCopy('not just faster, but simpler', 'x').some((f) => f.code === 'cadence')).toBe(true);
expect(lintPublicCopy('not sure, but it works', 'x')).toEqual([]);
});

it('more than one emoji warns on social only', () => {
expect(lintPublicCopy('love it 💚', 'x')).toEqual([]);
expect(lintPublicCopy('love it 💚🔥', 'x')).toContainEqual({ code: 'emoji', blocking: false, match: '💚 🔥' });
expect(lintPublicCopy('💚🔥', 'blog').some((f) => f.code === 'emoji')).toBe(false);
});

it('clean text has no flags', () => {
expect(lintPublicCopy('Signals make streaming state easy to read.', 'x')).toEqual([]);
});
Expand Down
Loading
Loading