diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
new file mode 100644
index 0000000..f15e673
--- /dev/null
+++ b/.github/workflows/build-and-test.yml
@@ -0,0 +1,28 @@
+name: Build and Test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore
+
+ - name: Test
+ run: dotnet test --no-build --verbosity normal
diff --git a/.gitignore b/.gitignore
index ce89292..065916a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,12 @@
*.sln.docstates
*.env
+# macOS
+.DS_Store
+
+# Ralph
+scripts/ralph/.last-branch
+
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
diff --git a/Writegeist.slnx b/Writegeist.slnx
new file mode 100644
index 0000000..b16396c
--- /dev/null
+++ b/Writegeist.slnx
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/writegeist-implementation-plan.md b/docs/writegeist-implementation-plan.md
new file mode 100644
index 0000000..bbc7520
--- /dev/null
+++ b/docs/writegeist-implementation-plan.md
@@ -0,0 +1,668 @@
+# Writegeist — Implementation Plan
+
+> A .NET CLI tool that clones a person's writing style from public social media posts and generates platform-specific content on demand.
+
+---
+
+## 1. Overview
+
+Writegeist ingests public social media posts (LinkedIn, X/Twitter, Instagram, Facebook), analyses the author's writing style into a reusable profile, and generates new posts in that style for any supported platform. It supports both Anthropic (Claude) and OpenAI APIs as the LLM backend.
+
+### Core Workflow
+
+1. **Ingest** — Fetch public posts from a social profile URL/handle, or manually paste/import from a file.
+2. **Analyse** — Send all ingested posts for a person to the LLM to extract a structured style profile. Store the profile in SQLite.
+3. **Generate** — Given bullet points or a topic + a target platform, produce a single draft post using the style profile and platform-specific conventions.
+4. **Refine** — Iterate on the last generated draft with natural language feedback, preserving style consistency.
+
+---
+
+## 2. Tech Stack
+
+| Concern | Choice |
+|---------|--------|
+| Runtime | .NET 10 (latest preview or stable, whichever is current) |
+| Project type | Console app using `DevJonny.InteractiveCli` (interactive menu-driven CLI) |
+| CLI framework | `DevJonny.InteractiveCli` — menu/action based interactive CLI built on Spectre.Console + CommandLineParser |
+| Database | SQLite via `Microsoft.Data.Sqlite` + Dapper or raw ADO.NET |
+| HTTP | `HttpClient` via `IHttpClientFactory` |
+| HTML parsing (scraping) | `AngleSharp` |
+| JSON serialisation | `System.Text.Json` (STJ) |
+| LLM: Anthropic | `Anthropic` NuGet package (official C# SDK) — verify this exists; if not, use raw HTTP to `https://api.anthropic.com/v1/messages` |
+| LLM: OpenAI | `OpenAI` NuGet package (official) or `Azure.AI.OpenAI` |
+| DI | `Microsoft.Extensions.DependencyInjection` + `Microsoft.Extensions.Hosting` (Generic Host) — provided by InteractiveCli |
+| Configuration | `Microsoft.Extensions.Configuration` (appsettings.json + env vars + user-secrets) |
+| Logging | `Serilog` (provided by InteractiveCli) |
+
+---
+
+## 3. Solution Structure
+
+```
+Writegeist/
+├── Writegeist.sln
+├── src/
+│ ├── Writegeist.Cli/ # Console app entry point
+│ │ ├── Program.cs # Host builder + InteractiveCli bootstrapping
+│ │ ├── Menus/
+│ │ │ ├── MainMenu.cs # Top-level menu (isTopLevel: true)
+│ │ │ ├── IngestMenu.cs # Sub-menu for ingestion options
+│ │ │ └── ProfileMenu.cs # Sub-menu for profile management
+│ │ ├── Actions/
+│ │ │ ├── IngestFromFileAction.cs # SingleActionAsync — ingest from file
+│ │ │ ├── IngestInteractiveAction.cs # RepeatableActionAsync — paste posts interactively
+│ │ │ ├── IngestFromUrlAction.cs # SingleActionAsync — ingest from URL/handle
+│ │ │ ├── AnalyseAction.cs # SingleActionAsync — analyse style
+│ │ │ ├── GenerateAction.cs # SingleActionAsync — generate a post
+│ │ │ ├── RefineAction.cs # RepeatableActionAsync — refine loop
+│ │ │ ├── ShowProfileAction.cs # SingleActionAsync — display a profile
+│ │ │ └── ListProfilesAction.cs # SingleActionAsync — list all profiles
+│ │ └── appsettings.json
+│ │
+│ ├── Writegeist.Core/ # Domain logic, interfaces, models
+│ │ ├── Models/
+│ │ │ ├── Person.cs
+│ │ │ ├── RawPost.cs
+│ │ │ ├── StyleProfile.cs
+│ │ │ ├── GeneratedDraft.cs
+│ │ │ └── Platform.cs # enum: LinkedIn, X, Instagram, Facebook
+│ │ ├── Interfaces/
+│ │ │ ├── IContentFetcher.cs # Per-platform content ingestion
+│ │ │ ├── ILlmProvider.cs # Analyse / Generate / Refine
+│ │ │ ├── IStyleProfileRepository.cs
+│ │ │ ├── IPostRepository.cs
+│ │ │ └── IPersonRepository.cs
+│ │ └── Services/
+│ │ ├── StyleAnalyser.cs # Orchestrates style profile creation
+│ │ ├── PostGenerator.cs # Orchestrates post generation
+│ │ └── PlatformConventions.cs # Platform-specific rules
+│ │
+│ ├── Writegeist.Infrastructure/ # Implementations
+│ │ ├── Fetchers/
+│ │ │ ├── LinkedInFetcher.cs
+│ │ │ ├── XTwitterFetcher.cs
+│ │ │ ├── InstagramFetcher.cs
+│ │ │ ├── FacebookFetcher.cs
+│ │ │ └── ManualFetcher.cs # File/interactive paste
+│ │ ├── LlmProviders/
+│ │ │ ├── AnthropicProvider.cs
+│ │ │ └── OpenAiProvider.cs
+│ │ └── Persistence/
+│ │ ├── SqliteDatabase.cs # Schema init + migrations
+│ │ ├── SqlitePersonRepository.cs
+│ │ ├── SqlitePostRepository.cs
+│ │ └── SqliteStyleProfileRepository.cs
+│ │
+│ └── Writegeist.Tests/ # xUnit test project
+│ ├── Services/
+│ │ ├── StyleAnalyserTests.cs
+│ │ ├── PostGeneratorTests.cs
+│ │ └── PlatformConventionsTests.cs
+│ ├── Fetchers/
+│ │ └── ManualFetcherTests.cs
+│ └── Persistence/
+│ └── SqliteRepositoryTests.cs
+```
+
+---
+
+## 4. Interactive CLI Design
+
+Writegeist uses `DevJonny.InteractiveCli` (NuGet: `DevJonny.InteractiveCli`) which provides a menu-driven interactive experience built on Spectre.Console. Instead of traditional CLI commands with flags, each workflow step is an **action** presented via interactive menus. User input (person name, platform, topic, etc.) is collected via Spectre.Console prompts within each action.
+
+### 4.1 Menu Structure
+
+```
+Main Menu
+├── Ingest Posts → IngestMenu
+│ ├── From File → IngestFromFileAction
+│ ├── From URL / Handle → IngestFromUrlAction
+│ ├── Interactive Paste → IngestInteractiveAction
+│ └── Back
+├── Analyse Style → AnalyseAction
+├── Generate Post → GenerateAction
+├── Refine Last Draft → RefineAction
+├── Profiles → ProfileMenu
+│ ├── Show Profile → ShowProfileAction
+│ ├── List All Profiles → ListProfilesAction
+│ └── Back
+└── Quit
+```
+
+### 4.2 Program.cs (Entry Point)
+
+```csharp
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using InteractiveCLI;
+using Serilog;
+using Writegeist.Cli.Menus;
+
+var configuration = new ConfigurationBuilder()
+ .AddJsonFile("appsettings.json")
+ .AddEnvironmentVariables()
+ .Build();
+
+Log.Logger = new LoggerConfiguration()
+ .ReadFrom.Configuration(configuration)
+ .CreateBootstrapLogger();
+
+var host =
+ Host.CreateDefaultBuilder()
+ .AddInteractiveCli(configuration, services =>
+ {
+ // Register Writegeist-specific services (repositories, LLM providers, fetchers, etc.)
+ })
+ .Build();
+
+host.UseInteractiveCli(_ => new MainMenu(), args);
+```
+
+### 4.3 Actions Detail
+
+Each action extends `SingleActionAsync` or `RepeatableActionAsync` from InteractiveCLI and collects input via Spectre.Console prompts.
+
+#### IngestFromFileAction (SingleActionAsync)
+
+Prompts for:
+- Person name (text prompt)
+- Platform (selection prompt: LinkedIn, X, Instagram, Facebook)
+- File path (text prompt)
+
+Behaviour:
+- Creates the person in SQLite if they don't exist.
+- Reads the file, splits on `---` separators.
+- Stores each post as a `RawPost` with SHA-256 content hash for dedup.
+- Prints summary: "Ingested 14 new posts for John Smith from LinkedIn (3 duplicates skipped)."
+
+#### IngestInteractiveAction (RepeatableActionAsync)
+
+Prompts for:
+- Person name (text prompt, on first iteration)
+- Platform (selection prompt, on first iteration)
+- Post content (multi-line text prompt)
+
+Returns `false` to keep looping, `true` when user confirms they're done. Stores each post as it's entered.
+
+#### IngestFromUrlAction (SingleActionAsync)
+
+Prompts for:
+- Person name (text prompt)
+- Platform (selection prompt)
+- URL or handle (text prompt)
+
+Dispatches to the appropriate `IContentFetcher`. If the platform doesn't support automated fetching, displays a warning and suggests using File or Interactive instead.
+
+#### AnalyseAction (SingleActionAsync)
+
+Prompts for:
+- Person name (selection prompt from existing persons)
+- LLM provider (selection prompt: Anthropic, OpenAI — defaults from config)
+
+Behaviour:
+- Loads all `RawPost` records for the person.
+- Sends them to the LLM with the style analysis prompt (see §7.1).
+- Stores the resulting `StyleProfile` JSON in SQLite, versioned with a timestamp.
+- Prints the profile summary to the console.
+
+#### GenerateAction (SingleActionAsync)
+
+Prompts for:
+- Person name (selection prompt from persons with a style profile)
+- Target platform (selection prompt)
+- Topic/key points (multi-line text prompt, or file path option)
+- LLM provider (selection prompt, optional override)
+
+Behaviour:
+- Loads the latest `StyleProfile` for the person.
+- Loads platform conventions for the target platform (see §6).
+- Sends the generation prompt (see §7.2) to the LLM.
+- Stores the result as a `GeneratedDraft` in SQLite.
+- Prints the generated post to stdout.
+
+#### RefineAction (RepeatableActionAsync)
+
+Prompts for:
+- Feedback (text prompt)
+
+Behaviour:
+- Loads the most recent `GeneratedDraft` (and its associated style profile + platform).
+- Sends the refinement prompt (see §7.3) with the previous draft + feedback.
+- Stores the new version as a `GeneratedDraft` linked to the previous one.
+- Prints the refined post.
+- Asks "Refine again?" — returns `true` to stop, `false` to continue looping.
+
+#### ShowProfileAction (SingleActionAsync)
+
+Prompts for:
+- Person name (selection prompt)
+
+Displays the full style profile in a formatted table.
+
+#### ListProfilesAction (SingleActionAsync)
+
+Lists all persons with style profiles in a table.
+
+---
+
+## 5. Data Model (SQLite)
+
+### Schema
+
+```sql
+CREATE TABLE IF NOT EXISTS persons (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS raw_posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id INTEGER NOT NULL REFERENCES persons(id),
+ platform TEXT NOT NULL, -- 'linkedin', 'x', 'instagram', 'facebook'
+ content TEXT NOT NULL,
+ content_hash TEXT NOT NULL, -- SHA-256 for dedup
+ source_url TEXT,
+ fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(person_id, content_hash)
+);
+
+CREATE TABLE IF NOT EXISTS style_profiles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id INTEGER NOT NULL REFERENCES persons(id),
+ profile_json TEXT NOT NULL, -- Structured style profile as JSON
+ provider TEXT NOT NULL, -- 'anthropic' or 'openai'
+ model TEXT NOT NULL, -- e.g. 'claude-sonnet-4-20250514'
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS generated_drafts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id INTEGER NOT NULL REFERENCES persons(id),
+ style_profile_id INTEGER NOT NULL REFERENCES style_profiles(id),
+ platform TEXT NOT NULL,
+ topic TEXT NOT NULL,
+ content TEXT NOT NULL,
+ parent_draft_id INTEGER REFERENCES generated_drafts(id), -- NULL for first draft, set for refinements
+ feedback TEXT, -- refinement feedback (NULL for first draft)
+ provider TEXT NOT NULL,
+ model TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+```
+
+Run schema creation on first use via `SqliteDatabase.EnsureCreated()`.
+
+---
+
+## 6. Platform Conventions
+
+Encapsulate in `PlatformConventions.cs` as a static lookup. Each platform defines:
+
+```csharp
+public record PlatformRules(
+ string Name,
+ int? MaxCharacters, // null = no hard limit
+ int RecommendedMaxLength, // soft target in characters
+ bool SupportsHashtags,
+ int RecommendedHashtagCount, // 0 if not typical
+ string HashtagPlacement, // "inline", "end", "none"
+ bool SupportsEmoji,
+ string EmojiGuidance, // "sparingly", "freely", "none"
+ string ToneGuidance, // platform-typical tone advice
+ string FormattingNotes // line breaks, paragraphs, etc.
+);
+```
+
+### Platform defaults:
+
+**LinkedIn:**
+- Max: 3,000 characters
+- Recommended: 1,200–1,800 chars
+- Hashtags: 3–5 at the end
+- Emoji: sparingly (bullet replacements, single opener)
+- Tone: professional but personal, thought-leadership
+- Formatting: short paragraphs (1–2 sentences), liberal line breaks, hook in first line
+
+**X/Twitter:**
+- Max: 280 characters (single tweet) or 25,000 (long-form post)
+- Default to 280 unless topic clearly needs a thread
+- Hashtags: 1–2 inline
+- Emoji: moderate
+- Tone: conversational, concise, punchy
+- Formatting: no paragraph breaks in single tweets
+
+**Instagram:**
+- Max: 2,200 characters
+- Recommended: 500–1,000 chars
+- Hashtags: up to 30, in a separate block at the end (after line breaks)
+- Emoji: freely
+- Tone: casual, visual-language, storytelling
+- Formatting: line breaks for readability, can use dot separators for spacing
+
+**Facebook:**
+- Max: 63,206 characters
+- Recommended: 400–800 chars
+- Hashtags: 1–3 or none
+- Emoji: moderate
+- Tone: conversational, community-oriented
+- Formatting: short paragraphs, conversational
+
+---
+
+## 7. LLM Prompts
+
+### 7.1 Style Analysis Prompt
+
+```
+You are a writing style analyst. Analyse the following social media posts by the same author and produce a detailed, structured style profile in JSON format.
+
+## Posts to analyse:
+
+{posts_block}
+
+## Output format (JSON):
+
+{
+ "vocabulary": {
+ "complexity_level": "simple | moderate | advanced",
+ "jargon_domains": ["tech", "marketing", ...],
+ "favourite_words": ["word1", "word2", ...],
+ "words_to_avoid": ["word1", ...],
+ "filler_phrases": ["to be honest", "at the end of the day", ...]
+ },
+ "sentence_structure": {
+ "average_length": "short | medium | long",
+ "variety": "low | moderate | high",
+ "fragment_usage": true/false,
+ "question_usage": "none | rare | frequent",
+ "exclamation_usage": "none | rare | frequent"
+ },
+ "paragraph_structure": {
+ "average_length_sentences": 1-5,
+ "uses_single_sentence_paragraphs": true/false,
+ "uses_line_breaks_for_emphasis": true/false
+ },
+ "tone": {
+ "formality": "casual | conversational | professional | formal",
+ "warmth": "cold | neutral | warm | enthusiastic",
+ "humour": "none | dry | playful | frequent",
+ "confidence": "hedging | balanced | assertive | provocative"
+ },
+ "rhetorical_patterns": {
+ "typical_opening": "description of how they start posts",
+ "typical_closing": "description of how they end posts",
+ "storytelling_tendency": "none | sometimes | often",
+ "uses_lists": true/false,
+ "uses_rhetorical_questions": true/false,
+ "call_to_action_style": "none | soft | direct"
+ },
+ "formatting": {
+ "emoji_usage": "none | rare | moderate | heavy",
+ "emoji_types": ["🚀", "💡", ...],
+ "hashtag_style": "none | minimal | moderate | heavy",
+ "capitalisation_quirks": "none | ALL CAPS for emphasis | Title Case headers",
+ "punctuation_quirks": "description of any unusual punctuation habits"
+ },
+ "content_patterns": {
+ "typical_topics": ["topic1", "topic2"],
+ "perspective": "first_person | third_person | mixed",
+ "self_reference_style": "I | we | the team | name",
+ "audience_address": "none | you | we | community"
+ },
+ "overall_voice_summary": "A 2-3 sentence summary of this person's writing voice that captures their essence."
+}
+
+Respond with only the JSON object, no other text.
+```
+
+### 7.2 Generation Prompt
+
+```
+You are a ghostwriter. Write a {platform} post in the exact writing style described by the style profile below. The post should be about the topic/points provided.
+
+## Style Profile:
+{style_profile_json}
+
+## Platform Conventions:
+- Platform: {platform_name}
+- Character limit: {max_chars}
+- Recommended length: {recommended_length}
+- Hashtags: {hashtag_guidance}
+- Emoji: {emoji_guidance}
+- Formatting: {formatting_notes}
+
+## Topic / Key Points:
+{topic}
+
+## Instructions:
+- Match the voice, tone, vocabulary, and structural patterns from the style profile exactly
+- Follow the platform conventions for formatting, length, and hashtag/emoji usage
+- Do NOT add disclaimers, meta-commentary, or explain what you're doing
+- Output ONLY the post text, ready to copy and paste
+```
+
+### 7.3 Refinement Prompt
+
+```
+You are a ghostwriter refining a draft. Adjust the post below according to the feedback while maintaining the original writing style.
+
+## Style Profile:
+{style_profile_json}
+
+## Platform: {platform_name}
+## Platform Conventions:
+{platform_conventions}
+
+## Current Draft:
+{current_draft}
+
+## Feedback:
+{feedback}
+
+## Instructions:
+- Apply the feedback while staying true to the style profile
+- Maintain platform conventions
+- Output ONLY the revised post text
+```
+
+---
+
+## 8. Key Interfaces
+
+### IContentFetcher
+
+```csharp
+public interface IContentFetcher
+{
+ Platform Platform { get; }
+ Task> FetchPostsAsync(
+ FetchRequest request,
+ CancellationToken cancellationToken = default);
+}
+
+public record FetchRequest(
+ string? Url = null,
+ string? Handle = null,
+ string? FilePath = null);
+
+public record FetchedPost(
+ string Content,
+ string? SourceUrl = null,
+ DateTimeOffset? PublishedAt = null);
+```
+
+### ILlmProvider
+
+```csharp
+public interface ILlmProvider
+{
+ string ProviderName { get; } // "anthropic" or "openai"
+ string ModelName { get; }
+
+ Task AnalyseStyleAsync(
+ string prompt,
+ CancellationToken cancellationToken = default);
+
+ Task GeneratePostAsync(
+ string prompt,
+ CancellationToken cancellationToken = default);
+
+ Task RefinePostAsync(
+ string prompt,
+ CancellationToken cancellationToken = default);
+}
+```
+
+---
+
+## 9. Configuration
+
+### appsettings.json
+
+```json
+{
+ "Writegeist": {
+ "DefaultProvider": "anthropic",
+ "DatabasePath": "writegeist.db",
+ "Anthropic": {
+ "Model": "claude-sonnet-4-20250514"
+ },
+ "OpenAi": {
+ "Model": "gpt-4o"
+ }
+ }
+}
+```
+
+### Environment Variables
+
+```
+ANTHROPIC_API_KEY=sk-ant-...
+OPENAI_API_KEY=sk-...
+```
+
+Use `IConfiguration` with the standard provider chain: appsettings.json → environment variables → user secrets (dev).
+
+---
+
+## 10. Content Fetcher Implementation Notes
+
+### General Architecture
+
+Each fetcher implements `IContentFetcher`. Register all of them in DI alongside the InteractiveCli action auto-registration. Resolve by `Platform` enum using a factory or keyed services (.NET 8+).
+
+### LinkedIn
+
+- **API**: LinkedIn's API requires OAuth2 and an approved app; post content is generally NOT available via the standard API. Not viable for v1.
+- **Scraping**: LinkedIn aggressively blocks unauthenticated scraping (login walls, bot detection).
+- **v1 approach**: `LinkedInFetcher` should log a warning that automated fetching is not available and suggest using File or Interactive paste instead. Implement as a stub that throws a user-friendly exception.
+- **Future**: Could integrate a headless browser (Playwright) with cookie-based auth, but this is fragile and potentially ToS-violating.
+
+### X/Twitter
+
+- **API**: X API v2 (free tier) allows fetching recent tweets by user ID. Requires a bearer token.
+- **Endpoint**: `GET /2/users/{id}/tweets` — returns up to 3,200 most recent tweets.
+- **Auth**: Bearer token via `X_BEARER_TOKEN` env var.
+- **v1 approach**: Implement API-based fetching. Fall back to manual if no token configured.
+- **Rate limits**: 1 request per 15 minutes on free tier (returns up to 100 tweets per request). Handle 429 gracefully.
+
+### Instagram
+
+- **API**: Meta Graph API requires a Facebook app + Instagram Business/Creator account connection. Not viable for fetching arbitrary public profiles.
+- **Scraping**: Instagram blocks most scraping. Public profile JSON endpoints have been locked down.
+- **v1 approach**: Stub with manual fallback, similar to LinkedIn.
+- **Future**: Playwright-based approach or third-party services.
+
+### Facebook
+
+- **API**: Graph API can access public Page posts (not personal profiles) with a Page Access Token.
+- **Scraping**: Heavily blocked.
+- **v1 approach**: Stub with manual fallback for personal profiles. Could implement Page post fetching if a Page Access Token is configured.
+
+### ManualFetcher
+
+- Reads from a file where posts are separated by `---` on its own line.
+- Or runs an interactive loop: user pastes a post, presses Enter twice (blank line) to submit, types `done` to finish.
+- Always available as fallback regardless of platform.
+
+---
+
+## 11. Build & Run
+
+```bash
+dotnet new sln -n Writegeist
+dotnet new console -n Writegeist.Cli -o src/Writegeist.Cli
+dotnet new classlib -n Writegeist.Core -o src/Writegeist.Core
+dotnet new classlib -n Writegeist.Infrastructure -o src/Writegeist.Infrastructure
+dotnet new xunit -n Writegeist.Tests -o src/Writegeist.Tests
+
+dotnet sln add src/Writegeist.Cli
+dotnet sln add src/Writegeist.Core
+dotnet sln add src/Writegeist.Infrastructure
+dotnet sln add src/Writegeist.Tests
+
+# Project references
+cd src/Writegeist.Cli && dotnet add reference ../Writegeist.Core ../Writegeist.Infrastructure
+cd ../Writegeist.Infrastructure && dotnet add reference ../Writegeist.Core
+cd ../Writegeist.Tests && dotnet add reference ../Writegeist.Core ../Writegeist.Infrastructure
+```
+
+### NuGet Packages
+
+**Writegeist.Cli:**
+- `DevJonny.InteractiveCli` (brings in Spectre.Console, CommandLineParser, Serilog, Microsoft.Extensions.Hosting)
+
+**Writegeist.Infrastructure:**
+- `Microsoft.Data.Sqlite`
+- `Dapper` (optional, or use raw ADO.NET)
+- `AngleSharp` (HTML parsing for scraping)
+- `Anthropic` (if official SDK exists; otherwise raw HttpClient)
+- `OpenAI`
+
+**Writegeist.Tests:**
+- `xunit`
+- `NSubstitute` or `Moq`
+- `FluentAssertions`
+
+---
+
+## 12. Implementation Order
+
+Build in this sequence — each step is independently testable:
+
+1. **Solution scaffolding** — Create projects, references, NuGet packages. Wire up InteractiveCli bootstrapper with a MainMenu that has placeholder actions.
+2. **Data model + SQLite persistence** — `Models/`, `SqliteDatabase.cs`, repositories. Write tests.
+3. **ManualFetcher** — File and interactive ingestion. Write tests.
+4. **Ingest actions** — Wire up IngestMenu → IngestFromFileAction, IngestInteractiveAction → ManualFetcher → SQLite.
+5. **ILlmProvider + AnthropicProvider** — Implement API calls with the style analysis prompt.
+6. **StyleAnalyser service + AnalyseAction** — Orchestrate analysis, store profile.
+7. **PlatformConventions** — Static platform rules.
+8. **PostGenerator service + GenerateAction** — Generation with style profile + platform rules.
+9. **RefineAction** — Refinement loop using RepeatableActionAsync.
+10. **ProfileMenu + actions** — ShowProfileAction, ListProfilesAction.
+11. **OpenAiProvider** — Second LLM backend.
+12. **XTwitterFetcher + IngestFromUrlAction** — API-based tweet fetching (if X API access is available).
+13. **Stub fetchers** — LinkedIn, Instagram, Facebook stubs with helpful error messages.
+14. **Polish** — Error handling, logging, help text, README.
+
+---
+
+## 13. Testing Strategy
+
+- **Unit tests**: Style analyser, post generator, platform conventions, repositories (in-memory SQLite).
+- **Integration tests**: Full action execution with a mock LLM provider (return canned responses).
+- **Manual smoke tests**: End-to-end with real API keys against both providers.
+- Mock `ILlmProvider` and `IContentFetcher` in tests — never call real APIs in automated tests.
+
+---
+
+## 14. Future Enhancements (Out of Scope for v1)
+
+- Headless browser fetching (Playwright) for LinkedIn/Instagram
+- Thread generation for X (multi-tweet)
+- Image caption generation for Instagram
+- A/B style comparison (generate from two different people's styles)
+- Export to clipboard / direct posting via APIs
+- Web UI wrapper
+- Style drift detection (re-analyse and compare profiles over time)
diff --git a/prd.json b/prd.json
new file mode 100644
index 0000000..84b1066
--- /dev/null
+++ b/prd.json
@@ -0,0 +1,445 @@
+{
+ "project": "Writegeist",
+ "branchName": "ralph/writegeist-v1",
+ "description": "Interactive .NET CLI tool that clones a person's writing style from social media posts and generates platform-specific content on demand",
+ "userStories": [
+ {
+ "id": "US-001",
+ "title": "Solution scaffolding and project structure",
+ "description": "As a developer, I need the .NET solution with all projects, references, and NuGet packages so that I can start building features.",
+ "acceptanceCriteria": [
+ "Writegeist.sln with four projects: Writegeist.Cli, Writegeist.Core, Writegeist.Infrastructure, Writegeist.Tests",
+ "All projects target net10.0",
+ "Project references wired: Cli references Core + Infrastructure, Infrastructure references Core, Tests references Core + Infrastructure",
+ "Writegeist.Cli references DevJonny.InteractiveCli NuGet package",
+ "Writegeist.Infrastructure references Microsoft.Data.Sqlite and AngleSharp",
+ "Writegeist.Tests references xunit, FluentAssertions, and NSubstitute",
+ "Program.cs bootstraps InteractiveCli with a MainMenu that displays menu items and quits cleanly",
+ "MainMenu is a top-level menu (isTopLevel: true) with a placeholder menu item",
+ "dotnet build succeeds with no errors",
+ "Typecheck passes"
+ ],
+ "priority": 1,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-002",
+ "title": "Domain models",
+ "description": "As a developer, I need the core domain models defined so that repositories and services can use them.",
+ "acceptanceCriteria": [
+ "Person model in Writegeist.Core/Models with Id (int), Name (string), CreatedAt (DateTime)",
+ "RawPost model with Id, PersonId, Platform, Content, ContentHash, SourceUrl, FetchedAt",
+ "Platform enum with values: LinkedIn, X, Instagram, Facebook",
+ "StyleProfile model with Id, PersonId, ProfileJson, Provider, Model, CreatedAt",
+ "GeneratedDraft model with Id, PersonId, StyleProfileId, Platform, Topic, Content, ParentDraftId (nullable), Feedback (nullable), Provider, Model, CreatedAt",
+ "FetchRequest record with nullable Url, Handle, FilePath properties",
+ "FetchedPost record with Content, nullable SourceUrl, nullable PublishedAt",
+ "dotnet build succeeds",
+ "Typecheck passes"
+ ],
+ "priority": 2,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-003",
+ "title": "Core interfaces",
+ "description": "As a developer, I need the core interfaces defined so that infrastructure implementations can be built against them.",
+ "acceptanceCriteria": [
+ "IPersonRepository interface in Writegeist.Core/Interfaces with CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync",
+ "IPostRepository interface with AddAsync, GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync",
+ "IStyleProfileRepository interface with SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync",
+ "IDraftRepository interface with SaveAsync, GetLatestAsync, GetByIdAsync",
+ "IContentFetcher interface with Platform property and FetchPostsAsync method",
+ "ILlmProvider interface with ProviderName, ModelName properties and AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync methods",
+ "dotnet build succeeds",
+ "Typecheck passes"
+ ],
+ "priority": 3,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-004",
+ "title": "SQLite database and schema initialisation",
+ "description": "As a developer, I need the SQLite database created with the correct schema so that data persists locally.",
+ "acceptanceCriteria": [
+ "SqliteDatabase class in Writegeist.Infrastructure/Persistence with EnsureCreated method",
+ "Creates persons table with id (autoincrement PK), name (unique, not null), created_at (default now)",
+ "Creates raw_posts table with FK to persons, platform, content, content_hash, source_url, fetched_at, unique constraint on (person_id, content_hash)",
+ "Creates style_profiles table with FK to persons, profile_json, provider, model, created_at",
+ "Creates generated_drafts table with FK to persons and style_profiles, parent_draft_id self-ref FK, platform, topic, content, feedback, provider, model, created_at",
+ "Schema creation is idempotent (uses CREATE TABLE IF NOT EXISTS)",
+ "Database path configurable via IConfiguration (Writegeist:DatabasePath key)",
+ "Unit tests verify all four tables are created using in-memory SQLite",
+ "Typecheck passes"
+ ],
+ "priority": 4,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-005",
+ "title": "Person repository implementation",
+ "description": "As a developer, I need the person repository so that persons can be stored and retrieved.",
+ "acceptanceCriteria": [
+ "SqlitePersonRepository implements IPersonRepository",
+ "CreateAsync inserts a new person and returns the model with the generated Id",
+ "GetByNameAsync performs case-insensitive lookup and returns null if not found",
+ "GetAllAsync returns all persons ordered by name",
+ "GetOrCreateAsync returns existing person if name matches (case-insensitive), creates new one if not",
+ "Unit tests with in-memory SQLite cover create, get by name, get all, get-or-create (existing), get-or-create (new)",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 5,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-006",
+ "title": "Post repository implementation",
+ "description": "As a developer, I need the post repository so that ingested posts can be stored and deduplicated.",
+ "acceptanceCriteria": [
+ "SqlitePostRepository implements IPostRepository",
+ "AddAsync inserts a post and returns a result indicating whether it was new or a duplicate",
+ "Content hash is SHA-256 of normalised (trimmed, lowercased) content text",
+ "Duplicate detection uses the unique constraint on (person_id, content_hash)",
+ "GetByPersonIdAsync returns all posts for a person ordered by fetched_at",
+ "GetCountByPersonIdAsync returns the count of posts for a person",
+ "Unit tests cover add new, add duplicate, retrieval, and count",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 6,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-007",
+ "title": "Style profile repository implementation",
+ "description": "As a developer, I need the style profile repository so that analysed profiles can be stored and retrieved.",
+ "acceptanceCriteria": [
+ "SqliteStyleProfileRepository implements IStyleProfileRepository",
+ "SaveAsync inserts a profile and returns the model with generated Id",
+ "GetLatestByPersonIdAsync returns the most recent profile by created_at, or null if none exist",
+ "GetAllByPersonIdAsync returns all profiles for a person ordered by created_at descending",
+ "Unit tests cover save, retrieve latest, retrieve all, and null case",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 7,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-008",
+ "title": "Draft repository implementation",
+ "description": "As a developer, I need the draft repository so that generated and refined drafts can be stored and chained.",
+ "acceptanceCriteria": [
+ "SqliteDraftRepository implements IDraftRepository",
+ "SaveAsync inserts a draft and returns the model with generated Id",
+ "GetLatestAsync returns the most recently created draft across all persons, or null if none exist",
+ "GetByIdAsync returns a draft by Id, or null if not found",
+ "parent_draft_id correctly links refined drafts to their predecessors",
+ "Unit tests cover save, get latest, get by id, parent-child linking, and null cases",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 8,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-009",
+ "title": "Manual fetcher — file import",
+ "description": "As a user, I want to import posts from a text file so that I can feed Writegeist content without API access.",
+ "acceptanceCriteria": [
+ "ManualFetcher class in Writegeist.Infrastructure/Fetchers implements IContentFetcher",
+ "Platform property returns a configurable platform value (set at construction)",
+ "FetchPostsAsync reads a file path from FetchRequest.FilePath",
+ "Splits file content on lines containing only --- as separator",
+ "Trims whitespace from each post and skips empty entries",
+ "Returns a list of FetchedPost records with content populated",
+ "Unit tests cover: normal file with multiple posts, empty file, file with no separators (single post), file with consecutive separators",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 9,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-010",
+ "title": "Platform conventions",
+ "description": "As a developer, I need platform-specific rules so that generated posts follow each platform's norms.",
+ "acceptanceCriteria": [
+ "PlatformRules record in Writegeist.Core with Name, MaxCharacters (nullable int), RecommendedMaxLength, SupportsHashtags, RecommendedHashtagCount, HashtagPlacement, SupportsEmoji, EmojiGuidance, ToneGuidance, FormattingNotes",
+ "PlatformConventions class with static GetRules(Platform) method",
+ "LinkedIn: 3000 max, 1500 recommended, hashtags end, 3-5 count, emoji sparingly",
+ "X: 280 max, 280 recommended, hashtags inline, 1-2 count, emoji moderate",
+ "Instagram: 2200 max, 750 recommended, hashtags end in separate block, up to 30, emoji freely",
+ "Facebook: 63206 max, 600 recommended, hashtags end, 1-3 count, emoji moderate",
+ "Unit tests verify rules for all four platforms",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 10,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-011",
+ "title": "Anthropic LLM provider",
+ "description": "As a user, I want to use Claude as the LLM backend so that I can analyse style and generate posts.",
+ "acceptanceCriteria": [
+ "AnthropicProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider",
+ "Uses official Anthropic NuGet SDK if available, otherwise raw HttpClient to https://api.anthropic.com/v1/messages",
+ "API key read from ANTHROPIC_API_KEY environment variable via IConfiguration",
+ "Model name configurable via appsettings.json (Writegeist:Anthropic:Model), defaults to claude-sonnet-4-20250514",
+ "ProviderName returns 'anthropic', ModelName returns the configured model",
+ "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response",
+ "Throws a descriptive exception if API key is not configured",
+ "Handles HTTP errors (401, 429, 500) with user-friendly exception messages",
+ "Typecheck passes"
+ ],
+ "priority": 11,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-012",
+ "title": "OpenAI LLM provider",
+ "description": "As a user, I want to use OpenAI as an alternative LLM backend.",
+ "acceptanceCriteria": [
+ "OpenAiProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider",
+ "Uses official OpenAI NuGet package",
+ "API key read from OPENAI_API_KEY environment variable via IConfiguration",
+ "Model name configurable via appsettings.json (Writegeist:OpenAi:Model), defaults to gpt-4o",
+ "ProviderName returns 'openai', ModelName returns the configured model",
+ "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response",
+ "Throws a descriptive exception if API key is not configured",
+ "Handles HTTP errors with user-friendly exception messages",
+ "Typecheck passes"
+ ],
+ "priority": 12,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-013",
+ "title": "Style analyser service",
+ "description": "As a developer, I need the style analysis orchestration so that ingested posts can be turned into a style profile.",
+ "acceptanceCriteria": [
+ "StyleAnalyser class in Writegeist.Core/Services",
+ "Constructor takes IPostRepository, IStyleProfileRepository, and ILlmProvider",
+ "AnalyseAsync(int personId) loads all posts for the person, builds the style analysis prompt with all post contents, sends to LLM, and stores the result",
+ "Uses the style analysis prompt template from the implementation plan (section 7.1)",
+ "Returns the saved StyleProfile model",
+ "Throws if no posts exist for the person",
+ "Unit tests with mocked dependencies verify prompt construction and profile storage",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 13,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-014",
+ "title": "Post generator service",
+ "description": "As a developer, I need the post generation orchestration so that drafts can be created from a style profile and topic.",
+ "acceptanceCriteria": [
+ "PostGenerator class in Writegeist.Core/Services",
+ "Constructor takes IStyleProfileRepository, IDraftRepository, and ILlmProvider",
+ "GenerateAsync(int personId, Platform platform, string topic) loads latest style profile, gets platform conventions, builds generation prompt, sends to LLM, stores draft",
+ "Uses the generation prompt template from the implementation plan (section 7.2)",
+ "RefineAsync(int draftId, string feedback) loads draft, its style profile, builds refinement prompt, sends to LLM, stores new draft linked to previous",
+ "Uses the refinement prompt template from the implementation plan (section 7.3)",
+ "Returns the saved GeneratedDraft model",
+ "Throws if no style profile exists for the person",
+ "Unit tests with mocked dependencies verify prompt construction and draft storage",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 14,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-015",
+ "title": "DI registration and appsettings.json",
+ "description": "As a developer, I need all services, repositories, and providers registered in DI so that actions can resolve them.",
+ "acceptanceCriteria": [
+ "Program.cs configureServices lambda registers: SqliteDatabase, all four repositories, StyleAnalyser, PostGenerator, both LLM providers, ManualFetcher",
+ "LLM providers registered as keyed services or via a factory that resolves by provider name string",
+ "appsettings.json contains Writegeist section with DefaultProvider, DatabasePath, Anthropic:Model, OpenAi:Model",
+ "SqliteDatabase.EnsureCreated() called during startup",
+ "dotnet build succeeds",
+ "Typecheck passes"
+ ],
+ "priority": 15,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-016",
+ "title": "Ingest menu and file import action",
+ "description": "As a user, I want an Ingest Posts sub-menu with a From File action so that I can import posts from a text file.",
+ "acceptanceCriteria": [
+ "IngestMenu class extends Menu (quitable: false, isTopLevel: false) with BuildMenu adding From File, Interactive Paste, From URL / Handle menu items",
+ "MainMenu adds IngestMenu as 'Ingest Posts' menu item",
+ "IngestFromFileAction extends SingleActionAsync",
+ "Prompts for person name via Spectre.Console TextPrompt",
+ "Prompts for platform via Spectre.Console SelectionPrompt (LinkedIn, X, Instagram, Facebook)",
+ "Prompts for file path via TextPrompt",
+ "Uses IPersonRepository.GetOrCreateAsync to ensure person exists",
+ "Uses ManualFetcher to read the file, then stores each post via IPostRepository.AddAsync",
+ "Displays a Spectre.Console Panel summary: count of new posts (green), duplicates skipped (yellow)",
+ "Typecheck passes"
+ ],
+ "priority": 16,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-017",
+ "title": "Interactive paste action",
+ "description": "As a user, I want to paste posts one at a time in an interactive session.",
+ "acceptanceCriteria": [
+ "IngestInteractiveAction extends RepeatableActionAsync",
+ "On first iteration, prompts for person name and platform via Spectre.Console prompts",
+ "Each iteration prompts for post content via TextPrompt",
+ "Stores each post via IPostRepository.AddAsync with content hash dedup",
+ "After each post, asks 'Add another post?' — returns false to continue, true to stop",
+ "On completion, displays Panel summary with total new posts and duplicates skipped",
+ "Typecheck passes"
+ ],
+ "priority": 17,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-018",
+ "title": "Analyse style action",
+ "description": "As a user, I want a menu action to analyse my posts and build a style profile.",
+ "acceptanceCriteria": [
+ "AnalyseAction extends SingleActionAsync, added to MainMenu as 'Analyse Style'",
+ "Prompts user to select a person from existing persons via SelectionPrompt (shows error if none exist)",
+ "Prompts user to select LLM provider via SelectionPrompt (Anthropic, OpenAI) with default from config",
+ "Calls StyleAnalyser.AnalyseAsync with the selected person",
+ "Displays spinner via AnsiConsole.Status() while LLM processes",
+ "Displays the profile summary in a styled Panel after completion",
+ "Catches and displays errors in red markup without crashing the menu loop",
+ "Typecheck passes"
+ ],
+ "priority": 18,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-019",
+ "title": "Generate post action",
+ "description": "As a user, I want a menu action to generate a new post in my style for a specific platform.",
+ "acceptanceCriteria": [
+ "GenerateAction extends SingleActionAsync, added to MainMenu as 'Generate Post'",
+ "Prompts user to select a person (only persons with a style profile) via SelectionPrompt",
+ "Prompts user to select target platform via SelectionPrompt",
+ "Prompts user to enter topic/key points via TextPrompt",
+ "Calls PostGenerator.GenerateAsync with person, platform, and topic",
+ "Displays spinner via AnsiConsole.Status() while LLM processes",
+ "Displays the generated post in a styled Panel with border and title",
+ "Catches and displays errors in red markup without crashing",
+ "Typecheck passes"
+ ],
+ "priority": 19,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-020",
+ "title": "Refine draft action",
+ "description": "As a user, I want a menu action to iteratively refine the last generated draft with feedback.",
+ "acceptanceCriteria": [
+ "RefineAction extends RepeatableActionAsync, added to MainMenu as 'Refine Last Draft'",
+ "On first iteration, loads the most recent GeneratedDraft and displays it in a Panel (shows error if none exist)",
+ "Prompts for feedback via TextPrompt",
+ "Calls PostGenerator.RefineAsync with the draft ID and feedback",
+ "Displays spinner via AnsiConsole.Status() while LLM processes",
+ "Displays the refined post in a styled Panel",
+ "Asks 'Refine again?' — returns false to continue looping, true to stop and return to menu",
+ "Catches and displays errors in red markup without crashing",
+ "Typecheck passes"
+ ],
+ "priority": 20,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-021",
+ "title": "Profile menu and actions",
+ "description": "As a user, I want to view and list style profiles from the menu.",
+ "acceptanceCriteria": [
+ "ProfileMenu extends Menu (quitable: false, isTopLevel: false) with Show Profile and List All Profiles items",
+ "MainMenu adds ProfileMenu as 'Profiles' menu item",
+ "ShowProfileAction extends SingleActionAsync, prompts for person selection, displays full style profile in a formatted Spectre.Console Table with grouped sections (vocabulary, tone, formatting, etc.)",
+ "ListProfilesAction extends SingleActionAsync, displays a Table of all persons with profiles showing name, provider, model, and created date",
+ "Both actions show a message if no profiles exist",
+ "Typecheck passes"
+ ],
+ "priority": 21,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-022",
+ "title": "Stub fetchers for unsupported platforms",
+ "description": "As a developer, I need stub fetchers for LinkedIn, Instagram, and Facebook that guide users to manual input.",
+ "acceptanceCriteria": [
+ "LinkedInFetcher, InstagramFetcher, FacebookFetcher each implement IContentFetcher in Writegeist.Infrastructure/Fetchers",
+ "Each returns the correct Platform enum value from the Platform property",
+ "FetchPostsAsync throws a descriptive exception explaining automated fetching is not available for this platform",
+ "Exception message suggests using From File or Interactive Paste instead",
+ "Typecheck passes"
+ ],
+ "priority": 22,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-023",
+ "title": "Ingest from URL action and fetcher dispatch",
+ "description": "As a user, I want to ingest posts from a URL or handle, with clear error messages for unsupported platforms.",
+ "acceptanceCriteria": [
+ "IngestFromUrlAction extends SingleActionAsync, wired into IngestMenu as 'From URL / Handle'",
+ "Prompts for person name, platform, and URL or handle via Spectre.Console prompts",
+ "Resolves the correct IContentFetcher by platform (using DI factory or keyed services)",
+ "Calls FetchPostsAsync and stores results via IPostRepository.AddAsync",
+ "Catches fetcher exceptions for unsupported platforms and displays the message in yellow markup",
+ "Displays Panel summary for successful fetches",
+ "Typecheck passes"
+ ],
+ "priority": 23,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-024",
+ "title": "X/Twitter API fetcher",
+ "description": "As a user, I want to fetch recent tweets automatically via the X API.",
+ "acceptanceCriteria": [
+ "XTwitterFetcher in Writegeist.Infrastructure/Fetchers implements IContentFetcher for Platform.X",
+ "Uses HttpClient to call X API v2 GET /2/users/{id}/tweets endpoint",
+ "Bearer token read from X_BEARER_TOKEN environment variable",
+ "Fetches up to 100 recent tweets per request",
+ "Handles 429 rate limit responses with a user-friendly message suggesting to wait and retry",
+ "Throws descriptive exception if no bearer token is configured, suggesting manual input instead",
+ "Typecheck passes"
+ ],
+ "priority": 24,
+ "passes": false,
+ "notes": ""
+ }
+ ]
+}
diff --git a/scripts/ralph/AGENTS.md b/scripts/ralph/AGENTS.md
new file mode 100644
index 0000000..17cfedf
--- /dev/null
+++ b/scripts/ralph/AGENTS.md
@@ -0,0 +1,30 @@
+# Ralph Agent Instructions
+
+## Overview
+
+Ralph is an autonomous AI agent loop that runs AI coding tools (Amp or Claude Code) repeatedly until all PRD items are complete. Each iteration is a fresh instance with clean context.
+
+## Commands
+
+```bash
+# Run Ralph with Amp (default)
+./scripts/ralph/ralph.sh [max_iterations]
+
+# Run Ralph with Claude Code
+./scripts/ralph/ralph.sh --tool claude [max_iterations]
+```
+
+## Key Files
+
+- `scripts/ralph/ralph.sh` - The bash loop that spawns fresh AI instances (supports `--tool amp` or `--tool claude`)
+- `scripts/ralph/prompt.md` - Instructions given to each Amp instance
+- `scripts/ralph/CLAUDE.md` - Instructions given to each Claude Code instance
+- `scripts/ralph/prd.json` - PRD with user stories
+- `scripts/ralph/progress.txt` - Progress log across iterations
+
+## Patterns
+
+- Each iteration spawns a fresh AI instance (Amp or Claude Code) with clean context
+- Memory persists via git history, `progress.txt`, and `prd.json`
+- Stories should be small enough to complete in one context window
+- Always update AGENTS.md with discovered patterns for future iterations
diff --git a/scripts/ralph/CLAUDE.md b/scripts/ralph/CLAUDE.md
new file mode 100644
index 0000000..f95bb92
--- /dev/null
+++ b/scripts/ralph/CLAUDE.md
@@ -0,0 +1,104 @@
+# Ralph Agent Instructions
+
+You are an autonomous coding agent working on a software project.
+
+## Your Task
+
+1. Read the PRD at `prd.json` (in the same directory as this file)
+2. Read the progress log at `progress.txt` (check Codebase Patterns section first)
+3. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main.
+4. Pick the **highest priority** user story where `passes: false`
+5. Implement that single user story
+6. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires)
+7. Update CLAUDE.md files if you discover reusable patterns (see below)
+8. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]`
+9. Update the PRD to set `passes: true` for the completed story
+10. Append your progress to `progress.txt`
+
+## Progress Report Format
+
+APPEND to progress.txt (never replace, always append):
+```
+## [Date/Time] - [Story ID]
+- What was implemented
+- Files changed
+- **Learnings for future iterations:**
+ - Patterns discovered (e.g., "this codebase uses X for Y")
+ - Gotchas encountered (e.g., "don't forget to update Z when changing W")
+ - Useful context (e.g., "the evaluation panel is in component X")
+---
+```
+
+The learnings section is critical - it helps future iterations avoid repeating mistakes and understand the codebase better.
+
+## Consolidate Patterns
+
+If you discover a **reusable pattern** that future iterations should know, add it to the `## Codebase Patterns` section at the TOP of progress.txt (create it if it doesn't exist). This section should consolidate the most important learnings:
+
+```
+## Codebase Patterns
+- Example: Use `sql` template for aggregations
+- Example: Always use `IF NOT EXISTS` for migrations
+- Example: Export types from actions.ts for UI components
+```
+
+Only add patterns that are **general and reusable**, not story-specific details.
+
+## Update CLAUDE.md Files
+
+Before committing, check if any edited files have learnings worth preserving in nearby CLAUDE.md files:
+
+1. **Identify directories with edited files** - Look at which directories you modified
+2. **Check for existing CLAUDE.md** - Look for CLAUDE.md in those directories or parent directories
+3. **Add valuable learnings** - If you discovered something future developers/agents should know:
+ - API patterns or conventions specific to that module
+ - Gotchas or non-obvious requirements
+ - Dependencies between files
+ - Testing approaches for that area
+ - Configuration or environment requirements
+
+**Examples of good CLAUDE.md additions:**
+- "When modifying X, also update Y to keep them in sync"
+- "This module uses pattern Z for all API calls"
+- "Tests require the dev server running on PORT 3000"
+- "Field names must match the template exactly"
+
+**Do NOT add:**
+- Story-specific implementation details
+- Temporary debugging notes
+- Information already in progress.txt
+
+Only update CLAUDE.md if you have **genuinely reusable knowledge** that would help future work in that directory.
+
+## Quality Requirements
+
+- ALL commits must pass your project's quality checks (typecheck, lint, test)
+- Do NOT commit broken code
+- Keep changes focused and minimal
+- Follow existing code patterns
+
+## Browser Testing (If Available)
+
+For any story that changes UI, verify it works in the browser if you have browser testing tools configured (e.g., via MCP):
+
+1. Navigate to the relevant page
+2. Verify the UI changes work as expected
+3. Take a screenshot if helpful for the progress log
+
+If no browser tools are available, note in your progress report that manual browser verification is needed.
+
+## Stop Condition
+
+After completing a user story, check if ALL stories have `passes: true`.
+
+If ALL stories are complete and passing, reply with:
+COMPLETE
+
+If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story).
+
+## Important
+
+- Work on ONE story per iteration
+- Commit frequently
+- Keep CI green
+- Read the Codebase Patterns section in progress.txt before starting
diff --git a/scripts/ralph/archive/2026-04-04-colorado-pain-scale/prd.json b/scripts/ralph/archive/2026-04-04-colorado-pain-scale/prd.json
new file mode 100644
index 0000000..4a3598c
--- /dev/null
+++ b/scripts/ralph/archive/2026-04-04-colorado-pain-scale/prd.json
@@ -0,0 +1,445 @@
+{
+ "project": "Writegeist",
+ "branchName": "ralph/writegeist-v1",
+ "description": "Interactive .NET CLI tool that clones a person's writing style from social media posts and generates platform-specific content on demand",
+ "userStories": [
+ {
+ "id": "US-001",
+ "title": "Solution scaffolding and project structure",
+ "description": "As a developer, I need the .NET solution with all projects, references, and NuGet packages so that I can start building features.",
+ "acceptanceCriteria": [
+ "Writegeist.sln with four projects: Writegeist.Cli, Writegeist.Core, Writegeist.Infrastructure, Writegeist.Tests",
+ "All projects target net10.0",
+ "Project references wired: Cli references Core + Infrastructure, Infrastructure references Core, Tests references Core + Infrastructure",
+ "Writegeist.Cli references DevJonny.InteractiveCli NuGet package",
+ "Writegeist.Infrastructure references Microsoft.Data.Sqlite and AngleSharp",
+ "Writegeist.Tests references xunit, FluentAssertions, and NSubstitute",
+ "Program.cs bootstraps InteractiveCli with a MainMenu that displays menu items and quits cleanly",
+ "MainMenu is a top-level menu (isTopLevel: true) with a placeholder menu item",
+ "dotnet build succeeds with no errors",
+ "Typecheck passes"
+ ],
+ "priority": 1,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-002",
+ "title": "Domain models",
+ "description": "As a developer, I need the core domain models defined so that repositories and services can use them.",
+ "acceptanceCriteria": [
+ "Person model in Writegeist.Core/Models with Id (int), Name (string), CreatedAt (DateTime)",
+ "RawPost model with Id, PersonId, Platform, Content, ContentHash, SourceUrl, FetchedAt",
+ "Platform enum with values: LinkedIn, X, Instagram, Facebook",
+ "StyleProfile model with Id, PersonId, ProfileJson, Provider, Model, CreatedAt",
+ "GeneratedDraft model with Id, PersonId, StyleProfileId, Platform, Topic, Content, ParentDraftId (nullable), Feedback (nullable), Provider, Model, CreatedAt",
+ "FetchRequest record with nullable Url, Handle, FilePath properties",
+ "FetchedPost record with Content, nullable SourceUrl, nullable PublishedAt",
+ "dotnet build succeeds",
+ "Typecheck passes"
+ ],
+ "priority": 2,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-003",
+ "title": "Core interfaces",
+ "description": "As a developer, I need the core interfaces defined so that infrastructure implementations can be built against them.",
+ "acceptanceCriteria": [
+ "IPersonRepository interface in Writegeist.Core/Interfaces with CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync",
+ "IPostRepository interface with AddAsync, GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync",
+ "IStyleProfileRepository interface with SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync",
+ "IDraftRepository interface with SaveAsync, GetLatestAsync, GetByIdAsync",
+ "IContentFetcher interface with Platform property and FetchPostsAsync method",
+ "ILlmProvider interface with ProviderName, ModelName properties and AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync methods",
+ "dotnet build succeeds",
+ "Typecheck passes"
+ ],
+ "priority": 3,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-004",
+ "title": "SQLite database and schema initialisation",
+ "description": "As a developer, I need the SQLite database created with the correct schema so that data persists locally.",
+ "acceptanceCriteria": [
+ "SqliteDatabase class in Writegeist.Infrastructure/Persistence with EnsureCreated method",
+ "Creates persons table with id (autoincrement PK), name (unique, not null), created_at (default now)",
+ "Creates raw_posts table with FK to persons, platform, content, content_hash, source_url, fetched_at, unique constraint on (person_id, content_hash)",
+ "Creates style_profiles table with FK to persons, profile_json, provider, model, created_at",
+ "Creates generated_drafts table with FK to persons and style_profiles, parent_draft_id self-ref FK, platform, topic, content, feedback, provider, model, created_at",
+ "Schema creation is idempotent (uses CREATE TABLE IF NOT EXISTS)",
+ "Database path configurable via IConfiguration (Writegeist:DatabasePath key)",
+ "Unit tests verify all four tables are created using in-memory SQLite",
+ "Typecheck passes"
+ ],
+ "priority": 4,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-005",
+ "title": "Person repository implementation",
+ "description": "As a developer, I need the person repository so that persons can be stored and retrieved.",
+ "acceptanceCriteria": [
+ "SqlitePersonRepository implements IPersonRepository",
+ "CreateAsync inserts a new person and returns the model with the generated Id",
+ "GetByNameAsync performs case-insensitive lookup and returns null if not found",
+ "GetAllAsync returns all persons ordered by name",
+ "GetOrCreateAsync returns existing person if name matches (case-insensitive), creates new one if not",
+ "Unit tests with in-memory SQLite cover create, get by name, get all, get-or-create (existing), get-or-create (new)",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 5,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-006",
+ "title": "Post repository implementation",
+ "description": "As a developer, I need the post repository so that ingested posts can be stored and deduplicated.",
+ "acceptanceCriteria": [
+ "SqlitePostRepository implements IPostRepository",
+ "AddAsync inserts a post and returns a result indicating whether it was new or a duplicate",
+ "Content hash is SHA-256 of normalised (trimmed, lowercased) content text",
+ "Duplicate detection uses the unique constraint on (person_id, content_hash)",
+ "GetByPersonIdAsync returns all posts for a person ordered by fetched_at",
+ "GetCountByPersonIdAsync returns the count of posts for a person",
+ "Unit tests cover add new, add duplicate, retrieval, and count",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 6,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-007",
+ "title": "Style profile repository implementation",
+ "description": "As a developer, I need the style profile repository so that analysed profiles can be stored and retrieved.",
+ "acceptanceCriteria": [
+ "SqliteStyleProfileRepository implements IStyleProfileRepository",
+ "SaveAsync inserts a profile and returns the model with generated Id",
+ "GetLatestByPersonIdAsync returns the most recent profile by created_at, or null if none exist",
+ "GetAllByPersonIdAsync returns all profiles for a person ordered by created_at descending",
+ "Unit tests cover save, retrieve latest, retrieve all, and null case",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 7,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-008",
+ "title": "Draft repository implementation",
+ "description": "As a developer, I need the draft repository so that generated and refined drafts can be stored and chained.",
+ "acceptanceCriteria": [
+ "SqliteDraftRepository implements IDraftRepository",
+ "SaveAsync inserts a draft and returns the model with generated Id",
+ "GetLatestAsync returns the most recently created draft across all persons, or null if none exist",
+ "GetByIdAsync returns a draft by Id, or null if not found",
+ "parent_draft_id correctly links refined drafts to their predecessors",
+ "Unit tests cover save, get latest, get by id, parent-child linking, and null cases",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 8,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-009",
+ "title": "Manual fetcher — file import",
+ "description": "As a user, I want to import posts from a text file so that I can feed Writegeist content without API access.",
+ "acceptanceCriteria": [
+ "ManualFetcher class in Writegeist.Infrastructure/Fetchers implements IContentFetcher",
+ "Platform property returns a configurable platform value (set at construction)",
+ "FetchPostsAsync reads a file path from FetchRequest.FilePath",
+ "Splits file content on lines containing only --- as separator",
+ "Trims whitespace from each post and skips empty entries",
+ "Returns a list of FetchedPost records with content populated",
+ "Unit tests cover: normal file with multiple posts, empty file, file with no separators (single post), file with consecutive separators",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 9,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-010",
+ "title": "Platform conventions",
+ "description": "As a developer, I need platform-specific rules so that generated posts follow each platform's norms.",
+ "acceptanceCriteria": [
+ "PlatformRules record in Writegeist.Core with Name, MaxCharacters (nullable int), RecommendedMaxLength, SupportsHashtags, RecommendedHashtagCount, HashtagPlacement, SupportsEmoji, EmojiGuidance, ToneGuidance, FormattingNotes",
+ "PlatformConventions class with static GetRules(Platform) method",
+ "LinkedIn: 3000 max, 1500 recommended, hashtags end, 3-5 count, emoji sparingly",
+ "X: 280 max, 280 recommended, hashtags inline, 1-2 count, emoji moderate",
+ "Instagram: 2200 max, 750 recommended, hashtags end in separate block, up to 30, emoji freely",
+ "Facebook: 63206 max, 600 recommended, hashtags end, 1-3 count, emoji moderate",
+ "Unit tests verify rules for all four platforms",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 10,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-011",
+ "title": "Anthropic LLM provider",
+ "description": "As a user, I want to use Claude as the LLM backend so that I can analyse style and generate posts.",
+ "acceptanceCriteria": [
+ "AnthropicProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider",
+ "Uses official Anthropic NuGet SDK if available, otherwise raw HttpClient to https://api.anthropic.com/v1/messages",
+ "API key read from ANTHROPIC_API_KEY environment variable via IConfiguration",
+ "Model name configurable via appsettings.json (Writegeist:Anthropic:Model), defaults to claude-sonnet-4-20250514",
+ "ProviderName returns 'anthropic', ModelName returns the configured model",
+ "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response",
+ "Throws a descriptive exception if API key is not configured",
+ "Handles HTTP errors (401, 429, 500) with user-friendly exception messages",
+ "Typecheck passes"
+ ],
+ "priority": 11,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-012",
+ "title": "OpenAI LLM provider",
+ "description": "As a user, I want to use OpenAI as an alternative LLM backend.",
+ "acceptanceCriteria": [
+ "OpenAiProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider",
+ "Uses official OpenAI NuGet package",
+ "API key read from OPENAI_API_KEY environment variable via IConfiguration",
+ "Model name configurable via appsettings.json (Writegeist:OpenAi:Model), defaults to gpt-4o",
+ "ProviderName returns 'openai', ModelName returns the configured model",
+ "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response",
+ "Throws a descriptive exception if API key is not configured",
+ "Handles HTTP errors with user-friendly exception messages",
+ "Typecheck passes"
+ ],
+ "priority": 12,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-013",
+ "title": "Style analyser service",
+ "description": "As a developer, I need the style analysis orchestration so that ingested posts can be turned into a style profile.",
+ "acceptanceCriteria": [
+ "StyleAnalyser class in Writegeist.Core/Services",
+ "Constructor takes IPostRepository, IStyleProfileRepository, and ILlmProvider",
+ "AnalyseAsync(int personId) loads all posts for the person, builds the style analysis prompt with all post contents, sends to LLM, and stores the result",
+ "Uses the style analysis prompt template from the implementation plan (section 7.1)",
+ "Returns the saved StyleProfile model",
+ "Throws if no posts exist for the person",
+ "Unit tests with mocked dependencies verify prompt construction and profile storage",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 13,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-014",
+ "title": "Post generator service",
+ "description": "As a developer, I need the post generation orchestration so that drafts can be created from a style profile and topic.",
+ "acceptanceCriteria": [
+ "PostGenerator class in Writegeist.Core/Services",
+ "Constructor takes IStyleProfileRepository, IDraftRepository, and ILlmProvider",
+ "GenerateAsync(int personId, Platform platform, string topic) loads latest style profile, gets platform conventions, builds generation prompt, sends to LLM, stores draft",
+ "Uses the generation prompt template from the implementation plan (section 7.2)",
+ "RefineAsync(int draftId, string feedback) loads draft, its style profile, builds refinement prompt, sends to LLM, stores new draft linked to previous",
+ "Uses the refinement prompt template from the implementation plan (section 7.3)",
+ "Returns the saved GeneratedDraft model",
+ "Throws if no style profile exists for the person",
+ "Unit tests with mocked dependencies verify prompt construction and draft storage",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 14,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-015",
+ "title": "DI registration and appsettings.json",
+ "description": "As a developer, I need all services, repositories, and providers registered in DI so that actions can resolve them.",
+ "acceptanceCriteria": [
+ "Program.cs configureServices lambda registers: SqliteDatabase, all four repositories, StyleAnalyser, PostGenerator, both LLM providers, ManualFetcher",
+ "LLM providers registered as keyed services or via a factory that resolves by provider name string",
+ "appsettings.json contains Writegeist section with DefaultProvider, DatabasePath, Anthropic:Model, OpenAi:Model",
+ "SqliteDatabase.EnsureCreated() called during startup",
+ "dotnet build succeeds",
+ "Typecheck passes"
+ ],
+ "priority": 15,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-016",
+ "title": "Ingest menu and file import action",
+ "description": "As a user, I want an Ingest Posts sub-menu with a From File action so that I can import posts from a text file.",
+ "acceptanceCriteria": [
+ "IngestMenu class extends Menu (quitable: false, isTopLevel: false) with BuildMenu adding From File, Interactive Paste, From URL / Handle menu items",
+ "MainMenu adds IngestMenu as 'Ingest Posts' menu item",
+ "IngestFromFileAction extends SingleActionAsync",
+ "Prompts for person name via Spectre.Console TextPrompt",
+ "Prompts for platform via Spectre.Console SelectionPrompt (LinkedIn, X, Instagram, Facebook)",
+ "Prompts for file path via TextPrompt",
+ "Uses IPersonRepository.GetOrCreateAsync to ensure person exists",
+ "Uses ManualFetcher to read the file, then stores each post via IPostRepository.AddAsync",
+ "Displays a Spectre.Console Panel summary: count of new posts (green), duplicates skipped (yellow)",
+ "Typecheck passes"
+ ],
+ "priority": 16,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-017",
+ "title": "Interactive paste action",
+ "description": "As a user, I want to paste posts one at a time in an interactive session.",
+ "acceptanceCriteria": [
+ "IngestInteractiveAction extends RepeatableActionAsync",
+ "On first iteration, prompts for person name and platform via Spectre.Console prompts",
+ "Each iteration prompts for post content via TextPrompt",
+ "Stores each post via IPostRepository.AddAsync with content hash dedup",
+ "After each post, asks 'Add another post?' — returns false to continue, true to stop",
+ "On completion, displays Panel summary with total new posts and duplicates skipped",
+ "Typecheck passes"
+ ],
+ "priority": 17,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-018",
+ "title": "Analyse style action",
+ "description": "As a user, I want a menu action to analyse my posts and build a style profile.",
+ "acceptanceCriteria": [
+ "AnalyseAction extends SingleActionAsync, added to MainMenu as 'Analyse Style'",
+ "Prompts user to select a person from existing persons via SelectionPrompt (shows error if none exist)",
+ "Prompts user to select LLM provider via SelectionPrompt (Anthropic, OpenAI) with default from config",
+ "Calls StyleAnalyser.AnalyseAsync with the selected person",
+ "Displays spinner via AnsiConsole.Status() while LLM processes",
+ "Displays the profile summary in a styled Panel after completion",
+ "Catches and displays errors in red markup without crashing the menu loop",
+ "Typecheck passes"
+ ],
+ "priority": 18,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-019",
+ "title": "Generate post action",
+ "description": "As a user, I want a menu action to generate a new post in my style for a specific platform.",
+ "acceptanceCriteria": [
+ "GenerateAction extends SingleActionAsync, added to MainMenu as 'Generate Post'",
+ "Prompts user to select a person (only persons with a style profile) via SelectionPrompt",
+ "Prompts user to select target platform via SelectionPrompt",
+ "Prompts user to enter topic/key points via TextPrompt",
+ "Calls PostGenerator.GenerateAsync with person, platform, and topic",
+ "Displays spinner via AnsiConsole.Status() while LLM processes",
+ "Displays the generated post in a styled Panel with border and title",
+ "Catches and displays errors in red markup without crashing",
+ "Typecheck passes"
+ ],
+ "priority": 19,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-020",
+ "title": "Refine draft action",
+ "description": "As a user, I want a menu action to iteratively refine the last generated draft with feedback.",
+ "acceptanceCriteria": [
+ "RefineAction extends RepeatableActionAsync, added to MainMenu as 'Refine Last Draft'",
+ "On first iteration, loads the most recent GeneratedDraft and displays it in a Panel (shows error if none exist)",
+ "Prompts for feedback via TextPrompt",
+ "Calls PostGenerator.RefineAsync with the draft ID and feedback",
+ "Displays spinner via AnsiConsole.Status() while LLM processes",
+ "Displays the refined post in a styled Panel",
+ "Asks 'Refine again?' — returns false to continue looping, true to stop and return to menu",
+ "Catches and displays errors in red markup without crashing",
+ "Typecheck passes"
+ ],
+ "priority": 20,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-021",
+ "title": "Profile menu and actions",
+ "description": "As a user, I want to view and list style profiles from the menu.",
+ "acceptanceCriteria": [
+ "ProfileMenu extends Menu (quitable: false, isTopLevel: false) with Show Profile and List All Profiles items",
+ "MainMenu adds ProfileMenu as 'Profiles' menu item",
+ "ShowProfileAction extends SingleActionAsync, prompts for person selection, displays full style profile in a formatted Spectre.Console Table with grouped sections (vocabulary, tone, formatting, etc.)",
+ "ListProfilesAction extends SingleActionAsync, displays a Table of all persons with profiles showing name, provider, model, and created date",
+ "Both actions show a message if no profiles exist",
+ "Typecheck passes"
+ ],
+ "priority": 21,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-022",
+ "title": "Stub fetchers for unsupported platforms",
+ "description": "As a developer, I need stub fetchers for LinkedIn, Instagram, and Facebook that guide users to manual input.",
+ "acceptanceCriteria": [
+ "LinkedInFetcher, InstagramFetcher, FacebookFetcher each implement IContentFetcher in Writegeist.Infrastructure/Fetchers",
+ "Each returns the correct Platform enum value from the Platform property",
+ "FetchPostsAsync throws a descriptive exception explaining automated fetching is not available for this platform",
+ "Exception message suggests using From File or Interactive Paste instead",
+ "Typecheck passes"
+ ],
+ "priority": 22,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-023",
+ "title": "Ingest from URL action and fetcher dispatch",
+ "description": "As a user, I want to ingest posts from a URL or handle, with clear error messages for unsupported platforms.",
+ "acceptanceCriteria": [
+ "IngestFromUrlAction extends SingleActionAsync, wired into IngestMenu as 'From URL / Handle'",
+ "Prompts for person name, platform, and URL or handle via Spectre.Console prompts",
+ "Resolves the correct IContentFetcher by platform (using DI factory or keyed services)",
+ "Calls FetchPostsAsync and stores results via IPostRepository.AddAsync",
+ "Catches fetcher exceptions for unsupported platforms and displays the message in yellow markup",
+ "Displays Panel summary for successful fetches",
+ "Typecheck passes"
+ ],
+ "priority": 23,
+ "passes": false,
+ "notes": ""
+ },
+ {
+ "id": "US-024",
+ "title": "X/Twitter API fetcher",
+ "description": "As a user, I want to fetch recent tweets automatically via the X API.",
+ "acceptanceCriteria": [
+ "XTwitterFetcher in Writegeist.Infrastructure/Fetchers implements IContentFetcher for Platform.X",
+ "Uses HttpClient to call X API v2 GET /2/users/{id}/tweets endpoint",
+ "Bearer token read from X_BEARER_TOKEN environment variable",
+ "Fetches up to 100 recent tweets per request",
+ "Handles 429 rate limit responses with a user-friendly message suggesting to wait and retry",
+ "Throws descriptive exception if no bearer token is configured, suggesting manual input instead",
+ "Typecheck passes"
+ ],
+ "priority": 24,
+ "passes": false,
+ "notes": ""
+ }
+ ]
+}
diff --git a/scripts/ralph/archive/2026-04-04-colorado-pain-scale/progress.txt b/scripts/ralph/archive/2026-04-04-colorado-pain-scale/progress.txt
new file mode 100644
index 0000000..6889bab
--- /dev/null
+++ b/scripts/ralph/archive/2026-04-04-colorado-pain-scale/progress.txt
@@ -0,0 +1,118 @@
+# Ralph Progress Log
+Started: Sat 4 Apr 2026 19:22:24 BST
+
+## Codebase Patterns
+- Test in-memory SQLite with shared cache: use `file:test_{guid}?mode=memory&cache=shared` as DB path so SqliteDatabase and verification connection share the same DB
+- SqliteDatabase.ConnectionString is public — use it for repository constructors that need to create their own connections
+- Repository pattern: constructor takes SqliteDatabase, stores ConnectionString, creates new SqliteConnection per method call
+- Use COLLATE NOCASE in WHERE clauses for case-insensitive text matching in SQLite
+- dotnet build/test require sandbox disabled (NuGet restore needs network + global cache access)
+- SQLite datetime('now') default has second granularity — don't rely on insertion order in tests when rows are inserted rapidly
+- Use INSERT OR IGNORE + unique constraint for dedup; check rowsAffected > 0 to distinguish new vs duplicate
+- Use secondary ORDER BY id DESC as tiebreaker for "latest" queries when datetime('now') produces same-second timestamps
+- Core interfaces live in Writegeist.Core/Interfaces: IPersonRepository, IPostRepository, IStyleProfileRepository, IDraftRepository, IContentFetcher, ILlmProvider
+- Domain models live in Writegeist.Core/Models: Person, RawPost, StyleProfile, GeneratedDraft (classes), FetchRequest, FetchedPost (records), Platform (enum)
+- DevJonny.InteractiveCli v2.4.0: Menu(quitable, isTopLevel) base class with abstract BuildMenu() using protected MenuBuilder field
+- MenuBuilder.AddMenuItem(name, description) where TAction : IAmAnAction — name is display text, description is tooltip
+- Actions: SingleActionAsync (override SingleAsyncAction), RepeatableActionAsync (override RepeatableAsyncAction returning bool to stop)
+- Bootstrapping: Host.CreateDefaultBuilder(args).AddInteractiveCli().Build().UseInteractiveCli((EmptyOptions _) => new MainMenu(), args)
+- Solution uses .slnx format (net10.0), projects under src/ and tests/
+- Cli references Core + Infrastructure; Infrastructure references Core; Tests references Core + Infrastructure
+---
+
+## 2026-04-04 - US-001
+- Scaffolded Writegeist.sln with four projects: Cli, Core, Infrastructure, Tests
+- All projects target net10.0 with project references wired correctly
+- NuGet packages: DevJonny.InteractiveCli, Microsoft.Extensions.Hosting (Cli); Microsoft.Data.Sqlite, AngleSharp (Infra); xunit, FluentAssertions, NSubstitute (Tests)
+- Program.cs bootstraps InteractiveCli with MainMenu (isTopLevel: true) and a PlaceholderAction
+- Files changed: Writegeist.slnx, src/Writegeist.Cli/*, src/Writegeist.Core/*.csproj, src/Writegeist.Infrastructure/*.csproj, tests/Writegeist.Tests/*.csproj
+- **Learnings for future iterations:**
+ - dotnet new commands need sandbox disabled for ~/.templateengine access
+ - InteractiveCli MenuBuilder.AddMenuItem is generic: AddMenuItem(name, desc) — T must implement IAmAnAction
+ - UseInteractiveCli needs explicit type arg: UseInteractiveCli((EmptyOptions _) => new MainMenu(), args)
+ - Solution created as .slnx format by default on net10.0
+---
+
+## 2026-04-04 - US-002
+- Created all domain models in Writegeist.Core/Models
+- Person, RawPost, StyleProfile, GeneratedDraft as classes with properties
+- Platform enum with LinkedIn, X, Instagram, Facebook
+- FetchRequest record with nullable Url, Handle, FilePath
+- FetchedPost record with Content, nullable SourceUrl, nullable PublishedAt
+- Files changed: src/Writegeist.Core/Models/{Person,RawPost,StyleProfile,GeneratedDraft,Platform,FetchRequest,FetchedPost}.cs
+- **Learnings for future iterations:**
+ - Classes used for entities with mutable Id (set by DB), records for immutable value objects
+ - All nullable properties use C# nullable reference types (string?, int?, DateTime?)
+---
+
+## 2026-04-04 - US-003
+- Created all core interfaces in Writegeist.Core/Interfaces
+- IPersonRepository: CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync
+- IPostRepository: AddAsync (returns bool for new vs duplicate), GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync
+- IStyleProfileRepository: SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync
+- IDraftRepository: SaveAsync, GetLatestAsync, GetByIdAsync
+- IContentFetcher: Platform property, FetchPostsAsync method
+- ILlmProvider: ProviderName, ModelName properties, AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync methods
+- Files changed: src/Writegeist.Core/Interfaces/{IPersonRepository,IPostRepository,IStyleProfileRepository,IDraftRepository,IContentFetcher,ILlmProvider}.cs
+- **Learnings for future iterations:**
+ - IPostRepository.AddAsync returns bool (true = new post, false = duplicate) — simplifies dedup tracking in actions
+ - ILlmProvider methods all take a single string prompt and return string — keeps interface simple
+---
+
+## 2026-04-04 - US-004
+- Created SqliteDatabase class in Writegeist.Infrastructure/Persistence
+- Schema creates 4 tables: persons, raw_posts, style_profiles, generated_drafts with correct FKs and constraints
+- Idempotent via CREATE TABLE IF NOT EXISTS
+- Database path configurable via IConfiguration["Writegeist:DatabasePath"], defaults to writegeist.db
+- Added Microsoft.Extensions.Configuration.Abstractions package to Infrastructure
+- 6 unit tests verify table creation, idempotency, and column presence for all tables
+- Files changed: src/Writegeist.Infrastructure/{Writegeist.Infrastructure.csproj,Persistence/SqliteDatabase.cs}, tests/Writegeist.Tests/Persistence/SqliteDatabaseTests.cs
+- **Learnings for future iterations:**
+ - Use shared cache in-memory SQLite for tests: `file:test_{guid}?mode=memory&cache=shared`
+ - SqliteDatabase exposes ConnectionString property for repos to create connections
+ - SQLite stores dates as TEXT with datetime('now') default
+---
+
+## 2026-04-04 - US-005
+- Implemented SqlitePersonRepository with CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync
+- 7 unit tests: create, get by name, case-insensitive lookup, not found returns null, get all ordered, get-or-create existing, get-or-create new
+- Files changed: src/Writegeist.Infrastructure/Persistence/SqlitePersonRepository.cs, tests/Writegeist.Tests/Persistence/SqlitePersonRepositoryTests.cs
+- **Learnings for future iterations:**
+ - Repository constructor takes SqliteDatabase, extracts ConnectionString, creates new SqliteConnection per method
+ - Use COLLATE NOCASE for case-insensitive matching in SQLite queries
+ - ReadPerson helper maps SqliteDataReader columns by ordinal position
+ - dotnet build/test commands need sandbox disabled for NuGet/global cache access
+---
+
+## 2026-04-04 - US-006
+- Implemented SqlitePostRepository with AddAsync (INSERT OR IGNORE for dedup), GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync
+- SHA-256 hash computed from normalised (trimmed, lowercased) content via static ComputeHash method
+- 8 unit tests: add new, add duplicate, normalised hash dedup, retrieval, count, exists by hash (true/false), hash format
+- Files changed: src/Writegeist.Infrastructure/Persistence/SqlitePostRepository.cs, tests/Writegeist.Tests/Persistence/SqlitePostRepositoryTests.cs
+- **Learnings for future iterations:**
+ - Use INSERT OR IGNORE with unique constraint for dedup — returns rowsAffected=0 for duplicates
+ - SQLite datetime('now') has second granularity — don't test ordering when rows inserted in same second
+ - ComputeHash is public static for testability and reuse in fetchers
+---
+
+## 2026-04-04 - US-007
+- Implemented SqliteStyleProfileRepository with SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync
+- ORDER BY created_at DESC, id DESC used for "latest" queries to handle same-second inserts
+- 5 unit tests: save, get latest, get latest null, get all descending, get all empty
+- Files changed: src/Writegeist.Infrastructure/Persistence/SqliteStyleProfileRepository.cs, tests/Writegeist.Tests/Persistence/SqliteStyleProfileRepositoryTests.cs
+- **Learnings for future iterations:**
+ - Use secondary ORDER BY id DESC as tiebreaker when datetime('now') produces same timestamps
+ - All repos follow identical pattern: constructor(SqliteDatabase), store ConnectionString, new connection per method
+---
+
+## 2026-04-04 - US-008
+- Implemented SqliteDraftRepository with SaveAsync, GetLatestAsync, GetByIdAsync
+- Handles nullable ParentDraftId and Feedback with DBNull.Value
+- Uses shared SelectColumns constant to avoid repeating column list
+- 7 unit tests: save, get latest, get latest null, get by id, get by id null, parent-child linking, nullable fields
+- Files changed: src/Writegeist.Infrastructure/Persistence/SqliteDraftRepository.cs, tests/Writegeist.Tests/Persistence/SqliteDraftRepositoryTests.cs
+- **Learnings for future iterations:**
+ - Use DBNull.Value for nullable parameters in SQLite commands: (object?)value ?? DBNull.Value
+ - Test setup for drafts requires inserting person AND style_profile first (FK constraints)
+ - All four repository implementations are now complete and follow the same pattern
+---
diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json
new file mode 100644
index 0000000..6c6504f
--- /dev/null
+++ b/scripts/ralph/prd.json
@@ -0,0 +1,445 @@
+{
+ "project": "Writegeist",
+ "branchName": "ralph/writegeist-v1",
+ "description": "Interactive .NET CLI tool that clones a person's writing style from social media posts and generates platform-specific content on demand",
+ "userStories": [
+ {
+ "id": "US-001",
+ "title": "Solution scaffolding and project structure",
+ "description": "As a developer, I need the .NET solution with all projects, references, and NuGet packages so that I can start building features.",
+ "acceptanceCriteria": [
+ "Writegeist.sln with four projects: Writegeist.Cli, Writegeist.Core, Writegeist.Infrastructure, Writegeist.Tests",
+ "All projects target net10.0",
+ "Project references wired: Cli references Core + Infrastructure, Infrastructure references Core, Tests references Core + Infrastructure",
+ "Writegeist.Cli references DevJonny.InteractiveCli NuGet package",
+ "Writegeist.Infrastructure references Microsoft.Data.Sqlite and AngleSharp",
+ "Writegeist.Tests references xunit, FluentAssertions, and NSubstitute",
+ "Program.cs bootstraps InteractiveCli with a MainMenu that displays menu items and quits cleanly",
+ "MainMenu is a top-level menu (isTopLevel: true) with a placeholder menu item",
+ "dotnet build succeeds with no errors",
+ "Typecheck passes"
+ ],
+ "priority": 1,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-002",
+ "title": "Domain models",
+ "description": "As a developer, I need the core domain models defined so that repositories and services can use them.",
+ "acceptanceCriteria": [
+ "Person model in Writegeist.Core/Models with Id (int), Name (string), CreatedAt (DateTime)",
+ "RawPost model with Id, PersonId, Platform, Content, ContentHash, SourceUrl, FetchedAt",
+ "Platform enum with values: LinkedIn, X, Instagram, Facebook",
+ "StyleProfile model with Id, PersonId, ProfileJson, Provider, Model, CreatedAt",
+ "GeneratedDraft model with Id, PersonId, StyleProfileId, Platform, Topic, Content, ParentDraftId (nullable), Feedback (nullable), Provider, Model, CreatedAt",
+ "FetchRequest record with nullable Url, Handle, FilePath properties",
+ "FetchedPost record with Content, nullable SourceUrl, nullable PublishedAt",
+ "dotnet build succeeds",
+ "Typecheck passes"
+ ],
+ "priority": 2,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-003",
+ "title": "Core interfaces",
+ "description": "As a developer, I need the core interfaces defined so that infrastructure implementations can be built against them.",
+ "acceptanceCriteria": [
+ "IPersonRepository interface in Writegeist.Core/Interfaces with CreateAsync, GetByNameAsync, GetAllAsync, GetOrCreateAsync",
+ "IPostRepository interface with AddAsync, GetByPersonIdAsync, GetCountByPersonIdAsync, ExistsByHashAsync",
+ "IStyleProfileRepository interface with SaveAsync, GetLatestByPersonIdAsync, GetAllByPersonIdAsync",
+ "IDraftRepository interface with SaveAsync, GetLatestAsync, GetByIdAsync",
+ "IContentFetcher interface with Platform property and FetchPostsAsync method",
+ "ILlmProvider interface with ProviderName, ModelName properties and AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync methods",
+ "dotnet build succeeds",
+ "Typecheck passes"
+ ],
+ "priority": 3,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-004",
+ "title": "SQLite database and schema initialisation",
+ "description": "As a developer, I need the SQLite database created with the correct schema so that data persists locally.",
+ "acceptanceCriteria": [
+ "SqliteDatabase class in Writegeist.Infrastructure/Persistence with EnsureCreated method",
+ "Creates persons table with id (autoincrement PK), name (unique, not null), created_at (default now)",
+ "Creates raw_posts table with FK to persons, platform, content, content_hash, source_url, fetched_at, unique constraint on (person_id, content_hash)",
+ "Creates style_profiles table with FK to persons, profile_json, provider, model, created_at",
+ "Creates generated_drafts table with FK to persons and style_profiles, parent_draft_id self-ref FK, platform, topic, content, feedback, provider, model, created_at",
+ "Schema creation is idempotent (uses CREATE TABLE IF NOT EXISTS)",
+ "Database path configurable via IConfiguration (Writegeist:DatabasePath key)",
+ "Unit tests verify all four tables are created using in-memory SQLite",
+ "Typecheck passes"
+ ],
+ "priority": 4,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-005",
+ "title": "Person repository implementation",
+ "description": "As a developer, I need the person repository so that persons can be stored and retrieved.",
+ "acceptanceCriteria": [
+ "SqlitePersonRepository implements IPersonRepository",
+ "CreateAsync inserts a new person and returns the model with the generated Id",
+ "GetByNameAsync performs case-insensitive lookup and returns null if not found",
+ "GetAllAsync returns all persons ordered by name",
+ "GetOrCreateAsync returns existing person if name matches (case-insensitive), creates new one if not",
+ "Unit tests with in-memory SQLite cover create, get by name, get all, get-or-create (existing), get-or-create (new)",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 5,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-006",
+ "title": "Post repository implementation",
+ "description": "As a developer, I need the post repository so that ingested posts can be stored and deduplicated.",
+ "acceptanceCriteria": [
+ "SqlitePostRepository implements IPostRepository",
+ "AddAsync inserts a post and returns a result indicating whether it was new or a duplicate",
+ "Content hash is SHA-256 of normalised (trimmed, lowercased) content text",
+ "Duplicate detection uses the unique constraint on (person_id, content_hash)",
+ "GetByPersonIdAsync returns all posts for a person ordered by fetched_at",
+ "GetCountByPersonIdAsync returns the count of posts for a person",
+ "Unit tests cover add new, add duplicate, retrieval, and count",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 6,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-007",
+ "title": "Style profile repository implementation",
+ "description": "As a developer, I need the style profile repository so that analysed profiles can be stored and retrieved.",
+ "acceptanceCriteria": [
+ "SqliteStyleProfileRepository implements IStyleProfileRepository",
+ "SaveAsync inserts a profile and returns the model with generated Id",
+ "GetLatestByPersonIdAsync returns the most recent profile by created_at, or null if none exist",
+ "GetAllByPersonIdAsync returns all profiles for a person ordered by created_at descending",
+ "Unit tests cover save, retrieve latest, retrieve all, and null case",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 7,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-008",
+ "title": "Draft repository implementation",
+ "description": "As a developer, I need the draft repository so that generated and refined drafts can be stored and chained.",
+ "acceptanceCriteria": [
+ "SqliteDraftRepository implements IDraftRepository",
+ "SaveAsync inserts a draft and returns the model with generated Id",
+ "GetLatestAsync returns the most recently created draft across all persons, or null if none exist",
+ "GetByIdAsync returns a draft by Id, or null if not found",
+ "parent_draft_id correctly links refined drafts to their predecessors",
+ "Unit tests cover save, get latest, get by id, parent-child linking, and null cases",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 8,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-009",
+ "title": "Manual fetcher — file import",
+ "description": "As a user, I want to import posts from a text file so that I can feed Writegeist content without API access.",
+ "acceptanceCriteria": [
+ "ManualFetcher class in Writegeist.Infrastructure/Fetchers implements IContentFetcher",
+ "Platform property returns a configurable platform value (set at construction)",
+ "FetchPostsAsync reads a file path from FetchRequest.FilePath",
+ "Splits file content on lines containing only --- as separator",
+ "Trims whitespace from each post and skips empty entries",
+ "Returns a list of FetchedPost records with content populated",
+ "Unit tests cover: normal file with multiple posts, empty file, file with no separators (single post), file with consecutive separators",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 9,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-010",
+ "title": "Platform conventions",
+ "description": "As a developer, I need platform-specific rules so that generated posts follow each platform's norms.",
+ "acceptanceCriteria": [
+ "PlatformRules record in Writegeist.Core with Name, MaxCharacters (nullable int), RecommendedMaxLength, SupportsHashtags, RecommendedHashtagCount, HashtagPlacement, SupportsEmoji, EmojiGuidance, ToneGuidance, FormattingNotes",
+ "PlatformConventions class with static GetRules(Platform) method",
+ "LinkedIn: 3000 max, 1500 recommended, hashtags end, 3-5 count, emoji sparingly",
+ "X: 280 max, 280 recommended, hashtags inline, 1-2 count, emoji moderate",
+ "Instagram: 2200 max, 750 recommended, hashtags end in separate block, up to 30, emoji freely",
+ "Facebook: 63206 max, 600 recommended, hashtags end, 1-3 count, emoji moderate",
+ "Unit tests verify rules for all four platforms",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 10,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-011",
+ "title": "Anthropic LLM provider",
+ "description": "As a user, I want to use Claude as the LLM backend so that I can analyse style and generate posts.",
+ "acceptanceCriteria": [
+ "AnthropicProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider",
+ "Uses official Anthropic NuGet SDK if available, otherwise raw HttpClient to https://api.anthropic.com/v1/messages",
+ "API key read from ANTHROPIC_API_KEY environment variable via IConfiguration",
+ "Model name configurable via appsettings.json (Writegeist:Anthropic:Model), defaults to claude-sonnet-4-20250514",
+ "ProviderName returns 'anthropic', ModelName returns the configured model",
+ "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response",
+ "Throws a descriptive exception if API key is not configured",
+ "Handles HTTP errors (401, 429, 500) with user-friendly exception messages",
+ "Typecheck passes"
+ ],
+ "priority": 11,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-012",
+ "title": "OpenAI LLM provider",
+ "description": "As a user, I want to use OpenAI as an alternative LLM backend.",
+ "acceptanceCriteria": [
+ "OpenAiProvider class in Writegeist.Infrastructure/LlmProviders implements ILlmProvider",
+ "Uses official OpenAI NuGet package",
+ "API key read from OPENAI_API_KEY environment variable via IConfiguration",
+ "Model name configurable via appsettings.json (Writegeist:OpenAi:Model), defaults to gpt-4o",
+ "ProviderName returns 'openai', ModelName returns the configured model",
+ "AnalyseStyleAsync, GeneratePostAsync, RefinePostAsync all send the prompt and return the text response",
+ "Throws a descriptive exception if API key is not configured",
+ "Handles HTTP errors with user-friendly exception messages",
+ "Typecheck passes"
+ ],
+ "priority": 12,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-013",
+ "title": "Style analyser service",
+ "description": "As a developer, I need the style analysis orchestration so that ingested posts can be turned into a style profile.",
+ "acceptanceCriteria": [
+ "StyleAnalyser class in Writegeist.Core/Services",
+ "Constructor takes IPostRepository, IStyleProfileRepository, and ILlmProvider",
+ "AnalyseAsync(int personId) loads all posts for the person, builds the style analysis prompt with all post contents, sends to LLM, and stores the result",
+ "Uses the style analysis prompt template from the implementation plan (section 7.1)",
+ "Returns the saved StyleProfile model",
+ "Throws if no posts exist for the person",
+ "Unit tests with mocked dependencies verify prompt construction and profile storage",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 13,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-014",
+ "title": "Post generator service",
+ "description": "As a developer, I need the post generation orchestration so that drafts can be created from a style profile and topic.",
+ "acceptanceCriteria": [
+ "PostGenerator class in Writegeist.Core/Services",
+ "Constructor takes IStyleProfileRepository, IDraftRepository, and ILlmProvider",
+ "GenerateAsync(int personId, Platform platform, string topic) loads latest style profile, gets platform conventions, builds generation prompt, sends to LLM, stores draft",
+ "Uses the generation prompt template from the implementation plan (section 7.2)",
+ "RefineAsync(int draftId, string feedback) loads draft, its style profile, builds refinement prompt, sends to LLM, stores new draft linked to previous",
+ "Uses the refinement prompt template from the implementation plan (section 7.3)",
+ "Returns the saved GeneratedDraft model",
+ "Throws if no style profile exists for the person",
+ "Unit tests with mocked dependencies verify prompt construction and draft storage",
+ "Typecheck passes",
+ "Tests pass"
+ ],
+ "priority": 14,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-015",
+ "title": "DI registration and appsettings.json",
+ "description": "As a developer, I need all services, repositories, and providers registered in DI so that actions can resolve them.",
+ "acceptanceCriteria": [
+ "Program.cs configureServices lambda registers: SqliteDatabase, all four repositories, StyleAnalyser, PostGenerator, both LLM providers, ManualFetcher",
+ "LLM providers registered as keyed services or via a factory that resolves by provider name string",
+ "appsettings.json contains Writegeist section with DefaultProvider, DatabasePath, Anthropic:Model, OpenAi:Model",
+ "SqliteDatabase.EnsureCreated() called during startup",
+ "dotnet build succeeds",
+ "Typecheck passes"
+ ],
+ "priority": 15,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-016",
+ "title": "Ingest menu and file import action",
+ "description": "As a user, I want an Ingest Posts sub-menu with a From File action so that I can import posts from a text file.",
+ "acceptanceCriteria": [
+ "IngestMenu class extends Menu (quitable: false, isTopLevel: false) with BuildMenu adding From File, Interactive Paste, From URL / Handle menu items",
+ "MainMenu adds IngestMenu as 'Ingest Posts' menu item",
+ "IngestFromFileAction extends SingleActionAsync",
+ "Prompts for person name via Spectre.Console TextPrompt",
+ "Prompts for platform via Spectre.Console SelectionPrompt (LinkedIn, X, Instagram, Facebook)",
+ "Prompts for file path via TextPrompt",
+ "Uses IPersonRepository.GetOrCreateAsync to ensure person exists",
+ "Uses ManualFetcher to read the file, then stores each post via IPostRepository.AddAsync",
+ "Displays a Spectre.Console Panel summary: count of new posts (green), duplicates skipped (yellow)",
+ "Typecheck passes"
+ ],
+ "priority": 16,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-017",
+ "title": "Interactive paste action",
+ "description": "As a user, I want to paste posts one at a time in an interactive session.",
+ "acceptanceCriteria": [
+ "IngestInteractiveAction extends RepeatableActionAsync",
+ "On first iteration, prompts for person name and platform via Spectre.Console prompts",
+ "Each iteration prompts for post content via TextPrompt",
+ "Stores each post via IPostRepository.AddAsync with content hash dedup",
+ "After each post, asks 'Add another post?' — returns false to continue, true to stop",
+ "On completion, displays Panel summary with total new posts and duplicates skipped",
+ "Typecheck passes"
+ ],
+ "priority": 17,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-018",
+ "title": "Analyse style action",
+ "description": "As a user, I want a menu action to analyse my posts and build a style profile.",
+ "acceptanceCriteria": [
+ "AnalyseAction extends SingleActionAsync, added to MainMenu as 'Analyse Style'",
+ "Prompts user to select a person from existing persons via SelectionPrompt (shows error if none exist)",
+ "Prompts user to select LLM provider via SelectionPrompt (Anthropic, OpenAI) with default from config",
+ "Calls StyleAnalyser.AnalyseAsync with the selected person",
+ "Displays spinner via AnsiConsole.Status() while LLM processes",
+ "Displays the profile summary in a styled Panel after completion",
+ "Catches and displays errors in red markup without crashing the menu loop",
+ "Typecheck passes"
+ ],
+ "priority": 18,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-019",
+ "title": "Generate post action",
+ "description": "As a user, I want a menu action to generate a new post in my style for a specific platform.",
+ "acceptanceCriteria": [
+ "GenerateAction extends SingleActionAsync, added to MainMenu as 'Generate Post'",
+ "Prompts user to select a person (only persons with a style profile) via SelectionPrompt",
+ "Prompts user to select target platform via SelectionPrompt",
+ "Prompts user to enter topic/key points via TextPrompt",
+ "Calls PostGenerator.GenerateAsync with person, platform, and topic",
+ "Displays spinner via AnsiConsole.Status() while LLM processes",
+ "Displays the generated post in a styled Panel with border and title",
+ "Catches and displays errors in red markup without crashing",
+ "Typecheck passes"
+ ],
+ "priority": 19,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-020",
+ "title": "Refine draft action",
+ "description": "As a user, I want a menu action to iteratively refine the last generated draft with feedback.",
+ "acceptanceCriteria": [
+ "RefineAction extends RepeatableActionAsync, added to MainMenu as 'Refine Last Draft'",
+ "On first iteration, loads the most recent GeneratedDraft and displays it in a Panel (shows error if none exist)",
+ "Prompts for feedback via TextPrompt",
+ "Calls PostGenerator.RefineAsync with the draft ID and feedback",
+ "Displays spinner via AnsiConsole.Status() while LLM processes",
+ "Displays the refined post in a styled Panel",
+ "Asks 'Refine again?' — returns false to continue looping, true to stop and return to menu",
+ "Catches and displays errors in red markup without crashing",
+ "Typecheck passes"
+ ],
+ "priority": 20,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-021",
+ "title": "Profile menu and actions",
+ "description": "As a user, I want to view and list style profiles from the menu.",
+ "acceptanceCriteria": [
+ "ProfileMenu extends Menu (quitable: false, isTopLevel: false) with Show Profile and List All Profiles items",
+ "MainMenu adds ProfileMenu as 'Profiles' menu item",
+ "ShowProfileAction extends SingleActionAsync, prompts for person selection, displays full style profile in a formatted Spectre.Console Table with grouped sections (vocabulary, tone, formatting, etc.)",
+ "ListProfilesAction extends SingleActionAsync, displays a Table of all persons with profiles showing name, provider, model, and created date",
+ "Both actions show a message if no profiles exist",
+ "Typecheck passes"
+ ],
+ "priority": 21,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-022",
+ "title": "Stub fetchers for unsupported platforms",
+ "description": "As a developer, I need stub fetchers for LinkedIn, Instagram, and Facebook that guide users to manual input.",
+ "acceptanceCriteria": [
+ "LinkedInFetcher, InstagramFetcher, FacebookFetcher each implement IContentFetcher in Writegeist.Infrastructure/Fetchers",
+ "Each returns the correct Platform enum value from the Platform property",
+ "FetchPostsAsync throws a descriptive exception explaining automated fetching is not available for this platform",
+ "Exception message suggests using From File or Interactive Paste instead",
+ "Typecheck passes"
+ ],
+ "priority": 22,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-023",
+ "title": "Ingest from URL action and fetcher dispatch",
+ "description": "As a user, I want to ingest posts from a URL or handle, with clear error messages for unsupported platforms.",
+ "acceptanceCriteria": [
+ "IngestFromUrlAction extends SingleActionAsync, wired into IngestMenu as 'From URL / Handle'",
+ "Prompts for person name, platform, and URL or handle via Spectre.Console prompts",
+ "Resolves the correct IContentFetcher by platform (using DI factory or keyed services)",
+ "Calls FetchPostsAsync and stores results via IPostRepository.AddAsync",
+ "Catches fetcher exceptions for unsupported platforms and displays the message in yellow markup",
+ "Displays Panel summary for successful fetches",
+ "Typecheck passes"
+ ],
+ "priority": 23,
+ "passes": true,
+ "notes": ""
+ },
+ {
+ "id": "US-024",
+ "title": "X/Twitter API fetcher",
+ "description": "As a user, I want to fetch recent tweets automatically via the X API.",
+ "acceptanceCriteria": [
+ "XTwitterFetcher in Writegeist.Infrastructure/Fetchers implements IContentFetcher for Platform.X",
+ "Uses HttpClient to call X API v2 GET /2/users/{id}/tweets endpoint",
+ "Bearer token read from X_BEARER_TOKEN environment variable",
+ "Fetches up to 100 recent tweets per request",
+ "Handles 429 rate limit responses with a user-friendly message suggesting to wait and retry",
+ "Throws descriptive exception if no bearer token is configured, suggesting manual input instead",
+ "Typecheck passes"
+ ],
+ "priority": 24,
+ "passes": true,
+ "notes": ""
+ }
+ ]
+}
diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt
new file mode 100644
index 0000000..6e55b38
--- /dev/null
+++ b/scripts/ralph/progress.txt
@@ -0,0 +1,185 @@
+# Ralph Progress Log
+Started: Sat 4 Apr 2026 21:15:40 BST
+
+## Codebase Patterns
+- Use `dotnet test` with `--filter` to run specific test classes (e.g., `--filter "ManualFetcher"`)
+- `dotnet build`/`dotnet test` commands need sandbox disabled (`dangerouslyDisableSandbox`) due to MSBuild named pipe permissions
+- Infrastructure project references Core; Tests references Core + Infrastructure (no Cli dependency)
+- Test projects use in-memory SQLite for repository tests, temp directories for file-based tests
+- Records are used for DTOs (FetchRequest, FetchedPost), classes for domain models (Person, RawPost, etc.)
+- Fetcher tests implement IDisposable to clean up temp directories
+- LLM providers use HttpClient injection + IConfiguration for API keys and model names
+- All ILlmProvider methods (AnalyseStyle, GeneratePost, RefinePost) delegate to a single API call method
+- For actions needing user-selected LLM provider: inject both AnthropicProvider and OpenAiProvider concrete types, construct service manually
+- AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync() for spinner during LLM calls
+- RepeatableActionAsync: return `true` to stop, `false` to continue; use instance fields for cross-iteration state
+- Platform-specific fetchers registered as IContentFetcher in DI; resolve via IEnumerable and filter by Platform
+---
+
+## 2026-04-04 21:55 BST - US-009
+- ManualFetcher and ManualFetcherTests were already implemented but not committed
+- Files committed: src/Writegeist.Infrastructure/Fetchers/ManualFetcher.cs, tests/Writegeist.Tests/Fetchers/ManualFetcherTests.cs
+- All 39 tests pass, full solution builds successfully
+- **Learnings for future iterations:**
+ - Previous iterations may leave files uncommitted — always check `git status` for untracked files
+ - The Cli project restore can hang in sandbox mode; build/test individual projects when possible
+ - ManualFetcher splits on `---` string separator and trims/filters empty entries
+---
+
+## 2026-04-04 22:00 BST - US-010
+- Implemented PlatformRules record in Writegeist.Core/Models
+- Implemented PlatformConventions static class in Writegeist.Core with GetRules(Platform) method
+- Rules for LinkedIn (3000/1500), X (280/280), Instagram (2200/750), Facebook (63206/600)
+- Files created: src/Writegeist.Core/Models/PlatformRules.cs, src/Writegeist.Core/PlatformConventions.cs, tests/Writegeist.Tests/PlatformConventionsTests.cs
+- All 43 tests pass
+- **Learnings for future iterations:**
+ - PlatformConventions is a static class at Writegeist.Core namespace level (not in Models)
+ - RecommendedHashtagCount for Instagram is up to 30 (much higher than other platforms)
+ - Tests use range assertions (BeInRange) for hashtag counts to match AC ranges like "3-5"
+---
+
+## 2026-04-04 22:05 BST - US-011
+- Implemented AnthropicProvider in Writegeist.Infrastructure/LlmProviders using raw HttpClient
+- Sends requests to https://api.anthropic.com/v1/messages with x-api-key and anthropic-version headers
+- API key from ANTHROPIC_API_KEY env var, model configurable via Writegeist:Anthropic:Model (default claude-sonnet-4-20250514)
+- Handles 401, 429, 500+ errors with user-friendly messages
+- Files created: src/Writegeist.Infrastructure/LlmProviders/AnthropicProvider.cs
+- All 43 tests pass, typecheck passes (no unit tests required per AC — only "Typecheck passes")
+- **Learnings for future iterations:**
+ - LLM providers live in Writegeist.Infrastructure/LlmProviders directory
+ - HttpClient is injected (for DI/testability), IConfiguration used for settings
+ - Anthropic API requires x-api-key header (not Bearer auth) and anthropic-version header
+ - All three ILlmProvider methods delegate to the same SendMessageAsync — the interface distinction is for future prompt differentiation
+---
+
+## 2026-04-05 - US-012
+- OpenAiProvider was implemented by a previous iteration but left uncommitted — committed it
+- Uses official OpenAI NuGet package (v2.10.0) with ChatClient
+- API key from OPENAI_API_KEY env var, model configurable via Writegeist:OpenAi:Model (default gpt-4o)
+- Error handling catches exceptions and maps to user-friendly HttpRequestException messages
+- Files committed: src/Writegeist.Infrastructure/LlmProviders/OpenAiProvider.cs, Writegeist.Infrastructure.csproj
+- All 43 tests pass, build succeeds
+- **Learnings for future iterations:**
+ - OpenAI SDK v2.x uses ChatClient(model, apiKey) constructor and CompleteChatAsync with UserChatMessage
+ - Error handling uses string matching on exception messages (SDK doesn't expose typed status codes cleanly)
+ - Pattern matches AnthropicProvider: constructor with IConfiguration, three methods delegate to one private method
+---
+
+## 2026-04-05 - US-017
+- Implemented IngestInteractiveAction extending RepeatableActionAsync
+- Prompts for person name and platform on first iteration, then prompts for post content each iteration
+- Uses SelectionPrompt for "Add another post?" (Yes/No), displays Panel summary on completion
+- Files changed: src/Writegeist.Cli/Actions/IngestInteractiveAction.cs
+- All 53 tests pass, build succeeds
+- **Learnings for future iterations:**
+ - RepeatableActionAsync: return `true` to stop repeating, `false` to continue
+ - Use instance fields to track state across iterations (e.g., `_isFirstIteration`, `_newCount`)
+ - Primary constructor injection works the same for RepeatableActionAsync as SingleActionAsync
+ - SelectionPrompt with "Yes"/"No" choices is the pattern for confirmation prompts
+---
+
+## 2026-04-05 - US-018
+- Implemented AnalyseAction extending SingleActionAsync, added to MainMenu as 'Analyse Style'
+- Prompts for person selection (with empty check), LLM provider selection (Anthropic/OpenAI), runs analysis with spinner
+- Displays profile JSON in a rounded-border Panel; catches errors in red markup
+- Files created: src/Writegeist.Cli/Actions/AnalyseAction.cs
+- Files changed: src/Writegeist.Cli/Menus/MainMenu.cs
+- All 53 tests pass, build succeeds
+- **Learnings for future iterations:**
+ - StyleAnalyser takes ILlmProvider in constructor — to allow user provider selection, inject both concrete providers (AnthropicProvider, OpenAiProvider) and construct StyleAnalyser manually with the chosen one
+ - AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync() is the pattern for spinner during async operations
+ - MainMenu uses `.AddMenuItem("Label", "Description")` chain pattern
+ - Wrap entire action body in try/catch for error display without crashing the menu loop
+---
+
+## 2026-04-05 - US-019
+- Implemented GenerateAction extending SingleActionAsync, added to MainMenu as 'Generate Post'
+- Filters persons to only those with style profiles before presenting selection
+- Prompts for person, target platform (via Platform enum), topic, and LLM provider
+- Constructs PostGenerator with selected LLM provider, generates draft with spinner, displays in rounded Panel
+- Escapes generated content via Markup.Escape() before rendering in Panel
+- Files created: src/Writegeist.Cli/Actions/GenerateAction.cs
+- Files changed: src/Writegeist.Cli/Menus/MainMenu.cs
+- All 53 tests pass, build succeeds
+- **Learnings for future iterations:**
+ - SelectionPrompt works directly with enum types — no need to convert to strings
+ - Filter persons by checking GetLatestByPersonIdAsync for each — no batch method available
+ - Generated content must be Markup.Escape()'d before displaying in Spectre Panel to avoid markup parsing errors
+---
+
+## 2026-04-05 - US-020
+- Implemented RefineAction extending RepeatableActionAsync, added to MainMenu as 'Refine Last Draft'
+- On first iteration: loads latest draft via IDraftRepository.GetLatestAsync, displays in Panel, prompts for LLM provider
+- Each iteration: prompts for feedback, calls PostGenerator.RefineAsync with spinner, displays refined draft
+- Asks "Refine again?" via SelectionPrompt — "No" returns true (stop), "Yes" returns false (continue)
+- Error handling wraps entire action in try/catch with red markup display
+- Files created: src/Writegeist.Cli/Actions/RefineAction.cs
+- Files changed: src/Writegeist.Cli/Menus/MainMenu.cs
+- All 53 tests pass, build succeeds
+- **Learnings for future iterations:**
+ - RefineAction needs both LLM provider concrete types injected for user selection (same pattern as GenerateAction/AnalyseAction)
+ - PostGenerator is constructed manually with the selected provider, same as GenerateAction
+ - _currentDraft tracks state across iterations — updated after each refinement so the next iteration refines the latest version
+ - LLM provider selection only happens on first iteration to avoid re-prompting during refinement loop
+---
+
+## 2026-04-05 - US-021
+- Implemented ProfileMenu extending Menu (quitable: false, isTopLevel: false) with Show Profile and List All Profiles items
+- ShowProfileAction: prompts for person (filtered to those with profiles), displays profile as structured Table (parsing JSON properties as rows) with fallback to raw Panel if not valid JSON
+- ListProfilesAction: displays a Table of all persons with profiles showing name, provider, model, and created date
+- Both actions show yellow message if no profiles exist
+- MainMenu updated to add ProfileMenu as 'Profiles' menu item
+- Files created: src/Writegeist.Cli/Menus/ProfileMenu.cs, src/Writegeist.Cli/Actions/ShowProfileAction.cs, src/Writegeist.Cli/Actions/ListProfilesAction.cs
+- Files changed: src/Writegeist.Cli/Menus/MainMenu.cs
+- All 53 tests pass, build succeeds
+- **Learnings for future iterations:**
+ - ProfileMenu follows same pattern as IngestMenu: Menu(quitable: false, isTopLevel: false)
+ - ShowProfileAction uses System.Text.Json to parse ProfileJson into a Table with Section/Details columns
+ - Fallback to raw Panel display when ProfileJson is not valid JSON (e.g., plain text LLM responses)
+ - Filter persons to only those with profiles by iterating and checking GetLatestByPersonIdAsync (same pattern as GenerateAction)
+---
+
+## 2026-04-05 - US-022
+- Implemented LinkedInFetcher, InstagramFetcher, FacebookFetcher as stub fetchers
+- Each returns correct Platform enum value via expression-bodied property
+- FetchPostsAsync throws NotSupportedException with descriptive message suggesting From File or Interactive Paste
+- Files created: src/Writegeist.Infrastructure/Fetchers/LinkedInFetcher.cs, InstagramFetcher.cs, FacebookFetcher.cs
+- All 53 tests pass, build succeeds
+- **Learnings for future iterations:**
+ - Stub fetchers use expression-bodied Platform property (`=> Platform.X`) vs ManualFetcher's constructor-injected platform
+ - NotSupportedException is the appropriate exception type for "not implemented" platform fetchers
+ - These fetchers will be resolved by platform in US-023's fetcher dispatch
+---
+
+## 2026-04-05 - US-023
+- Implemented IngestFromUrlAction extending SingleActionAsync with full fetcher dispatch
+- Prompts for person name, platform, and URL/handle via Spectre.Console prompts
+- Resolves correct IContentFetcher by platform from IEnumerable injected via DI
+- Catches NotSupportedException from stub fetchers and displays message in yellow markup
+- Catches general exceptions in red markup without crashing
+- Displays Panel summary for successful fetches (same pattern as IngestFromFileAction)
+- Registered LinkedInFetcher, InstagramFetcher, FacebookFetcher as IContentFetcher in Program.cs DI
+- Files changed: src/Writegeist.Cli/Actions/IngestFromUrlAction.cs, src/Writegeist.Cli/Program.cs
+- All 53 tests pass, build succeeds
+- **Learnings for future iterations:**
+ - Register fetchers as IContentFetcher (not concrete types) to enable IEnumerable injection
+ - Fetcher dispatch pattern: inject IEnumerable, filter by Platform property
+ - NotSupportedException caught separately from general exceptions for yellow vs red markup display
+ - FetchRequest populates both Url and Handle from the same user input (fetcher decides which to use)
+ - When US-024 (XTwitterFetcher) is implemented, just register it as another IContentFetcher — no action changes needed
+---
+
+## 2026-04-05 - US-024
+- XTwitterFetcher was already implemented by a previous iteration but left uncommitted — committed it
+- Uses HttpClient to call X API v2: resolves username to user ID, then fetches up to 100 tweets
+- Bearer token from X_BEARER_TOKEN env var; throws descriptive exception if missing
+- Handles 429 rate limit with user-friendly message; parses tweet text, id, and created_at
+- DI registration as IContentFetcher was already in place from US-023
+- Files committed: src/Writegeist.Infrastructure/Fetchers/XTwitterFetcher.cs
+- All 53 tests pass, build succeeds
+- **Learnings for future iterations:**
+ - XTwitterFetcher resolves handle to user ID via /2/users/by/username/{handle} before fetching tweets
+ - X API v2 uses Bearer token auth (not API key header like Anthropic)
+ - tweet.fields=created_at query param needed to get published dates
+ - Previous iterations may leave files untracked — always check git status for pending work
+---
diff --git a/scripts/ralph/prompt.md b/scripts/ralph/prompt.md
new file mode 100644
index 0000000..cdebe90
--- /dev/null
+++ b/scripts/ralph/prompt.md
@@ -0,0 +1,108 @@
+# Ralph Agent Instructions
+
+You are an autonomous coding agent working on a software project.
+
+## Your Task
+
+1. Read the PRD at `prd.json` (in the same directory as this file)
+2. Read the progress log at `progress.txt` (check Codebase Patterns section first)
+3. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main.
+4. Pick the **highest priority** user story where `passes: false`
+5. Implement that single user story
+6. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires)
+7. Update AGENTS.md files if you discover reusable patterns (see below)
+8. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]`
+9. Update the PRD to set `passes: true` for the completed story
+10. Append your progress to `progress.txt`
+
+## Progress Report Format
+
+APPEND to progress.txt (never replace, always append):
+```
+## [Date/Time] - [Story ID]
+Thread: https://ampcode.com/threads/$AMP_CURRENT_THREAD_ID
+- What was implemented
+- Files changed
+- **Learnings for future iterations:**
+ - Patterns discovered (e.g., "this codebase uses X for Y")
+ - Gotchas encountered (e.g., "don't forget to update Z when changing W")
+ - Useful context (e.g., "the evaluation panel is in component X")
+---
+```
+
+Include the thread URL so future iterations can use the `read_thread` tool to reference previous work if needed.
+
+The learnings section is critical - it helps future iterations avoid repeating mistakes and understand the codebase better.
+
+## Consolidate Patterns
+
+If you discover a **reusable pattern** that future iterations should know, add it to the `## Codebase Patterns` section at the TOP of progress.txt (create it if it doesn't exist). This section should consolidate the most important learnings:
+
+```
+## Codebase Patterns
+- Example: Use `sql` template for aggregations
+- Example: Always use `IF NOT EXISTS` for migrations
+- Example: Export types from actions.ts for UI components
+```
+
+Only add patterns that are **general and reusable**, not story-specific details.
+
+## Update AGENTS.md Files
+
+Before committing, check if any edited files have learnings worth preserving in nearby AGENTS.md files:
+
+1. **Identify directories with edited files** - Look at which directories you modified
+2. **Check for existing AGENTS.md** - Look for AGENTS.md in those directories or parent directories
+3. **Add valuable learnings** - If you discovered something future developers/agents should know:
+ - API patterns or conventions specific to that module
+ - Gotchas or non-obvious requirements
+ - Dependencies between files
+ - Testing approaches for that area
+ - Configuration or environment requirements
+
+**Examples of good AGENTS.md additions:**
+- "When modifying X, also update Y to keep them in sync"
+- "This module uses pattern Z for all API calls"
+- "Tests require the dev server running on PORT 3000"
+- "Field names must match the template exactly"
+
+**Do NOT add:**
+- Story-specific implementation details
+- Temporary debugging notes
+- Information already in progress.txt
+
+Only update AGENTS.md if you have **genuinely reusable knowledge** that would help future work in that directory.
+
+## Quality Requirements
+
+- ALL commits must pass your project's quality checks (typecheck, lint, test)
+- Do NOT commit broken code
+- Keep changes focused and minimal
+- Follow existing code patterns
+
+## Browser Testing (Required for Frontend Stories)
+
+For any story that changes UI, you MUST verify it works in the browser:
+
+1. Load the `dev-browser` skill
+2. Navigate to the relevant page
+3. Verify the UI changes work as expected
+4. Take a screenshot if helpful for the progress log
+
+A frontend story is NOT complete until browser verification passes.
+
+## Stop Condition
+
+After completing a user story, check if ALL stories have `passes: true`.
+
+If ALL stories are complete and passing, reply with:
+COMPLETE
+
+If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story).
+
+## Important
+
+- Work on ONE story per iteration
+- Commit frequently
+- Keep CI green
+- Read the Codebase Patterns section in progress.txt before starting
diff --git a/scripts/ralph/ralph.sh b/scripts/ralph/ralph.sh
new file mode 100755
index 0000000..fffec0d
--- /dev/null
+++ b/scripts/ralph/ralph.sh
@@ -0,0 +1,146 @@
+#!/bin/bash
+# Ralph Wiggum - Long-running AI agent loop
+# Usage: ./ralph.sh [--tool amp|claude] [max_iterations]
+
+set -e
+
+# Parse arguments
+TOOL="amp" # Default to amp for backwards compatibility
+MAX_ITERATIONS=10
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --tool)
+ TOOL="$2"
+ shift 2
+ ;;
+ --tool=*)
+ TOOL="${1#*=}"
+ shift
+ ;;
+ *)
+ # Assume it's max_iterations if it's a number
+ if [[ "$1" =~ ^[0-9]+$ ]]; then
+ MAX_ITERATIONS="$1"
+ fi
+ shift
+ ;;
+ esac
+done
+
+# Validate tool choice
+if [[ "$TOOL" != "amp" && "$TOOL" != "claude" ]]; then
+ echo "Error: Invalid tool '$TOOL'. Must be 'amp' or 'claude'."
+ exit 1
+fi
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PRD_FILE="$SCRIPT_DIR/prd.json"
+PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
+ARCHIVE_DIR="$SCRIPT_DIR/archive"
+LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch"
+
+# Archive previous run if branch changed
+if [ -f "$PRD_FILE" ] && [ -f "$LAST_BRANCH_FILE" ]; then
+ CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
+ LAST_BRANCH=$(cat "$LAST_BRANCH_FILE" 2>/dev/null || echo "")
+
+ if [ -n "$CURRENT_BRANCH" ] && [ -n "$LAST_BRANCH" ] && [ "$CURRENT_BRANCH" != "$LAST_BRANCH" ]; then
+ # Archive the previous run
+ DATE=$(date +%Y-%m-%d)
+ # Strip "ralph/" prefix from branch name for folder
+ FOLDER_NAME=$(echo "$LAST_BRANCH" | sed 's|^ralph/||')
+ ARCHIVE_FOLDER="$ARCHIVE_DIR/$DATE-$FOLDER_NAME"
+
+ echo "Archiving previous run: $LAST_BRANCH"
+ mkdir -p "$ARCHIVE_FOLDER"
+ [ -f "$PRD_FILE" ] && cp "$PRD_FILE" "$ARCHIVE_FOLDER/"
+ [ -f "$PROGRESS_FILE" ] && cp "$PROGRESS_FILE" "$ARCHIVE_FOLDER/"
+ echo " Archived to: $ARCHIVE_FOLDER"
+
+ # Reset progress file for new run
+ echo "# Ralph Progress Log" > "$PROGRESS_FILE"
+ echo "Started: $(date)" >> "$PROGRESS_FILE"
+ echo "---" >> "$PROGRESS_FILE"
+ fi
+fi
+
+# Track current branch
+if [ -f "$PRD_FILE" ]; then
+ CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
+ if [ -n "$CURRENT_BRANCH" ]; then
+ echo "$CURRENT_BRANCH" > "$LAST_BRANCH_FILE"
+ fi
+fi
+
+# Initialize progress file if it doesn't exist
+if [ ! -f "$PROGRESS_FILE" ]; then
+ echo "# Ralph Progress Log" > "$PROGRESS_FILE"
+ echo "Started: $(date)" >> "$PROGRESS_FILE"
+ echo "---" >> "$PROGRESS_FILE"
+fi
+
+echo "Starting Ralph - Tool: $TOOL - Max iterations: $MAX_ITERATIONS"
+
+for i in $(seq 1 $MAX_ITERATIONS); do
+ echo ""
+ echo "==============================================================="
+ echo " Ralph Iteration $i of $MAX_ITERATIONS ($TOOL)"
+ echo "==============================================================="
+
+ # Run the selected tool with the ralph prompt
+ if [[ "$TOOL" == "amp" ]]; then
+ OUTPUT=$(cat "$SCRIPT_DIR/prompt.md" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true
+ else
+ # Claude Code: stream JSON events and display activity in real-time
+ STREAM_FILE=$(mktemp)
+ claude --dangerously-skip-permissions --print --output-format stream-json --verbose < "$SCRIPT_DIR/CLAUDE.md" 2>&1 \
+ | while IFS= read -r line; do
+ echo "$line" >> "$STREAM_FILE"
+ # Parse each JSON line and display activity
+ type=$(echo "$line" | jq -r '.type // empty' 2>/dev/null)
+ case "$type" in
+ assistant)
+ # Show tool calls as they happen
+ echo "$line" | jq -r '
+ .message.content[]? |
+ select(.type == "tool_use") |
+ " \u001b[36m⚡ " + .name +
+ (if .name == "Read" then " → " + (.input.file_path // "" | split("/") | last)
+ elif .name == "Edit" then " → " + (.input.file_path // "" | split("/") | last)
+ elif .name == "Write" then " → " + (.input.file_path // "" | split("/") | last)
+ elif .name == "Bash" then " → " + (.input.command // "" | .[0:80])
+ elif .name == "Grep" then " → " + (.input.pattern // "")
+ elif .name == "Glob" then " → " + (.input.pattern // "")
+ else ""
+ end) + "\u001b[0m"
+ ' 2>/dev/null >&2
+ ;;
+ result)
+ echo "$line" | jq -r '
+ "\u001b[33m✓ Turn complete (" + (.num_turns // 0 | tostring) + " turns, $" + ((.total_cost_usd // 0 * 100 | round / 100) | tostring) + ")\u001b[0m"
+ ' 2>/dev/null >&2
+ ;;
+ esac
+ done || true
+
+ # Extract final result text for completion check
+ OUTPUT=$(jq -r 'select(.type == "result") | .result // empty' "$STREAM_FILE" 2>/dev/null | tail -1)
+ rm -f "$STREAM_FILE"
+ fi
+
+ # Check for completion signal
+ if echo "$OUTPUT" | grep -q "COMPLETE"; then
+ echo ""
+ echo "Ralph completed all tasks!"
+ echo "Completed at iteration $i of $MAX_ITERATIONS"
+ exit 0
+ fi
+
+ echo "Iteration $i complete. Continuing..."
+ sleep 2
+done
+
+echo ""
+echo "Ralph reached max iterations ($MAX_ITERATIONS) without completing all tasks."
+echo "Check $PROGRESS_FILE for status."
+exit 1
diff --git a/src/Writegeist.Cli/Actions/AnalyseAction.cs b/src/Writegeist.Cli/Actions/AnalyseAction.cs
new file mode 100644
index 0000000..6c7d48d
--- /dev/null
+++ b/src/Writegeist.Cli/Actions/AnalyseAction.cs
@@ -0,0 +1,67 @@
+using InteractiveCLI.Actions;
+using Microsoft.Extensions.Configuration;
+using Spectre.Console;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Services;
+using Writegeist.Infrastructure.LlmProviders;
+
+namespace Writegeist.Cli.Actions;
+
+public class AnalyseAction(
+ IPersonRepository personRepository,
+ IPostRepository postRepository,
+ IStyleProfileRepository styleProfileRepository,
+ AnthropicProvider anthropicProvider,
+ OpenAiProvider openAiProvider,
+ IConfiguration configuration) : SingleActionAsync
+{
+ protected override async Task SingleAsyncAction()
+ {
+ try
+ {
+ var persons = await personRepository.GetAllAsync();
+ if (persons.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[red]No persons found. Ingest some posts first.[/]");
+ return;
+ }
+
+ var selectedPerson = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Select a person:")
+ .AddChoices(persons.Select(p => p.Name)));
+
+ var person = persons.First(p => p.Name == selectedPerson);
+
+ var defaultProvider = configuration["Writegeist:DefaultProvider"] ?? "anthropic";
+ var providerChoices = new[] { "Anthropic", "OpenAI" };
+ var defaultChoice = defaultProvider.Equals("openai", StringComparison.OrdinalIgnoreCase)
+ ? "OpenAI"
+ : "Anthropic";
+
+ var selectedProvider = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Select LLM provider:")
+ .AddChoices(providerChoices));
+
+ ILlmProvider llmProvider = selectedProvider == "OpenAI"
+ ? openAiProvider
+ : anthropicProvider;
+
+ var analyser = new StyleAnalyser(postRepository, styleProfileRepository, llmProvider);
+
+ var profile = await AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .StartAsync("Analysing style...", async _ =>
+ await analyser.AnalyseAsync(person.Id));
+
+ AnsiConsole.Write(new Panel(profile.ProfileJson)
+ .Header($"Style Profile for {person.Name}")
+ .Border(BoxBorder.Rounded));
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
+ }
+ }
+}
diff --git a/src/Writegeist.Cli/Actions/GenerateAction.cs b/src/Writegeist.Cli/Actions/GenerateAction.cs
new file mode 100644
index 0000000..83ac188
--- /dev/null
+++ b/src/Writegeist.Cli/Actions/GenerateAction.cs
@@ -0,0 +1,88 @@
+using InteractiveCLI.Actions;
+using Microsoft.Extensions.Configuration;
+using Spectre.Console;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+using Writegeist.Core.Services;
+using Writegeist.Infrastructure.LlmProviders;
+
+namespace Writegeist.Cli.Actions;
+
+public class GenerateAction(
+ IPersonRepository personRepository,
+ IStyleProfileRepository styleProfileRepository,
+ IDraftRepository draftRepository,
+ AnthropicProvider anthropicProvider,
+ OpenAiProvider openAiProvider,
+ IConfiguration configuration) : SingleActionAsync
+{
+ protected override async Task SingleAsyncAction()
+ {
+ try
+ {
+ var persons = await personRepository.GetAllAsync();
+ if (persons.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[red]No persons found. Ingest some posts first.[/]");
+ return;
+ }
+
+ // Filter to only persons with a style profile
+ var personsWithProfiles = new List();
+ foreach (var person in persons)
+ {
+ var profile = await styleProfileRepository.GetLatestByPersonIdAsync(person.Id);
+ if (profile is not null)
+ personsWithProfiles.Add(person);
+ }
+
+ if (personsWithProfiles.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[red]No persons with style profiles found. Run 'Analyse Style' first.[/]");
+ return;
+ }
+
+ var selectedPerson = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Select a person:")
+ .AddChoices(personsWithProfiles.Select(p => p.Name)));
+
+ var person1 = personsWithProfiles.First(p => p.Name == selectedPerson);
+
+ var selectedPlatform = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Select target platform:")
+ .AddChoices(Enum.GetValues()));
+
+ var topic = AnsiConsole.Prompt(
+ new TextPrompt("Enter topic/key points:"));
+
+ var defaultProvider = configuration["Writegeist:DefaultProvider"] ?? "anthropic";
+ var providerChoices = new[] { "Anthropic", "OpenAI" };
+
+ var selectedProvider = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Select LLM provider:")
+ .AddChoices(providerChoices));
+
+ ILlmProvider llmProvider = selectedProvider == "OpenAI"
+ ? openAiProvider
+ : anthropicProvider;
+
+ var generator = new PostGenerator(styleProfileRepository, draftRepository, llmProvider);
+
+ var draft = await AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .StartAsync("Generating post...", async _ =>
+ await generator.GenerateAsync(person1.Id, selectedPlatform, topic));
+
+ AnsiConsole.Write(new Panel(Markup.Escape(draft.Content))
+ .Header($"Generated {selectedPlatform} Post")
+ .Border(BoxBorder.Rounded));
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
+ }
+ }
+}
diff --git a/src/Writegeist.Cli/Actions/IngestFromFileAction.cs b/src/Writegeist.Cli/Actions/IngestFromFileAction.cs
new file mode 100644
index 0000000..7e5a32f
--- /dev/null
+++ b/src/Writegeist.Cli/Actions/IngestFromFileAction.cs
@@ -0,0 +1,68 @@
+using InteractiveCLI.Actions;
+using Spectre.Console;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+using Writegeist.Infrastructure.Fetchers;
+
+namespace Writegeist.Cli.Actions;
+
+public class IngestFromFileAction(
+ IPersonRepository personRepository,
+ IPostRepository postRepository) : SingleActionAsync
+{
+ protected override async Task SingleAsyncAction()
+ {
+ var personName = AnsiConsole.Prompt(
+ new TextPrompt("Person name:"));
+
+ var platform = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Platform:")
+ .AddChoices(Enum.GetValues()));
+
+ var filePath = AnsiConsole.Prompt(
+ new TextPrompt("File path:"));
+
+ if (!File.Exists(filePath))
+ {
+ AnsiConsole.MarkupLine("[red]File not found.[/]");
+ return;
+ }
+
+ var person = await personRepository.GetOrCreateAsync(personName);
+ var fetcher = new ManualFetcher(platform);
+ var request = new FetchRequest(FilePath: filePath);
+
+ try
+ {
+ var posts = await fetcher.FetchPostsAsync(request);
+ var newCount = 0;
+ var dupCount = 0;
+
+ foreach (var fetchedPost in posts)
+ {
+ var rawPost = new RawPost
+ {
+ PersonId = person.Id,
+ Platform = platform,
+ Content = fetchedPost.Content,
+ SourceUrl = fetchedPost.SourceUrl,
+ FetchedAt = DateTime.UtcNow
+ };
+
+ var isNew = await postRepository.AddAsync(rawPost);
+ if (isNew) newCount++;
+ else dupCount++;
+ }
+
+ AnsiConsole.Write(new Panel(
+ $"[green]Ingested {newCount} new post(s)[/] for [bold]{personName}[/] from {platform}" +
+ (dupCount > 0 ? $" ([yellow]{dupCount} duplicate(s) skipped[/])" : ""))
+ .Header("Ingest Complete"));
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
+ }
+ }
+}
diff --git a/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs b/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs
new file mode 100644
index 0000000..e6af102
--- /dev/null
+++ b/src/Writegeist.Cli/Actions/IngestFromUrlAction.cs
@@ -0,0 +1,73 @@
+using InteractiveCLI.Actions;
+using Spectre.Console;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Cli.Actions;
+
+public class IngestFromUrlAction(
+ IPersonRepository personRepository,
+ IPostRepository postRepository,
+ IEnumerable fetchers) : SingleActionAsync
+{
+ protected override async Task SingleAsyncAction()
+ {
+ var personName = AnsiConsole.Prompt(
+ new TextPrompt("Person name:"));
+
+ var platform = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Platform:")
+ .AddChoices(Enum.GetValues()));
+
+ var urlOrHandle = AnsiConsole.Prompt(
+ new TextPrompt("URL or handle:"));
+
+ var person = await personRepository.GetOrCreateAsync(personName);
+
+ var fetcher = fetchers.FirstOrDefault(f => f.Platform == platform);
+ if (fetcher is null)
+ {
+ AnsiConsole.MarkupLine($"[yellow]No fetcher available for {platform}.[/]");
+ return;
+ }
+
+ var request = new FetchRequest(Url: urlOrHandle, Handle: urlOrHandle);
+
+ try
+ {
+ var posts = await fetcher.FetchPostsAsync(request);
+ var newCount = 0;
+ var dupCount = 0;
+
+ foreach (var fetchedPost in posts)
+ {
+ var rawPost = new RawPost
+ {
+ PersonId = person.Id,
+ Platform = platform,
+ Content = fetchedPost.Content,
+ SourceUrl = fetchedPost.SourceUrl,
+ FetchedAt = DateTime.UtcNow
+ };
+
+ var isNew = await postRepository.AddAsync(rawPost);
+ if (isNew) newCount++;
+ else dupCount++;
+ }
+
+ AnsiConsole.Write(new Panel(
+ $"[green]Ingested {newCount} new post(s)[/] for [bold]{Markup.Escape(personName)}[/] from {platform}" +
+ (dupCount > 0 ? $" ([yellow]{dupCount} duplicate(s) skipped[/])" : ""))
+ .Header("Ingest Complete"));
+ }
+ catch (NotSupportedException ex)
+ {
+ AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(ex.Message)}[/]");
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
+ }
+ }
+}
diff --git a/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs b/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs
new file mode 100644
index 0000000..2e8b5b6
--- /dev/null
+++ b/src/Writegeist.Cli/Actions/IngestInteractiveAction.cs
@@ -0,0 +1,65 @@
+using InteractiveCLI.Actions;
+using Spectre.Console;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Cli.Actions;
+
+public class IngestInteractiveAction(
+ IPersonRepository personRepository,
+ IPostRepository postRepository) : RepeatableActionAsync
+{
+ private Person? _person;
+ private Platform _platform;
+ private int _newCount;
+ private int _dupCount;
+ private bool _isFirstIteration = true;
+
+ protected override async Task RepeatableAsyncAction()
+ {
+ if (_isFirstIteration)
+ {
+ var personName = AnsiConsole.Prompt(
+ new TextPrompt("Person name:"));
+
+ _platform = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Platform:")
+ .AddChoices(Enum.GetValues()));
+
+ _person = await personRepository.GetOrCreateAsync(personName);
+ _isFirstIteration = false;
+ }
+
+ var content = AnsiConsole.Prompt(
+ new TextPrompt("Paste post content:"));
+
+ var rawPost = new RawPost
+ {
+ PersonId = _person!.Id,
+ Platform = _platform,
+ Content = content,
+ FetchedAt = DateTime.UtcNow
+ };
+
+ var isNew = await postRepository.AddAsync(rawPost);
+ if (isNew) _newCount++;
+ else _dupCount++;
+
+ var addAnother = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Add another post?")
+ .AddChoices("Yes", "No"));
+
+ if (addAnother == "No")
+ {
+ AnsiConsole.Write(new Panel(
+ $"[green]Ingested {_newCount} new post(s)[/] for [bold]{_person.Name}[/] from {_platform}" +
+ (_dupCount > 0 ? $" ([yellow]{_dupCount} duplicate(s) skipped[/])" : ""))
+ .Header("Ingest Complete"));
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Writegeist.Cli/Actions/ListProfilesAction.cs b/src/Writegeist.Cli/Actions/ListProfilesAction.cs
new file mode 100644
index 0000000..e6da160
--- /dev/null
+++ b/src/Writegeist.Cli/Actions/ListProfilesAction.cs
@@ -0,0 +1,53 @@
+using InteractiveCLI.Actions;
+using Spectre.Console;
+using Writegeist.Core.Interfaces;
+
+namespace Writegeist.Cli.Actions;
+
+public class ListProfilesAction(
+ IPersonRepository personRepository,
+ IStyleProfileRepository styleProfileRepository) : SingleActionAsync
+{
+ protected override async Task SingleAsyncAction()
+ {
+ try
+ {
+ var persons = await personRepository.GetAllAsync();
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .Title("Style Profiles")
+ .AddColumn("Name")
+ .AddColumn("Provider")
+ .AddColumn("Model")
+ .AddColumn("Created");
+
+ var hasProfiles = false;
+
+ foreach (var person in persons)
+ {
+ var profile = await styleProfileRepository.GetLatestByPersonIdAsync(person.Id);
+ if (profile is not null)
+ {
+ hasProfiles = true;
+ table.AddRow(
+ Markup.Escape(person.Name),
+ Markup.Escape(profile.Provider),
+ Markup.Escape(profile.Model),
+ profile.CreatedAt.ToString("g"));
+ }
+ }
+
+ if (!hasProfiles)
+ {
+ AnsiConsole.MarkupLine("[yellow]No style profiles found. Run 'Analyse Style' first.[/]");
+ return;
+ }
+
+ AnsiConsole.Write(table);
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
+ }
+ }
+}
diff --git a/src/Writegeist.Cli/Actions/PlaceholderAction.cs b/src/Writegeist.Cli/Actions/PlaceholderAction.cs
new file mode 100644
index 0000000..b7a538d
--- /dev/null
+++ b/src/Writegeist.Cli/Actions/PlaceholderAction.cs
@@ -0,0 +1,13 @@
+using InteractiveCLI.Actions;
+using Spectre.Console;
+
+namespace Writegeist.Cli.Actions;
+
+public class PlaceholderAction : SingleActionAsync
+{
+ protected override Task SingleAsyncAction()
+ {
+ AnsiConsole.MarkupLine("[grey]No actions configured yet.[/]");
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Writegeist.Cli/Actions/RefineAction.cs b/src/Writegeist.Cli/Actions/RefineAction.cs
new file mode 100644
index 0000000..4ad1c9b
--- /dev/null
+++ b/src/Writegeist.Cli/Actions/RefineAction.cs
@@ -0,0 +1,75 @@
+using InteractiveCLI.Actions;
+using Spectre.Console;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+using Writegeist.Core.Services;
+using Writegeist.Infrastructure.LlmProviders;
+
+namespace Writegeist.Cli.Actions;
+
+public class RefineAction(
+ IDraftRepository draftRepository,
+ IStyleProfileRepository styleProfileRepository,
+ AnthropicProvider anthropicProvider,
+ OpenAiProvider openAiProvider) : RepeatableActionAsync
+{
+ private GeneratedDraft? _currentDraft;
+ private PostGenerator? _generator;
+ private bool _isFirstIteration = true;
+
+ protected override async Task RepeatableAsyncAction()
+ {
+ try
+ {
+ if (_isFirstIteration)
+ {
+ _currentDraft = await draftRepository.GetLatestAsync();
+ if (_currentDraft is null)
+ {
+ AnsiConsole.MarkupLine("[red]No drafts found. Generate a post first.[/]");
+ return true;
+ }
+
+ AnsiConsole.Write(new Panel(Markup.Escape(_currentDraft.Content))
+ .Header($"Current Draft ({_currentDraft.Platform})")
+ .Border(BoxBorder.Rounded));
+
+ var selectedProvider = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Select LLM provider:")
+ .AddChoices("Anthropic", "OpenAI"));
+
+ ILlmProvider llmProvider = selectedProvider == "OpenAI"
+ ? openAiProvider
+ : anthropicProvider;
+
+ _generator = new PostGenerator(styleProfileRepository, draftRepository, llmProvider);
+ _isFirstIteration = false;
+ }
+
+ var feedback = AnsiConsole.Prompt(
+ new TextPrompt("Enter feedback:"));
+
+ _currentDraft = await AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .StartAsync("Refining post...", async _ =>
+ await _generator!.RefineAsync(_currentDraft!.Id, feedback));
+
+ AnsiConsole.Write(new Panel(Markup.Escape(_currentDraft.Content))
+ .Header($"Refined Draft ({_currentDraft.Platform})")
+ .Border(BoxBorder.Rounded));
+
+ var refineAgain = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Refine again?")
+ .AddChoices("Yes", "No"));
+
+ return refineAgain == "No";
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
+ return true;
+ }
+ }
+}
diff --git a/src/Writegeist.Cli/Actions/ShowProfileAction.cs b/src/Writegeist.Cli/Actions/ShowProfileAction.cs
new file mode 100644
index 0000000..3dcbb93
--- /dev/null
+++ b/src/Writegeist.Cli/Actions/ShowProfileAction.cs
@@ -0,0 +1,84 @@
+using System.Text.Json;
+using InteractiveCLI.Actions;
+using Spectre.Console;
+using Writegeist.Core.Interfaces;
+
+namespace Writegeist.Cli.Actions;
+
+public class ShowProfileAction(
+ IPersonRepository personRepository,
+ IStyleProfileRepository styleProfileRepository) : SingleActionAsync
+{
+ protected override async Task SingleAsyncAction()
+ {
+ try
+ {
+ var persons = await personRepository.GetAllAsync();
+ if (persons.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No persons found. Ingest some posts first.[/]");
+ return;
+ }
+
+ // Filter to persons with profiles
+ var personsWithProfiles = new List<(string Name, int Id)>();
+ foreach (var person in persons)
+ {
+ var profile = await styleProfileRepository.GetLatestByPersonIdAsync(person.Id);
+ if (profile is not null)
+ personsWithProfiles.Add((person.Name, person.Id));
+ }
+
+ if (personsWithProfiles.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No style profiles found. Run 'Analyse Style' first.[/]");
+ return;
+ }
+
+ var selectedName = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Select a person:")
+ .AddChoices(personsWithProfiles.Select(p => p.Name)));
+
+ var personId = personsWithProfiles.First(p => p.Name == selectedName).Id;
+ var latestProfile = (await styleProfileRepository.GetLatestByPersonIdAsync(personId))!;
+
+ // Try to parse and display as structured table
+ try
+ {
+ var json = JsonDocument.Parse(latestProfile.ProfileJson);
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .Title($"Style Profile for {selectedName}")
+ .AddColumn("Section")
+ .AddColumn("Details");
+
+ foreach (var property in json.RootElement.EnumerateObject())
+ {
+ var value = property.Value.ValueKind == JsonValueKind.String
+ ? property.Value.GetString() ?? ""
+ : property.Value.GetRawText();
+
+ table.AddRow(
+ Markup.Escape(property.Name),
+ Markup.Escape(value));
+ }
+
+ AnsiConsole.Write(table);
+ }
+ catch (JsonException)
+ {
+ // Not valid JSON — display as raw text in a panel
+ AnsiConsole.Write(new Panel(Markup.Escape(latestProfile.ProfileJson))
+ .Header($"Style Profile for {selectedName}")
+ .Border(BoxBorder.Rounded));
+ }
+
+ AnsiConsole.MarkupLine($"[dim]Provider: {Markup.Escape(latestProfile.Provider)} | Model: {Markup.Escape(latestProfile.Model)} | Created: {latestProfile.CreatedAt:g}[/]");
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"[red]Error: {Markup.Escape(ex.Message)}[/]");
+ }
+ }
+}
diff --git a/src/Writegeist.Cli/Menus/IngestMenu.cs b/src/Writegeist.Cli/Menus/IngestMenu.cs
new file mode 100644
index 0000000..07304c7
--- /dev/null
+++ b/src/Writegeist.Cli/Menus/IngestMenu.cs
@@ -0,0 +1,15 @@
+using InteractiveCLI.Menus;
+using Writegeist.Cli.Actions;
+
+namespace Writegeist.Cli.Menus;
+
+public class IngestMenu() : Menu(quitable: false, isTopLevel: false)
+{
+ protected override void BuildMenu()
+ {
+ MenuBuilder
+ .AddMenuItem("From File", "Import posts from a text file (separated by ---)")
+ .AddMenuItem("Interactive Paste", "Paste posts one at a time")
+ .AddMenuItem("From URL / Handle", "Fetch posts from a social media URL or handle");
+ }
+}
diff --git a/src/Writegeist.Cli/Menus/MainMenu.cs b/src/Writegeist.Cli/Menus/MainMenu.cs
new file mode 100644
index 0000000..962e26e
--- /dev/null
+++ b/src/Writegeist.Cli/Menus/MainMenu.cs
@@ -0,0 +1,17 @@
+using InteractiveCLI.Menus;
+using Writegeist.Cli.Actions;
+
+namespace Writegeist.Cli.Menus;
+
+public class MainMenu() : Menu(quitable: true, isTopLevel: true)
+{
+ protected override void BuildMenu()
+ {
+ MenuBuilder
+ .AddMenuItem("Ingest Posts", "Import posts from files, URLs, or paste interactively")
+ .AddMenuItem("Analyse Style", "Analyse posts and build a style profile")
+ .AddMenuItem("Generate Post", "Generate a new post in your style")
+ .AddMenuItem("Refine Last Draft", "Iteratively refine the last generated draft with feedback")
+ .AddMenuItem("Profiles", "View and list style profiles");
+ }
+}
diff --git a/src/Writegeist.Cli/Menus/ProfileMenu.cs b/src/Writegeist.Cli/Menus/ProfileMenu.cs
new file mode 100644
index 0000000..43b5495
--- /dev/null
+++ b/src/Writegeist.Cli/Menus/ProfileMenu.cs
@@ -0,0 +1,14 @@
+using InteractiveCLI.Menus;
+using Writegeist.Cli.Actions;
+
+namespace Writegeist.Cli.Menus;
+
+public class ProfileMenu() : Menu(quitable: false, isTopLevel: false)
+{
+ protected override void BuildMenu()
+ {
+ MenuBuilder
+ .AddMenuItem("Show Profile", "View the full style profile for a person")
+ .AddMenuItem("List All Profiles", "List all persons with style profiles");
+ }
+}
diff --git a/src/Writegeist.Cli/Program.cs b/src/Writegeist.Cli/Program.cs
new file mode 100644
index 0000000..8ee7c53
--- /dev/null
+++ b/src/Writegeist.Cli/Program.cs
@@ -0,0 +1,63 @@
+using InteractiveCLI;
+using InteractiveCLI.Options;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Writegeist.Cli.Menus;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Services;
+using Writegeist.Infrastructure.Fetchers;
+using Writegeist.Infrastructure.LlmProviders;
+using Writegeist.Infrastructure.Persistence;
+
+var configuration = new ConfigurationBuilder()
+ .AddJsonFile("appsettings.json", optional: true)
+ .AddEnvironmentVariables()
+ .Build();
+
+var host = Host.CreateDefaultBuilder(args)
+ .AddInteractiveCli(configuration, services =>
+ {
+ // Database
+ services.AddSingleton();
+
+ // Repositories
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // LLM providers
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(sp =>
+ {
+ var config = sp.GetRequiredService();
+ var defaultProvider = config["Writegeist:DefaultProvider"] ?? "anthropic";
+ return defaultProvider.ToLowerInvariant() switch
+ {
+ "openai" => sp.GetRequiredService(),
+ _ => sp.GetRequiredService()
+ };
+ });
+
+ // Fetchers
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Services
+ services.AddSingleton();
+ services.AddSingleton();
+ })
+ .Build();
+
+// Ensure database is created
+var db = InteractiveCliBootstrapper.ServiceProvider.GetRequiredService();
+db.EnsureCreated();
+
+host.UseInteractiveCli((EmptyOptions _) => new MainMenu(), args);
+
+await host.RunAsync();
diff --git a/src/Writegeist.Cli/Writegeist.Cli.csproj b/src/Writegeist.Cli/Writegeist.Cli.csproj
new file mode 100644
index 0000000..a4377d3
--- /dev/null
+++ b/src/Writegeist.Cli/Writegeist.Cli.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/src/Writegeist.Cli/appsettings.json b/src/Writegeist.Cli/appsettings.json
new file mode 100644
index 0000000..49a3d39
--- /dev/null
+++ b/src/Writegeist.Cli/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Writegeist": {
+ "DefaultProvider": "anthropic",
+ "DatabasePath": "writegeist.db",
+ "Anthropic": {
+ "Model": "claude-sonnet-4-20250514"
+ },
+ "OpenAi": {
+ "Model": "gpt-4o"
+ }
+ }
+}
diff --git a/src/Writegeist.Core/Interfaces/IContentFetcher.cs b/src/Writegeist.Core/Interfaces/IContentFetcher.cs
new file mode 100644
index 0000000..435662a
--- /dev/null
+++ b/src/Writegeist.Core/Interfaces/IContentFetcher.cs
@@ -0,0 +1,9 @@
+using Writegeist.Core.Models;
+
+namespace Writegeist.Core.Interfaces;
+
+public interface IContentFetcher
+{
+ Platform Platform { get; }
+ Task> FetchPostsAsync(FetchRequest request);
+}
diff --git a/src/Writegeist.Core/Interfaces/IDraftRepository.cs b/src/Writegeist.Core/Interfaces/IDraftRepository.cs
new file mode 100644
index 0000000..636ae1a
--- /dev/null
+++ b/src/Writegeist.Core/Interfaces/IDraftRepository.cs
@@ -0,0 +1,10 @@
+using Writegeist.Core.Models;
+
+namespace Writegeist.Core.Interfaces;
+
+public interface IDraftRepository
+{
+ Task SaveAsync(GeneratedDraft draft);
+ Task GetLatestAsync();
+ Task GetByIdAsync(int id);
+}
diff --git a/src/Writegeist.Core/Interfaces/ILlmProvider.cs b/src/Writegeist.Core/Interfaces/ILlmProvider.cs
new file mode 100644
index 0000000..5d97c66
--- /dev/null
+++ b/src/Writegeist.Core/Interfaces/ILlmProvider.cs
@@ -0,0 +1,10 @@
+namespace Writegeist.Core.Interfaces;
+
+public interface ILlmProvider
+{
+ string ProviderName { get; }
+ string ModelName { get; }
+ Task AnalyseStyleAsync(string prompt);
+ Task GeneratePostAsync(string prompt);
+ Task RefinePostAsync(string prompt);
+}
diff --git a/src/Writegeist.Core/Interfaces/IPersonRepository.cs b/src/Writegeist.Core/Interfaces/IPersonRepository.cs
new file mode 100644
index 0000000..8d615bc
--- /dev/null
+++ b/src/Writegeist.Core/Interfaces/IPersonRepository.cs
@@ -0,0 +1,11 @@
+using Writegeist.Core.Models;
+
+namespace Writegeist.Core.Interfaces;
+
+public interface IPersonRepository
+{
+ Task CreateAsync(string name);
+ Task GetByNameAsync(string name);
+ Task> GetAllAsync();
+ Task GetOrCreateAsync(string name);
+}
diff --git a/src/Writegeist.Core/Interfaces/IPostRepository.cs b/src/Writegeist.Core/Interfaces/IPostRepository.cs
new file mode 100644
index 0000000..5f38a63
--- /dev/null
+++ b/src/Writegeist.Core/Interfaces/IPostRepository.cs
@@ -0,0 +1,11 @@
+using Writegeist.Core.Models;
+
+namespace Writegeist.Core.Interfaces;
+
+public interface IPostRepository
+{
+ Task AddAsync(RawPost post);
+ Task> GetByPersonIdAsync(int personId);
+ Task GetCountByPersonIdAsync(int personId);
+ Task ExistsByHashAsync(int personId, string contentHash);
+}
diff --git a/src/Writegeist.Core/Interfaces/IStyleProfileRepository.cs b/src/Writegeist.Core/Interfaces/IStyleProfileRepository.cs
new file mode 100644
index 0000000..9324d4e
--- /dev/null
+++ b/src/Writegeist.Core/Interfaces/IStyleProfileRepository.cs
@@ -0,0 +1,10 @@
+using Writegeist.Core.Models;
+
+namespace Writegeist.Core.Interfaces;
+
+public interface IStyleProfileRepository
+{
+ Task SaveAsync(StyleProfile profile);
+ Task GetLatestByPersonIdAsync(int personId);
+ Task> GetAllByPersonIdAsync(int personId);
+}
diff --git a/src/Writegeist.Core/Models/FetchRequest.cs b/src/Writegeist.Core/Models/FetchRequest.cs
new file mode 100644
index 0000000..2a14a69
--- /dev/null
+++ b/src/Writegeist.Core/Models/FetchRequest.cs
@@ -0,0 +1,3 @@
+namespace Writegeist.Core.Models;
+
+public record FetchRequest(string? Url = null, string? Handle = null, string? FilePath = null);
diff --git a/src/Writegeist.Core/Models/FetchedPost.cs b/src/Writegeist.Core/Models/FetchedPost.cs
new file mode 100644
index 0000000..134b7f6
--- /dev/null
+++ b/src/Writegeist.Core/Models/FetchedPost.cs
@@ -0,0 +1,3 @@
+namespace Writegeist.Core.Models;
+
+public record FetchedPost(string Content, string? SourceUrl = null, DateTime? PublishedAt = null);
diff --git a/src/Writegeist.Core/Models/GeneratedDraft.cs b/src/Writegeist.Core/Models/GeneratedDraft.cs
new file mode 100644
index 0000000..fe1b8d7
--- /dev/null
+++ b/src/Writegeist.Core/Models/GeneratedDraft.cs
@@ -0,0 +1,16 @@
+namespace Writegeist.Core.Models;
+
+public class GeneratedDraft
+{
+ public int Id { get; set; }
+ public int PersonId { get; set; }
+ public int StyleProfileId { get; set; }
+ public Platform Platform { get; set; }
+ public string Topic { get; set; } = string.Empty;
+ public string Content { get; set; } = string.Empty;
+ public int? ParentDraftId { get; set; }
+ public string? Feedback { get; set; }
+ public string Provider { get; set; } = string.Empty;
+ public string Model { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/src/Writegeist.Core/Models/Person.cs b/src/Writegeist.Core/Models/Person.cs
new file mode 100644
index 0000000..42b7138
--- /dev/null
+++ b/src/Writegeist.Core/Models/Person.cs
@@ -0,0 +1,8 @@
+namespace Writegeist.Core.Models;
+
+public class Person
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/src/Writegeist.Core/Models/Platform.cs b/src/Writegeist.Core/Models/Platform.cs
new file mode 100644
index 0000000..0637f23
--- /dev/null
+++ b/src/Writegeist.Core/Models/Platform.cs
@@ -0,0 +1,9 @@
+namespace Writegeist.Core.Models;
+
+public enum Platform
+{
+ LinkedIn,
+ X,
+ Instagram,
+ Facebook
+}
diff --git a/src/Writegeist.Core/Models/PlatformRules.cs b/src/Writegeist.Core/Models/PlatformRules.cs
new file mode 100644
index 0000000..6b14677
--- /dev/null
+++ b/src/Writegeist.Core/Models/PlatformRules.cs
@@ -0,0 +1,13 @@
+namespace Writegeist.Core.Models;
+
+public record PlatformRules(
+ string Name,
+ int? MaxCharacters,
+ int RecommendedMaxLength,
+ bool SupportsHashtags,
+ int RecommendedHashtagCount,
+ string HashtagPlacement,
+ bool SupportsEmoji,
+ string EmojiGuidance,
+ string ToneGuidance,
+ string FormattingNotes);
diff --git a/src/Writegeist.Core/Models/RawPost.cs b/src/Writegeist.Core/Models/RawPost.cs
new file mode 100644
index 0000000..cba7280
--- /dev/null
+++ b/src/Writegeist.Core/Models/RawPost.cs
@@ -0,0 +1,12 @@
+namespace Writegeist.Core.Models;
+
+public class RawPost
+{
+ public int Id { get; set; }
+ public int PersonId { get; set; }
+ public Platform Platform { get; set; }
+ public string Content { get; set; } = string.Empty;
+ public string ContentHash { get; set; } = string.Empty;
+ public string? SourceUrl { get; set; }
+ public DateTime FetchedAt { get; set; }
+}
diff --git a/src/Writegeist.Core/Models/StyleProfile.cs b/src/Writegeist.Core/Models/StyleProfile.cs
new file mode 100644
index 0000000..8aabb58
--- /dev/null
+++ b/src/Writegeist.Core/Models/StyleProfile.cs
@@ -0,0 +1,11 @@
+namespace Writegeist.Core.Models;
+
+public class StyleProfile
+{
+ public int Id { get; set; }
+ public int PersonId { get; set; }
+ public string ProfileJson { get; set; } = string.Empty;
+ public string Provider { get; set; } = string.Empty;
+ public string Model { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/src/Writegeist.Core/PlatformConventions.cs b/src/Writegeist.Core/PlatformConventions.cs
new file mode 100644
index 0000000..c7411c5
--- /dev/null
+++ b/src/Writegeist.Core/PlatformConventions.cs
@@ -0,0 +1,59 @@
+using Writegeist.Core.Models;
+
+namespace Writegeist.Core;
+
+public static class PlatformConventions
+{
+ public static PlatformRules GetRules(Platform platform) => platform switch
+ {
+ Platform.LinkedIn => new PlatformRules(
+ Name: "LinkedIn",
+ MaxCharacters: 3000,
+ RecommendedMaxLength: 1500,
+ SupportsHashtags: true,
+ RecommendedHashtagCount: 4,
+ HashtagPlacement: "end",
+ SupportsEmoji: true,
+ EmojiGuidance: "Use sparingly for professional tone",
+ ToneGuidance: "Professional, insightful, conversational",
+ FormattingNotes: "Line breaks for readability; short paragraphs; hook in first line"),
+
+ Platform.X => new PlatformRules(
+ Name: "X",
+ MaxCharacters: 280,
+ RecommendedMaxLength: 280,
+ SupportsHashtags: true,
+ RecommendedHashtagCount: 2,
+ HashtagPlacement: "inline",
+ SupportsEmoji: true,
+ EmojiGuidance: "Moderate use to add personality",
+ ToneGuidance: "Concise, punchy, opinionated",
+ FormattingNotes: "Single tweet; no threading assumed"),
+
+ Platform.Instagram => new PlatformRules(
+ Name: "Instagram",
+ MaxCharacters: 2200,
+ RecommendedMaxLength: 750,
+ SupportsHashtags: true,
+ RecommendedHashtagCount: 30,
+ HashtagPlacement: "end in separate block",
+ SupportsEmoji: true,
+ EmojiGuidance: "Use freely to match visual culture",
+ ToneGuidance: "Casual, authentic, visually descriptive",
+ FormattingNotes: "Caption format; line breaks between sections; hashtag block at end"),
+
+ Platform.Facebook => new PlatformRules(
+ Name: "Facebook",
+ MaxCharacters: 63206,
+ RecommendedMaxLength: 600,
+ SupportsHashtags: true,
+ RecommendedHashtagCount: 2,
+ HashtagPlacement: "end",
+ SupportsEmoji: true,
+ EmojiGuidance: "Moderate use for engagement",
+ ToneGuidance: "Conversational, personal, community-oriented",
+ FormattingNotes: "Short paragraphs; question at end to drive engagement"),
+
+ _ => throw new ArgumentOutOfRangeException(nameof(platform), platform, "Unsupported platform")
+ };
+}
diff --git a/src/Writegeist.Core/Services/PostGenerator.cs b/src/Writegeist.Core/Services/PostGenerator.cs
new file mode 100644
index 0000000..aeb7170
--- /dev/null
+++ b/src/Writegeist.Core/Services/PostGenerator.cs
@@ -0,0 +1,132 @@
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Core.Services;
+
+public class PostGenerator
+{
+ private readonly IStyleProfileRepository _styleProfileRepository;
+ private readonly IDraftRepository _draftRepository;
+ private readonly ILlmProvider _llmProvider;
+
+ public PostGenerator(
+ IStyleProfileRepository styleProfileRepository,
+ IDraftRepository draftRepository,
+ ILlmProvider llmProvider)
+ {
+ _styleProfileRepository = styleProfileRepository;
+ _draftRepository = draftRepository;
+ _llmProvider = llmProvider;
+ }
+
+ public async Task GenerateAsync(int personId, Platform platform, string topic)
+ {
+ var profile = await _styleProfileRepository.GetLatestByPersonIdAsync(personId)
+ ?? throw new InvalidOperationException(
+ "No style profile found for this person. Run 'Analyse Style' first.");
+
+ var rules = PlatformConventions.GetRules(platform);
+ var prompt = BuildGenerationPrompt(profile.ProfileJson, rules, topic);
+ var content = await _llmProvider.GeneratePostAsync(prompt);
+
+ var draft = new GeneratedDraft
+ {
+ PersonId = personId,
+ StyleProfileId = profile.Id,
+ Platform = platform,
+ Topic = topic,
+ Content = content,
+ Provider = _llmProvider.ProviderName,
+ Model = _llmProvider.ModelName,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ return await _draftRepository.SaveAsync(draft);
+ }
+
+ public async Task RefineAsync(int draftId, string feedback)
+ {
+ var previousDraft = await _draftRepository.GetByIdAsync(draftId)
+ ?? throw new InvalidOperationException("Draft not found.");
+
+ var profile = await _styleProfileRepository.GetLatestByPersonIdAsync(previousDraft.PersonId)
+ ?? throw new InvalidOperationException("Style profile not found.");
+
+ var rules = PlatformConventions.GetRules(previousDraft.Platform);
+ var prompt = BuildRefinementPrompt(profile.ProfileJson, rules, previousDraft.Content, feedback);
+ var content = await _llmProvider.RefinePostAsync(prompt);
+
+ var refined = new GeneratedDraft
+ {
+ PersonId = previousDraft.PersonId,
+ StyleProfileId = profile.Id,
+ Platform = previousDraft.Platform,
+ Topic = previousDraft.Topic,
+ Content = content,
+ ParentDraftId = draftId,
+ Feedback = feedback,
+ Provider = _llmProvider.ProviderName,
+ Model = _llmProvider.ModelName,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ return await _draftRepository.SaveAsync(refined);
+ }
+
+ internal static string BuildGenerationPrompt(string styleProfileJson, PlatformRules rules, string topic)
+ {
+ return $"""
+ You are a ghostwriter. Write a {rules.Name} post in the exact writing style described by the style profile below. The post should be about the topic/points provided.
+
+ ## Style Profile:
+ {styleProfileJson}
+
+ ## Platform Conventions:
+ - Platform: {rules.Name}
+ - Character limit: {rules.MaxCharacters?.ToString() ?? "none"}
+ - Recommended length: {rules.RecommendedMaxLength} characters
+ - Hashtags: {rules.RecommendedHashtagCount} hashtags, placement: {rules.HashtagPlacement}
+ - Emoji: {rules.EmojiGuidance}
+ - Formatting: {rules.FormattingNotes}
+
+ ## Topic / Key Points:
+ {topic}
+
+ ## Instructions:
+ - Match the voice, tone, vocabulary, and structural patterns from the style profile exactly
+ - Follow the platform conventions for formatting, length, and hashtag/emoji usage
+ - Do NOT add disclaimers, meta-commentary, or explain what you're doing
+ - Output ONLY the post text, ready to copy and paste
+ """;
+ }
+
+ internal static string BuildRefinementPrompt(
+ string styleProfileJson, PlatformRules rules, string currentDraft, string feedback)
+ {
+ return $"""
+ You are a ghostwriter refining a draft. Adjust the post below according to the feedback while maintaining the original writing style.
+
+ ## Style Profile:
+ {styleProfileJson}
+
+ ## Platform: {rules.Name}
+ ## Platform Conventions:
+ - Character limit: {rules.MaxCharacters?.ToString() ?? "none"}
+ - Recommended length: {rules.RecommendedMaxLength} characters
+ - Hashtags: {rules.RecommendedHashtagCount} hashtags, placement: {rules.HashtagPlacement}
+ - Emoji: {rules.EmojiGuidance}
+ - Formatting: {rules.FormattingNotes}
+
+ ## Current Draft:
+ {currentDraft}
+
+ ## Feedback:
+ {feedback}
+
+ ## Instructions:
+ - Apply the feedback while staying true to the style profile
+ - Maintain platform conventions
+ - Output ONLY the revised post text
+ """;
+ }
+}
diff --git a/src/Writegeist.Core/Services/StyleAnalyser.cs b/src/Writegeist.Core/Services/StyleAnalyser.cs
new file mode 100644
index 0000000..59b2aaa
--- /dev/null
+++ b/src/Writegeist.Core/Services/StyleAnalyser.cs
@@ -0,0 +1,112 @@
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Core.Services;
+
+public class StyleAnalyser
+{
+ private readonly IPostRepository _postRepository;
+ private readonly IStyleProfileRepository _styleProfileRepository;
+ private readonly ILlmProvider _llmProvider;
+
+ public StyleAnalyser(
+ IPostRepository postRepository,
+ IStyleProfileRepository styleProfileRepository,
+ ILlmProvider llmProvider)
+ {
+ _postRepository = postRepository;
+ _styleProfileRepository = styleProfileRepository;
+ _llmProvider = llmProvider;
+ }
+
+ public async Task AnalyseAsync(int personId)
+ {
+ var posts = await _postRepository.GetByPersonIdAsync(personId);
+
+ if (posts.Count == 0)
+ throw new InvalidOperationException(
+ "No posts found for this person. Ingest some posts before analysing.");
+
+ var prompt = BuildStyleAnalysisPrompt(posts);
+ var profileJson = await _llmProvider.AnalyseStyleAsync(prompt);
+
+ var profile = new StyleProfile
+ {
+ PersonId = personId,
+ ProfileJson = profileJson,
+ Provider = _llmProvider.ProviderName,
+ Model = _llmProvider.ModelName,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ return await _styleProfileRepository.SaveAsync(profile);
+ }
+
+ internal static string BuildStyleAnalysisPrompt(IReadOnlyList posts)
+ {
+ var postsBlock = string.Join("\n\n---\n\n", posts.Select(p => p.Content));
+
+ return """
+ You are a writing style analyst. Analyse the following social media posts by the same author and produce a detailed, structured style profile in JSON format.
+
+ ## Posts to analyse:
+
+ """ + postsBlock + """
+
+
+ ## Output format (JSON):
+
+ {
+ "vocabulary": {
+ "complexity_level": "simple | moderate | advanced",
+ "jargon_domains": ["tech", "marketing", ...],
+ "favourite_words": ["word1", "word2", ...],
+ "words_to_avoid": ["word1", ...],
+ "filler_phrases": ["to be honest", "at the end of the day", ...]
+ },
+ "sentence_structure": {
+ "average_length": "short | medium | long",
+ "variety": "low | moderate | high",
+ "fragment_usage": true/false,
+ "question_usage": "none | rare | frequent",
+ "exclamation_usage": "none | rare | frequent"
+ },
+ "paragraph_structure": {
+ "average_length_sentences": 1-5,
+ "uses_single_sentence_paragraphs": true/false,
+ "uses_line_breaks_for_emphasis": true/false
+ },
+ "tone": {
+ "formality": "casual | conversational | professional | formal",
+ "warmth": "cold | neutral | warm | enthusiastic",
+ "humour": "none | dry | playful | frequent",
+ "confidence": "hedging | balanced | assertive | provocative"
+ },
+ "rhetorical_patterns": {
+ "typical_opening": "description of how they start posts",
+ "typical_closing": "description of how they end posts",
+ "storytelling_tendency": "none | sometimes | often",
+ "uses_lists": true/false,
+ "uses_rhetorical_questions": true/false,
+ "call_to_action_style": "none | soft | direct"
+ },
+ "formatting": {
+ "emoji_usage": "none | rare | moderate | heavy",
+ "emoji_types": ["🚀", "💡", ...],
+ "hashtag_style": "none | minimal | moderate | heavy",
+ "capitalisation_quirks": "none | ALL CAPS for emphasis | Title Case headers",
+ "punctuation_quirks": "description of any unusual punctuation habits"
+ },
+ "content_patterns": {
+ "typical_topics": ["topic1", "topic2"],
+ "perspective": "first_person | third_person | mixed",
+ "self_reference_style": "I | we | the team | name",
+ "audience_address": "none | you | we | community"
+ },
+ "overall_voice_summary": "A 2-3 sentence summary of this person's writing voice that captures their essence."
+ }
+
+ Respond with only the JSON object, no other text.
+ """;
+ }
+}
diff --git a/src/Writegeist.Core/Writegeist.Core.csproj b/src/Writegeist.Core/Writegeist.Core.csproj
new file mode 100644
index 0000000..7f76689
--- /dev/null
+++ b/src/Writegeist.Core/Writegeist.Core.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/src/Writegeist.Infrastructure/Fetchers/FacebookFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/FacebookFetcher.cs
new file mode 100644
index 0000000..4ede532
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Fetchers/FacebookFetcher.cs
@@ -0,0 +1,16 @@
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Infrastructure.Fetchers;
+
+public class FacebookFetcher : IContentFetcher
+{
+ public Platform Platform => Platform.Facebook;
+
+ public Task> FetchPostsAsync(FetchRequest request)
+ {
+ throw new NotSupportedException(
+ "Automated fetching is not available for Facebook. " +
+ "Please use 'From File' or 'Interactive Paste' to import your Facebook posts manually.");
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Fetchers/InstagramFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/InstagramFetcher.cs
new file mode 100644
index 0000000..91f5b4f
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Fetchers/InstagramFetcher.cs
@@ -0,0 +1,16 @@
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Infrastructure.Fetchers;
+
+public class InstagramFetcher : IContentFetcher
+{
+ public Platform Platform => Platform.Instagram;
+
+ public Task> FetchPostsAsync(FetchRequest request)
+ {
+ throw new NotSupportedException(
+ "Automated fetching is not available for Instagram. " +
+ "Please use 'From File' or 'Interactive Paste' to import your Instagram posts manually.");
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Fetchers/LinkedInFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/LinkedInFetcher.cs
new file mode 100644
index 0000000..37a7e42
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Fetchers/LinkedInFetcher.cs
@@ -0,0 +1,16 @@
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Infrastructure.Fetchers;
+
+public class LinkedInFetcher : IContentFetcher
+{
+ public Platform Platform => Platform.LinkedIn;
+
+ public Task> FetchPostsAsync(FetchRequest request)
+ {
+ throw new NotSupportedException(
+ "Automated fetching is not available for LinkedIn. " +
+ "Please use 'From File' or 'Interactive Paste' to import your LinkedIn posts manually.");
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Fetchers/ManualFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/ManualFetcher.cs
new file mode 100644
index 0000000..35dbaa7
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Fetchers/ManualFetcher.cs
@@ -0,0 +1,30 @@
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Infrastructure.Fetchers;
+
+public class ManualFetcher : IContentFetcher
+{
+ public Platform Platform { get; }
+
+ public ManualFetcher(Platform platform)
+ {
+ Platform = platform;
+ }
+
+ public async Task> FetchPostsAsync(FetchRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.FilePath))
+ throw new ArgumentException("FilePath is required for file import.", nameof(request));
+
+ var content = await File.ReadAllTextAsync(request.FilePath);
+ var posts = content
+ .Split(["---"], StringSplitOptions.None)
+ .Select(p => p.Trim())
+ .Where(p => !string.IsNullOrEmpty(p))
+ .Select(p => new FetchedPost(p))
+ .ToList();
+
+ return posts;
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Fetchers/XTwitterFetcher.cs b/src/Writegeist.Infrastructure/Fetchers/XTwitterFetcher.cs
new file mode 100644
index 0000000..ab8b8ef
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Fetchers/XTwitterFetcher.cs
@@ -0,0 +1,88 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using Microsoft.Extensions.Configuration;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Infrastructure.Fetchers;
+
+public class XTwitterFetcher(HttpClient httpClient, IConfiguration configuration) : IContentFetcher
+{
+ private const string BaseUrl = "https://api.x.com/2";
+ private const int MaxResults = 100;
+
+ public Platform Platform => Platform.X;
+
+ public async Task> FetchPostsAsync(FetchRequest request)
+ {
+ var bearerToken = configuration["X_BEARER_TOKEN"]
+ ?? throw new InvalidOperationException(
+ "X/Twitter Bearer Token is not configured. " +
+ "Set the X_BEARER_TOKEN environment variable, or use 'From File' or 'Interactive Paste' to import posts manually.");
+
+ var handle = (request.Handle ?? request.Url)?.TrimStart('@')
+ ?? throw new ArgumentException("A handle or URL is required to fetch tweets.", nameof(request));
+
+ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
+
+ var userId = await ResolveUserIdAsync(handle);
+ return await FetchTweetsAsync(userId);
+ }
+
+ private async Task ResolveUserIdAsync(string handle)
+ {
+ var response = await httpClient.GetAsync($"{BaseUrl}/users/by/username/{handle}");
+
+ if ((int)response.StatusCode == 429)
+ throw new HttpRequestException(
+ "X API rate limit exceeded. Please wait a few minutes and try again.");
+
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(json);
+
+ if (doc.RootElement.TryGetProperty("errors", out var errors))
+ {
+ var message = errors[0].GetProperty("detail").GetString();
+ throw new HttpRequestException($"X API error: {message}");
+ }
+
+ return doc.RootElement.GetProperty("data").GetProperty("id").GetString()
+ ?? throw new HttpRequestException("Could not resolve X user ID.");
+ }
+
+ private async Task> FetchTweetsAsync(string userId)
+ {
+ var url = $"{BaseUrl}/users/{userId}/tweets?max_results={MaxResults}&tweet.fields=created_at";
+ var response = await httpClient.GetAsync(url);
+
+ if ((int)response.StatusCode == 429)
+ throw new HttpRequestException(
+ "X API rate limit exceeded. Please wait a few minutes and try again.");
+
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(json);
+
+ if (!doc.RootElement.TryGetProperty("data", out var data))
+ return [];
+
+ var posts = new List();
+ foreach (var tweet in data.EnumerateArray())
+ {
+ var content = tweet.GetProperty("text").GetString() ?? string.Empty;
+ var tweetId = tweet.GetProperty("id").GetString();
+ var sourceUrl = tweetId is not null ? $"https://x.com/i/status/{tweetId}" : null;
+
+ DateTime? publishedAt = null;
+ if (tweet.TryGetProperty("created_at", out var createdAt))
+ publishedAt = DateTime.Parse(createdAt.GetString()!);
+
+ posts.Add(new FetchedPost(content, sourceUrl, publishedAt));
+ }
+
+ return posts;
+ }
+}
diff --git a/src/Writegeist.Infrastructure/LlmProviders/AnthropicProvider.cs b/src/Writegeist.Infrastructure/LlmProviders/AnthropicProvider.cs
new file mode 100644
index 0000000..57f4af4
--- /dev/null
+++ b/src/Writegeist.Infrastructure/LlmProviders/AnthropicProvider.cs
@@ -0,0 +1,82 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Configuration;
+using Writegeist.Core.Interfaces;
+
+namespace Writegeist.Infrastructure.LlmProviders;
+
+public class AnthropicProvider : ILlmProvider
+{
+ private const string ApiBaseUrl = "https://api.anthropic.com/v1/messages";
+ private const string DefaultModel = "claude-sonnet-4-20250514";
+ private const string ApiVersion = "2023-06-01";
+
+ private readonly HttpClient _httpClient;
+ private readonly string _apiKey;
+ private readonly string _model;
+
+ public AnthropicProvider(HttpClient httpClient, IConfiguration configuration)
+ {
+ _httpClient = httpClient;
+ _apiKey = configuration["ANTHROPIC_API_KEY"]
+ ?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
+ ?? throw new InvalidOperationException(
+ "Anthropic API key is not configured. Set the ANTHROPIC_API_KEY environment variable.");
+
+ _model = configuration["Writegeist:Anthropic:Model"] ?? DefaultModel;
+ }
+
+ public string ProviderName => "anthropic";
+ public string ModelName => _model;
+
+ public Task AnalyseStyleAsync(string prompt) => SendMessageAsync(prompt);
+
+ public Task GeneratePostAsync(string prompt) => SendMessageAsync(prompt);
+
+ public Task RefinePostAsync(string prompt) => SendMessageAsync(prompt);
+
+ private async Task SendMessageAsync(string prompt)
+ {
+ var requestBody = new
+ {
+ model = _model,
+ max_tokens = 4096,
+ messages = new[]
+ {
+ new { role = "user", content = prompt }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ using var request = new HttpRequestMessage(HttpMethod.Post, ApiBaseUrl);
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ request.Headers.Add("x-api-key", _apiKey);
+ request.Headers.Add("anthropic-version", ApiVersion);
+
+ using var response = await _httpClient.SendAsync(request);
+
+ var responseBody = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var statusCode = (int)response.StatusCode;
+ var message = statusCode switch
+ {
+ 401 => "Invalid Anthropic API key. Please check your ANTHROPIC_API_KEY.",
+ 429 => "Anthropic rate limit exceeded. Please wait a moment and try again.",
+ >= 500 => $"Anthropic API server error ({statusCode}). Please try again later.",
+ _ => $"Anthropic API request failed with status {statusCode}: {responseBody}"
+ };
+ throw new HttpRequestException(message);
+ }
+
+ using var doc = JsonDocument.Parse(responseBody);
+ var content = doc.RootElement
+ .GetProperty("content")[0]
+ .GetProperty("text")
+ .GetString();
+
+ return content ?? throw new InvalidOperationException("Anthropic API returned empty content.");
+ }
+}
diff --git a/src/Writegeist.Infrastructure/LlmProviders/OpenAiProvider.cs b/src/Writegeist.Infrastructure/LlmProviders/OpenAiProvider.cs
new file mode 100644
index 0000000..8558fba
--- /dev/null
+++ b/src/Writegeist.Infrastructure/LlmProviders/OpenAiProvider.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.Configuration;
+using OpenAI.Chat;
+using Writegeist.Core.Interfaces;
+
+namespace Writegeist.Infrastructure.LlmProviders;
+
+public class OpenAiProvider : ILlmProvider
+{
+ private const string DefaultModel = "gpt-4o";
+
+ private readonly ChatClient _chatClient;
+ private readonly string _model;
+
+ public OpenAiProvider(IConfiguration configuration)
+ {
+ var apiKey = configuration["OPENAI_API_KEY"]
+ ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY")
+ ?? throw new InvalidOperationException(
+ "OpenAI API key is not configured. Set the OPENAI_API_KEY environment variable.");
+
+ _model = configuration["Writegeist:OpenAi:Model"] ?? DefaultModel;
+ _chatClient = new ChatClient(_model, apiKey);
+ }
+
+ public string ProviderName => "openai";
+ public string ModelName => _model;
+
+ public Task AnalyseStyleAsync(string prompt) => SendMessageAsync(prompt);
+
+ public Task GeneratePostAsync(string prompt) => SendMessageAsync(prompt);
+
+ public Task RefinePostAsync(string prompt) => SendMessageAsync(prompt);
+
+ private async Task SendMessageAsync(string prompt)
+ {
+ try
+ {
+ var completion = await _chatClient.CompleteChatAsync(
+ [new UserChatMessage(prompt)]);
+
+ return completion.Value.Content[0].Text
+ ?? throw new InvalidOperationException("OpenAI API returned empty content.");
+ }
+ catch (Exception ex) when (ex is not InvalidOperationException)
+ {
+ var message = ex.Message switch
+ {
+ var m when m.Contains("401") => "Invalid OpenAI API key. Please check your OPENAI_API_KEY.",
+ var m when m.Contains("429") => "OpenAI rate limit exceeded. Please wait a moment and try again.",
+ var m when m.Contains("500") || m.Contains("502") || m.Contains("503") =>
+ "OpenAI API server error. Please try again later.",
+ _ => $"OpenAI API request failed: {ex.Message}"
+ };
+ throw new HttpRequestException(message, ex);
+ }
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Persistence/SqliteDatabase.cs b/src/Writegeist.Infrastructure/Persistence/SqliteDatabase.cs
new file mode 100644
index 0000000..5fb4f01
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Persistence/SqliteDatabase.cs
@@ -0,0 +1,73 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Configuration;
+
+namespace Writegeist.Infrastructure.Persistence;
+
+public class SqliteDatabase
+{
+ private readonly string _connectionString;
+
+ public SqliteDatabase(IConfiguration configuration)
+ {
+ var dbPath = configuration["Writegeist:DatabasePath"] ?? "writegeist.db";
+ _connectionString = $"Data Source={dbPath}";
+ }
+
+ public string ConnectionString => _connectionString;
+
+ public void EnsureCreated()
+ {
+ using var connection = new SqliteConnection(_connectionString);
+ connection.Open();
+
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ CREATE TABLE IF NOT EXISTS persons (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS raw_posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id INTEGER NOT NULL,
+ platform TEXT NOT NULL,
+ content TEXT NOT NULL,
+ content_hash TEXT NOT NULL,
+ source_url TEXT,
+ fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (person_id) REFERENCES persons(id),
+ UNIQUE (person_id, content_hash)
+ );
+
+ CREATE TABLE IF NOT EXISTS style_profiles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id INTEGER NOT NULL,
+ profile_json TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ model TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (person_id) REFERENCES persons(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS generated_drafts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id INTEGER NOT NULL,
+ style_profile_id INTEGER NOT NULL,
+ platform TEXT NOT NULL,
+ topic TEXT NOT NULL,
+ content TEXT NOT NULL,
+ parent_draft_id INTEGER,
+ feedback TEXT,
+ provider TEXT NOT NULL,
+ model TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (person_id) REFERENCES persons(id),
+ FOREIGN KEY (style_profile_id) REFERENCES style_profiles(id),
+ FOREIGN KEY (parent_draft_id) REFERENCES generated_drafts(id)
+ );
+ """;
+
+ command.ExecuteNonQuery();
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Persistence/SqliteDraftRepository.cs b/src/Writegeist.Infrastructure/Persistence/SqliteDraftRepository.cs
new file mode 100644
index 0000000..7c63ceb
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Persistence/SqliteDraftRepository.cs
@@ -0,0 +1,100 @@
+using Microsoft.Data.Sqlite;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Infrastructure.Persistence;
+
+public class SqliteDraftRepository : IDraftRepository
+{
+ private readonly string _connectionString;
+
+ public SqliteDraftRepository(SqliteDatabase database)
+ {
+ _connectionString = database.ConnectionString;
+ }
+
+ public async Task SaveAsync(GeneratedDraft draft)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = """
+ INSERT INTO generated_drafts (person_id, style_profile_id, platform, topic, content, parent_draft_id, feedback, provider, model)
+ VALUES (@personId, @styleProfileId, @platform, @topic, @content, @parentDraftId, @feedback, @provider, @model);
+ SELECT last_insert_rowid();
+ """;
+ command.Parameters.AddWithValue("@personId", draft.PersonId);
+ command.Parameters.AddWithValue("@styleProfileId", draft.StyleProfileId);
+ command.Parameters.AddWithValue("@platform", draft.Platform.ToString());
+ command.Parameters.AddWithValue("@topic", draft.Topic);
+ command.Parameters.AddWithValue("@content", draft.Content);
+ command.Parameters.AddWithValue("@parentDraftId", (object?)draft.ParentDraftId ?? DBNull.Value);
+ command.Parameters.AddWithValue("@feedback", (object?)draft.Feedback ?? DBNull.Value);
+ command.Parameters.AddWithValue("@provider", draft.Provider);
+ command.Parameters.AddWithValue("@model", draft.Model);
+
+ var id = (long)(await command.ExecuteScalarAsync())!;
+
+ await using var readCmd = connection.CreateCommand();
+ readCmd.CommandText = SelectColumns + " WHERE id = @id;";
+ readCmd.Parameters.AddWithValue("@id", id);
+
+ await using var reader = await readCmd.ExecuteReaderAsync();
+ await reader.ReadAsync();
+
+ return ReadDraft(reader);
+ }
+
+ public async Task GetLatestAsync()
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = SelectColumns + " ORDER BY created_at DESC, id DESC LIMIT 1;";
+
+ await using var reader = await command.ExecuteReaderAsync();
+ if (!await reader.ReadAsync())
+ return null;
+
+ return ReadDraft(reader);
+ }
+
+ public async Task GetByIdAsync(int id)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = SelectColumns + " WHERE id = @id;";
+ command.Parameters.AddWithValue("@id", id);
+
+ await using var reader = await command.ExecuteReaderAsync();
+ if (!await reader.ReadAsync())
+ return null;
+
+ return ReadDraft(reader);
+ }
+
+ private const string SelectColumns =
+ "SELECT id, person_id, style_profile_id, platform, topic, content, parent_draft_id, feedback, provider, model, created_at FROM generated_drafts";
+
+ private static GeneratedDraft ReadDraft(SqliteDataReader reader)
+ {
+ return new GeneratedDraft
+ {
+ Id = reader.GetInt32(0),
+ PersonId = reader.GetInt32(1),
+ StyleProfileId = reader.GetInt32(2),
+ Platform = Enum.Parse(reader.GetString(3)),
+ Topic = reader.GetString(4),
+ Content = reader.GetString(5),
+ ParentDraftId = reader.IsDBNull(6) ? null : reader.GetInt32(6),
+ Feedback = reader.IsDBNull(7) ? null : reader.GetString(7),
+ Provider = reader.GetString(8),
+ Model = reader.GetString(9),
+ CreatedAt = DateTime.Parse(reader.GetString(10))
+ };
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Persistence/SqlitePersonRepository.cs b/src/Writegeist.Infrastructure/Persistence/SqlitePersonRepository.cs
new file mode 100644
index 0000000..41cf759
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Persistence/SqlitePersonRepository.cs
@@ -0,0 +1,90 @@
+using Microsoft.Data.Sqlite;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Infrastructure.Persistence;
+
+public class SqlitePersonRepository : IPersonRepository
+{
+ private readonly string _connectionString;
+
+ public SqlitePersonRepository(SqliteDatabase database)
+ {
+ _connectionString = database.ConnectionString;
+ }
+
+ public async Task CreateAsync(string name)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = """
+ INSERT INTO persons (name) VALUES (@name);
+ SELECT last_insert_rowid();
+ """;
+ command.Parameters.AddWithValue("@name", name);
+
+ var id = (long)(await command.ExecuteScalarAsync())!;
+
+ await using var readCmd = connection.CreateCommand();
+ readCmd.CommandText = "SELECT id, name, created_at FROM persons WHERE id = @id;";
+ readCmd.Parameters.AddWithValue("@id", id);
+
+ await using var reader = await readCmd.ExecuteReaderAsync();
+ await reader.ReadAsync();
+
+ return ReadPerson(reader);
+ }
+
+ public async Task GetByNameAsync(string name)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = "SELECT id, name, created_at FROM persons WHERE name = @name COLLATE NOCASE;";
+ command.Parameters.AddWithValue("@name", name);
+
+ await using var reader = await command.ExecuteReaderAsync();
+ if (!await reader.ReadAsync())
+ return null;
+
+ return ReadPerson(reader);
+ }
+
+ public async Task> GetAllAsync()
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = "SELECT id, name, created_at FROM persons ORDER BY name;";
+
+ await using var reader = await command.ExecuteReaderAsync();
+ var persons = new List();
+ while (await reader.ReadAsync())
+ persons.Add(ReadPerson(reader));
+
+ return persons;
+ }
+
+ public async Task GetOrCreateAsync(string name)
+ {
+ var existing = await GetByNameAsync(name);
+ if (existing is not null)
+ return existing;
+
+ return await CreateAsync(name);
+ }
+
+ private static Person ReadPerson(SqliteDataReader reader)
+ {
+ return new Person
+ {
+ Id = reader.GetInt32(0),
+ Name = reader.GetString(1),
+ CreatedAt = DateTime.Parse(reader.GetString(2))
+ };
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Persistence/SqlitePostRepository.cs b/src/Writegeist.Infrastructure/Persistence/SqlitePostRepository.cs
new file mode 100644
index 0000000..34ff269
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Persistence/SqlitePostRepository.cs
@@ -0,0 +1,112 @@
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.Data.Sqlite;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Infrastructure.Persistence;
+
+public class SqlitePostRepository : IPostRepository
+{
+ private readonly string _connectionString;
+
+ public SqlitePostRepository(SqliteDatabase database)
+ {
+ _connectionString = database.ConnectionString;
+ }
+
+ public async Task AddAsync(RawPost post)
+ {
+ post.ContentHash = ComputeHash(post.Content);
+
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = """
+ INSERT OR IGNORE INTO raw_posts (person_id, platform, content, content_hash, source_url)
+ VALUES (@personId, @platform, @content, @contentHash, @sourceUrl);
+ """;
+ command.Parameters.AddWithValue("@personId", post.PersonId);
+ command.Parameters.AddWithValue("@platform", post.Platform.ToString());
+ command.Parameters.AddWithValue("@content", post.Content);
+ command.Parameters.AddWithValue("@contentHash", post.ContentHash);
+ command.Parameters.AddWithValue("@sourceUrl", (object?)post.SourceUrl ?? DBNull.Value);
+
+ var rowsAffected = await command.ExecuteNonQueryAsync();
+ return rowsAffected > 0;
+ }
+
+ public async Task> GetByPersonIdAsync(int personId)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT id, person_id, platform, content, content_hash, source_url, fetched_at
+ FROM raw_posts
+ WHERE person_id = @personId
+ ORDER BY fetched_at;
+ """;
+ command.Parameters.AddWithValue("@personId", personId);
+
+ await using var reader = await command.ExecuteReaderAsync();
+ var posts = new List();
+ while (await reader.ReadAsync())
+ posts.Add(ReadPost(reader));
+
+ return posts;
+ }
+
+ public async Task GetCountByPersonIdAsync(int personId)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = "SELECT COUNT(*) FROM raw_posts WHERE person_id = @personId;";
+ command.Parameters.AddWithValue("@personId", personId);
+
+ var count = (long)(await command.ExecuteScalarAsync())!;
+ return (int)count;
+ }
+
+ public async Task ExistsByHashAsync(int personId, string contentHash)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT COUNT(*) FROM raw_posts
+ WHERE person_id = @personId AND content_hash = @contentHash;
+ """;
+ command.Parameters.AddWithValue("@personId", personId);
+ command.Parameters.AddWithValue("@contentHash", contentHash);
+
+ var count = (long)(await command.ExecuteScalarAsync())!;
+ return count > 0;
+ }
+
+ public static string ComputeHash(string content)
+ {
+ var normalised = content.Trim().ToLowerInvariant();
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalised));
+ return Convert.ToHexStringLower(bytes);
+ }
+
+ private static RawPost ReadPost(SqliteDataReader reader)
+ {
+ return new RawPost
+ {
+ Id = reader.GetInt32(0),
+ PersonId = reader.GetInt32(1),
+ Platform = Enum.Parse(reader.GetString(2)),
+ Content = reader.GetString(3),
+ ContentHash = reader.GetString(4),
+ SourceUrl = reader.IsDBNull(5) ? null : reader.GetString(5),
+ FetchedAt = DateTime.Parse(reader.GetString(6))
+ };
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Persistence/SqliteStyleProfileRepository.cs b/src/Writegeist.Infrastructure/Persistence/SqliteStyleProfileRepository.cs
new file mode 100644
index 0000000..40206d6
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Persistence/SqliteStyleProfileRepository.cs
@@ -0,0 +1,100 @@
+using Microsoft.Data.Sqlite;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Infrastructure.Persistence;
+
+public class SqliteStyleProfileRepository : IStyleProfileRepository
+{
+ private readonly string _connectionString;
+
+ public SqliteStyleProfileRepository(SqliteDatabase database)
+ {
+ _connectionString = database.ConnectionString;
+ }
+
+ public async Task SaveAsync(StyleProfile profile)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = """
+ INSERT INTO style_profiles (person_id, profile_json, provider, model)
+ VALUES (@personId, @profileJson, @provider, @model);
+ SELECT last_insert_rowid();
+ """;
+ command.Parameters.AddWithValue("@personId", profile.PersonId);
+ command.Parameters.AddWithValue("@profileJson", profile.ProfileJson);
+ command.Parameters.AddWithValue("@provider", profile.Provider);
+ command.Parameters.AddWithValue("@model", profile.Model);
+
+ var id = (long)(await command.ExecuteScalarAsync())!;
+
+ await using var readCmd = connection.CreateCommand();
+ readCmd.CommandText = "SELECT id, person_id, profile_json, provider, model, created_at FROM style_profiles WHERE id = @id;";
+ readCmd.Parameters.AddWithValue("@id", id);
+
+ await using var reader = await readCmd.ExecuteReaderAsync();
+ await reader.ReadAsync();
+
+ return ReadProfile(reader);
+ }
+
+ public async Task GetLatestByPersonIdAsync(int personId)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT id, person_id, profile_json, provider, model, created_at
+ FROM style_profiles
+ WHERE person_id = @personId
+ ORDER BY created_at DESC, id DESC
+ LIMIT 1;
+ """;
+ command.Parameters.AddWithValue("@personId", personId);
+
+ await using var reader = await command.ExecuteReaderAsync();
+ if (!await reader.ReadAsync())
+ return null;
+
+ return ReadProfile(reader);
+ }
+
+ public async Task> GetAllByPersonIdAsync(int personId)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT id, person_id, profile_json, provider, model, created_at
+ FROM style_profiles
+ WHERE person_id = @personId
+ ORDER BY created_at DESC, id DESC;
+ """;
+ command.Parameters.AddWithValue("@personId", personId);
+
+ await using var reader = await command.ExecuteReaderAsync();
+ var profiles = new List();
+ while (await reader.ReadAsync())
+ profiles.Add(ReadProfile(reader));
+
+ return profiles;
+ }
+
+ private static StyleProfile ReadProfile(SqliteDataReader reader)
+ {
+ return new StyleProfile
+ {
+ Id = reader.GetInt32(0),
+ PersonId = reader.GetInt32(1),
+ ProfileJson = reader.GetString(2),
+ Provider = reader.GetString(3),
+ Model = reader.GetString(4),
+ CreatedAt = DateTime.Parse(reader.GetString(5))
+ };
+ }
+}
diff --git a/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj b/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj
new file mode 100644
index 0000000..185414b
--- /dev/null
+++ b/src/Writegeist.Infrastructure/Writegeist.Infrastructure.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/tasks/prd-writegeist.md b/tasks/prd-writegeist.md
new file mode 100644
index 0000000..48e5a17
--- /dev/null
+++ b/tasks/prd-writegeist.md
@@ -0,0 +1,277 @@
+# PRD: Writegeist
+
+## Introduction
+
+Writegeist is a .NET interactive CLI tool that learns a person's writing style from their social media posts and generates new platform-specific content in that style. It solves the problem of maintaining authentic, consistent voice across social platforms — you feed it your existing posts, it builds a reusable style profile, and then ghostwrites new posts that sound like you.
+
+The tool uses an interactive menu-driven interface (via DevJonny.InteractiveCli) with polished Spectre.Console output, supports both Anthropic and OpenAI as LLM backends, and stores all data locally in SQLite.
+
+## Goals
+
+- Clone the user's personal writing style from existing social media posts into a reusable profile
+- Generate platform-aware drafts (LinkedIn, X, Instagram, Facebook) that match the user's voice
+- Support iterative refinement of generated drafts with natural language feedback
+- Provide a polished interactive CLI experience with tables, panels, colours, and spinners
+- Support both Anthropic (Claude) and OpenAI as LLM providers from day one
+- Support all four platforms with automated fetching where possible and manual fallbacks where not
+
+## User Stories
+
+### US-001: Solution scaffolding
+**Description:** As a developer, I need the .NET solution structure created with all projects, references, and NuGet packages so that I can start building features.
+
+**Acceptance Criteria:**
+- [ ] `Writegeist.sln` with four projects: `Writegeist.Cli`, `Writegeist.Core`, `Writegeist.Infrastructure`, `Writegeist.Tests`
+- [ ] Project references wired: Cli → Core + Infrastructure, Infrastructure → Core, Tests → Core + Infrastructure
+- [ ] `Writegeist.Cli` references `DevJonny.InteractiveCli` NuGet package
+- [ ] `Writegeist.Infrastructure` references `Microsoft.Data.Sqlite`, `AngleSharp`, `Anthropic` (or raw HTTP fallback), `OpenAI`
+- [ ] `Writegeist.Tests` references `xunit`, `FluentAssertions`, and a mocking library
+- [ ] `Program.cs` bootstraps InteractiveCli with a MainMenu that displays and quits cleanly
+- [ ] `dotnet build` succeeds with no errors
+- [ ] `dotnet run` launches the interactive menu
+
+### US-002: SQLite database and schema
+**Description:** As a developer, I need a SQLite database with the schema for persons, raw posts, style profiles, and generated drafts so that all data persists locally.
+
+**Acceptance Criteria:**
+- [ ] `SqliteDatabase.EnsureCreated()` creates the database file and all four tables (`persons`, `raw_posts`, `style_profiles`, `generated_drafts`)
+- [ ] Schema matches the data model spec (auto-increment IDs, foreign keys, unique constraints, defaults)
+- [ ] Database path is configurable via `appsettings.json` (`Writegeist:DatabasePath`)
+- [ ] Tables are created idempotently (safe to call multiple times)
+- [ ] Unit tests verify schema creation with in-memory SQLite
+
+### US-003: Person repository
+**Description:** As a developer, I need CRUD operations for persons so that the ingest and analyse workflows can store and retrieve people.
+
+**Acceptance Criteria:**
+- [ ] `IPersonRepository` interface in Core with methods: `CreateAsync`, `GetByNameAsync`, `GetAllAsync`, `GetOrCreateAsync`
+- [ ] `SqlitePersonRepository` implementation in Infrastructure
+- [ ] `GetOrCreateAsync` returns existing person if name matches (case-insensitive), creates if not
+- [ ] Unit tests with in-memory SQLite cover create, get, get-all, and dedup scenarios
+
+### US-004: Post repository
+**Description:** As a developer, I need CRUD operations for raw posts so that ingested content can be stored and retrieved per person.
+
+**Acceptance Criteria:**
+- [ ] `IPostRepository` interface in Core with methods: `AddAsync`, `GetByPersonIdAsync`, `GetCountByPersonIdAsync`, `ExistsByHashAsync`
+- [ ] `SqlitePostRepository` implementation in Infrastructure
+- [ ] Posts are deduplicated by SHA-256 content hash per person (unique constraint on `person_id` + `content_hash`)
+- [ ] `AddAsync` returns a result indicating whether the post was new or a duplicate
+- [ ] Unit tests cover add, dedup, and retrieval
+
+### US-005: Style profile repository
+**Description:** As a developer, I need storage for style profiles so that analysed profiles persist and can be retrieved for generation.
+
+**Acceptance Criteria:**
+- [ ] `IStyleProfileRepository` interface in Core with methods: `SaveAsync`, `GetLatestByPersonIdAsync`, `GetAllByPersonIdAsync`
+- [ ] `SqliteStyleProfileRepository` implementation in Infrastructure
+- [ ] `GetLatestByPersonIdAsync` returns the most recent profile by `created_at`
+- [ ] Profile JSON stored as text, provider and model recorded
+- [ ] Unit tests cover save, retrieve latest, and retrieve all
+
+### US-006: Draft repository
+**Description:** As a developer, I need storage for generated drafts so that the refine workflow can retrieve and chain drafts.
+
+**Acceptance Criteria:**
+- [ ] Draft repository interface with methods: `SaveAsync`, `GetLatestAsync`, `GetByIdAsync`
+- [ ] `GetLatestAsync` returns the most recently created draft (across all persons)
+- [ ] `parent_draft_id` links refined drafts to their predecessors
+- [ ] Unit tests cover save, retrieval, and parent-child linking
+
+### US-007: Manual fetcher — file import
+**Description:** As a user, I want to import my posts from a text file so that I can feed Writegeist content from any platform without needing API access.
+
+**Acceptance Criteria:**
+- [ ] `ManualFetcher` implements `IContentFetcher`
+- [ ] Reads a text file and splits posts on `---` separator lines
+- [ ] Trims whitespace from each post, skips empty entries
+- [ ] Returns a list of `FetchedPost` records
+- [ ] Unit tests cover normal file, empty file, file with no separators, and file with consecutive separators
+
+### US-008: Manual fetcher — interactive paste
+**Description:** As a user, I want to paste posts one at a time in an interactive session so that I can quickly add content without creating a file.
+
+**Acceptance Criteria:**
+- [ ] Interactive mode uses Spectre.Console `TextPrompt` for multi-line input
+- [ ] User pastes a post, confirms, then is asked if they want to add another
+- [ ] Session ends when user chooses to stop
+- [ ] Each post is returned as a `FetchedPost`
+
+### US-009: Ingest menu and actions
+**Description:** As a user, I want a sub-menu for ingesting posts with options for file import, URL/handle fetch, and interactive paste so that I can choose my preferred input method.
+
+**Acceptance Criteria:**
+- [ ] `IngestMenu` appears as "Ingest Posts" in the MainMenu
+- [ ] Sub-menu contains: "From File", "From URL / Handle", "Interactive Paste", "Back"
+- [ ] `IngestFromFileAction` prompts for person name, platform (selection), and file path
+- [ ] `IngestInteractiveAction` prompts for person name and platform, then loops for posts
+- [ ] `IngestFromUrlAction` prompts for person name, platform, and URL/handle
+- [ ] All actions display a Spectre.Console summary panel after ingestion: new posts count, duplicates skipped
+- [ ] Person is created in SQLite if they don't exist
+
+### US-010: Platform conventions
+**Description:** As a developer, I need platform-specific rules (character limits, hashtag guidance, formatting) so that generated posts follow each platform's norms.
+
+**Acceptance Criteria:**
+- [ ] `PlatformConventions` class with a static lookup returning `PlatformRules` for each platform
+- [ ] LinkedIn: 3,000 max chars, 1,200–1,800 recommended, 3–5 hashtags at end, sparingly emoji
+- [ ] X/Twitter: 280 max chars (single tweet), 1–2 inline hashtags, moderate emoji
+- [ ] Instagram: 2,200 max chars, 500–1,000 recommended, up to 30 hashtags in separate block, freely emoji
+- [ ] Facebook: 63,206 max chars, 400–800 recommended, 1–3 hashtags or none, moderate emoji
+- [ ] Unit tests verify rules for all four platforms
+
+### US-011: Anthropic LLM provider
+**Description:** As a user, I want to use Claude as the LLM backend so that I can analyse my style and generate posts using Anthropic's models.
+
+**Acceptance Criteria:**
+- [ ] `AnthropicProvider` implements `ILlmProvider`
+- [ ] Uses the official `Anthropic` NuGet SDK if available, otherwise raw HTTP to `https://api.anthropic.com/v1/messages`
+- [ ] API key read from `ANTHROPIC_API_KEY` environment variable
+- [ ] Model configurable via `appsettings.json` (`Writegeist:Anthropic:Model`)
+- [ ] Handles API errors gracefully with user-friendly messages
+- [ ] Spinner displayed during API calls using Spectre.Console `AnsiConsole.Status()`
+
+### US-012: OpenAI LLM provider
+**Description:** As a user, I want to use OpenAI as an alternative LLM backend so that I have a choice of providers.
+
+**Acceptance Criteria:**
+- [ ] `OpenAiProvider` implements `ILlmProvider`
+- [ ] Uses the official `OpenAI` NuGet package
+- [ ] API key read from `OPENAI_API_KEY` environment variable
+- [ ] Model configurable via `appsettings.json` (`Writegeist:OpenAi:Model`)
+- [ ] Handles API errors gracefully with user-friendly messages
+- [ ] Spinner displayed during API calls
+
+### US-013: Style analysis
+**Description:** As a user, I want to analyse my ingested posts to build a style profile so that Writegeist can learn how I write.
+
+**Acceptance Criteria:**
+- [ ] `AnalyseAction` appears as "Analyse Style" in the MainMenu
+- [ ] Prompts user to select a person from existing persons (Spectre.Console selection prompt)
+- [ ] Prompts user to select LLM provider (defaults from config)
+- [ ] `StyleAnalyser` service loads all raw posts for the person and sends to the LLM with the style analysis prompt
+- [ ] Resulting style profile JSON is stored in SQLite with provider and model metadata
+- [ ] Profile summary is displayed in a styled Spectre.Console panel after analysis
+- [ ] Shows an error if the person has no ingested posts
+- [ ] Spinner displayed while LLM processes
+
+### US-014: Post generation
+**Description:** As a user, I want to generate a new post in my style for a specific platform so that I can quickly create on-brand content.
+
+**Acceptance Criteria:**
+- [ ] `GenerateAction` appears as "Generate Post" in the MainMenu
+- [ ] Prompts user to select a person (only persons with a style profile)
+- [ ] Prompts user to select a target platform (selection prompt)
+- [ ] Prompts user to enter topic/key points (multi-line text prompt)
+- [ ] `PostGenerator` service combines style profile + platform conventions + topic into the generation prompt
+- [ ] Generated draft is stored in SQLite as a `GeneratedDraft`
+- [ ] Generated post is displayed in a styled Spectre.Console panel
+- [ ] Shows an error if no style profile exists for the selected person
+- [ ] Spinner displayed while LLM processes
+
+### US-015: Draft refinement
+**Description:** As a user, I want to refine the last generated draft with natural language feedback so that I can iterate towards the perfect post.
+
+**Acceptance Criteria:**
+- [ ] `RefineAction` appears as "Refine Last Draft" in the MainMenu
+- [ ] Loads the most recent `GeneratedDraft` and displays it
+- [ ] Prompts for feedback (text prompt)
+- [ ] Sends refinement prompt to LLM with current draft + feedback + style profile
+- [ ] Stores refined draft linked to the previous one (`parent_draft_id`)
+- [ ] Displays the refined post in a styled panel
+- [ ] Asks "Refine again?" — loops if yes, returns to menu if no (uses `RepeatableActionAsync`)
+- [ ] Shows an error if no drafts exist yet
+- [ ] Spinner displayed while LLM processes
+
+### US-016: Profile viewing
+**Description:** As a user, I want to view and list style profiles so that I can see what Writegeist has learned about my writing.
+
+**Acceptance Criteria:**
+- [ ] `ProfileMenu` appears as "Profiles" in the MainMenu
+- [ ] Sub-menu contains: "Show Profile", "List All Profiles", "Back"
+- [ ] `ShowProfileAction` prompts for person selection and displays the full style profile in a formatted Spectre.Console table
+- [ ] `ListProfilesAction` displays a table of all persons with profiles, showing name, provider, model, and date
+- [ ] Shows a message if no profiles exist
+
+### US-017: X/Twitter API fetcher
+**Description:** As a user, I want to fetch my recent tweets automatically so that I don't have to manually copy them.
+
+**Acceptance Criteria:**
+- [ ] `XTwitterFetcher` implements `IContentFetcher` for platform `X`
+- [ ] Uses X API v2 `GET /2/users/{id}/tweets` endpoint
+- [ ] Bearer token read from `X_BEARER_TOKEN` environment variable
+- [ ] Fetches up to 100 recent tweets per request
+- [ ] Handles 429 rate limit responses gracefully with a user-friendly message
+- [ ] Falls back to manual input suggestion if no bearer token is configured
+
+### US-018: Stub fetchers for unsupported platforms
+**Description:** As a developer, I need stub fetchers for LinkedIn, Instagram, and Facebook that give clear guidance to use manual input instead.
+
+**Acceptance Criteria:**
+- [ ] `LinkedInFetcher`, `InstagramFetcher`, `FacebookFetcher` each implement `IContentFetcher`
+- [ ] Each throws a user-friendly exception explaining that automated fetching is not available for this platform
+- [ ] Exception message suggests using "From File" or "Interactive Paste" instead
+- [ ] `IngestFromUrlAction` catches these exceptions and displays the message via Spectre.Console markup
+
+### US-019: Polished UI
+**Description:** As a user, I want a visually polished CLI experience with colours, panels, tables, and spinners so that the tool feels professional.
+
+**Acceptance Criteria:**
+- [ ] All LLM calls wrapped in `AnsiConsole.Status()` with spinner animation
+- [ ] Generated posts displayed in `Panel` with a border and title
+- [ ] Style profiles displayed in formatted `Table` with grouped sections
+- [ ] Ingestion summaries displayed in coloured markup (green for new, yellow for duplicates)
+- [ ] Error messages displayed in red markup
+- [ ] Menu descriptions are helpful and concise
+
+## Functional Requirements
+
+- FR-1: The application must launch into an interactive menu using DevJonny.InteractiveCli
+- FR-2: The system must store persons, posts, style profiles, and drafts in a local SQLite database
+- FR-3: The system must deduplicate ingested posts by SHA-256 content hash per person
+- FR-4: The system must support ingesting posts from a text file (separated by `---`) or interactive paste
+- FR-5: The system must support ingesting posts via X API v2 when a bearer token is configured
+- FR-6: The system must display a user-friendly message when automated fetching is unavailable for a platform
+- FR-7: The system must analyse ingested posts via an LLM and produce a structured JSON style profile
+- FR-8: The system must generate platform-specific posts using a style profile and platform conventions
+- FR-9: The system must support iterative refinement of the most recent draft with natural language feedback
+- FR-10: The system must support both Anthropic (Claude) and OpenAI as LLM providers, selectable per action
+- FR-11: The system must read API keys from environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `X_BEARER_TOKEN`)
+- FR-12: The system must display spinners during LLM API calls and styled output for all results
+- FR-13: The default LLM provider must be configurable via `appsettings.json`
+
+## Non-Goals
+
+- No web UI or API server — this is a local CLI tool only
+- No direct posting to social platforms (no OAuth write access)
+- No multi-tweet thread generation for X
+- No image generation or caption writing for Instagram
+- No headless browser scraping (Playwright) for LinkedIn/Instagram/Facebook
+- No user authentication or multi-user access control
+- No cloud storage — all data stays in local SQLite
+- No automatic style drift detection or profile comparison
+- No clipboard integration or copy-to-clipboard
+- No scheduled or automated post generation
+
+## Technical Considerations
+
+- **DevJonny.InteractiveCli** provides the host builder, DI, Serilog logging, and Spectre.Console integration. Actions are auto-registered in DI by assembly scanning.
+- **InteractiveCli supports .NET 10** — all projects target net10.0.
+- LLM provider implementations should handle network failures and API errors without crashing the interactive session — catch exceptions and display errors, then return to the menu.
+- The `Anthropic` NuGet package (official C# SDK) should be verified to exist. If not, implement raw HTTP calls to the Messages API.
+- Style profile JSON schema should be well-defined in a C# model for serialisation/deserialisation, even though it's stored as raw JSON in SQLite.
+
+## Success Metrics
+
+- User can go from zero to generated post in under 5 minutes (ingest → analyse → generate)
+- Generated posts are indistinguishable in style from the user's real posts (subjective, tested manually)
+- Refinement loop produces noticeably improved output within 1–2 iterations
+- Both LLM providers produce usable results with the same prompts
+- The interactive CLI is intuitive enough to use without reading documentation
+
+## Open Questions
+
+- Should the style analysis prompt be tuneable/editable by the user, or fixed?
+- Should there be a way to merge style profiles from multiple platforms into one?
+- Should the tool support exporting generated posts to a file?
+- What's the minimum number of posts needed for a reliable style analysis? Should we warn if too few?
diff --git a/tests/Writegeist.Tests/Fetchers/ManualFetcherTests.cs b/tests/Writegeist.Tests/Fetchers/ManualFetcherTests.cs
new file mode 100644
index 0000000..2b8be05
--- /dev/null
+++ b/tests/Writegeist.Tests/Fetchers/ManualFetcherTests.cs
@@ -0,0 +1,100 @@
+using FluentAssertions;
+using Writegeist.Core.Models;
+using Writegeist.Infrastructure.Fetchers;
+
+namespace Writegeist.Tests.Fetchers;
+
+public class ManualFetcherTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly ManualFetcher _fetcher;
+
+ public ManualFetcherTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"writegeist_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+ _fetcher = new ManualFetcher(Platform.LinkedIn);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, true);
+ }
+
+ private string WriteFile(string content)
+ {
+ var path = Path.Combine(_tempDir, $"{Guid.NewGuid():N}.txt");
+ File.WriteAllText(path, content);
+ return path;
+ }
+
+ [Fact]
+ public async Task FetchPostsAsync_MultiplePosts_SplitsOnSeparator()
+ {
+ var path = WriteFile("First post\n---\nSecond post\n---\nThird post");
+ var request = new FetchRequest(FilePath: path);
+
+ var posts = await _fetcher.FetchPostsAsync(request);
+
+ posts.Should().HaveCount(3);
+ posts[0].Content.Should().Be("First post");
+ posts[1].Content.Should().Be("Second post");
+ posts[2].Content.Should().Be("Third post");
+ }
+
+ [Fact]
+ public async Task FetchPostsAsync_EmptyFile_ReturnsEmpty()
+ {
+ var path = WriteFile("");
+ var request = new FetchRequest(FilePath: path);
+
+ var posts = await _fetcher.FetchPostsAsync(request);
+
+ posts.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task FetchPostsAsync_NoSeparators_ReturnsSinglePost()
+ {
+ var path = WriteFile("Just one post with no separators");
+ var request = new FetchRequest(FilePath: path);
+
+ var posts = await _fetcher.FetchPostsAsync(request);
+
+ posts.Should().HaveCount(1);
+ posts[0].Content.Should().Be("Just one post with no separators");
+ }
+
+ [Fact]
+ public async Task FetchPostsAsync_ConsecutiveSeparators_SkipsEmpty()
+ {
+ var path = WriteFile("First\n---\n---\n---\nSecond");
+ var request = new FetchRequest(FilePath: path);
+
+ var posts = await _fetcher.FetchPostsAsync(request);
+
+ posts.Should().HaveCount(2);
+ posts[0].Content.Should().Be("First");
+ posts[1].Content.Should().Be("Second");
+ }
+
+ [Fact]
+ public async Task FetchPostsAsync_TrimsWhitespace()
+ {
+ var path = WriteFile(" First post \n---\n Second post ");
+ var request = new FetchRequest(FilePath: path);
+
+ var posts = await _fetcher.FetchPostsAsync(request);
+
+ posts[0].Content.Should().Be("First post");
+ posts[1].Content.Should().Be("Second post");
+ }
+
+ [Fact]
+ public void Platform_ReturnsConfiguredValue()
+ {
+ var fetcher = new ManualFetcher(Platform.X);
+ fetcher.Platform.Should().Be(Platform.X);
+ }
+}
diff --git a/tests/Writegeist.Tests/Persistence/SqliteDatabaseTests.cs b/tests/Writegeist.Tests/Persistence/SqliteDatabaseTests.cs
new file mode 100644
index 0000000..e0b6b42
--- /dev/null
+++ b/tests/Writegeist.Tests/Persistence/SqliteDatabaseTests.cs
@@ -0,0 +1,133 @@
+using FluentAssertions;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Configuration;
+using NSubstitute;
+using Writegeist.Infrastructure.Persistence;
+
+namespace Writegeist.Tests.Persistence;
+
+public class SqliteDatabaseTests : IDisposable
+{
+ private readonly string _dbName;
+ private readonly SqliteDatabase _database;
+ private readonly SqliteConnection _verifyConnection;
+
+ public SqliteDatabaseTests()
+ {
+ _dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared";
+ var config = Substitute.For();
+ config["Writegeist:DatabasePath"].Returns(_dbName);
+ _database = new SqliteDatabase(config);
+
+ // Keep a connection open so the shared in-memory DB persists
+ _verifyConnection = new SqliteConnection($"Data Source={_dbName}");
+ _verifyConnection.Open();
+ }
+
+ public void Dispose()
+ {
+ _verifyConnection.Dispose();
+ }
+
+ private List GetTableNames()
+ {
+ using var cmd = _verifyConnection.CreateCommand();
+ cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;";
+ using var reader = cmd.ExecuteReader();
+ var tables = new List();
+ while (reader.Read())
+ tables.Add(reader.GetString(0));
+ return tables;
+ }
+
+ private List GetColumnNames(string tableName)
+ {
+ using var cmd = _verifyConnection.CreateCommand();
+ cmd.CommandText = $"PRAGMA table_info({tableName});";
+ using var reader = cmd.ExecuteReader();
+ var columns = new List();
+ while (reader.Read())
+ columns.Add(reader.GetString(1));
+ return columns;
+ }
+
+ [Fact]
+ public void EnsureCreated_CreatesAllFourTables()
+ {
+ _database.EnsureCreated();
+
+ var tables = GetTableNames();
+ tables.Should().Contain("persons");
+ tables.Should().Contain("raw_posts");
+ tables.Should().Contain("style_profiles");
+ tables.Should().Contain("generated_drafts");
+ }
+
+ [Fact]
+ public void EnsureCreated_IsIdempotent()
+ {
+ _database.EnsureCreated();
+ _database.EnsureCreated(); // should not throw
+
+ GetTableNames().Should().Contain("persons");
+ }
+
+ [Fact]
+ public void EnsureCreated_PersonsTable_HasExpectedColumns()
+ {
+ _database.EnsureCreated();
+
+ var columns = GetColumnNames("persons");
+ columns.Should().Contain("id");
+ columns.Should().Contain("name");
+ columns.Should().Contain("created_at");
+ }
+
+ [Fact]
+ public void EnsureCreated_RawPostsTable_HasExpectedColumns()
+ {
+ _database.EnsureCreated();
+
+ var columns = GetColumnNames("raw_posts");
+ columns.Should().Contain("id");
+ columns.Should().Contain("person_id");
+ columns.Should().Contain("platform");
+ columns.Should().Contain("content");
+ columns.Should().Contain("content_hash");
+ columns.Should().Contain("source_url");
+ columns.Should().Contain("fetched_at");
+ }
+
+ [Fact]
+ public void EnsureCreated_StyleProfilesTable_HasExpectedColumns()
+ {
+ _database.EnsureCreated();
+
+ var columns = GetColumnNames("style_profiles");
+ columns.Should().Contain("id");
+ columns.Should().Contain("person_id");
+ columns.Should().Contain("profile_json");
+ columns.Should().Contain("provider");
+ columns.Should().Contain("model");
+ columns.Should().Contain("created_at");
+ }
+
+ [Fact]
+ public void EnsureCreated_GeneratedDraftsTable_HasExpectedColumns()
+ {
+ _database.EnsureCreated();
+
+ var columns = GetColumnNames("generated_drafts");
+ columns.Should().Contain("id");
+ columns.Should().Contain("person_id");
+ columns.Should().Contain("style_profile_id");
+ columns.Should().Contain("platform");
+ columns.Should().Contain("topic");
+ columns.Should().Contain("content");
+ columns.Should().Contain("parent_draft_id");
+ columns.Should().Contain("feedback");
+ columns.Should().Contain("provider");
+ columns.Should().Contain("model");
+ columns.Should().Contain("created_at");
+ }
+}
diff --git a/tests/Writegeist.Tests/Persistence/SqliteDraftRepositoryTests.cs b/tests/Writegeist.Tests/Persistence/SqliteDraftRepositoryTests.cs
new file mode 100644
index 0000000..d083cab
--- /dev/null
+++ b/tests/Writegeist.Tests/Persistence/SqliteDraftRepositoryTests.cs
@@ -0,0 +1,134 @@
+using FluentAssertions;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Configuration;
+using NSubstitute;
+using Writegeist.Core.Models;
+using Writegeist.Infrastructure.Persistence;
+
+namespace Writegeist.Tests.Persistence;
+
+public class SqliteDraftRepositoryTests : IDisposable
+{
+ private readonly SqliteDraftRepository _repository;
+ private readonly SqliteConnection _keepAlive;
+
+ public SqliteDraftRepositoryTests()
+ {
+ var dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared";
+ var config = Substitute.For();
+ config["Writegeist:DatabasePath"].Returns(dbName);
+
+ var database = new SqliteDatabase(config);
+ database.EnsureCreated();
+
+ _keepAlive = new SqliteConnection($"Data Source={dbName}");
+ _keepAlive.Open();
+
+ // Insert prerequisite person and style profile
+ using var cmd = _keepAlive.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO persons (name) VALUES ('TestPerson');
+ INSERT INTO style_profiles (person_id, profile_json, provider, model) VALUES (1, '{}', 'anthropic', 'test');
+ """;
+ cmd.ExecuteNonQuery();
+
+ _repository = new SqliteDraftRepository(database);
+ }
+
+ public void Dispose()
+ {
+ _keepAlive.Dispose();
+ }
+
+ private GeneratedDraft MakeDraft(string content = "Test post", int? parentDraftId = null, string? feedback = null)
+ {
+ return new GeneratedDraft
+ {
+ PersonId = 1,
+ StyleProfileId = 1,
+ Platform = Platform.LinkedIn,
+ Topic = "testing",
+ Content = content,
+ ParentDraftId = parentDraftId,
+ Feedback = feedback,
+ Provider = "anthropic",
+ Model = "test-model"
+ };
+ }
+
+ [Fact]
+ public async Task SaveAsync_ReturnsDraftWithGeneratedId()
+ {
+ var saved = await _repository.SaveAsync(MakeDraft());
+
+ saved.Id.Should().BeGreaterThan(0);
+ saved.Content.Should().Be("Test post");
+ saved.Platform.Should().Be(Platform.LinkedIn);
+ saved.Provider.Should().Be("anthropic");
+ saved.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(5));
+ }
+
+ [Fact]
+ public async Task GetLatestAsync_ReturnsMostRecentDraft()
+ {
+ await _repository.SaveAsync(MakeDraft("First"));
+ var second = await _repository.SaveAsync(MakeDraft("Second"));
+
+ var latest = await _repository.GetLatestAsync();
+
+ latest.Should().NotBeNull();
+ latest!.Id.Should().Be(second.Id);
+ latest.Content.Should().Be("Second");
+ }
+
+ [Fact]
+ public async Task GetLatestAsync_NoDrafts_ReturnsNull()
+ {
+ var result = await _repository.GetLatestAsync();
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetByIdAsync_ExistingDraft_ReturnsDraft()
+ {
+ var saved = await _repository.SaveAsync(MakeDraft());
+
+ var result = await _repository.GetByIdAsync(saved.Id);
+
+ result.Should().NotBeNull();
+ result!.Id.Should().Be(saved.Id);
+ result.Content.Should().Be("Test post");
+ }
+
+ [Fact]
+ public async Task GetByIdAsync_NonExisting_ReturnsNull()
+ {
+ var result = await _repository.GetByIdAsync(999);
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task SaveAsync_ParentChildLinking_WorksCorrectly()
+ {
+ var parent = await _repository.SaveAsync(MakeDraft("Original"));
+ var child = await _repository.SaveAsync(MakeDraft("Refined", parentDraftId: parent.Id, feedback: "Make it shorter"));
+
+ child.ParentDraftId.Should().Be(parent.Id);
+ child.Feedback.Should().Be("Make it shorter");
+
+ var retrieved = await _repository.GetByIdAsync(child.Id);
+ retrieved!.ParentDraftId.Should().Be(parent.Id);
+ retrieved.Feedback.Should().Be("Make it shorter");
+ }
+
+ [Fact]
+ public async Task SaveAsync_NullableFieldsAreNull_WhenNotSet()
+ {
+ var saved = await _repository.SaveAsync(MakeDraft());
+
+ saved.ParentDraftId.Should().BeNull();
+ saved.Feedback.Should().BeNull();
+ }
+}
diff --git a/tests/Writegeist.Tests/Persistence/SqlitePersonRepositoryTests.cs b/tests/Writegeist.Tests/Persistence/SqlitePersonRepositoryTests.cs
new file mode 100644
index 0000000..8750693
--- /dev/null
+++ b/tests/Writegeist.Tests/Persistence/SqlitePersonRepositoryTests.cs
@@ -0,0 +1,111 @@
+using FluentAssertions;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Configuration;
+using NSubstitute;
+using Writegeist.Infrastructure.Persistence;
+
+namespace Writegeist.Tests.Persistence;
+
+public class SqlitePersonRepositoryTests : IDisposable
+{
+ private readonly SqlitePersonRepository _repository;
+ private readonly SqliteConnection _keepAlive;
+
+ public SqlitePersonRepositoryTests()
+ {
+ var dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared";
+ var config = Substitute.For();
+ config["Writegeist:DatabasePath"].Returns(dbName);
+
+ var database = new SqliteDatabase(config);
+ database.EnsureCreated();
+
+ _keepAlive = new SqliteConnection($"Data Source={dbName}");
+ _keepAlive.Open();
+
+ _repository = new SqlitePersonRepository(database);
+ }
+
+ public void Dispose()
+ {
+ _keepAlive.Dispose();
+ }
+
+ [Fact]
+ public async Task CreateAsync_ReturnsPersonWithGeneratedId()
+ {
+ var person = await _repository.CreateAsync("Alice");
+
+ person.Id.Should().BeGreaterThan(0);
+ person.Name.Should().Be("Alice");
+ person.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(5));
+ }
+
+ [Fact]
+ public async Task GetByNameAsync_ExistingPerson_ReturnsPerson()
+ {
+ await _repository.CreateAsync("Bob");
+
+ var result = await _repository.GetByNameAsync("Bob");
+
+ result.Should().NotBeNull();
+ result!.Name.Should().Be("Bob");
+ }
+
+ [Fact]
+ public async Task GetByNameAsync_CaseInsensitive()
+ {
+ await _repository.CreateAsync("Charlie");
+
+ var result = await _repository.GetByNameAsync("charlie");
+
+ result.Should().NotBeNull();
+ result!.Name.Should().Be("Charlie");
+ }
+
+ [Fact]
+ public async Task GetByNameAsync_NotFound_ReturnsNull()
+ {
+ var result = await _repository.GetByNameAsync("Nobody");
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ReturnsAllOrderedByName()
+ {
+ await _repository.CreateAsync("Zara");
+ await _repository.CreateAsync("Alice");
+ await _repository.CreateAsync("Mike");
+
+ var all = await _repository.GetAllAsync();
+
+ all.Should().HaveCount(3);
+ all[0].Name.Should().Be("Alice");
+ all[1].Name.Should().Be("Mike");
+ all[2].Name.Should().Be("Zara");
+ }
+
+ [Fact]
+ public async Task GetOrCreateAsync_ExistingPerson_ReturnsExisting()
+ {
+ var created = await _repository.CreateAsync("Diana");
+
+ var result = await _repository.GetOrCreateAsync("diana");
+
+ result.Id.Should().Be(created.Id);
+ result.Name.Should().Be("Diana");
+ }
+
+ [Fact]
+ public async Task GetOrCreateAsync_NewPerson_CreatesAndReturns()
+ {
+ var result = await _repository.GetOrCreateAsync("Eve");
+
+ result.Id.Should().BeGreaterThan(0);
+ result.Name.Should().Be("Eve");
+
+ var verify = await _repository.GetByNameAsync("Eve");
+ verify.Should().NotBeNull();
+ }
+}
diff --git a/tests/Writegeist.Tests/Persistence/SqlitePostRepositoryTests.cs b/tests/Writegeist.Tests/Persistence/SqlitePostRepositoryTests.cs
new file mode 100644
index 0000000..cbdd8cb
--- /dev/null
+++ b/tests/Writegeist.Tests/Persistence/SqlitePostRepositoryTests.cs
@@ -0,0 +1,158 @@
+using FluentAssertions;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Configuration;
+using NSubstitute;
+using Writegeist.Core.Models;
+using Writegeist.Infrastructure.Persistence;
+
+namespace Writegeist.Tests.Persistence;
+
+public class SqlitePostRepositoryTests : IDisposable
+{
+ private readonly SqlitePostRepository _repository;
+ private readonly SqliteConnection _keepAlive;
+ private readonly SqliteDatabase _database;
+
+ public SqlitePostRepositoryTests()
+ {
+ var dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared";
+ var config = Substitute.For();
+ config["Writegeist:DatabasePath"].Returns(dbName);
+
+ _database = new SqliteDatabase(config);
+ _database.EnsureCreated();
+
+ _keepAlive = new SqliteConnection($"Data Source={dbName}");
+ _keepAlive.Open();
+
+ // Insert a test person
+ using var cmd = _keepAlive.CreateCommand();
+ cmd.CommandText = "INSERT INTO persons (name) VALUES ('TestPerson');";
+ cmd.ExecuteNonQuery();
+
+ _repository = new SqlitePostRepository(_database);
+ }
+
+ public void Dispose()
+ {
+ _keepAlive.Dispose();
+ }
+
+ [Fact]
+ public async Task AddAsync_NewPost_ReturnsTrueAndStoresPost()
+ {
+ var post = new RawPost
+ {
+ PersonId = 1,
+ Platform = Platform.LinkedIn,
+ Content = "Hello world"
+ };
+
+ var result = await _repository.AddAsync(post);
+
+ result.Should().BeTrue();
+ post.ContentHash.Should().NotBeNullOrEmpty();
+
+ var posts = await _repository.GetByPersonIdAsync(1);
+ posts.Should().HaveCount(1);
+ posts[0].Content.Should().Be("Hello world");
+ }
+
+ [Fact]
+ public async Task AddAsync_DuplicateContent_ReturnsFalse()
+ {
+ var post1 = new RawPost
+ {
+ PersonId = 1,
+ Platform = Platform.X,
+ Content = "Same content"
+ };
+ var post2 = new RawPost
+ {
+ PersonId = 1,
+ Platform = Platform.X,
+ Content = "Same content"
+ };
+
+ var first = await _repository.AddAsync(post1);
+ var second = await _repository.AddAsync(post2);
+
+ first.Should().BeTrue();
+ second.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task AddAsync_ContentHashIsNormalised()
+ {
+ var post1 = new RawPost
+ {
+ PersonId = 1,
+ Platform = Platform.LinkedIn,
+ Content = " Hello World "
+ };
+ var post2 = new RawPost
+ {
+ PersonId = 1,
+ Platform = Platform.LinkedIn,
+ Content = "hello world"
+ };
+
+ await _repository.AddAsync(post1);
+ var isDuplicate = await _repository.AddAsync(post2);
+
+ isDuplicate.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task GetByPersonIdAsync_ReturnsAllPostsForPerson()
+ {
+ await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.LinkedIn, Content = "First" });
+ await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.X, Content = "Second" });
+
+ var posts = await _repository.GetByPersonIdAsync(1);
+
+ posts.Should().HaveCount(2);
+ posts.Select(p => p.Content).Should().Contain("First").And.Contain("Second");
+ }
+
+ [Fact]
+ public async Task GetCountByPersonIdAsync_ReturnsCorrectCount()
+ {
+ await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.LinkedIn, Content = "One" });
+ await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.X, Content = "Two" });
+ await _repository.AddAsync(new RawPost { PersonId = 1, Platform = Platform.Instagram, Content = "Three" });
+
+ var count = await _repository.GetCountByPersonIdAsync(1);
+
+ count.Should().Be(3);
+ }
+
+ [Fact]
+ public async Task ExistsByHashAsync_ExistingHash_ReturnsTrue()
+ {
+ var post = new RawPost { PersonId = 1, Platform = Platform.LinkedIn, Content = "Check me" };
+ await _repository.AddAsync(post);
+
+ var exists = await _repository.ExistsByHashAsync(1, post.ContentHash);
+
+ exists.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task ExistsByHashAsync_NonExistingHash_ReturnsFalse()
+ {
+ var exists = await _repository.ExistsByHashAsync(1, "nonexistenthash");
+
+ exists.Should().BeFalse();
+ }
+
+ [Fact]
+ public void ComputeHash_IsSha256OfNormalisedContent()
+ {
+ var hash1 = SqlitePostRepository.ComputeHash(" Hello World ");
+ var hash2 = SqlitePostRepository.ComputeHash("hello world");
+
+ hash1.Should().Be(hash2);
+ hash1.Should().HaveLength(64); // SHA-256 hex string
+ }
+}
diff --git a/tests/Writegeist.Tests/Persistence/SqliteStyleProfileRepositoryTests.cs b/tests/Writegeist.Tests/Persistence/SqliteStyleProfileRepositoryTests.cs
new file mode 100644
index 0000000..d70d70e
--- /dev/null
+++ b/tests/Writegeist.Tests/Persistence/SqliteStyleProfileRepositoryTests.cs
@@ -0,0 +1,114 @@
+using FluentAssertions;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Configuration;
+using NSubstitute;
+using Writegeist.Core.Models;
+using Writegeist.Infrastructure.Persistence;
+
+namespace Writegeist.Tests.Persistence;
+
+public class SqliteStyleProfileRepositoryTests : IDisposable
+{
+ private readonly SqliteStyleProfileRepository _repository;
+ private readonly SqliteConnection _keepAlive;
+
+ public SqliteStyleProfileRepositoryTests()
+ {
+ var dbName = $"file:test_{Guid.NewGuid():N}?mode=memory&cache=shared";
+ var config = Substitute.For();
+ config["Writegeist:DatabasePath"].Returns(dbName);
+
+ var database = new SqliteDatabase(config);
+ database.EnsureCreated();
+
+ _keepAlive = new SqliteConnection($"Data Source={dbName}");
+ _keepAlive.Open();
+
+ using var cmd = _keepAlive.CreateCommand();
+ cmd.CommandText = "INSERT INTO persons (name) VALUES ('TestPerson');";
+ cmd.ExecuteNonQuery();
+
+ _repository = new SqliteStyleProfileRepository(database);
+ }
+
+ public void Dispose()
+ {
+ _keepAlive.Dispose();
+ }
+
+ [Fact]
+ public async Task SaveAsync_ReturnsProfileWithGeneratedId()
+ {
+ var profile = new StyleProfile
+ {
+ PersonId = 1,
+ ProfileJson = "{\"tone\": \"casual\"}",
+ Provider = "anthropic",
+ Model = "claude-sonnet-4-20250514"
+ };
+
+ var saved = await _repository.SaveAsync(profile);
+
+ saved.Id.Should().BeGreaterThan(0);
+ saved.PersonId.Should().Be(1);
+ saved.ProfileJson.Should().Be("{\"tone\": \"casual\"}");
+ saved.Provider.Should().Be("anthropic");
+ saved.Model.Should().Be("claude-sonnet-4-20250514");
+ saved.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(5));
+ }
+
+ [Fact]
+ public async Task GetLatestByPersonIdAsync_ReturnsLatestProfile()
+ {
+ await _repository.SaveAsync(new StyleProfile
+ {
+ PersonId = 1, ProfileJson = "{\"v\": 1}", Provider = "anthropic", Model = "m1"
+ });
+ var latest = await _repository.SaveAsync(new StyleProfile
+ {
+ PersonId = 1, ProfileJson = "{\"v\": 2}", Provider = "openai", Model = "m2"
+ });
+
+ var result = await _repository.GetLatestByPersonIdAsync(1);
+
+ result.Should().NotBeNull();
+ result!.Id.Should().Be(latest.Id);
+ result.ProfileJson.Should().Be("{\"v\": 2}");
+ }
+
+ [Fact]
+ public async Task GetLatestByPersonIdAsync_NoProfiles_ReturnsNull()
+ {
+ var result = await _repository.GetLatestByPersonIdAsync(1);
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetAllByPersonIdAsync_ReturnsAllProfilesDescending()
+ {
+ var first = await _repository.SaveAsync(new StyleProfile
+ {
+ PersonId = 1, ProfileJson = "{\"v\": 1}", Provider = "anthropic", Model = "m1"
+ });
+ var second = await _repository.SaveAsync(new StyleProfile
+ {
+ PersonId = 1, ProfileJson = "{\"v\": 2}", Provider = "openai", Model = "m2"
+ });
+
+ var all = await _repository.GetAllByPersonIdAsync(1);
+
+ all.Should().HaveCount(2);
+ // Ordered by created_at DESC, id DESC — second should come first
+ all[0].Id.Should().Be(second.Id);
+ all[1].Id.Should().Be(first.Id);
+ }
+
+ [Fact]
+ public async Task GetAllByPersonIdAsync_NoProfiles_ReturnsEmpty()
+ {
+ var all = await _repository.GetAllByPersonIdAsync(999);
+
+ all.Should().BeEmpty();
+ }
+}
diff --git a/tests/Writegeist.Tests/PlatformConventionsTests.cs b/tests/Writegeist.Tests/PlatformConventionsTests.cs
new file mode 100644
index 0000000..073cd0a
--- /dev/null
+++ b/tests/Writegeist.Tests/PlatformConventionsTests.cs
@@ -0,0 +1,68 @@
+using FluentAssertions;
+using Writegeist.Core;
+using Writegeist.Core.Models;
+
+namespace Writegeist.Tests;
+
+public class PlatformConventionsTests
+{
+ [Fact]
+ public void GetRules_LinkedIn_ReturnsCorrectRules()
+ {
+ var rules = PlatformConventions.GetRules(Platform.LinkedIn);
+
+ rules.Name.Should().Be("LinkedIn");
+ rules.MaxCharacters.Should().Be(3000);
+ rules.RecommendedMaxLength.Should().Be(1500);
+ rules.SupportsHashtags.Should().BeTrue();
+ rules.RecommendedHashtagCount.Should().BeInRange(3, 5);
+ rules.HashtagPlacement.Should().Be("end");
+ rules.SupportsEmoji.Should().BeTrue();
+ rules.EmojiGuidance.Should().ContainEquivalentOf("sparingly");
+ }
+
+ [Fact]
+ public void GetRules_X_ReturnsCorrectRules()
+ {
+ var rules = PlatformConventions.GetRules(Platform.X);
+
+ rules.Name.Should().Be("X");
+ rules.MaxCharacters.Should().Be(280);
+ rules.RecommendedMaxLength.Should().Be(280);
+ rules.SupportsHashtags.Should().BeTrue();
+ rules.RecommendedHashtagCount.Should().BeInRange(1, 2);
+ rules.HashtagPlacement.Should().Be("inline");
+ rules.SupportsEmoji.Should().BeTrue();
+ rules.EmojiGuidance.Should().ContainEquivalentOf("moderate");
+ }
+
+ [Fact]
+ public void GetRules_Instagram_ReturnsCorrectRules()
+ {
+ var rules = PlatformConventions.GetRules(Platform.Instagram);
+
+ rules.Name.Should().Be("Instagram");
+ rules.MaxCharacters.Should().Be(2200);
+ rules.RecommendedMaxLength.Should().Be(750);
+ rules.SupportsHashtags.Should().BeTrue();
+ rules.RecommendedHashtagCount.Should().BeGreaterThanOrEqualTo(30);
+ rules.HashtagPlacement.Should().Contain("end");
+ rules.SupportsEmoji.Should().BeTrue();
+ rules.EmojiGuidance.Should().ContainEquivalentOf("freely");
+ }
+
+ [Fact]
+ public void GetRules_Facebook_ReturnsCorrectRules()
+ {
+ var rules = PlatformConventions.GetRules(Platform.Facebook);
+
+ rules.Name.Should().Be("Facebook");
+ rules.MaxCharacters.Should().Be(63206);
+ rules.RecommendedMaxLength.Should().Be(600);
+ rules.SupportsHashtags.Should().BeTrue();
+ rules.RecommendedHashtagCount.Should().BeInRange(1, 3);
+ rules.HashtagPlacement.Should().Be("end");
+ rules.SupportsEmoji.Should().BeTrue();
+ rules.EmojiGuidance.Should().ContainEquivalentOf("moderate");
+ }
+}
diff --git a/tests/Writegeist.Tests/Services/PostGeneratorTests.cs b/tests/Writegeist.Tests/Services/PostGeneratorTests.cs
new file mode 100644
index 0000000..f4b461e
--- /dev/null
+++ b/tests/Writegeist.Tests/Services/PostGeneratorTests.cs
@@ -0,0 +1,161 @@
+using FluentAssertions;
+using NSubstitute;
+using Writegeist.Core;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+using Writegeist.Core.Services;
+
+namespace Writegeist.Tests.Services;
+
+public class PostGeneratorTests
+{
+ private readonly IStyleProfileRepository _styleProfileRepository = Substitute.For();
+ private readonly IDraftRepository _draftRepository = Substitute.For();
+ private readonly ILlmProvider _llmProvider = Substitute.For();
+ private readonly PostGenerator _sut;
+
+ public PostGeneratorTests()
+ {
+ _sut = new PostGenerator(_styleProfileRepository, _draftRepository, _llmProvider);
+ }
+
+ [Fact]
+ public async Task GenerateAsync_WithProfile_BuildsPromptAndStoresDraft()
+ {
+ // Arrange
+ var personId = 1;
+ var profile = new StyleProfile
+ {
+ Id = 10,
+ PersonId = personId,
+ ProfileJson = "{\"tone\": \"professional\"}"
+ };
+
+ _styleProfileRepository.GetLatestByPersonIdAsync(personId).Returns(profile);
+ _llmProvider.GeneratePostAsync(Arg.Any()).Returns("Generated post content");
+ _llmProvider.ProviderName.Returns("anthropic");
+ _llmProvider.ModelName.Returns("claude-sonnet-4-20250514");
+
+ _draftRepository.SaveAsync(Arg.Any())
+ .Returns(callInfo =>
+ {
+ var draft = callInfo.Arg();
+ draft.Id = 99;
+ return draft;
+ });
+
+ // Act
+ var result = await _sut.GenerateAsync(personId, Platform.LinkedIn, "My topic");
+
+ // Assert
+ result.Id.Should().Be(99);
+ result.PersonId.Should().Be(personId);
+ result.StyleProfileId.Should().Be(10);
+ result.Platform.Should().Be(Platform.LinkedIn);
+ result.Topic.Should().Be("My topic");
+ result.Content.Should().Be("Generated post content");
+ result.ParentDraftId.Should().BeNull();
+ result.Provider.Should().Be("anthropic");
+
+ await _llmProvider.Received(1).GeneratePostAsync(Arg.Is(p =>
+ p.Contains("My topic") && p.Contains("professional") && p.Contains("LinkedIn")));
+ }
+
+ [Fact]
+ public async Task GenerateAsync_WithNoProfile_ThrowsInvalidOperationException()
+ {
+ _styleProfileRepository.GetLatestByPersonIdAsync(1).Returns((StyleProfile?)null);
+
+ var act = () => _sut.GenerateAsync(1, Platform.LinkedIn, "topic");
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*No style profile found*");
+ }
+
+ [Fact]
+ public async Task RefineAsync_WithExistingDraft_BuildsPromptAndStoresLinkedDraft()
+ {
+ // Arrange
+ var previousDraft = new GeneratedDraft
+ {
+ Id = 5,
+ PersonId = 1,
+ StyleProfileId = 10,
+ Platform = Platform.X,
+ Topic = "Original topic",
+ Content = "Original draft content"
+ };
+
+ var profile = new StyleProfile
+ {
+ Id = 10,
+ PersonId = 1,
+ ProfileJson = "{\"tone\": \"casual\"}"
+ };
+
+ _draftRepository.GetByIdAsync(5).Returns(previousDraft);
+ _styleProfileRepository.GetLatestByPersonIdAsync(1).Returns(profile);
+ _llmProvider.RefinePostAsync(Arg.Any()).Returns("Refined post content");
+ _llmProvider.ProviderName.Returns("openai");
+ _llmProvider.ModelName.Returns("gpt-4o");
+
+ _draftRepository.SaveAsync(Arg.Any())
+ .Returns(callInfo =>
+ {
+ var draft = callInfo.Arg();
+ draft.Id = 6;
+ return draft;
+ });
+
+ // Act
+ var result = await _sut.RefineAsync(5, "Make it shorter");
+
+ // Assert
+ result.Id.Should().Be(6);
+ result.ParentDraftId.Should().Be(5);
+ result.Feedback.Should().Be("Make it shorter");
+ result.Content.Should().Be("Refined post content");
+ result.Platform.Should().Be(Platform.X);
+ result.Topic.Should().Be("Original topic");
+
+ await _llmProvider.Received(1).RefinePostAsync(Arg.Is(p =>
+ p.Contains("Original draft content") && p.Contains("Make it shorter") && p.Contains("casual")));
+ }
+
+ [Fact]
+ public async Task RefineAsync_WithNonExistentDraft_ThrowsInvalidOperationException()
+ {
+ _draftRepository.GetByIdAsync(999).Returns((GeneratedDraft?)null);
+
+ var act = () => _sut.RefineAsync(999, "feedback");
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*Draft not found*");
+ }
+
+ [Fact]
+ public void BuildGenerationPrompt_IncludesAllComponents()
+ {
+ var rules = PlatformConventions.GetRules(Platform.LinkedIn);
+ var prompt = PostGenerator.BuildGenerationPrompt("{\"style\":true}", rules, "My topic here");
+
+ prompt.Should().Contain("ghostwriter");
+ prompt.Should().Contain("{\"style\":true}");
+ prompt.Should().Contain("LinkedIn");
+ prompt.Should().Contain("My topic here");
+ prompt.Should().Contain("3000");
+ }
+
+ [Fact]
+ public void BuildRefinementPrompt_IncludesAllComponents()
+ {
+ var rules = PlatformConventions.GetRules(Platform.X);
+ var prompt = PostGenerator.BuildRefinementPrompt("{\"style\":true}", rules, "Draft text", "Make punchier");
+
+ prompt.Should().Contain("refining a draft");
+ prompt.Should().Contain("{\"style\":true}");
+ prompt.Should().Contain("Draft text");
+ prompt.Should().Contain("Make punchier");
+ prompt.Should().Contain("280");
+ }
+}
diff --git a/tests/Writegeist.Tests/Services/StyleAnalyserTests.cs b/tests/Writegeist.Tests/Services/StyleAnalyserTests.cs
new file mode 100644
index 0000000..c8b939f
--- /dev/null
+++ b/tests/Writegeist.Tests/Services/StyleAnalyserTests.cs
@@ -0,0 +1,112 @@
+using FluentAssertions;
+using NSubstitute;
+using Writegeist.Core.Interfaces;
+using Writegeist.Core.Models;
+using Writegeist.Core.Services;
+
+namespace Writegeist.Tests.Services;
+
+public class StyleAnalyserTests
+{
+ private readonly IPostRepository _postRepository = Substitute.For();
+ private readonly IStyleProfileRepository _styleProfileRepository = Substitute.For();
+ private readonly ILlmProvider _llmProvider = Substitute.For();
+ private readonly StyleAnalyser _sut;
+
+ public StyleAnalyserTests()
+ {
+ _sut = new StyleAnalyser(_postRepository, _styleProfileRepository, _llmProvider);
+ }
+
+ [Fact]
+ public async Task AnalyseAsync_WithPosts_SendsPromptToLlmAndStoresProfile()
+ {
+ // Arrange
+ var personId = 1;
+ var posts = new List
+ {
+ new() { Id = 1, PersonId = personId, Content = "First post content", Platform = Platform.LinkedIn },
+ new() { Id = 2, PersonId = personId, Content = "Second post content", Platform = Platform.LinkedIn }
+ };
+
+ _postRepository.GetByPersonIdAsync(personId).Returns(posts);
+ _llmProvider.AnalyseStyleAsync(Arg.Any()).Returns("{\"overall_voice_summary\": \"test\"}");
+ _llmProvider.ProviderName.Returns("anthropic");
+ _llmProvider.ModelName.Returns("claude-sonnet-4-20250514");
+
+ _styleProfileRepository.SaveAsync(Arg.Any())
+ .Returns(callInfo =>
+ {
+ var profile = callInfo.Arg();
+ profile.Id = 42;
+ return profile;
+ });
+
+ // Act
+ var result = await _sut.AnalyseAsync(personId);
+
+ // Assert
+ result.Id.Should().Be(42);
+ result.PersonId.Should().Be(personId);
+ result.ProfileJson.Should().Be("{\"overall_voice_summary\": \"test\"}");
+ result.Provider.Should().Be("anthropic");
+ result.Model.Should().Be("claude-sonnet-4-20250514");
+
+ await _llmProvider.Received(1).AnalyseStyleAsync(Arg.Is(p =>
+ p.Contains("First post content") && p.Contains("Second post content")));
+ await _styleProfileRepository.Received(1).SaveAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task AnalyseAsync_WithNoPosts_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ _postRepository.GetByPersonIdAsync(1).Returns(new List());
+
+ // Act
+ var act = () => _sut.AnalyseAsync(1);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*No posts found*");
+ }
+
+ [Fact]
+ public void BuildStyleAnalysisPrompt_IncludesAllPostContent()
+ {
+ // Arrange
+ var posts = new List
+ {
+ new() { Content = "Post one" },
+ new() { Content = "Post two" },
+ new() { Content = "Post three" }
+ };
+
+ // Act
+ var prompt = StyleAnalyser.BuildStyleAnalysisPrompt(posts);
+
+ // Assert
+ prompt.Should().Contain("Post one");
+ prompt.Should().Contain("Post two");
+ prompt.Should().Contain("Post three");
+ prompt.Should().Contain("writing style analyst");
+ prompt.Should().Contain("overall_voice_summary");
+ }
+
+ [Fact]
+ public void BuildStyleAnalysisPrompt_SeparatesPostsWithDividers()
+ {
+ // Arrange
+ var posts = new List
+ {
+ new() { Content = "Alpha" },
+ new() { Content = "Beta" }
+ };
+
+ // Act
+ var prompt = StyleAnalyser.BuildStyleAnalysisPrompt(posts);
+
+ // Assert
+ prompt.Should().Contain("Alpha\n\n---\n\nBeta");
+ }
+}
diff --git a/tests/Writegeist.Tests/Writegeist.Tests.csproj b/tests/Writegeist.Tests/Writegeist.Tests.csproj
new file mode 100644
index 0000000..a51d459
--- /dev/null
+++ b/tests/Writegeist.Tests/Writegeist.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file