Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
046bc3e
feat: apply stitch-inspired frontend ui refresh
Apr 29, 2026
4d006f8
chore: localize frontend ui to Chinese
Apr 29, 2026
59c4d19
docs: add round 1 external access scope
Apr 29, 2026
722d8fc
docs: add round 1 external access scope
Apr 29, 2026
4a67c72
fix: route frontend api through runtime proxy
Apr 29, 2026
e2bec7f
fix: preserve demo token across frontend navigation
Apr 29, 2026
a7e29c1
feat: add platform dropdown and external smoke validation
Apr 29, 2026
21565f5
feat: add personal production workflow
Apr 30, 2026
7fdbb8f
fix: align personal production ci gates
Apr 30, 2026
b936328
docs: record personal production release decision
Apr 30, 2026
30ef894
docs: clarify release report head evidence
Apr 30, 2026
8677393
test: stabilize personal workflow e2e onboarding
Apr 30, 2026
110ccf2
docs: record external smoke readiness evidence
Apr 30, 2026
e1f0e61
fix: clear project context for new analysis link
May 1, 2026
d636da9
fix: localize onboarding form copy
May 1, 2026
8018eef
fix: explain zero-signal collection states
May 1, 2026
c3fc14b
chore: close personal production release-freeze workspace
May 4, 2026
7da2021
feat: add mac mini owner-only local production
May 12, 2026
d2c044f
chore: localize production UI copy
May 12, 2026
8ed13c1
fix: authorize server-rendered production pages
May 12, 2026
73d13a9
fix: complete local production lifecycle closeout
May 12, 2026
4e509dd
fix: disable local production owner password gate
May 14, 2026
a9dbd8f
fix: reflect local platform configuration status
May 14, 2026
eae44b2
feat: enable Product Hunt production collection
May 15, 2026
f380b5c
feat: add gated real LLM classification
May 15, 2026
81a9ccc
fix: gate real embedding and product hunt topic search
May 17, 2026
343fc7c
fix: allow gated real modes in frontend validation
May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ LLM_API_KEY=
LLM_BASE_URL=
LLM_MODEL=
EMBEDDING_MODEL=
SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING=

REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
Expand Down
20 changes: 20 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
DATABASE_URL=
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_PASSWORD=
SIGNALFORGE_REQUIRE_OWNER_AUTH=false

LLM_API_KEY=
LLM_BASE_URL=
LLM_MODEL=
EMBEDDING_MODEL=
SIGNALFORGE_ALLOW_REAL_LLM_SMOKE=
SIGNALFORGE_ALLOW_REAL_LLM_PROCESSING=
SIGNALFORGE_ALLOW_REAL_EMBEDDING_SMOKE=

REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
REDDIT_USER_AGENT=
PRODUCT_HUNT_TOKEN=
SIGNALFORGE_ALLOW_REAL_PLATFORM_SMOKE=
SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE=
2 changes: 2 additions & 0 deletions .github/workflows/ci-acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate docs
run: python3 scripts/validate_docs.py
- name: Validate acceptance assets
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.env
.env.*
!.env.example
!.env.production.example

node_modules/
.venv/
Expand All @@ -10,8 +11,10 @@ __pycache__/
.mypy_cache/

.next/
apps/web/test-results/
dist/
build/
backups/

.DS_Store
*.log
58 changes: 58 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# SignalForge Agent Instructions

## Hermes/Codex local skills

This repo uses repo-local Hermes/Codex skills stored under:

`docs/agents/skills/hermes-codex/`

These skills are also installed globally under `~/.codex/skills/hermes-*` for Codex discovery.
The repo-local copies remain the SignalForge source of truth. Do not resync, overwrite, or remove
global copies unless the owner explicitly approves a separate global-sync step.

Use these skills when the user names them directly or the request matches their trigger:

