From 1b456b60e3a198b3ea871f8a91b52d8cbaab0208 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 7 Sep 2026 08:55:51 -0700 Subject: [PATCH] refactor(growth): consolidate shared code and remove unused marketing scaffolding --- .github/workflows/ci.yml | 4 + .gitignore | 1 - apps/growth-research/package.json | 7 +- .../scripts/package-langsmith.mts | 2 +- .../company-pilot/tools/readEvidence.ts | 2 +- .../company-pilot/tools/submitCandidate.ts | 6 +- .../src/{pilot => company}/context.ts | 0 .../src/{pilot => company}/contracts.ts | 0 .../src/{pilot => company}/validation.ts | 0 apps/growth-research/src/pilot/acquisition.ts | 4 +- .../growth-research/src/pilot/agent-runner.ts | 6 +- apps/growth-research/src/pilot/corpus.ts | 4 +- apps/growth-research/src/pilot/fixtures.ts | 2 +- apps/growth-research/src/pilot/runner.ts | 2 +- .../src/production/contracts.ts | 2 +- .../src/production/executor.ts | 4 +- .../src/production/telemetry.ts | 2 +- .../growth-research/src/production/tracing.ts | 2 +- .../src/runtime/model-boundary.ts | 2 +- .../test/model-boundary.spec.ts | 2 +- apps/growth-research/test/packaging.spec.ts | 18 ++- .../test/pilot-acquisition.spec.ts | 3 +- apps/growth-research/test/pilot-agent.spec.ts | 4 +- apps/growth-research/test/pilot-core.spec.ts | 4 +- .../test/pilot-submission-feedback.spec.ts | 2 +- .../test/pilot-telemetry.spec.ts | 2 +- apps/growth-research/test/production.spec.ts | 2 +- apps/lifecycle/README.md | 2 + apps/lifecycle/package.json | 4 +- apps/lifecycle/src/email-keyring.ts | 67 +-------- apps/lifecycle/src/enrichment/dawn-jobs.ts | 2 +- apps/lifecycle/src/enrichment/dawn-result.ts | 2 +- apps/lifecycle/src/enrichment/schema.ts | 11 -- apps/website/scripts/gsc/README.md | 2 + apps/website/src/lib/analytics/server.spec.ts | 75 ---------- apps/website/src/lib/analytics/server.ts | 141 ------------------ apps/website/src/lib/growth/email-keyring.ts | 73 +-------- docs/growth/README.md | 111 ++++++++++++++ eslint.config.mjs | 35 +++++ gtm.md | 49 +++--- libs/growth-capture/README.md | 20 +++ libs/growth-capture/package.json | 11 ++ libs/growth-capture/project.json | 12 ++ .../src}/company-capture.spec.ts | 0 .../growth-capture/src}/company-capture.ts | 0 .../growth-capture/src}/company-fetch.spec.ts | 0 .../growth-capture/src}/company-fetch.ts | 0 .../src}/evidence-extraction.spec.ts | 0 .../growth-capture/src}/firecrawl.spec.ts | 0 .../growth-capture/src}/firecrawl.ts | 0 libs/growth-capture/src/index.ts | 2 + libs/growth-capture/src/schema.ts | 15 ++ libs/growth-capture/tsconfig.json | 10 ++ libs/growth-capture/vitest.config.ts | 8 + libs/growth/README.md | 2 + libs/growth/src/index.ts | 2 +- .../growth/src/lib/campaign-analytics.spec.ts | 102 ------------- libs/growth/src/lib/campaign-analytics.ts | 82 ---------- libs/growth/src/lib/email-keyring.spec.ts | 68 +++++++++ libs/growth/src/lib/email-keyring.ts | 69 +++++++++ marketing/.env.example | 1 + marketing/README.md | 28 ++-- marketing/agent/package.json | 14 -- marketing/agent/project.json | 21 --- marketing/agent/src/index.ts | 25 ---- marketing/agent/tsconfig.json | 5 - marketing/agent/tsconfig.lib.json | 9 -- marketing/assets/README.md | 4 +- marketing/assets/package.json | 2 +- marketing/assets/project.json | 2 +- marketing/assets/src/index.ts | 2 +- marketing/assets/src/templates/card-shell.tsx | 1 - marketing/channels/MANUAL-SMOKE.md | 16 +- marketing/channels/README.md | 15 +- marketing/channels/package.json | 2 +- marketing/channels/project.json | 2 +- marketing/channels/scripts/smoke.ts | 16 +- marketing/channels/src/dry-run.spec.ts | 20 ++- marketing/channels/src/dry-run.ts | 5 +- marketing/channels/src/index.ts | 2 +- marketing/channels/src/types.ts | 2 +- marketing/channels/src/x/auth-cli.ts | 2 +- marketing/channels/src/x/auth.spec.ts | 7 +- marketing/channels/src/x/auth.ts | 4 +- marketing/channels/src/x/post.ts | 2 +- marketing/cowork/README.md | 13 +- marketing/cowork/gtm/SKILL.md | 32 ++-- marketing/cowork/marketing/SKILL.md | 30 ---- marketing/cowork/outbox/dry-runs/.gitignore | 2 - marketing/cowork/outbox/dry-runs/.gitkeep | 0 marketing/metrics/package.json | 14 -- marketing/metrics/project.json | 21 --- marketing/metrics/src/index.ts | 18 --- marketing/metrics/tsconfig.json | 5 - marketing/metrics/tsconfig.lib.json | 9 -- package-lock.json | 116 +++++++------- package.json | 13 +- scripts/ci-scope.mjs | 1 + scripts/ci-scope.spec.mjs | 19 ++- tools/posthog/README.md | 2 + tsconfig.base.json | 8 +- 101 files changed, 645 insertions(+), 933 deletions(-) rename apps/growth-research/src/{pilot => company}/context.ts (100%) rename apps/growth-research/src/{pilot => company}/contracts.ts (100%) rename apps/growth-research/src/{pilot => company}/validation.ts (100%) delete mode 100644 apps/website/src/lib/analytics/server.spec.ts delete mode 100644 apps/website/src/lib/analytics/server.ts create mode 100644 docs/growth/README.md create mode 100644 libs/growth-capture/README.md create mode 100644 libs/growth-capture/package.json create mode 100644 libs/growth-capture/project.json rename {apps/lifecycle/src/enrichment => libs/growth-capture/src}/company-capture.spec.ts (100%) rename {apps/lifecycle/src/enrichment => libs/growth-capture/src}/company-capture.ts (100%) rename {apps/lifecycle/src/enrichment => libs/growth-capture/src}/company-fetch.spec.ts (100%) rename {apps/lifecycle/src/enrichment => libs/growth-capture/src}/company-fetch.ts (100%) rename {apps/lifecycle/src/enrichment => libs/growth-capture/src}/evidence-extraction.spec.ts (100%) rename {apps/lifecycle/src/enrichment => libs/growth-capture/src}/firecrawl.spec.ts (100%) rename {apps/lifecycle/src/enrichment => libs/growth-capture/src}/firecrawl.ts (100%) create mode 100644 libs/growth-capture/src/index.ts create mode 100644 libs/growth-capture/src/schema.ts create mode 100644 libs/growth-capture/tsconfig.json create mode 100644 libs/growth-capture/vitest.config.ts delete mode 100644 libs/growth/src/lib/campaign-analytics.spec.ts delete mode 100644 libs/growth/src/lib/campaign-analytics.ts create mode 100644 libs/growth/src/lib/email-keyring.spec.ts create mode 100644 libs/growth/src/lib/email-keyring.ts delete mode 100644 marketing/agent/package.json delete mode 100644 marketing/agent/project.json delete mode 100644 marketing/agent/src/index.ts delete mode 100644 marketing/agent/tsconfig.json delete mode 100644 marketing/agent/tsconfig.lib.json delete mode 100644 marketing/cowork/marketing/SKILL.md delete mode 100644 marketing/cowork/outbox/dry-runs/.gitignore delete mode 100644 marketing/cowork/outbox/dry-runs/.gitkeep delete mode 100644 marketing/metrics/package.json delete mode 100644 marketing/metrics/project.json delete mode 100644 marketing/metrics/src/index.ts delete mode 100644 marketing/metrics/tsconfig.json delete mode 100644 marketing/metrics/tsconfig.lib.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e03411867..83b57657c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,7 @@ jobs: # node:test suites in scripts/ run in ci-scope and library instead — # see scripts/vite.config.mts for the split. - run: npx nx test scripts + - run: npx nx run-many -t lint test --projects=marketing-assets,marketing-channels --parallel=2 library: name: Library — lint / test / build @@ -267,6 +268,9 @@ jobs: cache: npm - run: npm ci - run: npx nx lint lifecycle + - run: npx nx lint growth-capture + - run: npx nx test growth-capture + - run: npx nx run growth-capture:check - run: npx nx test lifecycle - run: npx nx run lifecycle:check - run: npx nx build lifecycle diff --git a/.gitignore b/.gitignore index bbb32f774..fbba16ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,6 @@ cockpit/**/angular/test-results/ /test-results/ # Marketing channel dry-run simulation outputs (ephemeral; see "simulatedAt") -marketing/channels/marketing/cowork/outbox/dry-runs/ # Env .env diff --git a/apps/growth-research/package.json b/apps/growth-research/package.json index 1a273e044..2ccc1fbcd 100644 --- a/apps/growth-research/package.json +++ b/apps/growth-research/package.json @@ -3,7 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", - "engines": { "node": "24" }, + "engines": { + "node": "24" + }, "dependencies": { "@dawn-ai/cli": "0.8.26", "@dawn-ai/core": "0.8.26", @@ -22,6 +24,7 @@ "devDependencies": { "@dawn-ai/evals": "0.8.26", "@dawn-ai/testing": "0.8.26", - "@dawn-ai/workspace": "0.8.26" + "@dawn-ai/workspace": "0.8.26", + "@threadplane-internal/growth-capture": "0.0.0" } } diff --git a/apps/growth-research/scripts/package-langsmith.mts b/apps/growth-research/scripts/package-langsmith.mts index f51699e77..ba188944b 100644 --- a/apps/growth-research/scripts/package-langsmith.mts +++ b/apps/growth-research/scripts/package-langsmith.mts @@ -47,7 +47,7 @@ async function copySource(root: string, path: string, output: string): Promise createHash('sha256') .update(JSON.stringify({ facts: page.facts, snippets: page.snippets })) diff --git a/apps/growth-research/src/pilot/fixtures.ts b/apps/growth-research/src/pilot/fixtures.ts index cba303eb9..7a53ac179 100644 --- a/apps/growth-research/src/pilot/fixtures.ts +++ b/apps/growth-research/src/pilot/fixtures.ts @@ -1,5 +1,5 @@ import { evidenceHash } from './corpus.js'; -import type { Corpus, PilotCase } from './contracts.js'; +import type { Corpus, PilotCase } from '../company/contracts.js'; function fixture( id: string, facts: string[], diff --git a/apps/growth-research/src/pilot/runner.ts b/apps/growth-research/src/pilot/runner.ts index 1df21c892..0e45f5d11 100644 --- a/apps/growth-research/src/pilot/runner.ts +++ b/apps/growth-research/src/pilot/runner.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { validateCorpus, corpusHash } from './corpus.js'; import type { runAgent } from './agent-runner.js'; import { writeRecord, createReviewPacket } from './reports.js'; -import type { PilotCase } from './contracts.js'; +import type { PilotCase } from '../company/contracts.js'; type Options = { root: string; diff --git a/apps/growth-research/src/production/contracts.ts b/apps/growth-research/src/production/contracts.ts index a233aed97..6f48cd5d5 100644 --- a/apps/growth-research/src/production/contracts.ts +++ b/apps/growth-research/src/production/contracts.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { z } from 'zod'; -import { CandidateSchema, PageSchema } from '../pilot/contracts.js'; +import { CandidateSchema, PageSchema } from '../company/contracts.js'; export const productionGraphId = 'growth_company'; export const requestMaxAgeMs = 120_000; diff --git a/apps/growth-research/src/production/executor.ts b/apps/growth-research/src/production/executor.ts index 402868a46..b48796b75 100644 --- a/apps/growth-research/src/production/executor.ts +++ b/apps/growth-research/src/production/executor.ts @@ -6,8 +6,8 @@ import { drainPilotOperations, PilotStop, withPilotContext, -} from '../pilot/context.js'; -import { validateCandidate } from '../pilot/validation.js'; +} from '../company/context.js'; +import { validateCandidate } from '../company/validation.js'; import type { ClaimStore } from './claims.js'; import { CompanyResultSchema, diff --git a/apps/growth-research/src/production/telemetry.ts b/apps/growth-research/src/production/telemetry.ts index b3ec3ed23..31bd6d436 100644 --- a/apps/growth-research/src/production/telemetry.ts +++ b/apps/growth-research/src/production/telemetry.ts @@ -1,4 +1,4 @@ -import type { PilotEvent } from '../pilot/context.js'; +import type { PilotEvent } from '../company/context.js'; /** Deliberately no prompt, page, candidate, identity, error object or credentials. */ export interface CompanyTelemetry { attemptId: string; diff --git a/apps/growth-research/src/production/tracing.ts b/apps/growth-research/src/production/tracing.ts index 93655ecb2..de553fdb2 100644 --- a/apps/growth-research/src/production/tracing.ts +++ b/apps/growth-research/src/production/tracing.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; -import type { PilotEvent } from '../pilot/context.js'; +import type { PilotEvent } from '../company/context.js'; import type { TelemetrySink } from './telemetry.js'; const EventSchema = z.object({ diff --git a/apps/growth-research/src/runtime/model-boundary.ts b/apps/growth-research/src/runtime/model-boundary.ts index 8abbd7838..934c2ef56 100644 --- a/apps/growth-research/src/runtime/model-boundary.ts +++ b/apps/growth-research/src/runtime/model-boundary.ts @@ -8,7 +8,7 @@ import { recordRejectedSubmission, trackPilotOperation, type PilotEvent, -} from '../pilot/context.js'; +} from '../company/context.js'; export const providerLimits = { maxTokens: 1024, diff --git a/apps/growth-research/test/model-boundary.spec.ts b/apps/growth-research/test/model-boundary.spec.ts index 9e18463c0..33c0c9aff 100644 --- a/apps/growth-research/test/model-boundary.spec.ts +++ b/apps/growth-research/test/model-boundary.spec.ts @@ -2,7 +2,7 @@ import { createServer, type RequestListener, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { afterEach, expect, it, vi } from 'vitest'; import { BoundedChatOpenAI } from '../src/runtime/model-boundary.js'; -import { createPilotContext, withPilotContext } from '../src/pilot/context.js'; +import { createPilotContext, withPilotContext } from '../src/company/context.js'; import { syntheticCorpus } from '../src/pilot/fixtures.js'; let server: Server | undefined; diff --git a/apps/growth-research/test/packaging.spec.ts b/apps/growth-research/test/packaging.spec.ts index f783fda5a..9f5f72cde 100644 --- a/apps/growth-research/test/packaging.spec.ts +++ b/apps/growth-research/test/packaging.spec.ts @@ -50,7 +50,7 @@ describe('standalone LangSmith packaging', () => { const config = JSON.parse(await readFile(path, 'utf8')); config.graphs['/enrichment/company-pilot#agent'] = './.dawn/build/enrichment-company-pilot.ts:graph'; await writeFile(path, JSON.stringify(config)); - for (const file of ['.dawn/build/enrichment-company-pilot.ts', 'src/app/enrichment/company-pilot/index.ts', 'src/production/entry.ts', 'src/pilot/baseline.ts']) { + for (const file of ['.dawn/build/enrichment-company-pilot.ts', 'src/app/enrichment/company-pilot/index.ts', 'src/production/entry.ts', 'src/company/context.ts', 'src/company/contracts.ts', 'src/company/validation.ts', 'src/pilot/baseline.ts', 'src/pilot/acquisition.ts']) { await mkdir(dirname(join(root, file)), { recursive: true }); await writeFile(join(root, file), 'export const privatePilot = true;'); } @@ -59,6 +59,9 @@ describe('standalone LangSmith packaging', () => { expect(await readdir(join(output, 'src/app/enrichment'))).toEqual(['company-pilot', 'research']); expect(JSON.parse(await readFile(join(output, 'langgraph.json'), 'utf8')).graphs).toEqual({ growth_research: graphEntry, growth_company: './src/production/entry.ts:graph' }); await expect(readFile(join(output, 'src/pilot/baseline.ts'))).rejects.toThrow(); + await expect(readFile(join(output, 'src/pilot/acquisition.ts'))).rejects.toThrow(); + expect(await readdir(join(output, 'src/company'))).toEqual(['context.ts', 'contracts.ts', 'validation.ts']); + expect(JSON.parse(await readFile(join(output, 'package.json'), 'utf8'))).not.toHaveProperty('devDependencies'); }); it('normalizes Node 22 to 24 and clears environment file configuration', async () => { const output = await stageLangSmith(await fixture()); @@ -66,6 +69,19 @@ describe('standalone LangSmith packaging', () => { expect(config).toEqual({ graphs: { [publicGraphId]: graphEntry }, env: {}, node_version: '24', api_version: '0.13.4', dependencies: ['.'] }); }); + it('omits the local capture development dependency from the standalone manifest', async () => { + const root = await fixture(); + const path = join(root, 'package.json'); + const manifest = JSON.parse(await readFile(path, 'utf8')); + manifest.devDependencies = { '@threadplane-internal/growth-capture': '0.0.0' }; + await writeFile(path, JSON.stringify(manifest)); + const output = await stageLangSmith(root); + expect(JSON.parse(await readFile(join(output, 'package.json'), 'utf8'))).toEqual({ + name: 'fixture', version: '0.0.0', private: true, type: 'module', + engines: { node: '24' }, dependencies: { '@dawn-ai/core': '0.8.26' }, + }); + }); + it('accepts the explicit pinned Agent Server API version', async () => { const root = await fixture(); const path = join(root, '.dawn/build/langgraph.json'); diff --git a/apps/growth-research/test/pilot-acquisition.spec.ts b/apps/growth-research/test/pilot-acquisition.spec.ts index 1193a12b7..e17edcf58 100644 --- a/apps/growth-research/test/pilot-acquisition.spec.ts +++ b/apps/growth-research/test/pilot-acquisition.spec.ts @@ -1,8 +1,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import { acquireCompanies } from '../src/pilot/acquisition.js'; // Exercise the same internal capture dependency used by pilot acquisition. -// eslint-disable-next-line @nx/enforce-module-boundaries -import * as firecrawl from '../../lifecycle/src/enrichment/firecrawl.js'; +import * as firecrawl from '@threadplane-internal/growth-capture/firecrawl'; afterEach(() => { vi.restoreAllMocks(); diff --git a/apps/growth-research/test/pilot-agent.spec.ts b/apps/growth-research/test/pilot-agent.spec.ts index 3ec7879cf..4d9946f4f 100644 --- a/apps/growth-research/test/pilot-agent.spec.ts +++ b/apps/growth-research/test/pilot-agent.spec.ts @@ -11,7 +11,7 @@ import { withPilotContext, submitCandidate, getPilotContext, -} from '../src/pilot/context.js'; +} from '../src/company/context.js'; import { syntheticCorpus } from '../src/pilot/fixtures.js'; import { runAgent } from '../src/pilot/agent-runner.js'; let sharedMock: @@ -397,7 +397,7 @@ it('authorizes production contexts only under the independent managed gate', asy deadline: 123, }); expect(context.deadline).toBe(123); - const { assertPilotContext } = await import('../src/pilot/context.js'); + const { assertPilotContext } = await import('../src/company/context.js'); await withPilotContext(context, async () => { expect(() => assertPilotContext()).toThrow(/pilot_mode_required/); vi.stubEnv('GROWTH_RESEARCH_PRODUCTION_MODE', 'managed-company-only'); diff --git a/apps/growth-research/test/pilot-core.spec.ts b/apps/growth-research/test/pilot-core.spec.ts index ba64a0e6c..bf8da8d4e 100644 --- a/apps/growth-research/test/pilot-core.spec.ts +++ b/apps/growth-research/test/pilot-core.spec.ts @@ -1,14 +1,14 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { syntheticCorpus } from '../src/pilot/fixtures.js'; import { validateCorpus, corpusHash } from '../src/pilot/corpus.js'; -import { validateCandidate } from '../src/pilot/validation.js'; +import { validateCandidate } from '../src/company/validation.js'; import { createPilotContext, withPilotContext, readEvidence, submitCandidate, countModelRequest, -} from '../src/pilot/context.js'; +} from '../src/company/context.js'; const candidate = { profile: { name: 'Atlas Synthetic', description: null, industry: null }, unknowns: ['description', 'industry'], diff --git a/apps/growth-research/test/pilot-submission-feedback.spec.ts b/apps/growth-research/test/pilot-submission-feedback.spec.ts index f0a28a8d3..8df64803e 100644 --- a/apps/growth-research/test/pilot-submission-feedback.spec.ts +++ b/apps/growth-research/test/pilot-submission-feedback.spec.ts @@ -1,6 +1,6 @@ import { afterEach, expect, it, vi } from 'vitest'; import tool from '../src/app/enrichment/company-pilot/tools/submitCandidate.js'; -import { createPilotContext, withPilotContext } from '../src/pilot/context.js'; +import { createPilotContext, withPilotContext } from '../src/company/context.js'; import { syntheticCorpus } from '../src/pilot/fixtures.js'; afterEach(() => vi.unstubAllEnvs()); it('locates bad citations for repair while retaining the unchanged validation contract', async () => { diff --git a/apps/growth-research/test/pilot-telemetry.spec.ts b/apps/growth-research/test/pilot-telemetry.spec.ts index a35945d46..93fb53c7b 100644 --- a/apps/growth-research/test/pilot-telemetry.spec.ts +++ b/apps/growth-research/test/pilot-telemetry.spec.ts @@ -5,7 +5,7 @@ import { readEvidence, submitCandidate, recordRejectedSubmission, -} from '../src/pilot/context.js'; +} from '../src/company/context.js'; import { syntheticCorpus } from '../src/pilot/fixtures.js'; afterEach(() => vi.unstubAllEnvs()); diff --git a/apps/growth-research/test/production.spec.ts b/apps/growth-research/test/production.spec.ts index 9af0dbf10..aa9fffc4b 100644 --- a/apps/growth-research/test/production.spec.ts +++ b/apps/growth-research/test/production.spec.ts @@ -11,7 +11,7 @@ import { submitCandidate, trackPilotOperation, countModelRequest, -} from '../src/pilot/context.js'; +} from '../src/company/context.js'; import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons'; import { AsyncLocalStorage } from 'node:async_hooks'; import { RunTree } from 'langsmith/run_trees'; diff --git a/apps/lifecycle/README.md b/apps/lifecycle/README.md index 495345cf2..a110553c8 100644 --- a/apps/lifecycle/README.md +++ b/apps/lifecycle/README.md @@ -1,5 +1,7 @@ # Threadplane lifecycle service +Part of [Growth architecture and operations](../../docs/growth/README.md). + This Node 24 service builds Dawn 0.8.26's native Vercel target and adds a thin app-owned service boundary. The Vercel adapter requires the exact `LIFECYCLE_SERVICE_SECRET` bearer token on every Dawn path. Dawn route middleware repeats the same check for execution routes. The service has two database boundaries: diff --git a/apps/lifecycle/package.json b/apps/lifecycle/package.json index bc44d39ba..85f35e7a1 100644 --- a/apps/lifecycle/package.json +++ b/apps/lifecycle/package.json @@ -19,8 +19,8 @@ "@react-email/render": "2.1.0", "@threadplane-internal/growth": "0.0.0", "hono": "4.13.5", - "parse5": "8.0.1", "resend": "6.10.0", - "zod": "4.4.3" + "zod": "4.4.3", + "@threadplane-internal/growth-capture": "0.0.0" } } diff --git a/apps/lifecycle/src/email-keyring.ts b/apps/lifecycle/src/email-keyring.ts index 859c9e570..29e279d38 100644 --- a/apps/lifecycle/src/email-keyring.ts +++ b/apps/lifecycle/src/email-keyring.ts @@ -1,67 +1,8 @@ -import type { EmailHmacKey, EmailHmacKeyring } from './growth.js'; +import { parseEmailHmacKeyring } from './growth.js'; -function version(value: string | undefined): number { - if (!value || !/^\d+$/u.test(value)) { - throw new Error('Growth email HMAC active version is required'); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 32_767) { - throw new Error('Growth email HMAC active version is invalid'); - } - return parsed; -} - -function previousKey(candidate: unknown): EmailHmacKey { - if ( - candidate === null || - typeof candidate !== 'object' || - Array.isArray(candidate) - ) { - throw new Error('Growth email HMAC previous key is invalid'); - } - const record = candidate as Record; - if ( - Object.keys(record).length !== 2 || - typeof record['version'] !== 'number' || - !Number.isSafeInteger(record['version']) || - record['version'] < 1 || - record['version'] > 32_767 || - typeof record['secret'] !== 'string' || - Buffer.byteLength(record['secret'], 'utf8') < 32 - ) - throw new Error('Growth email HMAC previous key is invalid'); - return { version: record['version'], secret: record['secret'] }; -} - -// Keep lifecycle's server-only configuration aligned with collection's keyring. -// Load lazily: disabled activation must not require identity credentials. +// Load lazily so disabled activation does not require identity credentials. export function loadEmailHmacKeyring( environment: Readonly> = process.env -): EmailHmacKeyring { - const secret = environment['GROWTH_EMAIL_HMAC_ACTIVE_SECRET']; - if (!secret || Buffer.byteLength(secret, 'utf8') < 32) { - throw new Error('Growth email HMAC active secret is required'); - } - const active = { - version: version(environment['GROWTH_EMAIL_HMAC_ACTIVE_VERSION']), - secret, - }; - const rawPrevious = environment['GROWTH_EMAIL_HMAC_PREVIOUS_KEYS']; - if (!rawPrevious) return { active }; - let parsed: unknown; - try { - parsed = JSON.parse(rawPrevious) as unknown; - } catch { - throw new Error('Growth email HMAC previous keys are invalid'); - } - if (!Array.isArray(parsed)) - throw new Error('Growth email HMAC previous keys must be an array'); - const previous = parsed.map(previousKey); - const versions = new Set([active.version]); - for (const key of previous) { - if (versions.has(key.version)) - throw new Error('Growth email HMAC key versions must be unique'); - versions.add(key.version); - } - return { active, previous }; +) { + return parseEmailHmacKeyring(environment); } diff --git a/apps/lifecycle/src/enrichment/dawn-jobs.ts b/apps/lifecycle/src/enrichment/dawn-jobs.ts index b388f0933..95d4e02e7 100644 --- a/apps/lifecycle/src/enrichment/dawn-jobs.ts +++ b/apps/lifecycle/src/enrichment/dawn-jobs.ts @@ -19,7 +19,7 @@ import { type GrowthAppJobHandler, type SqlExecutor, } from '../growth.js'; -import { createCompanyCapture } from './company-capture.js'; +import { createCompanyCapture } from '@threadplane-internal/growth-capture'; import { createDawnResearchClient, type DawnResearchClient, diff --git a/apps/lifecycle/src/enrichment/dawn-result.ts b/apps/lifecycle/src/enrichment/dawn-result.ts index 002b23501..b5435e7d4 100644 --- a/apps/lifecycle/src/enrichment/dawn-result.ts +++ b/apps/lifecycle/src/enrichment/dawn-result.ts @@ -7,7 +7,7 @@ import { hashCompanyEvidence, } from '../../../growth-research/src/production/contracts.js'; // eslint-disable-next-line @nx/enforce-module-boundaries -- use the managed candidate validator at publication -import { validateCandidate } from '../../../growth-research/src/pilot/validation.js'; +import { validateCandidate } from '../../../growth-research/src/company/validation.js'; /** Revalidate the remote candidate against the original persisted snapshot. */ export function companyArtifact( diff --git a/apps/lifecycle/src/enrichment/schema.ts b/apps/lifecycle/src/enrichment/schema.ts index 2fefcddaa..7bf1e8af0 100644 --- a/apps/lifecycle/src/enrichment/schema.ts +++ b/apps/lifecycle/src/enrichment/schema.ts @@ -2,16 +2,6 @@ import { z } from 'zod'; const HttpsUrlSchema = z.url({ protocol: /^https$/u }).max(500); -export const CompanyPageEvidenceSchema = z - .object({ - canonicalUrl: HttpsUrlSchema, - retrievedAt: z.iso.datetime(), - contentHash: z.string().regex(/^[a-f0-9]{64}$/u), - facts: z.array(z.string().min(1).max(240)).max(6), - snippets: z.array(z.string().min(1).max(240)).max(6), - }) - .strict(); - const CitedSignalSchema = z .object({ signal: z.string().min(1).max(300), @@ -83,5 +73,4 @@ export const EnrichmentArtifactSchema = z }) .strict(); -export type CompanyPageEvidence = z.infer; export type EnrichmentArtifact = z.infer; diff --git a/apps/website/scripts/gsc/README.md b/apps/website/scripts/gsc/README.md index b82898740..2556bef2f 100644 --- a/apps/website/scripts/gsc/README.md +++ b/apps/website/scripts/gsc/README.md @@ -1,5 +1,7 @@ # Search Console API harness +Part of [Growth architecture and operations](../../../../docs/growth/README.md). + ## One-time setup 1. In Google Cloud console, create (or reuse) a project and enable the diff --git a/apps/website/src/lib/analytics/server.spec.ts b/apps/website/src/lib/analytics/server.spec.ts deleted file mode 100644 index 67bfad99d..000000000 --- a/apps/website/src/lib/analytics/server.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; - -const captureMock = vi.hoisted(() => vi.fn()); -vi.mock('posthog-node', () => ({ - PostHog: vi.fn(function () { - return { - capture: captureMock, - shutdown: vi.fn().mockResolvedValue(undefined), - }; - }), -})); - -beforeEach(() => { - captureMock.mockClear(); - process.env.NEXT_PUBLIC_POSTHOG_TOKEN = 'phc_test'; -}); - -describe('captureLeadQualified', () => { - it('fires marketing:lead_qualified when domain is non-personal and company is non-empty', async () => { - const { captureLeadQualified } = await import('./server'); - await captureLeadQualified({ - email: 'jane@acme.com', - company: 'Acme', - sourcePage: '/pricing', - }); - expect(captureMock).toHaveBeenCalledTimes(1); - const call = captureMock.mock.calls[0][0]; - expect(call.event).toBe('marketing:lead_qualified'); - expect(call.properties).toMatchObject({ - email_domain: 'acme.com', - company: 'Acme', - source_page: '/pricing', - track: 'enterprise', - }); - expect(call.distinctId).toMatch(/^email_sha256:[a-f0-9]{64}$/); - }); - - it('skips when the email domain is personal', async () => { - const { captureLeadQualified } = await import('./server'); - await captureLeadQualified({ - email: 'jane@gmail.com', - company: 'Acme', - sourcePage: '/pricing', - }); - expect(captureMock).not.toHaveBeenCalled(); - }); - - it('skips when company is missing', async () => { - const { captureLeadQualified } = await import('./server'); - await captureLeadQualified({ - email: 'jane@acme.com', - sourcePage: '/pricing', - }); - expect(captureMock).not.toHaveBeenCalled(); - }); - - it('skips when company is blank string', async () => { - const { captureLeadQualified } = await import('./server'); - await captureLeadQualified({ - email: 'jane@acme.com', - company: ' ', - sourcePage: '/pricing', - }); - expect(captureMock).not.toHaveBeenCalled(); - }); - - it('skips when email is malformed', async () => { - const { captureLeadQualified } = await import('./server'); - await captureLeadQualified({ - email: 'not-an-email', - company: 'Acme', - }); - expect(captureMock).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/website/src/lib/analytics/server.ts b/apps/website/src/lib/analytics/server.ts deleted file mode 100644 index e71c0b2c9..000000000 --- a/apps/website/src/lib/analytics/server.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createHash } from 'crypto'; -import { PostHog } from 'posthog-node'; -import { analyticsEvents, type AnalyticsEventName, type AnalyticsProperties, type WhitepaperId } from './events'; -import { getEmailDomain, isPersonalEmailDomain, normalizePostHogHost, toSafeAnalyticsString } from '@threadplane/telemetry/shared'; - -function getServerPostHogClient(): PostHog | null { - const token = toSafeAnalyticsString(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, 500); - if (!token) return null; - - return new PostHog(token, { - host: normalizePostHogHost(process.env.NEXT_PUBLIC_POSTHOG_HOST), - flushAt: 1, - flushInterval: 0, - }); -} - -function getHashedEmailDistinctId(email: unknown): string | null { - const value = toSafeAnalyticsString(email, 320)?.toLowerCase(); - if (!value || !getEmailDomain(value)) return null; - return `email_sha256:${createHash('sha256').update(value).digest('hex')}`; -} - -export async function captureServerEvent({ - distinctId, - event, - properties, -}: { - distinctId: string; - event: AnalyticsEventName; - properties?: AnalyticsProperties; -}) { - const posthog = getServerPostHogClient(); - if (!posthog) return; - - let didShutdown = false; - try { - posthog.capture({ - distinctId, - event, - properties, - }); - await posthog.shutdown(); - didShutdown = true; - } catch (err) { - console.error('[posthog] capture failed:', err); - } finally { - if (!didShutdown) { - await posthog.shutdown().catch(() => undefined); - } - } -} - -export async function captureLeadConversion({ - email, - company, - sourcePage, -}: { - email: string; - company?: string; - sourcePage?: string; -}) { - const distinctId = getHashedEmailDistinctId(email); - if (!distinctId) return; - - await captureServerEvent({ - distinctId, - event: analyticsEvents.marketingLeadFormSuccess, - properties: { - email_domain: getEmailDomain(email) ?? undefined, - company: toSafeAnalyticsString(company, 200), - source_page: sourcePage, - }, - }); -} - -export async function captureLeadQualified({ - email, - company, - sourcePage, -}: { - email: string; - company?: string; - sourcePage?: string; -}) { - const domain = getEmailDomain(email); - if (!domain || isPersonalEmailDomain(domain)) return; - - const safeCompany = toSafeAnalyticsString(company, 200); - if (!safeCompany) return; - - const distinctId = getHashedEmailDistinctId(email); - if (!distinctId) return; - - await captureServerEvent({ - distinctId, - event: analyticsEvents.marketingLeadQualified, - properties: { - email_domain: domain, - company: safeCompany, - source_page: sourcePage, - track: 'enterprise', - }, - }); -} - -export async function captureWhitepaperConversion({ - email, - paper, - sourcePage, -}: { - email: string; - paper: WhitepaperId; - sourcePage?: string; -}) { - const distinctId = getHashedEmailDistinctId(email); - if (!distinctId) return; - - await captureServerEvent({ - distinctId, - event: analyticsEvents.marketingWhitepaperSignupSuccess, - properties: { - email_domain: getEmailDomain(email) ?? undefined, - paper, - source_page: sourcePage, - }, - }); -} - -export async function captureNewsletterConversion({ email, sourcePage }: { email: string; sourcePage?: string }) { - const distinctId = getHashedEmailDistinctId(email); - if (!distinctId) return; - - await captureServerEvent({ - distinctId, - event: analyticsEvents.marketingNewsletterSignupSuccess, - properties: { - email_domain: getEmailDomain(email) ?? undefined, - source_page: sourcePage, - }, - }); -} diff --git a/apps/website/src/lib/growth/email-keyring.ts b/apps/website/src/lib/growth/email-keyring.ts index 751948354..0c82556aa 100644 --- a/apps/website/src/lib/growth/email-keyring.ts +++ b/apps/website/src/lib/growth/email-keyring.ts @@ -1,76 +1,11 @@ import 'server-only'; -// The website intentionally consumes the growth library through its internal boundary. +// The website consumes Growth through its internal boundary. // eslint-disable-next-line @nx/enforce-module-boundaries -import type { - EmailHmacKey, - EmailHmacKeyring, -} from '@threadplane-internal/growth'; - -function version(value: string | undefined): number { - if (!value || !/^\d+$/u.test(value)) { - throw new Error('Growth email HMAC active version is required'); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 32_767) { - throw new Error('Growth email HMAC active version is invalid'); - } - return parsed; -} - -function previousKey(candidate: unknown): EmailHmacKey { - if ( - candidate === null || - typeof candidate !== 'object' || - Array.isArray(candidate) - ) { - throw new Error('Growth email HMAC previous key is invalid'); - } - const record = candidate as Record; - if ( - Object.keys(record).length !== 2 || - typeof record['version'] !== 'number' || - !Number.isSafeInteger(record['version']) || - record['version'] < 1 || - record['version'] > 32_767 || - typeof record['secret'] !== 'string' || - Buffer.byteLength(record['secret'], 'utf8') < 32 - ) { - throw new Error('Growth email HMAC previous key is invalid'); - } - return { version: record['version'], secret: record['secret'] }; -} +import { parseEmailHmacKeyring } from '@threadplane-internal/growth'; export function loadEmailHmacKeyring( environment: Readonly> = process.env -): EmailHmacKeyring { - const secret = environment['GROWTH_EMAIL_HMAC_ACTIVE_SECRET']; - if (!secret || Buffer.byteLength(secret, 'utf8') < 32) { - throw new Error('Growth email HMAC active secret is required'); - } - const active = { - version: version(environment['GROWTH_EMAIL_HMAC_ACTIVE_VERSION']), - secret, - }; - const rawPrevious = environment['GROWTH_EMAIL_HMAC_PREVIOUS_KEYS']; - if (!rawPrevious) return { active }; - - let parsed: unknown; - try { - parsed = JSON.parse(rawPrevious) as unknown; - } catch { - throw new Error('Growth email HMAC previous keys are invalid'); - } - if (!Array.isArray(parsed)) { - throw new Error('Growth email HMAC previous keys must be an array'); - } - const previous = parsed.map(previousKey); - const versions = new Set([active.version]); - for (const key of previous) { - if (versions.has(key.version)) { - throw new Error('Growth email HMAC key versions must be unique'); - } - versions.add(key.version); - } - return { active, previous }; +) { + return parseEmailHmacKeyring(environment); } diff --git a/docs/growth/README.md b/docs/growth/README.md new file mode 100644 index 000000000..b1d66196c --- /dev/null +++ b/docs/growth/README.md @@ -0,0 +1,111 @@ +# Growth architecture and operations + +Growth covers acquisition, publishing, developer progress, company research, +and founder follow-up. This is the current implementation map. `gtm.md` owns +positioning and audience strategy; historical plans describe decisions at the +time they were written, not which services or capabilities exist today. + +## Ownership + +| Location | Owns | Boundary | +| --- | --- | --- | +| `apps/website` | Landing pages, forms, collection HTTP routes, browser engagement | Accept requests and record evidence; do not send campaign email in a browser request | +| `libs/telemetry` | Public SDK capture and development install/runtime collection | Keep the explicit analytics API distinct from development-only Growth collection | +| `libs/growth` | Contacts, authorization, observations, activation, durable jobs, delivery state, reports | Authoritative operational state in Neon; no dependencies on application implementations or publishing tools | +| `libs/growth-capture` | Company page capture, SSRF controls, evidence extraction and diagnostics | Shared server-side capture library; no contacts, outreach policy, or model execution | +| `apps/lifecycle` | Dawn job execution, research orchestration, fulfillment, notifications and founder sequence | Vercel deployment; consumes shared Growth and capture libraries | +| `apps/growth-research` | Dawn company research execution and local evaluation | LangSmith deployment; shared company primitives live in `src/company`, local corpus tooling in `src/pilot` | +| `marketing/assets`, `marketing/channels` | Branded images and manually invoked X/Dev.to publishing | Private operator packages; no contact database access or automatic campaign worker | +| `tools/posthog` | Event contracts, dashboard definitions, quality checks and reporting | Engagement measurement, not authorization or delivery truth | +| `apps/website/scripts/gsc` | Search Console snapshots and search reports | Website-specific operator tooling; local output, no in-repo schedule | +| `scripts/growth-*`, `migrations` | Operational commands and Growth schema changes | Explicit operator entry points; migrations belong to Growth despite their root location | + +Separate runtime deployments remain intentional. The lifecycle application uses +a small documented source bridge for Dawn's native Vercel bundler. Research's +standalone package contains registry dependencies only; the local evaluation +harness and its capture dependency do not enter that deployment. + +One existing source-level exception remains: lifecycle imports research's wire +contracts, candidate validation, settlement-claim reader and trace transport. +These imports share protocol and cleanup behavior without loading research's +graph or model bootstrap. They are explicitly documented at their call sites; +this cleanup does not claim complete source independence between those apps. + +## Data flow and vocabulary + +Social posts may link to the website with campaign UTMs. Website collection +records sanitized campaign metadata. Forms and install/runtime signals enter +Growth's observation and contact flows. Lifecycle leases durable jobs, requests +company capture and Dawn research, and persists the resulting evidence. + +Account intelligence comes from enrichment and observed signals. A submitted +company name or install email is evidence, not verified employment. Intelligence +and progress do not override authorization, reply stops or suppression. + +The **founder sequence** is the once-per-contact three-email lifecycle flow. +A **publishing campaign** is a content brief and its social posts. They do not +share a campaign scheduler. Newsletter signup uses Growth; there is no generic +newsletter broadcast engine, drafting agent, automated publishing approval loop, +or social metrics ingestion worker in this repository. + +The website whitepaper toast is an acquisition form. Development runtime +announcements are a separate collection surface; they do not send email inline. + +Neon is authoritative for contact eligibility and lifecycle outcomes. PostHog +measures engagement. LangSmith traces explain research execution. None should +be treated as a substitute for the other two. + +## Operator entry points + +Run from the repository root with the relevant service configuration available. +Commands do not share a global credential loader: adapters load only their own +configuration, and deployed apps receive secrets through their hosting platforms. + +| Command | Purpose and side effects | +| --- | --- | +| `npm run growth:report -- funnel --from --to ` | Read observation/activation cohorts; range must be positive and at most 31 days | +| `npm run growth:report -- journey --contact ` | Read one contact's recorded journey | +| `npm run growth:control -- status --email
` | Read contact controls; `approve`, `stop` and `delete` are separate explicit mutations | +| `npm run growth:research -- synthetic --output ` | Write a local synthetic evaluation corpus; `acquire` and `run` can call providers | +| `npm run growth:analytics:plan` | Read-only PostHog configuration comparison | +| `npm run growth:analytics:report` | Fetch dashboard results and write a local report for review | +| `npm run growth:analytics:quality -- --days 7` | Read recent event samples and validate the analytics contract | +| `npm run growth:search:pull` / `growth:search:report` | Pull Search Console data, then render local snapshots | + +Existing `growth:observability`, `posthog:*` and `gsc:*` commands remain available. +Publishing/authentication commands and dry-run instructions live in +[the channel guide](../../marketing/channels/README.md). Live publishing is an +explicit operator action; a smoke command without `DRY_RUN=1` can publish. + +## Schedules and measurement limits + +The website Vercel cron invokes lifecycle every minute. Due times live in Growth +jobs. The founder sequence starts the next business morning at 07:00 Pacific; +later steps are three and five business days after the preceding accepted send. +The send window is 07:00–08:00 Pacific, weekdays, with daylight-saving handling. +Requested fulfillment and internal notifications use their own execution rules. +See [lifecycle operations](../../apps/lifecycle/README.md). + +GitHub runs PostHog quality checks daily. Weekly GTM reports are operator-run; +the repository does not install a workstation scheduler. Social publishing and +search reporting also have no in-repo recurring worker. + +The Growth funnel is an observation/activation report, not a complete sequential +anonymous conversion funnel. A UTM is not a proven link from a social post to a +developer identity. Current PostHog report parsing needs separate improvement for +funnels and multiple trend series; do not treat unsupported or missing data as +proof of zero activity. An analytics contract failure does not itself prove a +lifecycle delivery failure. + +## Contributor checks + +Use project-scoped Nx tests, lint and builds. Changes to company primitives must +also pass the standalone LangSmith packaging check. Changes to lifecycle's +shared imports must pass the native Vercel artifact check. Keep graph identifiers +and deployment configuration stable during source-only refactors. + +Relevant guides: [Growth records and reports](../../libs/growth/README.md), +[research](../../apps/growth-research/README.md), +[publishing](../../marketing/README.md), +[analytics](../../tools/posthog/README.md), +[search](../../apps/website/scripts/gsc/README.md). diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e36359f5..d4947db4d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -57,6 +57,41 @@ export default [ // Override or add rules here rules: {}, }, + // Growth libraries own shared server behavior, not application or publishing + // orchestration. Keep deployment-specific imports at the app boundary. + { + files: ['libs/growth/src/**/*.ts', 'libs/growth-capture/src/**/*.ts'], + rules: { + 'no-restricted-imports': ['error', { + patterns: [{ + group: ['**/apps/**', '**/marketing/**', '@threadplane-internal/marketing-*'], + message: 'Shared Growth code must not depend on apps or publishing tools.', + }], + }], + }, + }, + { + files: ['apps/growth-research/src/**/*.ts', 'apps/growth-research/scripts/**/*.mts'], + rules: { + 'no-restricted-imports': ['error', { + patterns: [{ + group: ['**/lifecycle/**', '**/website/**'], + message: 'Research must consume shared libraries rather than another app implementation.', + }], + }], + }, + }, + { + files: ['marketing/**/*.ts', 'marketing/**/*.tsx'], + rules: { + 'no-restricted-imports': ['error', { + patterns: [{ + group: ['**/apps/**', '**/libs/growth/**', '@threadplane-internal/growth'], + message: 'Publishing tools must not access contact or lifecycle internals.', + }], + }], + }, + }, // Inline-style guard — apps/website migrated off static inline styles // (docs/superpowers/specs/2026-08-29-inline-style-substrate-migration-design.md, // batches #848–#857). Flags identifier-keyed members of a style object diff --git a/gtm.md b/gtm.md index a7a07354c..1465ef250 100644 --- a/gtm.md +++ b/gtm.md @@ -2,7 +2,8 @@ > Durable strategy. Hand-edited. Operational details live in `docs/gtm/`. > Workstream plans live in `docs/superpowers/specs/gtm/`. -> Last reviewed: 2026-05-13. +> Current system ownership and operator commands: [Growth architecture and operations](docs/growth/README.md). +> The phase roadmap below is historical planning, not a deployment inventory. ## 1. What we are @@ -28,35 +29,33 @@ Two tracks, both addressed from the homepage via the CTA fork. Detailed personas, buying signals, and disqualifiers: `docs/gtm/icp.md`. -## 4. Funnel & activation +## 4. Acquisition, activation and follow-up -Two funnels share acquisition surfaces and diverge at the homepage CTA. +Developer acquisition includes website engagement, npm installs and development +runtime announcements. Growth records evidence and evaluates linked install/runtime +activation. The founder sequence uses durable authorization and stop controls; +company intelligence comes from enrichment and signals, not explicit associations. -**Developer funnel:** -`$pageview` → comparison/content view → `Install @threadplane/chat` (copy) → cockpit recipe start → cockpit recipe complete → cockpit six-signal activation. +Enterprise forms record requests and submitted context. A work email and company +field are useful signals, not verified qualification or employment. Use the Growth +journey to assess intent and evidence. The former server-side +`marketing:lead_qualified` helper is removed; its absence is not a delivery alarm. -**Enterprise funnel:** -`$pageview` → `Talk to our engineers` click → `/contact` view → `LeadForm` submit → `lead_form_success` → `lead_qualified` (server-side enrichment) → discovery booked → pilot started → pilot shipped. +Cockpit engagement remains a separate product analytics measurement. Completing a +cockpit funnel is not the same as becoming eligible for founder outreach. -**North-star activation milestone (developer):** in under 30 minutes, complete all six cockpit signals: install command copied, transport connected, first chat message sent, thread persisted, interrupt handled, generative Angular component rendered. Event-level definitions in [docs/gtm/taxonomy.md](docs/gtm/taxonomy.md). Measured via PostHog cohort `Activated developers`. +## 5. Measurement -**North-star qualified lead (enterprise):** see operational definition in [docs/gtm/icp.md](docs/gtm/icp.md) §Enterprise track. Fired on `lead_form_success`; criteria are non-personal `email_domain`, non-empty `company`, and `track=enterprise`. Measured via PostHog cohort `Qualified leads`. +- Growth funnel and journey reports describe recorded observations, linked + activation decisions, contact controls and lifecycle outcomes. +- PostHog's committed dashboards cover developer engagement and runtime telemetry. + Additional dashboards in the historical roadmap are not evidence of implementation. +- Search Console scripts measure website search performance. +- Social metrics do not yet feed a central ingestion pipeline. -## 5. Metrics - -Two scoreboards, separate. Dashboard index (PostHog ids generated by `tools/posthog/sync.ts`): - -| Slug | What it measures | Source spec | -|-----------------------------|-----------------------------------------------------------|-------------| -| `developer-funnel` | pageview → install → cockpit activation | Spec 1 | -| `enterprise-funnel` | pageview → talk-to-engineers → lead_qualified → pilot | Spec 1, 7 | -| `activation-six-signals` | the six cockpit signals and the 30-min funnel | Spec 1, 4 | -| `content-intent` | docs/comparison/pillar page engagement and CTAs | Spec 1, 3, 5 | -| `runtime-telemetry` | Explicit runtime adapter and opt-in browser events | Spec 1 | - -> Spec numbers refer to the workstream DAG in [meta-spec §6](docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md). Not all referenced specs exist on disk yet — they get authored as each workstream is brainstormed. - -Event/property/CTA-id taxonomy: `docs/gtm/taxonomy.md`. Weekly snapshot: `docs/gtm/reports/`. +See [the operational map](docs/growth/README.md) for commands, schedules and +attribution/reporting limits. Do not infer a complete anonymous conversion funnel +from independent observation counts. ## 6. Phases @@ -93,7 +92,7 @@ Operational progress lives in agent runs and PostHog. The repo holds durable str ## 8. Non-goals (current phase) - We do not compete as a general agent UI framework. We claim the Angular final mile. -- We do not ship telemetry from `@threadplane/*` browser packages by default. Runtime telemetry is explicit opt-in in browsers and explicit capture in Node; see [libs/telemetry/README.md](libs/telemetry/README.md) for the full contract. +- Public analytics helpers remain explicit. Development-only install/runtime Growth collection follows its own controls; see [libs/telemetry/README.md](libs/telemetry/README.md) for the complete contract. - We do not run paid acquisition until Phase 2 organic baselines exist. - We do not pursue stars as a vanity metric. - We do not run A/B positioning experiments in Phase 1. Ship one hero, measure, iterate. diff --git a/libs/growth-capture/README.md b/libs/growth-capture/README.md new file mode 100644 index 000000000..b54b66145 --- /dev/null +++ b/libs/growth-capture/README.md @@ -0,0 +1,20 @@ +# Growth company capture + +Private server-side capture library shared by lifecycle and the local research +evaluation harness. It owns the authenticated crawler client, hostname/SSRF +checks, DNS cancellation, HTML evidence extraction, and page-evidence schema. +It does not own contact records, model calls, research claims, or email policy. + +`createCompanyCapture(environment, onDiagnostic?)` returns the bounded capture +function. Callers supply configuration and an abort signal; the library does not +load credential files. Production capture uses the separately deployed crawler. +The `company-fetch.ts` helpers validate hosts and extract evidence; they do not +restore the retired direct-fetch enrichment pipeline. + +Run `npx nx test growth-capture`, `npx nx lint growth-capture`, and +`npx nx run growth-capture:check`. Lifecycle's native build verifies the bundled +consumer. The research app lists capture as a development dependency because +its local evaluation harness acquires pages; its standalone deployment receives +captured evidence and excludes this package. + +See [Growth architecture and operations](../../docs/growth/README.md). diff --git a/libs/growth-capture/package.json b/libs/growth-capture/package.json new file mode 100644 index 000000000..a8626dcc0 --- /dev/null +++ b/libs/growth-capture/package.json @@ -0,0 +1,11 @@ +{ + "name": "@threadplane-internal/growth-capture", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./firecrawl": "./src/firecrawl.ts" + }, + "dependencies": { "parse5": "8.0.1", "zod": "4.4.3" } +} diff --git a/libs/growth-capture/project.json b/libs/growth-capture/project.json new file mode 100644 index 000000000..66ba15b0c --- /dev/null +++ b/libs/growth-capture/project.json @@ -0,0 +1,12 @@ +{ + "name": "growth-capture", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/growth-capture/src", + "projectType": "library", + "tags": ["scope:internal", "scope:growth-lifecycle", "runtime:node24"], + "targets": { + "test": { "executor": "@nx/vitest:test", "options": { "configFile": "libs/growth-capture/vitest.config.ts" } }, + "check": { "executor": "nx:run-commands", "options": { "command": "node node_modules/typescript/bin/tsc --noEmit -p libs/growth-capture/tsconfig.json" } }, + "lint": { "executor": "@nx/eslint:lint" } + } +} diff --git a/apps/lifecycle/src/enrichment/company-capture.spec.ts b/libs/growth-capture/src/company-capture.spec.ts similarity index 100% rename from apps/lifecycle/src/enrichment/company-capture.spec.ts rename to libs/growth-capture/src/company-capture.spec.ts diff --git a/apps/lifecycle/src/enrichment/company-capture.ts b/libs/growth-capture/src/company-capture.ts similarity index 100% rename from apps/lifecycle/src/enrichment/company-capture.ts rename to libs/growth-capture/src/company-capture.ts diff --git a/apps/lifecycle/src/enrichment/company-fetch.spec.ts b/libs/growth-capture/src/company-fetch.spec.ts similarity index 100% rename from apps/lifecycle/src/enrichment/company-fetch.spec.ts rename to libs/growth-capture/src/company-fetch.spec.ts diff --git a/apps/lifecycle/src/enrichment/company-fetch.ts b/libs/growth-capture/src/company-fetch.ts similarity index 100% rename from apps/lifecycle/src/enrichment/company-fetch.ts rename to libs/growth-capture/src/company-fetch.ts diff --git a/apps/lifecycle/src/enrichment/evidence-extraction.spec.ts b/libs/growth-capture/src/evidence-extraction.spec.ts similarity index 100% rename from apps/lifecycle/src/enrichment/evidence-extraction.spec.ts rename to libs/growth-capture/src/evidence-extraction.spec.ts diff --git a/apps/lifecycle/src/enrichment/firecrawl.spec.ts b/libs/growth-capture/src/firecrawl.spec.ts similarity index 100% rename from apps/lifecycle/src/enrichment/firecrawl.spec.ts rename to libs/growth-capture/src/firecrawl.spec.ts diff --git a/apps/lifecycle/src/enrichment/firecrawl.ts b/libs/growth-capture/src/firecrawl.ts similarity index 100% rename from apps/lifecycle/src/enrichment/firecrawl.ts rename to libs/growth-capture/src/firecrawl.ts diff --git a/libs/growth-capture/src/index.ts b/libs/growth-capture/src/index.ts new file mode 100644 index 000000000..1dc339536 --- /dev/null +++ b/libs/growth-capture/src/index.ts @@ -0,0 +1,2 @@ +export { createCompanyCapture, type CompanyCaptureDiagnostic } from './company-capture.js'; +export { CompanyPageEvidenceSchema, type CompanyPageEvidence } from './schema.js'; diff --git a/libs/growth-capture/src/schema.ts b/libs/growth-capture/src/schema.ts new file mode 100644 index 000000000..b1d8d33f0 --- /dev/null +++ b/libs/growth-capture/src/schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +const HttpsUrlSchema = z.url({ protocol: /^https$/u }).max(500); + +export const CompanyPageEvidenceSchema = z + .object({ + canonicalUrl: HttpsUrlSchema, + retrievedAt: z.iso.datetime(), + contentHash: z.string().regex(/^[a-f0-9]{64}$/u), + facts: z.array(z.string().min(1).max(240)).max(6), + snippets: z.array(z.string().min(1).max(240)).max(6), + }) + .strict(); + +export type CompanyPageEvidence = z.infer; diff --git a/libs/growth-capture/tsconfig.json b/libs/growth-capture/tsconfig.json new file mode 100644 index 000000000..505a285c5 --- /dev/null +++ b/libs/growth-capture/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", "paths": {}, "composite": false, + "declaration": false, "declarationMap": false, "emitDeclarationOnly": false, + "lib": ["es2024", "dom", "dom.iterable"], + "module": "NodeNext", "moduleResolution": "NodeNext", "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "*.ts"] +} diff --git a/libs/growth-capture/vitest.config.ts b/libs/growth-capture/vitest.config.ts new file mode 100644 index 000000000..631d15e24 --- /dev/null +++ b/libs/growth-capture/vitest.config.ts @@ -0,0 +1,8 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + root: resolve(dirname(fileURLToPath(import.meta.url)), '../..'), + test: { environment: 'node', include: ['libs/growth-capture/src/**/*.spec.ts'] }, +}); diff --git a/libs/growth/README.md b/libs/growth/README.md index 7ebce021f..a1cfe1177 100644 --- a/libs/growth/README.md +++ b/libs/growth/README.md @@ -1,5 +1,7 @@ # Internal growth operator reports +Part of [Growth architecture and operations](../../docs/growth/README.md). + Run the existing CLI with the intended read-only database credentials: ```sh diff --git a/libs/growth/src/index.ts b/libs/growth/src/index.ts index 725a34f65..c41125e4e 100644 --- a/libs/growth/src/index.ts +++ b/libs/growth/src/index.ts @@ -1,7 +1,7 @@ +export { parseEmailHmacKeyring } from './lib/email-keyring.ts'; export * from './lib/contacts.ts'; export * from './lib/company-domain.ts'; export { readInstallRuntimeEnrichmentContext } from './lib/observability/install-runtime-enrichment.ts'; -export * from './lib/campaign-analytics.ts'; export * from './lib/crypto.ts'; export * from './lib/database.ts'; export * from './lib/dispatcher.ts'; diff --git a/libs/growth/src/lib/campaign-analytics.spec.ts b/libs/growth/src/lib/campaign-analytics.spec.ts deleted file mode 100644 index c430666dd..000000000 --- a/libs/growth/src/lib/campaign-analytics.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as publicGrowth from '../index.ts'; -import { describe, expect, it } from 'vitest'; - -import { - CAMPAIGN_ANALYTICS_SCHEMA_VERSION, - toCampaignAggregateEvent, -} from './campaign-analytics.ts'; - -describe('closed campaign analytics taxonomy', () => { - it.each([ - ['campaign.enrolled:v1', 'enrolled'], - ['campaign.step_accepted', 'step_accepted'], - ['delivery.delivered', 'step_delivered'], - ['campaign.reply_received', 'reply'], - ['manual_suppression', 'stopped'], - ['hard_bounce', 'bounced'], - ['complaint', 'complained'], - ['provider_suppression', 'suppressed'], - ['delivery.acceptance_unknown', 'provider_unknown'], - ['delivery.provider_rejected', 'provider_failed'], - ['delivery.failed', 'provider_failed'], - ] as const)('maps %s to the closed %s outcome', (kind, outcome) => { - expect( - toCampaignAggregateEvent({ - kind, - ...(kind === 'campaign.step_accepted' || kind === 'delivery.delivered' - ? { step: 2 } - : {}), - }) - ).toEqual({ - schemaVersion: CAMPAIGN_ANALYTICS_SCHEMA_VERSION, - outcome, - ...(kind === 'campaign.step_accepted' || kind === 'delivery.delivered' - ? { step: 2 } - : {}), - }); - }); - - it('rejects arbitrary properties and identifying/provider/copy fields', () => { - for (const candidate of [ - { kind: 'campaign.enrolled:v1', email: 'ada@example.com' }, - { kind: 'campaign.enrolled:v1', contactId: 'contact-1' }, - { kind: 'campaign.step_accepted', step: 1, providerId: 'provider-1' }, - { kind: 'campaign.step_accepted', step: 1, copy: 'message body' }, - { kind: 'arbitrary.outcome' }, - ]) { - expect(() => toCampaignAggregateEvent(candidate)).toThrow(); - } - }); - - it.each(['constructor', 'toString', '__proto__'])( - 'rejects prototype key %s as an unregistered outcome', - (kind) => { - expect(() => toCampaignAggregateEvent({ kind })).toThrow(/registered/u); - } - ); - - it('rejects inherited kind or step properties and non-plain records', () => { - const inheritedKind = Object.create({ kind: 'campaign.enrolled:v1' }); - const inheritedStep = Object.assign(Object.create({ step: 2 }), { - kind: 'campaign.step_accepted', - }); - const nullPrototype = Object.assign(Object.create(null), { - kind: 'campaign.enrolled:v1', - }); - class AggregateCandidate { - kind = 'campaign.enrolled:v1'; - } - - for (const candidate of [ - inheritedKind, - inheritedStep, - nullPrototype, - new AggregateCandidate(), - ]) { - expect(() => toCampaignAggregateEvent(candidate)).toThrow(/object|own/u); - } - }); - - it('rejects non-enumerable and symbol property bags', () => { - const hidden = { kind: 'campaign.enrolled:v1' }; - Object.defineProperty(hidden, 'contactId', { - enumerable: false, - value: 'contact-1', - }); - const symbolKey = Object.assign( - { kind: 'campaign.enrolled:v1' }, - { [Symbol('provider-id')]: 'provider-1' } - ); - - expect(() => toCampaignAggregateEvent(hidden)).toThrow(/identifying/u); - expect(() => toCampaignAggregateEvent(symbolKey)).toThrow(/identifying/u); - }); - - it('exports only the pure versioned mapper and taxonomy from growth', () => { - expect(publicGrowth.toCampaignAggregateEvent).toBe( - toCampaignAggregateEvent - ); - expect(publicGrowth).not.toHaveProperty('emitCampaignAnalytics'); - expect(publicGrowth).not.toHaveProperty('captureCampaignAnalytics'); - }); -}); diff --git a/libs/growth/src/lib/campaign-analytics.ts b/libs/growth/src/lib/campaign-analytics.ts deleted file mode 100644 index 85898ae82..000000000 --- a/libs/growth/src/lib/campaign-analytics.ts +++ /dev/null @@ -1,82 +0,0 @@ -export const CAMPAIGN_ANALYTICS_SCHEMA_VERSION = 1 as const; - -export type CampaignAggregateOutcome = - | 'enrolled' - | 'step_accepted' - | 'step_delivered' - | 'reply' - | 'stopped' - | 'bounced' - | 'complained' - | 'suppressed' - | 'provider_unknown' - | 'provider_failed'; - -export interface CampaignAggregateEvent { - schemaVersion: typeof CAMPAIGN_ANALYTICS_SCHEMA_VERSION; - outcome: CampaignAggregateOutcome; - step?: 1 | 2 | 3; -} - -const OUTCOMES = { - 'campaign.enrolled:v1': 'enrolled', - 'campaign.step_accepted': 'step_accepted', - 'delivery.delivered': 'step_delivered', - 'campaign.reply_received': 'reply', - unsubscribe: 'stopped', - manual_suppression: 'stopped', - deletion: 'stopped', - invalid_address: 'stopped', - hard_bounce: 'bounced', - complaint: 'complained', - provider_suppression: 'suppressed', - 'delivery.acceptance_unknown': 'provider_unknown', - 'delivery.provider_rejected': 'provider_failed', - 'delivery.failed': 'provider_failed', -} as const satisfies Record; - -export function toCampaignAggregateEvent( - candidate: unknown -): CampaignAggregateEvent { - if ( - candidate === null || - typeof candidate !== 'object' || - Array.isArray(candidate) || - Object.getPrototypeOf(candidate) !== Object.prototype - ) { - throw new Error('Campaign analytics source must be a plain object'); - } - const record = candidate as Record; - const keys = Reflect.ownKeys(record); - if (keys.some((key) => key !== 'kind' && key !== 'step')) { - throw new Error('Campaign analytics source contains identifying data'); - } - if (!Object.hasOwn(record, 'kind')) { - throw new Error('Campaign analytics source requires an own kind'); - } - const kind = record['kind']; - if (typeof kind !== 'string' || !Object.hasOwn(OUTCOMES, kind)) { - throw new Error('Campaign analytics source kind is not registered'); - } - const outcome = OUTCOMES[kind as keyof typeof OUTCOMES]; - const requiresStep = - outcome === 'step_accepted' || outcome === 'step_delivered'; - const step = record['step']; - if (requiresStep) { - if ( - !Object.hasOwn(record, 'step') || - (step !== 1 && step !== 2 && step !== 3) - ) { - throw new Error('Campaign step outcome requires step 1, 2, or 3'); - } - return { - schemaVersion: CAMPAIGN_ANALYTICS_SCHEMA_VERSION, - outcome, - step, - }; - } - if (step !== undefined) { - throw new Error('Campaign non-step outcome cannot contain a step'); - } - return { schemaVersion: CAMPAIGN_ANALYTICS_SCHEMA_VERSION, outcome }; -} diff --git a/libs/growth/src/lib/email-keyring.spec.ts b/libs/growth/src/lib/email-keyring.spec.ts new file mode 100644 index 000000000..f1192bc0b --- /dev/null +++ b/libs/growth/src/lib/email-keyring.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { parseEmailHmacKeyring as loadEmailHmacKeyring } from './email-keyring.ts'; + +const active = { version: 2, secret: 'a'.repeat(32) }; +const environment = { + GROWTH_EMAIL_HMAC_ACTIVE_VERSION: '2', + GROWTH_EMAIL_HMAC_ACTIVE_SECRET: active.secret, +}; + +describe('shared email HMAC configuration', () => { + it('loads the same active and rotation keys used by collection', () => { + const previous = [{ version: 1, secret: 'b'.repeat(32) }]; + expect( + loadEmailHmacKeyring({ + ...environment, + GROWTH_EMAIL_HMAC_PREVIOUS_KEYS: JSON.stringify(previous), + }) + ).toEqual({ active, previous }); + expect(loadEmailHmacKeyring(environment)).toEqual({ active }); + }); + + it('requires an adequately sized active key when configuration is read', () => { + expect(() => loadEmailHmacKeyring({})).toThrow(/active secret/); + expect(() => + loadEmailHmacKeyring({ + ...environment, + GROWTH_EMAIL_HMAC_ACTIVE_SECRET: 'short', + }) + ).toThrow(/active secret/); + }); + + it.each(['0', '32768', '1.5', 'NaN'])( + 'rejects an invalid active key version %s', + (version) => { + expect(() => + loadEmailHmacKeyring({ + ...environment, + GROWTH_EMAIL_HMAC_ACTIVE_VERSION: version, + }) + ).toThrow(/active version/); + } + ); + + it('rejects duplicate key versions', () => { + expect(() => + loadEmailHmacKeyring({ + ...environment, + GROWTH_EMAIL_HMAC_PREVIOUS_KEYS: JSON.stringify([active]), + }) + ).toThrow(/unique/); + }); + + it('does not expose malformed previous key material in errors', () => { + const secret = 'private-malformed-key-material'; + let caught: unknown; + try { + loadEmailHmacKeyring({ + ...environment, + GROWTH_EMAIL_HMAC_PREVIOUS_KEYS: secret, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect(String(caught)).not.toContain(secret); + expect(String(caught)).toContain('previous keys are invalid'); + }); +}); diff --git a/libs/growth/src/lib/email-keyring.ts b/libs/growth/src/lib/email-keyring.ts new file mode 100644 index 000000000..2bc12c376 --- /dev/null +++ b/libs/growth/src/lib/email-keyring.ts @@ -0,0 +1,69 @@ +import type { EmailHmacKey, EmailHmacKeyring } from './crypto.ts'; + +function version(value: string | undefined): number { + if (!value || !/^\d+$/u.test(value)) { + throw new Error('Growth email HMAC active version is required'); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 32_767) { + throw new Error('Growth email HMAC active version is invalid'); + } + return parsed; +} + +function previousKey(candidate: unknown): EmailHmacKey { + if ( + candidate === null || + typeof candidate !== 'object' || + Array.isArray(candidate) + ) { + throw new Error('Growth email HMAC previous key is invalid'); + } + const record = candidate as Record; + if ( + Object.keys(record).length !== 2 || + typeof record['version'] !== 'number' || + !Number.isSafeInteger(record['version']) || + record['version'] < 1 || + record['version'] > 32_767 || + typeof record['secret'] !== 'string' || + Buffer.byteLength(record['secret'], 'utf8') < 32 + ) { + throw new Error('Growth email HMAC previous key is invalid'); + } + return { version: record['version'], secret: record['secret'] }; +} + +export function parseEmailHmacKeyring( + environment: Readonly> +): EmailHmacKeyring { + const secret = environment['GROWTH_EMAIL_HMAC_ACTIVE_SECRET']; + if (!secret || Buffer.byteLength(secret, 'utf8') < 32) { + throw new Error('Growth email HMAC active secret is required'); + } + const active = { + version: version(environment['GROWTH_EMAIL_HMAC_ACTIVE_VERSION']), + secret, + }; + const rawPrevious = environment['GROWTH_EMAIL_HMAC_PREVIOUS_KEYS']; + if (!rawPrevious) return { active }; + + let parsed: unknown; + try { + parsed = JSON.parse(rawPrevious) as unknown; + } catch { + throw new Error('Growth email HMAC previous keys are invalid'); + } + if (!Array.isArray(parsed)) { + throw new Error('Growth email HMAC previous keys must be an array'); + } + const previous = parsed.map(previousKey); + const versions = new Set([active.version]); + for (const key of previous) { + if (versions.has(key.version)) { + throw new Error('Growth email HMAC key versions must be unique'); + } + versions.add(key.version); + } + return { active, previous }; +} diff --git a/marketing/.env.example b/marketing/.env.example index 38be9adc9..e3605c766 100644 --- a/marketing/.env.example +++ b/marketing/.env.example @@ -29,3 +29,4 @@ REDDIT_PASSWORD= # Pipeline behavior # DRY_RUN=1 # Adapters return synthetic PostResults; nothing is posted. +# MARKETING_DRY_RUN_DIR=tmp/marketing/dry-runs # Relative to cwd, or an absolute path. diff --git a/marketing/README.md b/marketing/README.md index 2a5a69a2e..72617aad7 100644 --- a/marketing/README.md +++ b/marketing/README.md @@ -1,29 +1,23 @@ # marketing/ -Agentic marketing pipeline. Five composable subsystems that turn source content (blog posts, releases, prompts, schedules) into multi-channel posts (X, LinkedIn, Dev.to, Reddit), with Cowork as the human approval surface and PostHog as the feedback loop. +Internal publishing tools and operator materials for Threadplane. Growth owns contacts, authorization, observations, and lifecycle outcomes; these tools render assets and publish operator-approved content. See the [Growth architecture and operations guide](../docs/growth/README.md) for ownership and commands. ## Structure ``` marketing/ -├── assets/ # @threadplane/marketing-assets — branded image rendering -├── channels/ # @threadplane/marketing-channels — X, LinkedIn, Dev.to, Reddit adapters -├── agent/ # @threadplane/marketing-agent — LangGraph drafting agent -├── cowork/ # Claude skills (/gtm, /marketing) + inbox/outbox/archive -└── metrics/ # @threadplane/marketing-metrics — feedback ingestion → PostHog +├── assets/ # @threadplane-internal/marketing-assets — branded image rendering +├── channels/ # @threadplane-internal/marketing-channels — X and Dev.to adapters +└── cowork/ # GTM operator skill and retained campaign drafts/assets ``` -All four packages are internal (`"private": true`). They are NOT published to npm. +Both packages are internal (`"private": true`) and are not published to npm. There is no autonomous drafting agent, feedback worker, or campaign scheduler in this directory. LinkedIn and Reddit drafts remain available for manual publishing. -## Specs +## Operator workflow -- Meta (this umbrella): `docs/superpowers/specs/marketing/2026-05-17-marketing-meta-design.md` -- Sub-specs (when written): - - `brand-assets` — `docs/superpowers/specs/marketing/-brand-assets-design.md` - - `channel-adapters` — `docs/superpowers/specs/marketing/-channel-adapters-design.md` - - `content-agent` — `docs/superpowers/specs/marketing/-content-agent-design.md` - - `cowork-loop` — `docs/superpowers/specs/marketing/-cowork-loop-design.md` - - `metrics-ingest` — `docs/superpowers/specs/marketing/-metrics-ingest-design.md` +Use [assets](assets/README.md) to render cards and [channels](channels/README.md) to publish approved drafts. Set `DRY_RUN=1` to write simulated posts to `tmp/marketing/dry-runs/` relative to the working directory. Set `MARKETING_DRY_RUN_DIR` to choose a different relative or absolute directory. + +The [GTM skill](cowork/README.md) documents reporting and triage. PostHog measurement lives in `tools/posthog/`; Growth lifecycle state remains in Growth. Historical marketing specs under `docs/superpowers/` describe earlier proposals, not current runtime components. ## Voice + messaging source-of-truth @@ -32,7 +26,3 @@ All four packages are internal (`"private": true`). They are NOT published to np - `docs/gtm/icp.md` — audience All in this repo. No machine-local paths in checked-in code. - -## Status - -This directory was scaffolded by the marketing-meta spec. Subsystems are skeletons. Implementation lands as each sub-spec ships. diff --git a/marketing/agent/package.json b/marketing/agent/package.json deleted file mode 100644 index e387ebfb9..000000000 --- a/marketing/agent/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@threadplane/marketing-agent", - "version": "0.0.0", - "license": "MIT", - "main": "./src/index.ts", - "types": "./src/index.ts", - "repository": { - "type": "git", - "url": "https://github.com/cacheplane/angular-agent-framework.git", - "directory": "marketing/agent" - }, - "sideEffects": false, - "private": true -} diff --git a/marketing/agent/project.json b/marketing/agent/project.json deleted file mode 100644 index a412512f2..000000000 --- a/marketing/agent/project.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "marketing-agent", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "marketing/agent/src", - "projectType": "library", - "tags": ["scope:marketing", "type:lib"], - "targets": { - "build": { - "executor": "@nx/js:tsc", - "outputs": ["{workspaceRoot}/dist/marketing/agent"], - "options": { - "outputPath": "dist/marketing/agent", - "main": "marketing/agent/src/index.ts", - "tsConfig": "marketing/agent/tsconfig.lib.json" - } - }, - "lint": { - "executor": "@nx/eslint:lint" - } - } -} diff --git a/marketing/agent/src/index.ts b/marketing/agent/src/index.ts deleted file mode 100644 index 0feef7b03..000000000 --- a/marketing/agent/src/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// -// @threadplane/marketing-agent — LangGraph drafting agent for the marketing pipeline. -// Skeleton only. Implementation lands in the content-agent sub-spec. - -import type { Draft } from '@threadplane/marketing-channels'; - -export type Trigger = - | { kind: 'blog-merge'; slug: string } - | { kind: 'release'; tag: string } - | { kind: 'cowork-prompt'; topic: string; freeform?: string } - | { kind: 'cadence'; window: 'weekly' }; - -export interface DraftBundle { - id: string; - trigger: Trigger; - drafts: Draft[]; - source: { url?: string; title?: string; excerpt?: string }; - createdAt: string; -} - -export function draft(_trigger: Trigger): Promise { - throw new Error( - '@threadplane/marketing-agent: draft() not yet implemented. See content-agent sub-spec.', - ); -} diff --git a/marketing/agent/tsconfig.json b/marketing/agent/tsconfig.json deleted file mode 100644 index cf0cba0d6..000000000 --- a/marketing/agent/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "files": [], - "references": [{ "path": "./tsconfig.lib.json" }] -} diff --git a/marketing/agent/tsconfig.lib.json b/marketing/agent/tsconfig.lib.json deleted file mode 100644 index 643573425..000000000 --- a/marketing/agent/tsconfig.lib.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "declaration": true - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts"] -} diff --git a/marketing/assets/README.md b/marketing/assets/README.md index 9ecdc4bf0..4c014c51b 100644 --- a/marketing/assets/README.md +++ b/marketing/assets/README.md @@ -1,11 +1,11 @@ -# @threadplane/marketing-assets +# @threadplane-internal/marketing-assets Branded social-card rendering for the marketing pipeline. `renderCard()` turns typed input into a PNG via satori (JSX→SVG) + @resvg/resvg-js (SVG→PNG). No Next.js dependency — runs anywhere Node does. ## Usage ```ts -import { renderCard } from '@threadplane/marketing-assets'; +import { renderCard } from '@threadplane-internal/marketing-assets'; const card = await renderCard({ template: 'x-card', diff --git a/marketing/assets/package.json b/marketing/assets/package.json index a90f12660..de57b321a 100644 --- a/marketing/assets/package.json +++ b/marketing/assets/package.json @@ -1,5 +1,5 @@ { - "name": "@threadplane/marketing-assets", + "name": "@threadplane-internal/marketing-assets", "version": "0.0.0", "license": "MIT", "main": "./src/index.ts", diff --git a/marketing/assets/project.json b/marketing/assets/project.json index 8175cb294..fbf049b99 100644 --- a/marketing/assets/project.json +++ b/marketing/assets/project.json @@ -3,7 +3,7 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "marketing/assets/src", "projectType": "library", - "tags": ["scope:marketing", "type:lib"], + "tags": ["scope:marketing", "scope:scripts-tests", "type:lib"], "targets": { "build": { "executor": "@nx/js:tsc", diff --git a/marketing/assets/src/index.ts b/marketing/assets/src/index.ts index 8c0789b28..2ca695ed7 100644 --- a/marketing/assets/src/index.ts +++ b/marketing/assets/src/index.ts @@ -1,5 +1,5 @@ // -// @threadplane/marketing-assets — branded social-card rendering. +// @threadplane-internal/marketing-assets — branded social-card rendering. // See docs/superpowers/specs/marketing/2026-05-17-brand-assets-design.md export { renderCard } from './render'; export type { CardInput, RenderedCard, TemplateId } from './types'; diff --git a/marketing/assets/src/templates/card-shell.tsx b/marketing/assets/src/templates/card-shell.tsx index adec1bb9a..171e2b417 100644 --- a/marketing/assets/src/templates/card-shell.tsx +++ b/marketing/assets/src/templates/card-shell.tsx @@ -136,7 +136,6 @@ export function CardShell({ input, planeDataUri, headlineSize, padding }: CardSh color: brand.ink, }} > - {/* eslint-disable-next-line @next/next/no-img-element */} {brand.wordmark} diff --git a/marketing/channels/MANUAL-SMOKE.md b/marketing/channels/MANUAL-SMOKE.md index a3d61ea9b..335d3202a 100644 --- a/marketing/channels/MANUAL-SMOKE.md +++ b/marketing/channels/MANUAL-SMOKE.md @@ -7,15 +7,15 @@ Run after the bootstrapper has populated `.env`. ## 1. Dry-run (no API calls) ```bash -DRY_RUN=1 pnpm marketing:channels:x:smoke +DRY_RUN=1 npm run marketing:channels:x:smoke ``` -Expect: a JSON `PostResult` printed with `postId` prefixed `dry-` and a file under `marketing/cowork/outbox/dry-runs/`. +Expect: a JSON `PostResult` printed with `postId` prefixed `dry-` and a file under `tmp/marketing/dry-runs/`. ## 2. Live single tweet ```bash -pnpm marketing:channels:x:smoke +npm run marketing:channels:x:smoke ``` Expect: a real `https://x.com//status/` URL. Open it; confirm the post is on the timeline. **Then delete the post from the X UI.** @@ -23,7 +23,7 @@ Expect: a real `https://x.com//status/` URL. Open it; confirm the po ## 3. Live tweet with media ```bash -SMOKE_MEDIA=1 pnpm marketing:channels:x:smoke +SMOKE_MEDIA=1 npm run marketing:channels:x:smoke ``` Expect: the post has a 1×1 transparent pixel attached with the alt text. Delete after verifying. @@ -31,7 +31,7 @@ Expect: the post has a 1×1 transparent pixel attached with the alt text. Delete ## 4. Live thread ```bash -SMOKE_THREAD=1 pnpm marketing:channels:x:smoke +SMOKE_THREAD=1 npm run marketing:channels:x:smoke ``` Expect: two tweets posted; the second is a reply to the first. Delete both. @@ -47,15 +47,15 @@ Run after `DEVTO_API_KEY` is in `.env`. ## 1. Dry-run (no API calls) ```bash -DRY_RUN=1 pnpm marketing:channels:devto:smoke +DRY_RUN=1 npm run marketing:channels:devto:smoke ``` -Expect: a JSON `PostResult` with `postId` prefixed `dry-`, `channel: "devto"`, and a file under `marketing/cowork/outbox/dry-runs/`. +Expect: a JSON `PostResult` with `postId` prefixed `dry-`, `channel: "devto"`, and a file under `tmp/marketing/dry-runs/`. ## 2. Live article ```bash -pnpm marketing:channels:devto:smoke +npm run marketing:channels:devto:smoke ``` Expect: a real `https://dev.to//` URL. Open it; confirm the article is published. The script also fetches metrics after a 5-second pause — expect a `Metrics:` block with near-zero counts. **Then delete the article from Dev.to** (Dashboard → ⋯ → Delete). diff --git a/marketing/channels/README.md b/marketing/channels/README.md index c1c5b4325..494709e58 100644 --- a/marketing/channels/README.md +++ b/marketing/channels/README.md @@ -1,4 +1,4 @@ -# @threadplane/marketing-channels +# @threadplane-internal/marketing-channels Channel adapters for the Threadplane marketing pipeline. One adapter per channel, all behind a single `ChannelAdapter` interface. @@ -15,7 +15,7 @@ Channel adapters for the Threadplane marketing pipeline. One adapter per channel ## Quickstart ```ts -import { getAdapter } from '@threadplane/marketing-channels'; +import { getAdapter } from '@threadplane-internal/marketing-channels'; const x = getAdapter('x'); const result = await x.post({ @@ -30,7 +30,7 @@ console.log(result.url); X uses OAuth 2.0 User Context with PKCE. The first time you set it up, run the bootstrapper: ```bash -pnpm marketing:channels:x:auth +npm run marketing:channels:x:auth ``` It opens your browser, you authorize the app, and it prints the tokens for you to paste into `.env`: @@ -43,7 +43,7 @@ X_USER_HANDLE=brian Prerequisites: create an X v2 app at and set the `X_CLIENT_ID` + `X_CLIENT_SECRET` env vars from the app's OAuth 2.0 section. -When an access token expires, the adapter automatically calls `/2/oauth2/token` to refresh and prints the new refresh token to stderr (X rotates refresh tokens on use; update your `.env` for the next process start). +When an access token expires, the adapter automatically calls `/2/oauth2/token` to refresh. Rotated tokens remain in memory and are never logged by the adapter. They are not persisted across process restarts; if authentication fails after restarting, run the bootstrapper again. ## Auth (Dev.to) @@ -61,7 +61,7 @@ Dev.to uses a single static API key. 4. Verify with a dry-run: ```bash - DRY_RUN=1 pnpm marketing:channels:devto:smoke + DRY_RUN=1 npm run marketing:channels:devto:smoke ``` ### Tag rules (Dev.to) @@ -75,7 +75,9 @@ Dev.to is strict about tags. The validator catches violations before the API cal ## Dry-run -Set `DRY_RUN=1` and `post()` writes the draft to `marketing/cowork/outbox/dry-runs/.json` instead of hitting any API. Safe for local development and CI. +Set `DRY_RUN=1` and `post()` writes the draft to `tmp/marketing/dry-runs/.json` instead of hitting any API. Safe for local development and CI. + +The default path is relative to the process working directory. Set `MARKETING_DRY_RUN_DIR` to an absolute path or a different relative directory to direct output to an operator's review folder. This storage is independent of Cowork. ```bash DRY_RUN=1 npx tsx marketing/channels/scripts/smoke.ts @@ -103,6 +105,7 @@ All adapters call `validateDraft()` first. Drafts that violate per-channel rules ## See also +- [Growth architecture and operations](../../docs/growth/README.md) - Spec: `docs/superpowers/specs/marketing/2026-05-17-channel-adapters-design.md` - Meta: `docs/superpowers/specs/marketing/2026-05-17-marketing-meta-design.md` - Manual smoke recipe: `MANUAL-SMOKE.md` diff --git a/marketing/channels/package.json b/marketing/channels/package.json index eea202c37..f6526d102 100644 --- a/marketing/channels/package.json +++ b/marketing/channels/package.json @@ -1,5 +1,5 @@ { - "name": "@threadplane/marketing-channels", + "name": "@threadplane-internal/marketing-channels", "version": "0.0.0", "license": "MIT", "main": "./src/index.ts", diff --git a/marketing/channels/project.json b/marketing/channels/project.json index 42d292862..278ee1c18 100644 --- a/marketing/channels/project.json +++ b/marketing/channels/project.json @@ -3,7 +3,7 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "marketing/channels/src", "projectType": "library", - "tags": ["scope:marketing", "type:lib"], + "tags": ["scope:marketing", "scope:scripts-tests", "type:lib"], "targets": { "build": { "executor": "@nx/js:tsc", diff --git a/marketing/channels/scripts/smoke.ts b/marketing/channels/scripts/smoke.ts index f3c8256c6..609e79aca 100644 --- a/marketing/channels/scripts/smoke.ts +++ b/marketing/channels/scripts/smoke.ts @@ -1,13 +1,13 @@ // Standalone smoke runner for channel adapters. NOT exported by the package. // // Usage: -// pnpm marketing:channels:x:auth # one-time, fills .env (X only) -// DRY_RUN=1 pnpm marketing:channels:x:smoke -// pnpm marketing:channels:x:smoke -// SMOKE_MEDIA=1 pnpm marketing:channels:x:smoke -// SMOKE_THREAD=1 pnpm marketing:channels:x:smoke -// DRY_RUN=1 pnpm marketing:channels:devto:smoke -// pnpm marketing:channels:devto:smoke +// npm run marketing:channels:x:auth # one-time, fills .env (X only) +// DRY_RUN=1 npm run marketing:channels:x:smoke +// npm run marketing:channels:x:smoke +// SMOKE_MEDIA=1 npm run marketing:channels:x:smoke +// SMOKE_THREAD=1 npm run marketing:channels:x:smoke +// DRY_RUN=1 npm run marketing:channels:devto:smoke +// npm run marketing:channels:devto:smoke // // The default channel is 'x'. Override with --channel=devto. @@ -61,7 +61,7 @@ function buildDevToDraft(): Draft { text: [ '# Marketing Pipeline Smoke Test', '', - 'This is an automated smoke test of the @threadplane/marketing-channels Dev.to adapter.', + 'This is an automated smoke test of the @threadplane-internal/marketing-channels Dev.to adapter.', '', `Posted at ${stamp}. Please ignore — this article will be deleted.`, '', diff --git a/marketing/channels/src/dry-run.spec.ts b/marketing/channels/src/dry-run.spec.ts index 74cfe6832..f90eb38b6 100644 --- a/marketing/channels/src/dry-run.spec.ts +++ b/marketing/channels/src/dry-run.spec.ts @@ -12,15 +12,17 @@ beforeEach(() => { origCwd = process.cwd(); cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'dry-run-test-')); process.chdir(cwd); + vi.stubEnv('MARKETING_DRY_RUN_DIR', ''); }); afterEach(() => { process.chdir(origCwd); + vi.unstubAllEnvs(); fs.rmSync(cwd, { recursive: true, force: true }); }); describe('writeDryRunResult', () => { - it('writes a JSON file under marketing/cowork/outbox/dry-runs and returns a synthetic PostResult', async () => { + it('writes a JSON file under tmp/marketing/dry-runs and returns a synthetic PostResult', async () => { const draft: Draft = { channel: 'x', text: 'hello' }; const result = await writeDryRunResult(draft); @@ -31,9 +33,8 @@ describe('writeDryRunResult', () => { const outFile = path.join( cwd, + 'tmp', 'marketing', - 'cowork', - 'outbox', 'dry-runs', `${result.postId}.json`, ); @@ -52,9 +53,8 @@ describe('writeDryRunResult', () => { const result = await writeDryRunResult(draft); const outFile = path.join( cwd, + 'tmp', 'marketing', - 'cowork', - 'outbox', 'dry-runs', `${result.postId}.json`, ); @@ -62,4 +62,14 @@ describe('writeDryRunResult', () => { expect(parsed.draft.media[0].png).toBe('aGVsbG8='); // base64('hello') expect(parsed.draft.media[0].alt).toBe('h'); }); + + it.each(['operator-output', 'absolute'])('supports a configured %s directory', async (directory) => { + const configured = directory === 'absolute' ? path.join(cwd, 'absolute-output') : directory; + vi.stubEnv('MARKETING_DRY_RUN_DIR', configured); + const draft: Draft = { channel: 'devto', text: 'hello' }; + const result = await writeDryRunResult(draft); + const output = path.resolve(cwd, configured, `${result.postId}.json`); + expect(JSON.parse(fs.readFileSync(output, 'utf8')).draft).toEqual(draft); + expect(fs.existsSync(path.join(cwd, 'tmp'))).toBe(false); + }); }); diff --git a/marketing/channels/src/dry-run.ts b/marketing/channels/src/dry-run.ts index 290904f1f..234a99838 100644 --- a/marketing/channels/src/dry-run.ts +++ b/marketing/channels/src/dry-run.ts @@ -16,7 +16,10 @@ function serializeDraft(draft: Draft): unknown { export async function writeDryRunResult(draft: Draft): Promise { const id = `dry-${crypto.randomUUID()}`; - const outDir = path.join(process.cwd(), 'marketing', 'cowork', 'outbox', 'dry-runs'); + const outDir = path.resolve( + process.cwd(), + process.env.MARKETING_DRY_RUN_DIR || path.join('tmp', 'marketing', 'dry-runs'), + ); await fs.mkdir(outDir, { recursive: true }); const file = path.join(outDir, `${id}.json`); await fs.writeFile( diff --git a/marketing/channels/src/index.ts b/marketing/channels/src/index.ts index a14dc1892..af47c9935 100644 --- a/marketing/channels/src/index.ts +++ b/marketing/channels/src/index.ts @@ -1,5 +1,5 @@ // -// @threadplane/marketing-channels — public API. +// @threadplane-internal/marketing-channels — public API. // See docs/superpowers/specs/marketing/2026-05-17-channel-adapters-design.md export type { diff --git a/marketing/channels/src/types.ts b/marketing/channels/src/types.ts index 455cfe4b8..be3c311d9 100644 --- a/marketing/channels/src/types.ts +++ b/marketing/channels/src/types.ts @@ -1,5 +1,5 @@ // -// @threadplane/marketing-channels — public types. +// @threadplane-internal/marketing-channels — public types. export type ChannelId = 'x' | 'linkedin' | 'devto' | 'reddit'; diff --git a/marketing/channels/src/x/auth-cli.ts b/marketing/channels/src/x/auth-cli.ts index 7791993c9..dccabc45e 100644 --- a/marketing/channels/src/x/auth-cli.ts +++ b/marketing/channels/src/x/auth-cli.ts @@ -1,5 +1,5 @@ // One-time OAuth 2.0 bootstrapper for the X adapter. -// Run via: pnpm marketing:channels:x:auth +// Run via: npm run marketing:channels:x:auth import http from 'node:http'; import crypto from 'node:crypto'; diff --git a/marketing/channels/src/x/auth.spec.ts b/marketing/channels/src/x/auth.spec.ts index ff8fa6743..1f5954ced 100644 --- a/marketing/channels/src/x/auth.spec.ts +++ b/marketing/channels/src/x/auth.spec.ts @@ -62,7 +62,7 @@ describe('XAuth.refresh', () => { expect(auth.refreshToken).toBe('refresh-2'); }); - it('prints the new refresh token to stderr after a successful refresh', async () => { + it('reports successful refresh without printing credentials', async () => { server.use( mswHttp.post('https://api.x.com/2/oauth2/token', () => HttpResponse.json({ @@ -78,7 +78,10 @@ describe('XAuth.refresh', () => { await auth.refresh(); expect(spy).toHaveBeenCalled(); const written = spy.mock.calls.map((c) => String(c[0])).join(''); - expect(written).toContain('refresh-2'); + expect(written).toContain('X refresh successful'); + for (const secret of [...Object.values(env).slice(0, 4), 'access-2', 'refresh-2']) { + expect(written).not.toContain(secret); + } spy.mockRestore(); }); diff --git a/marketing/channels/src/x/auth.ts b/marketing/channels/src/x/auth.ts index 42837b682..c96264861 100644 --- a/marketing/channels/src/x/auth.ts +++ b/marketing/channels/src/x/auth.ts @@ -56,13 +56,13 @@ export class XAuth { }); } catch (err) { throw new Error( - `X access token expired and refresh failed — re-run \`pnpm marketing:channels:x:auth\`. Underlying: ${(err as Error).message}`, + `X access token expired and refresh failed — re-run \`npm run marketing:channels:x:auth\`. Underlying: ${(err as Error).message}`, ); } this.accessToken = tokens.access_token; this.refreshToken = tokens.refresh_token; process.stderr.write( - `\n✓ X refresh successful. Update your .env to keep the new refresh token across restarts:\n X_ACCESS_TOKEN=${tokens.access_token}\n X_REFRESH_TOKEN=${tokens.refresh_token}\n\n`, + '\n✓ X refresh successful. Rotated tokens are held in memory only; re-run npm run marketing:channels:x:auth if authentication fails after restart.\n', ); } } diff --git a/marketing/channels/src/x/post.ts b/marketing/channels/src/x/post.ts index c71a1b554..11619d3c4 100644 --- a/marketing/channels/src/x/post.ts +++ b/marketing/channels/src/x/post.ts @@ -38,7 +38,7 @@ export async function postX(auth: XAuth, draft: Draft): Promise { return writeDryRunResult(draft); } - let mediaIds: string[] = []; + const mediaIds: string[] = []; if (draft.media && draft.media.length > 0) { for (const m of draft.media) { mediaIds.push(await uploadMedia(auth, m.png, m.alt)); diff --git a/marketing/cowork/README.md b/marketing/cowork/README.md index 913d98ddd..530221563 100644 --- a/marketing/cowork/README.md +++ b/marketing/cowork/README.md @@ -11,8 +11,6 @@ marketing/cowork/ ├── README.md # This file. ├── gtm/ │ └── SKILL.md # The GTM Cowork skill. -├── marketing/ -│ └── SKILL.md # The marketing pipeline Cowork skill (stub; body in sub-spec 4). ├── inbox/ # Drafts awaiting review. ├── outbox/ # Approved + posted drafts. └── archive/ # Rejected or expired drafts. @@ -66,8 +64,9 @@ Edit `marketing/cowork/gtm/SKILL.md` in this repo. If you symlinked, the change ## See also -- [gtm.md](../gtm.md) — durable strategy -- [docs/gtm/](../docs/gtm/) — operational docs (ICP, messaging, taxonomy) -- [docs/superpowers/specs/gtm/](../docs/superpowers/specs/gtm/) — per-workstream design specs -- [tools/posthog/README.md](../tools/posthog/README.md) — dashboards-as-code conventions -- [libs/telemetry/README.md](../libs/telemetry/README.md) — telemetry trust contract +- [Growth architecture and operations](../../docs/growth/README.md) — contacts, authorization, publishing, and measurement ownership +- [gtm.md](../../gtm.md) — durable strategy +- [docs/gtm/](../../docs/gtm/) — operational docs (ICP, messaging, taxonomy) +- [docs/superpowers/specs/gtm/](../../docs/superpowers/specs/gtm/) — per-workstream design specs +- [tools/posthog/README.md](../../tools/posthog/README.md) — dashboards-as-code conventions +- [libs/telemetry/README.md](../../libs/telemetry/README.md) — telemetry trust contract diff --git a/marketing/cowork/gtm/SKILL.md b/marketing/cowork/gtm/SKILL.md index 6f12e2233..ea7bfff25 100644 --- a/marketing/cowork/gtm/SKILL.md +++ b/marketing/cowork/gtm/SKILL.md @@ -1,6 +1,6 @@ --- name: gtm -description: Threadplane GTM operator. Use to run weekly PostHog snapshots, draft the Notes section, triage inbound leads against the qualified-lead definition, scaffold new workstream specs, and answer "where are we?" questions by reading gtm.md plus the latest report. Invoke any time GTM motion work is happening or the weekly cadence fires. +description: Threadplane GTM operator. Use to run weekly PostHog snapshots, draft the Notes section, triage inbound leads using Growth evidence and authorization, scaffold new workstream specs, and answer "where are we?" questions by reading gtm.md plus the latest report. Invoke any time GTM motion work is happening or the weekly cadence fires. disable-model-invocation: false allowed-tools: Read, Edit, Write, Bash(npm run posthog:*), Bash(npm run gtm:*), Bash(gh pr *), Bash(git *), Glob, Grep --- @@ -21,6 +21,8 @@ You are the GTM operator for Threadplane. You own the operational layer of the G Read `gtm.md` first on any invocation. It is the source of truth for category, ICP, phases, exit gates, and non-goals. +Read `docs/growth/README.md` for current architecture, lifecycle authorization, and operator commands. Growth owns contacts and lifecycle outcomes; PostHog is a measurement surface. + ## When invoked, identify the intent Common intents and the procedure to run: @@ -49,21 +51,17 @@ If a previous weekly PR is still unmerged, **comment on the existing PR** with t ## Lead triage procedure -The qualified-lead v1 definition (canonical: `docs/gtm/icp.md` §Enterprise track): - -- Non-personal `email_domain` (rules out gmail/outlook/yahoo/protonmail/icloud/aol/yandex/hotmail/live). -- Non-empty `company` field. -- `track=enterprise` (the surface they came from). +Use the current Growth records and evidence described in `docs/growth/README.md`. The historical qualified-lead rule (company plus non-personal email domain) is not an authorization decision, and the legacy `marketing:lead_qualified` event is not emitted by the website's current Growth form flow. For each inbound lead provided: -1. Verify the three criteria. If any fail, the lead is **unqualified for v1** (still respond personally, but don't count it). -2. For qualified leads: confirm `marketing:lead_qualified` fired in PostHog (server side). If it didn't, flag the enrichment pipeline as broken. +1. Review the provided contact's Growth observations and company/research evidence against `docs/gtm/icp.md`. State missing evidence explicitly. +2. Check Growth authorization and suppression state before proposing outreach. Company fit and analytics events do not grant consent or override a stop. Use Growth outcomes to verify lifecycle activity; a missing legacy PostHog event does not establish a pipeline failure. 3. Suggest a personal reply that: - Names what they're building back to them (extracted from the body). - Offers one concrete next step: code sketch, 15-minute call, or a documented pattern that fits. - Avoids calendar-first responses (the contract says "code, not a calendar invite"). -4. Record the lead in `docs/gtm/reports/-weekly.md` Notes if it's the first qualified lead from a new `source_page`. +4. Record an aggregate observation in `docs/gtm/reports/-weekly.md` Notes when meaningful. Keep contact identity and raw lead text in Growth; draft replies for human review and do not send them without authorization. ## New workstream procedure @@ -108,10 +106,12 @@ Answer with a tight status: what phase, what's next, what's blocked. ## Reference - [../README.md](../README.md) — install instructions -- [../../gtm.md](../../gtm.md) — strategy -- [../../docs/gtm/icp.md](../../docs/gtm/icp.md) — ICP -- [../../docs/gtm/messaging.md](../../docs/gtm/messaging.md) — positioning, hero, comparison framing -- [../../docs/gtm/taxonomy.md](../../docs/gtm/taxonomy.md) — events, properties, CTA ids, redaction rules -- [../../tools/posthog/README.md](../../tools/posthog/README.md) — dashboards-as-code -- [../../libs/telemetry/README.md](../../libs/telemetry/README.md) — telemetry trust contract -- [../../docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md](../../docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md) — the meta-spec this skill operates against +- [../../../gtm.md](../../../gtm.md) — strategy +- [../../../docs/gtm/icp.md](../../../docs/gtm/icp.md) — ICP +- [../../../docs/gtm/messaging.md](../../../docs/gtm/messaging.md) — positioning, hero, comparison framing +- [../../../docs/gtm/taxonomy.md](../../../docs/gtm/taxonomy.md) — events, properties, CTA ids, redaction rules +- [../../../tools/posthog/README.md](../../../tools/posthog/README.md) — dashboards-as-code +- [../../../libs/telemetry/README.md](../../../libs/telemetry/README.md) — telemetry trust contract +- [../../../docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md](../../../docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md) — the meta-spec this skill operates against + +- [Growth architecture and operations](../../../docs/growth/README.md) — current ownership and operator commands diff --git a/marketing/cowork/marketing/SKILL.md b/marketing/cowork/marketing/SKILL.md deleted file mode 100644 index 06c29d1d1..000000000 --- a/marketing/cowork/marketing/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: marketing -description: | - Threadplane marketing pipeline operator. Reads `marketing/cowork/inbox/*.json` - draft bundles, presents them for review in conversation, supports - edit/approve/reject decisions, and dispatches approved drafts to channel - adapters. Invoke when "drafts are waiting" or when the user wants to - produce a thread/post for X, LinkedIn, Dev.to, or Reddit. -status: stub ---- - -# Marketing Cowork skill — STUB - -Implementation lands in the cowork-loop sub-spec -(`docs/superpowers/specs/marketing/-cowork-loop-design.md`). - -This file exists so the directory shape and skill name are reserved. -Do NOT invoke this skill until the cowork-loop sub-spec is merged. - -## Expected file conventions (preview) - -- `marketing/cowork/inbox/.json` — drafts awaiting review (agent writes) -- `marketing/cowork/outbox/.json` — approved + posted (skill writes) -- `marketing/cowork/archive/.json` — rejected or expired (skill writes) - -`` is `YYYY-MM-DD-`. - -## Expected DraftBundle shape (preview) - -See `docs/superpowers/specs/marketing/2026-05-17-marketing-meta-design.md` §5.3. diff --git a/marketing/cowork/outbox/dry-runs/.gitignore b/marketing/cowork/outbox/dry-runs/.gitignore deleted file mode 100644 index db9ec4d54..000000000 --- a/marketing/cowork/outbox/dry-runs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.json -!.gitkeep diff --git a/marketing/cowork/outbox/dry-runs/.gitkeep b/marketing/cowork/outbox/dry-runs/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/marketing/metrics/package.json b/marketing/metrics/package.json deleted file mode 100644 index 0675b3aa7..000000000 --- a/marketing/metrics/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@threadplane/marketing-metrics", - "version": "0.0.0", - "license": "MIT", - "main": "./src/index.ts", - "types": "./src/index.ts", - "repository": { - "type": "git", - "url": "https://github.com/cacheplane/angular-agent-framework.git", - "directory": "marketing/metrics" - }, - "sideEffects": false, - "private": true -} diff --git a/marketing/metrics/project.json b/marketing/metrics/project.json deleted file mode 100644 index 5528232b8..000000000 --- a/marketing/metrics/project.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "marketing-metrics", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "marketing/metrics/src", - "projectType": "library", - "tags": ["scope:marketing", "type:lib"], - "targets": { - "build": { - "executor": "@nx/js:tsc", - "outputs": ["{workspaceRoot}/dist/marketing/metrics"], - "options": { - "outputPath": "dist/marketing/metrics", - "main": "marketing/metrics/src/index.ts", - "tsConfig": "marketing/metrics/tsconfig.lib.json" - } - }, - "lint": { - "executor": "@nx/eslint:lint" - } - } -} diff --git a/marketing/metrics/src/index.ts b/marketing/metrics/src/index.ts deleted file mode 100644 index b64d46400..000000000 --- a/marketing/metrics/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -// -// @threadplane/marketing-metrics — Metrics ingestion for the marketing pipeline. -// Skeleton only. Implementation lands in the metrics-ingest sub-spec. - -export interface RunOptions { - sinceHours?: number; -} - -export interface RunResult { - posts: number; - eventsEmitted: number; -} - -export function run(_opts?: RunOptions): Promise { - throw new Error( - '@threadplane/marketing-metrics: run() not yet implemented. See metrics-ingest sub-spec.', - ); -} diff --git a/marketing/metrics/tsconfig.json b/marketing/metrics/tsconfig.json deleted file mode 100644 index cf0cba0d6..000000000 --- a/marketing/metrics/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "files": [], - "references": [{ "path": "./tsconfig.lib.json" }] -} diff --git a/marketing/metrics/tsconfig.lib.json b/marketing/metrics/tsconfig.lib.json deleted file mode 100644 index 643573425..000000000 --- a/marketing/metrics/tsconfig.lib.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "declaration": true - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts"] -} diff --git a/package-lock.json b/package-lock.json index ebf2d3404..85a58e1a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,7 @@ "apps/*", "libs/*", "marketing/assets", - "marketing/channels", - "marketing/agent", - "marketing/metrics" + "marketing/channels" ], "dependencies": { "@ag-ui/client": "^0.0.59", @@ -136,7 +134,8 @@ "devDependencies": { "@dawn-ai/evals": "0.8.26", "@dawn-ai/testing": "0.8.26", - "@dawn-ai/workspace": "0.8.26" + "@dawn-ai/workspace": "0.8.26", + "@threadplane-internal/growth-capture": "0.0.0" }, "engines": { "node": "24" @@ -324,8 +323,8 @@ "@neondatabase/serverless": "0.10.4", "@react-email/render": "2.1.0", "@threadplane-internal/growth": "0.0.0", + "@threadplane-internal/growth-capture": "0.0.0", "hono": "4.13.5", - "parse5": "8.0.1", "resend": "6.10.0", "zod": "4.4.3" }, @@ -353,30 +352,6 @@ } } }, - "apps/lifecycle/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "apps/lifecycle/node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "apps/lifecycle/node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -572,6 +547,47 @@ "@neondatabase/serverless": "0.10.4" } }, + "libs/growth-capture": { + "name": "@threadplane-internal/growth-capture", + "version": "0.0.0", + "dependencies": { + "parse5": "8.0.1", + "zod": "4.4.3" + } + }, + "libs/growth-capture/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "libs/growth-capture/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "libs/growth-capture/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "libs/langgraph": { "name": "@threadplane/langgraph", "version": "0.0.66", @@ -653,13 +669,8 @@ "react-dom": "^19.0.0" } }, - "marketing/agent": { - "name": "@threadplane/marketing-agent", - "version": "0.0.0", - "license": "MIT" - }, "marketing/assets": { - "name": "@threadplane/marketing-assets", + "name": "@threadplane-internal/marketing-assets", "version": "0.0.0", "license": "MIT", "dependencies": { @@ -669,12 +680,7 @@ } }, "marketing/channels": { - "name": "@threadplane/marketing-channels", - "version": "0.0.0", - "license": "MIT" - }, - "marketing/metrics": { - "name": "@threadplane/marketing-metrics", + "name": "@threadplane-internal/marketing-channels", "version": "0.0.0", "license": "MIT" }, @@ -21484,6 +21490,10 @@ "resolved": "libs/growth", "link": true }, + "node_modules/@threadplane-internal/growth-capture": { + "resolved": "libs/growth-capture", + "link": true + }, "node_modules/@threadplane-internal/growth-research": { "resolved": "apps/growth-research", "link": true @@ -21492,6 +21502,14 @@ "resolved": "apps/lifecycle", "link": true }, + "node_modules/@threadplane-internal/marketing-assets": { + "resolved": "marketing/assets", + "link": true + }, + "node_modules/@threadplane-internal/marketing-channels": { + "resolved": "marketing/channels", + "link": true + }, "node_modules/@threadplane/a2ui": { "resolved": "libs/a2ui", "link": true @@ -21532,22 +21550,6 @@ "resolved": "libs/langgraph", "link": true }, - "node_modules/@threadplane/marketing-agent": { - "resolved": "marketing/agent", - "link": true - }, - "node_modules/@threadplane/marketing-assets": { - "resolved": "marketing/assets", - "link": true - }, - "node_modules/@threadplane/marketing-channels": { - "resolved": "marketing/channels", - "link": true - }, - "node_modules/@threadplane/marketing-metrics": { - "resolved": "marketing/metrics", - "link": true - }, "node_modules/@threadplane/middleware": { "resolved": "libs/middleware", "link": true diff --git a/package.json b/package.json index 4d3df39c7..18e8f0243 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,14 @@ "posthog:apply": "nx run posthog-tools:sync:apply", "posthog:report": "nx run posthog-tools:report", "posthog:quality": "nx run posthog-tools:quality:live", - "posthog:generate-types": "nx run posthog-tools:generate-types" + "posthog:generate-types": "nx run posthog-tools:generate-types", + "growth:report": "npm run growth:observability --", + "growth:research": "nx run growth-research:pilot --", + "growth:analytics:plan": "nx run posthog-tools:sync:plan", + "growth:analytics:report": "nx run posthog-tools:report", + "growth:analytics:quality": "nx run posthog-tools:quality:live", + "growth:search:pull": "npm run gsc:pull", + "growth:search:report": "npm run gsc:report" }, "private": true, "overrides": { @@ -101,9 +108,7 @@ "apps/*", "libs/*", "marketing/assets", - "marketing/channels", - "marketing/agent", - "marketing/metrics" + "marketing/channels" ], "dependencies": { "@ag-ui/client": "^0.0.59", diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index ea330a32c..3cfd9d0eb 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -70,6 +70,7 @@ const LINT_ONLY_FILES = new Set(['eslint.config.mjs']); /** Subset of SCOPE_KEYS that own jobs running `nx lint`. Flipped true * when a LINT_ONLY_FILES entry changes. */ const LINT_SCOPE_KEYS = [ + 'scripts_tests', 'library', 'cockpit', 'website', diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index 79c027838..f22645d79 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -120,6 +120,7 @@ describe('classifyFromAffected — lint-only files', () => { assert.equal(scope.examples_chat, true); assert.equal(scope.growth_lifecycle, true); assert.equal(scope.growth_research, true); + assert.equal(scope.scripts_tests, true); // E2e / smoke / deploy / posthog scopes: false assert.equal(scope.website_e2e, false); assert.equal(scope.cockpit_e2e, false); @@ -155,7 +156,7 @@ describe('growth research project ownership', () => { await readFile('apps/growth-research/project.json', 'utf8') ); const scope = classifyFromAffected( - ['apps/growth-research/src/pilot/context.ts'], + ['apps/growth-research/src/company/context.ts'], [{ name: project.name, tags: project.tags }] ); assert.deepEqual(scope, { ...emptyScope(), growth_research: true }); @@ -163,7 +164,7 @@ describe('growth research project ownership', () => { it('Nx selects Growth Research for a pilot source change', () => { assert.ok( - nxAffectedFiles('apps/growth-research/src/pilot/context.ts').includes( + nxAffectedFiles('apps/growth-research/src/company/context.ts').includes( 'growth-research' ) ); @@ -506,6 +507,20 @@ describe('classifyFromAffected — apps + fallback paths via namedInputs', () => }); describe('classifyFromAffected — tag isolation', () => { + it('marketing operator changes select the job that runs their boundary checks', async () => { + for (const name of ['assets', 'channels']) { + const project = JSON.parse(await readFile(`marketing/${name}/project.json`, 'utf8')); + const scope = classifyFromAffected( + [`marketing/${name}/src/index.ts`], + [{ name: project.name, tags: project.tags }] + ); + assert.equal(scope.scripts_tests, true); + assert.equal(scope.growth_lifecycle, false); + } + const workflow = await readFile('.github/workflows/ci.yml', 'utf8'); + const job = workflow.split(' scripts-tests:')[1].split('\n library:')[0]; + assert.match(job, /nx run-many -t lint test --projects=marketing-assets,marketing-channels/); + }); it('tags not prefixed with "scope:" are ignored', () => { const scope = classifyFromAffected( ['some.ts'], diff --git a/tools/posthog/README.md b/tools/posthog/README.md index 5c929fa95..0f821bae1 100644 --- a/tools/posthog/README.md +++ b/tools/posthog/README.md @@ -1,5 +1,7 @@ # PostHog dashboards-as-code +Part of [Growth architecture and operations](../../docs/growth/README.md). + > Spec: [analytics-foundation-1a-dashboards-as-code](../../docs/superpowers/specs/gtm/2026-05-14-analytics-foundation-1a-dashboards-as-code-design.md). PostHog is configured via a Public-API-driven sync script — not through the PostHog UI. Every dashboard, insight, and cohort the GTM motion depends on is a JSON file in this directory. The sync tool reconciles JSON ↔ PostHog. Git is the source of truth. diff --git a/tsconfig.base.json b/tsconfig.base.json index aeebe9b41..3a00a4735 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -36,15 +36,15 @@ "@threadplane/cockpit-telemetry": ["libs/cockpit-telemetry/src/index.ts"], "@threadplane/example-layouts": ["libs/example-layouts/src/public-api.ts"], "@threadplane/langgraph": ["libs/langgraph/src/public-api.ts"], - "@threadplane/marketing-agent": ["marketing/agent/src/index.ts"], - "@threadplane/marketing-assets": ["marketing/assets/src/index.ts"], - "@threadplane/marketing-channels": ["marketing/channels/src/index.ts"], - "@threadplane/marketing-metrics": ["marketing/metrics/src/index.ts"], + "@threadplane-internal/marketing-assets": ["marketing/assets/src/index.ts"], + "@threadplane-internal/marketing-channels": ["marketing/channels/src/index.ts"], "@threadplane/render": ["libs/render/src/public-api.ts"], "@threadplane/telemetry": ["libs/telemetry/src/index.ts"], "@threadplane/telemetry/browser": ["libs/telemetry/src/browser/public-api.ts"], "@threadplane/telemetry/node": ["libs/telemetry/src/node/index.ts"], "@threadplane/telemetry/shared": ["libs/telemetry/src/shared/public-api.ts"], + "@threadplane-internal/growth-capture": ["libs/growth-capture/src/index.ts"], + "@threadplane-internal/growth-capture/firecrawl": ["libs/growth-capture/src/firecrawl.ts"], "@threadplane-internal/growth": ["libs/growth/src/index.ts"], "@threadplane-internal/e2e-harness": ["libs/e2e-harness/src/index.ts"], "@threadplane-internal/e2e-harness/global-teardown": ["libs/e2e-harness/src/global-teardown.ts"]