Tenant-scoped TypeScript application for:
- ingesting internal and market signals
- turning them into editorial opportunities
- generating drafts
- tracking what was actually published
- ingesting LinkedIn performance
- deriving scorecards, idea families, family guidance, and opportunity priority
- serving the admin UI
- running the sales signal pipeline
In plain language:
- Fresh is no longer just a draft generator
- it is an editorial operating system with a closed feedback loop
- it learns from what got published and how it performed
Fresh has 3 clear system roles:
- Fresh DB: the canonical source of truth
- opportunities
- drafts
PublishedArtifactAnalyticsSnapshotDerivedScore- idea-family and advisory layers
- Notion Content Calendar: the operator workflow surface
- planning
- owner
- status
- final published text
- LinkedIn URL
- LinkedIn: the external platform truth
- post URL / URN when available
- analytics numbers
Important rule:
ContentOpportunityis not the content calendar- workflow stays in Notion
- publication truth and learning live in Fresh
There are 2 different Notion surfaces:
- Content Opportunities: a Fresh-managed mirror for operators to inspect
- not the database of record
- humans should not use it as the planning surface
- Content Calendar: the planning / execution surface
- owner
- schedule
- publication status
- final published text and URL when available
In plain language:
- the opportunities view and the calendar are not the same Notion database
- they must not share the same database ID
- runtime calendar sync only depends on the Content Calendar database
The editorial loop now works like this:
- Fresh ingests raw signals from configured sources.
- Fresh turns those signals into
Opportunityrows. - Fresh generates
Draftrows from selected opportunities. - Operators manage planning in the Notion Content Calendar.
- When a post is actually published, Fresh crystallises a
PublishedArtifact. - Fresh ingests LinkedIn analytics into append-only
AnalyticsSnapshotrows. - Fresh computes
DerivedScorerows from canonical analytics. - Fresh derives idea families, family matching, variant guidance, family coverage gaps, and opportunity priority advisories.
In plain language:
- Fresh now knows what got published
- how it performed
- which family it belongs to
- whether a new opportunity is fresh, too close, promising, or low leverage
The multi-company isolation program is already shipped.
Meaning:
- tenant context is explicit
- credentials and settings are company-scoped
- fail-closed guards are active
- RLS is in place
- connectors, Notion, and sales flows are tenant-scoped
If you need the detailed plan and rationale, read:
- PostgreSQL is the source of truth.
- Every content command is tenant-scoped and requires
--company <slug>. - Notion is a workflow surface, not the database of record.
- Tone of voice is repo-first.
- base profiles live in
editorial/profiles - if
NOTION_TONE_OF_VOICE_DB_IDis configured, Notion entries are applied as overrides on top of those repo profiles - Notion is not the canonical source unless the architecture is explicitly changed
- base profiles live in
- LinkedIn analytics ingestion is append-only.
- Canonical analytics precedence is:
api > csv > manual
- Historical format analysis must use frozen
PublishedArtifact.format, not mutable opportunity fields.
In plain language:
- engineering owns the base voice profiles in the repo
- Notion can refine them, but does not replace them
- this avoids split-brain about which voice definition wins at runtime
Install dependencies:
pnpm installGenerate Prisma client:
pnpm prisma:generateApply migrations:
pnpm prisma:migrate:deployLocal developer migration flow:
pnpm prisma:migrate:devTypecheck:
pnpm run typecheckUnit tests:
pnpm testIntegration tests:
pnpm test:integrationBuild:
pnpm run buildAll commands below require --company <slug>.
pnpm run ingest:run -- --company <slug>Dry-run:
pnpm exec tsx src/cli.ts ingest:run --company <slug> --dry-runpnpm run market-research:run -- --company <slug>Dry-run:
pnpm exec tsx src/cli.ts market-research:run --company <slug> --dry-runTurns source items into editorial opportunities and enriches existing ones.
pnpm run intelligence:run -- --company <slug>Dry-run:
pnpm exec tsx src/cli.ts intelligence:run --company <slug> --dry-runpnpm run draft:generate -- --company <slug> --opportunity-id <opportunity-id>pnpm exec tsx src/cli.ts draft:generate-ready --company <slug>Dry-run:
pnpm exec tsx src/cli.ts draft:generate-ready --company <slug> --dry-runReads the operator workflow surface and crystallises publication truth into Fresh.
pnpm exec tsx src/cli.ts notion:calendar:sync --company <slug>This command is the bridge from:
- planned draft in Notion
- to actual published artifact in Fresh
pnpm exec tsx src/cli.ts analytics:snapshot:ingest --company <slug> --csv <path-to-linkedin-export.csv>Important:
- CSV is a primary ingestion path, not a temporary hack
- URNs are never derived by parsing URLs
- duplicate
(artifact, date, source)rows are rejected
Recomputes:
DerivedScore- family variant coverage
- variant guidance
- opportunity priority advisories
pnpm run feedback:recompute-scores -- --company <slug>Force recompute:
pnpm run feedback:recompute-scores -- --company <slug> --forceThis is the explicit recompute path for family clustering and related advisory layers.
It requires 3 explicit method flags:
--cluster-method--state-method--candidate-rule-method
Example:
pnpm run feedback:recompute-families -- --company <slug> \
--cluster-method <cluster-method> \
--state-method <state-method> \
--candidate-rule-method <candidate-rule-method>Fresh refuses to run this command without those explicit method values.
That matters because:
- family logic is derived
- family logic is versioned
- reruns must be intentional
The normal operator loop is now:
pnpm run ingest:run -- --company <slug>
pnpm run market-research:run -- --company <slug>
pnpm run intelligence:run -- --company <slug>
pnpm exec tsx src/cli.ts draft:generate-ready --company <slug>
pnpm exec tsx src/cli.ts notion:calendar:sync --company <slug>
pnpm exec tsx src/cli.ts analytics:snapshot:ingest --company <slug> --csv <path.csv>
pnpm run feedback:recompute-scores -- --company <slug>In plain language:
- generate opportunities
- generate drafts
- sync what actually got published
- ingest LinkedIn results
- recompute learning layers
For one-command execution of the daily editorial pass, use pipeline:nightly:
pnpm run pipeline:nightly -- --company <slug>It chains the same five steps (ingest:run → intelligence:run → draft:generate-ready → notion:calendar:sync → feedback:recompute-scores) under a single parent SyncRun, one company per invocation.
Flags:
--skip <step,step>— omit one or more steps (example:--skip notion:calendar:sync,feedback:recompute-scores)--stop-on-failure— abort the run on the first step failure instead of soft-continuing--max-cost-usd <n>— stop between steps once cumulative LLM spend for this run reaches the cap
Error policy:
- default = soft-continue on ordinary step failures (each failure is recorded on the parent run's warnings)
PipelineHardStopErrorandForbiddenErroralways hard-stop, regardless of--stop-on-failure- cost-cap breach hard-stops the run but the final status is
completed(the cap was respected, not a failure)
No scheduler is wired in yet — invoke the command from any cron, systemd timer, or CI job.
Start the server:
pnpm run server:startThe admin includes, among others:
DashboardSource ItemsOpportunitiesDraftsPublishedScorecardIdea Families- review queues
- doctrine and source config pages
The admin is now an operator tool for:
- editorial review
- publication truth inspection
- analytics ingestion and verification
- scorecard reading
- family and priority guidance
Fresh stores publication truth in PublishedArtifact.
That row freezes:
- company
- draft
- opportunity
- actual owner
- actual publish timestamp
- LinkedIn URL
- final published text
- frozen format
Analytics then land in AnalyticsSnapshot.
Scores and advisory layers are derived from there.
Important consequences:
- raw analytics are never overwritten
- derived layers are recomputable
- historical reads should rely on frozen published fields, not mutable opportunity fields
Fresh currently supports these post-publication and pre-publication read layers:
Scorecard- top posts
- flop posts
- surprising posts
Idea Families- cluster related published posts
Opportunity Family Candidates- likely family match for an open opportunity
Opportunity Variant Guidancefresh_varianttoo_closeunclear
Family Variant Coverage- which owner/format combinations are proven, weak, or credibly untested
Opportunity Priority Advisoryhigh_leverageworth_testingholdunclear
These are advisory layers.
Meaning:
- they help the operator decide
- they do not auto-publish
- they do not auto-schedule
Backfill evidence packs:
pnpm run backfill:evidence -- --company <slug>Dry-run:
pnpm exec tsx src/cli.ts backfill:evidence --company <slug> --dry-runCleanup retention:
pnpm run cleanup:retention -- --company <slug>Cleanup Claap publishability drift:
pnpm run cleanup:claap-publishability -- --company <slug>Dry-run:
pnpm exec tsx src/cli.ts cleanup:claap-publishability --company <slug> --dry-runInspect tone-of-voice profiles:
pnpm run tone:inspect -- --company <slug>Run scope migration helper:
pnpm run scope-migration:runSales commands use src/sales/cli.ts, not the editorial CLI.
pnpm run sales:check-config
pnpm run sales:preflightpnpm run sales:syncpnpm run sales:extract
pnpm run sales:extract -- --reprocess
pnpm run sales:extract -- --drain
pnpm run sales:extract -- --batch-size 100pnpm run sales:detect
pnpm run sales:match
pnpm run sales:cleanup
pnpm run sales:status
pnpm run sales:cleanup-orphansIf you touch the feedback loop, read:
- docs/ROADMAP.md
- docs/notion_content_calendar_contract.md
- docs/feedback_slice3_format_freeze_runbook.md
Those docs define the non-negotiable rules around:
- tenant scoping
- Notion handoff
- publication truth
- append-only analytics
- frozen format
- versioned derived layers