- `hermes-diagnose` - bugs, failures, regressions, signal/data/connector/debug work.
- `hermes-tdd` - test-first feature or bug work through public behavior.
- `hermes-zoom-out` - system maps and unfamiliar code areas.
- `hermes-grill-with-docs` - plan review, domain language, CONTEXT/ADR proposals.
- `hermes-improve-codebase-architecture` - architecture friction and deep-module candidates.
- `hermes-to-prd` - PRD draft from current context.
- `hermes-to-issues` - local Markdown issue slices from a PRD or plan.
- `hermes-triage` - local Markdown triage notes and agent briefs.
- `hermes-write-a-skill` - create or update SignalForge-local Hermes/Codex skills.

## Mandatory safety protocol

Before any write action, state:

- Task judgment
- Current goal
- Confirmed facts
- Risks
- Recommended approach
- Change boundary
- Implementation steps
- Verification standard

After implementation, report:

- Modified files
- Purpose of changes
- Commands run
- Verification result
- Residual issues
- Rollback method

Default to draft-only for PRDs, issues, triage notes, CONTEXT changes, and ADRs. Drafts live
under `docs/agents/drafts/` unless the user approves another path.

Do not perform these actions without explicit owner approval:

- Global skill installation or sync to `~/.codex/skills`
- `git push`, `git reset`, `git clean`, destructive delete commands
- Issue tracker writes, label changes, comments, or closes
- Production logic changes
- Dependency installation
- Hook, CI, deployment, secret, credential, or permission changes
- Real platform, real LLM, real embedding, or production smoke actions
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ If `http://localhost:3000` is reachable, `scripts/validate_frontend_mvp.py` also
Expected Phase 6 behavior:

- Required frontend pages exist.
- The UI includes Open Source evidence text and a high value marker.
- The UI includes Open Source / 打开来源 evidence text and a High Value / 高价值 marker.
- The frontend UI is localized to Chinese while preserving backend enum values, URL paths, and API request parameters.
- Settings does not render `encrypted_payload`.
- Reports expose markdown and csv export controls.
- The frontend API client accepts only SignalForge backend-relative paths.
Expand Down
53 changes: 53 additions & 0 deletions apps/api/app/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from __future__ import annotations

import hmac
import os

from fastapi import Request, status
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse, Response

from app.api.errors import error_body


OWNER_TOKEN_HEADER = "X-SignalForge-Owner-Token"


def env_flag_enabled(name: str) -> bool:
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}


class OwnerAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
if not _requires_owner_auth(request):
return await call_next(request)

expected_token = os.getenv("SIGNALFORGE_OWNER_API_TOKEN", "").strip()
if not expected_token:
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content=error_body(
"owner_auth_not_configured",
"Owner API authentication is required but not configured.",
),
)

provided_token = request.headers.get(OWNER_TOKEN_HEADER, "")
if not hmac.compare_digest(provided_token, expected_token):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content=error_body(
"owner_auth_required",
"Owner API authentication is required.",
),
)

return await call_next(request)


def _requires_owner_auth(request: Request) -> bool:
if not env_flag_enabled("SIGNALFORGE_REQUIRE_OWNER_AUTH"):
return False
if request.url.path == "/health":
return False
return request.url.path.startswith("/api/")
8 changes: 8 additions & 0 deletions apps/api/app/api/routes/collection_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
from fastapi import APIRouter, Body
from sqlalchemy import select

from app.api.auth import env_flag_enabled
from app.api.errors import phase_not_available
from app.api.deps import DbSession
from app.connectors.credential_resolver import REAL_PLATFORM_SMOKE_ENV
from app.db.models import CollectionLog
from app.schemas.collection_logs import CollectionLogRead
from app.schemas.collection_jobs import (
Expand All @@ -33,6 +35,8 @@ def create_collection_job(
execution_mode = _execution_mode(payload)
if execution_mode not in PHASE_4_EXECUTION_MODES:
raise phase_not_available(PHASE_4_FORBIDDEN_MODE_MESSAGE)
if _real_platform_write_required(execution_mode) and not env_flag_enabled("SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE"):
raise phase_not_available("Real platform collection write requires SIGNALFORGE_ALLOW_REAL_PLATFORM_WRITE=true.")

job = execute_collection(db, project_id=project_id, execution_mode=execution_mode)

Expand Down Expand Up @@ -64,3 +68,7 @@ def _execution_mode(payload: CollectionJobCreateRequest | None) -> str:
raise phase_not_available(PHASE_4_FORBIDDEN_MODE_MESSAGE)
normalized = mode.strip()
return normalized or DEFAULT_EXECUTION_MODE


def _real_platform_write_required(execution_mode: str) -> bool:
return execution_mode in {"reddit", "product_hunt", "p0_real"} and env_flag_enabled(REAL_PLATFORM_SMOKE_ENV)
35 changes: 35 additions & 0 deletions apps/api/app/api/routes/production_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

from uuid import UUID

from fastapi import APIRouter, status

from app.api.deps import DbSession, PaginationDep
from app.schemas.common import PaginatedResponse
from app.schemas.production_runs import ProductionRunCloseout, ProductionRunCreate, ProductionRunRead
from app.services import production_runs as production_run_service
from app.services.common import paginated_response


router = APIRouter(prefix="/api/production/runs", tags=["production"])


@router.get("", response_model=PaginatedResponse[ProductionRunRead])
def list_production_runs(db: DbSession, pagination: PaginationDep) -> PaginatedResponse[ProductionRunRead]:
runs, total = production_run_service.list_runs(db, pagination)
return paginated_response(runs, pagination, total)


@router.post("", response_model=ProductionRunRead, status_code=status.HTTP_201_CREATED)
def create_production_run(payload: ProductionRunCreate, db: DbSession) -> ProductionRunRead:
return production_run_service.create_run(db, payload)


@router.get("/{run_id}", response_model=ProductionRunRead)
def get_production_run(run_id: UUID, db: DbSession) -> ProductionRunRead:
return production_run_service.get_run(db, run_id)


@router.post("/{run_id}/closeout", response_model=ProductionRunRead)
def closeout_production_run(run_id: UUID, payload: ProductionRunCloseout, db: DbSession) -> ProductionRunRead:
return production_run_service.closeout_run(db, run_id, payload)
11 changes: 10 additions & 1 deletion apps/api/app/api/routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

from fastapi import APIRouter

from app.api.errors import not_found
from app.api.deps import DbSession
from app.schemas.settings import CredentialStatusResponse, PlatformsResponse
from app.schemas.settings import CredentialStatusResponse, PlatformEnvTestResponse, PlatformsResponse
from app.services import settings as settings_service


Expand All @@ -18,3 +19,11 @@ def list_platforms(db: DbSession) -> PlatformsResponse:
@router.get("/credentials/status", response_model=CredentialStatusResponse)
def list_credential_statuses(db: DbSession) -> CredentialStatusResponse:
return CredentialStatusResponse(credentials=settings_service.list_credential_statuses(db))


@router.post("/platforms/{platform}/test", response_model=PlatformEnvTestResponse)
def test_platform_env_status(platform: str) -> PlatformEnvTestResponse:
try:
return settings_service.test_platform_env_status(platform)
except ValueError as exc:
raise not_found("Unsupported platform", {"platform": platform}) from exc
20 changes: 19 additions & 1 deletion apps/api/app/config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
import os


DEFAULT_CORS_ALLOW_ORIGINS = ("http://localhost:3000", "http://127.0.0.1:3000")


def parse_cors_allow_origins(value: str | None) -> tuple[str, ...]:
if value is None:
return DEFAULT_CORS_ALLOW_ORIGINS

origins: list[str] = []
for origin in value.split(","):
normalized = origin.strip().rstrip("/")
if normalized:
origins.append(normalized)
return tuple(origins) or DEFAULT_CORS_ALLOW_ORIGINS


@dataclass(frozen=True)
class Settings:
database_url: str = os.getenv("DATABASE_URL", "")
redis_url: str = os.getenv("REDIS_URL", "")
cors_allow_origins: tuple[str, ...] = field(
default_factory=lambda: parse_cors_allow_origins(os.getenv("CORS_ALLOW_ORIGINS"))
)


settings = Settings()
47 changes: 38 additions & 9 deletions apps/api/app/connectors/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ def __init__(self) -> None:
super().__init__(self.platform)

def collect(self, config: ProjectCollectionConfig) -> ConnectorResult:
item_prefix = str(config.project_id)
items = [
NormalizedRawItem(
platform=self.platform,
platform_item_id="mock-001",
platform_item_id=f"{item_prefix}-mock-001",
source_url="https://example.local/mock/mock-001",
author_hash="mock-author-alpha",
content_text="SignalForge mock item for wallet onboarding feedback.",
content_text="I need a less confusing onboarding flow for wallet setup and position tracking.",
content_excerpt="Wallet onboarding feedback",
normalized_text="signalforge mock item for wallet onboarding feedback",
normalized_text="i need a less confusing onboarding flow for wallet setup and position tracking",
language="en",
engagement={"score": 12, "comments": 3},
keyword_hits=["wallet", "onboarding"],
Expand All @@ -35,12 +36,12 @@ def collect(self, config: ProjectCollectionConfig) -> ConnectorResult:
),
NormalizedRawItem(
platform=self.platform,
platform_item_id="mock-002",
platform_item_id=f"{item_prefix}-mock-002",
source_url="https://example.local/mock/mock-002",
author_hash="mock-author-beta",
content_text="Mock complaint about pricing clarity during checkout.",
content_text="Looking for an alternative to expensive market research tools with clearer pricing.",
content_excerpt="Pricing clarity during checkout",
normalized_text="mock complaint about pricing clarity during checkout",
normalized_text="looking for an alternative to expensive market research tools with clearer pricing",
language="en",
engagement={"score": 8, "comments": 1},
keyword_hits=["pricing", "checkout"],
Expand All @@ -49,18 +50,46 @@ def collect(self, config: ProjectCollectionConfig) -> ConnectorResult:
),
NormalizedRawItem(
platform=self.platform,
platform_item_id="mock-003",
platform_item_id=f"{item_prefix}-mock-003",
source_url="https://example.local/mock/mock-003",
author_hash="mock-author-gamma",
content_text="Static request for better integration docs.",
content_text="We need better integration docs before we can adopt this workflow.",
content_excerpt="Better integration documentation",
normalized_text="static request for better integration docs",
normalized_text="we need better integration docs before we can adopt this workflow",
language="en",
engagement={"score": 15, "comments": 5},
keyword_hits=["integration", "documentation"],
raw_payload={"source": "static_mock", "sequence": 3},
created_at_source=datetime(2026, 1, 3, 14, 15, tzinfo=UTC),
),
NormalizedRawItem(
platform=self.platform,
platform_item_id=f"{item_prefix}-mock-004",
source_url="https://example.local/mock/mock-004",
author_hash="mock-author-delta",
content_text="The current signal workflow is too noisy and hard to turn into next actions.",
content_excerpt="Signal workflow is too noisy",
normalized_text="the current signal workflow is too noisy and hard to turn into next actions",
language="en",
engagement={"score": 22, "comments": 7},
keyword_hits=["workflow", "actions"],
raw_payload={"source": "static_mock", "sequence": 4},
created_at_source=datetime(2026, 1, 4, 16, 0, tzinfo=UTC),
),
NormalizedRawItem(
platform=self.platform,
platform_item_id=f"{item_prefix}-mock-005",
source_url="https://example.local/mock/mock-005",
author_hash="mock-author-epsilon",
content_text="I wish there was a simple report that tells me what to build next.",
content_excerpt="Report should tell what to build next",
normalized_text="i wish there was a simple report that tells me what to build next",
language="en",
engagement={"score": 18, "comments": 4},
keyword_hits=["report", "next action"],
raw_payload={"source": "static_mock", "sequence": 5},
created_at_source=datetime(2026, 1, 5, 11, 45, tzinfo=UTC),
),
]

return ConnectorResult(
Expand Down
Loading
Loading