Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:43:26 +02:00
commit 5aa3e8d685
700 files changed
+132801

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
node_modules
backend/node_modules
dist
backend/dist
.git
.gitea
.dyad
.codex
.claude
.agents
.idea
.vscode
.vs
tmp
coverage
playwright-report
test-results
release-artifacts
*.log
*.zip
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
checkpoints
.env
.env.*
!.env.example
!backend/.env.example
# Obsolete duplicate init migration removed in V60/V81. Keep it out of Docker builds
# even when an older update overlay left the directory on disk.
backend/prisma/migrations/20260606200000_init_project_workspace
+54
View File
@@ -0,0 +1,54 @@
# ProjectBrain local/self-host defaults
PROJECTBRAIN_WEB_PORT=1209
PROJECTBRAIN_DATA_SOURCE=database
PROJECTBRAIN_SEED_ON_START=true
LOG_LEVEL=info
# PostgreSQL values used by docker-compose.yml.
# Copy this file to .env and replace the password before starting production.
POSTGRES_DB=projectbrain
POSTGRES_USER=projectbrain
POSTGRES_PASSWORD=replace-with-a-unique-postgres-password
DATABASE_URL=postgresql://projectbrain:replace-with-a-unique-postgres-password@postgres:5432/projectbrain?schema=public
# Frontend build-time mode
# mock = use current in-memory frontend client
# real = use the Fastify API through the nginx /api proxy
VITE_PROJECTBRAIN_CLIENT_MODE=real
VITE_PROJECTBRAIN_API_URL=
# Development-only Vite proxy target used when client mode is real.
PROJECTBRAIN_DEV_API_TARGET=http://127.0.0.1:4000
# Required authentication bootstrap. Production rejects these placeholder values.
PROJECTBRAIN_OWNER_USERNAME=owner
PROJECTBRAIN_OWNER_PASSWORD=replace-with-a-unique-password-of-at-least-12-characters
PROJECTBRAIN_AUTH_SECRET=replace-with-at-least-32-random-characters
PROJECTBRAIN_SESSION_TTL_HOURS=24
PROJECTBRAIN_TRUST_PROXY=true
# Server-side Gitea and ChatGPT MCP integration. The token never reaches the browser.
PROJECTBRAIN_GITEA_URL=https://gitea.example.com
PROJECTBRAIN_GITEA_TOKEN=
# Optional separate token with write:issue and write:repository. Falls back to the service token.
PROJECTBRAIN_GITEA_WRITE_TOKEN=
PROJECTBRAIN_GITEA_WEBHOOK_SECRET=
PROJECTBRAIN_MCP_ENABLED=false
PROJECTBRAIN_ANALYSIS_WORKER_PORT=4001
PROJECTBRAIN_ANALYSIS_POLL_MS=1000
PROJECTBRAIN_ANALYSIS_BATCH_SIZE=5
PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY=2
PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS=1800
PROJECTBRAIN_ANALYSIS_STALE_SECONDS=300
PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES=1440
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400
PROJECTBRAIN_PUBLIC_URL=https://projectbrain.example.com
# Optional server-to-server credential used only by the governed ITWorx MCP Hub.
PROJECTBRAIN_HUB_SERVICE_TOKEN=
# Optional Microsoft Entra ID login. Register the callback as a Web redirect URI:
# https://projectbrain.example.com/api/auth/entra/callback
PROJECTBRAIN_ENTRA_TENANT_ID=
PROJECTBRAIN_ENTRA_CLIENT_ID=
PROJECTBRAIN_ENTRA_CLIENT_SECRET=
+10
View File
@@ -0,0 +1,10 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.zip binary
+64
View File
@@ -0,0 +1,64 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies and build output
node_modules/
backend/node_modules/
.opencode/
dist/
backend/dist/
release-artifacts/
dist-ssr/
coverage/
playwright-report/
test-results/
checkpoints/
*.zip
*.local
*.tsbuildinfo
# Runtime and local durable data
tmp/
.cache/
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
backups/local/
# Local AI/editor workspace state
.dyad/
.codex/
.claude/
.agents/
.vscode/*
!.vscode/extensions.json
.idea/
.vs/
.DS_Store
Thumbs.db
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local environment files and credentials
.env
.env.*
!.env.example
!backend/.env.example
*.key
*.pem
*.p12
*.pfx
secrets/
credentials/
+1
View File
@@ -0,0 +1 @@
24
+42
View File
@@ -0,0 +1,42 @@
FROM node:24-alpine AS build
WORKDIR /app
ENV CI=true
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV npm_config_fetch_warn_timeout=60000
RUN corepack enable
ARG VITE_PROJECTBRAIN_API_URL=""
ARG VITE_PROJECTBRAIN_CLIENT_MODE="real"
ARG VITE_PROJECTBRAIN_RELEASE_TAG=""
ARG VITE_PROJECTBRAIN_COMMIT=""
ARG VITE_PROJECTBRAIN_BRANCH=""
ARG VITE_PROJECTBRAIN_BUILD_TIME=""
ARG VITE_PROJECTBRAIN_SOURCE=""
ENV VITE_PROJECTBRAIN_API_URL=$VITE_PROJECTBRAIN_API_URL
ENV VITE_PROJECTBRAIN_CLIENT_MODE=$VITE_PROJECTBRAIN_CLIENT_MODE
ENV VITE_PROJECTBRAIN_RELEASE_TAG=$VITE_PROJECTBRAIN_RELEASE_TAG
ENV VITE_PROJECTBRAIN_COMMIT=$VITE_PROJECTBRAIN_COMMIT
ENV VITE_PROJECTBRAIN_BRANCH=$VITE_PROJECTBRAIN_BRANCH
ENV VITE_PROJECTBRAIN_BUILD_TIME=$VITE_PROJECTBRAIN_BUILD_TIME
ENV VITE_PROJECTBRAIN_SOURCE=$VITE_PROJECTBRAIN_SOURCE
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json backend/package.json
COPY packages/contracts/package.json packages/contracts/package.json
RUN pnpm install --frozen-lockfile --filter projectbrain... > /tmp/pnpm-install.log 2>&1 || (cat /tmp/pnpm-install.log; exit 1); awk 'index($0, "[W" "ARN] Request took") == 0 { print }' /tmp/pnpm-install.log
COPY packages/contracts packages/contracts
COPY index.html components.json postcss.config.js tailwind.config.ts tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts ./
COPY public public
COPY src src
RUN pnpm run build
FROM nginxinc/nginx-unprivileged:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
USER nginx
+91
View File
@@ -0,0 +1,91 @@
FROM node:24-alpine AS frontend-build
WORKDIR /app
ENV CI=true
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV npm_config_fetch_warn_timeout=60000
RUN corepack enable
ARG VITE_PROJECTBRAIN_API_URL=""
ARG VITE_PROJECTBRAIN_CLIENT_MODE="real"
ARG VITE_PROJECTBRAIN_RELEASE_TAG=""
ARG VITE_PROJECTBRAIN_COMMIT=""
ARG VITE_PROJECTBRAIN_BRANCH=""
ARG VITE_PROJECTBRAIN_BUILD_TIME=""
ARG VITE_PROJECTBRAIN_SOURCE=""
ENV VITE_PROJECTBRAIN_API_URL=$VITE_PROJECTBRAIN_API_URL
ENV VITE_PROJECTBRAIN_CLIENT_MODE=$VITE_PROJECTBRAIN_CLIENT_MODE
ENV VITE_PROJECTBRAIN_RELEASE_TAG=$VITE_PROJECTBRAIN_RELEASE_TAG
ENV VITE_PROJECTBRAIN_COMMIT=$VITE_PROJECTBRAIN_COMMIT
ENV VITE_PROJECTBRAIN_BRANCH=$VITE_PROJECTBRAIN_BRANCH
ENV VITE_PROJECTBRAIN_BUILD_TIME=$VITE_PROJECTBRAIN_BUILD_TIME
ENV VITE_PROJECTBRAIN_SOURCE=$VITE_PROJECTBRAIN_SOURCE
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json backend/package.json
COPY packages/contracts/package.json packages/contracts/package.json
RUN pnpm install --frozen-lockfile --filter projectbrain... > /tmp/pnpm-install.log 2>&1 || (cat /tmp/pnpm-install.log; exit 1); awk 'index($0, "[W" "ARN] Request took") == 0 { print }' /tmp/pnpm-install.log
COPY packages/contracts packages/contracts
COPY index.html components.json postcss.config.js tailwind.config.ts tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts ./
COPY public public
COPY src src
RUN pnpm run build
FROM node:24-alpine AS backend-build
WORKDIR /app
ENV CI=true
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV npm_config_fetch_warn_timeout=60000
ENV PRISMA_SKIP_POSTINSTALL_GENERATE=true
RUN apk add --no-cache openssl libc6-compat ca-certificates
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json backend/package.json
COPY packages/contracts/package.json packages/contracts/package.json
RUN pnpm install --frozen-lockfile --filter projectbrain-api... > /tmp/pnpm-install.log 2>&1 || (cat /tmp/pnpm-install.log; exit 1); awk 'index($0, "[W" "ARN] Request took") == 0 { print }' /tmp/pnpm-install.log
COPY packages/contracts packages/contracts
COPY backend backend
WORKDIR /app/backend
RUN pnpm run prisma:generate && pnpm run build
WORKDIR /app
RUN pnpm --filter projectbrain-api deploy --prod --legacy /runtime
RUN cp -R /app/backend/dist /runtime/dist \
&& cd /runtime \
&& ./node_modules/.bin/prisma generate --schema prisma/schema.prisma
# trivy:ignore:DS-0002 -- Accepted v1.0 single-container exception: the init
# process must create/chown the persistent PostgreSQL volume and then starts
# PostgreSQL as the dedicated postgres user. Nginx drops worker privileges and
# the Fastify service has no Docker socket or additional Linux capabilities.
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=127.0.0.1
ENV PORT=4000
ENV PROJECTBRAIN_DATA_SOURCE=database
ENV PROJECTBRAIN_SEED_ON_START=true
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN apk add --no-cache nginx openssl libc6-compat ca-certificates curl postgresql16 postgresql16-client
RUN rm -rf /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/npm \
/usr/local/bin/corepack /usr/local/bin/npm /usr/local/bin/npx \
/usr/local/bin/pnpm /usr/local/bin/pnpx
COPY --from=backend-build /runtime /app/backend
COPY --from=frontend-build /app/dist /usr/share/nginx/html
COPY docker/nginx.single.conf /etc/nginx/http.d/default.conf
COPY scripts/unraid-single-entrypoint.sh /usr/local/bin/projectbrain-unraid-entrypoint.sh
RUN chmod +x /usr/local/bin/projectbrain-unraid-entrypoint.sh
EXPOSE 80
VOLUME ["/var/lib/projectbrain"]
HEALTHCHECK --interval=15s --timeout=5s --start-period=35s --retries=10 CMD curl -fsS http://127.0.0.1/api/system/readiness >/dev/null || exit 1
CMD ["sh", "/usr/local/bin/projectbrain-unraid-entrypoint.sh"]
+3
View File
@@ -0,0 +1,3 @@
# Public source export
This source snapshot was generated from the private canonical repository at revision `8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
File diff suppressed because it is too large. Load diff
+114
View File
@@ -0,0 +1,114 @@
# ProjectBrain
**A self-hosted operating workspace for software projects, from durable context to validated delivery.**
ProjectBrain keeps project knowledge, connected repository evidence, work requests, human decisions and delivery results together. Its normal flow starts with a plain-language request and ends with an understandable result and the evidence behind it. Technical work graphs, executors and release details remain available when needed.
## The project journey
```mermaid
flowchart LR
A[Project context and repository] --> B[Request and constraints]
B --> C[Plan and governed execution]
C --> D[Validate and recover]
D --> E[Result and evidence]
C --> F[Human decision when required]
F --> C
```
## What it provides
- Portfolio orientation: attention, active work, recovery and recent delivery across projects.
- Project overview, request workspace, attention inbox, results and chronological activity.
- Repository intake and analysis, project knowledge with provenance/freshness/conflict signals, and supplementary source libraries.
- Durable work graphs with dependencies, priorities, bounded execution recovery and explicit completion criteria.
- Governed Gitea execution using deterministic work branches, exact-head Actions evidence, pull requests and controlled merges.
- Review, evidence, release and handoff surfaces that distinguish implemented, validated, merged, deployed and runtime-verified work.
- Session/context packs and MCP integration for coordinating external reasoning and execution clients.
- Database-backed persistence with Prisma/PostgreSQL and separate analysis/reconciliation workers.
Capabilities depend on configured integrations and verified execution paths. A reachable endpoint, mock success or passing build does not establish production deployment capability. High-risk or destructive work remains a human decision boundary.
## Start with a preview
The frontend has a development-only mock client for exploring the product without a database or repository credentials:
```bash
pnpm install --frozen-lockfile
VITE_PROJECTBRAIN_CLIENT_MODE=mock pnpm dev
```
Open the Vite URL shown in the terminal. In PowerShell, set `$env:VITE_PROJECTBRAIN_CLIENT_MODE='mock'` before `pnpm dev`. Mock state is synthetic development data and is not production readiness evidence. Production builds reject explicit mock mode.
## Interface preview
These screenshots show the existing synthetic WideFrog/Nebula development fixtures. The current product UI includes Dutch labels; this README is in English. No live project inventory, repository credentials or private runtime data is shown.
![Synthetic project catalogue and next action](docs/screenshots/mock-portfolio.png)
![Synthetic project overview and execution state](docs/screenshots/mock-project-overview.png)
## Deploy your own instance
Requirements: **Node.js 24**, **pnpm 11.5.2**, Docker Compose and persistent PostgreSQL storage.
```bash
git clone https://gitea.itworx.tech/Jens/projectbrain-Public.git
cd projectbrain-Public
cp .env.example .env
# Configure independent database, owner-login and authentication secrets.
docker compose up --build
```
The default web port is `1209`. The integrated stack runs the React frontend behind nginx, Fastify API, PostgreSQL and background workers. Production uses `PROJECTBRAIN_DATA_SOURCE=database` and `VITE_PROJECTBRAIN_CLIENT_MODE=real`; an empty frontend API URL uses the same-origin `/api` proxy. The server rejects placeholder authentication secrets in production.
Persist the database and application state independently of the checkout. Before upgrading, back up state, review Prisma migrations and verify authenticated workflows after deployment. The single-container Unraid build remains available in `Dockerfile.unraid`; it is a bootstrap/packaging option, not evidence that a live managed target has been promoted.
The canonical private deployment uses a separate Deployment Controller: reviewed merges, validation on the exact merge SHA, immutable promotion, controller receipts and runtime checks. Public source synchronization does not deploy that private target or replace its release procedure.
## Connect repositories and execution
Set Gitea service credentials in the backend environment. Give the read connector only the intended repository access; configure the separate write token only when governed execution is required. Keep tokens, webhook secrets and MCP service credentials out of frontend builds and source control.
Create or import a project, connect its repository, review the known context and capabilities, then submit a request with clear scope and constraints. Use **Your attention** for genuine product/risk decisions, **Results** for completed work and evidence, and **Activity** for recovery and history. Repository evidence remains the primary technical source; additional sources supply requirements or references outside that repository.
Optional Microsoft Entra login requires a registered Web callback and server-side tenant/client credentials; see [Entra configuration](docs/MICROSOFT_ENTRA_LOGIN.md). MCP is opt-in and requires its own authentication and capability policy.
## Develop and validate
```bash
pnpm install --frozen-lockfile
pnpm dev
# In another terminal:
pnpm --filter projectbrain-api run dev
```
For local database work, start `docker compose -f docker-compose.dev.yml up -d postgres`, set a local `DATABASE_URL`, generate Prisma and apply migrations through the backend scripts. The supplied development database credentials and exposed database port are for isolated development only.
```bash
pnpm run typecheck
pnpm run lint
pnpm run test
pnpm run build
pnpm --filter projectbrain-api run typecheck
pnpm --filter projectbrain-api run test
```
Database/browser release qualification needs disposable infrastructure and authenticated flows. Fixture tests alone prove development behavior. See the [product contract](docs/UX_AUTONOMY_PRODUCT_CONTRACT.md) and [backend architecture decision](docs/REAL_BACKEND_ARCHITECTURE_DECISION.md) for the intended boundaries.
## Technical map
| Directory | Responsibility |
| --- | --- |
| `src/` | React product workspace, API clients and synthetic preview data |
| `backend/` | Fastify API, persistence, workers, Prisma schema and migrations |
| `packages/contracts/` | Shared typed contracts |
| `docker/` | nginx and self-hosted packaging templates |
| `e2e/` | Development browser regression tests |
| `docs/` | Selected durable product, integration and licensing documentation |
## Public source and licensing
This is the curated public source edition of ProjectBrain 0.7.1. Reviewed changes are synchronized from the private canonical repository after merges to `main`. Private Git history, internal workflows, deployment evidence, operator records and runtime credentials are not part of this distribution. Generic deployment examples must be configured for your environment.
No repository-wide software license has been selected. Public visibility does not grant reuse, modification or redistribution rights. The existing [licensing status](docs/LICENSING.md) is preserved; third-party dependency and asset terms require separate review.
+13
View File
@@ -0,0 +1,13 @@
# Security Policy
## Reporting vulnerabilities
Report suspected vulnerabilities privately to the repository owner. Do not publish Gitea credentials, repository write tokens, project-private source material, execution/session packs containing sensitive data, operator credentials, private infrastructure details or exploit-sensitive evidence in a public issue.
Include the affected release/commit, component, minimal reproduction conditions using synthetic project data where possible, expected and observed behaviour, impact, and whether the issue affects authentication, repository delivery, project isolation, approvals, MCP boundaries, archive/session-pack generation, webhook processing, audit history or deployment.
## Security and publication boundaries
ProjectBrain manages project context and governed repository delivery. Source material imported from private projects does not become public merely because ProjectBrain can index or package it. Session-pack exports must preserve their documented bounded scope and must not silently become repository backups.
Repository writes, merges and other external effects must remain attributable, validated and policy-controlled. Never commit live `.env` files, service credentials, private project archives or unredacted operational data.
+40
View File
@@ -0,0 +1,40 @@
# ProjectBrain API local development defaults
NODE_ENV=development
HOST=0.0.0.0
PORT=4000
LOG_LEVEL=info
# Database mode is the normal self-hosted default. Use fixture for safe offline API previews.
PROJECTBRAIN_DATA_SOURCE=database
PROJECTBRAIN_SEED_ON_START=true
# Required for Prisma commands and database mode.
DATABASE_URL=postgresql://projectbrain:projectbrain@localhost:5432/projectbrain?schema=public
# Required authentication bootstrap. Generate unique production values.
PROJECTBRAIN_OWNER_USERNAME=owner
PROJECTBRAIN_OWNER_PASSWORD=replace-with-a-unique-password-of-at-least-12-characters
PROJECTBRAIN_AUTH_SECRET=replace-with-at-least-32-random-characters
PROJECTBRAIN_SESSION_TTL_HOURS=24
PROJECTBRAIN_TRUST_PROXY=false
# Optional Gitea/MCP integration. The webhook secret must match the generic Gitea webhook secret.
PROJECTBRAIN_GITEA_URL=
PROJECTBRAIN_GITEA_TOKEN=
PROJECTBRAIN_GITEA_WRITE_TOKEN=
PROJECTBRAIN_GITEA_WEBHOOK_SECRET=
PROJECTBRAIN_MCP_ENABLED=false
PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE=1200
PROJECTBRAIN_ANALYSIS_WORKER_PORT=4001
PROJECTBRAIN_ANALYSIS_POLL_MS=1000
PROJECTBRAIN_ANALYSIS_BATCH_SIZE=5
PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY=2
PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS=1800
PROJECTBRAIN_ANALYSIS_STALE_SECONDS=300
PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES=1440
# When enabled, every writable Gitea repository is idempotently linked to a
# matching ProjectBrain project. Exclusions remain durable and are respected.
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400
PROJECTBRAIN_PUBLIC_URL=
+3
View File
@@ -0,0 +1,3 @@
dist/
node_modules/
coverage/
+31
View File
@@ -0,0 +1,31 @@
FROM node:24-alpine
WORKDIR /app
ENV CI=true
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV npm_config_fetch_warn_timeout=60000
ENV PRISMA_SKIP_POSTINSTALL_GENERATE=true
# Prisma engines require OpenSSL at runtime. Alpine images do not always
# include it, which makes prisma migrate deploy fail with schema-engine JSON
# parse errors such as: Error loading shared library libssl.
RUN apk add --no-cache openssl libc6-compat ca-certificates
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json backend/package.json
COPY packages/contracts/package.json packages/contracts/package.json
RUN pnpm install --frozen-lockfile --filter projectbrain-api... > /tmp/pnpm-install.log 2>&1 || (cat /tmp/pnpm-install.log; exit 1); awk 'index($0, "[W" "ARN] Request took") == 0 { print }' /tmp/pnpm-install.log
COPY --chown=node:node packages/contracts packages/contracts
COPY --chown=node:node backend backend
WORKDIR /app/backend
RUN pnpm run prisma:generate && pnpm run build && chmod +x scripts/docker-entrypoint.sh
EXPOSE 4000
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=10 CMD node -e "fetch('http://127.0.0.1:4000/api/system/readiness').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
USER node
CMD ["sh", "scripts/docker-entrypoint.sh"]
+126
View File
@@ -0,0 +1,126 @@
# ProjectBrain API Backend
This is the dedicated Fastify TypeScript backend for ProjectBrain.
It currently provides fixture-backed API endpoints matching the frontend/API contract while the persistent PostgreSQL/Prisma transition is being prepared.
## Current Status
Implemented backend phases:
- R1: Fastify skeleton, health endpoint, envelopes, request IDs, error handling.
- R2: Projects, Workspace, and Project DNA read endpoints.
- R3: Feature Groups, Features, and Evidence read endpoints.
- R4: Context Packs and deterministic Prompt generation.
- R5: Checks and Review Board actions with idempotency.
- R6: Builds, Regressions, and Snapshots.
- R7: Knowledge, Memory Rules, Relationships, and Insights.
- R8: Capture Center, Review Queue, and AI Actions.
- P1: Prisma/PostgreSQL scaffold.
- P2: Project/Workspace/DNA database repository path prepared behind a data-source flag, pending local DB smoke verification.
Fixture mode remains available for deterministic previews. Database mode provides production persistence, owner/member authentication, Microsoft Entra login, Gitea integration, remote MCP and the durable portfolio control plane. ProjectBrain intentionally delegates model reasoning to ChatGPT through MCP and therefore has no embedded AI provider by default.
## Run
```bash
cd backend
pnpm install
pnpm run dev
```
The default server listens on `0.0.0.0:4000`.
## Scripts
- `pnpm run dev` starts the backend with `tsx watch`.
- `pnpm run build` compiles TypeScript to `dist/`.
- `pnpm run start` runs the compiled server.
- `pnpm run typecheck` runs TypeScript without emitting files.
- `pnpm run lint` currently aliases typecheck.
- `pnpm run prisma:validate` validates the Prisma schema.
- `pnpm run prisma:generate` generates Prisma Client.
- `pnpm run db:migrate:dev` runs local development migrations when `DATABASE_URL` is available.
- `pnpm run db:seed` runs the local seed when `DATABASE_URL` is available.
- `pnpm run smoke:p2` verifies Project/Workspace/DNA database-mode behavior when the backend is running in database mode.
## Health
`GET /api/health`
```json
{
"data": {
"status": "ok",
"service": "projectbrain-api",
"version": "0.1.0",
"environment": "development"
},
"meta": {
"requestId": "req-...",
"timestamp": "2026-06-06T00:00:00.000Z"
}
}
```
Incoming `x-request-id` headers are respected and echoed in both the response header and envelope metadata.
## Response Envelope
Success:
```json
{
"data": {},
"meta": {
"requestId": "req-...",
"timestamp": "..."
}
}
```
Error:
```json
{
"error": {
"code": "PROJECT_NOT_FOUND",
"message": "Project not found",
"validation": []
},
"meta": {
"requestId": "req-...",
"timestamp": "..."
}
}
```
## Data Source Mode
```text
PROJECTBRAIN_DATA_SOURCE=fixture | database
```
Default is `fixture`.
When `database` is used, `DATABASE_URL` is required. The database-backed path is currently prepared for Project/Workspace/DNA parity only. Other endpoint families remain fixture-backed.
## Local Postgres
See:
- `../docs/PERSISTENCE_LOCAL_POSTGRES_SETUP.md`
- `../docs/P2_LOCAL_RUNTIME_VERIFICATION.md`
## Endpoint Index
See `../docs/REAL_BACKEND_ENDPOINT_INDEX.md` for the current R1-R8 endpoint overview.
## Not Included Yet
- Authenticated private-network deployment is supported; public multi-tenant SaaS operation is not.
- No real AI calls.
- No upload/import pipeline.
- No Redis/BullMQ worker.
- No full database-backed replacement for R3-R8.
- No frontend client switch by default.
+69
View File
@@ -0,0 +1,69 @@
{
"name": "projectbrain-api",
"version": "0.7.1",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "pnpm run prisma:generate && tsc -p tsconfig.json",
"start": "node dist/server.js",
"typecheck": "pnpm run prisma:generate && tsc -p tsconfig.json --noEmit",
"lint": "pnpm run prisma:generate && tsc -p tsconfig.json --noEmit",
"test": "pnpm run prisma:generate && vitest run",
"test:coverage": "pnpm run prisma:generate && vitest run --coverage",
"test:database": "vitest run test/database-authentication.test.ts test/database-repository-sync.test.ts test/database-autonomy.test.ts",
"prisma:generate": "node scripts/prisma-generate.mjs",
"prisma:validate": "node scripts/prisma-validate.mjs",
"prisma:format": "prisma format --schema prisma/schema.prisma",
"db:migrate:dev": "prisma migrate dev --schema prisma/schema.prisma",
"db:seed": "tsx prisma/seed.ts",
"smoke:p2": "node scripts/smoke-p2.mjs",
"smoke:p3": "node scripts/smoke-p3.mjs",
"smoke:fixtures": "node scripts/smoke-fixture-api.mjs",
"smoke:real-read-pilot": "node scripts/smoke-real-read-pilot.mjs",
"smoke:vertical-slice": "node scripts/smoke-vertical-slice.mjs",
"smoke:runtime-suite": "node scripts/smoke-runtime-suite.mjs",
"smoke:release-evidence": "node scripts/smoke-release-evidence.mjs",
"smoke:database-suite": "node scripts/smoke-database-suite.mjs",
"smoke:p4": "node scripts/smoke-p4.mjs",
"smoke:p5": "node scripts/smoke-p5.mjs",
"smoke:p6": "node scripts/smoke-p6.mjs",
"smoke:p7": "node scripts/smoke-p7.mjs",
"smoke:project-loop": "node scripts/smoke-project-loop.mjs",
"smoke:project-create": "node scripts/smoke-project-create.mjs",
"smoke:p8": "node scripts/smoke-p8.mjs",
"smoke:blueprints": "node scripts/smoke-blueprints-v52.mjs",
"db:deploy": "prisma migrate deploy --schema prisma/schema.prisma",
"db:status": "prisma migrate status --schema prisma/schema.prisma",
"db:verify-restore": "node scripts/verify-restored-database.mjs",
"db:validate": "node scripts/prisma-validate.mjs",
"audit:contract": "node scripts/audit-backend-contract.mjs",
"smoke:system-readiness": "node scripts/smoke-system-readiness.mjs",
"smoke:brainstorms": "node scripts/smoke-brainstorms.mjs",
"smoke:initiatives": "node scripts/smoke-initiatives.mjs"
},
"dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/formbody": "^8.0.2",
"@fastify/helmet": "^13.1.0",
"@fastify/rate-limit": "^11.1.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"@prisma/client": "^5.22.0",
"@projectbrain/contracts": "workspace:*",
"fastify": "^5.12.4",
"jose": "^6.2.9",
"prisma": "5.22.0",
"tsx": "^4.23.12",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^4.1.10",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24 <25",
"pnpm": "11.5.2"
}
}
@@ -0,0 +1,617 @@
-- CreateEnum
CREATE TYPE "ProjectStatus" AS ENUM ('active', 'archived', 'paused');
-- CreateEnum
CREATE TYPE "FeatureStatus" AS ENUM ('todo', 'in_progress', 'done');
-- CreateEnum
CREATE TYPE "FeatureLifecycle" AS ENUM ('feature_candidate', 'planned', 'active', 'implemented', 'verified', 'production_proven', 'deprecated', 'archived');
-- CreateEnum
CREATE TYPE "ApprovalState" AS ENUM ('pending', 'approved', 'rejected', 'blocked');
-- CreateEnum
CREATE TYPE "EvidenceStrength" AS ENUM ('weak', 'medium', 'strong', 'verified');
-- CreateEnum
CREATE TYPE "VerificationState" AS ENUM ('unverified', 'needs_evidence', 'verified', 'blocked');
-- CreateEnum
CREATE TYPE "ReviewStatus" AS ENUM ('pending', 'accepted', 'rejected', 'merged', 'converted');
-- CreateEnum
CREATE TYPE "EntityType" AS ENUM ('project', 'project_dna', 'workspace_state', 'feature_group', 'feature', 'evidence', 'context_pack', 'context_pack_section', 'memory_rule', 'knowledge_item', 'review_queue_item', 'idempotency_record', 'entity_relation', 'activity_event');
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"readableId" TEXT,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"domain" TEXT NOT NULL,
"status" "ProjectStatus" NOT NULL DEFAULT 'active',
"health" INTEGER NOT NULL DEFAULT 0,
"healthStatus" TEXT NOT NULL,
"activeContextPackId" TEXT,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectDNA" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"vision" TEXT NOT NULL,
"goalsJson" JSONB NOT NULL,
"nonGoalsJson" JSONB NOT NULL,
"targetAudience" TEXT NOT NULL,
"principlesJson" JSONB NOT NULL,
"metricsJson" JSONB NOT NULL,
"maturityStage" TEXT NOT NULL,
"revision" INTEGER NOT NULL DEFAULT 1,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectDNA_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkspaceState" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"activeContextPackId" TEXT,
"primaryObjective" TEXT NOT NULL,
"activeSection" TEXT NOT NULL,
"notesJson" JSONB NOT NULL,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkspaceState_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeatureGroup" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"name" TEXT NOT NULL,
"description" TEXT,
"focusArea" TEXT,
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FeatureGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Feature" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"featureGroupId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"status" "FeatureStatus" NOT NULL DEFAULT 'todo',
"lifecycle" "FeatureLifecycle" NOT NULL DEFAULT 'feature_candidate',
"intent" TEXT,
"confidence" INTEGER NOT NULL DEFAULT 0,
"health" INTEGER NOT NULL DEFAULT 0,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"evidenceStrength" "EvidenceStrength",
"verificationState" "VerificationState",
"verifiedAt" TIMESTAMP(3),
"verifiedBy" TEXT,
"trustJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Feature_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Evidence" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"type" TEXT NOT NULL,
"source" TEXT NOT NULL,
"confidence" INTEGER NOT NULL DEFAULT 0,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'weak',
"verificationState" "VerificationState",
"reason" TEXT NOT NULL,
"trustJson" JSONB,
"provenanceJson" JSONB,
"metadataJson" JSONB,
"lastSeen" TIMESTAMP(3) NOT NULL,
"verifiedAt" TIMESTAMP(3),
"verifiedBy" TEXT,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Evidence_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContextPack" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"title" TEXT NOT NULL,
"category" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"generatedBy" TEXT NOT NULL,
"confidence" INTEGER NOT NULL DEFAULT 0,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"qualityJson" JSONB,
"trustJson" JSONB,
"provenanceJson" JSONB,
"metadataJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContextPack_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContextPackSection" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"contextPackId" TEXT NOT NULL,
"slug" TEXT,
"title" TEXT NOT NULL,
"sectionType" TEXT NOT NULL,
"orderIndex" INTEGER NOT NULL,
"contentJson" JSONB NOT NULL,
"metadataJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContextPackSection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemoryRule" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"title" TEXT NOT NULL,
"rule" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"category" TEXT NOT NULL,
"severity" TEXT NOT NULL,
"includeInContextPacks" BOOLEAN NOT NULL DEFAULT false,
"confidence" INTEGER NOT NULL DEFAULT 0,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"evidenceStrength" "EvidenceStrength",
"verificationState" "VerificationState",
"verifiedAt" TIMESTAMP(3),
"verifiedBy" TEXT,
"trustJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MemoryRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KnowledgeItem" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"title" TEXT NOT NULL,
"category" TEXT NOT NULL,
"content" TEXT NOT NULL,
"source" TEXT NOT NULL,
"confidence" INTEGER NOT NULL DEFAULT 0,
"includeInContextPacks" BOOLEAN NOT NULL DEFAULT false,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"evidenceStrength" "EvidenceStrength",
"verificationState" "VerificationState",
"verifiedAt" TIMESTAMP(3),
"verifiedBy" TEXT,
"trustJson" JSONB,
"provenanceJson" JSONB,
"metadataJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KnowledgeItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReviewQueueItem" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"priority" TEXT NOT NULL,
"status" "ReviewStatus" NOT NULL DEFAULT 'pending',
"source" TEXT NOT NULL,
"confidence" INTEGER NOT NULL DEFAULT 0,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'weak',
"verificationState" "VerificationState" NOT NULL DEFAULT 'unverified',
"suggestedAction" TEXT NOT NULL,
"notes" TEXT,
"payloadJson" JSONB,
"provenanceJson" JSONB,
"createdEntityType" "EntityType",
"createdEntityId" TEXT,
"targetEntityType" "EntityType",
"targetEntityId" TEXT,
"verifiedAt" TIMESTAMP(3),
"verifiedBy" TEXT,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReviewQueueItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "IdempotencyRecord" (
"id" TEXT NOT NULL,
"projectId" TEXT,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"key" TEXT NOT NULL,
"requestHash" TEXT NOT NULL,
"statusCode" INTEGER NOT NULL,
"responseJson" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IdempotencyRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EntityRelation" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"sourceEntityType" "EntityType" NOT NULL,
"sourceEntityId" TEXT NOT NULL,
"targetEntityType" "EntityType" NOT NULL,
"targetEntityId" TEXT NOT NULL,
"relationshipType" TEXT NOT NULL,
"metadataJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EntityRelation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ActivityEvent" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"entityType" "EntityType" NOT NULL,
"entityId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"actor" TEXT NOT NULL,
"payloadJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ActivityEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug");
-- CreateIndex
CREATE INDEX "Project_status_idx" ON "Project"("status");
-- CreateIndex
CREATE INDEX "Project_activeContextPackId_idx" ON "Project"("activeContextPackId");
-- CreateIndex
CREATE INDEX "Project_deletedAt_idx" ON "Project"("deletedAt");
-- CreateIndex
CREATE INDEX "Project_updatedAt_idx" ON "Project"("updatedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectDNA_projectId_key" ON "ProjectDNA"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "WorkspaceState_projectId_key" ON "WorkspaceState"("projectId");
-- CreateIndex
CREATE INDEX "WorkspaceState_projectId_activeContextPackId_idx" ON "WorkspaceState"("projectId", "activeContextPackId");
-- CreateIndex
CREATE INDEX "FeatureGroup_projectId_idx" ON "FeatureGroup"("projectId");
-- CreateIndex
CREATE INDEX "FeatureGroup_projectId_deletedAt_idx" ON "FeatureGroup"("projectId", "deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "FeatureGroup_projectId_slug_key" ON "FeatureGroup"("projectId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "FeatureGroup_projectId_readableId_key" ON "FeatureGroup"("projectId", "readableId");
-- CreateIndex
CREATE INDEX "Feature_projectId_idx" ON "Feature"("projectId");
-- CreateIndex
CREATE INDEX "Feature_projectId_status_idx" ON "Feature"("projectId", "status");
-- CreateIndex
CREATE INDEX "Feature_projectId_lifecycle_idx" ON "Feature"("projectId", "lifecycle");
-- CreateIndex
CREATE INDEX "Feature_projectId_featureGroupId_idx" ON "Feature"("projectId", "featureGroupId");
-- CreateIndex
CREATE INDEX "Feature_projectId_name_idx" ON "Feature"("projectId", "name");
-- CreateIndex
CREATE INDEX "Feature_projectId_deletedAt_idx" ON "Feature"("projectId", "deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Feature_projectId_slug_key" ON "Feature"("projectId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "Feature_projectId_readableId_key" ON "Feature"("projectId", "readableId");
-- CreateIndex
CREATE INDEX "Evidence_projectId_idx" ON "Evidence"("projectId");
-- CreateIndex
CREATE INDEX "Evidence_projectId_type_idx" ON "Evidence"("projectId", "type");
-- CreateIndex
CREATE INDEX "Evidence_projectId_approvalState_idx" ON "Evidence"("projectId", "approvalState");
-- CreateIndex
CREATE INDEX "Evidence_projectId_evidenceStrength_idx" ON "Evidence"("projectId", "evidenceStrength");
-- CreateIndex
CREATE INDEX "Evidence_projectId_lastSeen_idx" ON "Evidence"("projectId", "lastSeen");
-- CreateIndex
CREATE INDEX "Evidence_projectId_deletedAt_idx" ON "Evidence"("projectId", "deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Evidence_projectId_slug_key" ON "Evidence"("projectId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "Evidence_projectId_readableId_key" ON "Evidence"("projectId", "readableId");
-- CreateIndex
CREATE INDEX "ContextPack_projectId_idx" ON "ContextPack"("projectId");
-- CreateIndex
CREATE INDEX "ContextPack_projectId_category_idx" ON "ContextPack"("projectId", "category");
-- CreateIndex
CREATE INDEX "ContextPack_projectId_generatedBy_idx" ON "ContextPack"("projectId", "generatedBy");
-- CreateIndex
CREATE INDEX "ContextPack_projectId_createdAt_idx" ON "ContextPack"("projectId", "createdAt");
-- CreateIndex
CREATE INDEX "ContextPack_projectId_deletedAt_idx" ON "ContextPack"("projectId", "deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ContextPack_projectId_slug_key" ON "ContextPack"("projectId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "ContextPack_projectId_readableId_key" ON "ContextPack"("projectId", "readableId");
-- CreateIndex
CREATE INDEX "ContextPackSection_projectId_idx" ON "ContextPackSection"("projectId");
-- CreateIndex
CREATE INDEX "ContextPackSection_contextPackId_orderIndex_idx" ON "ContextPackSection"("contextPackId", "orderIndex");
-- CreateIndex
CREATE INDEX "ContextPackSection_projectId_sectionType_idx" ON "ContextPackSection"("projectId", "sectionType");
-- CreateIndex
CREATE UNIQUE INDEX "ContextPackSection_contextPackId_slug_key" ON "ContextPackSection"("contextPackId", "slug");
-- CreateIndex
CREATE INDEX "MemoryRule_projectId_idx" ON "MemoryRule"("projectId");
-- CreateIndex
CREATE INDEX "MemoryRule_projectId_scope_idx" ON "MemoryRule"("projectId", "scope");
-- CreateIndex
CREATE INDEX "MemoryRule_projectId_category_idx" ON "MemoryRule"("projectId", "category");
-- CreateIndex
CREATE INDEX "MemoryRule_projectId_severity_idx" ON "MemoryRule"("projectId", "severity");
-- CreateIndex
CREATE INDEX "MemoryRule_projectId_includeInContextPacks_idx" ON "MemoryRule"("projectId", "includeInContextPacks");
-- CreateIndex
CREATE INDEX "MemoryRule_projectId_deletedAt_idx" ON "MemoryRule"("projectId", "deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "MemoryRule_projectId_slug_key" ON "MemoryRule"("projectId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "MemoryRule_projectId_readableId_key" ON "MemoryRule"("projectId", "readableId");
-- CreateIndex
CREATE INDEX "KnowledgeItem_projectId_idx" ON "KnowledgeItem"("projectId");
-- CreateIndex
CREATE INDEX "KnowledgeItem_projectId_category_idx" ON "KnowledgeItem"("projectId", "category");
-- CreateIndex
CREATE INDEX "KnowledgeItem_projectId_source_idx" ON "KnowledgeItem"("projectId", "source");
-- CreateIndex
CREATE INDEX "KnowledgeItem_projectId_createdAt_idx" ON "KnowledgeItem"("projectId", "createdAt");
-- CreateIndex
CREATE INDEX "KnowledgeItem_projectId_deletedAt_idx" ON "KnowledgeItem"("projectId", "deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "KnowledgeItem_projectId_slug_key" ON "KnowledgeItem"("projectId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "KnowledgeItem_projectId_readableId_key" ON "KnowledgeItem"("projectId", "readableId");
-- CreateIndex
CREATE INDEX "ReviewQueueItem_projectId_idx" ON "ReviewQueueItem"("projectId");
-- CreateIndex
CREATE INDEX "ReviewQueueItem_projectId_status_idx" ON "ReviewQueueItem"("projectId", "status");
-- CreateIndex
CREATE INDEX "ReviewQueueItem_projectId_type_idx" ON "ReviewQueueItem"("projectId", "type");
-- CreateIndex
CREATE INDEX "ReviewQueueItem_projectId_priority_idx" ON "ReviewQueueItem"("projectId", "priority");
-- CreateIndex
CREATE INDEX "ReviewQueueItem_projectId_createdAt_idx" ON "ReviewQueueItem"("projectId", "createdAt");
-- CreateIndex
CREATE INDEX "ReviewQueueItem_projectId_deletedAt_idx" ON "ReviewQueueItem"("projectId", "deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ReviewQueueItem_projectId_slug_key" ON "ReviewQueueItem"("projectId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "ReviewQueueItem_projectId_readableId_key" ON "ReviewQueueItem"("projectId", "readableId");
-- CreateIndex
CREATE INDEX "IdempotencyRecord_projectId_idx" ON "IdempotencyRecord"("projectId");
-- CreateIndex
CREATE INDEX "IdempotencyRecord_expiresAt_idx" ON "IdempotencyRecord"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "IdempotencyRecord_method_path_key_key" ON "IdempotencyRecord"("method", "path", "key");
-- CreateIndex
CREATE INDEX "EntityRelation_projectId_idx" ON "EntityRelation"("projectId");
-- CreateIndex
CREATE INDEX "EntityRelation_projectId_sourceEntityType_sourceEntityId_idx" ON "EntityRelation"("projectId", "sourceEntityType", "sourceEntityId");
-- CreateIndex
CREATE INDEX "EntityRelation_projectId_targetEntityType_targetEntityId_idx" ON "EntityRelation"("projectId", "targetEntityType", "targetEntityId");
-- CreateIndex
CREATE INDEX "EntityRelation_projectId_relationshipType_idx" ON "EntityRelation"("projectId", "relationshipType");
-- CreateIndex
CREATE INDEX "EntityRelation_projectId_deletedAt_idx" ON "EntityRelation"("projectId", "deletedAt");
-- CreateIndex
CREATE INDEX "ActivityEvent_projectId_idx" ON "ActivityEvent"("projectId");
-- CreateIndex
CREATE INDEX "ActivityEvent_projectId_entityType_entityId_idx" ON "ActivityEvent"("projectId", "entityType", "entityId");
-- CreateIndex
CREATE INDEX "ActivityEvent_projectId_eventType_idx" ON "ActivityEvent"("projectId", "eventType");
-- CreateIndex
CREATE INDEX "ActivityEvent_projectId_createdAt_idx" ON "ActivityEvent"("projectId", "createdAt");
-- AddForeignKey
ALTER TABLE "ProjectDNA" ADD CONSTRAINT "ProjectDNA_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkspaceState" ADD CONSTRAINT "WorkspaceState_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeatureGroup" ADD CONSTRAINT "FeatureGroup_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Feature" ADD CONSTRAINT "Feature_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Feature" ADD CONSTRAINT "Feature_featureGroupId_fkey" FOREIGN KEY ("featureGroupId") REFERENCES "FeatureGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Evidence" ADD CONSTRAINT "Evidence_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContextPack" ADD CONSTRAINT "ContextPack_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContextPackSection" ADD CONSTRAINT "ContextPackSection_contextPackId_fkey" FOREIGN KEY ("contextPackId") REFERENCES "ContextPack"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemoryRule" ADD CONSTRAINT "MemoryRule_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KnowledgeItem" ADD CONSTRAINT "KnowledgeItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReviewQueueItem" ADD CONSTRAINT "ReviewQueueItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EntityRelation" ADD CONSTRAINT "EntityRelation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,40 @@
-- P5 adds persistent capture items. ReviewQueueItem already exists in the initial schema.
CREATE TABLE "CaptureItem" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"source" TEXT NOT NULL,
"status" TEXT NOT NULL,
"classificationType" TEXT,
"confidence" INTEGER,
"evidenceStrength" "EvidenceStrength",
"suggestedAction" TEXT,
"linkedReviewItemId" TEXT,
"metadataJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CaptureItem_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "CaptureItem_projectId_slug_key" ON "CaptureItem"("projectId", "slug");
CREATE UNIQUE INDEX "CaptureItem_projectId_readableId_key" ON "CaptureItem"("projectId", "readableId");
CREATE INDEX "CaptureItem_projectId_idx" ON "CaptureItem"("projectId");
CREATE INDEX "CaptureItem_projectId_status_idx" ON "CaptureItem"("projectId", "status");
CREATE INDEX "CaptureItem_projectId_type_idx" ON "CaptureItem"("projectId", "type");
CREATE INDEX "CaptureItem_projectId_classificationType_idx" ON "CaptureItem"("projectId", "classificationType");
CREATE INDEX "CaptureItem_projectId_linkedReviewItemId_idx" ON "CaptureItem"("projectId", "linkedReviewItemId");
CREATE INDEX "CaptureItem_projectId_createdAt_idx" ON "CaptureItem"("projectId", "createdAt");
CREATE INDEX "CaptureItem_projectId_deletedAt_idx" ON "CaptureItem"("projectId", "deletedAt");
ALTER TABLE "CaptureItem" ADD CONSTRAINT "CaptureItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,113 @@
-- P6 accepted truth, project updates, and operating log persistence.
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'accepted_truth_item';
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'project_update';
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'operating_log_entry';
CREATE TABLE IF NOT EXISTS "AcceptedTruthItem" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"sourceReviewItemId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'accepted',
"source" TEXT NOT NULL,
"confidence" INTEGER NOT NULL DEFAULT 0,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'approved',
"evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'weak',
"verificationState" "VerificationState" NOT NULL DEFAULT 'verified',
"payloadJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'review_queue',
"verifiedAt" TIMESTAMP(3),
"verifiedBy" TEXT,
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AcceptedTruthItem_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "ProjectUpdate" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"sourceTruthItemId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"targetArea" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ready',
"confidence" INTEGER NOT NULL DEFAULT 0,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'weak',
"payloadJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'accepted_truth',
"appliedAt" TIMESTAMP(3),
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectUpdate_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "OperatingLogEntry" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"linkedEntityIdsJson" JSONB NOT NULL,
"source" TEXT NOT NULL,
"importance" TEXT NOT NULL DEFAULT 'normal',
"payloadJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'runtime',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OperatingLogEntry_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_slug_key" ON "AcceptedTruthItem"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_readableId_key" ON "AcceptedTruthItem"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_idx" ON "AcceptedTruthItem"("projectId");
CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_sourceReviewItemId_idx" ON "AcceptedTruthItem"("projectId", "sourceReviewItemId");
CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_kind_idx" ON "AcceptedTruthItem"("projectId", "kind");
CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_status_idx" ON "AcceptedTruthItem"("projectId", "status");
CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_createdAt_idx" ON "AcceptedTruthItem"("projectId", "createdAt");
CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_deletedAt_idx" ON "AcceptedTruthItem"("projectId", "deletedAt");
CREATE UNIQUE INDEX IF NOT EXISTS "ProjectUpdate_projectId_slug_key" ON "ProjectUpdate"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "ProjectUpdate_projectId_readableId_key" ON "ProjectUpdate"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_idx" ON "ProjectUpdate"("projectId");
CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_sourceTruthItemId_idx" ON "ProjectUpdate"("projectId", "sourceTruthItemId");
CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_targetArea_idx" ON "ProjectUpdate"("projectId", "targetArea");
CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_status_idx" ON "ProjectUpdate"("projectId", "status");
CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_createdAt_idx" ON "ProjectUpdate"("projectId", "createdAt");
CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_deletedAt_idx" ON "ProjectUpdate"("projectId", "deletedAt");
CREATE UNIQUE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_slug_key" ON "OperatingLogEntry"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_readableId_key" ON "OperatingLogEntry"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_idx" ON "OperatingLogEntry"("projectId");
CREATE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_type_idx" ON "OperatingLogEntry"("projectId", "type");
CREATE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_timestamp_idx" ON "OperatingLogEntry"("projectId", "timestamp");
CREATE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_deletedAt_idx" ON "OperatingLogEntry"("projectId", "deletedAt");
ALTER TABLE "AcceptedTruthItem" ADD CONSTRAINT "AcceptedTruthItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectUpdate" ADD CONSTRAINT "ProjectUpdate_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OperatingLogEntry" ADD CONSTRAINT "OperatingLogEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,147 @@
-- P7 current state, handoff bundle, Codex task, and Codex import persistence.
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'current_state_report';
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'handoff_bundle';
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'codex_task';
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'codex_run_import';
CREATE TABLE IF NOT EXISTS "CurrentStateReport" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"existsJson" JSONB NOT NULL,
"worksJson" JSONB NOT NULL,
"uncertainJson" JSONB NOT NULL,
"brokenJson" JSONB NOT NULL,
"currentRisksJson" JSONB NOT NULL,
"pendingReviewItemIdsJson" JSONB NOT NULL,
"activeContextPackId" TEXT,
"recommendedNextActionsJson" JSONB NOT NULL,
"source" TEXT NOT NULL DEFAULT 'runtime',
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"payloadJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'runtime',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CurrentStateReport_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "HandoffBundle" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"target" TEXT NOT NULL,
"title" TEXT NOT NULL,
"markdown" TEXT NOT NULL,
"qualityScore" INTEGER NOT NULL DEFAULT 0,
"sourceTruthItemIdsJson" JSONB NOT NULL,
"sourceReviewItemIdsJson" JSONB NOT NULL,
"sourceCaptureItemIdsJson" JSONB NOT NULL,
"sourceProjectUpdateIdsJson" JSONB,
"sourceOperatingLogEntryIdsJson" JSONB,
"status" TEXT NOT NULL DEFAULT 'ready',
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"payloadJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'runtime',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HandoffBundle_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "CodexTask" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"handoffBundleId" TEXT,
"goal" TEXT NOT NULL,
"contextPackId" TEXT,
"constraintsJson" JSONB NOT NULL,
"relevantFilesJson" JSONB NOT NULL,
"doNotBreakRulesJson" JSONB NOT NULL,
"validationCommandsJson" JSONB NOT NULL,
"expectedReportFormat" TEXT NOT NULL,
"outputImportInstructions" TEXT NOT NULL,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"status" TEXT NOT NULL DEFAULT 'draft',
"payloadJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'handoff',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CodexTask_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "CodexRunImport" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"codexTaskId" TEXT,
"sourceRun" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"extractedReviewItemIdsJson" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'imported',
"payloadJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'codex_output',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CodexRunImport_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "CurrentStateReport_projectId_slug_key" ON "CurrentStateReport"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "CurrentStateReport_projectId_readableId_key" ON "CurrentStateReport"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "CurrentStateReport_projectId_idx" ON "CurrentStateReport"("projectId");
CREATE INDEX IF NOT EXISTS "CurrentStateReport_projectId_generatedAt_idx" ON "CurrentStateReport"("projectId", "generatedAt");
CREATE INDEX IF NOT EXISTS "CurrentStateReport_projectId_approvalState_idx" ON "CurrentStateReport"("projectId", "approvalState");
CREATE INDEX IF NOT EXISTS "CurrentStateReport_projectId_deletedAt_idx" ON "CurrentStateReport"("projectId", "deletedAt");
CREATE UNIQUE INDEX IF NOT EXISTS "HandoffBundle_projectId_slug_key" ON "HandoffBundle"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "HandoffBundle_projectId_readableId_key" ON "HandoffBundle"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_idx" ON "HandoffBundle"("projectId");
CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_target_idx" ON "HandoffBundle"("projectId", "target");
CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_status_idx" ON "HandoffBundle"("projectId", "status");
CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_createdAt_idx" ON "HandoffBundle"("projectId", "createdAt");
CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_deletedAt_idx" ON "HandoffBundle"("projectId", "deletedAt");
CREATE UNIQUE INDEX IF NOT EXISTS "CodexTask_projectId_slug_key" ON "CodexTask"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "CodexTask_projectId_readableId_key" ON "CodexTask"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "CodexTask_projectId_idx" ON "CodexTask"("projectId");
CREATE INDEX IF NOT EXISTS "CodexTask_projectId_handoffBundleId_idx" ON "CodexTask"("projectId", "handoffBundleId");
CREATE INDEX IF NOT EXISTS "CodexTask_projectId_status_idx" ON "CodexTask"("projectId", "status");
CREATE INDEX IF NOT EXISTS "CodexTask_projectId_approvalState_idx" ON "CodexTask"("projectId", "approvalState");
CREATE INDEX IF NOT EXISTS "CodexTask_projectId_deletedAt_idx" ON "CodexTask"("projectId", "deletedAt");
CREATE UNIQUE INDEX IF NOT EXISTS "CodexRunImport_projectId_slug_key" ON "CodexRunImport"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "CodexRunImport_projectId_readableId_key" ON "CodexRunImport"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "CodexRunImport_projectId_idx" ON "CodexRunImport"("projectId");
CREATE INDEX IF NOT EXISTS "CodexRunImport_projectId_codexTaskId_idx" ON "CodexRunImport"("projectId", "codexTaskId");
CREATE INDEX IF NOT EXISTS "CodexRunImport_projectId_status_idx" ON "CodexRunImport"("projectId", "status");
CREATE INDEX IF NOT EXISTS "CodexRunImport_projectId_deletedAt_idx" ON "CodexRunImport"("projectId", "deletedAt");
ALTER TABLE "CurrentStateReport" ADD CONSTRAINT "CurrentStateReport_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "HandoffBundle" ADD CONSTRAINT "HandoffBundle_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CodexTask" ADD CONSTRAINT "CodexTask_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CodexTask" ADD CONSTRAINT "CodexTask_handoffBundleId_fkey" FOREIGN KEY ("handoffBundleId") REFERENCES "HandoffBundle"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "CodexRunImport" ADD CONSTRAINT "CodexRunImport_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'documentation_item';
-- P8 Documentation / Knowledge / Memory persistence
CREATE TABLE IF NOT EXISTS "DocumentationItem" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"title" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"status" TEXT NOT NULL,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"content" TEXT,
"metadataJson" JSONB,
"trustJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'fixture_seed',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DocumentationItem_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "DocumentationItem_projectId_slug_key" ON "DocumentationItem"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "DocumentationItem_projectId_readableId_key" ON "DocumentationItem"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_idx" ON "DocumentationItem"("projectId");
CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_kind_idx" ON "DocumentationItem"("projectId", "kind");
CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_status_idx" ON "DocumentationItem"("projectId", "status");
CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_approvalState_idx" ON "DocumentationItem"("projectId", "approvalState");
CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_deletedAt_idx" ON "DocumentationItem"("projectId", "deletedAt");
ALTER TABLE "DocumentationItem" ADD CONSTRAINT "DocumentationItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,117 @@
-- V52 Blueprint Engine persistence foundation
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'blueprint_graph';
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'blueprint_node';
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'blueprint_node_run';
CREATE TABLE IF NOT EXISTS "BlueprintGraph" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"archetypeId" TEXT NOT NULL,
"archetypeLabel" TEXT NOT NULL,
"depth" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'planned',
"documentRoot" TEXT NOT NULL DEFAULT 'docs/',
"intakeSummary" TEXT NOT NULL,
"assumptionsJson" JSONB NOT NULL,
"exportManifestJson" JSONB NOT NULL,
"recommendedNextActionsJson" JSONB NOT NULL,
"metadataJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'blueprint_engine',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlueprintGraph_pkey" PRIMARY KEY ("id"),
CONSTRAINT "BlueprintGraph_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "BlueprintNode" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"graphId" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"title" TEXT NOT NULL,
"cluster" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"generationOrder" INTEGER NOT NULL,
"requiredDepth" TEXT NOT NULL,
"dependsOnJson" JSONB NOT NULL,
"dependencyTitlesJson" JSONB NOT NULL,
"blockedByJson" JSONB NOT NULL,
"outputPath" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"requiredContextJson" JSONB NOT NULL,
"qualityChecksJson" JSONB NOT NULL,
"producesJson" JSONB NOT NULL,
"promptPreview" TEXT NOT NULL,
"contextSelectionJson" JSONB NOT NULL,
"documentationItemId" TEXT,
"reviewItemId" TEXT,
"latestRunId" TEXT,
"staleReason" TEXT,
"metadataJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'blueprint_engine',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlueprintNode_pkey" PRIMARY KEY ("id"),
CONSTRAINT "BlueprintNode_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "BlueprintNode_graphId_fkey" FOREIGN KEY ("graphId") REFERENCES "BlueprintGraph"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "BlueprintNodeRun" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"graphId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'needs_review',
"generatedBy" TEXT NOT NULL DEFAULT 'deterministic',
"inputSnapshotJson" JSONB NOT NULL,
"prompt" TEXT NOT NULL,
"draftMarkdown" TEXT NOT NULL,
"qualityReportJson" JSONB NOT NULL,
"documentationItemId" TEXT,
"reviewItemId" TEXT,
"metadataJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'blueprint_engine',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlueprintNodeRun_pkey" PRIMARY KEY ("id"),
CONSTRAINT "BlueprintNodeRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "BlueprintNodeRun_graphId_fkey" FOREIGN KEY ("graphId") REFERENCES "BlueprintGraph"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "BlueprintNodeRun_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "BlueprintNode"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "BlueprintGraph_projectId_slug_key" ON "BlueprintGraph"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "BlueprintGraph_projectId_readableId_key" ON "BlueprintGraph"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "BlueprintGraph_projectId_idx" ON "BlueprintGraph"("projectId");
CREATE INDEX IF NOT EXISTS "BlueprintGraph_projectId_archetypeId_idx" ON "BlueprintGraph"("projectId", "archetypeId");
CREATE INDEX IF NOT EXISTS "BlueprintGraph_projectId_status_idx" ON "BlueprintGraph"("projectId", "status");
CREATE INDEX IF NOT EXISTS "BlueprintGraph_projectId_deletedAt_idx" ON "BlueprintGraph"("projectId", "deletedAt");
CREATE UNIQUE INDEX IF NOT EXISTS "BlueprintNode_graphId_templateId_key" ON "BlueprintNode"("graphId", "templateId");
CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_idx" ON "BlueprintNode"("projectId");
CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_graphId_idx" ON "BlueprintNode"("projectId", "graphId");
CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_status_idx" ON "BlueprintNode"("projectId", "status");
CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_cluster_idx" ON "BlueprintNode"("projectId", "cluster");
CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_outputPath_idx" ON "BlueprintNode"("projectId", "outputPath");
CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_deletedAt_idx" ON "BlueprintNode"("projectId", "deletedAt");
CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_idx" ON "BlueprintNodeRun"("projectId");
CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_graphId_idx" ON "BlueprintNodeRun"("projectId", "graphId");
CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_nodeId_idx" ON "BlueprintNodeRun"("projectId", "nodeId");
CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_status_idx" ON "BlueprintNodeRun"("projectId", "status");
CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_createdAt_idx" ON "BlueprintNodeRun"("projectId", "createdAt");
CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_deletedAt_idx" ON "BlueprintNodeRun"("projectId", "deletedAt");
@@ -0,0 +1,40 @@
CREATE TABLE "BrainstormSession" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"title" TEXT NOT NULL,
"prompt" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"mode" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'exploring',
"targetEntityId" TEXT,
"targetEntityType" TEXT,
"generatedBy" TEXT NOT NULL DEFAULT 'manual',
"contextSummary" TEXT NOT NULL,
"ideasJson" JSONB NOT NULL,
"clustersJson" JSONB NOT NULL,
"outcomesJson" JSONB NOT NULL,
"selectedIdeaIdsJson" JSONB NOT NULL,
"nextActionsJson" JSONB NOT NULL,
"metadataJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'brainstorm_cpu',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BrainstormSession_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "BrainstormSession_projectId_slug_key" ON "BrainstormSession"("projectId", "slug");
CREATE UNIQUE INDEX "BrainstormSession_projectId_readableId_key" ON "BrainstormSession"("projectId", "readableId");
CREATE INDEX "BrainstormSession_projectId_idx" ON "BrainstormSession"("projectId");
CREATE INDEX "BrainstormSession_projectId_scope_idx" ON "BrainstormSession"("projectId", "scope");
CREATE INDEX "BrainstormSession_projectId_mode_idx" ON "BrainstormSession"("projectId", "mode");
CREATE INDEX "BrainstormSession_projectId_status_idx" ON "BrainstormSession"("projectId", "status");
CREATE INDEX "BrainstormSession_projectId_targetEntityType_targetEntityId_idx" ON "BrainstormSession"("projectId", "targetEntityType", "targetEntityId");
CREATE INDEX "BrainstormSession_projectId_deletedAt_idx" ON "BrainstormSession"("projectId", "deletedAt");
ALTER TABLE "BrainstormSession" ADD CONSTRAINT "BrainstormSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
-- V72: persistent initiatives created from Brainstorm CPU roadmap outcomes.
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'initiative';
CREATE TABLE IF NOT EXISTS "Initiative" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"title" TEXT NOT NULL,
"intent" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'proposed',
"priority" TEXT NOT NULL DEFAULT 'normal',
"featureIdsJson" JSONB NOT NULL DEFAULT '[]',
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"confidence" INTEGER NOT NULL DEFAULT 0,
"sourceBrainstormId" TEXT,
"reviewItemId" TEXT,
"metadataJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'brainstorm_cpu',
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Initiative_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "Initiative_projectId_slug_key" ON "Initiative"("projectId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "Initiative_projectId_readableId_key" ON "Initiative"("projectId", "readableId");
CREATE INDEX IF NOT EXISTS "Initiative_projectId_idx" ON "Initiative"("projectId");
CREATE INDEX IF NOT EXISTS "Initiative_projectId_status_idx" ON "Initiative"("projectId", "status");
CREATE INDEX IF NOT EXISTS "Initiative_projectId_approvalState_idx" ON "Initiative"("projectId", "approvalState");
CREATE INDEX IF NOT EXISTS "Initiative_projectId_sourceBrainstormId_idx" ON "Initiative"("projectId", "sourceBrainstormId");
CREATE INDEX IF NOT EXISTS "Initiative_projectId_deletedAt_idx" ON "Initiative"("projectId", "deletedAt");
ALTER TABLE "Initiative" ADD CONSTRAINT "Initiative_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,63 @@
-- AlterEnum
ALTER TYPE "EntityType" ADD VALUE 'release_evidence_record';
-- CreateTable
CREATE TABLE "ReleaseEvidenceRecord" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"slug" TEXT,
"readableId" TEXT,
"capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"kind" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'captured',
"command" TEXT,
"outputText" TEXT,
"route" TEXT,
"source" TEXT NOT NULL DEFAULT 'runtime_readiness',
"confidence" INTEGER NOT NULL DEFAULT 100,
"approvalState" "ApprovalState" NOT NULL DEFAULT 'approved',
"evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'verified',
"verificationState" "VerificationState" NOT NULL DEFAULT 'verified',
"metadataJson" JSONB,
"provenanceJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'api',
"updatedBy" TEXT,
"sourceType" TEXT NOT NULL DEFAULT 'release_evidence',
"verifiedAt" TIMESTAMP(3),
"verifiedBy" TEXT,
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReleaseEvidenceRecord_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ReleaseEvidenceRecord_projectId_slug_key" ON "ReleaseEvidenceRecord"("projectId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "ReleaseEvidenceRecord_projectId_readableId_key" ON "ReleaseEvidenceRecord"("projectId", "readableId");
-- CreateIndex
CREATE INDEX "ReleaseEvidenceRecord_projectId_idx" ON "ReleaseEvidenceRecord"("projectId");
-- CreateIndex
CREATE INDEX "ReleaseEvidenceRecord_projectId_kind_idx" ON "ReleaseEvidenceRecord"("projectId", "kind");
-- CreateIndex
CREATE INDEX "ReleaseEvidenceRecord_projectId_status_idx" ON "ReleaseEvidenceRecord"("projectId", "status");
-- CreateIndex
CREATE INDEX "ReleaseEvidenceRecord_projectId_capturedAt_idx" ON "ReleaseEvidenceRecord"("projectId", "capturedAt");
-- CreateIndex
CREATE INDEX "ReleaseEvidenceRecord_projectId_verificationState_idx" ON "ReleaseEvidenceRecord"("projectId", "verificationState");
-- CreateIndex
CREATE INDEX "ReleaseEvidenceRecord_projectId_deletedAt_idx" ON "ReleaseEvidenceRecord"("projectId", "deletedAt");
-- AddForeignKey
ALTER TABLE "ReleaseEvidenceRecord" ADD CONSTRAINT "ReleaseEvidenceRecord_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,49 @@
CREATE TYPE "UserRole" AS ENUM ('owner', 'member');
CREATE TYPE "ProjectAccessRole" AS ENUM ('owner', 'editor', 'reviewer', 'reader');
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'owner',
"credentialVersion" INTEGER NOT NULL DEFAULT 1,
"disabledAt" TIMESTAMP(3),
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "AuthSession" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"csrfToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"credentialVersion" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuthSession_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ProjectMembership" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "ProjectAccessRole" NOT NULL DEFAULT 'owner',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectMembership_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "AuthSession_tokenHash_key" ON "AuthSession"("tokenHash");
CREATE INDEX "AuthSession_userId_expiresAt_idx" ON "AuthSession"("userId", "expiresAt");
CREATE INDEX "AuthSession_expiresAt_idx" ON "AuthSession"("expiresAt");
CREATE UNIQUE INDEX "ProjectMembership_projectId_userId_key" ON "ProjectMembership"("projectId", "userId");
CREATE INDEX "ProjectMembership_userId_idx" ON "ProjectMembership"("userId");
ALTER TABLE "AuthSession" ADD CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectMembership" ADD CONSTRAINT "ProjectMembership_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectMembership" ADD CONSTRAINT "ProjectMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,42 @@
CREATE TYPE "GiteaAccessMode" AS ENUM ('read_only', 'read_write');
CREATE TABLE "GiteaRepositoryLink" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"owner" TEXT NOT NULL,
"repository" TEXT NOT NULL,
"defaultBranch" TEXT NOT NULL,
"accessMode" "GiteaAccessMode" NOT NULL DEFAULT 'read_write',
"linkedBy" TEXT NOT NULL,
"lastVerifiedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GiteaRepositoryLink_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "McpAuditEvent" (
"id" TEXT NOT NULL,
"projectId" TEXT,
"operationId" TEXT NOT NULL,
"actor" TEXT NOT NULL,
"toolName" TEXT NOT NULL,
"repository" TEXT,
"branch" TEXT,
"outcome" TEXT NOT NULL,
"errorCode" TEXT,
"durationMs" INTEGER NOT NULL,
"metadataJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "McpAuditEvent_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "GiteaRepositoryLink_projectId_key" ON "GiteaRepositoryLink"("projectId");
CREATE UNIQUE INDEX "GiteaRepositoryLink_owner_repository_key" ON "GiteaRepositoryLink"("owner", "repository");
CREATE INDEX "GiteaRepositoryLink_owner_repository_idx" ON "GiteaRepositoryLink"("owner", "repository");
CREATE UNIQUE INDEX "McpAuditEvent_operationId_key" ON "McpAuditEvent"("operationId");
CREATE INDEX "McpAuditEvent_projectId_createdAt_idx" ON "McpAuditEvent"("projectId", "createdAt");
CREATE INDEX "McpAuditEvent_toolName_createdAt_idx" ON "McpAuditEvent"("toolName", "createdAt");
CREATE INDEX "McpAuditEvent_outcome_createdAt_idx" ON "McpAuditEvent"("outcome", "createdAt");
ALTER TABLE "GiteaRepositoryLink" ADD CONSTRAINT "GiteaRepositoryLink_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "McpAuditEvent" ADD CONSTRAINT "McpAuditEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
CREATE TABLE "ExternalIdentity" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"displayName" TEXT,
"preferredUsername" TEXT,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExternalIdentity_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "OidcLoginAttempt" (
"id" TEXT NOT NULL,
"stateHash" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"userId" TEXT,
"codeVerifier" TEXT NOT NULL,
"nonce" TEXT NOT NULL,
"returnTo" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OidcLoginAttempt_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ExternalIdentity_provider_tenantId_subject_key" ON "ExternalIdentity"("provider", "tenantId", "subject");
CREATE UNIQUE INDEX "ExternalIdentity_provider_userId_key" ON "ExternalIdentity"("provider", "userId");
CREATE INDEX "ExternalIdentity_userId_idx" ON "ExternalIdentity"("userId");
CREATE UNIQUE INDEX "OidcLoginAttempt_stateHash_key" ON "OidcLoginAttempt"("stateHash");
CREATE INDEX "OidcLoginAttempt_expiresAt_idx" ON "OidcLoginAttempt"("expiresAt");
CREATE INDEX "OidcLoginAttempt_userId_idx" ON "OidcLoginAttempt"("userId");
ALTER TABLE "ExternalIdentity" ADD CONSTRAINT "ExternalIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OidcLoginAttempt" ADD CONSTRAINT "OidcLoginAttempt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,220 @@
CREATE TYPE "RepositoryRole" AS ENUM ('primary', 'frontend', 'backend', 'infra', 'docs', 'shared', 'other');
CREATE TYPE "RepositorySyncStatus" AS ENUM ('unverified', 'ready', 'degraded', 'unavailable');
CREATE TYPE "DeliveryStatus" AS ENUM ('draft', 'awaiting_approval', 'approved', 'executing', 'validating', 'pull_request_open', 'merged', 'verified', 'released', 'blocked', 'failed', 'cancelled', 'superseded');
CREATE TYPE "DeliveryChangeOperation" AS ENUM ('create', 'update', 'delete', 'rename');
CREATE TYPE "DeliveryValidationStatus" AS ENUM ('pending', 'passed', 'failed', 'skipped');
CREATE TYPE "ExternalOperationStatus" AS ENUM ('prepared', 'executing', 'succeeded', 'failed', 'unknown');
DROP INDEX IF EXISTS "GiteaRepositoryLink_projectId_key";
ALTER TABLE "GiteaRepositoryLink"
ADD COLUMN "role" "RepositoryRole" NOT NULL DEFAULT 'primary',
ADD COLUMN "isPrimary" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "syncStatus" "RepositorySyncStatus" NOT NULL DEFAULT 'unverified',
ADD COLUMN "capabilitiesJson" JSONB,
ADD COLUMN "lastSyncAt" TIMESTAMP(3);
CREATE INDEX "GiteaRepositoryLink_projectId_isPrimary_idx" ON "GiteaRepositoryLink"("projectId", "isPrimary");
CREATE INDEX "GiteaRepositoryLink_projectId_role_idx" ON "GiteaRepositoryLink"("projectId", "role");
CREATE TABLE "DeliveryPlan" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"initiativeId" TEXT,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"intent" TEXT NOT NULL,
"priority" TEXT NOT NULL DEFAULT 'normal',
"problem" TEXT NOT NULL,
"desiredOutcome" TEXT NOT NULL,
"usersJson" JSONB NOT NULL DEFAULT '[]',
"goalsJson" JSONB NOT NULL,
"nonGoalsJson" JSONB NOT NULL DEFAULT '[]',
"acceptanceCriteriaJson" JSONB NOT NULL,
"implementationPlanJson" JSONB NOT NULL,
"risksJson" JSONB NOT NULL DEFAULT '[]',
"validationCommandsJson" JSONB NOT NULL,
"status" "DeliveryStatus" NOT NULL DEFAULT 'awaiting_approval',
"approvalState" "ApprovalState" NOT NULL DEFAULT 'pending',
"approvedAt" TIMESTAMP(3),
"approvedBy" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DeliveryPlan_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "DeliveryRun" (
"id" TEXT NOT NULL,
"deliveryPlanId" TEXT NOT NULL,
"repositoryLinkId" TEXT NOT NULL,
"branch" TEXT NOT NULL,
"baseBranch" TEXT NOT NULL,
"baseCommitSha" TEXT,
"headSha" TEXT,
"status" "DeliveryStatus" NOT NULL DEFAULT 'executing',
"pullRequestNumber" INTEGER,
"pullRequestUrl" TEXT,
"pullRequestTitle" TEXT,
"pullRequestState" TEXT,
"mergeCommitSha" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"pullRequestOpenedAt" TIMESTAMP(3),
"mergedAt" TIMESTAMP(3),
"verifiedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DeliveryRun_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "DeliveryFileChange" (
"id" TEXT NOT NULL,
"deliveryRunId" TEXT NOT NULL,
"path" TEXT NOT NULL,
"previousPath" TEXT,
"operation" "DeliveryChangeOperation" NOT NULL,
"beforeSha" TEXT,
"afterSha" TEXT,
"commitSha" TEXT,
"actor" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DeliveryFileChange_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "DeliveryValidation" (
"id" TEXT NOT NULL,
"deliveryRunId" TEXT NOT NULL,
"command" TEXT NOT NULL,
"checkName" TEXT,
"status" "DeliveryValidationStatus" NOT NULL DEFAULT 'pending',
"provider" TEXT,
"externalRunId" TEXT,
"externalUrl" TEXT,
"headSha" TEXT,
"summary" TEXT,
"verifiedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DeliveryValidation_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ExternalOperation" (
"id" TEXT NOT NULL,
"operationId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"deliveryRunId" TEXT,
"actor" TEXT NOT NULL,
"action" TEXT NOT NULL,
"targetJson" JSONB NOT NULL,
"status" "ExternalOperationStatus" NOT NULL DEFAULT 'prepared',
"resultJson" JSONB,
"errorCode" TEXT,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ExternalOperation_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "OutboxEvent" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"aggregateType" TEXT NOT NULL,
"aggregateId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"payloadJson" JSONB NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"publishedAt" TIMESTAMP(3),
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OutboxEvent_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "DeliveryPlan_initiativeId_key" ON "DeliveryPlan"("initiativeId");
CREATE UNIQUE INDEX "DeliveryPlan_projectId_slug_key" ON "DeliveryPlan"("projectId", "slug");
CREATE INDEX "DeliveryPlan_projectId_status_idx" ON "DeliveryPlan"("projectId", "status");
CREATE INDEX "DeliveryPlan_projectId_updatedAt_idx" ON "DeliveryPlan"("projectId", "updatedAt");
CREATE UNIQUE INDEX "DeliveryRun_repositoryLinkId_branch_key" ON "DeliveryRun"("repositoryLinkId", "branch");
CREATE INDEX "DeliveryRun_deliveryPlanId_status_idx" ON "DeliveryRun"("deliveryPlanId", "status");
CREATE INDEX "DeliveryRun_pullRequestNumber_idx" ON "DeliveryRun"("pullRequestNumber");
CREATE INDEX "DeliveryFileChange_deliveryRunId_createdAt_idx" ON "DeliveryFileChange"("deliveryRunId", "createdAt");
CREATE INDEX "DeliveryFileChange_deliveryRunId_path_idx" ON "DeliveryFileChange"("deliveryRunId", "path");
CREATE INDEX "DeliveryValidation_deliveryRunId_status_idx" ON "DeliveryValidation"("deliveryRunId", "status");
CREATE INDEX "DeliveryValidation_deliveryRunId_headSha_idx" ON "DeliveryValidation"("deliveryRunId", "headSha");
CREATE UNIQUE INDEX "ExternalOperation_operationId_key" ON "ExternalOperation"("operationId");
CREATE INDEX "ExternalOperation_projectId_status_createdAt_idx" ON "ExternalOperation"("projectId", "status", "createdAt");
CREATE INDEX "ExternalOperation_deliveryRunId_createdAt_idx" ON "ExternalOperation"("deliveryRunId", "createdAt");
CREATE UNIQUE INDEX "OutboxEvent_eventId_key" ON "OutboxEvent"("eventId");
CREATE INDEX "OutboxEvent_projectId_createdAt_idx" ON "OutboxEvent"("projectId", "createdAt");
CREATE INDEX "OutboxEvent_publishedAt_createdAt_idx" ON "OutboxEvent"("publishedAt", "createdAt");
ALTER TABLE "DeliveryPlan" ADD CONSTRAINT "DeliveryPlan_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DeliveryPlan" ADD CONSTRAINT "DeliveryPlan_initiativeId_fkey" FOREIGN KEY ("initiativeId") REFERENCES "Initiative"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "DeliveryRun" ADD CONSTRAINT "DeliveryRun_deliveryPlanId_fkey" FOREIGN KEY ("deliveryPlanId") REFERENCES "DeliveryPlan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DeliveryRun" ADD CONSTRAINT "DeliveryRun_repositoryLinkId_fkey" FOREIGN KEY ("repositoryLinkId") REFERENCES "GiteaRepositoryLink"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "DeliveryFileChange" ADD CONSTRAINT "DeliveryFileChange_deliveryRunId_fkey" FOREIGN KEY ("deliveryRunId") REFERENCES "DeliveryRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DeliveryValidation" ADD CONSTRAINT "DeliveryValidation_deliveryRunId_fkey" FOREIGN KEY ("deliveryRunId") REFERENCES "DeliveryRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ExternalOperation" ADD CONSTRAINT "ExternalOperation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ExternalOperation" ADD CONSTRAINT "ExternalOperation_deliveryRunId_fkey" FOREIGN KEY ("deliveryRunId") REFERENCES "DeliveryRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OutboxEvent" ADD CONSTRAINT "OutboxEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
INSERT INTO "DeliveryPlan" (
"id", "projectId", "initiativeId", "slug", "title", "intent", "priority", "problem", "desiredOutcome",
"usersJson", "goalsJson", "nonGoalsJson", "acceptanceCriteriaJson", "implementationPlanJson", "risksJson",
"validationCommandsJson", "status", "approvalState", "approvedAt", "approvedBy", "createdBy", "updatedBy", "createdAt", "updatedAt"
)
SELECT
'delivery-plan-' || substr(md5(i."id"), 1, 20), i."projectId", i."id", COALESCE(i."slug", i."id"), i."title", i."intent", i."priority",
COALESCE(i."metadataJson"->>'problem', i."intent"), COALESCE(i."metadataJson"->>'desiredOutcome', i."intent"),
COALESCE(i."metadataJson"->'users', '[]'::jsonb), COALESCE(i."metadataJson"->'goals', '[]'::jsonb),
COALESCE(i."metadataJson"->'nonGoals', '[]'::jsonb), COALESCE(i."metadataJson"->'acceptanceCriteria', '[]'::jsonb),
COALESCE(i."metadataJson"->'implementationPlan', '[]'::jsonb), COALESCE(i."metadataJson"->'risks', '[]'::jsonb),
COALESCE(i."metadataJson"->'validationCommands', '[]'::jsonb),
CASE COALESCE(i."metadataJson"->>'status', 'draft')
WHEN 'ready_for_approval' THEN 'awaiting_approval'::"DeliveryStatus"
WHEN 'approved' THEN 'approved'::"DeliveryStatus"
WHEN 'in_progress' THEN 'executing'::"DeliveryStatus"
WHEN 'ready_for_review' THEN 'validating'::"DeliveryStatus"
WHEN 'completed' THEN 'pull_request_open'::"DeliveryStatus"
WHEN 'blocked' THEN 'blocked'::"DeliveryStatus"
ELSE 'draft'::"DeliveryStatus"
END,
i."approvalState", CASE WHEN i."approvalState" = 'approved' THEN i."updatedAt" ELSE NULL END,
CASE WHEN i."approvalState" = 'approved' THEN COALESCE(i."updatedBy", 'owner') ELSE NULL END,
i."createdBy", i."updatedBy", i."createdAt", i."updatedAt"
FROM "Initiative" i
WHERE i."sourceType" = 'chatgpt_feature_delivery' AND i."deletedAt" IS NULL
ON CONFLICT ("initiativeId") DO NOTHING;
INSERT INTO "DeliveryRun" (
"id", "deliveryPlanId", "repositoryLinkId", "branch", "baseBranch", "headSha", "status",
"pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState", "startedAt", "pullRequestOpenedAt",
"createdBy", "updatedBy", "createdAt", "updatedAt"
)
SELECT
'delivery-run-' || substr(md5(dp."id" || l."id"), 1, 20), dp."id", l."id", i."metadataJson"->>'branch', l."defaultBranch", NULL,
CASE COALESCE(i."metadataJson"->>'status', 'in_progress')
WHEN 'ready_for_review' THEN 'validating'::"DeliveryStatus"
WHEN 'completed' THEN 'pull_request_open'::"DeliveryStatus"
ELSE 'executing'::"DeliveryStatus"
END,
CASE WHEN (i."metadataJson"->'pullRequest'->>'number') ~ '^[0-9]+$' THEN (i."metadataJson"->'pullRequest'->>'number')::integer ELSE NULL END,
i."metadataJson"->'pullRequest'->>'url', i."metadataJson"->'pullRequest'->>'title',
CASE WHEN i."metadataJson" ? 'pullRequest' THEN 'open' ELSE NULL END,
i."updatedAt", CASE WHEN i."metadataJson" ? 'pullRequest' THEN i."updatedAt" ELSE NULL END,
COALESCE(i."updatedBy", i."createdBy"), i."updatedBy", i."createdAt", i."updatedAt"
FROM "DeliveryPlan" dp
JOIN "Initiative" i ON i."id" = dp."initiativeId"
JOIN LATERAL (
SELECT gl.* FROM "GiteaRepositoryLink" gl
WHERE gl."projectId" = dp."projectId"
ORDER BY gl."isPrimary" DESC, gl."createdAt" ASC
LIMIT 1
) l ON true
WHERE COALESCE(i."metadataJson"->>'branch', '') <> ''
ON CONFLICT ("repositoryLinkId", "branch") DO NOTHING;
@@ -0,0 +1 @@
ALTER TABLE "DeliveryPlan" ADD COLUMN "reviewFeedback" TEXT;
@@ -0,0 +1,76 @@
CREATE TYPE "AnalysisJobKind" AS ENUM ('portfolio_health');
CREATE TYPE "AnalysisJobStatus" AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled');
CREATE TYPE "AnalysisJobItemStatus" AS ENUM ('pending', 'running', 'succeeded', 'failed', 'cancelled');
CREATE TABLE "AnalysisJob" (
"id" TEXT NOT NULL,
"kind" "AnalysisJobKind" NOT NULL DEFAULT 'portfolio_health',
"status" "AnalysisJobStatus" NOT NULL DEFAULT 'queued',
"actor" TEXT NOT NULL,
"requestJson" JSONB NOT NULL,
"requestHash" TEXT NOT NULL,
"activeDedupeKey" TEXT,
"progressCurrent" INTEGER NOT NULL DEFAULT 0,
"progressTotal" INTEGER NOT NULL DEFAULT 0,
"summaryText" TEXT,
"summaryJson" JSONB,
"errorCode" TEXT,
"cancelRequestedAt" TIMESTAMP(3),
"queuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"startedAt" TIMESTAMP(3),
"heartbeatAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AnalysisJob_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "AnalysisJobItem" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"repositoryLinkId" TEXT,
"projectId" TEXT,
"projectSlug" TEXT,
"projectName" TEXT,
"owner" TEXT NOT NULL,
"repository" TEXT NOT NULL,
"defaultBranch" TEXT NOT NULL,
"accessMode" "GiteaAccessMode" NOT NULL,
"sequence" INTEGER NOT NULL,
"status" "AnalysisJobItemStatus" NOT NULL DEFAULT 'pending',
"summary" TEXT,
"detailJson" JSONB,
"findingCount" INTEGER NOT NULL DEFAULT 0,
"errorCode" TEXT,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AnalysisJobItem_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AnalysisJob_activeDedupeKey_key" ON "AnalysisJob"("activeDedupeKey");
CREATE INDEX "AnalysisJob_status_queuedAt_idx" ON "AnalysisJob"("status", "queuedAt");
CREATE INDEX "AnalysisJob_actor_createdAt_idx" ON "AnalysisJob"("actor", "createdAt");
CREATE INDEX "AnalysisJob_expiresAt_idx" ON "AnalysisJob"("expiresAt");
CREATE UNIQUE INDEX "AnalysisJobItem_jobId_owner_repository_key" ON "AnalysisJobItem"("jobId", "owner", "repository");
CREATE INDEX "AnalysisJobItem_jobId_sequence_idx" ON "AnalysisJobItem"("jobId", "sequence");
CREATE INDEX "AnalysisJobItem_projectId_createdAt_idx" ON "AnalysisJobItem"("projectId", "createdAt");
CREATE INDEX "AnalysisJobItem_repositoryLinkId_createdAt_idx" ON "AnalysisJobItem"("repositoryLinkId", "createdAt");
ALTER TABLE "AnalysisJobItem" ADD CONSTRAINT "AnalysisJobItem_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "AnalysisJob"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AnalysisJobItem" ADD CONSTRAINT "AnalysisJobItem_repositoryLinkId_fkey" FOREIGN KEY ("repositoryLinkId") REFERENCES "GiteaRepositoryLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE TABLE "AnalysisWorkerHeartbeat" (
"id" TEXT NOT NULL,
"instanceId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"activeJobId" TEXT,
"detail" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AnalysisWorkerHeartbeat_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AnalysisWorkerHeartbeat_lastSeenAt_idx" ON "AnalysisWorkerHeartbeat"("lastSeenAt");
@@ -0,0 +1,129 @@
-- Closed-loop repository findings, Gitea issue synchronization and investigations.
CREATE TYPE "FindingSeverity" AS ENUM ('info', 'warning', 'critical');
CREATE TYPE "FindingStatus" AS ENUM ('new', 'triaged', 'issue_open', 'investigating', 'planned', 'fixing', 'verifying', 'resolved', 'regressed', 'suppressed');
CREATE TYPE "InvestigationStatus" AS ENUM ('collecting', 'ready', 'concluded', 'planned', 'fixing', 'verifying', 'resolved', 'failed', 'cancelled');
CREATE TYPE "IssueSyncMode" AS ENUM ('manual', 'review', 'automatic');
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'repository_finding';
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'finding_investigation';
ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'gitea_issue';
CREATE TABLE "RepositoryFinding" (
"id" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"projectId" TEXT,
"repositoryLinkId" TEXT,
"owner" TEXT NOT NULL,
"repository" TEXT NOT NULL,
"ruleCode" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"severity" "FindingSeverity" NOT NULL,
"confidence" INTEGER NOT NULL DEFAULT 90,
"riskScore" INTEGER NOT NULL DEFAULT 0,
"status" "FindingStatus" NOT NULL DEFAULT 'new',
"occurrenceCount" INTEGER NOT NULL DEFAULT 1,
"clearCount" INTEGER NOT NULL DEFAULT 0,
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolvedAt" TIMESTAMP(3),
"suppressedAt" TIMESTAMP(3),
"suppressedUntil" TIMESTAMP(3),
"suppressionReason" TEXT,
"lastAnalysisJobId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RepositoryFinding_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FindingOccurrence" (
"id" TEXT NOT NULL,
"findingId" TEXT NOT NULL,
"analysisJobItemId" TEXT NOT NULL,
"observed" BOOLEAN NOT NULL DEFAULT true,
"detailJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FindingOccurrence_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "GiteaIssueLink" (
"id" TEXT NOT NULL,
"findingId" TEXT NOT NULL,
"owner" TEXT NOT NULL,
"repository" TEXT NOT NULL,
"issueNumber" INTEGER NOT NULL,
"issueUrl" TEXT NOT NULL,
"state" TEXT NOT NULL DEFAULT 'open',
"title" TEXT NOT NULL,
"bodyFingerprint" TEXT NOT NULL,
"lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GiteaIssueLink_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FindingInvestigation" (
"id" TEXT NOT NULL,
"findingId" TEXT NOT NULL,
"projectId" TEXT,
"deliveryPlanId" TEXT,
"status" "InvestigationStatus" NOT NULL DEFAULT 'collecting',
"actor" TEXT NOT NULL,
"contextJson" JSONB NOT NULL,
"diagnosis" TEXT,
"rootCause" TEXT,
"proposedChangesJson" JSONB NOT NULL DEFAULT '[]',
"confidence" INTEGER,
"conclusion" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"concludedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FindingInvestigation_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FindingAutomationPolicy" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"issueSyncMode" "IssueSyncMode" NOT NULL DEFAULT 'review',
"minimumSeverity" "FindingSeverity" NOT NULL DEFAULT 'warning',
"minimumOccurrences" INTEGER NOT NULL DEFAULT 2,
"maxIssuesPerRun" INTEGER NOT NULL DEFAULT 5,
"autoCloseResolved" BOOLEAN NOT NULL DEFAULT true,
"autoReopenRegressed" BOOLEAN NOT NULL DEFAULT true,
"scheduledAnalysis" BOOLEAN NOT NULL DEFAULT true,
"scheduleMinutes" INTEGER NOT NULL DEFAULT 1440,
"updatedBy" TEXT NOT NULL DEFAULT 'system',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FindingAutomationPolicy_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "RepositoryFinding_fingerprint_key" ON "RepositoryFinding"("fingerprint");
CREATE INDEX "RepositoryFinding_projectId_status_severity_idx" ON "RepositoryFinding"("projectId", "status", "severity");
CREATE INDEX "RepositoryFinding_repositoryLinkId_status_idx" ON "RepositoryFinding"("repositoryLinkId", "status");
CREATE INDEX "RepositoryFinding_owner_repository_status_idx" ON "RepositoryFinding"("owner", "repository", "status");
CREATE INDEX "RepositoryFinding_lastSeenAt_idx" ON "RepositoryFinding"("lastSeenAt");
CREATE UNIQUE INDEX "FindingOccurrence_findingId_analysisJobItemId_key" ON "FindingOccurrence"("findingId", "analysisJobItemId");
CREATE INDEX "FindingOccurrence_analysisJobItemId_idx" ON "FindingOccurrence"("analysisJobItemId");
CREATE UNIQUE INDEX "GiteaIssueLink_findingId_key" ON "GiteaIssueLink"("findingId");
CREATE UNIQUE INDEX "GiteaIssueLink_owner_repository_issueNumber_key" ON "GiteaIssueLink"("owner", "repository", "issueNumber");
CREATE INDEX "GiteaIssueLink_state_updatedAt_idx" ON "GiteaIssueLink"("state", "updatedAt");
CREATE UNIQUE INDEX "FindingInvestigation_deliveryPlanId_key" ON "FindingInvestigation"("deliveryPlanId");
CREATE INDEX "FindingInvestigation_findingId_createdAt_idx" ON "FindingInvestigation"("findingId", "createdAt");
CREATE INDEX "FindingInvestigation_projectId_status_idx" ON "FindingInvestigation"("projectId", "status");
CREATE UNIQUE INDEX "FindingAutomationPolicy_projectId_key" ON "FindingAutomationPolicy"("projectId");
ALTER TABLE "RepositoryFinding" ADD CONSTRAINT "RepositoryFinding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "RepositoryFinding" ADD CONSTRAINT "RepositoryFinding_repositoryLinkId_fkey" FOREIGN KEY ("repositoryLinkId") REFERENCES "GiteaRepositoryLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FindingOccurrence" ADD CONSTRAINT "FindingOccurrence_findingId_fkey" FOREIGN KEY ("findingId") REFERENCES "RepositoryFinding"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FindingOccurrence" ADD CONSTRAINT "FindingOccurrence_analysisJobItemId_fkey" FOREIGN KEY ("analysisJobItemId") REFERENCES "AnalysisJobItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "GiteaIssueLink" ADD CONSTRAINT "GiteaIssueLink_findingId_fkey" FOREIGN KEY ("findingId") REFERENCES "RepositoryFinding"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FindingInvestigation" ADD CONSTRAINT "FindingInvestigation_findingId_fkey" FOREIGN KEY ("findingId") REFERENCES "RepositoryFinding"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FindingInvestigation" ADD CONSTRAINT "FindingInvestigation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FindingInvestigation" ADD CONSTRAINT "FindingInvestigation_deliveryPlanId_fkey" FOREIGN KEY ("deliveryPlanId") REFERENCES "DeliveryPlan"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FindingAutomationPolicy" ADD CONSTRAINT "FindingAutomationPolicy_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,30 @@
-- Durable Gitea portfolio discovery and onboarding state.
CREATE TYPE "PortfolioRepositoryStatus" AS ENUM ('discovered', 'onboarded', 'excluded', 'unavailable');
CREATE TABLE "PortfolioRepository" (
"id" TEXT NOT NULL,
"owner" TEXT NOT NULL,
"repository" TEXT NOT NULL,
"projectId" TEXT,
"description" TEXT,
"defaultBranch" TEXT NOT NULL,
"htmlUrl" TEXT,
"private" BOOLEAN NOT NULL DEFAULT true,
"archived" BOOLEAN NOT NULL DEFAULT false,
"canPush" BOOLEAN NOT NULL DEFAULT false,
"status" "PortfolioRepositoryStatus" NOT NULL DEFAULT 'discovered',
"exclusionReason" TEXT,
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"onboardedAt" TIMESTAMP(3),
"excludedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PortfolioRepository_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PortfolioRepository_owner_repository_key" ON "PortfolioRepository"("owner", "repository");
CREATE INDEX "PortfolioRepository_status_lastSeenAt_idx" ON "PortfolioRepository"("status", "lastSeenAt");
CREATE INDEX "PortfolioRepository_projectId_idx" ON "PortfolioRepository"("projectId");
ALTER TABLE "PortfolioRepository" ADD CONSTRAINT "PortfolioRepository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TYPE "AnalysisJobStatus" ADD VALUE IF NOT EXISTS 'partial';
@@ -0,0 +1,25 @@
CREATE TABLE "ProjectSource" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"repositoryLinkId" TEXT,
"title" TEXT NOT NULL,
"kind" TEXT NOT NULL DEFAULT 'document',
"inputMode" TEXT NOT NULL DEFAULT 'git',
"path" TEXT,
"url" TEXT,
"content" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'processed',
"branch" TEXT,
"contentSha" TEXT,
"commitSha" TEXT,
"lastSyncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectSource_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProjectSource_repositoryLinkId_path_key" ON "ProjectSource"("repositoryLinkId", "path");
CREATE INDEX "ProjectSource_projectId_status_idx" ON "ProjectSource"("projectId", "status");
CREATE INDEX "ProjectSource_repositoryLinkId_lastSyncedAt_idx" ON "ProjectSource"("repositoryLinkId", "lastSyncedAt");
ALTER TABLE "ProjectSource" ADD CONSTRAINT "ProjectSource_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectSource" ADD CONSTRAINT "ProjectSource_repositoryLinkId_fkey" FOREIGN KEY ("repositoryLinkId") REFERENCES "GiteaRepositoryLink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- Historical fixture and smoke records are useful evidence, but were never
-- real owner decisions. Close only those narrowly identified pending items.
UPDATE "ReviewQueueItem"
SET
"status" = 'rejected',
"approvalState" = 'rejected',
"notes" = CASE
WHEN "notes" IS NULL OR "notes" = '' THEN 'Automatically closed: synthetic seed or smoke evidence is not a human attention item.'
ELSE "notes" || E'\nAutomatically closed: synthetic seed or smoke evidence is not a human attention item.'
END,
"updatedBy" = 'attention_policy_migration',
"updatedAt" = CURRENT_TIMESTAMP
WHERE
"status" = 'pending'
AND (
"createdBy" = 'seed'
OR LOWER("source") LIKE '%smoke%'
OR LOWER("sourceType") LIKE '%smoke%'
);
@@ -0,0 +1,15 @@
CREATE TABLE "AutonomyExecutorHeartbeat" (
"id" TEXT NOT NULL,
"instanceId" TEXT NOT NULL,
"runId" TEXT,
"status" TEXT NOT NULL,
"activeProjectId" TEXT,
"detail" TEXT,
"startedAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutonomyExecutorHeartbeat_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AutonomyExecutorHeartbeat_lastSeenAt_idx" ON "AutonomyExecutorHeartbeat"("lastSeenAt");
@@ -0,0 +1,164 @@
-- Durable cross-domain work graph, completion contract, saved views and diagnostics.
-- Existing domain records remain authoritative for their domain; these tables provide
-- the shared operating-system projection and its durable dependency/audit edges.
CREATE TABLE "WorkGraphNode" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"stableKey" TEXT NOT NULL,
"source" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"canonicalLifecycle" TEXT NOT NULL DEFAULT 'ready',
"domainLifecycle" TEXT NOT NULL DEFAULT 'pending',
"scope" TEXT NOT NULL DEFAULT 'committed',
"priority" INTEGER NOT NULL DEFAULT 50,
"phase" TEXT,
"risk" TEXT NOT NULL DEFAULT 'normal',
"blockingStatus" TEXT NOT NULL DEFAULT 'open',
"executionEligibility" TEXT NOT NULL DEFAULT 'blocked',
"blockReason" TEXT,
"owner" TEXT,
"leaseToken" TEXT,
"leaseExpiresAt" TIMESTAMP(3),
"attemptCount" INTEGER NOT NULL DEFAULT 0,
"consecutiveFailureCount" INTEGER NOT NULL DEFAULT 0,
"recoveryStartedAt" TIMESTAMP(3),
"lastMeaningfulProgressAt" TIMESTAMP(3),
"completionEvidenceJson" JSONB,
"metadataJson" JSONB,
"supersededById" TEXT,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkGraphNode_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "WorkGraphDependency" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"dependsOnNodeId" TEXT NOT NULL,
"dependencyType" TEXT NOT NULL DEFAULT 'blocks',
"createdBy" TEXT NOT NULL DEFAULT 'system',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkGraphDependency_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "WorkGraphAuditEvent" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"nodeId" TEXT,
"eventType" TEXT NOT NULL,
"actor" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"payloadJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkGraphAuditEvent_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "WorkGraphEvidence" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"referenceType" TEXT NOT NULL,
"referenceId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'observed',
"summary" TEXT NOT NULL,
"metadataJson" JSONB,
"verifiedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkGraphEvidence_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ProjectDefinitionOfDone" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"requirementsJson" JSONB NOT NULL,
"scopePolicyJson" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'incomplete',
"satisfiedCount" INTEGER NOT NULL DEFAULT 0,
"requirementCount" INTEGER NOT NULL DEFAULT 0,
"lastEvaluatedAt" TIMESTAMP(3),
"lastEvidenceAt" TIMESTAMP(3),
"evaluationJson" JSONB,
"createdBy" TEXT NOT NULL DEFAULT 'system',
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectDefinitionOfDone_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ProjectSavedView" (
"id" TEXT NOT NULL,
"ownerKey" TEXT NOT NULL,
"projectId" TEXT,
"name" TEXT NOT NULL,
"scope" TEXT NOT NULL DEFAULT 'portfolio',
"filtersJson" JSONB NOT NULL,
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectSavedView_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "DiagnosticRun" (
"id" TEXT NOT NULL,
"projectId" TEXT,
"scope" TEXT NOT NULL DEFAULT 'platform',
"status" TEXT NOT NULL DEFAULT 'running',
"summary" TEXT,
"checksJson" JSONB NOT NULL,
"repairPlanJson" JSONB,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL DEFAULT 'system',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DiagnosticRun_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "WorkGraphNode_projectId_stableKey_key" ON "WorkGraphNode"("projectId", "stableKey");
CREATE UNIQUE INDEX "WorkGraphNode_projectId_source_sourceId_key" ON "WorkGraphNode"("projectId", "source", "sourceId");
CREATE INDEX "WorkGraphNode_projectId_executionEligibility_priority_idx" ON "WorkGraphNode"("projectId", "executionEligibility", "priority");
CREATE INDEX "WorkGraphNode_projectId_canonicalLifecycle_idx" ON "WorkGraphNode"("projectId", "canonicalLifecycle");
CREATE INDEX "WorkGraphNode_projectId_scope_idx" ON "WorkGraphNode"("projectId", "scope");
CREATE INDEX "WorkGraphNode_projectId_leaseExpiresAt_idx" ON "WorkGraphNode"("projectId", "leaseExpiresAt");
CREATE INDEX "WorkGraphNode_projectId_updatedAt_idx" ON "WorkGraphNode"("projectId", "updatedAt");
CREATE INDEX "WorkGraphNode_projectId_supersededById_idx" ON "WorkGraphNode"("projectId", "supersededById");
CREATE UNIQUE INDEX "WorkGraphDependency_nodeId_dependsOnNodeId_key" ON "WorkGraphDependency"("nodeId", "dependsOnNodeId");
CREATE INDEX "WorkGraphDependency_projectId_nodeId_idx" ON "WorkGraphDependency"("projectId", "nodeId");
CREATE INDEX "WorkGraphDependency_projectId_dependsOnNodeId_idx" ON "WorkGraphDependency"("projectId", "dependsOnNodeId");
CREATE INDEX "WorkGraphAuditEvent_projectId_createdAt_idx" ON "WorkGraphAuditEvent"("projectId", "createdAt");
CREATE INDEX "WorkGraphAuditEvent_projectId_nodeId_createdAt_idx" ON "WorkGraphAuditEvent"("projectId", "nodeId", "createdAt");
CREATE INDEX "WorkGraphAuditEvent_projectId_eventType_idx" ON "WorkGraphAuditEvent"("projectId", "eventType");
CREATE UNIQUE INDEX "WorkGraphEvidence_nodeId_kind_referenceType_referenceId_key" ON "WorkGraphEvidence"("nodeId", "kind", "referenceType", "referenceId");
CREATE INDEX "WorkGraphEvidence_projectId_nodeId_idx" ON "WorkGraphEvidence"("projectId", "nodeId");
CREATE INDEX "WorkGraphEvidence_projectId_status_idx" ON "WorkGraphEvidence"("projectId", "status");
CREATE UNIQUE INDEX "ProjectDefinitionOfDone_projectId_key" ON "ProjectDefinitionOfDone"("projectId");
CREATE INDEX "ProjectDefinitionOfDone_status_lastEvaluatedAt_idx" ON "ProjectDefinitionOfDone"("status", "lastEvaluatedAt");
CREATE UNIQUE INDEX "ProjectSavedView_ownerKey_scope_name_key" ON "ProjectSavedView"("ownerKey", "scope", "name");
CREATE INDEX "ProjectSavedView_ownerKey_scope_updatedAt_idx" ON "ProjectSavedView"("ownerKey", "scope", "updatedAt");
CREATE INDEX "ProjectSavedView_projectId_updatedAt_idx" ON "ProjectSavedView"("projectId", "updatedAt");
CREATE INDEX "DiagnosticRun_projectId_createdAt_idx" ON "DiagnosticRun"("projectId", "createdAt");
CREATE INDEX "DiagnosticRun_scope_status_createdAt_idx" ON "DiagnosticRun"("scope", "status", "createdAt");
ALTER TABLE "WorkGraphNode" ADD CONSTRAINT "WorkGraphNode_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WorkGraphNode" ADD CONSTRAINT "WorkGraphNode_supersededById_fkey" FOREIGN KEY ("supersededById") REFERENCES "WorkGraphNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "WorkGraphDependency" ADD CONSTRAINT "WorkGraphDependency_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WorkGraphDependency" ADD CONSTRAINT "WorkGraphDependency_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "WorkGraphNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WorkGraphDependency" ADD CONSTRAINT "WorkGraphDependency_dependsOnNodeId_fkey" FOREIGN KEY ("dependsOnNodeId") REFERENCES "WorkGraphNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WorkGraphAuditEvent" ADD CONSTRAINT "WorkGraphAuditEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WorkGraphAuditEvent" ADD CONSTRAINT "WorkGraphAuditEvent_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "WorkGraphNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WorkGraphEvidence" ADD CONSTRAINT "WorkGraphEvidence_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WorkGraphEvidence" ADD CONSTRAINT "WorkGraphEvidence_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "WorkGraphNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectDefinitionOfDone" ADD CONSTRAINT "ProjectDefinitionOfDone_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectSavedView" ADD CONSTRAINT "ProjectSavedView_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "DiagnosticRun" ADD CONSTRAINT "DiagnosticRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- Durable per-user project pins. Saved views reference these pins rather than
-- pretending that a view-level bookmark can identify projects on its own.
CREATE TABLE "ProjectPortfolioPin" (
"id" TEXT NOT NULL,
"ownerKey" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProjectPortfolioPin_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProjectPortfolioPin_ownerKey_projectId_key" ON "ProjectPortfolioPin"("ownerKey", "projectId");
CREATE INDEX "ProjectPortfolioPin_ownerKey_createdAt_idx" ON "ProjectPortfolioPin"("ownerKey", "createdAt");
CREATE INDEX "ProjectPortfolioPin_projectId_idx" ON "ProjectPortfolioPin"("projectId");
ALTER TABLE "ProjectPortfolioPin" ADD CONSTRAINT "ProjectPortfolioPin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g. Git)
provider = "postgresql"
File diff suppressed because it is too large. Load diff
+828
View File
@@ -0,0 +1,828 @@
import {
ApprovalState,
EvidenceStrength,
FeatureLifecycle,
FeatureStatus,
ReviewStatus,
PrismaClient,
VerificationState,
type Prisma,
} from "@prisma/client";
import {
findProject,
getWorkspaceRead,
} from "../src/fixtures/projectFixtures.js";
import { contextPackFixtures } from "../src/fixtures/contextPackFixtures.js";
import { knowledgeFixtures } from "../src/fixtures/knowledgeFixtures.js";
import { memoryRuleFixtures } from "../src/fixtures/memoryRuleFixtures.js";
import { captureFixtures } from "../src/fixtures/captureFixtures.js";
import { reviewQueueFixtures } from "../src/fixtures/reviewQueueFixtures.js";
import {
evidenceFixtures,
featureFixtures,
featureGroupFixtures,
} from "../src/fixtures/featureFixtures.js";
const prisma = new PrismaClient();
const widefrog = findProject("widefrog");
const toJson = (value: unknown): Prisma.InputJsonValue =>
JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
const main = async () => {
if (!widefrog) {
throw new Error("WideFrog fixture is missing; cannot seed P2 database.");
}
const project = await prisma.project.upsert({
where: { slug: widefrog.id },
create: {
slug: widefrog.id,
readableId: widefrog.id,
name: widefrog.name,
description: widefrog.description,
domain: widefrog.domain,
status: widefrog.status,
health: widefrog.health,
healthStatus: widefrog.healthStatus,
activeContextPackId: widefrog.activeContextPackId,
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
createdAt: new Date(widefrog.createdAt),
},
update: {
readableId: widefrog.id,
name: widefrog.name,
description: widefrog.description,
domain: widefrog.domain,
status: widefrog.status,
health: widefrog.health,
healthStatus: widefrog.healthStatus,
activeContextPackId: widefrog.activeContextPackId,
updatedBy: "seed",
sourceType: "fixture_seed",
deletedAt: null,
archivedAt: null,
},
});
await prisma.projectDNA.upsert({
where: { projectId: project.id },
create: {
projectId: project.id,
vision: widefrog.dna.vision,
goalsJson: toJson(widefrog.dna.goals),
nonGoalsJson: toJson(widefrog.dna.nonGoals),
targetAudience: widefrog.dna.targetAudience,
principlesJson: toJson(widefrog.dna.principles),
metricsJson: toJson(widefrog.dna.successMetrics),
maturityStage: widefrog.dna.maturityStage,
revision: widefrog.dna.revision,
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
},
update: {
vision: widefrog.dna.vision,
goalsJson: toJson(widefrog.dna.goals),
nonGoalsJson: toJson(widefrog.dna.nonGoals),
targetAudience: widefrog.dna.targetAudience,
principlesJson: toJson(widefrog.dna.principles),
metricsJson: toJson(widefrog.dna.successMetrics),
maturityStage: widefrog.dna.maturityStage,
revision: widefrog.dna.revision,
updatedBy: "seed",
sourceType: "fixture_seed",
},
});
const workspaceRead = getWorkspaceRead(widefrog);
const workspaceState = widefrog.workspaceState;
if (workspaceState) {
await prisma.workspaceState.upsert({
where: { projectId: project.id },
create: {
projectId: project.id,
activeContextPackId: workspaceState.activeContextPackId,
primaryObjective: workspaceState.primaryObjective,
activeSection: workspaceState.activeSection,
notesJson: toJson({
workspaceState,
focusAreas: workspaceRead.focusAreas,
activeFocusAreaId: workspaceRead.activeFocusAreaId,
currentStateSummary: workspaceRead.currentStateSummary,
healthSummary: workspaceRead.healthSummary,
}),
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
},
update: {
activeContextPackId: workspaceState.activeContextPackId,
primaryObjective: workspaceState.primaryObjective,
activeSection: workspaceState.activeSection,
notesJson: toJson({
workspaceState,
focusAreas: workspaceRead.focusAreas,
activeFocusAreaId: workspaceRead.activeFocusAreaId,
currentStateSummary: workspaceRead.currentStateSummary,
healthSummary: workspaceRead.healthSummary,
}),
updatedBy: "seed",
sourceType: "fixture_seed",
},
});
}
const seededProjects = new Map([[widefrog.id, project.id]]);
for (const group of featureGroupFixtures.filter((item) => seededProjects.has(item.projectId))) {
const projectId = seededProjects.get(group.projectId)!;
await prisma.featureGroup.upsert({
where: {
projectId_slug: {
projectId,
slug: group.id,
},
},
create: {
projectId,
slug: group.id,
readableId: group.id,
name: group.name,
description: group.description,
focusArea: group.focusArea,
},
update: {
readableId: group.id,
name: group.name,
description: group.description,
focusArea: group.focusArea,
deletedAt: null,
archivedAt: null,
},
});
}
const mapFeatureStatus = (status: string): FeatureStatus => {
if (status === "Done") return FeatureStatus.done;
if (status === "In Progress") return FeatureStatus.in_progress;
return FeatureStatus.todo;
};
const mapFeatureLifecycle = (feature: { lifecycle?: string; status: string }): FeatureLifecycle => {
if (feature.lifecycle && feature.lifecycle in FeatureLifecycle) {
return FeatureLifecycle[feature.lifecycle as keyof typeof FeatureLifecycle];
}
if (feature.status === "Done") return FeatureLifecycle.verified;
if (feature.status === "In Progress") return FeatureLifecycle.active;
return FeatureLifecycle.planned;
};
const mapVerificationState = (status: string): VerificationState => {
if (status === "Done") return VerificationState.verified;
if (status === "In Progress") return VerificationState.needs_evidence;
return VerificationState.unverified;
};
const mapEvidenceStrength = (confidence: number): EvidenceStrength => {
if (confidence >= 95) return EvidenceStrength.verified;
if (confidence >= 85) return EvidenceStrength.strong;
if (confidence >= 70) return EvidenceStrength.medium;
return EvidenceStrength.weak;
};
const mapApprovalState = (approvalState: string): ApprovalState => {
if (approvalState === "approved") return ApprovalState.approved;
if (approvalState === "rejected") return ApprovalState.rejected;
if (approvalState === "blocked") return ApprovalState.blocked;
return ApprovalState.pending;
};
const mapFixtureEvidenceStrength = (strength: string | undefined, confidence: number): EvidenceStrength => {
if (strength && strength in EvidenceStrength) {
return EvidenceStrength[strength as keyof typeof EvidenceStrength];
}
return mapEvidenceStrength(confidence);
};
for (const feature of featureFixtures.filter((item) => seededProjects.has(item.projectId))) {
const projectId = seededProjects.get(feature.projectId)!;
const group = await prisma.featureGroup.findFirst({
where: {
projectId,
slug: feature.groupId,
deletedAt: null,
},
});
if (!group) {
throw new Error(`Feature group '${feature.groupId}' is missing for feature '${feature.id}'.`);
}
await prisma.feature.upsert({
where: {
projectId_slug: {
projectId,
slug: feature.id,
},
},
create: {
projectId,
featureGroupId: group.id,
slug: feature.id,
readableId: feature.id,
name: feature.name,
description: feature.description,
status: mapFeatureStatus(feature.status),
lifecycle: mapFeatureLifecycle(feature),
intent: feature.intent,
confidence: feature.confidence,
health: feature.health,
approvalState: feature.status === "Done" ? ApprovalState.approved : ApprovalState.pending,
evidenceStrength: mapFixtureEvidenceStrength(feature.evidenceStrength, feature.confidence),
verificationState: mapVerificationState(feature.status),
trustJson: toJson({
bugCount: feature.bugCount,
taskCount: feature.taskCount,
decisionCount: feature.decisionCount,
codeEvidence: feature.codeEvidence,
aiSuggestionCount: feature.aiSuggestionCount,
evidenceIds: feature.evidenceIds,
initiativeId: feature.initiativeId,
}),
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/featureFixtures.ts",
fixtureId: feature.id,
}),
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
},
update: {
featureGroupId: group.id,
readableId: feature.id,
name: feature.name,
description: feature.description,
status: mapFeatureStatus(feature.status),
lifecycle: mapFeatureLifecycle(feature),
intent: feature.intent,
confidence: feature.confidence,
health: feature.health,
approvalState: feature.status === "Done" ? ApprovalState.approved : ApprovalState.pending,
evidenceStrength: mapFixtureEvidenceStrength(feature.evidenceStrength, feature.confidence),
verificationState: mapVerificationState(feature.status),
trustJson: toJson({
bugCount: feature.bugCount,
taskCount: feature.taskCount,
decisionCount: feature.decisionCount,
codeEvidence: feature.codeEvidence,
aiSuggestionCount: feature.aiSuggestionCount,
evidenceIds: feature.evidenceIds,
initiativeId: feature.initiativeId,
}),
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/featureFixtures.ts",
fixtureId: feature.id,
}),
updatedBy: "seed",
sourceType: "fixture_seed",
deletedAt: null,
archivedAt: null,
},
});
}
for (const evidence of evidenceFixtures.filter((item) => seededProjects.has(item.projectId))) {
const projectId = seededProjects.get(evidence.projectId)!;
await prisma.evidence.upsert({
where: {
projectId_slug: {
projectId,
slug: evidence.id,
},
},
create: {
projectId,
slug: evidence.id,
readableId: evidence.id,
type: evidence.type,
source: evidence.source,
confidence: evidence.confidence,
approvalState: mapApprovalState(evidence.approvalState),
evidenceStrength: mapEvidenceStrength(evidence.confidence),
verificationState: evidence.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified,
reason: evidence.reason,
metadataJson: toJson({
linkedEntity: evidence.linkedEntity,
featureIds: evidence.featureIds,
route: evidence.route,
}),
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/featureFixtures.ts",
fixtureId: evidence.id,
}),
lastSeen: new Date(evidence.lastSeen),
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
},
update: {
readableId: evidence.id,
type: evidence.type,
source: evidence.source,
confidence: evidence.confidence,
approvalState: mapApprovalState(evidence.approvalState),
evidenceStrength: mapEvidenceStrength(evidence.confidence),
verificationState: evidence.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified,
reason: evidence.reason,
metadataJson: toJson({
linkedEntity: evidence.linkedEntity,
featureIds: evidence.featureIds,
route: evidence.route,
}),
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/featureFixtures.ts",
fixtureId: evidence.id,
}),
lastSeen: new Date(evidence.lastSeen),
updatedBy: "seed",
sourceType: "fixture_seed",
deletedAt: null,
archivedAt: null,
},
});
}
for (const contextPack of contextPackFixtures.filter((item) => seededProjects.has(item.projectId))) {
const projectId = seededProjects.get(contextPack.projectId)!;
await prisma.contextPack.upsert({
where: {
projectId_slug: {
projectId,
slug: contextPack.id,
},
},
create: {
projectId,
slug: contextPack.id,
readableId: contextPack.id,
title: contextPack.title,
category: contextPack.category,
summary: contextPack.summary,
generatedBy: contextPack.generatedBy,
confidence: contextPack.trust.confidence,
approvalState: mapApprovalState(contextPack.trust.approvalState),
qualityJson: toJson(contextPack.quality ?? {}),
trustJson: toJson(contextPack.trust),
metadataJson: toJson({
dna: contextPack.dna,
assumptions: contextPack.assumptions,
constraints: contextPack.constraints,
evidence: contextPack.evidence,
signals: contextPack.signals,
featureIds: contextPack.featureIds,
bugIds: contextPack.bugIds,
taskIds: contextPack.taskIds,
decisionIds: contextPack.decisionIds,
memoryRuleIds: contextPack.memoryRuleIds,
architectureNotes: contextPack.architectureNotes,
regressions: contextPack.regressions,
risks: contextPack.risks,
currentFocus: contextPack.currentFocus,
}),
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/contextPackFixtures.ts",
fixtureId: contextPack.id,
}),
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
createdAt: new Date(contextPack.createdAt),
sections: {
create: [
{
projectId,
slug: "dna",
title: "Project DNA",
sectionType: "dna",
orderIndex: 1,
contentJson: toJson(contextPack.dna),
},
{
projectId,
slug: "features",
title: "Relevant features",
sectionType: "features",
orderIndex: 2,
contentJson: toJson(contextPack.featureIds ?? []),
},
{
projectId,
slug: "evidence",
title: "Evidence references",
sectionType: "evidence",
orderIndex: 3,
contentJson: toJson(contextPack.evidence),
},
{
projectId,
slug: "risks",
title: "Risks",
sectionType: "risks",
orderIndex: 4,
contentJson: toJson(contextPack.risks ?? []),
},
],
},
},
update: {
readableId: contextPack.id,
title: contextPack.title,
category: contextPack.category,
summary: contextPack.summary,
generatedBy: contextPack.generatedBy,
confidence: contextPack.trust.confidence,
approvalState: mapApprovalState(contextPack.trust.approvalState),
qualityJson: toJson(contextPack.quality ?? {}),
trustJson: toJson(contextPack.trust),
metadataJson: toJson({
dna: contextPack.dna,
assumptions: contextPack.assumptions,
constraints: contextPack.constraints,
evidence: contextPack.evidence,
signals: contextPack.signals,
featureIds: contextPack.featureIds,
bugIds: contextPack.bugIds,
taskIds: contextPack.taskIds,
decisionIds: contextPack.decisionIds,
memoryRuleIds: contextPack.memoryRuleIds,
architectureNotes: contextPack.architectureNotes,
regressions: contextPack.regressions,
risks: contextPack.risks,
currentFocus: contextPack.currentFocus,
}),
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/contextPackFixtures.ts",
fixtureId: contextPack.id,
}),
updatedBy: "seed",
sourceType: "fixture_seed",
deletedAt: null,
archivedAt: null,
},
});
const seededContextPack = await prisma.contextPack.findFirstOrThrow({
where: { projectId, slug: contextPack.id },
});
await prisma.contextPackSection.deleteMany({ where: { contextPackId: seededContextPack.id } });
await prisma.contextPackSection.createMany({
data: [
{
projectId,
contextPackId: seededContextPack.id,
slug: "dna",
title: "Project DNA",
sectionType: "dna",
orderIndex: 1,
contentJson: toJson(contextPack.dna),
},
{
projectId,
contextPackId: seededContextPack.id,
slug: "features",
title: "Relevant features",
sectionType: "features",
orderIndex: 2,
contentJson: toJson(contextPack.featureIds ?? []),
},
{
projectId,
contextPackId: seededContextPack.id,
slug: "evidence",
title: "Evidence references",
sectionType: "evidence",
orderIndex: 3,
contentJson: toJson(contextPack.evidence),
},
{
projectId,
contextPackId: seededContextPack.id,
slug: "risks",
title: "Risks",
sectionType: "risks",
orderIndex: 4,
contentJson: toJson(contextPack.risks ?? []),
},
],
});
}
for (const capture of captureFixtures.filter((item) => seededProjects.has(item.projectId))) {
const projectId = seededProjects.get(capture.projectId)!;
await prisma.captureItem.upsert({
where: {
projectId_slug: {
projectId,
slug: capture.id,
},
},
create: {
projectId,
slug: capture.id,
readableId: capture.id,
type: capture.type,
title: capture.title,
content: capture.content,
source: capture.source,
status: capture.status,
classificationType: capture.classificationType,
confidence: capture.confidence,
evidenceStrength: capture.evidenceStrength
? mapFixtureEvidenceStrength(capture.evidenceStrength, capture.confidence ?? 0)
: undefined,
suggestedAction: capture.suggestedAction,
linkedReviewItemId: capture.linkedReviewItemId,
metadataJson: toJson({}),
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/captureFixtures.ts",
fixtureId: capture.id,
}),
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
createdAt: new Date(capture.createdAt),
},
update: {
readableId: capture.id,
type: capture.type,
title: capture.title,
content: capture.content,
source: capture.source,
status: capture.status,
classificationType: capture.classificationType,
confidence: capture.confidence,
evidenceStrength: capture.evidenceStrength
? mapFixtureEvidenceStrength(capture.evidenceStrength, capture.confidence ?? 0)
: undefined,
suggestedAction: capture.suggestedAction,
linkedReviewItemId: capture.linkedReviewItemId,
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/captureFixtures.ts",
fixtureId: capture.id,
}),
updatedBy: "seed",
sourceType: "fixture_seed",
deletedAt: null,
archivedAt: null,
},
});
}
const mapReviewStatus = (status: string): ReviewStatus => {
if (status === "accepted") return ReviewStatus.accepted;
if (status === "rejected") return ReviewStatus.rejected;
if (status === "merged") return ReviewStatus.merged;
if (status === "converted") return ReviewStatus.converted;
return ReviewStatus.pending;
};
const mapReviewVerificationState = (state: string): VerificationState => {
if (state === "verified") return VerificationState.verified;
if (state === "blocked") return VerificationState.blocked;
if (state === "needs evidence") return VerificationState.needs_evidence;
return VerificationState.unverified;
};
for (const review of reviewQueueFixtures.filter((item) => seededProjects.has(item.projectId))) {
const projectId = seededProjects.get(review.projectId)!;
await prisma.reviewQueueItem.upsert({
where: {
projectId_slug: {
projectId,
slug: review.id,
},
},
create: {
projectId,
slug: review.id,
readableId: review.id,
type: review.type,
title: review.title,
description: review.description,
priority: review.priority,
status: mapReviewStatus(review.status),
source: review.source,
confidence: review.confidence,
approvalState: review.status === "accepted" || review.status === "converted" || review.status === "merged"
? ApprovalState.approved
: review.status === "rejected"
? ApprovalState.rejected
: ApprovalState.pending,
evidenceStrength: mapFixtureEvidenceStrength(review.evidenceStrength, review.confidence),
verificationState: mapReviewVerificationState(review.verificationState),
suggestedAction: review.suggestedAction,
notes: review.notes,
targetEntityId: review.targetEntityId,
createdEntityId: review.createdEntityId,
payloadJson: toJson({ convertedTarget: review.convertedTarget }),
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/reviewQueueFixtures.ts",
fixtureId: review.id,
}),
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
createdAt: new Date(review.createdAt),
},
update: {
readableId: review.id,
type: review.type,
title: review.title,
description: review.description,
priority: review.priority,
status: mapReviewStatus(review.status),
source: review.source,
confidence: review.confidence,
approvalState: review.status === "accepted" || review.status === "converted" || review.status === "merged"
? ApprovalState.approved
: review.status === "rejected"
? ApprovalState.rejected
: ApprovalState.pending,
evidenceStrength: mapFixtureEvidenceStrength(review.evidenceStrength, review.confidence),
verificationState: mapReviewVerificationState(review.verificationState),
suggestedAction: review.suggestedAction,
notes: review.notes,
targetEntityId: review.targetEntityId,
createdEntityId: review.createdEntityId,
payloadJson: toJson({ convertedTarget: review.convertedTarget }),
provenanceJson: toJson({
seededFrom: "backend/src/fixtures/reviewQueueFixtures.ts",
fixtureId: review.id,
}),
updatedBy: "seed",
sourceType: "fixture_seed",
deletedAt: null,
archivedAt: null,
},
});
}
for (const knowledge of knowledgeFixtures.filter((item) => seededProjects.has(item.projectId))) {
const projectId = seededProjects.get(knowledge.projectId)!;
await prisma.knowledgeItem.upsert({
where: { projectId_slug: { projectId, slug: knowledge.id } },
create: {
projectId,
slug: knowledge.id,
readableId: knowledge.id,
title: knowledge.title,
category: knowledge.category,
content: knowledge.content,
source: knowledge.source,
confidence: knowledge.confidence,
includeInContextPacks: knowledge.includeInContextPacks.length > 0,
approvalState: mapApprovalState(knowledge.trust.approvalState),
evidenceStrength: mapEvidenceStrength(knowledge.confidence),
verificationState: knowledge.trust.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified,
metadataJson: toJson({ linkedFeatureIds: knowledge.linkedFeatureIds, includeInContextPacks: knowledge.includeInContextPacks }),
trustJson: toJson(knowledge.trust),
provenanceJson: toJson({ seededFrom: "backend/src/fixtures/knowledgeFixtures.ts", fixtureId: knowledge.id }),
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
createdAt: new Date(knowledge.createdAt),
},
update: {
readableId: knowledge.id,
title: knowledge.title,
category: knowledge.category,
content: knowledge.content,
source: knowledge.source,
confidence: knowledge.confidence,
includeInContextPacks: knowledge.includeInContextPacks.length > 0,
approvalState: mapApprovalState(knowledge.trust.approvalState),
evidenceStrength: mapEvidenceStrength(knowledge.confidence),
verificationState: knowledge.trust.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified,
metadataJson: toJson({ linkedFeatureIds: knowledge.linkedFeatureIds, includeInContextPacks: knowledge.includeInContextPacks }),
trustJson: toJson(knowledge.trust),
provenanceJson: toJson({ seededFrom: "backend/src/fixtures/knowledgeFixtures.ts", fixtureId: knowledge.id }),
updatedBy: "seed",
sourceType: "fixture_seed",
deletedAt: null,
archivedAt: null,
},
});
}
for (const rule of memoryRuleFixtures.filter((item) => seededProjects.has(item.projectId))) {
const projectId = seededProjects.get(rule.projectId)!;
await prisma.memoryRule.upsert({
where: { projectId_slug: { projectId, slug: rule.id } },
create: {
projectId,
slug: rule.id,
readableId: rule.id,
title: rule.title,
rule: rule.rule,
scope: rule.scope,
category: rule.category,
severity: rule.severity,
includeInContextPacks: rule.includeInContextPacksDefault,
confidence: rule.trust.confidence,
approvalState: mapApprovalState(rule.trust.approvalState),
evidenceStrength: mapEvidenceStrength(rule.trust.confidence),
verificationState: rule.trust.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified,
trustJson: toJson(rule.trust),
provenanceJson: toJson({ linkedFeatureIds: rule.linkedFeatureIds, includeInContextPacks: rule.includeInContextPacks, seededFrom: "backend/src/fixtures/memoryRuleFixtures.ts", fixtureId: rule.id }),
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
createdAt: new Date(rule.createdAt),
},
update: {
readableId: rule.id,
title: rule.title,
rule: rule.rule,
scope: rule.scope,
category: rule.category,
severity: rule.severity,
includeInContextPacks: rule.includeInContextPacksDefault,
confidence: rule.trust.confidence,
approvalState: mapApprovalState(rule.trust.approvalState),
evidenceStrength: mapEvidenceStrength(rule.trust.confidence),
verificationState: rule.trust.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified,
trustJson: toJson(rule.trust),
provenanceJson: toJson({ linkedFeatureIds: rule.linkedFeatureIds, includeInContextPacks: rule.includeInContextPacks, seededFrom: "backend/src/fixtures/memoryRuleFixtures.ts", fixtureId: rule.id }),
updatedBy: "seed",
sourceType: "fixture_seed",
deletedAt: null,
archivedAt: null,
},
});
}
const documentationSeed = [
{ id: "doc-wide-readme", title: "README summary", kind: "README summary", status: "outdated", linkedFeatureIds: [], approvalState: ApprovalState.pending, content: "Project README summary needs an approved refresh after runtime persistence work." },
{ id: "doc-wide-operating-model", title: "ProjectBrain Operating Model", kind: "architecture docs", status: "AI-generated", linkedFeatureIds: ["f-auth-mfa", "f-auth-social"], approvalState: ApprovalState.pending, content: "Operating model generated from existing project memory and runtime workflow." },
{ id: "doc-wide-codex", title: "Codex instructions", kind: "Codex instructions", status: "needs verification", linkedFeatureIds: [], approvalState: ApprovalState.pending, content: "Codex instructions must preserve review-before-truth boundaries." },
{ id: "doc-wide-decision-log", title: "Decision log", kind: "decision log", status: "approved", linkedFeatureIds: ["f-auth-mfa"], approvalState: ApprovalState.approved, content: "Decision log is approved and can feed context packs." },
];
for (const doc of documentationSeed) {
await prisma.documentationItem.upsert({
where: { projectId_slug: { projectId: project.id, slug: doc.id } },
create: {
projectId: project.id,
slug: doc.id,
readableId: doc.id,
title: doc.title,
kind: doc.kind,
status: doc.status,
approvalState: doc.approvalState,
content: doc.content,
metadataJson: toJson({ linkedFeatureIds: doc.linkedFeatureIds, lastUpdated: new Date().toISOString() }),
trustJson: toJson({ source: "documentation seed", confidence: doc.approvalState === ApprovalState.approved ? 90 : 70, evidence: [], lastChecked: new Date().toISOString(), approvalState: doc.approvalState }),
provenanceJson: toJson({ seededFrom: "backend/prisma/seed.ts", fixtureId: doc.id }),
createdBy: "seed",
updatedBy: "seed",
sourceType: "fixture_seed",
},
update: {
readableId: doc.id,
title: doc.title,
kind: doc.kind,
status: doc.status,
approvalState: doc.approvalState,
content: doc.content,
metadataJson: toJson({ linkedFeatureIds: doc.linkedFeatureIds, lastUpdated: new Date().toISOString() }),
trustJson: toJson({ source: "documentation seed", confidence: doc.approvalState === ApprovalState.approved ? 90 : 70, evidence: [], lastChecked: new Date().toISOString(), approvalState: doc.approvalState }),
provenanceJson: toJson({ seededFrom: "backend/prisma/seed.ts", fixtureId: doc.id }),
updatedBy: "seed",
sourceType: "fixture_seed",
deletedAt: null,
archivedAt: null,
},
});
}
console.log(`Seeded P2-P8 ProjectBrain persistence data for ${widefrog.id}.`);
};
try {
await main();
} finally {
await prisma.$disconnect();
}
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env node
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const backendRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const root = dirname(backendRoot);
const failures = [];
const notes = [];
const read = (relativePath) => readFileSync(join(root, relativePath), "utf8");
const exists = (relativePath) => existsSync(join(root, relativePath));
const requireFile = (relativePath) => {
if (!exists(relativePath)) failures.push(`Missing required backend contract file: ${relativePath}`);
};
const requireIncludes = (relativePath, markers) => {
if (!exists(relativePath)) {
failures.push(`Missing required file for marker audit: ${relativePath}`);
return;
}
const text = read(relativePath);
for (const marker of markers) {
if (!text.includes(marker)) failures.push(`${relativePath} is missing marker: ${marker}`);
}
};
const rejectIncludes = (relativePath, markers) => {
if (!exists(relativePath)) return;
const text = read(relativePath);
for (const marker of markers) {
if (text.includes(marker)) failures.push(`${relativePath} still contains stale backend status marker: ${marker}`);
}
};
const routeRegistrations = [
"registerHealthRoutes",
"registerAuthRoutes",
"registerEntraAuthRoutes",
"registerProjectRoutes",
"registerFeatureRoutes",
"registerEvidenceRoutes",
"registerContextPackRoutes",
"registerPromptRoutes",
"registerCheckRoutes",
"registerBrainstormRoutes",
"registerBuildRoutes",
"registerBlueprintRoutes",
"registerRegressionRoutes",
"registerSnapshotRoutes",
"registerInsightRoutes",
"registerRelationshipRoutes",
"registerDocumentationRoutes",
"registerKnowledgeRoutes",
"registerInitiativeRoutes",
"registerMemoryRuleRoutes",
"registerCaptureRoutes",
"registerReviewQueueRoutes",
"registerRuntimePersistenceRoutes",
"registerP7RuntimeRoutes",
"registerAIActionRoutes",
"registerSystemReadinessRoutes",
];
const routeFiles = [
"backend/src/routes/health.ts",
"backend/src/routes/auth.ts",
"backend/src/routes/entraAuth.ts",
"backend/src/routes/projects.ts",
"backend/src/routes/features.ts",
"backend/src/routes/evidence.ts",
"backend/src/routes/contextPacks.ts",
"backend/src/routes/prompts.ts",
"backend/src/routes/checks.ts",
"backend/src/routes/brainstorms.ts",
"backend/src/routes/builds.ts",
"backend/src/routes/blueprints.ts",
"backend/src/routes/regressions.ts",
"backend/src/routes/snapshots.ts",
"backend/src/routes/insights.ts",
"backend/src/routes/relationships.ts",
"backend/src/routes/documentation.ts",
"backend/src/routes/knowledge.ts",
"backend/src/routes/initiatives.ts",
"backend/src/routes/memoryRules.ts",
"backend/src/routes/capture.ts",
"backend/src/routes/reviewQueue.ts",
"backend/src/routes/runtimePersistence.ts",
"backend/src/routes/p7Runtime.ts",
"backend/src/routes/aiActions.ts",
"backend/src/routes/systemReadiness.ts",
];
const endpointDocs = [
"GET /api/health",
"GET /api/auth/entra/config",
"GET /api/auth/entra/start",
"GET /api/auth/entra/callback",
"GET /api/auth/entra/status",
"POST /api/auth/entra/link/start",
"DELETE /api/auth/entra/identity",
"GET /api/projects",
"POST /api/projects",
"GET /api/projects/:projectId",
"PATCH /api/projects/:projectId",
"GET /api/projects/:projectId/workspace",
"PATCH /api/projects/:projectId/workspace/active-context-pack",
"GET /api/projects/:projectId/dna",
"GET /api/projects/:projectId/feature-groups",
"GET /api/projects/:projectId/features",
"GET /api/projects/:projectId/evidence",
"GET /api/projects/:projectId/context-packs",
"POST /api/projects/:projectId/prompts/generate",
"GET /api/projects/:projectId/checks",
"GET /api/projects/:projectId/builds",
"GET /api/projects/:projectId/regressions",
"GET /api/projects/:projectId/snapshots",
"GET /api/projects/:projectId/insights",
"GET /api/projects/:projectId/relationships",
"GET /api/projects/:projectId/knowledge",
"GET /api/projects/:projectId/memory-rules",
"GET /api/projects/:projectId/capture-items",
"GET /api/projects/:projectId/review-queue",
"GET /api/projects/:projectId/ai-actions",
"GET /api/projects/:projectId/accepted-truth",
"GET /api/projects/:projectId/project-updates",
"GET /api/projects/:projectId/operating-log",
"GET /api/projects/:projectId/current-state",
"GET /api/projects/:projectId/handoff-bundles",
"GET /api/projects/:projectId/codex-tasks",
"GET /api/system/readiness",
];
const smokeScripts = [
"smoke:p2",
"smoke:p3",
"smoke:p4",
"smoke:p5",
"smoke:p6",
"smoke:p7",
"smoke:p8",
"smoke:fixtures",
"smoke:runtime-suite",
"smoke:database-suite",
"smoke:system-readiness",
"smoke:blueprints",
"smoke:brainstorms",
"smoke:initiatives",
];
for (const file of [
"backend/src/app.ts",
"backend/src/config/env.ts",
"backend/src/utils/envelope.ts",
"backend/src/plugins/errorHandler.ts",
"backend/src/plugins/requestId.ts",
"backend/src/db/prisma.ts",
"backend/prisma/schema.prisma",
"backend/prisma/seed.ts",
"backend/package.json",
"docs/REAL_BACKEND_ENDPOINT_INDEX.md",
"docs/PROJECTBRAIN_STATUS_AND_GATES.md",
"docs/BACKEND_RELEASE_COMPLETION_PASS.md",
]) {
requireFile(file);
}
for (const routeFile of routeFiles) requireFile(routeFile);
requireIncludes("backend/src/app.ts", routeRegistrations);
requireIncludes("backend/src/config/env.ts", [
"PROJECTBRAIN_DATA_SOURCE",
"DATABASE_URL is required in production",
"DATABASE_URL is required when PROJECTBRAIN_DATA_SOURCE=database",
]);
requireIncludes("backend/src/utils/envelope.ts", ["successEnvelope", "errorEnvelope", "requestId", "timestamp"]);
requireIncludes("backend/src/db/prisma.ts", ["PrismaClient", "getPrismaClient", "prisma"]);
requireIncludes("backend/package.json", ["audit:contract", ...smokeScripts]);
requireIncludes("package.json", ["audit:backend-contract", "audit:all"]);
requireIncludes("docs/REAL_BACKEND_ENDPOINT_INDEX.md", endpointDocs);
requireIncludes("docs/PROJECTBRAIN_STATUS_AND_GATES.md", [
"PostgreSQL production path",
"Gate 3 — Post-merge exact-SHA validation",
"Gate 4 — Runnerless deployment receipt",
"Gate 5 — Runtime verification",
]);
requireIncludes("docs/BACKEND_RELEASE_COMPLETION_PASS.md", [
"Backend Release Completion Pass",
"Fixture mode",
"Database mode",
"Database-only Routes",
"Release Gates",
]);
rejectIncludes("docs/REAL_BACKEND_ENDPOINT_INDEX.md", [
"mostly fixture-backed",
"P2 database parity is prepared",
"must be locally smoke-tested before P3 begins",
]);
rejectIncludes("docs/PROJECTBRAIN_STATUS_AND_GATES.md", [
"P2 DB parity prepared but not runtime-smoked locally yet",
"Required before P3",
"Can start only after Gate 2",
"Run P2 database smoke locally. If green, proceed to P3",
]);
requireIncludes("backend/src/routes/brainstorms.ts", ["DATABASE_MODE_REQUIRED", "PROJECTBRAIN_DATA_SOURCE=database"]);
requireIncludes("backend/src/routes/initiatives.ts", ["DATABASE_MODE_REQUIRED", "PROJECTBRAIN_DATA_SOURCE=database"]);
requireIncludes("backend/src/routes/documentation.ts", ["DATABASE_MODE_REQUIRED", "PROJECTBRAIN_DATA_SOURCE=database"]);
for (const file of [
"backend/src/routes/projects.ts",
"backend/src/routes/features.ts",
"backend/src/routes/evidence.ts",
"backend/src/routes/contextPacks.ts",
"backend/src/routes/prompts.ts",
"backend/src/routes/capture.ts",
"backend/src/routes/reviewQueue.ts",
"backend/src/routes/runtimePersistence.ts",
"backend/src/routes/p7Runtime.ts",
"backend/src/routes/knowledge.ts",
"backend/src/routes/memoryRules.ts",
"backend/src/routes/blueprints.ts",
]) {
requireIncludes(file, ["dataSource", "database"]);
}
requireIncludes("backend/scripts/smoke-fixture-api.mjs", [
"R1 health",
"R2 projects/workspace/DNA",
"R3 features/evidence",
"R4 context packs/prompts",
"R5 checks/review",
"R6 builds/regressions/snapshots",
"R7 knowledge/memory/relationships/insights",
"R8 capture/review queue/AI actions",
"error envelopes",
]);
const migrationsPath = join(root, "backend/prisma/migrations");
if (!existsSync(migrationsPath)) {
failures.push("Missing backend/prisma/migrations");
} else {
const migrationCount = readdirSync(migrationsPath, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length;
if (migrationCount < 8) failures.push(`Expected at least 8 backend migrations, found ${migrationCount}`);
else notes.push(`Migrations present: ${migrationCount}`);
}
requireIncludes("backend/prisma/schema.prisma", [
"model Project",
"model Feature",
"model Evidence",
"model ContextPack",
"model ReviewQueueItem",
"model CaptureItem",
"model AcceptedTruthItem",
"model ProjectUpdate",
"model OperatingLogEntry",
"model CurrentStateReport",
"model HandoffBundle",
"model CodexTask",
"model DocumentationItem",
"model KnowledgeItem",
"model MemoryRule",
"model BlueprintGraph",
"model BrainstormSession",
"model Initiative",
"model IdempotencyRecord",
]);
console.log("ProjectBrain backend contract audit");
console.log("===================================");
for (const note of notes) console.log(`INFO ${note}`);
if (failures.length) {
console.log("\nFailures:");
for (const failure of failures) console.log(`- ${failure}`);
process.exit(1);
}
console.log("\nPASS: backend contract surface, docs and release gates are aligned.");
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
set -eu
LEGACY_INIT_MIGRATION="prisma/migrations/20260606200000_init_project_workspace"
if [ -d "$LEGACY_INIT_MIGRATION" ]; then
echo "[ProjectBrain] Removing obsolete duplicate init migration: $LEGACY_INIT_MIGRATION"
rm -rf "$LEGACY_INIT_MIGRATION"
fi
FAILED_LEGACY_COUNT=$(./node_modules/.bin/prisma migrate status --schema prisma/schema.prisma 2>&1 | grep -c "20260606200000_init_project_workspace" || true)
if [ "$FAILED_LEGACY_COUNT" != "0" ]; then
echo "[ProjectBrain] Legacy migration 20260606200000_init_project_workspace is recorded in the database."
echo "[ProjectBrain] Marking it as rolled back so migrate deploy can continue with the corrected migration chain."
./node_modules/.bin/prisma migrate resolve --rolled-back 20260606200000_init_project_workspace --schema prisma/schema.prisma || true
fi
echo "[ProjectBrain] Running Prisma migrations..."
./node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma
if [ "${PROJECTBRAIN_SEED_ON_START:-false}" = "true" ]; then
echo "[ProjectBrain] Seeding database..."
./node_modules/.bin/tsx prisma/seed.ts
else
echo "[ProjectBrain] Skipping database seed. Set PROJECTBRAIN_SEED_ON_START=true to seed fixtures."
fi
echo "[ProjectBrain] Starting API..."
exec node dist/server.js
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const prismaCli = require.resolve("prisma/build/index.js");
const child = spawn(process.execPath, [prismaCli, "generate", "--schema", "prisma/schema.prisma"], {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
PRISMA_HIDE_UPDATE_MESSAGE: "true",
},
});
const writeFiltered = (stream, chunk) => {
const lines = chunk
.toString()
.split(/\r?\n/)
.filter((line) => {
const trimmed = line.trim();
return (
trimmed &&
!trimmed.startsWith("Tip:") &&
!trimmed.startsWith("Help us improve the Prisma ORM") &&
!trimmed.includes("https://pris.ly/orm/survey/")
);
});
if (lines.length) stream.write(`${lines.join("\n")}\n`);
};
child.stdout?.on("data", (chunk) => writeFiltered(process.stdout, chunk));
child.stderr?.on("data", (chunk) => writeFiltered(process.stderr, chunk));
child.on("error", (error) => {
console.error(`Failed to start Prisma: ${error.message}`);
process.exit(1);
});
child.on("close", (code) => {
process.exit(code ?? 1);
});
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const prismaCli = require.resolve("prisma/build/index.js");
const localValidationUrl =
"postgresql://projectbrain:projectbrain@localhost:5432/projectbrain?schema=public";
const child = spawn(process.execPath, [prismaCli, "validate", "--schema", "prisma/schema.prisma"], {
stdio: "inherit",
env: {
...process.env,
DATABASE_URL: process.env.DATABASE_URL || localValidationUrl,
PRISMA_HIDE_UPDATE_MESSAGE: "true",
},
});
child.on("error", (error) => {
console.error(`Failed to start Prisma: ${error.message}`);
process.exit(1);
});
child.on("close", (code) => {
process.exit(code ?? 1);
});
+189
View File
@@ -0,0 +1,189 @@
import Fastify, { LogController } from "fastify";
import cookie from "@fastify/cookie";
import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
import formbody from "@fastify/formbody";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Writable } from "node:stream";
import { loadEnv, type AppEnv } from "./config/env.js";
import { errorHandlerPlugin } from "./plugins/errorHandler.js";
import { authenticationPlugin } from "./plugins/authentication.js";
import { createRequestId, requestIdPlugin } from "./plugins/requestId.js";
import { registerAIActionRoutes } from "./routes/aiActions.js";
import { registerActivityRoutes } from "./routes/activity.js";
import { registerAuthRoutes } from "./routes/auth.js";
import { registerEntraAuthRoutes } from "./routes/entraAuth.js";
import { initializeOwner } from "./security/authStore.js";
import { registerAutonomyRoutes } from "./routes/autonomy.js";
import { registerBrainstormRoutes } from "./routes/brainstorms.js";
import { registerBuildRoutes } from "./routes/builds.js";
import { registerBlueprintRoutes } from "./routes/blueprints.js";
import { registerCaptureRoutes } from "./routes/capture.js";
import { registerCheckRoutes } from "./routes/checks.js";
import { registerContextPackRoutes } from "./routes/contextPacks.js";
import { registerEvidenceRoutes } from "./routes/evidence.js";
import { registerDocumentationRoutes } from "./routes/documentation.js";
import { registerFeatureRoutes } from "./routes/features.js";
import { registerHealthRoutes } from "./routes/health.js";
import { registerInsightRoutes } from "./routes/insights.js";
import { registerInitiativeRoutes } from "./routes/initiatives.js";
import { registerKnowledgeRoutes } from "./routes/knowledge.js";
import { registerMemoryRuleRoutes } from "./routes/memoryRules.js";
import { registerProjectRoutes } from "./routes/projects.js";
import { registerPromptRoutes } from "./routes/prompts.js";
import { registerRegressionRoutes } from "./routes/regressions.js";
import { registerRelationshipRoutes } from "./routes/relationships.js";
import { registerReleaseEvidenceRoutes } from "./routes/releaseEvidence.js";
import { registerReviewQueueRoutes } from "./routes/reviewQueue.js";
import { registerRuntimePersistenceRoutes } from "./routes/runtimePersistence.js";
import { registerSystemReadinessRoutes } from "./routes/systemReadiness.js";
import { registerP7RuntimeRoutes } from "./routes/p7Runtime.js";
import { registerSnapshotRoutes } from "./routes/snapshots.js";
import { observabilityPlugin } from "./plugins/observability.js";
import { registerMetricsRoutes } from "./routes/metrics.js";
import { registerGiteaIntegrationRoutes } from "./routes/giteaIntegration.js";
import { registerMcpOAuthRoutes } from "./routes/mcpOAuth.js";
import { registerMcpRoutes } from "./routes/mcp.js";
import { registerAnalysisJobRoutes } from "./routes/analysisJobs.js";
import { registerFindingRoutes } from "./routes/findings.js";
import { registerPortfolioRoutes } from "./routes/portfolio.js";
import { registerOperatingSystemRoutes } from "./routes/operatingSystem.js";
const filename = fileURLToPath(import.meta.url);
const currentDirectory = dirname(filename);
const readPackageVersion = (): string => {
try {
const packageJsonPath = resolve(currentDirectory, "../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
version?: string;
};
return packageJson.version ?? "0.0.0";
} catch {
return "0.0.0";
}
};
export type BuildAppOptions = {
env?: AppEnv;
logStream?: Writable;
};
export const buildApp = async (options: BuildAppOptions = {}) => {
const env = options.env ?? loadEnv();
const app = Fastify({
logController: new LogController({ disableRequestLogging: true }),
logger: {
level: env.LOG_LEVEL,
redact: {
paths: ["req.headers.authorization", "req.headers.cookie", "req.headers.x-csrf-token", "req.body", "res.headers.set-cookie", "username", "password", "currentPassword", "newPassword", "content", "summary", "outputText"],
censor: "[REDACTED]",
},
...(options.logStream ? { stream: options.logStream } : {}),
},
genReqId: (request) => createRequestId(request.headers["x-request-id"]),
bodyLimit: 1_048_576,
trustProxy: env.PROJECTBRAIN_TRUST_PROXY,
});
app.removeContentTypeParser("application/json");
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (request, body, done) => {
const rawBody = Buffer.isBuffer(body) ? body : Buffer.from(body);
request.rawBody = rawBody;
try { done(null, JSON.parse(rawBody.toString("utf8"))); }
catch (error) { done(error as Error, undefined); }
});
await app.register(cookie, { secret: env.PROJECTBRAIN_AUTH_SECRET, hook: "onRequest" });
await app.register(helmet, { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], baseUri: ["'self'"], frameAncestors: ["'none'"], objectSrc: ["'none'"] } } });
await app.register(rateLimit, { global: true, max: 240, timeWindow: "1 minute" });
await app.register(formbody);
await requestIdPlugin(app);
await observabilityPlugin(app);
await errorHandlerPlugin(app);
await initializeOwner(env);
await authenticationPlugin(app, env);
await app.register(registerHealthRoutes, {
env,
version: readPackageVersion(),
});
await app.register(registerMetricsRoutes, { env });
await app.register(registerAuthRoutes, { env });
await app.register(registerEntraAuthRoutes, { env });
await app.register(registerMcpOAuthRoutes, { env });
await app.register(registerMcpRoutes, { env });
await app.register(registerGiteaIntegrationRoutes, { env });
await app.register(registerAnalysisJobRoutes, { env });
await app.register(registerFindingRoutes, { env });
await app.register(registerPortfolioRoutes, { env });
await app.register(registerActivityRoutes, { env });
await app.register(registerProjectRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
env,
});
await app.register(registerAutonomyRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
env,
});
await app.register(registerFeatureRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerEvidenceRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerContextPackRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerPromptRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerCheckRoutes);
await app.register(registerBrainstormRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerBuildRoutes);
await app.register(registerBlueprintRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerRegressionRoutes);
await app.register(registerSnapshotRoutes);
await app.register(registerInsightRoutes);
await app.register(registerRelationshipRoutes);
await app.register(registerDocumentationRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerKnowledgeRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerInitiativeRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerMemoryRuleRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerCaptureRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerReviewQueueRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerRuntimePersistenceRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerReleaseEvidenceRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerP7RuntimeRoutes, {
dataSource: env.PROJECTBRAIN_DATA_SOURCE,
});
await app.register(registerAIActionRoutes);
await app.register(registerSystemReadinessRoutes, {
env,
});
await app.register(registerOperatingSystemRoutes, { env });
return app;
};
+154
View File
@@ -0,0 +1,154 @@
import { z } from "zod";
const optionalEnvValue = <T extends z.ZodTypeAny>(schema: T) => z.preprocess(
(value) => typeof value === "string" && value.trim() === "" ? undefined : value,
schema.optional(),
);
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(4000),
HOST: z.string().min(1).default("0.0.0.0"),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.default("info"),
DATABASE_URL: z.string().min(1).optional(),
PROJECTBRAIN_DATA_SOURCE: z.enum(["fixture", "database"]).default("fixture"),
PROJECTBRAIN_OWNER_USERNAME: z.string().trim().min(3).max(80),
PROJECTBRAIN_OWNER_PASSWORD: z.string().min(12).max(256),
PROJECTBRAIN_AUTH_SECRET: z.string().min(32),
PROJECTBRAIN_SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(720).default(24),
PROJECTBRAIN_TRUST_PROXY: z.enum(["true", "false"]).default("false").transform((value) => value === "true"),
PROJECTBRAIN_GITEA_URL: z.string().url().optional(),
PROJECTBRAIN_GITEA_TOKEN: z.string().min(20).optional(),
PROJECTBRAIN_GITEA_WRITE_TOKEN: z.string().min(20).optional(),
PROJECTBRAIN_GITEA_WEBHOOK_SECRET: optionalEnvValue(z.string().min(24).max(512)),
PROJECTBRAIN_MCP_ENABLED: z.enum(["true", "false"]).default("false").transform((value) => value === "true"),
PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(60).max(5_000).default(1_200),
PROJECTBRAIN_PUBLIC_URL: z.string().url().optional(),
PROJECTBRAIN_HUB_SERVICE_TOKEN: optionalEnvValue(z.string().min(32).max(512)),
PROJECTBRAIN_ENTRA_TENANT_ID: optionalEnvValue(z.string().uuid()),
PROJECTBRAIN_ENTRA_CLIENT_ID: optionalEnvValue(z.string().uuid()),
PROJECTBRAIN_ENTRA_CLIENT_SECRET: optionalEnvValue(z.string().min(16)),
PROJECTBRAIN_ANALYSIS_WORKER_PORT: z.coerce.number().int().min(1024).max(65535).default(4001),
PROJECTBRAIN_ANALYSIS_POLL_MS: z.coerce.number().int().min(250).max(60_000).default(1_000),
PROJECTBRAIN_ANALYSIS_BATCH_SIZE: z.coerce.number().int().min(1).max(10).default(5),
PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY: z.coerce.number().int().min(1).max(2).default(2),
PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS: z.coerce.number().int().min(60).max(3_600).default(1_800),
PROJECTBRAIN_ANALYSIS_STALE_SECONDS: z.coerce.number().int().min(60).max(7_200).default(300),
PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES: z.coerce.number().int().min(0).max(43_200).default(1_440),
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: z.enum(["true", "false"]).default("true").transform((value) => value === "true"),
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: z.coerce.number().int().min(15).max(3_600).default(60),
// An ordinary ChatGPT Chat task runs the autonomous executor hourly through
// the ProjectBrain MCP plug-in. Keep a 30-minute grace window so ordinary
// scheduler jitter does not make a healthy executor appear disconnected.
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: z.coerce.number().int().min(60).max(86_400).default(5_400),
});
export type AppEnv = z.infer<typeof envSchema>;
const productionPlaceholderValues = new Set([
"replace-with-a-unique-password-of-at-least-12-characters",
"replace-with-at-least-32-random-characters",
"replace-with-a-unique-postgres-password",
"projectbrain",
"password",
"changeme",
"adminadminadmin",
]);
const assertProductionSecret = (name: string, value: string) => {
const normalized = value.trim().toLowerCase();
if (productionPlaceholderValues.has(normalized) || normalized.startsWith("replace-with-")) {
throw new Error(`${name} must not use a placeholder or known insecure default in production.`);
}
};
const assertProductionDatabaseUrl = (value: string) => {
const url = new URL(value);
const password = decodeURIComponent(url.password || "");
// External PostgreSQL deployments may authenticate outside the URL. When an
// inline password is supplied, however, it must never be a shipped default.
if (password) assertProductionSecret("DATABASE_URL database password", password);
};
export const loadEnv = (
input: NodeJS.ProcessEnv = process.env,
): AppEnv => {
const parsed = envSchema.parse({
NODE_ENV: input.NODE_ENV,
PORT: input.PORT,
HOST: input.HOST,
LOG_LEVEL: input.LOG_LEVEL,
DATABASE_URL: input.DATABASE_URL,
PROJECTBRAIN_DATA_SOURCE: input.PROJECTBRAIN_DATA_SOURCE,
PROJECTBRAIN_OWNER_USERNAME: input.PROJECTBRAIN_OWNER_USERNAME,
PROJECTBRAIN_OWNER_PASSWORD: input.PROJECTBRAIN_OWNER_PASSWORD,
PROJECTBRAIN_AUTH_SECRET: input.PROJECTBRAIN_AUTH_SECRET,
PROJECTBRAIN_SESSION_TTL_HOURS: input.PROJECTBRAIN_SESSION_TTL_HOURS,
PROJECTBRAIN_TRUST_PROXY: input.PROJECTBRAIN_TRUST_PROXY,
PROJECTBRAIN_GITEA_URL: input.PROJECTBRAIN_GITEA_URL,
PROJECTBRAIN_GITEA_TOKEN: input.PROJECTBRAIN_GITEA_TOKEN,
PROJECTBRAIN_GITEA_WRITE_TOKEN: input.PROJECTBRAIN_GITEA_WRITE_TOKEN,
PROJECTBRAIN_GITEA_WEBHOOK_SECRET: input.PROJECTBRAIN_GITEA_WEBHOOK_SECRET,
PROJECTBRAIN_MCP_ENABLED: input.PROJECTBRAIN_MCP_ENABLED,
PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE: input.PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE,
PROJECTBRAIN_PUBLIC_URL: input.PROJECTBRAIN_PUBLIC_URL,
PROJECTBRAIN_HUB_SERVICE_TOKEN: input.PROJECTBRAIN_HUB_SERVICE_TOKEN,
PROJECTBRAIN_ENTRA_TENANT_ID: input.PROJECTBRAIN_ENTRA_TENANT_ID,
PROJECTBRAIN_ENTRA_CLIENT_ID: input.PROJECTBRAIN_ENTRA_CLIENT_ID,
PROJECTBRAIN_ENTRA_CLIENT_SECRET: input.PROJECTBRAIN_ENTRA_CLIENT_SECRET,
PROJECTBRAIN_ANALYSIS_WORKER_PORT: input.PROJECTBRAIN_ANALYSIS_WORKER_PORT,
PROJECTBRAIN_ANALYSIS_POLL_MS: input.PROJECTBRAIN_ANALYSIS_POLL_MS,
PROJECTBRAIN_ANALYSIS_BATCH_SIZE: input.PROJECTBRAIN_ANALYSIS_BATCH_SIZE,
PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY: input.PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY,
PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS: input.PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS,
PROJECTBRAIN_ANALYSIS_STALE_SECONDS: input.PROJECTBRAIN_ANALYSIS_STALE_SECONDS,
PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES: input.PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES,
PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: input.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES,
PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: input.PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS,
PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: input.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS,
});
if (parsed.NODE_ENV === "production" && !parsed.DATABASE_URL) {
throw new Error("DATABASE_URL is required in production.");
}
if (parsed.PROJECTBRAIN_DATA_SOURCE === "database" && !parsed.DATABASE_URL) {
throw new Error(
"DATABASE_URL is required when PROJECTBRAIN_DATA_SOURCE=database.",
);
}
if (parsed.PROJECTBRAIN_MCP_ENABLED && (!parsed.PROJECTBRAIN_GITEA_URL || !parsed.PROJECTBRAIN_GITEA_TOKEN || !parsed.PROJECTBRAIN_PUBLIC_URL)) {
throw new Error("PROJECTBRAIN_GITEA_URL, PROJECTBRAIN_GITEA_TOKEN and PROJECTBRAIN_PUBLIC_URL are required when MCP is enabled.");
}
if (parsed.PROJECTBRAIN_MCP_ENABLED && parsed.PROJECTBRAIN_DATA_SOURCE !== "database") {
throw new Error("PROJECTBRAIN_DATA_SOURCE=database is required when MCP is enabled.");
}
if (parsed.NODE_ENV === "production" && parsed.PROJECTBRAIN_MCP_ENABLED && new URL(parsed.PROJECTBRAIN_PUBLIC_URL!).protocol !== "https:") {
throw new Error("PROJECTBRAIN_PUBLIC_URL must use HTTPS when MCP is enabled in production.");
}
const entraValues = [parsed.PROJECTBRAIN_ENTRA_TENANT_ID, parsed.PROJECTBRAIN_ENTRA_CLIENT_ID, parsed.PROJECTBRAIN_ENTRA_CLIENT_SECRET];
if (entraValues.some(Boolean) && !entraValues.every(Boolean)) {
throw new Error("PROJECTBRAIN_ENTRA_TENANT_ID, PROJECTBRAIN_ENTRA_CLIENT_ID and PROJECTBRAIN_ENTRA_CLIENT_SECRET must be configured together.");
}
if (entraValues.every(Boolean) && (!parsed.PROJECTBRAIN_PUBLIC_URL || parsed.PROJECTBRAIN_DATA_SOURCE !== "database")) {
throw new Error("PROJECTBRAIN_PUBLIC_URL and PROJECTBRAIN_DATA_SOURCE=database are required when Entra login is configured.");
}
if (parsed.NODE_ENV === "production" && entraValues.every(Boolean) && new URL(parsed.PROJECTBRAIN_PUBLIC_URL!).protocol !== "https:") {
throw new Error("PROJECTBRAIN_PUBLIC_URL must use HTTPS when Entra login is configured in production.");
}
if (parsed.NODE_ENV === "production") {
assertProductionDatabaseUrl(parsed.DATABASE_URL!);
assertProductionSecret("PROJECTBRAIN_OWNER_PASSWORD", parsed.PROJECTBRAIN_OWNER_PASSWORD);
assertProductionSecret("PROJECTBRAIN_AUTH_SECRET", parsed.PROJECTBRAIN_AUTH_SECRET);
if (parsed.PROJECTBRAIN_GITEA_WEBHOOK_SECRET) assertProductionSecret("PROJECTBRAIN_GITEA_WEBHOOK_SECRET", parsed.PROJECTBRAIN_GITEA_WEBHOOK_SECRET);
if (parsed.PROJECTBRAIN_HUB_SERVICE_TOKEN) assertProductionSecret("PROJECTBRAIN_HUB_SERVICE_TOKEN", parsed.PROJECTBRAIN_HUB_SERVICE_TOKEN);
if (parsed.PROJECTBRAIN_ENTRA_CLIENT_SECRET) assertProductionSecret("PROJECTBRAIN_ENTRA_CLIENT_SECRET", parsed.PROJECTBRAIN_ENTRA_CLIENT_SECRET);
}
return parsed;
};
+17
View File
@@ -0,0 +1,17 @@
import prismaPackage from "@prisma/client";
import type { PrismaClient as PrismaClientType } from "@prisma/client";
const PrismaClient = prismaPackage.PrismaClient as new () => PrismaClientType;
let prismaClient: PrismaClientType | undefined;
export const getPrismaClient = (): PrismaClientType => {
prismaClient ??= new PrismaClient();
return prismaClient;
};
export const prisma = new Proxy({} as PrismaClientType, {
get(_target, property, receiver) {
return Reflect.get(getPrismaClient(), property, receiver);
},
});
+245
View File
@@ -0,0 +1,245 @@
import { createReviewPreviewFromAIAction } from "./reviewQueueFixtures.js";
import type {
AIActionDto,
AIActionListFilters,
AIActionRunDto,
AIActionRunListFilters,
RunAIActionRequestDto,
} from "../types/api.js";
let runCounter = 1;
export const aiActionFixtures: AIActionDto[] = [
{
id: "generate_features",
projectId: "widefrog",
label: "Generate Features",
category: "generation",
sourceType: ["constitution", "dna", "feature_map", "capture_items"],
outputTarget: ["review_queue", "roadmap"],
description: "Generate feature candidates from approved project context.",
requiresContextPack: true,
status: "available",
},
{
id: "detect_bugs",
projectId: "widefrog",
label: "Detect Bugs",
category: "analysis",
sourceType: ["checks", "feature_map", "manual"],
outputTarget: ["review_queue"],
description: "Detect bug candidates from checks, notes and build summaries.",
requiresContextPack: false,
status: "available",
},
{
id: "classify_notes",
projectId: "widefrog",
label: "Classify Notes",
category: "classification",
sourceType: ["capture_items", "manual"],
outputTarget: ["review_queue"],
description: "Classify captured notes into reviewable candidates.",
requiresContextPack: false,
status: "available",
},
{
id: "extract_decisions",
projectId: "widefrog",
label: "Extract Decisions",
category: "extraction",
sourceType: ["capture_items", "manual"],
outputTarget: ["review_queue"],
description: "Extract decision candidates from captured material.",
requiresContextPack: false,
status: "available",
},
{
id: "extract_memory_rules",
projectId: "widefrog",
label: "Extract Memory Rules",
category: "extraction",
sourceType: ["capture_items", "checks", "manual"],
outputTarget: ["review_queue", "context_pack"],
description: "Extract do-not-break and workflow memory rule candidates.",
requiresContextPack: false,
status: "available",
},
{
id: "update_documentation",
projectId: "widefrog",
label: "Update Documentation",
category: "documentation",
sourceType: ["feature_map", "manual"],
outputTarget: ["documentation", "review_queue"],
description: "Generate deterministic documentation update suggestions.",
requiresContextPack: false,
status: "available",
},
{
id: "generate_codex_task",
projectId: "widefrog",
label: "Generate Codex Task",
category: "generation",
sourceType: ["context_pack", "manual"],
outputTarget: ["codex_task"],
description: "Generate a structured Codex task from a context pack.",
requiresContextPack: true,
status: "available",
},
{
id: "import_build_summary",
projectId: "widefrog",
label: "Import Build Summary",
category: "extraction",
sourceType: ["manual"],
outputTarget: ["review_queue"],
description: "Turn a build summary into reviewable project-memory candidates.",
requiresContextPack: false,
status: "available",
},
{
id: "suggest_roadmap",
projectId: "widefrog",
label: "Suggest Roadmap",
category: "planning",
sourceType: ["feature_map", "context_pack"],
outputTarget: ["roadmap", "review_queue"],
description: "Suggest now/next/later planning candidates.",
requiresContextPack: true,
status: "available",
},
{
id: "generate_current_state",
projectId: "widefrog",
label: "Generate Current State",
category: "analysis",
sourceType: ["constitution", "dna", "feature_map", "context_pack"],
outputTarget: ["current_state", "review_queue"],
description: "Summarize what exists, works, is uncertain and is risky.",
requiresContextPack: true,
status: "available",
},
{
id: "detect_missing_docs_verification_tests_context_risks",
projectId: "widefrog",
label: "Detect Missing Docs, Verification, Tests, Context and Risks",
category: "analysis",
sourceType: ["checks", "feature_map", "context_pack"],
outputTarget: ["review_queue"],
description: "Detect missing documentation, verification, tests, context and risks.",
requiresContextPack: false,
status: "available",
},
];
export const aiActionRunFixtures: AIActionRunDto[] = [];
export const listAIActionsForProject = (projectId: string): AIActionDto[] =>
aiActionFixtures.filter((action) => action.projectId === projectId);
export const findAIAction = (
projectId: string,
actionId: string,
): AIActionDto | undefined =>
aiActionFixtures.find(
(action) => action.projectId === projectId && action.id === actionId,
);
export const applyAIActionFilters = (
actions: AIActionDto[],
filters: AIActionListFilters,
): AIActionDto[] =>
actions.filter((action) => {
if (filters.category?.length && !filters.category.includes(action.category)) {
return false;
}
if (
filters.outputTarget?.length &&
!filters.outputTarget.some((target) => action.outputTarget.includes(target))
) {
return false;
}
if (
filters.sourceType?.length &&
!filters.sourceType.some((source) => action.sourceType.includes(source))
) {
return false;
}
return true;
});
export const runAIAction = (
projectId: string,
action: AIActionDto,
body: RunAIActionRequestDto,
): AIActionRunDto => {
const createdAt = new Date().toISOString();
const inputSource = body.inputSource ?? action.sourceType[0] ?? "manual";
const outputTarget = body.outputTarget ?? action.outputTarget[0] ?? "review_queue";
const objective = body.objective?.trim() || `Run ${action.label}`;
const runId = `airun-${projectId}-${runCounter++}`;
const outputText = [
`Deterministic mock run for ${action.id}.`,
`Objective: ${objective}.`,
`Input source: ${inputSource}${body.inputId ? ` (${body.inputId})` : ""}.`,
`Output target: ${outputTarget}.`,
"No real AI call was made.",
].join("\n");
const reviewQueuePreview =
outputTarget === "review_queue"
? createReviewPreviewFromAIAction(
projectId,
action.id,
`${action.label} preview`,
outputText,
)
: undefined;
const run: AIActionRunDto = {
id: runId,
projectId,
actionId: action.id,
status: "completed",
inputSource,
inputId: body.inputId,
outputTarget,
objective,
outputText,
createdFrom: action.id,
reviewQueuePreview,
createdAt,
};
aiActionRunFixtures.push(run);
return run;
};
export const listAIActionRunsForProject = (projectId: string): AIActionRunDto[] =>
aiActionRunFixtures.filter((run) => run.projectId === projectId);
export const findAIActionRun = (
projectId: string,
runId: string,
): AIActionRunDto | undefined =>
aiActionRunFixtures.find((run) => run.projectId === projectId && run.id === runId);
export const applyAIActionRunFilters = (
runs: AIActionRunDto[],
filters: AIActionRunListFilters,
): AIActionRunDto[] =>
runs.filter((run) => {
if (filters.actionId?.length && !filters.actionId.includes(run.actionId)) {
return false;
}
if (filters.status?.length && !filters.status.includes(run.status)) return false;
if (
filters.outputTarget?.length &&
!filters.outputTarget.includes(run.outputTarget)
) {
return false;
}
if (filters.createdFrom?.length && !filters.createdFrom.includes(run.createdFrom)) {
return false;
}
return true;
});
+175
View File
@@ -0,0 +1,175 @@
import type {
BuildHistoryDto,
BuildListFilters,
BuildValidationResult,
CreateBuildRequestDto,
TrustMetadataDto,
} from "../types/api.js";
const trustNow = "2026-06-04T09:00:00Z";
const trust = (confidence: number): TrustMetadataDto => ({
source: "deterministic build fixture",
confidence,
evidence: [],
lastChecked: trustNow,
approvalState: "approved",
});
let buildCounter = 103;
export const buildFixtures: BuildHistoryDto[] = [
{
id: "build-wide-01",
projectId: "widefrog",
buildNumber: "BLD-101",
agent: "Build Assistant",
goal: "Stabilize auth telemetry before stream release.",
targetTool: "agent-review",
persona: "delivery-engineer",
expectedOutput: "Updated telemetry checks and evidence matrix",
promptContextPackId: "cp-widefrog-core",
selectedContextPackIds: ["cp-widefrog-core"],
contextSelectionSummary: "Seed build uses the core context pack as its preserved prompt context.",
filesChanged: [
"src/hooks/useMFA.ts",
"src/services/telemetry.ts",
"src/components/AuthGuard.tsx",
],
validationResult: "pass",
regressionsIntroduced: [
{
title: "Queue telemetry timing regression in low-bandwidth Safari",
severity: "medium",
linkedFeatureIds: ["f-auth-mfa"],
preventionRule: "R-SEC-02",
},
],
lessonsLearned: [
"Route all streaming lifecycle signals through telemetry adapter.",
"Keep fallback queue handling aligned across auth modules.",
],
linkedFeatureIds: ["f-auth-mfa"],
source: "import-analysis pipeline",
summary: "Build passed with pending accessibility review.",
status: "success",
createdAt: "2026-06-03T11:30:00Z",
trust: trust(94),
},
{
id: "build-wide-02",
projectId: "widefrog",
buildNumber: "BLD-102",
agent: "Build Assistant",
goal: "Create regression guardrails for context-pack prompts.",
targetTool: "prompt-builder",
persona: "ai-trainer",
expectedOutput: "Prompt template + checks mapping + prevention tasks",
promptContextPackId: "cp-widefrog-core",
selectedContextPackIds: ["cp-widefrog-core"],
contextSelectionSummary: "Seed build uses the core context pack as its preserved prompt context.",
filesChanged: [
"docs/analysis/context-pack-guidelines.md",
"src/shared/lib/mock-data.ts",
],
validationResult: "warning",
regressionsIntroduced: [],
lessonsLearned: [
"No new regressions introduced.",
"Memory pack includes Do-Not-Break constraints by default.",
],
linkedFeatureIds: ["f-auth-mfa", "f-auth-social"],
source: "mock flow",
summary: "Context-prompt build finished with warning: one pending knowledge gap.",
status: "success",
createdAt: "2026-06-04T08:15:00Z",
trust: trust(89),
},
];
export const listBuildsForProject = (projectId: string): BuildHistoryDto[] =>
buildFixtures.filter((build) => build.projectId === projectId);
export const findBuild = (
projectId: string,
buildId: string,
): BuildHistoryDto | undefined =>
buildFixtures.find((build) => build.projectId === projectId && build.id === buildId);
export const applyBuildFilters = (
builds: BuildHistoryDto[],
filters: BuildListFilters,
): BuildHistoryDto[] =>
builds.filter((build) => {
if (filters.agent?.length && !filters.agent.includes(build.agent)) return false;
if (filters.result?.length && !filters.result.includes(build.validationResult)) {
return false;
}
if (filters.status?.length && !filters.status.includes(build.status)) return false;
if (
filters.featureId &&
!build.linkedFeatureIds.includes(filters.featureId) &&
!build.regressionsIntroduced.some((regression) =>
regression.linkedFeatureIds.includes(filters.featureId as string),
)
) {
return false;
}
if (
filters.contextPackId &&
build.promptContextPackId !== filters.contextPackId
) {
return false;
}
return true;
});
export const createBuild = (
projectId: string,
body: CreateBuildRequestDto,
): BuildHistoryDto => {
const validationResult = toValidationResult(body.validationResult);
const createdAt = new Date().toISOString();
const buildNumber = `BLD-${buildCounter++}`;
const build: BuildHistoryDto = {
id: `build-${projectId}-${buildNumber.toLowerCase()}`,
projectId,
buildNumber,
agent: body.agent ?? "manual",
goal: body.goal.trim(),
targetTool: body.agent ?? "manual",
persona: body.agent === "codex" ? "codex-agent" : "operator",
expectedOutput: "Deterministic backend build journal entry.",
promptContextPackId: body.contextPackId,
selectedContextPackIds: body.selectedContextPackIds,
contextSelectionSummary: body.contextSelectionSummary,
promptId: body.promptId,
filesChanged: body.filesChanged ?? [],
validationResult,
regressionsIntroduced: [],
lessonsLearned: body.lessonsLearned ?? [],
linkedFeatureIds: body.linkedFeatureIds ?? [],
source: "real-backend-r6-fixture",
summary: `${body.goal.trim()} (${validationResult})`,
status: validationResult === "fail" ? "failed" : "success",
createdAt,
trust: {
source: "deterministic build create",
confidence: validationResult === "pass" ? 88 : 70,
evidence: [],
lastChecked: createdAt,
approvalState: "pending",
},
};
buildFixtures.push(build);
return build;
};
const toValidationResult = (
value: string | undefined,
): BuildValidationResult => {
if (value === "fail" || value === "warning" || value === "pass") return value;
return "pass";
};
+184
View File
@@ -0,0 +1,184 @@
import type {
CaptureClassificationDto,
CaptureItemDto,
CaptureItemListFilters,
CreateCaptureItemRequestDto,
} from "../types/api.js";
let captureCounter = 3;
export const captureFixtures: CaptureItemDto[] = [
{
id: "cap-wide-01",
projectId: "widefrog",
type: "codex_output",
title: "Codex scope note: AI operating system",
content:
"ProjectBrain should capture notes, route AI output through review, and export structured Codex tasks.",
source: "codex",
status: "in_review",
classificationType: "documentation_update",
confidence: 88,
evidenceStrength: "medium",
suggestedAction: "Send to review as documentation update.",
createdAt: "2026-06-05T21:10:00Z",
updatedAt: "2026-06-05T21:20:00Z",
linkedReviewItemId: "rq-wide-01",
},
{
id: "cap-wide-02",
projectId: "widefrog",
type: "note",
title: "Unraid deployment constraint",
content:
"The product must remain self-hosted and not require cloud services or paid API calls.",
source: "manual",
status: "classified",
classificationType: "memory_rule",
confidence: 92,
evidenceStrength: "strong",
suggestedAction: "Review as do-not-break memory rule.",
createdAt: "2026-06-05T21:12:00Z",
updatedAt: "2026-06-05T21:18:00Z",
},
];
export const listCaptureItemsForProject = (projectId: string): CaptureItemDto[] =>
captureFixtures.filter((item) => item.projectId === projectId);
export const findCaptureItem = (
projectId: string,
captureItemId: string,
): CaptureItemDto | undefined =>
captureFixtures.find(
(item) => item.projectId === projectId && item.id === captureItemId,
);
export const applyCaptureFilters = (
items: CaptureItemDto[],
filters: CaptureItemListFilters,
): CaptureItemDto[] =>
items.filter((item) => {
if (filters.status?.length && !filters.status.includes(item.status)) {
return false;
}
if (filters.type?.length && !filters.type.includes(item.type)) return false;
if (filters.source?.length && !filters.source.includes(item.source)) {
return false;
}
if (filters.confidence !== undefined && item.confidence !== filters.confidence) {
return false;
}
if (
filters.classificationType?.length &&
(!item.classificationType ||
!filters.classificationType.includes(item.classificationType))
) {
return false;
}
return true;
});
export const createCaptureItem = (
projectId: string,
body: CreateCaptureItemRequestDto,
): CaptureItemDto => {
const createdAt = new Date().toISOString();
const title = body.content.trim().slice(0, 64);
const item: CaptureItemDto = {
id: `cap-${projectId}-${captureCounter++}`,
projectId,
type: body.type,
title: title || "Captured item",
content: body.content.trim(),
source: body.source?.trim() || "manual",
status: "captured",
createdAt,
updatedAt: createdAt,
};
captureFixtures.push(item);
return item;
};
export const classifyCaptureItem = (
projectId: string,
captureItemId: string,
): CaptureClassificationDto | undefined | "CAPTURE_ALREADY_IN_REVIEW" => {
const item = findCaptureItem(projectId, captureItemId);
if (!item) return undefined;
if (item.status === "in_review") return "CAPTURE_ALREADY_IN_REVIEW";
const classification = classifyContent(item);
item.status = "classified";
item.classificationType = classification.classificationType;
item.confidence = classification.confidence;
item.evidenceStrength = classification.evidenceStrength;
item.suggestedAction = classification.suggestedAction;
item.updatedAt = new Date().toISOString();
return {
captureItemId,
...classification,
};
};
export const markCaptureItemInReview = (
projectId: string,
captureItemId: string,
reviewItemId: string,
): CaptureItemDto | undefined => {
const item = findCaptureItem(projectId, captureItemId);
if (!item) return undefined;
item.status = "in_review";
item.linkedReviewItemId = reviewItemId;
item.updatedAt = new Date().toISOString();
return item;
};
const classifyContent = (
item: CaptureItemDto,
): Omit<CaptureClassificationDto, "captureItemId"> => {
const text = `${item.type} ${item.content}`.toLowerCase();
if (text.includes("bug") || text.includes("error")) {
return {
classificationType: "bug",
confidence: 86,
evidenceStrength: "medium",
suggestedAction: "Send to review as bug candidate.",
};
}
if (text.includes("decision")) {
return {
classificationType: "decision",
confidence: 82,
evidenceStrength: "medium",
suggestedAction: "Send to review as decision candidate.",
};
}
if (text.includes("rule") || text.includes("must") || text.includes("do not")) {
return {
classificationType: "memory_rule",
confidence: 90,
evidenceStrength: "strong",
suggestedAction: "Send to review as memory rule.",
};
}
if (text.includes("docs") || text.includes("documentation")) {
return {
classificationType: "documentation_update",
confidence: 78,
evidenceStrength: "weak",
suggestedAction: "Send to review as documentation update.",
};
}
return {
classificationType: "knowledge",
confidence: 72,
evidenceStrength: "weak",
suggestedAction: "Send to review as knowledge item.",
};
};
+321
View File
@@ -0,0 +1,321 @@
import type {
ApprovalState,
CheckCategory,
CheckItemDto,
CheckListFilters,
CheckPipelineStage,
CheckSeverity,
CheckStatus,
ConvertTarget,
ReviewAction,
ReviewBoardDto,
ReviewMutationResponseDto,
TrustMetadataDto,
} from "../types/api.js";
const trustNow = "2026-06-04T09:00:00Z";
const trust = (
confidence: number,
approvalState: ApprovalState = "pending",
): TrustMetadataDto => ({
source: "deterministic check fixture",
confidence,
evidence: [
{
id: "e-arch-01",
type: "analysis",
refId: "arch-widefrog-v1",
label: "architecture model baseline",
},
],
lastChecked: trustNow,
approvalState,
});
let conversionCounter = 1;
export const checkFixtures: CheckItemDto[] = [
{
id: "ck-wide-01",
projectId: "widefrog",
title: "Authentication evidence check",
severity: "warning",
status: "open",
pipelineStage: "risk_analysis",
category: "missing evidence",
architectureModelId: "arch-widefrog-v1",
linkedFeatureIds: ["f-auth-mfa", "f-auth-social"],
linkedEvidenceIds: ["ev-wide-01", "ev-wide-02"],
linkedRelationshipIds: ["rel-01"],
linkedContextPackIds: ["cp-widefrog-core"],
linkedEntities: [
{
type: "check",
id: "ck-wide-01",
label: "Authentication evidence check",
link: "/projects/widefrog/checks?section=review",
},
{
type: "feature",
id: "f-auth-mfa",
label: "Multi-factor Authentication",
link: "/projects/widefrog/features/f-auth-mfa",
},
{
type: "feature",
id: "f-auth-social",
label: "Social Login",
link: "/projects/widefrog/features/f-auth-social",
},
],
detail:
"Two checks are incomplete due missing queue telemetry in streaming states.",
trust: trust(94),
},
{
id: "ck-wide-02",
projectId: "widefrog",
title: "MFA implementation confirmed",
severity: "info",
status: "resolved",
pipelineStage: "feature_detection",
category: "confirmed feature",
linkedFeatureIds: ["f-auth-mfa"],
linkedEvidenceIds: ["ev-wide-01"],
linkedContextPackIds: ["cp-widefrog-core"],
detail: "MFA exists in the feature map and has linked implementation evidence.",
trust: trust(91, "approved"),
},
{
id: "ck-wide-03",
projectId: "widefrog",
title: "Streaming queue regression candidate",
severity: "high",
status: "open",
pipelineStage: "risk_analysis",
category: "possible regression",
linkedFeatureIds: ["f-auth-mfa"],
linkedEvidenceIds: ["ev-wide-02"],
linkedContextPackIds: ["cp-widefrog-core"],
detail:
"Queue telemetry may regress under low-bandwidth Safari conditions.",
trust: trust(87),
},
{
id: "ck-wide-04",
projectId: "widefrog",
title: "Relationship graph duplicate risk",
severity: "warning",
status: "in_progress",
pipelineStage: "recommendation",
category: "architecture conflict",
linkedFeatureIds: ["f-auth-mfa", "f-auth-social"],
linkedRelationshipIds: ["rel-01"],
linkedContextPackIds: ["cp-widefrog-core"],
detail: "Auth fallback and MFA relationship semantics need review.",
trust: trust(79),
},
{
id: "ck-wide-05",
projectId: "widefrog",
title: "Recovery codes candidate",
severity: "info",
status: "open",
pipelineStage: "feature_detection",
category: "new feature candidate",
linkedFeatureIds: ["f-auth-mfa"],
linkedContextPackIds: ["cp-widefrog-core"],
detail: "Backup recovery codes are suggested for privileged action recovery.",
trust: trust(76),
},
{
id: "ck-wide-06",
projectId: "widefrog",
title: "Unclear Codex import review mapping",
severity: "warning",
status: "open",
pipelineStage: "module_inference",
category: "uncertain finding",
linkedContextPackIds: ["cp-widefrog-core"],
detail: "Codex output import review state needs a stronger mapping.",
trust: trust(68),
},
{
id: "ck-wide-07",
projectId: "widefrog",
title: "Missing prompt verification",
severity: "critical",
status: "open",
pipelineStage: "recommendation",
category: "missing verification",
linkedContextPackIds: ["cp-widefrog-core"],
detail: "Prompt generation needs explicit validation before backend switch.",
trust: trust(83),
},
{
id: "ck-nebula-01",
projectId: "nebula",
title: "Onboarding policy evidence placeholder",
severity: "warning",
status: "open",
pipelineStage: "risk_analysis",
category: "missing evidence",
linkedFeatureIds: ["f-onboard-flow"],
linkedContextPackIds: ["cp-nebula-core"],
detail: "Onboarding policy needs verified evidence before implementation.",
trust: trust(72),
},
];
export const listChecksForProject = (projectId: string): CheckItemDto[] =>
checkFixtures.filter((check) => check.projectId === projectId);
export const findCheck = (
projectId: string,
checkId: string,
): CheckItemDto | undefined =>
checkFixtures.find((check) => check.projectId === projectId && check.id === checkId);
export const applyCheckFilters = (
checks: CheckItemDto[],
filters: CheckListFilters,
): CheckItemDto[] =>
checks.filter((check) => {
if (filters.status?.length && !filters.status.includes(check.status)) {
return false;
}
if (filters.severity?.length && !filters.severity.includes(check.severity)) {
return false;
}
if (filters.source?.length && !filters.source.includes(check.pipelineStage)) {
return false;
}
if (filters.category?.length && !filters.category.includes(check.category)) {
return false;
}
if (
filters.confidence !== undefined &&
check.trust.confidence !== filters.confidence
) {
return false;
}
if (
filters.approvalState !== undefined &&
check.trust.approvalState !== filters.approvalState
) {
return false;
}
return true;
});
export const toReviewBoard = (checks: CheckItemDto[]): ReviewBoardDto => ({
confirmedFeatures: checks.filter(
(check) => check.category === "confirmed feature",
),
newFeatureCandidates: checks.filter(
(check) => check.category === "new feature candidate",
),
possibleRegressions: checks.filter(
(check) => check.category === "possible regression",
),
uncertainFindings: checks.filter(
(check) => check.category === "uncertain finding",
),
missingEvidence: checks.filter((check) => check.category === "missing evidence"),
missingVerification: checks.filter(
(check) => check.category === "missing verification",
),
architectureConflicts: checks.filter(
(check) => check.category === "architecture conflict",
),
});
export const applyReviewAction = (
projectId: string,
sourceId: string,
action: ReviewAction,
reason?: string,
targetEntityId?: string,
): ReviewMutationResponseDto | undefined => {
const check = findCheck(projectId, sourceId);
if (!check) return undefined;
check.status = "resolved";
check.reviewAction = action;
check.reviewReason = reason;
check.reviewedAt = new Date().toISOString();
check.trust = {
...check.trust,
approvalState: action === "reject" || action === "ignore" ? "rejected" : "approved",
lastChecked: check.reviewedAt,
};
if (action === "merge") {
check.mergedIntoEntityId = targetEntityId;
}
return {
success: true,
message: `Check ${sourceId} review action ${action} applied.`,
updatedEntityId: sourceId,
affectedEntities: targetEntityId ? [sourceId, targetEntityId] : [sourceId],
};
};
export const convertCheck = (
projectId: string,
sourceId: string,
target: ConvertTarget,
title?: string,
reason?: string,
): ReviewMutationResponseDto | undefined => {
const check = findCheck(projectId, sourceId);
if (!check) return undefined;
const createdEntityId = `mock-${target.replace("_", "-")}-${conversionCounter++}`;
const convertedAt = new Date().toISOString();
check.status = "resolved";
check.convertedTarget = target;
check.convertedEntityId = createdEntityId;
check.reviewReason = reason;
check.reviewedAt = convertedAt;
check.trust = {
...check.trust,
approvalState: "approved",
lastChecked: convertedAt,
};
const titleSuffix = title?.trim() ? ` with title '${title.trim()}'` : "";
return {
success: true,
message: `Converted check ${sourceId} to ${target} ${createdEntityId}${titleSuffix}.`,
updatedEntityId: sourceId,
createdEntityId,
affectedEntities: [sourceId, createdEntityId],
};
};
export const checkFilterValues = {
status: ["open", "in_progress", "resolved"] satisfies CheckStatus[],
severity: ["info", "warning", "high", "critical"] satisfies CheckSeverity[],
source: [
"ingest",
"architecture_discovery",
"module_inference",
"feature_detection",
"risk_analysis",
"recommendation",
] satisfies CheckPipelineStage[],
category: [
"confirmed feature",
"new feature candidate",
"possible regression",
"uncertain finding",
"missing evidence",
"missing verification",
"architecture conflict",
] satisfies CheckCategory[],
};
+383
View File
@@ -0,0 +1,383 @@
import {
evidenceFixtures,
featureFixtures,
paginate,
type PaginationInput,
} from "./featureFixtures.js";
import { findProject } from "./projectFixtures.js";
import type {
ContextPackDto,
ContextPackGeneratedBy,
ContextPackListFilters,
ContextPackSourceType,
ContextPackSummaryDto,
CreateContextPackRequestDto,
GeneratePromptRequestDto,
GeneratedPromptDto,
TrustMetadataDto,
} from "../types/api.js";
const trustNow = "2026-06-04T09:00:00Z";
const coreTrust: TrustMetadataDto = {
source: "Mock import analysis v1",
confidence: 94,
evidence: [
{
id: "e-arch-01",
type: "analysis",
refId: "arch-widefrog-v1",
label: "architecture model baseline",
},
],
lastChecked: trustNow,
approvalState: "approved",
};
let contextPackCreateCounter = 1;
let promptGenerateCounter = 1;
export const contextPackFixtures: ContextPackDto[] = [
{
id: "cp-widefrog-core",
projectId: "widefrog",
title: "Core Identity and Streams",
category: "architecture",
dna: findProject("widefrog")?.dna ?? {
vision: "",
goals: [],
nonGoals: [],
targetAudience: "",
principles: [],
successMetrics: [],
maturityStage: "Discovery",
revision: 0,
updatedAt: trustNow,
},
summary:
"Authentication and stream modules have the highest evidence value and must stay explicit before adding scope.",
assumptions: [
"Identity service is synchronous and can be used across all modules",
"Streaming stack needs explicit buffering policy",
],
constraints: [
"No auth regression tolerance above low confidence",
"Every new feature must include evidence entry",
],
evidence: [
{
id: "er-01",
type: "artifact",
refId: "f-auth-mfa",
label: "MFA implementation and evidence",
},
],
featureIds: ["f-auth-mfa", "f-auth-social"],
bugIds: ["B-MFA-01"],
taskIds: ["T-MFA-01"],
decisionIds: ["D-MFA-01"],
memoryRuleIds: ["R-UX-01", "R-SEC-02"],
architectureNotes: [
"Frontend has domain grouped architecture with shared auth services.",
],
risks: [
"Streaming regression from queue underflow.",
"Context mismatch across auth fallback screens.",
],
regressions: ["ck-wide-01"],
quality: {
dnaIncluded: true,
constitutionIncluded: true,
constraintsIncluded: true,
featuresIncluded: true,
evidenceIncluded: true,
risksIncluded: true,
memoryIncluded: true,
doNotBreakIncluded: true,
verificationIncluded: false,
score: 88,
},
currentFocus: {
id: "fa-auth",
title: "Identity and Access",
purpose: "quality",
},
signals: ["open-bug", "stale-evidence", "architecture-risk"],
trust: coreTrust,
generatedBy: "import",
createdAt: "2026-06-03T09:00:00Z",
updatedAt: trustNow,
},
{
id: "cp-nebula-core",
projectId: "nebula",
title: "Onboarding Policy Baseline",
category: "quality",
dna: findProject("nebula")?.dna ?? {
vision: "",
goals: [],
nonGoals: [],
targetAudience: "",
principles: [],
successMetrics: [],
maturityStage: "Discovery",
revision: 0,
updatedAt: trustNow,
},
summary: "Focus on policy traceability and stable onboarding defaults.",
assumptions: ["Policy updates are infrequent during execution"],
constraints: ["Onboarding policy changes must be auditable"],
evidence: [
{
id: "er-02",
type: "artifact",
refId: "f-onboard-flow",
label: "Feature evidence placeholder",
},
],
featureIds: ["f-onboard-flow"],
memoryRuleIds: ["R-NB-01"],
architectureNotes: ["Onboarding is policy-centric and stateful."],
risks: ["Policy drift risk when role templates are updated externally."],
quality: {
dnaIncluded: true,
constitutionIncluded: false,
constraintsIncluded: true,
featuresIncluded: true,
evidenceIncluded: false,
risksIncluded: true,
memoryIncluded: true,
doNotBreakIncluded: false,
verificationIncluded: false,
score: 61,
},
currentFocus: {
id: "fa-onboard",
title: "Onboarding Policy and Delivery",
purpose: "architecture",
},
signals: ["knowledge-gap", "confidence-drift"],
trust: {
...coreTrust,
source: "User-defined context pack",
confidence: 78,
approvalState: "pending",
},
generatedBy: "user",
createdAt: "2026-06-02T14:00:00Z",
updatedAt: "2026-06-03T16:00:00Z",
},
];
export const listContextPacksForProject = (
projectId: string,
): ContextPackDto[] =>
contextPackFixtures.filter((contextPack) => contextPack.projectId === projectId);
export const findContextPack = (
projectId: string,
contextPackId: string,
): ContextPackDto | undefined =>
contextPackFixtures.find(
(contextPack) =>
contextPack.projectId === projectId && contextPack.id === contextPackId,
);
export const toContextPackSummary = (
contextPack: ContextPackDto,
): ContextPackSummaryDto => ({
projectId: contextPack.projectId,
contextPackId: contextPack.id,
title: contextPack.title,
category: contextPack.category,
summary: contextPack.summary,
counts: {
assumptions: contextPack.assumptions.length,
constraints: contextPack.constraints.length,
evidence: contextPack.evidence.length,
features: contextPack.featureIds?.length ?? 0,
bugs: contextPack.bugIds?.length ?? 0,
tasks: contextPack.taskIds?.length ?? 0,
decisions: contextPack.decisionIds?.length ?? 0,
memoryRules: contextPack.memoryRuleIds?.length ?? 0,
risks: contextPack.risks?.length ?? 0,
},
qualityScore: contextPack.quality?.score,
createdAt: contextPack.createdAt,
updatedAt: contextPack.updatedAt,
});
export const findContextPackSummary = (
projectId: string,
contextPackId: string,
): ContextPackSummaryDto | undefined => {
const contextPack = findContextPack(projectId, contextPackId);
return contextPack ? toContextPackSummary(contextPack) : undefined;
};
export const listContextPacksPaginated = (
projectId: string,
input: PaginationInput,
): ReturnType<typeof paginate<ContextPackDto>> =>
paginate(listContextPacksForProject(projectId), input);
export const createContextPack = (
projectId: string,
request: CreateContextPackRequestDto,
): ContextPackDto | undefined => {
const project = findProject(projectId);
if (!project) return undefined;
const createdAt = new Date().toISOString();
const selectedFeatureIds =
request.sourceType === "feature" && request.sourceId
? [request.sourceId]
: featureFixtures
.filter((feature) => feature.projectId === projectId)
.map((feature) => feature.id);
const selectedEvidence = evidenceFixtures.filter(
(evidence) =>
evidence.projectId === projectId &&
(selectedFeatureIds.length === 0 ||
evidence.featureIds.some((featureId) =>
selectedFeatureIds.includes(featureId),
)),
);
const packId = `cp-${projectId}-${request.sourceType}-${contextPackCreateCounter++}`;
const contextPack: ContextPackDto = {
id: packId,
projectId,
title:
request.title?.trim() ||
`${project.name} ${request.sourceType.replace("_", " ")} context pack`,
category: request.sourceType === "feature" ? "feature" : "quality",
dna: project.dna,
summary: `Created from project ${projectId} via ${request.sourceType} context source. DNA is always included.`,
assumptions:
request.sourceType === "manual"
? ["Manual context source requires human review."]
: ["Generated fixture context should be verified before use."],
constraints: project.constraints.map((constraint) => constraint.title),
evidence:
request.includeEvidence === false
? []
: selectedEvidence.map((evidence) => ({
id: `er-${evidence.id}`,
type: "analysis",
refId: evidence.id,
label: evidence.linkedEntity.label,
})),
signals: ["fixture-generated", request.sourceType],
...(request.includeFeatures === false ? {} : { featureIds: selectedFeatureIds }),
...(request.includeMemoryRules === false
? {}
: { memoryRuleIds: project.memoryRules.map((rule) => rule.id) }),
...(request.includeArchitecture === false
? {}
: {
architectureNotes: [
`Architecture notes are fixture-generated for ${project.name}.`,
],
}),
...(request.includeRisks === false
? {}
: { risks: ["Fixture risk: generated context still needs review."] }),
...(request.includeRegressions === false ? {} : { regressions: [] }),
quality: {
dnaIncluded: true,
constitutionIncluded: Boolean(project.constitution),
constraintsIncluded: true,
featuresIncluded: request.includeFeatures !== false,
evidenceIncluded: request.includeEvidence !== false,
risksIncluded: request.includeRisks !== false,
memoryIncluded: request.includeMemoryRules !== false,
doNotBreakIncluded: true,
verificationIncluded: false,
score: request.includeEvidence === false ? 58 : 74,
},
trust: {
source: "deterministic fixture context-pack create",
confidence: request.includeEvidence === false ? 64 : 82,
evidence: [],
lastChecked: createdAt,
approvalState: "pending",
},
generatedBy: toGeneratedBy(request.sourceType),
createdAt,
updatedAt: createdAt,
};
contextPackFixtures.push(contextPack);
return contextPack;
};
export const generatePrompt = (
projectId: string,
request: GeneratePromptRequestDto,
): GeneratedPromptDto | undefined => {
const contextPack = findContextPack(projectId, request.contextPackId);
if (!contextPack) return undefined;
const createdAt = new Date().toISOString();
const id = `prompt-${projectId}-${promptGenerateCounter++}`;
const expectedOutput =
request.expectedOutput?.trim() || "Clear implementation notes and validation steps.";
const generatedText = [
`Generated from context pack ${contextPack.id} (${contextPack.title}).`,
`Target tool: ${request.targetTool}.`,
`Persona: ${request.persona}.`,
`Objective: ${request.objective.trim()}.`,
`Expected output: ${expectedOutput}`,
`Context summary: ${contextPack.summary}`,
`Project vision: ${contextPack.dna.vision}`,
`Relevant features: ${(contextPack.featureIds ?? []).join(", ") || "none"}.`,
`Risks: ${(contextPack.risks ?? []).join(" ") || "none listed"}.`,
"Use only the supplied context pack facts and mark uncertainty explicitly.",
].join("\n");
return {
id,
projectId,
contextPackId: contextPack.id,
targetTool: request.targetTool,
persona: request.persona,
objective: request.objective.trim(),
expectedOutput,
generatedText,
generatedFrom: "context-pack",
createdAt,
};
};
export const applyContextPackFilters = (
contextPacks: ContextPackDto[],
filters: ContextPackListFilters,
): ContextPackDto[] => {
const generatedBy = filters.source ?? filters.generatedBy;
return contextPacks.filter((contextPack) => {
if (filters.category?.length && !filters.category.includes(contextPack.category)) {
return false;
}
if (generatedBy?.length && !generatedBy.includes(contextPack.generatedBy)) {
return false;
}
if (
filters.confidence !== undefined &&
contextPack.trust.confidence !== filters.confidence
) {
return false;
}
return true;
});
};
const toGeneratedBy = (
sourceType: ContextPackSourceType,
): ContextPackGeneratedBy => {
if (sourceType === "check") return "check-run";
if (sourceType === "manual") return "manual";
return "user";
};
+278
View File
@@ -0,0 +1,278 @@
import type {
EvidenceDto,
FeatureDto,
FeatureGroupDto,
PaginatedListResponse,
} from "../types/api.js";
export type PaginationInput = {
page: number;
pageSize: number;
cursor?: string;
sort?: string;
filters?: Record<string, string | number | boolean | string[]>;
};
export const featureGroupFixtures: FeatureGroupDto[] = [
{
id: "auth",
projectId: "widefrog",
name: "Authentication",
description: "Authentication and identity layer",
focusArea: "architecture",
featureIds: ["f-auth-social", "f-auth-mfa"],
},
{
id: "onboarding",
projectId: "nebula",
name: "Onboarding",
description: "Identity and team onboarding modules",
focusArea: "delivery",
featureIds: ["f-onboard-flow"],
},
];
export const featureFixtures: FeatureDto[] = [
{
id: "f-auth-social",
projectId: "widefrog",
groupId: "auth",
name: "Social Login",
description:
"OAuth2 login via Google and GitHub with account linking and fallback recovery.",
status: "Done",
confidence: 97,
health: 95,
bugCount: 0,
taskCount: 0,
decisionCount: 0,
codeEvidence: [
{
file: "src/services/auth.service.ts",
lines: "120-245",
type: "Logic",
link: "file://src/services/auth.service.ts",
},
{
file: "src/components/LoginForm.tsx",
lines: "45-82",
type: "UI",
link: "file://src/components/LoginForm.tsx",
},
],
aiSuggestionCount: 0,
evidenceIds: ["ev-wide-03"],
},
{
id: "f-auth-mfa",
projectId: "widefrog",
groupId: "auth",
name: "Multi-factor Authentication",
description:
"SMS and TOTP based second factor, enforced for privileged actions.",
status: "In Progress",
lifecycle: "active",
intent: "Protect privileged actions without increasing account recovery risk.",
initiativeId: "init-wide-reliability",
evidenceStrength: "medium",
confidence: 88,
health: 80,
bugCount: 1,
taskCount: 1,
decisionCount: 1,
codeEvidence: [
{
file: "src/hooks/useMFA.ts",
lines: "10-78",
type: "Hook",
link: "file://src/hooks/useMFA.ts",
},
],
aiSuggestionCount: 1,
evidenceIds: ["ev-wide-01", "ev-wide-02", "ev-wide-03", "ev-wide-04"],
},
{
id: "f-onboard-flow",
projectId: "nebula",
groupId: "onboarding",
name: "Team Onboarding Flow",
description: "Wizard with role-based defaults and policy checks.",
status: "Todo",
confidence: 72,
health: 70,
bugCount: 0,
taskCount: 0,
decisionCount: 0,
codeEvidence: [],
aiSuggestionCount: 0,
evidenceIds: ["ev-nebula-01"],
},
];
export const evidenceFixtures: EvidenceDto[] = [
{
id: "ev-wide-01",
projectId: "widefrog",
type: "analysis",
linkedEntity: {
type: "feature",
id: "f-auth-mfa",
label: "Multi-factor Authentication",
},
featureIds: ["f-auth-mfa"],
source: "architecture-first import pipeline",
confidence: 94,
reason:
"Feature flagged for telemetry verification due missing queue coverage.",
lastSeen: "2026-06-03T09:20:00Z",
approvalState: "approved",
route: "/projects/widefrog/features/f-auth-mfa",
},
{
id: "ev-wide-02",
projectId: "widefrog",
type: "check",
linkedEntity: {
type: "check",
id: "ck-wide-01",
label: "Authentication evidence check",
},
featureIds: ["f-auth-mfa"],
source: "mock check engine",
confidence: 88,
reason: "Evidence missing for queue telemetry in streaming contexts.",
lastSeen: "2026-06-03T09:40:00Z",
approvalState: "pending",
route: "/projects/widefrog/checks",
},
{
id: "ev-wide-03",
projectId: "widefrog",
type: "relationship",
linkedEntity: {
type: "feature",
id: "f-auth-social",
label: "Social Login",
},
featureIds: ["f-auth-social", "f-auth-mfa"],
source: "review-board",
confidence: 86,
reason:
"Dependency edge indicates fallback flow should stay aligned with MFA.",
lastSeen: "2026-06-03T10:00:00Z",
approvalState: "approved",
route: "/projects/widefrog/relationships",
},
{
id: "ev-wide-04",
projectId: "widefrog",
type: "memory-rule",
linkedEntity: {
type: "memory-rule",
id: "R-SEC-02",
label: "Global API Error Handling",
},
featureIds: ["f-auth-mfa"],
source: "manual review",
confidence: 97,
reason:
"Memory rule linked to all API-related checks and evidence generation.",
lastSeen: "2026-06-03T12:20:00Z",
approvalState: "approved",
route: "/projects/widefrog/memory",
},
{
id: "ev-wide-05",
projectId: "widefrog",
type: "architecture",
linkedEntity: {
type: "architecture",
id: "arch-widefrog-v1",
label: "Frontend architecture map",
},
featureIds: [],
source: "import scan",
confidence: 91,
reason: "Stack and layers inferred before feature checks.",
lastSeen: "2026-06-03T09:00:00Z",
approvalState: "approved",
route: "/projects/widefrog/checks?section=pipeline",
},
{
id: "ev-nebula-01",
projectId: "nebula",
type: "artifact",
linkedEntity: {
type: "feature",
id: "f-onboard-flow",
label: "Team Onboarding Flow",
},
featureIds: ["f-onboard-flow"],
source: "feature evidence placeholder",
confidence: 72,
reason: "Fixture evidence for onboarding policy baseline.",
lastSeen: "2026-06-03T16:00:00Z",
approvalState: "pending",
route: "/projects/nebula/features/f-onboard-flow",
},
];
export const listFeatureGroupsForProject = (
projectId: string,
): FeatureGroupDto[] =>
featureGroupFixtures.filter((group) => group.projectId === projectId);
export const listFeaturesForProject = (projectId: string): FeatureDto[] =>
featureFixtures.filter((feature) => feature.projectId === projectId);
export const findFeature = (
projectId: string,
featureId: string,
): FeatureDto | undefined =>
featureFixtures.find(
(feature) => feature.projectId === projectId && feature.id === featureId,
);
export const listEvidenceForProject = (projectId: string): EvidenceDto[] =>
evidenceFixtures.filter((evidence) => evidence.projectId === projectId);
export const findEvidence = (
projectId: string,
evidenceId: string,
): EvidenceDto | undefined =>
evidenceFixtures.find(
(evidence) => evidence.projectId === projectId && evidence.id === evidenceId,
);
export const listEvidenceForFeature = (
projectId: string,
featureId: string,
): EvidenceDto[] =>
evidenceFixtures.filter(
(evidence) =>
evidence.projectId === projectId && evidence.featureIds.includes(featureId),
);
export const paginate = <T>(
items: T[],
input: PaginationInput,
): PaginatedListResponse<T> => {
const cursorOffset = input.cursor ? Number.parseInt(input.cursor, 10) : NaN;
const start = Number.isFinite(cursorOffset)
? cursorOffset
: (input.page - 1) * input.pageSize;
const pageItems = items.slice(start, start + input.pageSize);
const nextOffset = start + input.pageSize;
return {
items: pageItems,
total: items.length,
page: input.page,
pageSize: input.pageSize,
hasNextPage: nextOffset < items.length,
hasPreviousPage: input.page > 1,
...(nextOffset < items.length ? { nextCursor: String(nextOffset) } : {}),
...(input.sort ? { sort: input.sort } : {}),
...(input.filters ? { filters: input.filters } : {}),
};
};
+114
View File
@@ -0,0 +1,114 @@
import type {
InsightListFilters,
InsightReviewAction,
ProjectInsightDto,
TrustMetadataDto,
} from "../types/api.js";
const trustNow = "2026-06-04T09:00:00Z";
const trust = (source: string, confidence: number): TrustMetadataDto => ({
source,
confidence,
evidence: [],
lastChecked: trustNow,
approvalState: "pending",
});
export const insightFixtures: ProjectInsightDto[] = [
{
id: "ins-wide-01",
projectId: "widefrog",
title: "Auth evidence confidence is strong but stale",
category: "evidence",
severity: "warning",
status: "open",
summary:
"Authentication features have good evidence coverage, but the last confidence check predates the newest build.",
recommendation:
"Refresh feature evidence before using the auth context pack for implementation work.",
reviewed: false,
confidence: 86,
linkedFeatureIds: ["f-auth-mfa", "f-auth-social"],
linkedEvidenceIds: ["ev-auth-mfa-01"],
trust: trust("deterministic insight fixture", 86),
createdAt: "2026-06-04T09:15:00Z",
},
{
id: "ins-wide-02",
projectId: "widefrog",
title: "Memory rules should be included in prompt context",
category: "memory",
severity: "high",
status: "open",
summary:
"Do-not-break style memory rules are available but must be explicitly carried into generated context packs.",
recommendation:
"Include R-UX-01 and R-SEC-02 before generating developer prompts.",
reviewed: false,
confidence: 91,
linkedFeatureIds: ["f-auth-mfa"],
linkedEvidenceIds: [],
trust: trust("deterministic insight fixture", 91),
createdAt: "2026-06-04T10:20:00Z",
},
];
export const listInsightsForProject = (projectId: string): ProjectInsightDto[] =>
insightFixtures.filter((insight) => insight.projectId === projectId);
export const findInsight = (
projectId: string,
insightId: string,
): ProjectInsightDto | undefined =>
insightFixtures.find(
(insight) => insight.projectId === projectId && insight.id === insightId,
);
export const applyInsightFilters = (
insights: ProjectInsightDto[],
filters: InsightListFilters,
): ProjectInsightDto[] =>
insights.filter((insight) => {
if (filters.category?.length && !filters.category.includes(insight.category)) {
return false;
}
if (filters.severity?.length && !filters.severity.includes(insight.severity)) {
return false;
}
if (filters.status?.length && !filters.status.includes(insight.status)) {
return false;
}
if (filters.confidence !== undefined && insight.confidence !== filters.confidence) {
return false;
}
if (filters.reviewed !== undefined && insight.reviewed !== filters.reviewed) {
return false;
}
return true;
});
export const reviewInsight = (
projectId: string,
insightId: string,
action: InsightReviewAction,
reason?: string,
): ProjectInsightDto | undefined => {
const insight = findInsight(projectId, insightId);
if (!insight) return undefined;
const reviewedAt = new Date().toISOString();
insight.status =
action === "accept" ? "accepted" : action === "reject" ? "rejected" : "ignored";
insight.reviewed = true;
insight.reviewedAt = reviewedAt;
insight.reviewReason = reason;
insight.trust = {
...insight.trust,
approvalState: action === "accept" ? "approved" : "rejected",
lastChecked: reviewedAt,
};
return insight;
};
+157
View File
@@ -0,0 +1,157 @@
import type {
KnowledgeItemDto,
KnowledgeListFilters,
KnowledgeMutationRequestDto,
TrustMetadataDto,
} from "../types/api.js";
const trustNow = "2026-06-04T09:00:00Z";
let knowledgeCounter = 3;
const trust = (source: string, confidence: number): TrustMetadataDto => ({
source,
confidence,
evidence: [],
lastChecked: trustNow,
approvalState: "pending",
});
export const knowledgeFixtures: KnowledgeItemDto[] = [
{
id: "kn-wide-01",
projectId: "widefrog",
title: "Auth fallback evidence policy",
category: "Technical",
content:
"Authentication fallback changes need evidence that both success and failure API states are handled.",
linkedFeatureIds: ["f-auth-mfa"],
source: "fixture-analysis",
confidence: 90,
includeInContextPacks: ["cp-widefrog-core"],
trust: trust("deterministic knowledge fixture", 90),
createdAt: "2026-06-03T15:00:00Z",
updatedAt: "2026-06-03T15:00:00Z",
},
{
id: "kn-wide-02",
projectId: "widefrog",
title: "Context pack prompt boundary",
category: "Workflow",
content:
"Generated prompts must state when context comes from a context pack and must preserve uncertainty.",
linkedFeatureIds: [],
source: "manual",
confidence: 84,
includeInContextPacks: [],
trust: trust("deterministic knowledge fixture", 84),
createdAt: "2026-06-03T16:00:00Z",
updatedAt: "2026-06-03T16:00:00Z",
},
];
export const listKnowledgeForProject = (projectId: string): KnowledgeItemDto[] =>
knowledgeFixtures.filter((knowledge) => knowledge.projectId === projectId);
export const findKnowledge = (
projectId: string,
knowledgeId: string,
): KnowledgeItemDto | undefined =>
knowledgeFixtures.find(
(knowledge) => knowledge.projectId === projectId && knowledge.id === knowledgeId,
);
export const applyKnowledgeFilters = (
items: KnowledgeItemDto[],
filters: KnowledgeListFilters,
): KnowledgeItemDto[] =>
items.filter((item) => {
if (filters.category?.length && !filters.category.includes(item.category)) {
return false;
}
if (filters.featureId && !item.linkedFeatureIds.includes(filters.featureId)) {
return false;
}
if (filters.source?.length && !filters.source.includes(item.source)) {
return false;
}
if (filters.confidence !== undefined && item.confidence !== filters.confidence) {
return false;
}
if (filters.search) {
const haystack = `${item.title} ${item.content} ${item.category}`.toLowerCase();
if (!haystack.includes(filters.search.toLowerCase())) return false;
}
return true;
});
export const createKnowledge = (
projectId: string,
body: KnowledgeMutationRequestDto & { title: string; content: string },
): KnowledgeItemDto => {
const createdAt = new Date().toISOString();
const confidence = body.confidence ?? 75;
const item: KnowledgeItemDto = {
id: `kn-${projectId}-${knowledgeCounter++}`,
projectId,
title: body.title.trim(),
category: body.category?.trim() || "Technical",
content: body.content.trim(),
linkedFeatureIds: body.linkedFeatureIds ?? [],
source: body.source?.trim() || "manual",
confidence,
includeInContextPacks: [],
trust: {
source: "deterministic knowledge create",
confidence,
evidence: [],
lastChecked: createdAt,
approvalState: "pending",
},
createdAt,
updatedAt: createdAt,
};
knowledgeFixtures.push(item);
return item;
};
export const updateKnowledge = (
projectId: string,
knowledgeId: string,
body: KnowledgeMutationRequestDto,
): KnowledgeItemDto | undefined => {
const item = findKnowledge(projectId, knowledgeId);
if (!item) return undefined;
const updatedAt = new Date().toISOString();
if (body.title !== undefined) item.title = body.title.trim();
if (body.category !== undefined) item.category = body.category.trim();
if (body.content !== undefined) item.content = body.content.trim();
if (body.linkedFeatureIds !== undefined) {
item.linkedFeatureIds = body.linkedFeatureIds;
}
if (body.source !== undefined) item.source = body.source.trim();
if (body.confidence !== undefined) {
item.confidence = body.confidence;
item.trust.confidence = body.confidence;
}
item.updatedAt = updatedAt;
item.trust.lastChecked = updatedAt;
return item;
};
export const includeKnowledgeInContextPack = (
projectId: string,
knowledgeId: string,
contextPackId: string,
): KnowledgeItemDto | undefined => {
const item = findKnowledge(projectId, knowledgeId);
if (!item) return undefined;
if (!item.includeInContextPacks.includes(contextPackId)) {
item.includeInContextPacks.push(contextPackId);
}
item.updatedAt = new Date().toISOString();
return item;
};
+161
View File
@@ -0,0 +1,161 @@
import type {
MemoryRuleDto,
MemoryRuleListFilters,
MemoryRuleMutationRequestDto,
TrustMetadataDto,
} from "../types/api.js";
const trustNow = "2026-06-04T09:00:00Z";
let memoryRuleCounter = 3;
const trust = (source: string, confidence: number): TrustMetadataDto => ({
source,
confidence,
evidence: [],
lastChecked: trustNow,
approvalState: "pending",
});
export const memoryRuleFixtures: MemoryRuleDto[] = [
{
id: "R-UX-01",
projectId: "widefrog",
title: "Primary Action Placement",
rule: "Primary action buttons must stay visually stable across workspace screens.",
scope: "global",
category: "UX",
severity: "important",
linkedFeatureIds: ["f-auth-mfa"],
includeInContextPacks: ["cp-widefrog-core"],
includeInContextPacksDefault: true,
trust: trust("deterministic memory-rule fixture", 93),
createdAt: "2026-06-02T08:00:00Z",
updatedAt: "2026-06-02T08:00:00Z",
},
{
id: "R-SEC-02",
projectId: "widefrog",
title: "Global API Error Handling",
rule: "Do not remove explicit API error envelopes from backend read or mock mutation flows.",
scope: "global",
category: "Security",
severity: "do_not_break",
linkedFeatureIds: ["f-auth-mfa", "f-auth-social"],
includeInContextPacks: ["cp-widefrog-core"],
includeInContextPacksDefault: true,
trust: trust("deterministic memory-rule fixture", 96),
createdAt: "2026-06-02T08:30:00Z",
updatedAt: "2026-06-02T08:30:00Z",
},
];
export const listMemoryRulesForProject = (projectId: string): MemoryRuleDto[] =>
memoryRuleFixtures.filter((rule) => rule.projectId === projectId);
export const findMemoryRule = (
projectId: string,
ruleId: string,
): MemoryRuleDto | undefined =>
memoryRuleFixtures.find((rule) => rule.projectId === projectId && rule.id === ruleId);
export const applyMemoryRuleFilters = (
rules: MemoryRuleDto[],
filters: MemoryRuleListFilters,
): MemoryRuleDto[] =>
rules.filter((rule) => {
if (filters.scope?.length && !filters.scope.includes(rule.scope)) return false;
if (filters.category?.length && !filters.category.includes(rule.category)) {
return false;
}
if (filters.severity?.length && !filters.severity.includes(rule.severity)) {
return false;
}
if (
filters.includeInContextPacks !== undefined &&
rule.includeInContextPacksDefault !== filters.includeInContextPacks
) {
return false;
}
if (filters.featureId && !rule.linkedFeatureIds.includes(filters.featureId)) {
return false;
}
if (filters.search) {
const haystack = `${rule.title} ${rule.rule} ${rule.category}`.toLowerCase();
if (!haystack.includes(filters.search.toLowerCase())) return false;
}
return true;
});
export const createMemoryRule = (
projectId: string,
body: MemoryRuleMutationRequestDto & { rule: string },
): MemoryRuleDto => {
const createdAt = new Date().toISOString();
const includeDefault = body.includeInContextPacks ?? false;
const rule: MemoryRuleDto = {
id: `R-${projectId.toUpperCase()}-${memoryRuleCounter++}`,
projectId,
title: body.title?.trim() || body.rule.trim().slice(0, 60),
rule: body.rule.trim(),
scope: body.scope ?? "global",
category: body.category ?? "Technical",
severity: body.severity ?? "important",
linkedFeatureIds: body.linkedFeatureIds ?? [],
includeInContextPacks: [],
includeInContextPacksDefault: includeDefault,
trust: {
source: "deterministic memory-rule create",
confidence: 78,
evidence: [],
lastChecked: createdAt,
approvalState: "pending",
},
createdAt,
updatedAt: createdAt,
};
memoryRuleFixtures.push(rule);
return rule;
};
export const updateMemoryRule = (
projectId: string,
ruleId: string,
body: MemoryRuleMutationRequestDto,
): MemoryRuleDto | undefined => {
const rule = findMemoryRule(projectId, ruleId);
if (!rule) return undefined;
const updatedAt = new Date().toISOString();
if (body.title !== undefined) rule.title = body.title.trim();
if (body.rule !== undefined) rule.rule = body.rule.trim();
if (body.scope !== undefined) rule.scope = body.scope;
if (body.category !== undefined) rule.category = body.category;
if (body.severity !== undefined) rule.severity = body.severity;
if (body.linkedFeatureIds !== undefined) {
rule.linkedFeatureIds = body.linkedFeatureIds;
}
if (body.includeInContextPacks !== undefined) {
rule.includeInContextPacksDefault = body.includeInContextPacks;
}
rule.updatedAt = updatedAt;
rule.trust.lastChecked = updatedAt;
return rule;
};
export const includeMemoryRuleInContextPack = (
projectId: string,
ruleId: string,
contextPackId: string,
): MemoryRuleDto | undefined => {
const rule = findMemoryRule(projectId, ruleId);
if (!rule) return undefined;
if (!rule.includeInContextPacks.includes(contextPackId)) {
rule.includeInContextPacks.push(contextPackId);
}
rule.updatedAt = new Date().toISOString();
rule.includeInContextPacksDefault = true;
return rule;
};
+453
View File
@@ -0,0 +1,453 @@
import type {
ContextPackSummaryDto,
CurrentStateSummaryDto,
ProjectDetailDto,
ProjectDna,
ProjectSummaryDto,
WorkspaceReadDto,
} from "../types/api.js";
const trustNow = "2026-06-04T09:00:00Z";
const widefrogDna: ProjectDna = {
vision: "Deliver accessible, adaptive media-first workflows with high reliability.",
goals: [
"Ship quality features in 2 week cycles",
"Increase test confidence for authentication and streaming",
"Track architectural risks before implementation",
],
nonGoals: [
"No direct platform migration",
"No cross-region multi-tenancy in phase 1",
],
targetAudience: "Small to medium engineering teams with one release train",
principles: [
"Evidence-first decisions",
"Small, measurable feature changes",
"No hidden architecture assumptions",
],
successMetrics: [
{
metric: "release-cycle-duration",
target: "<= 14",
targetValue: 14,
operator: "<=",
},
{
metric: "regression-rate",
target: "<2%",
targetValue: 2,
operator: "<",
},
],
maturityStage: "Execution",
revision: 2,
updatedAt: trustNow,
};
const nebulaDna: ProjectDna = {
vision: "Create a secure onboarding toolkit for internal operations teams.",
goals: [
"Reduce manual onboarding time by 30%",
"Keep architecture explicit with documented trust metadata",
],
nonGoals: ["No native mobile app in phase 1"],
targetAudience: "Operations and product teams in enterprise settings",
principles: [
"Policy-driven behavior",
"Traceable AI output",
"Minimal accidental complexity",
],
successMetrics: [
{
metric: "ticket-resolution-time",
target: "-25%",
targetValue: 25,
operator: ">=",
},
],
maturityStage: "Definition",
revision: 1,
updatedAt: trustNow,
};
export const projectFixtures: ProjectDetailDto[] = [
{
id: "widefrog",
name: "WideFrog",
description: "Cross-platform streaming service for real-time media teams.",
health: 92,
healthStatus: "healthy",
lastSync: "2026-06-04 09:20",
domain: "streaming",
status: "active",
createdAt: "2026-01-10T09:00:00Z",
updatedAt: trustNow,
activeContextPackId: "cp-widefrog-core",
dna: widefrogDna,
constitution: {
id: "const-widefrog-01",
projectId: "widefrog",
title: "ProjectBrain Constitution",
intent: "Keep the project operating system context-first, traceable, and review-gated.",
rules: [
"Context first, prompt second.",
"AI may suggest but not silently mutate truth.",
"Everything must be traceable.",
"Features are operational truth.",
"Do-not-break rules override new feature requests.",
"Verification before expansion.",
],
updatedAt: trustNow,
revision: 1,
},
constraints: [
{
id: "con-wide-01",
projectId: "widefrog",
title: "Self-hosted by default",
description:
"The product must support local operation without depending on managed cloud services.",
category: "hosting",
status: "active",
intent: "Preserve deployment independence for Unraid and home-lab users.",
},
{
id: "con-wide-02",
projectId: "widefrog",
title: "No paid API requirement",
description:
"Core workflows must remain useful without mandatory paid AI or SaaS APIs.",
category: "cost",
status: "active",
intent: "Avoid vendor lock-in and surprise operational cost.",
},
{
id: "con-wide-03",
projectId: "widefrog",
title: "Local-first where possible",
description:
"Project knowledge, context packs and imports should be modelled for local storage first.",
category: "portability",
status: "active",
intent: "Make project memory durable outside any single provider.",
},
],
principles: [
{
id: "pri-wide-01",
projectId: "widefrog",
title: "Context first",
description: "Every AI or Codex task starts from a current context pack.",
intent: "Reduce prompt drift and missing project history.",
status: "active",
},
{
id: "pri-wide-02",
projectId: "widefrog",
title: "Review before truth",
description:
"AI outputs enter the review queue before they affect project truth.",
intent: "Keep human approval as the boundary between suggestion and state.",
status: "active",
},
{
id: "pri-wide-03",
projectId: "widefrog",
title: "Traceability over speed",
description:
"Prefer slower accepted changes with evidence over fast untraceable updates.",
intent: "Make future context packs trustworthy.",
status: "active",
},
],
initiatives: [
{
id: "init-wide-reliability",
projectId: "widefrog",
title: "Improve Reliability",
intent: "Make regressions visible before implementation continues.",
status: "active",
featureIds: ["f-auth-mfa", "f-auth-social"],
priority: "important",
},
{
id: "init-wide-ai-ops",
projectId: "widefrog",
title: "AI Operations Layer",
intent:
"Make Capture, Review, Current State and Codex Task flows visible before backend work resumes.",
status: "planned",
featureIds: ["f-auth-mfa"],
priority: "important",
},
],
featureGroups: [
{
id: "auth",
name: "Authentication",
description: "Authentication and identity layer",
focusArea: "architecture",
featureIds: ["f-auth-social", "f-auth-mfa"],
},
],
memoryRules: [
{
id: "R-UX-01",
title: "Primary Action Placement",
category: "UX",
scope: "project",
priority: "High",
status: "Verified",
},
{
id: "R-SEC-02",
title: "Global API Error Handling",
category: "Technical",
scope: "project",
priority: "Critical",
status: "Verified",
},
],
promptTemplates: [
{
id: "tmpl-feature",
name: "New Feature",
description: "Generate scaffold for a new feature.",
icon: "Plus",
},
{
id: "tmpl-bug",
name: "Fix Bug",
description: "Create bug-fix task with context.",
icon: "Bug",
},
],
workspaceState: {
projectId: "widefrog",
activeContextPackId: "cp-widefrog-core",
primaryObjective: "Stabilize auth and stream experiences before next release.",
focusAreas: [
{
id: "fa-auth",
title: "Identity and Access",
purpose: "quality",
owner: "Alex",
confidence: 4,
summary: "Increase trust on auth flow and reduce login regressions.",
state: "active",
entities: [
{
entityType: "feature",
entityId: "f-auth-mfa",
projectId: "widefrog",
label: "Multi-factor Authentication",
link: "/projects/widefrog/features/f-auth-mfa",
},
{
entityType: "bug",
entityId: "B-MFA-01",
projectId: "widefrog",
label: "SMS delivery fails on some carriers",
link: "/projects/widefrog/bugs",
},
],
},
],
openTasksCount: 1,
openBugCount: 1,
activeSection: "workspace",
updatedAt: trustNow,
notes: [
"Auth is primary risk area.",
"Run AI checks after each auth change.",
],
},
},
{
id: "nebula",
name: "Nebula Console",
description: "Operations-first internal console for role-driven onboarding.",
health: 84,
healthStatus: "warn",
lastSync: "2026-06-03 16:10",
domain: "operations",
status: "active",
createdAt: "2026-03-01T10:00:00Z",
updatedAt: "2026-06-03T16:10:00Z",
activeContextPackId: "cp-nebula-core",
dna: nebulaDna,
constraints: [],
principles: [],
initiatives: [],
featureGroups: [
{
id: "onboarding",
name: "Onboarding",
description: "Identity and team onboarding modules",
focusArea: "delivery",
featureIds: ["f-onboard-flow"],
},
],
memoryRules: [
{
id: "R-NB-01",
title: "Keep onboarding policy explicit",
category: "Architecture",
scope: "project",
priority: "High",
status: "Pending",
},
],
promptTemplates: [
{
id: "tmpl-onboard",
name: "Onboarding Flow",
description: "Draft onboarding execution plan.",
icon: "User",
},
],
},
];
const contextPackSummaries: ContextPackSummaryDto[] = [
{
projectId: "widefrog",
contextPackId: "cp-widefrog-core",
title: "Core Identity and Streams",
category: "architecture",
summary:
"Authentication and stream modules have the highest evidence value and must stay explicit before adding scope.",
counts: {
assumptions: 2,
constraints: 2,
evidence: 1,
features: 2,
bugs: 1,
tasks: 1,
decisions: 1,
memoryRules: 2,
risks: 2,
},
qualityScore: 88,
createdAt: "2026-06-03T09:00:00Z",
updatedAt: trustNow,
},
{
projectId: "nebula",
contextPackId: "cp-nebula-core",
title: "Onboarding Policy Baseline",
category: "quality",
summary: "Focus on policy traceability and stable onboarding defaults.",
counts: {
assumptions: 1,
constraints: 1,
evidence: 1,
features: 1,
bugs: 0,
tasks: 0,
decisions: 0,
memoryRules: 1,
risks: 1,
},
qualityScore: 61,
createdAt: "2026-06-02T14:00:00Z",
updatedAt: "2026-06-03T16:00:00Z",
},
];
const currentStateSummaries: Record<string, CurrentStateSummaryDto> = {
widefrog: {
generatedAt: "2026-06-05T21:45:00Z",
exists: [
"Project DNA",
"Feature Map",
"Context Packs",
"Mock Build Journal",
"Memory Rules",
],
works: [
"Context-first navigation",
"Mock evidence display",
"Workspace focus areas",
],
uncertain: [
"Final evidence threshold for feature lifecycle",
"Codex import review mapping",
],
broken: [],
currentRisks: [
"AI output could be mistaken for truth without visible review state.",
],
pendingReviewItemIds: ["rq-wide-01", "rq-wide-02"],
recommendedNextActions: [
"Run the database smoke suite against the active Tower deployment.",
"Review stale review queue items before promoting new project truth.",
"Use Repo Builder and Handoffs to package the next release slice.",
],
},
};
export const toProjectSummary = (
project: ProjectDetailDto,
): ProjectSummaryDto => ({
id: project.id,
name: project.name,
description: project.description,
health: project.health,
healthStatus: project.healthStatus,
lastSync: project.lastSync,
domain: project.domain,
status: project.status,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
activeContextPackId: project.activeContextPackId,
});
export const listProjects = (): ProjectSummaryDto[] =>
projectFixtures.map(toProjectSummary);
export const findProject = (projectId: string): ProjectDetailDto | undefined =>
projectFixtures.find((project) => project.id === projectId);
export const findContextPackSummary = (
projectId: string,
contextPackId: string,
): ContextPackSummaryDto | undefined =>
contextPackSummaries.find(
(contextPack) =>
contextPack.projectId === projectId &&
contextPack.contextPackId === contextPackId,
);
export const getWorkspaceRead = (
project: ProjectDetailDto,
): WorkspaceReadDto => {
const focusAreas = project.workspaceState?.focusAreas ?? [];
const activeContextPackId =
project.workspaceState?.activeContextPackId ?? project.activeContextPackId;
const currentStateSummary = currentStateSummaries[project.id];
return {
project: toProjectSummary(project),
dna: project.dna,
activeFocusAreaId: focusAreas.find((area) => area.state === "active")?.id,
activeContextPackId,
focusAreas,
workspaceState: project.workspaceState,
currentStateSummary,
healthSummary: {
health: project.health,
healthStatus: project.healthStatus,
openTasksCount: project.workspaceState?.openTasksCount ?? 0,
openBugCount: project.workspaceState?.openBugCount ?? 0,
riskCount:
activeContextPackId === undefined
? 0
: (findContextPackSummary(project.id, activeContextPackId)?.counts
.risks ?? 0),
pendingReviewCount:
currentStateSummary?.pendingReviewItemIds.length ?? 0,
},
};
};
+168
View File
@@ -0,0 +1,168 @@
import type {
ConvertRegressionRuleSeverity,
CreateRegressionRequestDto,
RegressionListFilters,
RegressionMemoryDto,
RegressionRuleDto,
RegressionSeverity,
TrustMetadataDto,
} from "../types/api.js";
const trustNow = "2026-06-04T09:00:00Z";
const trust = (confidence: number): TrustMetadataDto => ({
source: "deterministic regression fixture",
confidence,
evidence: [],
lastChecked: trustNow,
approvalState: "pending",
});
let regressionCounter = 2;
let ruleCounter = 1;
export const regressionFixtures: RegressionMemoryDto[] = [
{
id: "rm-wide-01",
projectId: "widefrog",
title: "Streaming queue telemetry fallback regression",
description:
"Telemetry timing can drift when fallback queue handling changes.",
affectedFeatureIds: ["f-auth-mfa", "f-auth-social"],
firstSeen: "2026-06-01T08:30:00Z",
timesOccurred: 3,
typicalCause:
"Incomplete telemetry update in fallback code path after architecture refactor.",
preventionRule: "Global API Error Handling",
linkedBuildIds: ["build-wide-01"],
severity: "medium",
status: "watch",
includeInContextPacks: ["cp-widefrog-core"],
trust: trust(91),
},
];
export const regressionRuleFixtures: RegressionRuleDto[] = [];
export const listRegressionsForProject = (
projectId: string,
): RegressionMemoryDto[] =>
regressionFixtures.filter((regression) => regression.projectId === projectId);
export const findRegression = (
projectId: string,
regressionId: string,
): RegressionMemoryDto | undefined =>
regressionFixtures.find(
(regression) =>
regression.projectId === projectId && regression.id === regressionId,
);
export const applyRegressionFilters = (
regressions: RegressionMemoryDto[],
filters: RegressionListFilters,
): RegressionMemoryDto[] =>
regressions.filter((regression) => {
if (filters.status?.length && !filters.status.includes(regression.status)) {
return false;
}
if (
filters.featureId &&
!regression.affectedFeatureIds.includes(filters.featureId)
) {
return false;
}
if (
filters.severity?.length &&
!filters.severity.includes(regression.severity)
) {
return false;
}
if (
filters.timesOccurredMin !== undefined &&
regression.timesOccurred < filters.timesOccurredMin
) {
return false;
}
if (
filters.timesOccurredMax !== undefined &&
regression.timesOccurred > filters.timesOccurredMax
) {
return false;
}
return true;
});
export const createRegression = (
projectId: string,
body: CreateRegressionRequestDto,
): RegressionMemoryDto => {
const createdAt = new Date().toISOString();
const regression: RegressionMemoryDto = {
id: `rm-${projectId}-${regressionCounter++}`,
projectId,
title: body.title.trim(),
description: body.description,
affectedFeatureIds: body.affectedFeatureIds ?? [],
firstSeen: createdAt,
timesOccurred: 1,
typicalCause: body.typicalCause?.trim() || "Fixture-created regression cause.",
preventionRule:
body.preventionRule?.trim() || "Add deterministic prevention rule.",
linkedBuildIds: [],
severity: body.severity ?? "medium",
status: "active",
includeInContextPackStatus: "not_in_pack",
trust: {
source: "deterministic regression create",
confidence: 76,
evidence: [],
lastChecked: createdAt,
approvalState: "pending",
},
};
regressionFixtures.push(regression);
return regression;
};
export const convertRegressionToRule = (
projectId: string,
regressionId: string,
ruleTitle?: string,
severity?: ConvertRegressionRuleSeverity,
): { createdRule: RegressionRuleDto } | undefined => {
const regression = findRegression(projectId, regressionId);
if (!regression) return undefined;
const createdAt = new Date().toISOString();
const createdRule: RegressionRuleDto = {
id: `rule-${projectId}-${ruleCounter++}`,
projectId,
regressionId,
title: ruleTitle?.trim() || regression.preventionRule,
severity: severity ?? toRuleSeverity(regression.severity),
source: "regression-memory",
createdAt,
};
regression.convertedRuleId = createdRule.id;
regression.status = "resolved";
regression.trust = {
...regression.trust,
approvalState: "approved",
lastChecked: createdAt,
};
regressionRuleFixtures.push(createdRule);
return { createdRule };
};
const toRuleSeverity = (
severity: RegressionSeverity,
): ConvertRegressionRuleSeverity => {
if (severity === "critical") return "critical";
if (severity === "high") return "do_not_break";
return "important";
};
@@ -0,0 +1,130 @@
import type {
CreateRelationshipRequestDto,
RelationshipDto,
RelationshipListFilters,
TrustMetadataDto,
} from "../types/api.js";
const trustNow = "2026-06-04T09:00:00Z";
let relationshipCounter = 3;
const trust = (confidence: number): TrustMetadataDto => ({
source: "deterministic relationship fixture",
confidence,
evidence: [],
lastChecked: trustNow,
approvalState: "pending",
});
export const relationshipFixtures: RelationshipDto[] = [
{
id: "rel-wide-01",
projectId: "widefrog",
from: "f-auth-mfa",
to: "R-SEC-02",
type: "depends_on",
rationale:
"MFA implementation depends on global API error handling staying stable.",
strength: 5,
confidence: 92,
source: "fixture-analysis",
trust: trust(92),
createdAt: "2026-06-03T12:00:00Z",
},
{
id: "rel-wide-02",
projectId: "widefrog",
from: "cp-widefrog-core",
to: "f-auth-mfa",
type: "references",
rationale: "The core context pack references MFA as active implementation context.",
strength: 4,
confidence: 88,
source: "fixture-analysis",
trust: trust(88),
createdAt: "2026-06-03T12:30:00Z",
},
];
export const listRelationshipsForProject = (
projectId: string,
): RelationshipDto[] =>
relationshipFixtures.filter(
(relationship) =>
relationship.projectId === projectId && relationship.deletedAt === undefined,
);
export const findRelationship = (
projectId: string,
relationshipId: string,
): RelationshipDto | undefined =>
relationshipFixtures.find(
(relationship) =>
relationship.projectId === projectId &&
relationship.id === relationshipId &&
relationship.deletedAt === undefined,
);
export const applyRelationshipFilters = (
relationships: RelationshipDto[],
filters: RelationshipListFilters,
): RelationshipDto[] =>
relationships.filter((relationship) => {
if (filters.from && relationship.from !== filters.from) return false;
if (filters.to && relationship.to !== filters.to) return false;
if (filters.type?.length && !filters.type.includes(relationship.type)) {
return false;
}
if (
filters.confidence !== undefined &&
relationship.confidence !== filters.confidence
) {
return false;
}
if (filters.strength !== undefined && relationship.strength !== filters.strength) {
return false;
}
return true;
});
export const createRelationship = (
projectId: string,
body: CreateRelationshipRequestDto,
): RelationshipDto => {
const createdAt = new Date().toISOString();
const confidence = body.confidence ?? 75;
const relationship: RelationshipDto = {
id: `rel-${projectId}-${relationshipCounter++}`,
projectId,
from: body.from.trim(),
to: body.to.trim(),
type: body.type,
rationale: body.rationale.trim(),
strength: body.strength ?? 3,
confidence,
source: "deterministic relationship create",
trust: {
source: "deterministic relationship create",
confidence,
evidence: [],
lastChecked: createdAt,
approvalState: "pending",
},
createdAt,
};
relationshipFixtures.push(relationship);
return relationship;
};
export const deleteRelationship = (
projectId: string,
relationshipId: string,
): RelationshipDto | undefined => {
const relationship = findRelationship(projectId, relationshipId);
if (!relationship) return undefined;
relationship.deletedAt = new Date().toISOString();
return relationship;
};
+244
View File
@@ -0,0 +1,244 @@
import type {
CaptureItemDto,
ReviewMutationResponseDto,
ReviewQueueConvertTarget,
ReviewQueueItemDto,
ReviewQueueListFilters,
UpdateReviewQueueItemRequestDto,
} from "../types/api.js";
let reviewItemCounter = 3;
export const reviewQueueFixtures: ReviewQueueItemDto[] = [
{
id: "rq-wide-01",
projectId: "widefrog",
type: "feature",
title: "Capture Center routes intake into Review Queue",
description:
"Create a central intake surface that classifies notes, build summaries, AI output and errors before they can change project truth.",
priority: "important",
status: "pending",
source: "scope update",
confidence: 89,
evidenceStrength: "medium",
verificationState: "needs evidence",
suggestedAction:
"Accept as planned backend mock scope and keep project truth review-gated.",
createdAt: "2026-06-05T21:20:00Z",
updatedAt: "2026-06-05T21:20:00Z",
},
{
id: "rq-wide-02",
projectId: "widefrog",
type: "architecture_conflict",
title: "Prompts must remain downstream from Context Packs",
description:
"The model needs structured Codex task exports while preserving context packs as the upstream source.",
priority: "normal",
status: "pending",
source: "product constitution",
confidence: 82,
evidenceStrength: "strong",
verificationState: "unverified",
suggestedAction:
"Convert prompt-builder planning into Codex task planning without real Codex integration.",
createdAt: "2026-06-05T21:24:00Z",
updatedAt: "2026-06-05T21:24:00Z",
},
];
export const listReviewItemsForProject = (
projectId: string,
): ReviewQueueItemDto[] =>
reviewQueueFixtures.filter((item) => item.projectId === projectId);
export const findReviewItem = (
projectId: string,
reviewItemId: string,
): ReviewQueueItemDto | undefined =>
reviewQueueFixtures.find(
(item) => item.projectId === projectId && item.id === reviewItemId,
);
export const applyReviewQueueFilters = (
items: ReviewQueueItemDto[],
filters: ReviewQueueListFilters,
): ReviewQueueItemDto[] =>
items.filter((item) => {
if (filters.priority?.length && !filters.priority.includes(item.priority)) {
return false;
}
if (filters.type?.length && !filters.type.includes(item.type)) return false;
if (filters.status?.length && !filters.status.includes(item.status)) {
return false;
}
if (filters.source?.length && !filters.source.includes(item.source)) {
return false;
}
if (filters.confidence !== undefined && item.confidence !== filters.confidence) {
return false;
}
if (
filters.evidenceStrength?.length &&
!filters.evidenceStrength.includes(item.evidenceStrength)
) {
return false;
}
if (
filters.verificationState?.length &&
!filters.verificationState.includes(item.verificationState)
) {
return false;
}
return true;
});
export const updateReviewItem = (
projectId: string,
reviewItemId: string,
body: UpdateReviewQueueItemRequestDto,
): ReviewQueueItemDto | undefined | "REVIEW_ALREADY_DECIDED" => {
const item = findReviewItem(projectId, reviewItemId);
if (!item) return undefined;
if (item.status !== "pending") return "REVIEW_ALREADY_DECIDED";
if (body.title !== undefined) item.title = body.title.trim();
if (body.description !== undefined) item.description = body.description.trim();
if (body.priority !== undefined) item.priority = body.priority;
if (body.notes !== undefined) item.notes = body.notes.trim();
item.updatedAt = new Date().toISOString();
return item;
};
export const createReviewItemFromCapture = (
captureItem: CaptureItemDto,
): ReviewQueueItemDto => {
const createdAt = new Date().toISOString();
const item: ReviewQueueItemDto = {
id: `rq-${captureItem.projectId}-${reviewItemCounter++}`,
projectId: captureItem.projectId,
type: toReviewType(captureItem.classificationType ?? "knowledge"),
title: captureItem.title,
description: captureItem.content,
priority: captureItem.confidence && captureItem.confidence >= 90 ? "important" : "normal",
status: "pending",
source: captureItem.source,
confidence: captureItem.confidence ?? 72,
evidenceStrength: captureItem.evidenceStrength ?? "weak",
verificationState: "needs evidence",
suggestedAction:
captureItem.suggestedAction ?? "Review captured item before project truth changes.",
createdAt,
updatedAt: createdAt,
};
reviewQueueFixtures.push(item);
return item;
};
export const createReviewPreviewFromAIAction = (
projectId: string,
source: string,
title: string,
description: string,
): ReviewQueueItemDto => {
const createdAt = new Date().toISOString();
return {
id: `rq-preview-${projectId}-${reviewItemCounter++}`,
projectId,
type: "feature",
title,
description,
priority: "normal",
status: "pending",
source,
confidence: 74,
evidenceStrength: "weak",
verificationState: "unverified",
suggestedAction: "Preview only: route to Review Queue before accepting truth.",
createdAt,
updatedAt: createdAt,
};
};
export const applyReviewAction = (
projectId: string,
reviewItemId: string,
action: "accept" | "reject",
notes?: string,
): ReviewMutationResponseDto | undefined | "REVIEW_ALREADY_DECIDED" => {
const item = findReviewItem(projectId, reviewItemId);
if (!item) return undefined;
if (item.status !== "pending") return "REVIEW_ALREADY_DECIDED";
item.status = action === "accept" ? "accepted" : "rejected";
item.notes = notes;
item.updatedAt = new Date().toISOString();
return {
success: true,
message: `Review item '${reviewItemId}' ${item.status}.`,
updatedEntityId: reviewItemId,
affectedEntities: [reviewItemId],
};
};
export const mergeReviewItem = (
projectId: string,
reviewItemId: string,
targetEntityId: string,
notes?: string,
): ReviewMutationResponseDto | undefined | "REVIEW_ALREADY_DECIDED" => {
const item = findReviewItem(projectId, reviewItemId);
if (!item) return undefined;
if (item.status !== "pending") return "REVIEW_ALREADY_DECIDED";
item.status = "merged";
item.targetEntityId = targetEntityId;
item.notes = notes;
item.updatedAt = new Date().toISOString();
return {
success: true,
message: `Review item '${reviewItemId}' merged into '${targetEntityId}'.`,
updatedEntityId: reviewItemId,
affectedEntities: [reviewItemId, targetEntityId],
};
};
export const convertReviewItem = (
projectId: string,
reviewItemId: string,
target: ReviewQueueConvertTarget,
notes?: string,
): ReviewMutationResponseDto | undefined | "REVIEW_ALREADY_DECIDED" => {
const item = findReviewItem(projectId, reviewItemId);
if (!item) return undefined;
if (item.status !== "pending") return "REVIEW_ALREADY_DECIDED";
const createdEntityId = `${target}-${projectId}-${reviewItemCounter++}`;
item.status = "converted";
item.convertedTarget = target;
item.createdEntityId = createdEntityId;
item.notes = notes;
item.updatedAt = new Date().toISOString();
return {
success: true,
message: `Review item '${reviewItemId}' converted to ${target}.`,
updatedEntityId: reviewItemId,
createdEntityId,
affectedEntities: [reviewItemId, createdEntityId],
};
};
const toReviewType = (
classificationType: NonNullable<CaptureItemDto["classificationType"]>,
): ReviewQueueItemDto["type"] => {
if (classificationType === "documentation_update") return "documentation_update";
if (classificationType === "open_question") return "open_question";
if (classificationType === "memory_rule") return "memory_rule";
return classificationType;
};
+123
View File
@@ -0,0 +1,123 @@
import type {
CreateSnapshotRequestDto,
SnapshotCompareDto,
SnapshotDto,
SnapshotListFilters,
} from "../types/api.js";
let snapshotCounter = 2;
export const snapshotFixtures: SnapshotDto[] = [
{
id: "sn-widefrog-001",
timestamp: "2026-06-03T14:00:00Z",
sourceRun: "import-analysis",
projectId: "widefrog",
itemsAdded: ["f-auth-mfa", "B-MFA-01", "cp-widefrog-core"],
itemsRemoved: [],
diffs: [
{
section: "Feature Map",
before: "Auth flow had no MFA feature",
after: "Added MFA feature with checks and constraints",
reason: "Architecture-first ingest revealed critical auth risk",
},
],
keyRisks: ["MFA edge-case not covered in streaming contexts"],
summary: "First architecture-first snapshot for auth stabilisation.",
},
];
export const listSnapshotsForProject = (projectId: string): SnapshotDto[] =>
snapshotFixtures.filter((snapshot) => snapshot.projectId === projectId);
export const findSnapshot = (
projectId: string,
snapshotId: string,
): SnapshotDto | undefined =>
snapshotFixtures.find(
(snapshot) => snapshot.projectId === projectId && snapshot.id === snapshotId,
);
export const applySnapshotFilters = (
snapshots: SnapshotDto[],
filters: SnapshotListFilters,
): SnapshotDto[] =>
snapshots.filter((snapshot) => {
if (
filters.sourceRun?.length &&
!filters.sourceRun.includes(snapshot.sourceRun)
) {
return false;
}
if (filters.from && snapshot.timestamp < filters.from) return false;
if (filters.to && snapshot.timestamp > filters.to) return false;
if (
filters.containsRisk &&
!snapshot.keyRisks.some((risk) =>
risk.toLowerCase().includes(filters.containsRisk?.toLowerCase() ?? ""),
)
) {
return false;
}
return true;
});
export const createSnapshot = (
projectId: string,
body: CreateSnapshotRequestDto,
): SnapshotDto => {
const timestamp = new Date().toISOString();
const snapshot: SnapshotDto = {
id: `sn-${projectId}-${String(snapshotCounter++).padStart(3, "0")}`,
timestamp,
sourceRun: body.sourceRun ?? "manual",
projectId,
itemsAdded: [],
itemsRemoved: [],
diffs: [
{
section: "R6 Snapshot",
before: "Previous fixture state",
after: "New deterministic snapshot state",
reason: "Snapshot created through real backend R6 API.",
},
],
keyRisks: body.keyRisks ?? [],
summary: body.summary?.trim() || "Deterministic backend snapshot.",
};
snapshotFixtures.push(snapshot);
return snapshot;
};
export const compareSnapshots = (
projectId: string,
baseSnapshotId: string,
targetSnapshotId: string,
): SnapshotCompareDto | undefined => {
const baseSnapshot = findSnapshot(projectId, baseSnapshotId);
const targetSnapshot = findSnapshot(projectId, targetSnapshotId);
if (!baseSnapshot || !targetSnapshot) return undefined;
const added = targetSnapshot.itemsAdded.filter(
(item) => !baseSnapshot.itemsAdded.includes(item),
);
const removed = baseSnapshot.itemsAdded.filter(
(item) => !targetSnapshot.itemsAdded.includes(item),
);
const conflictCount = targetSnapshot.keyRisks.filter((risk) =>
baseSnapshot.keyRisks.includes(risk),
).length;
return {
baseSnapshotId,
targetSnapshotId,
added,
removed,
sectionCount: targetSnapshot.diffs.length,
conflictCount,
summary: `Compared ${baseSnapshotId} to ${targetSnapshotId}: ${added.length} added, ${removed.length} removed.`,
};
};
+264
View File
@@ -0,0 +1,264 @@
import type { CreateGiteaRepositoryInput, GiteaActionJobList, GiteaActionRunnerList, GiteaActionRunList, GiteaBranch, GiteaContent, GiteaIssue, GiteaIssueComment, GiteaLabel, GiteaPullRequest, GiteaRepository, GiteaSearchResult, GiteaWebhook } from "./types.js";
export class GiteaApiError extends Error {
constructor(public readonly status: number, public readonly code: string, message: string) {
super(message);
}
}
type RequestOptions = { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; query?: Record<string, string | number | undefined>; body?: unknown; allowEmptyResponse?: boolean };
type GiteaBranchProtection = { enable_status_check?: boolean; status_check_contexts?: string[] };
type GiteaCombinedStatus = { state?: string; statuses?: Array<{ context?: string; status?: string }> };
export class GiteaClient {
private readonly apiBase: string;
private mergeStrategyKey?: "Do" | "do";
constructor(baseUrl: string, private readonly token: string, private readonly fetcher: typeof fetch = fetch) {
this.apiBase = `${baseUrl.replace(/\/+$/, "")}/api/v1`;
}
private async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const url = new URL(`${this.apiBase}${path}`);
for (const [key, value] of Object.entries(options.query ?? {})) if (value !== undefined) url.searchParams.set(key, String(value));
const response = await this.fetcher(url, {
method: options.method ?? "GET",
headers: {
Accept: "application/json",
Authorization: `token ${this.token}`,
...(options.body === undefined ? {} : { "Content-Type": "application/json" }),
},
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
const payload = await response.json().catch(() => undefined) as { message?: string } | undefined;
throw new GiteaApiError(response.status, `GITEA_HTTP_${response.status}`, payload?.message ?? `Gitea request failed with HTTP ${response.status}`);
}
if (response.status === 204) return undefined as T;
if (options.allowEmptyResponse) {
const body = await response.text();
return body.trim() ? JSON.parse(body) as T : undefined as T;
}
return await response.json() as T;
}
getCurrentUser() { return this.request<{ id: number; login: string; full_name?: string }>("/user"); }
createRepository(input: CreateGiteaRepositoryInput) {
return this.request<GiteaRepository>("/user/repos", {
method: "POST",
body: {
name: input.name,
description: input.description,
private: input.private,
auto_init: input.autoInit ?? true,
default_branch: input.defaultBranch ?? "main",
},
});
}
async listRepositories() {
const repositories: GiteaRepository[] = [];
const seenIds = new Set<number>();
for (let page = 1; page <= 10; page += 1) {
const batch = await this.request<GiteaRepository[]>("/user/repos", { query: { limit: 100, page } });
if (!Array.isArray(batch)) throw new GiteaApiError(502, "GITEA_INVENTORY_INVALID", "Gitea returned an invalid repository inventory page.");
// Gitea may clamp limit=100 to a smaller server-configured page size.
// Only an empty page proves exhaustion without trusting arbitrary Link URLs.
if (!batch.length) return repositories;
for (const repository of batch) {
if (!Number.isSafeInteger(repository.id) || repository.id <= 0 || seenIds.has(repository.id)) {
throw new GiteaApiError(502, "GITEA_INVENTORY_NONADVANCING", "Gitea repository pagination returned invalid or repeated identities; inventory is incomplete.");
}
seenIds.add(repository.id);
}
repositories.push(...batch);
}
throw new GiteaApiError(502, "GITEA_INVENTORY_LIMIT_EXCEEDED", "Gitea repository inventory exceeded the bounded pagination window; no partial inventory was returned.");
}
getRepository(owner: string, repository: string) { return this.request<GiteaRepository>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`); }
listBranches(owner: string, repository: string) { return this.request<GiteaBranch[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches`, { query: { limit: 100 } }); }
getBranch(owner: string, repository: string, branch: string) { return this.request<GiteaBranch>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`); }
compareCommits(owner: string, repository: string, base: string, head: string) {
return this.request<{ total_commits: number }>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/compare/${encodeURIComponent(base)}..${encodeURIComponent(head)}`);
}
getBranchProtection(owner: string, repository: string, branch: string) {
return this.request<GiteaBranchProtection>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branch_protections/${encodeURIComponent(branch)}`);
}
getCombinedCommitStatus(owner: string, repository: string, sha: string) {
return this.request<GiteaCombinedStatus>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/commits/${encodeURIComponent(sha)}/status`);
}
async waitForRequiredPullRequestChecks(owner: string, repository: string, branch: string, headSha: string, options: { timeoutMs?: number; pollIntervalMs?: number } = {}) {
let protection: GiteaBranchProtection;
try { protection = await this.getBranchProtection(owner, repository, branch); }
catch (error) {
if (error instanceof GiteaApiError && error.status === 404) return { requiredContexts: [], state: "not-required" as const };
throw error;
}
const requiredContexts = protection.enable_status_check ? (protection.status_check_contexts ?? []).filter(Boolean) : [];
if (!requiredContexts.length) return { requiredContexts, state: "not-required" as const };
const timeoutMs = options.timeoutMs ?? 8_000;
const pollIntervalMs = options.pollIntervalMs ?? 2_000;
const deadline = Date.now() + timeoutMs;
while (true) {
const combined = await this.getCombinedCommitStatus(owner, repository, headSha);
const latestByContext = new Map<string, string>();
for (const status of combined.statuses ?? []) if (status.context && !latestByContext.has(status.context)) latestByContext.set(status.context, status.status ?? "");
const states = requiredContexts.map((context) => latestByContext.get(context));
if (states.every((state) => state === "success")) return { requiredContexts, state: "success" as const };
if (states.some((state) => state === "failure" || state === "error")) throw new GiteaApiError(409, "GITEA_REQUIRED_CHECKS_FAILED", "A required pull-request check failed for the exact head commit");
if (Date.now() >= deadline) throw new GiteaApiError(409, "GITEA_REQUIRED_CHECKS_PENDING", "Required pull-request checks did not finish before the bounded merge deadline");
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
}
getContents(owner: string, repository: string, path: string, ref?: string) {
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
const suffix = encodedPath ? `/${encodedPath}` : "";
return this.request<GiteaContent | GiteaContent[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/contents${suffix}`, { query: { ref } });
}
searchCode(owner: string, repository: string, query: string, ref?: string) {
return this.request<{ total_count?: number; data?: GiteaSearchResult[] } | GiteaSearchResult[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/search/code`, { query: { q: query, ref, limit: 50 } });
}
createBranch(owner: string, repository: string, branch: string, sourceBranch: string) {
return this.request<GiteaBranch>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches`, {
method: "POST", body: { new_branch_name: branch, old_branch_name: sourceBranch },
});
}
createFile(owner: string, repository: string, path: string, branch: string, content: string, message: string) {
return this.request<unknown>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/contents/${path.split("/").map(encodeURIComponent).join("/")}`, {
method: "POST", body: { branch, content: Buffer.from(content, "utf8").toString("base64"), message },
});
}
updateFile(owner: string, repository: string, path: string, branch: string, sha: string, content: string, message: string) {
return this.request<unknown>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/contents/${path.split("/").map(encodeURIComponent).join("/")}`, {
method: "PUT", body: { branch, sha, content: Buffer.from(content, "utf8").toString("base64"), message },
});
}
deleteFile(owner: string, repository: string, path: string, branch: string, sha: string, message: string) {
return this.request<unknown>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/contents/${path.split("/").map(encodeURIComponent).join("/")}`, {
method: "DELETE", body: { branch, sha, message },
});
}
listPullRequests(owner: string, repository: string, state: "open" | "closed" | "all" = "open") {
return this.request<GiteaPullRequest[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls`, { query: { state, limit: 50 } });
}
getPullRequest(owner: string, repository: string, number: number) {
return this.request<GiteaPullRequest>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls/${number}`);
}
createPullRequest(owner: string, repository: string, input: { title: string; body: string; head: string; base: string }) {
return this.request<GiteaPullRequest>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls`, { method: "POST", body: input });
}
private async getMergeStrategyKey(): Promise<"Do" | "do"> {
if (this.mergeStrategyKey) return this.mergeStrategyKey;
const { version } = await this.request<{ version?: string }>("/version");
const match = /^1\.(25|26|27)(?:\.\d+)?(?:[-+].*)?$/.exec(version ?? "");
if (!match) throw new GiteaApiError(500, "GITEA_VERSION_UNSUPPORTED", "Gitea merge compatibility requires an identified 1.25, 1.26, or 1.27 server");
this.mergeStrategyKey = match[1] === "25" ? "Do" : "do";
return this.mergeStrategyKey;
}
async mergePullRequest(owner: string, repository: string, number: number, input: { method: "merge" | "rebase" | "rebase-merge" | "squash" | "fast-forward-only"; headCommitId: string }) {
const strategyKey = await this.getMergeStrategyKey();
return this.request<unknown>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls/${number}/merge`, {
method: "POST",
allowEmptyResponse: true,
body: {
[strategyKey]: input.method,
head_commit_id: input.headCommitId,
force_merge: false,
merge_when_checks_succeed: false,
delete_branch_after_merge: false,
},
});
}
deleteBranch(owner: string, repository: string, branch: string) {
return this.request<void>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`, { method: "DELETE", allowEmptyResponse: true });
}
listIssues(owner: string, repository: string, state: "open" | "closed" | "all" = "open", page = 1, limit = 50) {
return this.request<GiteaIssue[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues`, { query: { state, page, limit, type: "issues" } });
}
getIssue(owner: string, repository: string, issueNumber: number) {
return this.request<GiteaIssue>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}`);
}
createIssue(owner: string, repository: string, input: { title: string; body: string; labels?: number[] }) {
return this.request<GiteaIssue>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues`, { method: "POST", body: input });
}
editIssue(owner: string, repository: string, issueNumber: number, input: { title?: string; body?: string; state?: "open" | "closed" }) {
return this.request<GiteaIssue>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}`, { method: "PATCH", body: input });
}
listIssueComments(owner: string, repository: string, issueNumber: number) {
return this.request<GiteaIssueComment[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}/comments`, { query: { limit: 50 } });
}
createIssueComment(owner: string, repository: string, issueNumber: number, body: string) {
return this.request<GiteaIssueComment>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}/comments`, { method: "POST", body: { body } });
}
listLabels(owner: string, repository: string) {
return this.request<GiteaLabel[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/labels`, { query: { limit: 100 } });
}
createLabel(owner: string, repository: string, input: { name: string; color: string; description?: string }) {
return this.request<GiteaLabel>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/labels`, { method: "POST", body: input });
}
replaceIssueLabels(owner: string, repository: string, issueNumber: number, labels: number[]) {
return this.request<GiteaLabel[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}/labels`, { method: "PUT", body: { labels } });
}
listActionRuns(owner: string, repository: string, input: { branch?: string; headSha?: string; limit?: number } = {}) {
return this.request<GiteaActionRunList>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/actions/runs`, {
// Filtering by branch upstream hides native PR runs with head_branch="".
// Exact-head callers perform branch/PR reconciliation in policy.ts.
query: { branch: input.headSha ? undefined : input.branch, head_sha: input.headSha, limit: input.limit ?? 30 },
});
}
listUserActionRunners() {
return this.request<GiteaActionRunnerList>("/user/actions/runners");
}
listUserActionJobs(input: { limit?: number; page?: number; status?: "pending" | "waiting" | "queued" | "in_progress" | "failure" | "success" | "skipped" } = {}) {
return this.request<GiteaActionJobList>("/user/actions/jobs", {
query: { status: input.status, limit: input.limit ?? 50, page: input.page ?? 1, sort: "id", order: "desc" },
});
}
listRepositoryWebhooks(owner: string, repository: string) {
return this.request<GiteaWebhook[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/hooks`, { query: { limit: 100 } });
}
createRepositoryWebhook(owner: string, repository: string, input: { url: string; secret: string; events?: string[] }) {
return this.request<GiteaWebhook>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/hooks`, {
method: "POST", body: { type: "gitea", active: true, events: input.events ?? ["pull_request", "issues", "issue_comment"], config: { url: input.url, content_type: "json", secret: input.secret } },
});
}
editRepositoryWebhook(owner: string, repository: string, hookId: number, input: { url: string; secret: string; events: string[] }) {
return this.request<GiteaWebhook>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/hooks/${hookId}`, {
method: "PATCH", body: { active: true, events: input.events, config: { url: input.url, content_type: "json", secret: input.secret } },
});
}
deleteRepositoryWebhook(owner: string, repository: string, hookId: number) {
return this.request<void>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/hooks/${hookId}`, { method: "DELETE" });
}
}
+92
View File
@@ -0,0 +1,92 @@
import type { GiteaActionRun, GiteaPullRequest } from "./types.js";
export const findReusableDeliveryPullRequest = (pullRequests: GiteaPullRequest[], branch: string, base: string, headSha: string) => {
const matches = pullRequests.filter((pr) => pr.head.ref === branch && pr.head.sha === headSha && pr.base.ref === base &&
(pr.state === "open" || pr.merged));
if (matches.length > 1) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_AMBIGUOUS");
const match = matches[0];
if (match?.merged && !/^[0-9a-f]{40,64}$/i.test(match.merge_commit_sha ?? "")) throw new Error("FEATURE_DELIVERY_MERGE_EVIDENCE_REQUIRED");
return match;
};
const forbiddenSegments = new Set([".git", ".ssh"]);
const sensitiveNames = /(^|\/)(\.env($|\.(?!example$|sample$))|id_(rsa|dsa|ecdsa|ed25519)|.*\.(pem|p12|pfx|key)|credentials(?:\.[^/]*)?|secrets?(?:\.[^/]*)?)$/i;
export const MAX_GITEA_FILE_BYTES = 512 * 1024;
export const normalizeRepositoryPath = (input: string): string => {
const path = input.trim().replace(/\\/g, "/").replace(/^\/+/, "");
const segments = path.split("/").filter(Boolean);
if (!path || segments.some((segment) => segment === ".." || segment === "." || forbiddenSegments.has(segment.toLowerCase()))) {
throw new Error("REPOSITORY_PATH_FORBIDDEN");
}
return segments.join("/");
};
export const assertReadablePath = (input: string): string => {
const path = normalizeRepositoryPath(input);
if (sensitiveNames.test(path)) throw new Error("SENSITIVE_PATH_FORBIDDEN");
return path;
};
export const assertWritablePath = (input: string): string => assertReadablePath(input);
export const assertExpectedFileSha = (expectedSha: string | undefined, actualSha: string | undefined): void => {
if (expectedSha !== actualSha) throw new Error("GITEA_FILE_CONFLICT");
};
const assertNonDefaultBranch = (branch: string, defaultBranch: string, errorCode: string): string => {
const normalized = branch.trim();
if (!/^[a-z0-9][a-z0-9._/-]{0,299}$/i.test(normalized) || normalized.includes("..") || normalized.includes("//") || normalized.endsWith("/") || normalized.endsWith(".lock")) {
throw new Error(errorCode);
}
if (normalized === defaultBranch) throw new Error("DEFAULT_BRANCH_WRITE_FORBIDDEN");
return normalized;
};
export const assertChatGptBranch = (branch: string, defaultBranch: string): string =>
assertNonDefaultBranch(branch, defaultBranch, "MCP_BRANCH_FORBIDDEN");
export const assertProjectBrainBranch = (branch: string, defaultBranch: string): string =>
assertNonDefaultBranch(branch, defaultBranch, "PROJECTBRAIN_BRANCH_FORBIDDEN");
export const assertFileSize = (content: string): void => {
if (Buffer.byteLength(content, "utf8") > MAX_GITEA_FILE_BYTES) throw new Error("MCP_FILE_TOO_LARGE");
};
const actionWorkflowKey = (run: GiteaActionRun): string =>
`${run.event}:${run.path.split("@")[0]?.trim() || run.display_title.trim() || `workflow-${run.id}`}`;
// Native Gitea PR runs may omit head_branch. Keep the immutable SHA as the
// primary identity, and reject contradictory PR metadata when it is supplied.
export const exactHeadActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun[] =>
runs.filter((run) => {
if (run.head_sha !== headSha) return false;
if (run.head_branch === branch) return true;
if (run.head_branch?.trim() || run.event !== "pull_request") return false;
const references = run.pull_requests ?? [];
return references.length === 0 || references.some((pr) =>
(!pr.head?.ref || pr.head.ref === branch) && (!pr.head?.sha || pr.head.sha === headSha));
});
export const assertSuccessfulActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun[] => {
const exactRuns = exactHeadActionRuns(runs, branch, headSha);
if (exactRuns.length === 0) throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
// Gitea retains failed attempts when the same workflow is rerun for an
// unchanged revision. Authorization therefore follows the newest run of each
// workflow, while callers still receive the complete exact-SHA evidence set.
const latestByWorkflow = new Map<string, GiteaActionRun>();
for (const run of exactRuns) {
const key = actionWorkflowKey(run);
const current = latestByWorkflow.get(key);
if (!current || run.run_number > current.run_number || (run.run_number === current.run_number && run.id > current.id)) {
latestByWorkflow.set(key, run);
}
}
if ([...latestByWorkflow.values()].some((run) => run.status !== "completed" || run.conclusion !== "success")) {
throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL");
}
return exactRuns;
};
@@ -0,0 +1,97 @@
import type { AppEnv } from "../../config/env.js";
import { getGiteaClient, listLinkedRepositories, type LinkedRepository } from "./service.js";
export type PortfolioRepository = {
id: string;
owner: string;
repository: string;
fullName: string;
description?: string;
private: boolean;
archived: boolean;
defaultBranch: string;
htmlUrl: string;
updatedAt: string;
canPush: boolean;
linked: boolean;
link?: Pick<
LinkedRepository,
| "id"
| "projectId"
| "projectSlug"
| "projectName"
| "accessMode"
| "role"
| "isPrimary"
| "syncStatus"
>;
};
/**
* Project the complete repository inventory visible to ProjectBrain's scoped
* Gitea identity without creating or moving any ProjectBrain ownership links.
*
* GiteaRepositoryLink intentionally remains the write/delivery boundary. This
* projection exists only for portfolio discovery, search and read-only fetch so
* an aggregate workspace can see repositories that have not been linked to an
* individual ProjectBrain project yet.
*/
export const listPortfolioRepositories = async (env: AppEnv): Promise<PortfolioRepository[]> => {
const [repositories, links] = await Promise.all([
getGiteaClient(env).listRepositories(),
listLinkedRepositories(),
]);
const linksByRepository = new Map(links.map((link) => [link.fullName.toLowerCase(), link]));
return repositories
.map((repository) => {
const link = linksByRepository.get(repository.full_name.toLowerCase());
return {
id: `gitea-repository:${repository.id}`,
owner: repository.owner.login,
repository: repository.name,
fullName: repository.full_name,
description: repository.description,
private: repository.private,
archived: repository.archived,
defaultBranch: repository.default_branch,
htmlUrl: repository.html_url,
updatedAt: repository.updated_at,
canPush: Boolean(repository.permissions?.push || repository.permissions?.admin),
linked: Boolean(link),
...(link
? {
link: {
id: link.id,
projectId: link.projectId,
projectSlug: link.projectSlug,
projectName: link.projectName,
accessMode: link.accessMode,
role: link.role,
isPrimary: link.isPrimary,
syncStatus: link.syncStatus,
},
}
: {}),
} satisfies PortfolioRepository;
})
.sort((left, right) => left.fullName.localeCompare(right.fullName));
};
export const portfolioFetchId = (
repository: Pick<PortfolioRepository, "owner" | "repository">,
ref: string,
path: string,
) =>
`gitea-repository://${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repository)}/${encodeURIComponent(ref)}/${encodeURIComponent(path)}`;
export const parsePortfolioFetchId = (id: string) => {
const match = id.match(/^gitea-repository:\/\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
if (!match) throw new Error("MCP_FETCH_ID_INVALID");
return {
owner: decodeURIComponent(match[1]),
repository: decodeURIComponent(match[2]),
ref: decodeURIComponent(match[3]),
path: decodeURIComponent(match[4]),
};
};
+419
View File
@@ -0,0 +1,419 @@
import { randomUUID } from "node:crypto";
import { loadEnv, type AppEnv } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { GiteaApiError, GiteaClient } from "./client.js";
export const PROJECTBRAIN_GITEA_WEBHOOK_EVENTS = ["push", "pull_request", "issues", "issue_comment"] as const;
export type LinkedRepository = {
id: string;
projectId: string;
projectSlug: string;
projectName: string;
owner: string;
repository: string;
fullName: string;
defaultBranch: string;
accessMode: "read_only" | "read_write";
role: "primary" | "frontend" | "backend" | "infra" | "docs" | "shared" | "other";
isPrimary: boolean;
syncStatus: "unverified" | "ready" | "degraded" | "unavailable";
capabilities?: Record<string, unknown>;
lastVerifiedAt?: string;
lastSyncAt?: string;
htmlUrl?: string;
};
export class GiteaRepositoryOwnershipConflictError extends Error {
readonly code = "GITEA_REPOSITORY_ALREADY_LINKED";
constructor(
readonly repositoryFullName: string,
readonly sourceProjectId: string,
readonly sourceProjectName: string,
) {
super("GITEA_REPOSITORY_ALREADY_LINKED");
this.name = "GiteaRepositoryOwnershipConflictError";
}
}
export class GiteaRepositoryTransferBlockedError extends Error {
readonly code = "GITEA_REPOSITORY_TRANSFER_BLOCKED";
constructor(
readonly repositoryFullName: string,
readonly deliveryRunCount: number,
) {
super("GITEA_REPOSITORY_TRANSFER_BLOCKED");
this.name = "GiteaRepositoryTransferBlockedError";
}
}
export const assertExplicitRepositoryTransfer = (
existing: { projectId: string; project: { name: string } } | null | undefined,
targetProjectId: string,
repositoryFullName: string,
transfer: boolean,
) => {
if (existing && existing.projectId !== targetProjectId && !transfer) {
throw new GiteaRepositoryOwnershipConflictError(repositoryFullName, existing.projectId, existing.project.name);
}
};
export const assertRepositoryTransferHasNoDeliveryHistory = (
repositoryFullName: string,
deliveryRunCount: number,
) => {
if (deliveryRunCount > 0) {
throw new GiteaRepositoryTransferBlockedError(repositoryFullName, deliveryRunCount);
}
};
type UnlinkContext = { env?: AppEnv; actor?: string };
const serviceErrorCode = (error: unknown) =>
error instanceof GiteaApiError ? error.code : error instanceof Error ? error.message : "GITEA_INTEGRATION_FAILED";
const webhookUrlForEnv = (env: AppEnv) =>
env.PROJECTBRAIN_PUBLIC_URL ? `${env.PROJECTBRAIN_PUBLIC_URL.replace(/\/$/, "")}/api/integrations/gitea/webhook` : undefined;
const jsonObject = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
export const getGiteaClient = (env: AppEnv): GiteaClient => {
if (!env.PROJECTBRAIN_GITEA_URL || !env.PROJECTBRAIN_GITEA_TOKEN) throw new Error("GITEA_NOT_CONFIGURED");
return new GiteaClient(env.PROJECTBRAIN_GITEA_URL, env.PROJECTBRAIN_GITEA_TOKEN);
};
export const getGiteaIssueClient = (env: AppEnv): GiteaClient => {
if (!env.PROJECTBRAIN_GITEA_URL || !(env.PROJECTBRAIN_GITEA_WRITE_TOKEN || env.PROJECTBRAIN_GITEA_TOKEN)) throw new Error("GITEA_NOT_CONFIGURED");
return new GiteaClient(env.PROJECTBRAIN_GITEA_URL, env.PROJECTBRAIN_GITEA_WRITE_TOKEN || env.PROJECTBRAIN_GITEA_TOKEN!);
};
const serializeLink = (link: {
id: string; projectId: string; owner: string; repository: string; defaultBranch: string;
accessMode: "read_only" | "read_write";
role: LinkedRepository["role"]; isPrimary: boolean; syncStatus: LinkedRepository["syncStatus"];
capabilitiesJson: unknown; lastVerifiedAt: Date | null; lastSyncAt: Date | null;
project: { slug: string; name: string };
}, htmlUrl?: string): LinkedRepository => ({
id: link.id,
projectId: link.projectId,
projectSlug: link.project.slug,
projectName: link.project.name,
owner: link.owner,
repository: link.repository,
fullName: `${link.owner}/${link.repository}`,
defaultBranch: link.defaultBranch,
accessMode: link.accessMode,
role: link.role,
isPrimary: link.isPrimary,
syncStatus: link.syncStatus,
...(link.capabilitiesJson && typeof link.capabilitiesJson === "object" && !Array.isArray(link.capabilitiesJson)
? { capabilities: link.capabilitiesJson as Record<string, unknown> }
: {}),
...(link.lastVerifiedAt ? { lastVerifiedAt: link.lastVerifiedAt.toISOString() } : {}),
...(link.lastSyncAt ? { lastSyncAt: link.lastSyncAt.toISOString() } : {}),
...(htmlUrl ? { htmlUrl } : {}),
});
export const listLinkedRepositories = async (): Promise<LinkedRepository[]> => {
const links = await prisma.giteaRepositoryLink.findMany({ include: { project: { select: { slug: true, name: true } } }, orderBy: { updatedAt: "desc" } });
return links.map((link) => serializeLink(link));
};
export const getLinkedRepository = async (projectId: string): Promise<LinkedRepository | undefined> => {
const link = await prisma.giteaRepositoryLink.findFirst({
where: { project: { OR: [{ id: projectId }, { slug: projectId }] } },
orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }],
include: { project: { select: { slug: true, name: true } } },
});
return link ? serializeLink(link) : undefined;
};
export const listProjectRepositories = async (projectId: string): Promise<LinkedRepository[]> => {
const links = await prisma.giteaRepositoryLink.findMany({
where: { project: { OR: [{ id: projectId }, { slug: projectId }] } },
include: { project: { select: { slug: true, name: true } } },
orderBy: [{ isPrimary: "desc" }, { role: "asc" }, { createdAt: "asc" }],
});
return links.map((link) => serializeLink(link));
};
export const linkRepository = async (
env: AppEnv,
projectId: string,
fullName: string,
accessMode: "read_only" | "read_write",
linkedBy: string,
options: { role?: LinkedRepository["role"]; isPrimary?: boolean; transfer?: boolean } = {},
): Promise<LinkedRepository> => {
const [owner, repository, ...rest] = fullName.split("/");
if (!owner || !repository || rest.length) throw new Error("GITEA_REPOSITORY_INVALID");
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectId }, { slug: projectId }] }, select: { id: true } });
if (!project) throw new Error("PROJECT_NOT_FOUND");
const remote = await getGiteaClient(env).getRepository(owner, repository);
const writeRemote = accessMode === "read_write" && env.PROJECTBRAIN_GITEA_WRITE_TOKEN && !remote.permissions?.push && !remote.permissions?.admin
? await getGiteaIssueClient(env).getRepository(owner, repository)
: remote;
if (accessMode === "read_write" && (!writeRemote.permissions?.push && !writeRemote.permissions?.admin)) throw new Error("GITEA_WRITE_ACCESS_REQUIRED");
const [existingCount, existingLink] = await Promise.all([
prisma.giteaRepositoryLink.count({ where: { projectId: project.id } }),
prisma.giteaRepositoryLink.findUnique({
where: { owner_repository: { owner, repository } },
select: { id: true, projectId: true, isPrimary: true, role: true, capabilitiesJson: true, project: { select: { name: true } } },
}),
]);
assertExplicitRepositoryTransfer(existingLink, project.id, fullName, options.transfer === true);
const isTransfer = Boolean(existingLink && existingLink.projectId !== project.id);
if (existingLink && isTransfer && options.transfer === true) {
const deliveryRunCount = await prisma.deliveryRun.count({ where: { repositoryLinkId: existingLink.id } });
assertRepositoryTransferHasNoDeliveryHistory(fullName, deliveryRunCount);
}
const isPrimary = options.isPrimary ?? (existingLink?.projectId === project.id ? existingLink.isPrimary : existingCount === 0);
const role = options.role ?? (isPrimary ? "primary" : existingLink?.projectId === project.id ? existingLink.role : "other");
const previousCapabilities = jsonObject(existingLink?.capabilitiesJson);
const desiredWebhookEvents = [...PROJECTBRAIN_GITEA_WEBHOOK_EVENTS];
let webhookConfigured = previousCapabilities.webhookConfigured === true;
let webhookId = typeof previousCapabilities.webhookId === "number" ? previousCapabilities.webhookId : undefined;
let webhookUrl = typeof previousCapabilities.webhookUrl === "string" ? previousCapabilities.webhookUrl : undefined;
let webhookEvents = Array.isArray(previousCapabilities.webhookEvents)
? previousCapabilities.webhookEvents.filter((event): event is string => typeof event === "string")
: [];
let externalOperationId: string | undefined;
let externalOperationAction: string | undefined;
let remoteMutationReturned = false;
const externalStarted = Date.now();
try {
if (env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET && env.PROJECTBRAIN_PUBLIC_URL) {
const client = getGiteaIssueClient(env);
webhookUrl = webhookUrlForEnv(env)!;
const hooks = await client.listRepositoryWebhooks(owner, repository);
const existingHook = hooks.find((hook) => hook.config.url === webhookUrl);
const needsMutation = !existingHook || !existingHook.active || desiredWebhookEvents.some((event) => !existingHook.events?.includes(event));
if (needsMutation) {
externalOperationAction = existingHook ? "gitea.webhook.update" : "gitea.webhook.create";
const prepared = await prepareExternalOperation({
projectId: project.id,
actor: linkedBy,
action: externalOperationAction,
target: { repository: fullName, webhookId: existingHook?.id, webhookUrl, events: desiredWebhookEvents },
});
externalOperationId = prepared.operationId;
await markExternalOperationExecuting(externalOperationId);
const hook = existingHook
? await client.editRepositoryWebhook(owner, repository, existingHook.id, { url: webhookUrl, secret: env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET, events: desiredWebhookEvents })
: await client.createRepositoryWebhook(owner, repository, { url: webhookUrl, secret: env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET, events: desiredWebhookEvents });
remoteMutationReturned = true;
webhookId = hook.id;
} else {
webhookId = existingHook.id;
}
webhookConfigured = true;
webhookEvents = desiredWebhookEvents;
}
const capabilitiesJson = {
...previousCapabilities,
canPush: Boolean(writeRemote.permissions?.push || writeRemote.permissions?.admin),
actions: previousCapabilities.actions ?? "unknown",
webhookConfigured,
webhookEvents,
...(webhookId ? { webhookId } : {}),
...(webhookUrl ? { webhookUrl } : {}),
};
const link = await prisma.$transaction(async (tx) => {
if (isPrimary) await tx.giteaRepositoryLink.updateMany({ where: { projectId: project.id }, data: { isPrimary: false } });
const updatedLink = await tx.giteaRepositoryLink.upsert({
where: { owner_repository: { owner, repository } },
create: {
projectId: project.id, owner, repository, defaultBranch: remote.default_branch, accessMode,
role, isPrimary, syncStatus: "ready",
capabilitiesJson: capabilitiesJson as never,
linkedBy, lastVerifiedAt: new Date(), lastSyncAt: new Date(),
},
update: {
projectId: project.id, defaultBranch: remote.default_branch, accessMode,
role, isPrimary, syncStatus: "ready",
capabilitiesJson: capabilitiesJson as never,
linkedBy, lastVerifiedAt: new Date(), lastSyncAt: new Date(),
},
include: { project: { select: { slug: true, name: true } } },
});
if (existingLink?.projectId !== project.id && existingLink?.isPrimary) {
const replacement = await tx.giteaRepositoryLink.findFirst({ where: { projectId: existingLink.projectId }, orderBy: { createdAt: "asc" } });
if (replacement) {
await tx.giteaRepositoryLink.update({
where: { id: replacement.id },
data: { isPrimary: true, role: replacement.role === "other" ? "primary" : replacement.role },
});
}
}
if (existingLink && isTransfer) {
await tx.repositoryFinding.updateMany({ where: { repositoryLinkId: existingLink.id }, data: { projectId: project.id } });
await tx.portfolioRepository.updateMany({
where: { owner, repository },
data: { projectId: project.id, status: "onboarded", exclusionReason: null, excludedAt: null, onboardedAt: new Date() },
});
}
return updatedLink;
});
if (externalOperationId) {
await finishExternalOperation(externalOperationId, { status: "succeeded", result: { repository: fullName, webhookId, webhookUrl } });
await recordMcpAudit({
operationId: externalOperationId, projectId: project.id, actor: linkedBy,
toolName: externalOperationAction!, repository: fullName, outcome: "succeeded", durationMs: Date.now() - externalStarted,
metadata: { webhookId, webhookUrl },
}).catch(() => undefined);
}
return serializeLink(link, remote.html_url);
} catch (error) {
const code = serviceErrorCode(error);
if (externalOperationId) {
await finishExternalOperation(externalOperationId, { status: remoteMutationReturned ? "unknown" : "failed", errorCode: code }).catch(() => undefined);
await recordMcpAudit({
operationId: externalOperationId, projectId: project.id, actor: linkedBy,
toolName: externalOperationAction ?? "gitea.webhook.align", repository: fullName,
outcome: "failed", errorCode: code, durationMs: Date.now() - externalStarted,
}).catch(() => undefined);
}
throw error;
}
};
export const unlinkRepository = async (projectId: string, context: UnlinkContext = {}): Promise<boolean> => {
const env = context.env ?? loadEnv();
const actor = context.actor ?? "owner";
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectId }, { slug: projectId }] }, select: { id: true } });
if (!project) return false;
const primary = await prisma.giteaRepositoryLink.findFirst({ where: { projectId: project.id }, orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] });
if (!primary) return false;
return unlinkRepositoryById(project.id, primary.id, { env, actor });
};
export const unlinkRepositoryById = async (projectId: string, repositoryLinkId: string, context: UnlinkContext = {}): Promise<boolean> => {
const env = context.env ?? loadEnv();
const actor = context.actor ?? "owner";
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectId }, { slug: projectId }] }, select: { id: true } });
if (!project) return false;
const link = await prisma.giteaRepositoryLink.findFirst({ where: { id: repositoryLinkId, projectId: project.id } });
if (!link) return false;
const fullName = `${link.owner}/${link.repository}`;
const capabilities = jsonObject(link.capabilitiesJson);
const recordedWebhookId = typeof capabilities.webhookId === "number" ? capabilities.webhookId : undefined;
const recordedWebhookUrl = typeof capabilities.webhookUrl === "string" ? capabilities.webhookUrl : undefined;
const expectedWebhookUrl = recordedWebhookUrl ?? webhookUrlForEnv(env);
let webhookId = recordedWebhookId;
let externalOperationId: string | undefined;
let remoteMutationReturned = false;
const started = Date.now();
try {
if (!webhookId && expectedWebhookUrl) {
const hooks = await getGiteaIssueClient(env).listRepositoryWebhooks(link.owner, link.repository);
webhookId = hooks.find((hook) => hook.config.url === expectedWebhookUrl)?.id;
}
if (!webhookId && capabilities.webhookConfigured === true && !expectedWebhookUrl) {
throw new Error("GITEA_WEBHOOK_CLEANUP_UNAVAILABLE");
}
if (webhookId) {
const prepared = await prepareExternalOperation({
projectId: project.id,
actor,
action: "gitea.webhook.delete",
target: { repository: fullName, webhookId, webhookUrl: expectedWebhookUrl },
});
externalOperationId = prepared.operationId;
await markExternalOperationExecuting(externalOperationId);
try {
await getGiteaIssueClient(env).deleteRepositoryWebhook(link.owner, link.repository, webhookId);
remoteMutationReturned = true;
} catch (error) {
if (!(error instanceof GiteaApiError && error.status === 404)) throw error;
remoteMutationReturned = true;
}
}
await prisma.$transaction(async (tx) => {
await tx.giteaRepositoryLink.delete({ where: { id: link.id } });
if (link.isPrimary) {
const replacement = await tx.giteaRepositoryLink.findFirst({ where: { projectId: project.id }, orderBy: { createdAt: "asc" } });
if (replacement) await tx.giteaRepositoryLink.update({ where: { id: replacement.id }, data: { isPrimary: true, role: replacement.role === "other" ? "primary" : replacement.role } });
}
});
if (externalOperationId) {
await finishExternalOperation(externalOperationId, { status: "succeeded", result: { repository: fullName, webhookId, webhookUrl: expectedWebhookUrl, linkRemoved: true } });
await recordMcpAudit({
operationId: externalOperationId, projectId: project.id, actor, toolName: "gitea.webhook.delete",
repository: fullName, outcome: "succeeded", durationMs: Date.now() - started, metadata: { webhookId, linkRemoved: true },
}).catch(() => undefined);
} else {
await recordMcpAudit({ projectId: project.id, actor, toolName: "gitea.repository.unlink", repository: fullName, outcome: "succeeded", durationMs: Date.now() - started }).catch(() => undefined);
}
return true;
} catch (error) {
const code = serviceErrorCode(error);
if (externalOperationId) {
await finishExternalOperation(externalOperationId, { status: remoteMutationReturned ? "unknown" : "failed", errorCode: code }).catch(() => undefined);
await recordMcpAudit({
operationId: externalOperationId, projectId: project.id, actor, toolName: "gitea.webhook.delete",
repository: fullName, outcome: "failed", errorCode: code, durationMs: Date.now() - started,
}).catch(() => undefined);
} else {
await recordMcpAudit({ projectId: project.id, actor, toolName: "gitea.repository.unlink", repository: fullName, outcome: "failed", errorCode: code, durationMs: Date.now() - started }).catch(() => undefined);
}
throw error;
}
};
export const recordMcpAudit = async (input: {
operationId?: string;
projectId?: string; actor: string; toolName: string; repository?: string; branch?: string;
outcome: "succeeded" | "failed" | "denied"; errorCode?: string; durationMs: number; metadata?: Record<string, unknown>;
}) => prisma.mcpAuditEvent.create({ data: {
operationId: input.operationId ?? randomUUID(), projectId: input.projectId, actor: input.actor, toolName: input.toolName,
repository: input.repository, branch: input.branch, outcome: input.outcome, errorCode: input.errorCode,
durationMs: input.durationMs, metadataJson: input.metadata as never,
} });
export const prepareExternalOperation = async (input: {
projectId: string; actor: string; action: string; target: Record<string, unknown>;
}) => {
const operationId = randomUUID();
const operation = await prisma.$transaction(async (tx) => {
const row = await tx.externalOperation.create({ data: {
operationId, projectId: input.projectId, actor: input.actor, action: input.action,
targetJson: input.target as never,
} });
await tx.outboxEvent.create({ data: {
eventId: randomUUID(), projectId: input.projectId, aggregateType: "external_operation",
aggregateId: row.id, eventType: "external_operation.prepared",
payloadJson: { operationId, actor: input.actor, action: input.action, target: input.target } as never,
} });
return row;
});
return operation;
};
export const markExternalOperationExecuting = async (operationId: string) =>
prisma.externalOperation.update({ where: { operationId }, data: { status: "executing", startedAt: new Date() } });
export const finishExternalOperation = async (operationId: string, input: {
status: "succeeded" | "failed" | "unknown"; result?: Record<string, unknown>; errorCode?: string;
}) => prisma.$transaction(async (tx) => {
const row = await tx.externalOperation.update({ where: { operationId }, data: {
status: input.status, resultJson: input.result as never, errorCode: input.errorCode, completedAt: new Date(),
} });
await tx.outboxEvent.create({ data: {
eventId: randomUUID(), projectId: row.projectId, aggregateType: "external_operation",
aggregateId: row.id, eventType: `external_operation.${input.status}`,
payloadJson: { operationId, errorCode: input.errorCode } as never,
} });
return row;
});
+140
View File
@@ -0,0 +1,140 @@
export type GiteaRepository = {
id: number;
name: string;
full_name: string;
description?: string;
private: boolean;
archived: boolean;
default_branch: string;
html_url: string;
updated_at: string;
permissions?: { admin?: boolean; push?: boolean; pull?: boolean };
owner: { login: string };
};
export type CreateGiteaRepositoryInput = {
name: string;
description?: string;
private: boolean;
autoInit?: boolean;
defaultBranch?: string;
};
export type GiteaContent = {
type: "file" | "dir" | "symlink" | "submodule";
name: string;
path: string;
sha: string;
size: number;
encoding?: string;
content?: string;
html_url?: string;
download_url?: string;
};
export type GiteaPullRequest = {
id: number;
number: number;
title: string;
state: string;
html_url: string;
merged: boolean;
mergeable?: boolean;
draft?: boolean;
merged_at?: string;
merge_commit_sha?: string;
created_at: string;
updated_at: string;
head: { ref: string; sha: string };
base: { ref: string; sha: string };
};
export type GiteaBranch = {
name: string;
commit: { id: string; message?: string; timestamp?: string };
protected: boolean;
};
export type GiteaSearchResult = {
name: string;
path: string;
sha: string;
html_url?: string;
repository?: { full_name?: string };
};
export type GiteaActionRun = {
id: number;
run_number: number;
display_title: string;
path: string;
event: string;
head_sha: string;
head_branch?: string;
status: "pending" | "queued" | "in_progress" | "completed" | string;
conclusion?: "success" | "failure" | "cancelled" | "skipped" | string;
html_url: string;
started_at?: string;
completed_at?: string;
pull_requests?: Array<{ number?: number; head?: { ref?: string; sha?: string }; base?: { ref?: string; sha?: string } }>;
};
export type GiteaActionRunList = { total_count: number; workflow_runs: GiteaActionRun[] };
export type GiteaActionRunner = {
id: number;
name: string;
status: string;
busy: boolean;
disabled: boolean;
ephemeral: boolean;
labels: Array<{ id: number; name: string; type: string }>;
};
export type GiteaActionRunnerList = { total_count: number; runners: GiteaActionRunner[] };
export type GiteaActionJobStep = {
name: string;
number: number;
status: string;
conclusion?: string;
started_at?: string;
completed_at?: string;
};
export type GiteaActionJob = {
id: number;
html_url: string;
run_id: number;
name: string;
labels: string[];
run_attempt: number;
head_sha: string;
head_branch?: string;
status: string;
conclusion?: string;
runner_id: number;
runner_name?: string;
steps: GiteaActionJobStep[];
created_at: string;
started_at?: string;
completed_at?: string;
};
export type GiteaActionJobList = { total_count: number; jobs: GiteaActionJob[] };
export type GiteaLabel = { id: number; name: string; color: string; description?: string; url?: string };
export type GiteaIssue = {
id: number;
number: number;
title: string;
body: string;
state: "open" | "closed" | string;
html_url: string;
labels: GiteaLabel[];
created_at: string;
updated_at: string;
closed_at?: string;
user?: { login: string };
};
export type GiteaIssueComment = { id: number; body: string; html_url?: string; created_at: string; updated_at: string; user?: { login: string } };
export type GiteaWebhook = { id: number; type: string; active: boolean; events?: string[]; config: { url?: string; content_type?: string } };
+36
View File
@@ -0,0 +1,36 @@
export const PROJECTBRAIN_ANALYSIS_WIDGET_URI = "ui://projectbrain/analysis-job-v1.html";
export const PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE = "text/html;profile=mcp-app";
export const PROJECTBRAIN_ANALYSIS_WIDGET_HTML = `<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,sans-serif}body{margin:0;padding:14px;background:transparent;color:CanvasText}.card{border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:14px;padding:14px;background:Canvas}.top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}h1{font-size:16px;margin:0 0 4px}.muted{opacity:.68;font-size:12px}.badge{font-size:11px;font-weight:700;padding:4px 8px;border-radius:999px;background:color-mix(in srgb,#2563eb 15%,Canvas)}.bar{height:7px;border-radius:999px;background:color-mix(in srgb,CanvasText 10%,transparent);overflow:hidden;margin:14px 0 8px}.fill{height:100%;background:#2563eb;width:0;transition:width .2s}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px 0}.stat{padding:8px;border-radius:10px;background:color-mix(in srgb,CanvasText 5%,transparent)}.stat strong{display:block;font-size:15px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}button{border:1px solid color-mix(in srgb,CanvasText 20%,transparent);background:Canvas;border-radius:9px;padding:7px 10px;color:inherit;cursor:pointer}button:disabled{opacity:.5;cursor:default}.results{display:grid;gap:7px;margin-top:12px}.result{font-size:12px;padding:8px;border-radius:9px;background:color-mix(in srgb,CanvasText 5%,transparent)}
</style>
</head>
<body>
<main class="card">
<div class="top"><div><h1>Repositoryanalyse</h1><div id="id" class="muted">Job laden…</div></div><span id="status" class="badge">—</span></div>
<div class="bar"><div id="fill" class="fill"></div></div>
<div id="progress" class="muted">Nog geen voortgang ontvangen.</div>
<div class="stats"><div class="stat"><strong id="done">0</strong><span class="muted">Afgerond</span></div><div class="stat"><strong id="findings">0</strong><span class="muted">Bevindingen</span></div><div class="stat"><strong id="failed">0</strong><span class="muted">Mislukt</span></div></div>
<div id="summary" class="muted">ProjectBrain bewaart resultaten buiten deze chat.</div>
<div class="actions"><button id="refresh">Status vernieuwen</button><button id="results">Eerste resultaten</button></div>
<section id="resultList" class="results"></section>
</main>
<script>
const pending=new Map();let nextId=1;let jobId;
const el=id=>document.getElementById(id);
const dataOf=value=>value?.structuredContent??value?.result?.structuredContent??value;
function render(value){const data=dataOf(value)||{};const job=data.job||data;if(!job?.id)return;jobId=job.id;const total=Number(job.progressTotal||0),done=Number(job.progressCurrent||0),summary=job.summaryJson||{};el('id').textContent='Job '+job.id;el('status').textContent=job.status||'—';el('fill').style.width=(total?Math.min(100,done/total*100):0)+'%';el('progress').textContent=done+' van '+total+' repositories verwerkt';el('done').textContent=done;el('findings').textContent=summary.findingCount??0;el('failed').textContent=summary.failed??0;el('summary').textContent=job.summaryText||'Analyse staat in de wachtrij.';}
function call(name,args){if(window.openai?.callTool)return window.openai.callTool(name,args);const id=nextId++;window.parent.postMessage({jsonrpc:'2.0',id,method:'tools/call',params:{name,arguments:args}},'*');return new Promise((resolve,reject)=>pending.set(id,{resolve,reject}));}
async function callCompatible(publicName,upstreamName,args){try{return await call(publicName,args);}catch{return call(upstreamName,args);}}
window.addEventListener('message',event=>{if(event.source!==window.parent)return;const message=event.data;if(!message||message.jsonrpc!=='2.0')return;if(message.id!==undefined&&pending.has(message.id)){const waiter=pending.get(message.id);pending.delete(message.id);message.error?waiter.reject(message.error):waiter.resolve(message.result);return;}if(message.method==='ui/notifications/tool-result')render(message.params);if(message.method==='openai:set_globals')render(message.params?.globals?.toolOutput??message.params?.toolOutput);},{passive:true});
el('refresh').onclick=async()=>{if(!jobId)return;el('refresh').disabled=true;try{const value=await callCompatible('projectbrain.analysis.status','get_analysis_job',{job_id:jobId});render(value);}finally{el('refresh').disabled=false;}};
el('results').onclick=async()=>{if(!jobId)return;el('results').disabled=true;try{const value=dataOf(await callCompatible('projectbrain.analysis.results','get_analysis_job_results',{job_id:jobId,page_size:5,character_budget:12000}))||{};const items=value.items||[];el('resultList').replaceChildren(...items.map(item=>{const node=document.createElement('div');node.className='result';node.textContent=(item.repository||'Repository')+' — '+(item.summary||item.status||'onbekend');return node;}));}finally{el('results').disabled=false;}};
let attempts=0;const hydrate=()=>{const output=window.openai?.toolOutput;if(output){render(output);return;}if(++attempts<50)setTimeout(hydrate,100);};hydrate();
</script>
</body>
</html>`;
+325
View File
@@ -0,0 +1,325 @@
import { z } from "zod";
import type { AppEnv } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, normalizeAutonomyState, reportAutonomyWork, resumeAutonomy, retryAutonomyBlockerIfDue } from "../repositories/autonomyRepository.js";
import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js";
import { compactAutonomyState, recordAutonomyExecutorHeartbeat, touchAutonomyExecutorHeartbeat } from "../services/autonomyRuntime.js";
import { syncGitSources } from "../services/gitSourceSync.js";
import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js";
import { reconcileGiteaAutonomyExecution } from "../services/giteaAutonomyExecution.js";
import { createProjectBrainMcpServer as createBaseProjectBrainMcpServer } from "./server.js";
const result = (data: Record<string, unknown>, text: string) => ({
content: [{ type: "text" as const, text }],
structuredContent: data,
});
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
const autonomyStatuses = ["off", "waiting_roadmap", "running", "blocked", "completed"] as const;
type AutoModeProjectRow = {
status: string;
name: string;
pending_work_items: number;
active_work_kind?: string;
last_run_at?: string;
};
const autoModeQueueRank = (row: AutoModeProjectRow) =>
row.status === "running" && !row.active_work_kind ? 0 : row.active_work_kind ? 1 : row.status === "blocked" ? 2 : 3;
const queueTimestamp = (value?: string) => {
const parsed = value ? Date.parse(value) : 0;
return Number.isFinite(parsed) ? parsed : 0;
};
export const compareAutoModeProjectRows = (left: AutoModeProjectRow, right: AutoModeProjectRow) => {
const rankDifference = autoModeQueueRank(left) - autoModeQueueRank(right);
if (rankDifference) return rankDifference;
if (autoModeQueueRank(left) === 0) {
const ageDifference = queueTimestamp(left.last_run_at) - queueTimestamp(right.last_run_at);
if (ageDifference) return ageDifference;
}
return right.pending_work_items - left.pending_work_items || left.name.localeCompare(right.name);
};
const reportWithConflictRetry = async (
projectId: string,
input: Parameters<typeof reportAutonomyWork>[1],
actor: string,
) => {
try {
return await reportAutonomyWork(projectId, input, actor);
} catch (error) {
if (!(error instanceof Error) || error.message !== "AUTO_MODE_CONFLICT") throw error;
return reportAutonomyWork(projectId, input, actor);
}
};
export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
const server = createBaseProjectBrainMcpServer(env, actor);
server.registerTool("list_auto_mode_projects", {
title: "List ProjectBrain Auto Mode projects",
description: "Discover projects that have durable Auto Mode state without scanning every project through separate MCP calls. Intended as the first call for scheduled or newly resumed ChatGPT runs so they can find enabled autonomous roadmaps efficiently.",
inputSchema: {
enabled_only: z.boolean().default(true),
statuses: z.array(z.enum(autonomyStatuses)).max(autonomyStatuses.length).optional(),
max_projects: z.number().int().min(1).max(50).default(20),
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ enabled_only, statuses, max_projects }) => {
const projects = await prisma.project.findMany({
where: { deletedAt: null },
orderBy: { updatedAt: "desc" },
select: { slug: true, readableId: true, name: true, workspaceState: { select: { notesJson: true } } },
});
const selectedStatuses = statuses?.length ? new Set(statuses) : undefined;
const rows = projects.flatMap((project) => {
const notes = asRecord(project.workspaceState?.notesJson);
const autonomy = asRecord(notes.autonomy);
if (!Object.keys(autonomy).length) return [];
const state = retryAutonomyBlockerIfDue(normalizeAutonomyState(autonomy));
const enabled = state.enabled;
const status = state.status;
if (enabled_only && !enabled) return [];
if (selectedStatuses && !selectedStatuses.has(status)) return [];
const roadmap = state.roadmap;
const phases = roadmap?.phases ?? [];
const currentPhaseIndex = roadmap?.currentPhaseIndex ?? 0;
const currentPhase = phases[currentPhaseIndex];
const activeLease = state.activeLease && Date.parse(state.activeLease.expiresAt) > Date.now() ? state.activeLease : undefined;
const phaseItems = phases.flatMap((phase) => phase.items);
const lastRun = state.runs.reduce<(typeof state.runs)[number] | undefined>((latest, candidate) =>
!latest || queueTimestamp(candidate.startedAt) > queueTimestamp(latest.startedAt) ? candidate : latest, undefined);
return [{
project_id: project.slug,
readable_id: project.readableId ?? undefined,
name: project.name,
enabled,
status,
roadmap_title: roadmap?.title,
current_phase_index: phases.length ? currentPhaseIndex : undefined,
phase_count: phases.length,
current_phase_title: currentPhase?.title,
pending_work_items: phaseItems.filter((item) => item.status === "pending" || item.status === "in_progress").length,
completed_work_items: phaseItems.filter((item) => item.status === "completed").length,
max_work_items_per_run: state.maxWorkItemsPerRun,
max_run_minutes: state.maxRunMinutes,
last_run_at: lastRun?.startedAt,
active_work_kind: activeLease?.kind,
active_run_id: activeLease?.runId,
lease_expires_at: activeLease?.expiresAt,
blocker_reason: state.blocker?.reason,
blocker_kind: state.blocker?.kind,
retry_at: state.blocker?.retryAt,
}];
}).sort(compareAutoModeProjectRows).slice(0, max_projects);
return result({ projects: rows }, `${rows.length} Auto Mode project(s) matched. Claimable projects are ordered fairly by oldest last run (never-run projects first), so select the first row and keep processing claim_auto_work → execute → report_auto_work until that project's item or time budget is reached, work becomes busy or terminal, or user action is required.`);
});
server.registerTool("heartbeat_auto_executor", {
title: "Report ProjectBrain autonomous executor heartbeat",
description: "Report one bounded scheduled executor wake-up so ProjectBrain can distinguish queued autonomous work from a connected executor. Ordinary ChatGPT web tasks may run different projects concurrently; active_project_id selects the isolated ownership slot. Call at the start and end of every scheduled run. Normal claim/report calls refresh the heartbeat automatically; add an explicit heartbeat only immediately before and after an external wait that may exceed five minutes. A different run cannot replace the current owner of the same project inside the server-enforced run window.",
inputSchema: {
instance_id: z.string().trim().min(3).max(120),
run_id: z.string().trim().min(1).max(160).optional(),
status: z.enum(["idle", "working", "degraded"]),
active_project_id: z.string().trim().min(1).max(240).optional(),
detail: z.string().trim().min(1).max(1_000).optional(),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ instance_id, run_id, status, active_project_id, detail }) => {
const recorded = await recordAutonomyExecutorHeartbeat({
instanceId: instance_id,
...(run_id ? { runId: run_id } : {}),
status,
...(active_project_id ? { activeProjectId: active_project_id } : {}),
...(detail ? { detail } : {}),
});
const heartbeat = recorded.heartbeat;
return result({
accepted: recorded.accepted,
...(recorded.accepted ? {} : { rejection_reason: recorded.reason }),
executor: {
instance_id: heartbeat.instanceId,
run_id: heartbeat.runId ?? undefined,
status: heartbeat.status,
active_project_id: heartbeat.activeProjectId ?? undefined,
detail: heartbeat.detail ?? undefined,
last_seen_at: heartbeat.lastSeenAt.toISOString(),
},
}, recorded.accepted
? `Autonomous executor '${heartbeat.instanceId}' is ${heartbeat.status}; heartbeat recorded.`
: `Heartbeat rejected (${recorded.reason}); active run '${heartbeat.runId ?? "unknown"}' retains executor ownership. Do not claim work for the rejected run.`);
});
server.registerTool("get_auto_mode_state", {
title: "Get ProjectBrain Auto Mode state",
description: "Read the durable Auto Mode controller for one project and reconcile any newly observed canonical Gitea branch/PR/validation/merge evidence first. The default summary omits the large roadmap Markdown and returns bounded current-phase items and recent events. Request full only for targeted diagnostics that genuinely require the complete durable state.",
inputSchema: {
project_id: z.string().min(1),
view: z.enum(["summary", "full"]).default("summary"),
item_limit: z.number().int().min(1).max(25).default(25),
event_limit: z.number().int().min(0).max(20).default(5),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, view, item_limit, event_limit }) => {
let state = await getAutonomyState(project_id);
try {
state = (await reconcileGiteaAutonomyExecution(env, project_id, "system:mcp-auto-read")).state;
} catch {
// Durable ProjectBrain state remains readable during a transient Gitea read failure.
}
const projected = view === "full" ? state : compactAutonomyState(state, { itemLimit: item_limit, eventLimit: event_limit });
return result({ state: projected as unknown as Record<string, unknown>, view }, `Auto Mode is ${state.enabled ? "enabled" : "disabled"}; state=${state.status}${state.roadmap ? `, roadmap='${state.roadmap.title}', phase ${state.roadmap.currentPhaseIndex + 1}/${state.roadmap.phases.length}` : ""}; view=${view}.`);
});
server.registerTool("configure_auto_mode", {
title: "Configure ProjectBrain Auto Mode",
description: "Enable or disable autonomous roadmap execution for one project and configure the bounded brainstorm/failure/lease policy. When a linked repository exposes ROADMAP.md or a supported structured backlog, enabling Auto Mode synchronizes and bootstraps that repository plan automatically; explicit manually imported plans are preserved.",
inputSchema: {
project_id: z.string().min(1),
profile: z.enum(["conservative", "normal", "autonomous", "custom"]).optional(),
enabled: z.boolean().optional(),
auto_brainstorm: z.boolean().optional(),
max_brainstorm_iterations: z.number().int().min(1).max(10).optional(),
max_consecutive_failures: z.number().int().min(1).max(10).optional(),
lease_minutes: z.number().int().min(5).max(120).optional(),
max_work_items_per_run: z.number().int().min(1).max(20).optional(),
max_run_minutes: z.number().int().min(5).max(55).optional(),
max_recovery_minutes: z.number().int().min(15).max(1_440).optional(),
pause_after_current: z.boolean().optional(),
scheduling_policy: z.enum(["balanced", "downstream-impact"]).optional(),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, profile, enabled, auto_brainstorm, max_brainstorm_iterations, max_consecutive_failures, lease_minutes, max_work_items_per_run, max_run_minutes, max_recovery_minutes, pause_after_current, scheduling_policy }) => {
let state = await configureAutonomy(project_id, {
...(profile ? { profile } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(auto_brainstorm !== undefined ? { autoBrainstorm: auto_brainstorm } : {}),
...(max_brainstorm_iterations !== undefined ? { maxBrainstormIterations: max_brainstorm_iterations } : {}),
...(max_consecutive_failures !== undefined ? { maxConsecutiveFailures: max_consecutive_failures } : {}),
...(lease_minutes !== undefined ? { leaseMinutes: lease_minutes } : {}),
...(max_work_items_per_run !== undefined ? { maxWorkItemsPerRun: max_work_items_per_run } : {}),
...(max_run_minutes !== undefined ? { maxRunMinutes: max_run_minutes } : {}),
...(max_recovery_minutes !== undefined ? { maxRecoveryMinutes: max_recovery_minutes } : {}),
...(pause_after_current !== undefined ? { pauseAfterCurrent: pause_after_current } : {}),
...(scheduling_policy ? { schedulingPolicy: scheduling_policy } : {}),
}, actor);
let repositorySyncWarning: string | undefined;
if (state.enabled) {
try {
const sourceSync = await syncGitSources(env, { projectId: project_id });
if (sourceSync.errors.length) repositorySyncWarning = sourceSync.errors.map((entry) => `${entry.repository}: ${entry.code}`).join("; ");
} catch (error) {
repositorySyncWarning = error instanceof Error ? error.message : "REPOSITORY_SOURCE_SYNC_FAILED";
}
const repositoryPlan = await reconcileRepositoryAutonomyRoadmap(project_id, actor);
state = repositoryPlan.state;
}
return result({
state: state as unknown as Record<string, unknown>,
...(repositorySyncWarning ? { repository_sync_warning: repositorySyncWarning } : {}),
}, `Auto Mode configuration saved; enabled=${state.enabled}, state=${state.status}.${repositorySyncWarning ? ` Repository source refresh warning: ${repositorySyncWarning}` : ""}`);
});
server.registerTool("import_auto_roadmap", {
title: "Import ProjectBrain autonomous roadmap",
description: "Import a Markdown roadmap into ProjectBrain Auto Mode. H2+ headings become ordered phases and checkbox, bullet or numbered lines become work items. Import replaces the active autonomous roadmap but preserves Auto Mode policy settings.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(2).max(200).optional(), markdown: z.string().trim().min(3).max(100_000) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, markdown }) => {
const state = await importAutonomyRoadmap(project_id, { title, markdown }, actor);
const roadmap = state.roadmap!;
return result({ state: state as unknown as Record<string, unknown> }, `Roadmap '${roadmap.title}' imported with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`);
});
server.registerTool("resume_auto_mode", {
title: "Resume blocked ProjectBrain Auto Mode",
description: "Clear the current Auto Mode blocker and make its blocked roadmap phase/item claimable again. The roadmap, completed phases and completed work are preserved; only blocker/retry state for the blocked work is reset.",
inputSchema: { project_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id }) => {
const state = await resumeAutonomy(project_id, actor);
return result({ state: state as unknown as Record<string, unknown> }, `Auto Mode resume applied; enabled=${state.enabled}, state=${state.status}.`);
});
server.registerTool("claim_auto_work", {
title: "Claim next ProjectBrain autonomous work",
description: "Backward-compatible controller claim for one Auto work item. Implementation work returns a canonical Gitea execution target whose deterministic branch is the atomic execution claim. Heartbeat and lease ownership only coordinate legacy controller runs; repository correctness is reconciled from Gitea branch, pull-request, exact-head validation and merge evidence.",
inputSchema: {
project_id: z.string().min(1),
run_id: z.string().trim().min(1).max(160),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, run_id }) => {
const ownsExecutor = await touchAutonomyExecutorHeartbeat(run_id, project_id);
if (!ownsExecutor) {
const state = await getAutonomyState(project_id);
return result({ state: state as unknown as Record<string, unknown>, work: undefined, busy: true, heartbeat_required: true }, "This run does not own an accepted working executor heartbeat. Do not claim or execute work; another run may be active, or heartbeat_auto_executor must be called first.");
}
let repositorySyncWarning: string | undefined;
const beforeClaimState = await getAutonomyState(project_id);
if (!beforeClaimState.runs.some((run) => run.id === run_id)) {
try {
const sourceSync = await syncGitSources(env, { projectId: project_id });
if (sourceSync.errors.length) repositorySyncWarning = sourceSync.errors.map((entry) => `${entry.repository}: ${entry.code}`).join("; ");
} catch (error) {
repositorySyncWarning = error instanceof Error ? error.message : "REPOSITORY_SOURCE_SYNC_FAILED";
}
}
try {
try {
await reconcileGiteaAutonomyExecution(env, project_id, "system:mcp-auto-claim");
} catch {
// Keep the compatibility claim path available during transient Gitea read failures.
}
const claim = await claimAutonomyWork(project_id, actor, run_id);
const work = claim.work;
return result({ ...claim, state: compactAutonomyState(claim.state, { itemLimit: 5, eventLimit: 2 }), ...(repositorySyncWarning ? { repository_sync_warning: repositorySyncWarning } : {}) } as unknown as Record<string, unknown>, (work ? `${claim.resumed ? "Resumed" : "Claimed"} ${work.kind}: ${work.itemTitle ?? work.phaseTitle}. Execute the returned prompt, report with token ${work.token}, and continue within the run budget.` : claim.budgetReached ? `This run reached its server-enforced ${claim.budgetReason === "item_limit" ? "item" : "time"} budget; stop cleanly and leave the recurring task enabled.` : claim.busy ? `Autonomous work is already leased by another executor run; do not duplicate it. Controller state=${claim.state.status}.` : `No autonomous work is currently claimable; controller state=${claim.state.status}.`) + (repositorySyncWarning ? ` Repository source refresh warning: ${repositorySyncWarning}` : ""));
} catch (error) {
if (!(error instanceof Error) || error.message !== "AUTO_MODE_CONFLICT") throw error;
const claim = await claimAutonomyWork(project_id, actor, run_id);
const work = claim.work;
return result({ ...claim, state: compactAutonomyState(claim.state, { itemLimit: 5, eventLimit: 2 }) } as unknown as Record<string, unknown>, work ? `Concurrent claim resolved by resuming this run's durable ${work.kind} lease for ${work.itemTitle ?? work.phaseTitle}. Report with token ${work.token} and continue within the run budget.` : `Concurrent claim resolved without duplicating another run; controller state=${claim.state.status}.`);
}
});
server.registerTool("report_auto_work", {
title: "Report ProjectBrain autonomous work",
description: "Backward-compatible bookkeeping for a claimed lease. Canonical implementation truth comes from the deterministic Gitea branch, PR, exact-head Actions validation and merge; ProjectBrain reconciles that evidence even when this callback is never sent. delivery_id remains supported for legacy feature deliveries. Use discovered_tasks for repair prerequisites and reserve human blockers for actual owner decisions.",
inputSchema: {
project_id: z.string().min(1), token: z.string().uuid(), outcome: z.enum(["success", "failed", "blocked"]), summary: z.string().trim().min(3).max(12_000),
blocker_type: z.enum(["human", "retryable"]).optional(),
retry_after_minutes: z.number().int().min(5).max(1_440).optional(),
delivery_id: z.string().trim().min(1).max(240).optional(),
discovered_tasks: z.array(z.string().trim().min(2).max(1_000)).max(100).default([]),
brainstorm: z.object({
context_summary: z.string().trim().min(3).max(8_000).optional(),
ideas: z.array(z.object({ id: z.string().min(1).max(160), title: z.string().min(1).max(300), description: z.string().min(1).max(4_000), score: z.number().min(0).max(100).optional() })).max(50).default([]),
outcomes: z.array(z.string().min(1).max(2_000)).max(50).default([]),
selected_idea_ids: z.array(z.string().min(1).max(160)).max(50).default([]),
next_actions: z.array(z.string().trim().min(2).max(1_000)).max(100).default([]),
}).optional(),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, token, outcome, summary, blocker_type, retry_after_minutes, delivery_id, discovered_tasks, brainstorm }) => {
const reportInput = {
token, outcome, summary, ...(blocker_type ? { blockerType: blocker_type } : {}), ...(retry_after_minutes ? { retryAfterMinutes: retry_after_minutes } : {}), ...(delivery_id ? { deliveryId: delivery_id } : {}), discoveredTasks: discovered_tasks,
...(brainstorm ? { brainstorm: { ...(brainstorm.context_summary ? { contextSummary: brainstorm.context_summary } : {}), ideas: brainstorm.ideas, outcomes: brainstorm.outcomes, selectedIdeaIds: brainstorm.selected_idea_ids, nextActions: brainstorm.next_actions } } : {}),
} satisfies Parameters<typeof reportAutonomyWork>[1];
try {
await reconcileGiteaAutonomyExecution(env, project_id, "system:mcp-auto-report");
} catch {
// A compatibility report can still be recorded if Gitea is temporarily unavailable.
}
const report = await reportWithConflictRetry(project_id, reportInput, actor);
if (report.runId) await touchAutonomyExecutorHeartbeat(report.runId, project_id);
return result({ ...report, state: compactAutonomyState(report.state, { itemLimit: 5, eventLimit: 2 }) } as unknown as Record<string, unknown>, `${report.replayed ? "Replayed" : "Recorded"} autonomous work as ${outcome}; ${report.addedTasks.length} new task(s) added. Controller state=${report.state.status}.`);
});
return server;
};
+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod";
import { assertFileSize, assertWritablePath } from "../integrations/gitea/policy.js";
export const featureChangesetOperationSchema = z.discriminatedUnion("operation", [
z.object({ operation: z.literal("create"), path: z.string().min(1), content: z.string(), expected_sha: z.undefined().optional() }),
z.object({ operation: z.literal("update"), path: z.string().min(1), content: z.string(), expected_sha: z.string().min(1).max(200) }),
z.object({ operation: z.literal("delete"), path: z.string().min(1), expected_sha: z.string().min(1).max(200) }),
]);
export type FeatureChangesetOperation = z.infer<typeof featureChangesetOperationSchema>;
export const assertFeatureChangesetBudget = (operations: FeatureChangesetOperation[]) => {
const paths = operations.map((operation) => assertWritablePath(operation.path));
if (new Set(paths).size !== paths.length) throw new Error("FEATURE_CHANGESET_DUPLICATE_PATH");
const bytes = operations.reduce((total, operation) => total + ("content" in operation ? Buffer.byteLength(operation.content, "utf8") : 0), 0);
if (bytes > 1024 * 1024) throw new Error("FEATURE_CHANGESET_TOO_LARGE");
for (const operation of operations) if ("content" in operation) assertFileSize(operation.content);
return paths;
};
export const mapBounded = async <T, R>(items: T[], limit: number, operation: (item: T, index: number) => Promise<R>) => {
const output: R[] = [];
for (let index = 0; index < items.length; index += limit) {
output.push(...await Promise.all(items.slice(index, index + limit).map((item, offset) => operation(item, index + offset))));
}
return output;
};
+239
View File
@@ -0,0 +1,239 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { AppEnv } from "../config/env.js";
import { assertReadablePath } from "../integrations/gitea/policy.js";
import {
listPortfolioRepositories,
parsePortfolioFetchId,
portfolioFetchId,
type PortfolioRepository,
} from "../integrations/gitea/portfolio.js";
import { getGiteaClient, recordMcpAudit } from "../integrations/gitea/service.js";
import type { GiteaContent } from "../integrations/gitea/types.js";
type ToolResult = {
content: Array<{ type: "text"; text: string }>;
structuredContent?: Record<string, unknown>;
isError?: boolean;
};
const asResult = (data: unknown, message = "Operation completed"): ToolResult => ({
content: [{ type: "text", text: `${message}\n${JSON.stringify(data, null, 2)}` }],
structuredContent:
data && typeof data === "object" && !Array.isArray(data)
? (data as Record<string, unknown>)
: { data },
});
const asKnowledgeResult = (data: Record<string, unknown>): ToolResult => ({
content: [{ type: "text", text: JSON.stringify(data) }],
});
const decodeFile = (content: GiteaContent) => {
if (
content.type !== "file" ||
content.encoding !== "base64" ||
typeof content.content !== "string"
) {
throw new Error("GITEA_CONTENT_NOT_FILE");
}
return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8");
};
const mapBounded = async <T, R>(
items: T[],
limit: number,
operation: (item: T) => Promise<R>,
): Promise<R[]> => {
const output: R[] = [];
for (let index = 0; index < items.length; index += limit) {
output.push(...(await Promise.all(items.slice(index, index + limit).map(operation))));
}
return output;
};
const repositoryKey = (repository: Pick<PortfolioRepository, "owner" | "repository">) =>
`${repository.owner}/${repository.repository}`.toLowerCase();
const errorCode = (error: unknown) =>
error instanceof Error ? error.message : "MCP_PORTFOLIO_OPERATION_FAILED";
const auditedRead = async (
actor: string,
toolName: string,
operation: () => Promise<ToolResult>,
context: { repository?: string; branch?: string } = {},
): Promise<ToolResult> => {
const started = Date.now();
try {
const result = await operation();
await recordMcpAudit({
actor,
toolName,
repository: context.repository,
branch: context.branch,
outcome: "succeeded",
durationMs: Date.now() - started,
metadata: { projection: "gitea-portfolio", readOnly: true },
}).catch(() => undefined);
return result;
} catch (error) {
const code = errorCode(error);
await recordMcpAudit({
actor,
toolName,
repository: context.repository,
branch: context.branch,
outcome: "failed",
errorCode: code,
durationMs: Date.now() - started,
metadata: { projection: "gitea-portfolio", readOnly: true },
}).catch(() => undefined);
return {
content: [{ type: "text", text: `Operation failed: ${code}` }],
structuredContent: { error: code },
isError: true,
};
}
};
/**
* Register cross-repository search and fetch tools without changing the
* existing portfolio catalog or the GiteaRepositoryLink ownership/write
* boundary used by ProjectBrain delivery.
*/
export const registerPortfolioMcpTools = (
server: McpServer,
env: AppEnv,
actor: string,
) => {
const client = getGiteaClient(env);
server.registerTool(
"search_portfolio_repositories",
{
title: "Search Gitea portfolio repositories",
description:
"Search code across every repository visible to ProjectBrain's scoped Gitea identity. Returned gitea-repository:// ids can be read with fetch_portfolio_file without creating ProjectBrain ownership links.",
inputSchema: {
query: z.string().trim().min(2).max(200).describe("Plain-text code search query"),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ query }) =>
auditedRead(actor, "search_portfolio_repositories", async () => {
const repositories = await listPortfolioRepositories(env);
const searched = repositories.filter((repository) => !repository.archived);
const batches = await mapBounded(searched, 6, async (repository) => {
try {
const response = await client.searchCode(
repository.owner,
repository.repository,
query,
repository.defaultBranch,
);
const rows = Array.isArray(response) ? response : response.data ?? [];
return {
results: rows.map((row) => ({
id: portfolioFetchId(repository, repository.defaultBranch, row.path),
title: `${repository.fullName}: ${row.path}`,
url: row.html_url,
metadata: {
repository: repository.fullName,
ref: repository.defaultBranch,
path: row.path,
linked: repository.linked,
projectId: repository.link?.projectSlug,
},
})),
};
} catch {
return { results: [], failedRepository: repository.fullName };
}
});
const allResults = batches.flatMap((batch) => batch.results);
const failedRepositories = batches
.map((batch) => batch.failedRepository)
.filter((value): value is string => typeof value === "string");
return asKnowledgeResult({
results: allResults.slice(0, 50),
metadata: {
repositoryCount: repositories.length,
searchedRepositoryCount: searched.length,
failedRepositories,
truncated: allResults.length > 50,
},
});
}),
);
server.registerTool(
"fetch_portfolio_file",
{
title: "Fetch Gitea portfolio file",
description:
"Read one text file by the canonical gitea-repository:// id returned by search_portfolio_repositories. The repository must still be visible to ProjectBrain's scoped Gitea identity and sensitive paths remain blocked.",
inputSchema: {
id: z
.string()
.startsWith("gitea-repository://")
.max(2_000)
.describe("Canonical gitea-repository://owner/repository/ref/path identifier"),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ id }) => {
let parsed: ReturnType<typeof parsePortfolioFetchId>;
try {
parsed = parsePortfolioFetchId(id);
} catch (error) {
return asResult({ error: errorCode(error) }, "Invalid portfolio fetch id");
}
return auditedRead(
actor,
"fetch_portfolio_file",
async () => {
const repositories = await listPortfolioRepositories(env);
const visible = new Map(repositories.map((repository) => [repositoryKey(repository), repository]));
const repository = visible.get(`${parsed.owner}/${parsed.repository}`.toLowerCase());
if (!repository) throw new Error("GITEA_REPOSITORY_NOT_VISIBLE");
const path = assertReadablePath(parsed.path);
const file = await client.getContents(
repository.owner,
repository.repository,
path,
parsed.ref,
);
if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
return asKnowledgeResult({
id,
title: `${repository.fullName}: ${path}`,
text: decodeFile(file),
url: file.html_url,
metadata: {
repository: repository.fullName,
path,
ref: parsed.ref,
sha: file.sha,
size: file.size,
linked: repository.linked,
projectId: repository.link?.projectSlug,
},
});
},
{ repository: `${parsed.owner}/${parsed.repository}`, branch: parsed.ref },
);
},
);
};
+60
View File
@@ -0,0 +1,60 @@
import { GiteaApiError } from "../integrations/gitea/client.js";
import type { GiteaContent } from "../integrations/gitea/types.js";
export type ToolResult = { content: Array<{ type: "text"; text: string }>; structuredContent?: Record<string, unknown>; isError?: boolean };
export const MAX_MCP_READ_BYTES = 512 * 1024;
export const asResult = (data: unknown, message = "Operation completed"): ToolResult => ({ content: [{ type: "text", text: message }], structuredContent: data && typeof data === "object" && !Array.isArray(data) ? data as Record<string, unknown> : { data } });
export const asStandardKnowledgeResult = (data: Record<string, unknown>): ToolResult => ({ content: [{ type: "text", text: JSON.stringify(data) }] });
export const asCompactResult = (data: Record<string, unknown>, message: string): ToolResult => ({ content: [{ type: "text", text: message }], structuredContent: data });
export const errorCode = (error: unknown) => error instanceof GiteaApiError ? error.code : error instanceof Error ? error.message : "MCP_OPERATION_FAILED";
export const operationRecovery = (code: string) => {
if (/CHECKS_PENDING/.test(code)) return { action: "wait_and_resume", retryable: true, detail: "Required checks are still queued/running. Keep the same delivery, branch and PR; inspect status after the indicated wait and retry the merge with the current head." };
if (/CI_|CHECKS_FAILED|VALIDATION_FAILED/.test(code)) return { action: "repair_and_revalidate", retryable: true, detail: "Read exact-head validation runs and the selected failed-job evidence. Fix the cause on the existing delivery branch. For canceled/transient runs, rerun the affected workflow. Sync successful evidence, then retry this PR's merge; never create a replacement branch or weaken checks." };
if (/HEAD_CONFLICT|FILE_CONFLICT|AUTO_MODE_CONFLICT/.test(code)) return { action: "reload_current_state", retryable: true, detail: "Reload the existing delivery, branch head and affected file SHAs. Reconcile actual progress before retrying; do not replay stale writes." };
if (/MERGE_UNCONFIRMED|PULL_REQUEST_NOT_READY/.test(code)) return { action: "reconcile_existing_pr", retryable: true, detail: "Read the existing PR and retry merge_feature_delivery with its expected head. An already merged PR is reconciled without another merge." };
return { action: "inspect_existing_delivery", retryable: false, detail: "Read the existing delivery and repository state before acting. Do not create another branch to work around a tool error." };
};
export const pageCollection = <T>(items: T[], cursor: number, pageSize: number) => ({ items: items.slice(cursor, cursor + pageSize), total: items.length, nextCursor: cursor + pageSize < items.length ? String(cursor + pageSize) : undefined });
export const compactRecord = (value: unknown): Record<string, unknown> => {
if (!value || typeof value !== "object" || Array.isArray(value)) return { value };
const item = value as Record<string, unknown>;
const spec = item.spec && typeof item.spec === "object" && !Array.isArray(item.spec) ? item.spec as Record<string, unknown> : undefined;
const summaryCandidate = [item.summary, item.contextSummary, item.rule, item.intent, item.content]
.find((candidate): candidate is string => typeof candidate === "string" && candidate.length > 0);
return Object.fromEntries(Object.entries({
id: item.id,
projectId: item.projectId,
title: item.title,
status: item.status ?? spec?.status,
approvalState: item.approvalState,
kind: item.kind,
category: item.category,
scope: item.scope,
severity: item.severity,
confidence: item.confidence,
priority: item.priority,
branch: spec?.branch,
pullRequest: spec?.pullRequest,
summary: summaryCandidate ? `${summaryCandidate.slice(0, 600)}${summaryCandidate.length > 600 ? "…" : ""}` : undefined,
updatedAt: item.updatedAt,
}).filter(([, field]) => field !== undefined));
};
export const compactDelivery = (value: unknown) => compactRecord(value);
export const decodeFile = (content: GiteaContent) => {
if (content.type !== "file" || content.encoding !== "base64" || typeof content.content !== "string") throw new Error("GITEA_CONTENT_NOT_FILE");
if (content.size > MAX_MCP_READ_BYTES) throw new Error("MCP_FILE_READ_TOO_LARGE");
return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8");
};
export const parseFetchId = (id: string) => {
const match = id.match(/^gitea:\/\/([^/]+)\/([^/]+)\/(.+)$/);
if (!match) throw new Error("MCP_FETCH_ID_INVALID");
return { projectId: decodeURIComponent(match[1]), ref: decodeURIComponent(match[2]), path: decodeURIComponent(match[3]) };
};
+23
View File
@@ -0,0 +1,23 @@
import { getLinkedRepository, listProjectRepositories, type LinkedRepository } from "../integrations/gitea/service.js";
import { getFindingInvestigation, getRepositoryFinding } from "../repositories/findingRepository.js";
export class PartialExternalMutationError extends Error {}
export const requireLink = async (projectId: string, write = false, repositoryId?: string): Promise<LinkedRepository> => {
const link = repositoryId ? (await listProjectRepositories(projectId)).find((item) => item.id === repositoryId) : await getLinkedRepository(projectId);
if (!link) throw new Error("GITEA_LINK_NOT_FOUND");
if (write && link.accessMode !== "read_write") throw new Error("GITEA_REPOSITORY_READ_ONLY");
return link;
};
export const assertFindingProject = async (projectId: string, findingId: string) => {
const [link, finding] = await Promise.all([requireLink(projectId), getRepositoryFinding(findingId)]);
if (finding.projectId !== link.projectId) throw new Error("FINDING_PROJECT_SCOPE_MISMATCH");
return finding;
};
export const assertInvestigationProject = async (projectId: string, investigationId: string) => {
const [link, investigation] = await Promise.all([requireLink(projectId), getFindingInvestigation(investigationId)]);
if (investigation.projectId !== link.projectId) throw new Error("FINDING_PROJECT_SCOPE_MISMATCH");
return investigation;
};
+799
View File
@@ -0,0 +1,799 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { AppEnv } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { GiteaApiError } from "../integrations/gitea/client.js";
import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, exactHeadActionRuns, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js";
import { finishExternalOperation, getGiteaClient, listLinkedRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, type LinkedRepository } from "../integrations/gitea/service.js";
import type { GiteaContent } from "../integrations/gitea/types.js";
import { assertFeatureDeliveryActionEvidence, canSyncFeatureDeliveryValidation, getFeatureDelivery, listFeatureDeliveries, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery, updateFeatureDelivery } from "../repositories/featureDeliveryRepository.js";
import { createBrainstormForProject, listBrainstormsForProject } from "../repositories/brainstormRepository.js";
import { createContextPackForProject, listContextPacksForProject } from "../repositories/contextPacksRepository.js";
import { createDocumentationItem, createKnowledgeItem, createMemoryRuleItem, listDocumentationForProject, listKnowledgeForProject, listMemoryRulesForProject } from "../repositories/p8KnowledgeMemoryRepository.js";
import { listProjects } from "../repositories/projectsRepository.js";
import { listReviewItemsFromDatabase } from "../repositories/reviewQueueRepository.js";
import { importCodexOutputInDatabase } from "../repositories/p7RuntimeRepository.js";
import { PROJECTBRAIN_WORKSPACE_HTML, PROJECTBRAIN_WORKSPACE_MIME_TYPE, PROJECTBRAIN_WORKSPACE_URI } from "./widget.js";
import { resolveProjectContext } from "../services/contextResolver.js";
import { cancelAnalysisJob, createPortfolioAnalysisJob, getAnalysisJob, getAnalysisJobResults, listAnalysisJobs } from "../repositories/analysisJobRepository.js";
import { PROJECTBRAIN_ANALYSIS_WIDGET_HTML, PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE, PROJECTBRAIN_ANALYSIS_WIDGET_URI } from "./analysisWidget.js";
import { createDeliveryFromFindingInvestigation, getFindingInvestigation, getRepositoryFinding, listRepositoryFindings, queueFindingVerificationForDelivery, saveFindingInvestigationConclusion, startFindingInvestigation, suppressRepositoryFinding, syncFindingIssue } from "../repositories/findingRepository.js";
import { excludePortfolioRepository, getPortfolioControlPlane, listPortfolioRepositories, reconcilePortfolioRepositories, restorePortfolioRepository } from "../services/portfolioControlPlane.js";
import { assertFeatureChangesetBudget, featureChangesetOperationSchema, mapBounded } from "./featureChangeset.js";
import { asCompactResult, asResult, asStandardKnowledgeResult, compactDelivery, compactRecord, decodeFile, errorCode, operationRecovery, pageCollection, parseFetchId, type ToolResult } from "./resultHelpers.js";
import { assertFindingProject, assertInvestigationProject, PartialExternalMutationError, requireLink } from "./scopeGuards.js";
import { assertDeliveryWriteAllowed, cleanupDeliveryBranch, deliveryContinuation } from "../services/featureDeliveryFlow.js";
import { createOrResumeAutonomyDelivery } from "../services/autonomyDelivery.js";
import { refreshDeliveryFromGitea, resolveReconciledProjectContext } from "../services/deliveryReconciliation.js";
const withAudit = async (input: { actor: string; tool: string; projectId?: string; branch?: string; repositoryId?: string; deliveryId?: string }, operation: (link?: LinkedRepository) => Promise<ToolResult>, write = false): Promise<ToolResult> => {
const started = Date.now();
let link: LinkedRepository | undefined;
let externalOperationId: string | undefined;
let externalMutationReturned = false;
let externalOperationFinished = false;
try {
const deliveryRepositoryId = input.projectId && input.deliveryId ? (await getFeatureDelivery(input.projectId, input.deliveryId)).spec.repositoryId : undefined;
link = input.projectId ? await requireLink(input.projectId, write, input.repositoryId ?? deliveryRepositoryId) : undefined;
if (write && link) {
const prepared = await prepareExternalOperation({
projectId: link.projectId, actor: input.actor, action: input.tool,
target: { repository: link.fullName, branch: input.branch },
});
externalOperationId = prepared.operationId;
await markExternalOperationExecuting(externalOperationId);
}
const result = await operation(link);
externalMutationReturned = write;
if (externalOperationId) {
await finishExternalOperation(externalOperationId, { status: "succeeded" });
externalOperationFinished = true;
}
const audit = recordMcpAudit({ operationId: externalOperationId, projectId: link?.projectId, actor: input.actor, toolName: input.tool, repository: link?.fullName, branch: input.branch, outcome: "succeeded", durationMs: Date.now() - started });
if (write) await audit; else await audit.catch(() => undefined);
return result;
} catch (error) {
if (error instanceof PartialExternalMutationError) externalMutationReturned = true;
const code = errorCode(error);
if (externalOperationId && !externalOperationFinished) await finishExternalOperation(externalOperationId, { status: externalMutationReturned ? "unknown" : "failed", errorCode: code }).catch(() => undefined);
await recordMcpAudit({ operationId: externalOperationId, projectId: link?.projectId, actor: input.actor, toolName: input.tool, repository: link?.fullName, branch: input.branch, outcome: ["GITEA_REPOSITORY_READ_ONLY", "MCP_BRANCH_FORBIDDEN", "DEFAULT_BRANCH_WRITE_FORBIDDEN", "SENSITIVE_PATH_FORBIDDEN", "REPOSITORY_PATH_FORBIDDEN"].includes(code) ? "denied" : "failed", errorCode: code, durationMs: Date.now() - started }).catch(() => undefined);
return { content: [{ type: "text", text: `Operation failed: ${code}` }], structuredContent: { error: code, recovery: operationRecovery(code) }, isError: true };
}
};
export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => {
const server = new McpServer({ name: "ProjectBrain", version: "0.7.1", websiteUrl: env.PROJECTBRAIN_PUBLIC_URL });
const client = getGiteaClient(env);
server.registerResource("ProjectBrain workspace", PROJECTBRAIN_WORKSPACE_URI, {
title: "ProjectBrain workspace",
description: "Interactive project-memory, review, and feature-delivery workspace.",
mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE,
_meta: {
ui: {
prefersBorder: true,
domain: env.PROJECTBRAIN_PUBLIC_URL,
csp: { connectDomains: [], resourceDomains: [] },
},
"openai/widgetDescription": "Shows durable ProjectBrain context, pending reviews, and active feature deliveries.",
},
}, async () => ({ contents: [{
uri: PROJECTBRAIN_WORKSPACE_URI,
mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE,
text: PROJECTBRAIN_WORKSPACE_HTML,
_meta: {
ui: {
prefersBorder: true,
domain: env.PROJECTBRAIN_PUBLIC_URL,
csp: { connectDomains: [], resourceDomains: [] },
},
"openai/widgetDescription": "Shows durable ProjectBrain context, pending reviews, and active feature deliveries.",
},
}] }));
server.registerResource("ProjectBrain repository analysis", PROJECTBRAIN_ANALYSIS_WIDGET_URI, {
title: "ProjectBrain repository analysis",
description: "Compact progress and result summary for a durable repository-analysis job.",
mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE,
_meta: {
ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } },
"openai/widgetDescription": "Shows bounded progress for a server-side ProjectBrain repository analysis.",
},
}, async () => ({ contents: [{
uri: PROJECTBRAIN_ANALYSIS_WIDGET_URI,
mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE,
text: PROJECTBRAIN_ANALYSIS_WIDGET_HTML,
_meta: {
ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } },
"openai/widgetDescription": "Shows bounded progress for a server-side ProjectBrain repository analysis.",
},
}] }));
server.registerTool("list_projects", {
title: "List ProjectBrain projects", description: "Use this to choose a ProjectBrain project. Results are paginated so the complete portfolio is never returned to the browser at once.", inputSchema: { cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ cursor, page_size }) => withAudit({ actor, tool: "list_projects" }, async () => {
const page = pageCollection(await listProjects(), Number.parseInt(cursor, 10), page_size);
return asCompactResult({ projects: page.items, total: page.total, nextCursor: page.nextCursor }, `${page.items.length} project(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
}));
server.registerTool("list_project_repositories", {
title: "List ProjectBrain repositories", description: "Use this to find a linked Gitea repository and access mode. Results are paginated for browser-safe portfolio growth.", inputSchema: { cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ cursor, page_size }) => withAudit({ actor, tool: "list_project_repositories" }, async () => {
const page = pageCollection(await listLinkedRepositories(), Number.parseInt(cursor, 10), page_size);
return asCompactResult({ repositories: page.items, total: page.total, nextCursor: page.nextCursor }, `${page.items.length} repositorykoppeling(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
}));
server.registerTool("get_portfolio_control_plane", {
title: "Get ProjectBrain portfolio control plane",
description: "Use this first whenever the user asks what needs attention across projects. It returns one recommended next action, a short actionable queue, live notifications, repository/project coverage and compact analysis state without loading repository contents.",
inputSchema: {},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async () => withAudit({ actor, tool: "get_portfolio_control_plane" }, async () => {
const { repositories: _repositories, projects: _projects, ...summary } = await getPortfolioControlPlane(env);
return asCompactResult(summary as unknown as Record<string, unknown>, `Portfolio geladen: volgende actie is '${summary.nextAction.label}'. ${summary.coverage.onboardedRepositories}/${summary.coverage.visibleRepositories} zichtbare repositories zijn gekoppeld; ${summary.findings.active} actieve finding(s).`);
}));
server.registerTool("list_portfolio_repositories", {
title: "List ProjectBrain portfolio repositories",
description: "List one bounded page of Gitea repositories and their durable ProjectBrain onboarding status. Use get_portfolio_control_plane first and paginate only when repository-level detail is needed.",
inputSchema: { status: z.enum(["discovered", "onboarded", "excluded", "unavailable"]).optional(), cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async ({ status, cursor, page_size }) => withAudit({ actor, tool: "list_portfolio_repositories" }, async () => {
const repositories = (await listPortfolioRepositories(env)).filter((repository) => !status || repository.status === status);
const page = pageCollection(repositories, Number.parseInt(cursor, 10), page_size);
return asCompactResult(page as unknown as Record<string, unknown>, `${page.items.length} repositoryrecords geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
}));
server.registerTool("reconcile_portfolio_repositories", {
title: "Reconcile ProjectBrain portfolio repositories",
description: "Preview or apply idempotent onboarding for newly discovered Gitea repositories. Applying creates or matches a ProjectBrain project, configures the signed webhook, grants the available read/write mode and enables conservative critical-finding issue automation.",
inputSchema: { apply: z.boolean().default(false), repository_full_names: z.array(z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/)).max(50).optional() },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
_meta: { "openai/toolInvocation/invoking": "Portfolio reconciliëren…", "openai/toolInvocation/invoked": "Portfolio gereconcilieerd" },
}, async ({ apply, repository_full_names }) => withAudit({ actor, tool: "reconcile_portfolio_repositories" }, async () => {
const result = await reconcilePortfolioRepositories(env, actor, { dryRun: !apply, repositoryFullNames: repository_full_names });
return asCompactResult(result as unknown as Record<string, unknown>, apply ? `${result.onboardedCount} repository(s) gekoppeld; ${result.failedCount} mislukt.` : `${result.candidateCount} repository(s) klaar voor onboarding. Roep opnieuw aan met apply=true na bevestiging.`);
}));
server.registerTool("set_portfolio_repository_exclusion", {
title: "Set ProjectBrain repository onboarding exclusion",
description: "Exclude a discovered repository from current and future automatic onboarding, or restore it. Excluding an already onboarded repository is refused; unlinking remains a separate owner operation.",
inputSchema: { repository_id: z.string().min(1), excluded: z.boolean(), reason: z.string().trim().min(3).max(2_000).optional() },
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
}, async ({ repository_id, excluded, reason }) => withAudit({ actor, tool: "set_portfolio_repository_exclusion" }, async () => {
if (excluded && !reason) throw new Error("PORTFOLIO_EXCLUSION_REASON_REQUIRED");
const repository = excluded ? await excludePortfolioRepository(repository_id, reason!) : await restorePortfolioRepository(repository_id);
return asCompactResult({ repository } as unknown as Record<string, unknown>, `${repository.fullName} is ${excluded ? "uitgesloten van" : "hersteld voor"} automatische onboarding.`);
}));
server.registerTool("search", {
title: "Search ProjectBrain repositories", description: "Search code in all Gitea repositories linked to ProjectBrain. Use fetch with a returned id to read the complete file.",
inputSchema: { query: z.string().trim().min(2).max(200).describe("Plain-text code search query") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ query }) => withAudit({ actor, tool: "search" }, async () => {
const links = await listLinkedRepositories();
const batches = await mapBounded(links, 6, async (link) => {
try {
const response = await client.searchCode(link.owner, link.repository, query, link.defaultBranch);
const rows = Array.isArray(response) ? response : response.data ?? [];
return rows.map((row) => ({ id: `gitea://${encodeURIComponent(link.projectSlug)}/${encodeURIComponent(link.defaultBranch)}/${encodeURIComponent(row.path)}`, title: `${link.fullName}: ${row.path}`, url: row.html_url }));
} catch { return []; }
});
const results = batches.flat().slice(0, 50);
return asStandardKnowledgeResult({ results });
}));
server.registerTool("fetch", {
title: "Fetch repository file", description: "Fetch a complete repository file by the canonical gitea:// id returned by search.",
inputSchema: { id: z.string().startsWith("gitea://").describe("Canonical gitea://project/ref/path identifier") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ id }) => {
let parsed: ReturnType<typeof parseFetchId>;
try { parsed = parseFetchId(id); } catch (error) { return asResult({ error: errorCode(error) }, "Invalid fetch id"); }
return withAudit({ actor, tool: "fetch", projectId: parsed.projectId, branch: parsed.ref }, async (link) => {
const path = assertReadablePath(parsed.path);
const file = await client.getContents(link!.owner, link!.repository, path, parsed.ref);
if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
return asStandardKnowledgeResult({ id, title: `${link!.fullName}: ${path}`, text: decodeFile(file), url: file.html_url, metadata: { projectId: link!.projectSlug, repository: link!.fullName, path, ref: parsed.ref, sha: file.sha, size: file.size } });
});
});
server.registerTool("get_project_context", {
title: "Get ProjectBrain project context", description: "Use this first for one known project. It returns bounded approved context plus a stable resume state and one recommended next action; paginate only when resolver.nextCursor is present.",
inputSchema: { project_id: z.string().min(1).describe("ProjectBrain project id or slug"), focus: z.string().trim().max(500).optional(), max_items: z.number().int().min(1).max(25).default(10), cursor: z.string().regex(/^\d+$/).optional(), character_budget: z.number().int().min(4000).max(80000).default(24000) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, focus, max_items, cursor, character_budget }) => withAudit({ actor, tool: "get_project_context", projectId: project_id }, async (link) => {
const context = await resolveReconciledProjectContext(client, link!.projectId, { focus, maxItems: max_items, cursor, characterBudget: character_budget });
return asCompactResult(context as unknown as Record<string, unknown>, `Projectcontext geladen. Volgende actie: ${String((context.resume.nextAction as Record<string, unknown>).type)}.`);
}));
server.registerTool("render_project_workspace", {
title: "Open ProjectBrain workspace",
description: "Render an interactive overview of a project's durable context, repository, pending reviews, and feature-delivery work.",
inputSchema: { project_id: z.string().min(1).describe("ProjectBrain project id or slug") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
_meta: { ui: { resourceUri: PROJECTBRAIN_WORKSPACE_URI }, "openai/outputTemplate": PROJECTBRAIN_WORKSPACE_URI },
}, async ({ project_id }) => withAudit({ actor, tool: "render_project_workspace", projectId: project_id }, async (link) => {
const project = await prisma.project.findUnique({ where: { id: link!.projectId }, select: { id: true, slug: true, name: true, description: true, updatedAt: true } });
const [contextPackCount, deliveries, reviews] = await Promise.all([
prisma.contextPack.count({ where: { projectId: link!.projectId, deletedAt: null } }),
listFeatureDeliveries(project_id),
listReviewItemsFromDatabase(project_id, { status: ["pending"] }, { page: 1, pageSize: 20 }),
]);
return asResult({ projectId: link!.projectSlug, project, repository: link, contextPackCount, featureDeliveries: deliveries.slice(0, 12).map(compactDelivery), pendingReviews: (reviews?.items ?? []).slice(0, 12).map(compactRecord) }, "ProjectBrain workspace");
}));
server.registerTool("get_project_modules", {
title: "Get ProjectBrain module state", description: "Load the current brainstorms, knowledge, documentation, memory rules, context packs, feature plans and pending reviews for a project. Use this to understand what already exists before adding or changing project knowledge.",
inputSchema: { project_id: z.string().min(1), max_items: z.number().int().min(1).max(25).default(10), cursor: z.string().regex(/^\d+$/).default("0") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, max_items, cursor }) => withAudit({ actor, tool: "get_project_modules", projectId: project_id }, async () => {
const [brainstorms, knowledge, documentation, memoryRules, contextPacks, deliveries, reviews] = await Promise.all([
listBrainstormsForProject(project_id),
listKnowledgeForProject(project_id),
listDocumentationForProject(project_id),
listMemoryRulesForProject(project_id),
listContextPacksForProject(project_id),
listFeatureDeliveries(project_id),
listReviewItemsFromDatabase(project_id, { status: ["pending"] }, { page: 1, pageSize: 25 }),
]);
const offset = Number.parseInt(cursor, 10);
const compactPage = (items: unknown[]) => pageCollection(items.map(compactRecord), offset, max_items);
return asCompactResult({ cursor, pageSize: max_items, brainstorms: compactPage(brainstorms), knowledge: compactPage(knowledge), documentation: compactPage(documentation), memoryRules: compactPage(memoryRules), contextPacks: compactPage(contextPacks ?? []), featureDeliveries: compactPage(deliveries), pendingReviews: compactPage(reviews?.items ?? []) }, "Compact, paginated ProjectBrain module inventory");
}));
server.registerTool("start_repository_analysis", {
title: "Start server-side repository analysis",
description: "Use this when a repository or portfolio analysis would otherwise require many synchronous Gitea calls. It queues one durable, bounded server-side job and immediately returns its job id.",
inputSchema: {
project_ids: z.array(z.string().trim().min(1)).max(200).optional(),
repository_ids: z.array(z.string().trim().min(1)).max(200).optional(),
include_branches: z.boolean().default(true),
include_pull_requests: z.boolean().default(true),
include_actions: z.boolean().default(true),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
_meta: { "openai/toolInvocation/invoking": "Analyse in wachtrij plaatsen…", "openai/toolInvocation/invoked": "Analysejob aangemaakt" },
}, async ({ project_ids, repository_ids, include_branches, include_pull_requests, include_actions }) => withAudit({ actor, tool: "start_repository_analysis" }, async () => {
const result = await createPortfolioAnalysisJob(env, actor, { projectIds: project_ids, repositoryIds: repository_ids, includeBranches: include_branches, includePullRequests: include_pull_requests, includeActions: include_actions });
const job = result.job as Record<string, unknown>;
return asCompactResult({ job, deduplicated: result.deduplicated }, `Analysejob ${job.id} staat ${job.status === "queued" ? "in de wachtrij" : "al actief"}. Gebruik get_analysis_job voor de voortgang.`);
}));
server.registerTool("list_analysis_jobs", {
title: "List repository analysis jobs",
description: "Use this to find recent server-side repository-analysis jobs created by the current caller.",
inputSchema: { limit: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ limit }) => withAudit({ actor, tool: "list_analysis_jobs" }, async () => {
const jobs = await listAnalysisJobs(actor, limit);
return asCompactResult({ jobs }, `${jobs.length} analysejob(s) gevonden.`);
}));
server.registerTool("get_analysis_job", {
title: "Get repository analysis status",
description: "Use this to check compact progress for one analysis job. Respect pollAfterMs and repeat only while terminal=false; it never returns per-repository details.",
inputSchema: { job_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ job_id }) => withAudit({ actor, tool: "get_analysis_job" }, async () => {
const job = await getAnalysisJob(actor, job_id) as Record<string, unknown>;
return asCompactResult({ job }, `Analysejob ${job.id}: ${job.status}, ${job.progressCurrent}/${job.progressTotal}.`);
}));
server.registerTool("get_analysis_job_results", {
title: "Get paginated repository analysis results",
description: "Use this only after get_analysis_job reports progress or completion. It returns a bounded page of repository results, never the entire portfolio at once.",
inputSchema: { job_id: z.string().min(1), cursor: z.string().regex(/^\d+$/).optional(), page_size: z.number().int().min(1).max(10).default(5), character_budget: z.number().int().min(4000).max(32000).default(24000) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ job_id, cursor, page_size, character_budget }) => withAudit({ actor, tool: "get_analysis_job_results" }, async () => {
const result = await getAnalysisJobResults(actor, job_id, { cursor, pageSize: page_size, characterBudget: character_budget });
return asCompactResult(result as unknown as Record<string, unknown>, `${result.items.length} begrensde repositoryresultaten geladen.${result.nextCursor ? ` Volgende cursor: ${result.nextCursor}.` : ""}`);
}));
server.registerTool("cancel_analysis_job", {
title: "Cancel repository analysis",
description: "Use this when the user wants to stop a queued or running ProjectBrain repository-analysis job. Completed work remains available until retention expiry.",
inputSchema: { job_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ job_id }) => withAudit({ actor, tool: "cancel_analysis_job" }, async () => {
const job = await cancelAnalysisJob(actor, job_id) as Record<string, unknown>;
return asCompactResult({ job }, `Annulering voor analysejob ${job.id} is verwerkt.`);
}));
server.registerTool("render_analysis_job", {
title: "Open repository analysis progress",
description: "Render the compact progress card for an existing analysis job. Always call start_repository_analysis or get_analysis_job first.",
inputSchema: { job_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
_meta: { ui: { resourceUri: PROJECTBRAIN_ANALYSIS_WIDGET_URI }, "openai/outputTemplate": PROJECTBRAIN_ANALYSIS_WIDGET_URI },
}, async ({ job_id }) => withAudit({ actor, tool: "render_analysis_job" }, async () => {
const job = await getAnalysisJob(actor, job_id) as Record<string, unknown>;
return asCompactResult({ job }, `Voortgangskaart voor analysejob ${job.id}.`);
}));
server.registerTool("list_repository_findings", {
title: "List durable repository findings",
description: "Use this when the user wants new, active, regressed, resolved, or suppressed ProjectBrain findings. Results are risk-ranked and paginated.",
inputSchema: {
project_id: z.string().min(1).optional(),
statuses: z.array(z.enum(["new", "triaged", "issue_open", "investigating", "planned", "fixing", "verifying", "resolved", "regressed", "suppressed"])).max(10).optional(),
severities: z.array(z.enum(["info", "warning", "critical"])).max(3).optional(),
cursor: z.string().regex(/^\d+$/).optional(),
limit: z.number().int().min(1).max(50).default(20),
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, statuses, severities, cursor, limit }) => withAudit({ actor, tool: "list_repository_findings", projectId: project_id }, async () => {
const result = await listRepositoryFindings({ projectId: project_id, statuses, severities, cursor, limit });
return asCompactResult(result as unknown as Record<string, unknown>, `${result.items.length} duurzame bevinding(en) geladen.${result.nextCursor ? ` Volgende cursor: ${result.nextCursor}.` : ""}`);
}));
server.registerTool("get_repository_finding", {
title: "Get repository finding",
description: "Use this before creating an issue, starting an investigation, suppressing a finding, or planning a fix. Returns its lifecycle, occurrences, issue and recent investigations.",
inputSchema: { finding_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ finding_id }) => withAudit({ actor, tool: "get_repository_finding" }, async () => asCompactResult({ finding: await getRepositoryFinding(finding_id) } as unknown as Record<string, unknown>, `Bevinding ${finding_id} geladen.`)));
server.registerTool("create_finding_issue", {
title: "Create or synchronize Gitea issue for finding",
description: "Use this only when the user wants a durable Gitea issue for an existing ProjectBrain finding. The operation is idempotent: retries update or reopen the same marked issue instead of creating duplicates.",
inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
_meta: { "openai/toolInvocation/invoking": "Gitea-issue synchroniseren…", "openai/toolInvocation/invoked": "Gitea-issue gesynchroniseerd" },
}, async ({ project_id, finding_id }) => {
const finding = await assertFindingProject(project_id, finding_id);
return withAudit({ actor, tool: "create_finding_issue", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => {
const result = await syncFindingIssue(env, finding_id, actor);
return asCompactResult({ finding: result.finding, issue: result.issue } as unknown as Record<string, unknown>, `Gitea-issue #${result.issue.number} is gekoppeld aan bevinding ${finding_id}.`);
}, true);
});
server.registerTool("start_finding_investigation", {
title: "Start finding investigation",
description: "Use this when the user asks ChatGPT to investigate a ProjectBrain finding or its Gitea issue. ProjectBrain assembles one bounded evidence pack server-side and reuses an active investigation on retries.",
inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async ({ project_id, finding_id }) => {
const finding = await assertFindingProject(project_id, finding_id);
return withAudit({ actor, tool: "start_finding_investigation", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => {
const investigation = await startFindingInvestigation(env, finding_id, actor);
return asCompactResult({ investigation } as unknown as Record<string, unknown>, `Onderzoek ${investigation.id} is klaar voor analyse.`);
});
});
server.registerTool("get_finding_investigation", {
title: "Get finding investigation",
description: "Use this to load the bounded evidence pack, conclusion and linked delivery for one ProjectBrain finding investigation.",
inputSchema: { investigation_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ investigation_id }) => withAudit({ actor, tool: "get_finding_investigation" }, async () => asCompactResult({ investigation: await getFindingInvestigation(investigation_id) } as unknown as Record<string, unknown>, `Onderzoek ${investigation_id} geladen.`)));
server.registerTool("save_finding_investigation", {
title: "Save finding investigation conclusion",
description: "Use this after examining the evidence pack. Persists a concise diagnosis, root cause, proposed file-level changes, confidence and conclusion without starting repository changes.",
inputSchema: {
project_id: z.string().min(1), investigation_id: z.string().min(1), diagnosis: z.string().trim().min(8).max(12_000), root_cause: z.string().trim().min(8).max(12_000),
proposed_changes: z.array(z.string().trim().min(1).max(2_000)).min(1).max(50), confidence: z.number().int().min(0).max(100), conclusion: z.string().trim().min(8).max(8_000),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, investigation_id, diagnosis, root_cause, proposed_changes, confidence, conclusion }) => {
const investigation = await assertInvestigationProject(project_id, investigation_id);
return withAudit({ actor, tool: "save_finding_investigation", projectId: project_id, repositoryId: investigation.finding.repositoryLinkId ?? undefined }, async () => asCompactResult({ investigation: await saveFindingInvestigationConclusion(investigation_id, { diagnosis, rootCause: root_cause, proposedChanges: proposed_changes, confidence, conclusion }) } as unknown as Record<string, unknown>, `Onderzoeksconclusie ${investigation_id} opgeslagen.`));
});
server.registerTool("suppress_repository_finding", {
title: "Suppress repository finding",
description: "Use this only when the user explicitly accepts or defers a finding. A reason is mandatory and an optional expiry lets future scans reactivate it.",
inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1), reason: z.string().trim().min(3).max(2_000), until: z.string().datetime().optional() },
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, finding_id, reason, until }) => {
const finding = await assertFindingProject(project_id, finding_id);
return withAudit({ actor, tool: "suppress_repository_finding", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => asCompactResult({ finding: await suppressRepositoryFinding(finding_id, reason, until ? new Date(until) : undefined) } as unknown as Record<string, unknown>, `Bevinding ${finding_id} is onderdrukt.`));
});
server.registerTool("save_brainstorm", {
title: "Save ChatGPT brainstorm", description: "Persist an idea exploration in ProjectBrain's Brainstorm module. Use this for meaningful alternatives, trade-offs and next actions; use create_feature_delivery only after the user has chosen a direction.",
inputSchema: {
project_id: z.string().min(1), title: z.string().trim().min(3).max(160), prompt: z.string().trim().min(3).max(8000),
context_summary: z.string().trim().min(3).max(8000), ideas: z.array(z.object({ id: z.string().min(1), title: z.string().min(1), description: z.string().min(1), score: z.number().min(0).max(100).optional() })).max(50).default([]),
outcomes: z.array(z.string().min(1).max(2000)).max(50).default([]), selected_idea_ids: z.array(z.string().min(1)).max(50).default([]), next_actions: z.array(z.string().min(1).max(1000)).max(50).default([]),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, prompt, context_summary, ideas, outcomes, selected_idea_ids, next_actions }) => withAudit({ actor, tool: "save_brainstorm", projectId: project_id }, async () => asResult(await createBrainstormForProject(project_id, {
title, prompt, scope: "project", mode: "chatgpt", status: selected_idea_ids.length ? "selected" : "exploring", generatedBy: "chatgpt_mcp", contextSummary: context_summary,
ideas, outcomes: outcomes.map((summary, index) => ({ id: `outcome-${index + 1}`, summary })), selectedIdeaIds: selected_idea_ids, nextActions: next_actions,
}), "Brainstorm saved in ProjectBrain")));
server.registerTool("save_project_knowledge", {
title: "Save project knowledge", description: "Save a reusable fact, finding or technical explanation to ProjectBrain's Knowledge module. New knowledge remains pending for human review.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), content: z.string().trim().min(3).max(30000), category: z.string().trim().min(1).max(80).default("Technical"), confidence: z.number().int().min(0).max(100).default(75) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, content, category, confidence }) => withAudit({ actor, tool: "save_project_knowledge", projectId: project_id }, async () => asResult(await createKnowledgeItem(project_id, { title, content, category, confidence, source: "ChatGPT via ProjectBrain MCP" }), "Pending knowledge saved")));
server.registerTool("save_documentation_draft", {
title: "Save documentation draft", description: "Write a documentation draft into ProjectBrain. The draft remains pending until the user reviews it in the Documentation module.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), content: z.string().trim().min(3).max(50000), kind: z.string().trim().min(1).max(80).default("design document") },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, content, kind }) => withAudit({ actor, tool: "save_documentation_draft", projectId: project_id }, async () => asResult(await createDocumentationItem(project_id, { title, content, kind, status: "draft", approvalState: "pending" }), "Documentation draft saved")));
server.registerTool("save_memory_rule", {
title: "Save project memory rule", description: "Save an enduring instruction or do-not-break rule in ProjectBrain. New rules remain pending for human review.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), rule: z.string().trim().min(3).max(8000), scope: z.enum(["global", "module", "feature", "prompt_only"]).default("global"), category: z.enum(["UX", "Technical", "Architecture", "Do Not Break", "Naming", "Workflow", "Security"]).default("Technical"), severity: z.enum(["info", "important", "critical", "do_not_break"]).default("important") },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, rule, scope, category, severity }) => withAudit({ actor, tool: "save_memory_rule", projectId: project_id }, async () => asResult(await createMemoryRuleItem(project_id, { title, rule, scope, category, severity, includeInContextPacks: true }), "Pending memory rule saved")));
server.registerTool("create_project_context_pack", {
title: "Create project context pack", description: "Create a fresh ProjectBrain context pack from the project's approved DNA, features, evidence, memory and architecture before a substantial planning or implementation task.",
inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160).optional() },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title }) => withAudit({ actor, tool: "create_project_context_pack", projectId: project_id }, async () => asResult(await createContextPackForProject(project_id, { sourceType: "workspace", title, includeDNA: true, includeFeatures: true, includeEvidence: true, includeMemoryRules: true, includeRisks: true, includeRegressions: true, includeArchitecture: true }), "Context pack created")));
server.registerTool("record_execution_feedback", {
title: "Record execution feedback",
description: "Import the outcome of a completed coding or automation run into ProjectBrain's Review Queue. Feedback stays pending until a human accepts it as project truth.",
inputSchema: {
project_id: z.string().min(1), codex_task_id: z.string().min(1), source_run: z.string().trim().min(1).max(240),
summary: z.string().trim().min(3).max(8000), output_text: z.string().max(100_000).default(""),
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, codex_task_id, source_run, summary, output_text }) => withAudit({ actor, tool: "record_execution_feedback", projectId: project_id }, async () => {
const result = await importCodexOutputInDatabase(project_id, codex_task_id, { sourceRun: source_run, summary, outputText: output_text });
if (!result) throw new Error("PROJECT_NOT_FOUND");
if (result === "CODEX_TASK_NOT_FOUND") throw new Error(result);
return asResult(result, "Execution feedback imported for human review");
}));
server.registerTool("list_feature_deliveries", {
title: "List feature deliveries", description: "Use this to find a delivery id or compact status. Results are paginated summaries; call get_feature_delivery only for the selected delivery's full plan and runtime evidence.",
inputSchema: { project_id: z.string().min(1), cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, cursor, page_size }) => withAudit({ actor, tool: "list_feature_deliveries", projectId: project_id }, async () => {
const deliveries = (await listFeatureDeliveries(project_id)).map(compactDelivery);
const page = pageCollection(deliveries, Number.parseInt(cursor, 10), page_size);
return asCompactResult(page as unknown as Record<string, unknown>, `${page.items.length} deliverysamenvatting(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`);
}));
server.registerTool("get_feature_delivery", {
title: "Get feature delivery", description: "Use this before continuing a feature to load its current scope, authorization state, acceptance criteria, implementation plan, branch, progress, validations, and pull request. If approvalState is approved, ProjectBrain policy has already authorized execution; do not ask for redundant approval.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "get_feature_delivery", projectId: project_id }, async () => {
const delivery = await refreshDeliveryFromGitea(client, project_id, delivery_id);
return asResult({ ...delivery, continuation: deliveryContinuation(delivery) }, "Delivery reconciled with Gitea; follow continuation.");
}));
const featurePlanSchema = {
project_id: z.string().min(1), title: z.string().trim().min(3).max(160), intent: z.string().trim().min(8).max(4000),
priority: z.enum(["critical", "important", "normal", "low"]).default("normal"), problem: z.string().trim().min(8).max(8000),
desired_outcome: z.string().trim().min(8).max(8000), users: z.array(z.string().min(1).max(300)).max(30).default([]),
goals: z.array(z.string().min(1).max(1000)).min(1).max(50), non_goals: z.array(z.string().min(1).max(1000)).max(50).default([]),
acceptance_criteria: z.array(z.string().min(1).max(1000)).min(1).max(100), implementation_plan: z.array(z.string().min(1).max(2000)).min(1).max(100),
risks: z.array(z.string().min(1).max(1000)).max(50).default([]), validation_commands: z.array(z.string().min(1).max(500)).min(1).max(30),
risk_level: z.enum(["low", "medium", "high", "destructive"]).default("medium").describe("Classify execution risk. low/medium reversible work is policy-authorized automatically; high/destructive requires a real user decision before repository writes."),
};
server.registerTool("create_delivery_from_finding", {
title: "Create delivery plan from finding investigation",
description: "Use this only after save_finding_investigation. Creates a reviewable ProjectBrain delivery linked to the finding and investigation; it never approves the plan or starts code changes.",
inputSchema: { investigation_id: z.string().min(1), ...featurePlanSchema },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, investigation_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands }) => {
const investigation = await assertInvestigationProject(project_id, investigation_id);
return withAudit({ actor, tool: "create_delivery_from_finding", projectId: project_id, repositoryId: investigation.finding.repositoryLinkId ?? undefined }, async () => {
const result = await createDeliveryFromFindingInvestigation(investigation_id, {
title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals,
acceptanceCriteria: acceptance_criteria, implementationPlan: implementation_plan, risks, validationCommands: validation_commands,
}, actor);
return asCompactResult(result as unknown as Record<string, unknown>, `Delivery ${result.delivery.id} is aangemaakt en wacht op goedkeuring in ProjectBrain.`);
});
});
server.registerTool("verify_finding_resolution", {
title: "Queue post-merge finding verification",
description: "Use this after the finding-linked pull request is merged. Queues a bounded repository reanalysis; ProjectBrain closes the issue only when the original finding is absent.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "verify_finding_resolution", projectId: project_id, deliveryId: delivery_id }, async () => {
const verification = await queueFindingVerificationForDelivery(env, project_id, delivery_id);
if (!verification) throw new Error("FINDING_VERIFICATION_NOT_AVAILABLE");
return asCompactResult(verification as unknown as Record<string, unknown>, `Gerichte verificatiejob ${(verification.job as Record<string, unknown>).id} staat in de wachtrij.`);
}));
server.registerTool("create_feature_delivery", {
title: "Create feature delivery plan",
description: "Create the bounded execution plan for a user request. Classify risk explicitly. ProjectBrain auto-authorizes low/medium reversible work; high/destructive work remains pending and is surfaced under Jouw aandacht. Creating the plan never writes repository files by itself.",
inputSchema: featurePlanSchema,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands, risk_level }) => withAudit({ actor, tool: "create_feature_delivery", projectId: project_id }, async () => {
const delivery = await createOrResumeAutonomyDelivery(project_id, {
title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals, acceptanceCriteria: acceptance_criteria,
implementationPlan: implementation_plan, risks, validationCommands: validation_commands, riskLevel: risk_level,
}, actor);
return asResult(delivery, delivery.approvalState === "approved"
? "Feature delivery policy-authorized. Continue autonomously with start_feature_delivery; do not ask the user for redundant approval."
: "Feature delivery requires a real user decision. Stop repository writes and surface the decision in ProjectBrain under Jouw aandacht.");
}));
server.registerTool("update_feature_delivery", {
title: "Update feature delivery plan",
description: "Refine an unstarted feature plan and re-evaluate its risk policy. Low/medium reversible work is policy-authorized; high/destructive work returns to a real user-decision state. Active or completed plans are locked.",
inputSchema: { delivery_id: z.string().min(1), ...featurePlanSchema },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands, risk_level }) => withAudit({ actor, tool: "update_feature_delivery", projectId: project_id }, async () => {
const delivery = await updateFeatureDelivery(project_id, delivery_id, {
title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals, acceptanceCriteria: acceptance_criteria,
implementationPlan: implementation_plan, risks, validationCommands: validation_commands, riskLevel: risk_level,
}, actor);
return asResult(delivery, delivery.approvalState === "approved"
? "Feature delivery updated and policy-authorized. Continue autonomously without an extra approval round."
: "Feature delivery updated and now requires a real user decision before repository execution.");
}));
server.registerTool("list_repository_tree", {
title: "List repository tree", description: "List files and directories at a repository path and ref.",
inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), path: z.string().default(""), ref: z.string().optional() },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, repository_id, path, ref }) => withAudit({ actor, tool: "list_repository_tree", projectId: project_id, repositoryId: repository_id, branch: ref }, async (link) => {
const safePath = path ? assertReadablePath(path) : "";
const resolvedRef = ref ?? link!.defaultBranch;
const contents = await client.getContents(link!.owner, link!.repository, safePath, resolvedRef);
const entries = (Array.isArray(contents) ? contents : [contents]).map(({ content: _content, ...entry }) => entry);
return asResult({ projectId: link!.projectSlug, repository: link!.fullName, path: safePath, ref: resolvedRef, entries }, "Repository tree");
}));
server.registerTool("get_file", {
title: "Read repository file", description: "Read a text file from a linked repository at a branch, tag, or commit.",
inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), path: z.string().min(1), ref: z.string().optional() },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, repository_id, path, ref }) => withAudit({ actor, tool: "get_file", projectId: project_id, repositoryId: repository_id, branch: ref }, async (link) => {
const safePath = assertReadablePath(path); const resolvedRef = ref ?? link!.defaultBranch;
const file = await client.getContents(link!.owner, link!.repository, safePath, resolvedRef);
if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
return asResult({ projectId: link!.projectSlug, repository: link!.fullName, path: safePath, ref: resolvedRef, sha: file.sha, size: file.size, content: decodeFile(file), htmlUrl: file.html_url }, "Repository file");
}));
server.registerTool("list_branches", {
title: "List repository branches", description: "List branches and protection status for a linked repository.", inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional() },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, repository_id }) => withAudit({ actor, tool: "list_branches", projectId: project_id, repositoryId: repository_id }, async (link) => asResult({ defaultBranch: link!.defaultBranch, branches: await client.listBranches(link!.owner, link!.repository) }, "Repository branches")));
server.registerTool("list_pull_requests", {
title: "List pull requests", description: "List pull requests for a linked repository.", inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), state: z.enum(["open", "closed", "all"]).default("open") },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, repository_id, state }) => withAudit({ actor, tool: "list_pull_requests", projectId: project_id, repositoryId: repository_id }, async (link) => asResult({ pullRequests: await client.listPullRequests(link!.owner, link!.repository, state) }, "Pull requests")));
server.registerTool("start_feature_delivery", {
title: "Start authorized feature delivery",
description: "Start an isolated non-default delivery branch only when ProjectBrain policy already reports the delivery as approved. Low/medium work can arrive pre-approved automatically; high/destructive work cannot start until its real user decision is recorded. Never ask for a redundant approval when approvalState is already approved.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), repository_id: z.string().min(1).optional(), branch: z.string().min(1).max(300).describe("Unique valid non-default Git branch") },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, delivery_id, repository_id, branch }) => withAudit({ actor, tool: "start_feature_delivery", projectId: project_id, repositoryId: repository_id, deliveryId: delivery_id, branch }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
if (delivery.spec.branch === safeBranch && ["executing", "validating", "pull_request_open", "merged", "verified", "released"].includes(delivery.spec.status)) return asResult(delivery, "Feature delivery already started");
if (delivery.approvalState !== "approved" || delivery.spec.status !== "approved") throw new Error("FEATURE_DELIVERY_APPROVAL_REQUIRED");
const base = await client.getBranch(link!.owner, link!.repository, link!.defaultBranch);
try { await client.createBranch(link!.owner, link!.repository, safeBranch, link!.defaultBranch); }
catch (error) {
if (!(error instanceof GiteaApiError && error.status === 409)) throw error;
await client.getBranch(link!.owner, link!.repository, safeBranch);
}
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
return asResult(await startFeatureDelivery(project_id, delivery_id, safeBranch, actor, { repositoryLinkId: link!.id, baseCommitSha: base.commit.id, headSha: head.commit.id }), "Authorized feature delivery started");
}, true));
server.registerTool("write_feature_file", {
title: "Write approved feature file", description: "Use this to create or update one text file for an active approved feature delivery. The delivery must own the supplied ChatGPT/ProjectBrain branch. Pass the SHA returned by get_file when updating an existing file.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(9), path: z.string().min(1), content: z.string(), message: z.string().trim().min(3).max(200), expected_sha: z.string().min(1).max(200).optional(), progress_note: z.string().max(2000).optional() },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, delivery_id, branch, path, content, message, expected_sha, progress_note }) => withAudit({ actor, tool: "write_feature_file", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!);
const safePath = assertWritablePath(path); assertFileSize(content);
let existing: GiteaContent | undefined;
try { const value = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (!Array.isArray(value)) existing = value; }
catch (error) { if (!(error instanceof GiteaApiError && error.status === 404)) throw error; }
assertExpectedFileSha(expected_sha, existing?.sha);
const result = existing ? await client.updateFile(link!.owner, link!.repository, safePath, safeBranch, expected_sha!, content, message) : await client.createFile(link!.owner, link!.repository, safePath, safeBranch, content, message);
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
let written: GiteaContent | undefined;
try { const value = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (!Array.isArray(value)) written = value; } catch { /* The commit is still tracked by branch head. */ }
const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: [{ path: safePath, operation: existing ? "update" : "create", beforeSha: existing?.sha, afterSha: written?.sha, commitSha: head.commit.id }], note: progress_note, headSha: head.commit.id }, actor);
return asResult({ repository: link!.fullName, branch: safeBranch, path: safePath, action: existing ? "updated" : "created", result, delivery: progress }, "Feature file committed and progress recorded");
}, true));
server.registerTool("delete_feature_file", {
title: "Delete approved feature file", description: "Delete one file from an active approved feature delivery branch and record that change. This is destructive and requires explicit confirmation.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(9), path: z.string().min(1), message: z.string().trim().min(3).max(200), expected_sha: z.string().min(1).max(200), progress_note: z.string().max(2000).optional() },
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
}, async ({ project_id, delivery_id, branch, path, message, expected_sha, progress_note }) => withAudit({ actor, tool: "delete_feature_file", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!);
const safePath = assertWritablePath(path);
const file = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE");
assertExpectedFileSha(expected_sha, file.sha);
const result = await client.deleteFile(link!.owner, link!.repository, safePath, safeBranch, expected_sha, message);
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: [{ path: safePath, operation: "delete", beforeSha: file.sha, commitSha: head.commit.id }], note: progress_note, headSha: head.commit.id }, actor);
return asResult({ repository: link!.fullName, branch: safeBranch, path: safePath, deleted: true, result, delivery: progress }, "Feature file deleted and progress recorded");
}, true));
server.registerTool("apply_feature_changeset", {
title: "Apply approved feature changeset",
description: "Create, update, and delete up to 25 text files for one approved delivery in a single bounded server-side call. Use this instead of repeated file tools for multi-file work. Every path is preflighted against its expected SHA and the exact branch head before the first write.",
inputSchema: {
project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(1),
expected_head_sha: z.string().regex(/^[0-9a-f]{40,64}$/i), message: z.string().trim().min(3).max(200),
operations: z.array(featureChangesetOperationSchema).min(1).max(25), progress_note: z.string().max(2_000).optional(),
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
_meta: { "openai/toolInvocation/invoking": "Gebudgetteerde changeset toepassen…", "openai/toolInvocation/invoked": "Changeset toegepast" },
}, async ({ project_id, delivery_id, branch, expected_head_sha, message, operations, progress_note }) => withAudit({ actor, tool: "apply_feature_changeset", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const safeBranch = assertChatGptBranch(branch, link!.defaultBranch);
await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!);
const paths = assertFeatureChangesetBudget(operations);
const initialHead = await client.getBranch(link!.owner, link!.repository, safeBranch);
if (initialHead.commit.id !== expected_head_sha) throw new Error("FEATURE_CHANGESET_HEAD_CONFLICT");
const preflight = await mapBounded(operations, 4, async (operation, index) => {
let current: GiteaContent | undefined;
try {
const value = await client.getContents(link!.owner, link!.repository, paths[index], safeBranch);
if (Array.isArray(value)) throw new Error("GITEA_CONTENT_NOT_FILE");
current = value;
} catch (error) {
if (!(error instanceof GiteaApiError && error.status === 404)) throw error;
}
if (operation.operation === "create") assertExpectedFileSha(undefined, current?.sha);
else assertExpectedFileSha(operation.expected_sha, current?.sha);
return current;
});
const preflightHead = await client.getBranch(link!.owner, link!.repository, safeBranch);
if (preflightHead.commit.id !== expected_head_sha) throw new Error("FEATURE_CHANGESET_HEAD_CONFLICT");
const appliedChanges: Array<{ path: string; operation: "create" | "update" | "delete"; beforeSha?: string; afterSha?: string; commitSha?: string }> = [];
try {
for (let index = 0; index < operations.length; index += 1) {
const operation = operations[index]; const path = paths[index]; const before = preflight[index];
if (operation.operation === "create") await client.createFile(link!.owner, link!.repository, path, safeBranch, operation.content, message);
else if (operation.operation === "update") await client.updateFile(link!.owner, link!.repository, path, safeBranch, operation.expected_sha, operation.content, message);
else await client.deleteFile(link!.owner, link!.repository, path, safeBranch, operation.expected_sha, message);
const head = await client.getBranch(link!.owner, link!.repository, safeBranch);
let afterSha: string | undefined;
if (operation.operation !== "delete") {
const written = await client.getContents(link!.owner, link!.repository, path, safeBranch);
if (!Array.isArray(written)) afterSha = written.sha;
}
appliedChanges.push({ path, operation: operation.operation, beforeSha: before?.sha, afterSha, commitSha: head.commit.id });
}
} catch {
if (appliedChanges.length) {
const partialHead = await client.getBranch(link!.owner, link!.repository, safeBranch).catch(() => undefined);
await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: appliedChanges, note: `Partial changeset: ${appliedChanges.length}/${operations.length} operations applied before a remote failure.`, headSha: partialHead?.commit.id }, actor).catch(() => undefined);
throw new PartialExternalMutationError("FEATURE_CHANGESET_PARTIAL");
}
throw new Error("FEATURE_CHANGESET_APPLY_FAILED");
}
const finalHead = await client.getBranch(link!.owner, link!.repository, safeBranch);
const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: appliedChanges, note: progress_note, headSha: finalHead.commit.id }, actor);
return asCompactResult({ repository: link!.fullName, deliveryId: progress.id, branch: safeBranch, previousHeadSha: initialHead.commit.id, headSha: finalHead.commit.id, applied: appliedChanges.map(({ path, operation }) => ({ path, operation })) }, `${appliedChanges.length} bestandsbewerkingen server-side toegepast op ${safeBranch}.`);
}, true));
server.registerTool("record_feature_validation", {
title: "Record reported feature validation", description: "Store reported, failed, or not-run checks for an active feature. Reported results are useful progress evidence but cannot authorize a pull request; use sync_feature_validation to verify a successful Gitea Actions run for the current branch head.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), results: z.array(z.object({ command: z.string().min(1).max(500), status: z.enum(["passed", "failed", "not_run"]), summary: z.string().max(2000).optional() })).min(1).max(30), note: z.string().max(4000).optional(), ready_for_review: z.boolean().default(false) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id, results, note, ready_for_review }) => withAudit({ actor, tool: "record_feature_validation", projectId: project_id }, async () => asResult(await recordFeatureDeliveryProgress(project_id, delivery_id, { validationResults: results, note, readyForReview: ready_for_review }, actor), "Feature validation recorded")));
server.registerTool("get_feature_validation_runs", {
title: "Get feature validation runs", description: "Inspect Gitea Actions runs for the active feature branch and identify whether the current branch head has completed successfully.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "get_feature_validation_runs", projectId: project_id, deliveryId: delivery_id }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
if (!delivery.spec.branch) throw new Error("FEATURE_DELIVERY_NOT_STARTED");
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
const head = await client.getBranch(link!.owner, link!.repository, branch);
const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
const runs = exactHeadActionRuns(response.workflow_runs, branch, head.commit.id);
let successful = true;
try { assertSuccessfulActionRuns(runs, branch, head.commit.id); } catch { successful = false; }
return asResult({ branch, headSha: head.commit.id, runs, successful }, "Feature validation runs");
}));
server.registerTool("sync_feature_validation", {
title: "Verify feature with Gitea Actions", description: "Verify and persist a successful Gitea Actions run for the exact current feature branch head. This authorizes opening a pull request and refreshes evidence after an open pull request branch changes.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "sync_feature_validation", projectId: project_id, deliveryId: delivery_id }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
if (!delivery.spec.branch || delivery.approvalState !== "approved" || !canSyncFeatureDeliveryValidation(delivery.spec.status)) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN");
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
const head = await client.getBranch(link!.owner, link!.repository, branch);
const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
const verifiedRuns = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id);
const run = verifiedRuns.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest);
const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() };
return asResult({ run, delivery: await recordFeatureDeliveryActionValidation(project_id, delivery_id, evidence, actor) }, "Gitea Actions validation verified and persisted");
}, true));
server.registerTool("complete_feature_delivery", {
title: "Open feature pull request", description: "Use this after an approved feature delivery is implemented and its validation results are recorded. Opens the pull request and links it back to the originating feature plan in ProjectBrain.",
inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), title: z.string().trim().min(3).max(200), body: z.string().max(20_000).default("") },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}, async ({ project_id, delivery_id, title, body }) => withAudit({ actor, tool: "complete_feature_delivery", projectId: project_id, deliveryId: delivery_id }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
if (!delivery.spec.branch || delivery.approvalState !== "approved") throw new Error("FEATURE_DELIVERY_NOT_STARTED");
if (delivery.spec.pullRequest) return asResult(delivery, "Feature delivery already has a pull request");
if (delivery.spec.validationResults.some((result) => result.status === "failed")) throw new Error("FEATURE_DELIVERY_VALIDATION_FAILED");
// Opening review needs verified exact-head Actions evidence below. Planned
// PR checks cannot have run before the PR exists, and a dispatch must never
// manufacture passed results for those commands. Merge still uses Gitea's
// separate protected-check gate; reported failures continue to block review.
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
const head = await client.getBranch(link!.owner, link!.repository, branch);
assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, head.commit.id);
const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 });
assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, head.commit.id);
const existing = findReusableDeliveryPullRequest(await client.listPullRequests(link!.owner, link!.repository, "all"), branch, link!.defaultBranch, head.commit.id);
const pullRequest = existing ?? await client.createPullRequest(link!.owner, link!.repository, { title, body, head: branch, base: link!.defaultBranch });
let updated = await openFeatureDeliveryPullRequest(project_id, delivery_id, { number: pullRequest.number, url: pullRequest.html_url, title: pullRequest.title }, actor);
if (pullRequest.merged) updated = await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: pullRequest.number, state: "closed", merged: true, mergeCommitSha: pullRequest.merge_commit_sha, mergedAt: pullRequest.merged_at, headSha: pullRequest.head.sha, actor }) ?? updated;
return asResult({ repository: link!.fullName, pullRequest, delivery: updated }, pullRequest.merged ? "Existing merged pull request reconciled with delivery" : "Feature pull request created; delivery is awaiting merge");
}, true));
server.registerTool("merge_feature_delivery", {
title: "Merge validated feature delivery",
description: "Merge an open ProjectBrain delivery pull request after exact-head and Gitea Actions verification, reconcile the durable delivery, queue finding verification, and optionally remove the merged work branch. This never writes directly to the default branch outside the reviewed pull request.",
inputSchema: {
project_id: z.string().min(1), delivery_id: z.string().min(1), expected_head_sha: z.string().regex(/^[0-9a-f]{40,64}$/i),
method: z.enum(["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"]).default("squash"),
delete_branch: z.boolean().default(true),
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
_meta: { "openai/toolInvocation/invoking": "Gevalideerde delivery mergen…", "openai/toolInvocation/invoked": "Delivery gemerged" },
}, async ({ project_id, delivery_id, expected_head_sha, method, delete_branch }) => withAudit({ actor, tool: "merge_feature_delivery", projectId: project_id, deliveryId: delivery_id }, async (link) => {
const delivery = await getFeatureDelivery(project_id, delivery_id);
const pullRequestNumber = delivery.spec.pullRequest?.number;
if (!pullRequestNumber || !delivery.spec.branch || delivery.approvalState !== "approved") throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN");
const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch);
const pullRequest = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber);
if (pullRequest.head.ref !== branch || pullRequest.base.ref !== link!.defaultBranch || pullRequest.head.sha !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
const cleanupBranch = () => cleanupDeliveryBranch(client, link!, branch, expected_head_sha, delete_branch);
if (pullRequest.merged) {
if (!["merged", "verified", "released"].includes(delivery.spec.status)) await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: pullRequest.number, state: "closed", merged: true, mergedAt: pullRequest.merged_at, mergeCommitSha: pullRequest.merge_commit_sha, headSha: pullRequest.head.sha, actor });
await queueFindingVerificationForDelivery(env, project_id, delivery_id).catch(() => undefined);
const cleanup = await cleanupBranch();
return asCompactResult({ repository: link!.fullName, deliveryId: delivery.id, pullRequest: { number: pullRequest.number, url: pullRequest.html_url, mergeCommitSha: pullRequest.merge_commit_sha }, branch, alreadyMerged: true, ...cleanup }, `PR #${pullRequest.number} was al gemerged.${cleanup.branchDeleted ? " De werkbranch is opgeruimd." : ""}`);
}
if (delivery.spec.status !== "pull_request_open") throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN");
if (pullRequest.merged || pullRequest.state !== "open" || pullRequest.draft === true || pullRequest.mergeable === false) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_READY");
const head = await client.getBranch(link!.owner, link!.repository, branch);
if (head.commit.id !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, expected_head_sha);
const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: expected_head_sha, limit: 30 });
assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, expected_head_sha);
await client.waitForRequiredPullRequestChecks(link!.owner, link!.repository, link!.defaultBranch, expected_head_sha);
const mergeReadyPullRequest = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber);
const mergeReadyHead = await client.getBranch(link!.owner, link!.repository, branch);
if (mergeReadyPullRequest.state !== "open" || mergeReadyPullRequest.merged || mergeReadyPullRequest.head.sha !== expected_head_sha || mergeReadyPullRequest.base.ref !== link!.defaultBranch || mergeReadyHead.commit.id !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT");
await client.mergePullRequest(link!.owner, link!.repository, pullRequestNumber, { method, headCommitId: expected_head_sha });
const merged = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber);
if (!merged.merged) throw new PartialExternalMutationError("FEATURE_DELIVERY_MERGE_UNCONFIRMED");
const reconciled = await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: merged.number, state: "closed", merged: true, mergedAt: merged.merged_at, mergeCommitSha: merged.merge_commit_sha, headSha: merged.head.sha, actor });
const verification = await queueFindingVerificationForDelivery(env, project_id, delivery_id).catch(() => undefined);
const { branchDeleted, branchCleanupError } = await cleanupBranch();
return asCompactResult({ repository: link!.fullName, deliveryId: reconciled?.id ?? delivery_id, pullRequest: { number: merged.number, url: merged.html_url, mergeCommitSha: merged.merge_commit_sha }, mergeMethod: method, branch, branchDeleted, ...(branchCleanupError ? { branchCleanupError } : {}), verificationQueued: Boolean(verification) }, `PR #${merged.number} is via ${method} gemerged.${branchDeleted ? " De werkbranch is opgeruimd." : branchCleanupError ? " De merge slaagde; branchopruiming kan afzonderlijk worden herhaald." : ""}`);
}, true));
return server;
};
+72
View File
@@ -0,0 +1,72 @@
import { createHash } from "node:crypto";
const EXTENSION_ID = "io.modelcontextprotocol/skills";
const SERVER_SLUG = "projectbrain";
type SkillDefinition = { name: string; description: string; body: string };
const definitions: SkillDefinition[] = [
{
name: "projectbrain-operator",
description: "Operate Auto Mode with ProjectBrain as controller and Gitea as the canonical repository execution and evidence surface.",
body: `# ProjectBrain operator\n\n1. Start with projectbrain.project.list and projectbrain.context.get when the ProjectBrain connector is available. Durable ProjectBrain state and repository planning files, not chat history, are the source of truth.\n2. ProjectBrain is the controller: it owns Auto policy, roadmap state, dependencies, recovery and human-attention decisions. Gitea is the only primary repository execution surface. ChatGPT may orchestrate and reason, but code changes must travel through governed Gitea branches, validation, pull requests and merge evidence. No OpenAI API key is required for this operating model.\n3. For implementation work, use the canonical branch returned as work.execution.branch. When working directly from repository planning truth without a ProjectBrain claim call, derive the same branch as projectbrain/auto/<normalized-work-id>: lowercase the durable task id and replace non alphanumeric, dot, underscore or hyphen runs with hyphens. The existence of that branch is the atomic execution claim. If it already exists, resume it; never create a competing branch for the same task.\n4. Load repository context from the current default branch, then resume or create the canonical non-default work branch. Use the Gitea workspace changeset flow for edits, run the narrowest managed validation profile and a full profile before merge when required, open or reuse the PR, confirm exact-head validation evidence, review the diff, and merge the exact validated head. Direct default-branch writes remain forbidden.\n5. An open PR is unfinished. A work item is implementation-complete only when its canonical PR is merged and the exact PR head has successful required Gitea Actions evidence. ProjectBrain then reconciles that evidence and downstream state back into Auto Mode.\n6. heartbeat_auto_executor, claim_auto_work and report_auto_work remain backward-compatible controller coordination tools. They are not the repository correctness authority. A missing or interrupted report_auto_work callback must never invalidate a valid Gitea branch/PR/validation/merge chain. When a compatibility lease is present, reuse it; after Gitea reconciliation a late report is an idempotent replay.\n7. For brainstorm or phase-review work, inspect ProjectBrain context plus repository roadmap/backlog, findings and evidence. Persist actionable implementation work into repository planning truth or ProjectBrain durable tasks, then execute resulting code work through the same canonical Gitea path. Do not invent routine approvals.\n8. Treat transient runner, Gitea, network and provider failures as retryable. Escalate to human attention only for credentials, policy authorization, destructive risk or a genuine product decision. Local models such as Ollama are optional helpers when explicitly permitted; they do not change the Gitea execution contract.\n`,
},
{
name: "repository-bootstrap",
description: "Create a branch-ready Gitea repository, onboard it into ProjectBrain immediately, and verify its governed workspace without manual catalog changes.",
body: `# Repository bootstrap\n\n1. Call gitea.repository.create with an explicit name, description, private value, autoInit=true and defaultBranch=main. Reuse the returned repositoryRef; never guess one.\n2. Immediately call projectbrain.portfolio.repository.reconcile for only the returned owner/repository with apply=true. Production also auto-onboards future repositories every fifteen minutes, so a retry is safe and idempotent.\n3. Verify the link with projectbrain.portfolio.repository.list or projectbrain.repository.list and load projectbrain.context.get before planning work.\n4. Inspect gitea.branchprotection.get and use the preview/ensure pair when governance is missing.\n5. Continue changes only on an isolated non-default branch and report the repository URL, repositoryRef, ProjectBrain project and write mode.\n`,
},
{
name: "feature-delivery",
description: "Plan, implement, validate, review, and merge repository work through ProjectBrain's governed feature-delivery loop.",
body: `# Feature delivery\n\n1. Load projectbrain.delivery.get when resuming; durable state, not chat history, is the source of truth.\n2. Create a complete plan. ProjectBrain auto-authorizes owner-controlled MCP delivery plans, so continue directly to start or resume the valid isolated non-default delivery branch instead of asking for routine plan approval.\n3. Apply up to 25 create/update/delete operations atomically with projectbrain.delivery.changeset.apply. Refresh the branch head and file SHAs before every changeset and retry only after re-reading a conflict.\n4. Resume through gitea.workspace.status. Dispatch the narrowest fixed validation profile with gitea.workspace.run and use full before merge when supported. Respect pollAfterMs and poll only while terminal=false; after a transient runner failure, dispatch the same managed profile again instead of using the Gitea UI. Never stream logs into the browser unless diagnosing one selected failed job.\n5. Open or update the pull request, synchronize exact-head validation evidence, and inspect gitea.change.review.\n6. When completion/merge is already part of the user's instruction or an active Auto Mode roadmap, call projectbrain.delivery.merge with the exact head SHA after validation. It is idempotent across retries, rechecks Actions evidence, reconciles ProjectBrain and can clean the merged branch.\n7. Import a compact final execution summary as durable feedback and report changed paths, validation evidence, PR URL and merge state.\n`,
},
{
name: "portfolio-curator",
description: "Work consistently across the complete ProjectBrain/Gitea portfolio while keeping repository-specific context isolated.",
body: `# Portfolio curator\n\n1. Start with projectbrain.portfolio.get. Reconcile newly discovered repositories immediately when the user wants them managed; automatic onboarding is the fallback.\n2. Run broad inspection as a persistent projectbrain.analysis.start job. Respect status.pollAfterMs and poll only while terminal=false, then page projectbrain.analysis.results instead of loading repository trees or logs into the browser.\n3. Use search and fetch only for focused cross-project discovery, then load the selected project's context.\n4. Keep findings attributed to their project, repository, ref, path and SHA. Prefer bounded batches and summarize partial failures instead of silently dropping them.\n5. Sync actionable findings to Gitea issues, investigate them, and create auto-authorized owner deliveries through the focused closed loop. Store reusable conclusions in the correct ProjectBrain project for review.\n`,
},
];
const resources = new Map<string, { uri: string; mimeType: string; text: string; digest: string }>();
const skills = definitions.map(({ name, description, body }) => {
const frontmatter = { name, description };
const text = `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}`;
const uri = `skill://${SERVER_SLUG}/${name}/SKILL.md`;
const digest = `sha256:${createHash("sha256").update(text).digest("hex")}`;
resources.set(uri, { uri, mimeType: "text/markdown", text, digest });
return { uri, frontmatter, resources: [{ uri, digest }] };
});
export const projectBrainSkills = skills;
export const handleProjectBrainSkillMethod = (method: string, params: Record<string, unknown>) => {
if (method === "skills/list") {
if (params.cursor !== undefined && params.cursor !== null) throw new Error("MCP_SKILLS_CURSOR_INVALID");
return { skills };
}
if (method === "skills/get") {
const skill = skills.find((item) => item.uri === params.uri);
if (!skill) throw new Error("MCP_SKILL_NOT_FOUND");
return { skill };
}
if (method === "resources/read" && typeof params.uri === "string" && params.uri.startsWith("skill://")) {
const resource = resources.get(params.uri);
if (!resource) throw new Error("MCP_SKILL_RESOURCE_NOT_FOUND");
return { contents: [{ uri: resource.uri, mimeType: resource.mimeType, text: resource.text }] };
}
return undefined;
};
export const augmentProjectBrainInitialize = (payload: unknown) => {
if (!payload || typeof payload !== "object") return payload;
const document = payload as { result?: { capabilities?: Record<string, unknown>; instructions?: string } };
if (!document.result || typeof document.result !== "object") return payload;
const capabilities = document.result.capabilities ?? {};
const extensions = capabilities.extensions && typeof capabilities.extensions === "object" ? capabilities.extensions as Record<string, unknown> : {};
extensions[EXTENSION_ID] ??= {};
document.result.capabilities = { ...capabilities, extensions };
const autoInstruction = "ProjectBrain Auto Mode uses Gitea as its only primary repository execution surface. ProjectBrain remains the durable controller for roadmap, dependencies, recovery and human decisions; ChatGPT may orchestrate reasoning, but implementation changes must use governed Gitea branches, exact-head validation, pull requests and merge. For an Auto implementation item, use work.execution.branch when available or the deterministic projectbrain/auto/<normalized-work-id> branch when working directly from repository planning truth. Branch existence is the atomic claim: resume an existing branch instead of creating a duplicate. Completion is reconciled from the canonical PR, successful exact-head Gitea Actions evidence, its merge commit and downstream ProjectBrain state. heartbeat_auto_executor, claim_auto_work and report_auto_work are backward-compatible coordination/bookkeeping surfaces only; missing report_auto_work must never make a valid Gitea execution appear incomplete. Never write directly to the default branch, bypass validation, or create parallel branches for one durable work id.";
document.result.instructions = document.result.instructions ? `${document.result.instructions}\n\n${autoInstruction}` : autoInstruction;
return document;
};
+15
View File
@@ -0,0 +1,15 @@
// The resource URI is the host-side cache key. Bump it whenever the widget
// document or its MCP Apps contract changes so ChatGPT cannot reuse stale HTML.
export const PROJECTBRAIN_WORKSPACE_URI = "ui://projectbrain/workspace-v2.html";
export const PROJECTBRAIN_WORKSPACE_MIME_TYPE = "text/html;profile=mcp-app";
export const PROJECTBRAIN_WORKSPACE_HTML = String.raw`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ProjectBrain workspace</title><style>
:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;--fg:#202124}:root[data-theme="dark"]{--fg:#edf1f7;color-scheme:dark}:root[data-theme="light"]{--fg:#202124;color-scheme:light}*{box-sizing:border-box}body{margin:0;padding:14px;background:transparent;color:var(--fg)}main{display:grid;gap:12px}.top{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}h1{font-size:17px;margin:0 0 4px}.muted{opacity:.66;font-size:12px}.pill{border-radius:999px;padding:5px 9px;font-size:12px;font-weight:650;background:#daf3df;color:#145c26}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(145px,1fr));gap:9px}.card{border:1px solid color-mix(in srgb,currentColor 18%,transparent);border-radius:10px;padding:10px;min-width:0}.label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;opacity:.62}.value{font-size:13px;font-weight:600;margin-top:4px;overflow-wrap:anywhere}ul{list-style:none;margin:6px 0 0;padding:0;display:grid;gap:5px}li{display:flex;justify-content:space-between;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid color-mix(in srgb,currentColor 10%,transparent)}li:last-child{border:0}button{font:inherit;border:1px solid color-mix(in srgb,currentColor 25%,transparent);background:transparent;color:inherit;border-radius:8px;padding:7px 10px;cursor:pointer}button:disabled{opacity:.45}.actions{display:flex;gap:7px;flex-wrap:wrap}.error{color:#a12626;font-size:12px}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--fg:#edf1f7}.pill{background:#183f23;color:#b9f1c5}}
</style></head><body><main id="root" aria-live="polite"><div class="muted">ProjectBrain laden…</div></main><script>
(()=>{const root=document.getElementById('root');let state=null,nextId=1;const pending=new Map();const nl=(document.documentElement.lang||navigator.language||'nl').toLowerCase().startsWith('nl');const t=(en,nlText)=>nl?nlText:en;const node=(tag,value='',cls='')=>{const e=document.createElement(tag);e.textContent=value??'';if(cls)e.className=cls;return e};const card=(label,value)=>{const e=node('div','','card');e.append(node('div',label,'label'),node('div',value??'—','value'));return e};const unwrap=v=>v?.structuredContent?.data??v?.structuredContent??v?.data??v?.toolOutput?.structuredContent?.data??v?.toolOutput?.data??v;
function request(method,params){const id=nextId++;window.parent.postMessage({jsonrpc:'2.0',id,method,params},'*');return new Promise((resolve,reject)=>{const timeout=setTimeout(()=>{pending.delete(id);reject(new Error('MCP Apps bridge timeout'))},5000);pending.set(id,{resolve,reject,timeout})})}function theme(value){if(value==='dark'||value==='light')document.documentElement.dataset.theme=value}
function render(raw){const data=unwrap(raw);if(!data||typeof data!=='object'){root.replaceChildren(node('div',t('No workspace data returned.','Geen werkruimtegegevens ontvangen.'),'muted'));return}state=data;const project=data.project||{};const repo=data.repository||{};const deliveries=Array.isArray(data.featureDeliveries)?data.featureDeliveries:[];const reviews=Array.isArray(data.pendingReviews)?data.pendingReviews:[];const heading=node('div');heading.append(node('h1',project.name||project.title||project.slug||data.projectId||'ProjectBrain'),node('div',repo.fullName||t('Project memory and delivery','Projectgeheugen en uitvoering'),'muted'));const top=node('section','','top');top.append(heading,node('span',t('Ready','Gereed'),'pill'));const grid=node('section','','grid');grid.append(card(t('Deliveries','Uitvoeringen'),String(deliveries.length)),card(t('Pending review','Te beoordelen'),String(reviews.length)),card(t('Context packs','Contextpakketten'),String(data.contextPackCount??0)),card(t('Repository','Repository'),repo.defaultBranch||'—'));const detail=node('section','','card');detail.append(node('div',t('Active delivery work','Actieve uitvoering'),'label'));const list=node('ul');deliveries.slice(0,12).forEach(item=>{const row=node('li');row.append(node('span',item.title||item.id),node('span',item.spec?.status||item.status||'','muted'));list.append(row)});detail.append(deliveries.length?list:node('div',t('No feature delivery is active.','Geen feature-uitvoering actief.'),'muted'));const actions=node('section','','actions');const refresh=node('button',t('Refresh context','Context vernieuwen'));refresh.onclick=async()=>{refresh.disabled=true;const args={project_id:data.projectId||project.slug};try{let result;try{result=await request('tools/call',{name:'projectbrain.workspace.render',arguments:args})}catch(first){try{result=await request('tools/call',{name:'render_project_workspace',arguments:args})}catch(second){if(!window.openai?.callTool)throw second;try{result=await window.openai.callTool('projectbrain.workspace.render',args)}catch{result=await window.openai.callTool('render_project_workspace',args)}}}render(result)}catch(e){root.append(node('div',String(e?.message||e),'error'))}finally{refresh.disabled=false}};actions.append(refresh);root.replaceChildren(top,grid,detail,actions);window.openai?.setWidgetState?.({projectId:data.projectId||project.slug||null,deliveryCount:deliveries.length})}
window.addEventListener('message',event=>{if(event.source!==window.parent)return;const message=event.data;if(!message||message.jsonrpc!=='2.0')return;if(message.id!==undefined&&pending.has(message.id)){const item=pending.get(message.id);pending.delete(message.id);clearTimeout(item.timeout);message.error?item.reject(message.error):item.resolve(message.result);return}if(message.method==='ui/notifications/tool-result')render(message.params?.structuredContent)},{passive:true});window.addEventListener('openai:set_globals',event=>{theme(event.detail?.globals?.theme);render(event.detail?.globals?.toolOutput)});theme(window.openai?.theme);render(window.openai?.toolOutput)})();
</script></body></html>`;
+52
View File
@@ -0,0 +1,52 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import type { AppEnv } from "../config/env.js";
import type { AuthUser } from "../security/authStore.js";
import { canAccessProject, resolveSession } from "../security/authStore.js";
import { errorEnvelope } from "../utils/envelope.js";
import { getRequestId } from "./requestId.js";
declare module "fastify" {
interface FastifyRequest {
authUser?: AuthUser;
authCsrfToken?: string;
}
}
export const SESSION_COOKIE = "projectbrain_session";
export const sessionCookieOptions = (env: AppEnv) => ({
// OIDC returns through a cross-site top-level navigation. Lax keeps that
// callback usable while the separate CSRF token still protects mutations.
path: "/api", httpOnly: true, secure: env.NODE_ENV === "production", sameSite: "lax" as const,
signed: true, maxAge: env.PROJECTBRAIN_SESSION_TTL_HOURS * 60 * 60,
});
const PUBLIC_PATHS = new Set(["/api/health", "/api/system/readiness", "/api/metrics", "/api/auth/login", "/api/auth/entra/config", "/api/auth/entra/start", "/api/auth/entra/callback", "/api/integrations/gitea/webhook", "/mcp", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp", "/oauth/register", "/oauth/authorize", "/oauth/token"]);
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const firstValue = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value;
export const authenticationPlugin = async (app: FastifyInstance, env: AppEnv) => {
app.decorateRequest("authUser", undefined);
app.decorateRequest("authCsrfToken", undefined);
app.addHook("onRequest", async (request, reply) => {
const path = request.url.split("?")[0];
if (request.method === "OPTIONS" || PUBLIC_PATHS.has(path)) return;
const signedToken = request.cookies[SESSION_COOKIE];
const unsigned = signedToken ? request.unsignCookie(signedToken) : undefined;
const token = unsigned?.valid ? unsigned.value : undefined;
const session = token ? await resolveSession(env, token) : undefined;
if (!session) {
return reply.status(401).send(errorEnvelope("AUTHENTICATION_REQUIRED", "Authentication required", getRequestId(request)));
}
// Renew both the server-side expiry and browser cookie while the user is active.
reply.setCookie(SESSION_COOKIE, token!, sessionCookieOptions(env));
request.authUser = session.user;
request.authCsrfToken = session.csrfToken;
if (MUTATING.has(request.method) && firstValue(request.headers["x-csrf-token"]) !== session.csrfToken) {
return reply.status(403).send(errorEnvelope("CSRF_TOKEN_INVALID", "A valid CSRF token is required", getRequestId(request)));
}
const match = path.match(/^\/api\/projects\/([^/]+)/);
if (match && !(await canAccessProject(env, session.user, decodeURIComponent(match[1]), MUTATING.has(request.method)))) {
return reply.status(403).send(errorEnvelope("PROJECT_ACCESS_DENIED", "Project access denied", getRequestId(request)));
}
});
};
+72
View File
@@ -0,0 +1,72 @@
import type { FastifyInstance } from "fastify";
import { getRequestId } from "./requestId.js";
import { errorEnvelope } from "../utils/envelope.js";
type NormalizedError = {
code?: string;
message: string;
statusCode?: number;
};
const normalizeError = (error: unknown): NormalizedError => {
if (error instanceof Error) {
const maybeFastifyError = error as Error & {
code?: string;
statusCode?: number;
};
return {
code: maybeFastifyError.code,
message: maybeFastifyError.message,
statusCode: maybeFastifyError.statusCode,
};
}
return {
message: "Internal server error",
};
};
export const errorHandlerPlugin = async (app: FastifyInstance) => {
app.setErrorHandler((error, request, reply) => {
const normalizedError = normalizeError(error);
const requestId = getRequestId(request);
const statusCode =
normalizedError.statusCode &&
normalizedError.statusCode >= 400 &&
normalizedError.statusCode < 600
? normalizedError.statusCode
: 500;
request.log.error({ err: error, requestId }, "Request failed");
if (statusCode >= 500) {
reply
.status(statusCode)
.send(
errorEnvelope(
"INTERNAL_SERVER_ERROR",
"Internal server error",
requestId,
),
);
return;
}
reply
.status(statusCode)
.send(
errorEnvelope(
normalizedError.code ?? "REQUEST_ERROR",
normalizedError.message,
requestId,
),
);
});
app.setNotFoundHandler((request, reply) => {
reply
.status(404)
.send(errorEnvelope("ROUTE_NOT_FOUND", "Route not found", getRequestId(request)));
});
};
+73
View File
@@ -0,0 +1,73 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import { performance } from "node:perf_hooks";
declare module "fastify" {
interface FastifyRequest {
observationStartedAt: number;
operationId?: string;
}
}
type RouteMetric = { count: number; errors: number; durationMs: number; maxDurationMs: number };
const routeMetrics = new Map<string, RouteMetric>();
const safeSegment = (value: unknown): string | undefined =>
typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : undefined;
const routeLabel = (request: FastifyRequest): string =>
(request.routeOptions?.url ?? request.url.split("?")[0] ?? "unknown").replaceAll('"', "");
export const observabilityPlugin = async (app: FastifyInstance) => {
app.addHook("onRequest", async (request) => {
request.observationStartedAt = performance.now();
request.operationId = safeSegment(request.headers["x-operation-id"] ?? request.headers["idempotency-key"]);
});
app.addHook("onResponse", async (request, reply) => {
if (request.url.startsWith("/api/metrics")) return;
const durationMs = Math.max(0, performance.now() - request.observationStartedAt);
const route = routeLabel(request);
const key = `${request.method} ${route}`;
const current = routeMetrics.get(key) ?? { count: 0, errors: 0, durationMs: 0, maxDurationMs: 0 };
current.count += 1;
current.errors += reply.statusCode >= 500 ? 1 : 0;
current.durationMs += durationMs;
current.maxDurationMs = Math.max(current.maxDurationMs, durationMs);
routeMetrics.set(key, current);
const params = request.params as { projectId?: unknown } | undefined;
request.log.info({
event: "http_request_completed",
requestId: request.requestId,
operationId: request.operationId,
projectId: safeSegment(params?.projectId),
method: request.method,
route,
statusCode: reply.statusCode,
durationMs: Number(durationMs.toFixed(2)),
}, "Request completed");
});
};
export const renderHttpMetrics = (): string => {
const lines = [
"# HELP projectbrain_http_requests_total Completed HTTP requests.",
"# TYPE projectbrain_http_requests_total counter",
"# HELP projectbrain_http_errors_total Completed HTTP requests with a 5xx response.",
"# TYPE projectbrain_http_errors_total counter",
"# HELP projectbrain_http_request_duration_ms_sum Sum of request duration in milliseconds.",
"# TYPE projectbrain_http_request_duration_ms_sum counter",
"# HELP projectbrain_http_request_duration_ms_max Maximum observed request duration in milliseconds.",
"# TYPE projectbrain_http_request_duration_ms_max gauge",
];
for (const [key, metric] of [...routeMetrics.entries()].sort(([left], [right]) => left.localeCompare(right))) {
const separator = key.indexOf(" ");
const method = key.slice(0, separator);
const route = key.slice(separator + 1);
const labels = `method="${method}",route="${route}"`;
lines.push(`projectbrain_http_requests_total{${labels}} ${metric.count}`);
lines.push(`projectbrain_http_errors_total{${labels}} ${metric.errors}`);
lines.push(`projectbrain_http_request_duration_ms_sum{${labels}} ${metric.durationMs.toFixed(3)}`);
lines.push(`projectbrain_http_request_duration_ms_max{${labels}} ${metric.maxDurationMs.toFixed(3)}`);
}
return `${lines.join("\n")}\n`;
};
+32
View File
@@ -0,0 +1,32 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import { randomUUID } from "node:crypto";
declare module "fastify" {
interface FastifyRequest {
requestId: string;
}
}
const firstHeaderValue = (
value: string | string[] | undefined,
): string | undefined => (Array.isArray(value) ? value[0] : value);
export const createRequestId = (
incomingRequestId: string | string[] | undefined,
): string => {
const requestId = firstHeaderValue(incomingRequestId);
return requestId && /^[A-Za-z0-9_-]{1,128}$/.test(requestId)
? requestId
: `req-${randomUUID()}`;
};
export const requestIdPlugin = async (app: FastifyInstance) => {
app.addHook("onRequest", async (request, reply) => {
request.requestId = request.id;
reply.header("x-request-id", request.requestId);
});
};
export const getRequestId = (request: FastifyRequest): string =>
request.requestId ?? request.id ?? `req-${randomUUID()}`;
@@ -0,0 +1,314 @@
import { createHash } from "node:crypto";
import type { AppEnv } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { getGiteaClient, listLinkedRepositories, type LinkedRepository } from "../integrations/gitea/service.js";
import type { GiteaRepository } from "../integrations/gitea/types.js";
import { chooseAnalysisStrategy, type AnalysisStrategy } from "../services/analysisStrategy.js";
export type PortfolioAnalysisRequest = {
projectIds?: string[];
repositoryIds?: string[];
includeBranches: boolean;
includePullRequests: boolean;
includeActions: boolean;
changedPaths?: string[];
strategy?: AnalysisStrategy;
strategyReason?: string;
};
const TERMINAL_STATUSES = ["succeeded", "partial", "failed", "cancelled"] as const;
const MAX_REPOSITORIES = 200;
export const projectAnalysisItemScope = (projectIdOrSlug: string) => ({ OR: [{ projectId: projectIdOrSlug }, { projectSlug: projectIdOrSlug }] });
const canonicalRequest = (input: Partial<PortfolioAnalysisRequest>): PortfolioAnalysisRequest => ({
projectIds: [...new Set((input.projectIds ?? []).map((value) => value.trim()).filter(Boolean))].sort(),
repositoryIds: [...new Set((input.repositoryIds ?? []).map((value) => value.trim()).filter(Boolean))].sort(),
includeBranches: input.includeBranches ?? true,
includePullRequests: input.includePullRequests ?? true,
includeActions: input.includeActions ?? true,
...(input.changedPaths?.length ? { changedPaths: [...new Set(input.changedPaths.map((value) => value.trim()).filter(Boolean))].slice(0, 200) } : {}),
...(input.strategy ? { strategy: input.strategy } : {}),
...(input.strategyReason ? { strategyReason: input.strategyReason } : {}),
});
export type AnalysisTarget = Pick<LinkedRepository, "id" | "projectId" | "projectSlug" | "projectName" | "owner" | "repository" | "fullName" | "defaultBranch" | "accessMode"> & {
repositoryLinkId?: string;
};
const matchesRepository = (target: AnalysisTarget, request: PortfolioAnalysisRequest) => {
return !request.repositoryIds?.length || request.repositoryIds.includes(target.id) || request.repositoryIds.includes(target.fullName);
};
export const resolveAnalysisTargets = (
links: LinkedRepository[],
repositories: GiteaRepository[],
request: PortfolioAnalysisRequest,
): AnalysisTarget[] => {
let targets: AnalysisTarget[];
if (request.projectIds?.length) {
targets = links
.filter((link) => request.projectIds!.includes(link.projectId) || request.projectIds!.includes(link.projectSlug))
.map((link) => ({ ...link, repositoryLinkId: link.id }));
} else {
const byFullName = new Map(links.map((link) => [link.fullName.toLowerCase(), link]));
targets = repositories.map((repository) => {
const link = byFullName.get(repository.full_name.toLowerCase());
return {
id: link?.id ?? `gitea:${repository.full_name}`,
repositoryLinkId: link?.id,
projectId: link?.projectId ?? "",
projectSlug: link?.projectSlug ?? "gitea-all",
projectName: link?.projectName ?? "Gitea portfolio",
owner: repository.owner.login,
repository: repository.name,
fullName: repository.full_name,
defaultBranch: repository.default_branch || "main",
accessMode: link?.accessMode ?? (repository.permissions?.push || repository.permissions?.admin ? "read_write" : "read_only"),
};
});
}
return targets.filter((target) => matchesRepository(target, request)).sort((left, right) => left.fullName.localeCompare(right.fullName));
};
const jobSelection = {
id: true,
kind: true,
status: true,
actor: true,
progressCurrent: true,
progressTotal: true,
summaryText: true,
summaryJson: true,
errorCode: true,
cancelRequestedAt: true,
queuedAt: true,
startedAt: true,
heartbeatAt: true,
completedAt: true,
expiresAt: true,
createdAt: true,
updatedAt: true,
} as const;
export const serializeAnalysisJob = (job: Record<string, unknown>) => {
const serialized = Object.fromEntries(
Object.entries(job).map(([key, value]) => [key, value instanceof Date ? value.toISOString() : value]),
);
const status = typeof serialized.status === "string" ? serialized.status : "unknown";
const terminal = TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]);
const stateVersion = createHash("sha256").update(JSON.stringify({
id: serialized.id,
status,
progressCurrent: serialized.progressCurrent,
progressTotal: serialized.progressTotal,
summaryText: serialized.summaryText,
errorCode: serialized.errorCode,
updatedAt: serialized.updatedAt,
})).digest("hex").slice(0, 24);
const nextAction = status === "succeeded"
? "read-results"
: status === "partial"
? "read-results-and-inspect-failures"
: status === "failed"
? "inspect-summary-or-restart"
: status === "cancelled"
? "restart-if-needed"
: "wait-for-change";
return { ...serialized, stateVersion, terminal, pollAfterMs: terminal ? 0 : status === "queued" ? 5_000 : 4_000, nextAction };
};
export const createPortfolioAnalysisJob = async (
env: AppEnv,
actor: string,
input: Partial<PortfolioAnalysisRequest>,
) => {
const request = canonicalRequest(input);
const links = await listLinkedRepositories();
const repositories = request.projectIds?.length ? [] : await getGiteaClient(env).listRepositories();
const targets = resolveAnalysisTargets(links, repositories, request);
if (!targets.length) throw new Error("ANALYSIS_REPOSITORIES_NOT_FOUND");
if (targets.length > MAX_REPOSITORIES) throw new Error("ANALYSIS_REPOSITORY_LIMIT_EXCEEDED");
const lastFullScan = await prisma.analysisJob.findFirst({ where: { status: "succeeded", items: { some: { repositoryLinkId: { in: targets.map((target) => target.repositoryLinkId ?? target.id) } } } }, orderBy: { completedAt: "desc" }, select: { completedAt: true } });
const analysisPlan = chooseAnalysisStrategy({ changedPaths: request.changedPaths, lastFullScanAt: lastFullScan?.completedAt?.toISOString(), budgetItems: targets.length });
const persistedRequest = { ...request, strategy: request.strategy ?? analysisPlan.strategy, strategyReason: request.strategyReason ?? analysisPlan.reason, repositoryIds: targets.map((target) => target.id) };
const requestHash = createHash("sha256").update(JSON.stringify(persistedRequest)).digest("hex");
const activeDedupeKey = createHash("sha256").update(`${actor}:portfolio_health:${requestHash}`).digest("hex");
const existing = await prisma.analysisJob.findUnique({ where: { activeDedupeKey }, select: jobSelection });
if (existing) return { job: serializeAnalysisJob(existing), deduplicated: true };
try {
const job = await prisma.analysisJob.create({
data: {
actor,
requestJson: persistedRequest as never,
requestHash,
activeDedupeKey,
progressTotal: targets.length,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
items: {
create: targets.map((target, sequence) => ({
repositoryLinkId: target.repositoryLinkId,
projectId: target.projectId || undefined,
projectSlug: target.projectSlug,
projectName: target.projectName,
owner: target.owner,
repository: target.repository,
defaultBranch: target.defaultBranch,
accessMode: target.accessMode,
sequence,
})),
},
},
select: jobSelection,
});
return { job: serializeAnalysisJob(job), deduplicated: false };
} catch (error) {
const raced = await prisma.analysisJob.findUnique({ where: { activeDedupeKey }, select: jobSelection });
if (raced) return { job: serializeAnalysisJob(raced), deduplicated: true };
throw error;
}
};
export const getAnalysisJob = async (actor: string, jobId: string) => {
const job = await prisma.analysisJob.findFirst({ where: { id: jobId, actor }, select: jobSelection });
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
return serializeAnalysisJob(job);
};
export const listAnalysisJobs = async (actor: string, limit = 10) => {
const jobs = await prisma.analysisJob.findMany({
where: { actor },
orderBy: { createdAt: "desc" },
take: Math.min(Math.max(limit, 1), 25),
select: jobSelection,
});
return jobs.map((job) => serializeAnalysisJob(job));
};
export const listProjectAnalysisJobs = async (projectId: string, limit = 10) => {
const jobs = await prisma.analysisJob.findMany({
where: { items: { some: projectAnalysisItemScope(projectId) } },
orderBy: { createdAt: "desc" },
take: Math.min(Math.max(limit, 1), 25),
select: jobSelection,
});
return jobs.map((job) => serializeAnalysisJob(job));
};
export const getProjectAnalysisJob = async (projectId: string, jobId: string) => {
const job = await prisma.analysisJob.findFirst({ where: { id: jobId, items: { some: projectAnalysisItemScope(projectId) } }, select: jobSelection });
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
return serializeAnalysisJob(job);
};
export const getPortfolioAnalysisJob = async (jobId: string) => {
const job = await prisma.analysisJob.findUnique({ where: { id: jobId }, select: jobSelection });
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
return serializeAnalysisJob(job);
};
export const cancelAnalysisJob = async (actor: string, jobId: string) => {
const job = await prisma.analysisJob.findFirst({ where: { id: jobId, actor }, select: { id: true, status: true } });
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
if (TERMINAL_STATUSES.includes(job.status as (typeof TERMINAL_STATUSES)[number])) return getAnalysisJob(actor, jobId);
const now = new Date();
await prisma.$transaction([
prisma.analysisJob.update({
where: { id: job.id },
data: job.status === "queued"
? { status: "cancelled", cancelRequestedAt: now, completedAt: now, activeDedupeKey: null, summaryText: "Analyse geannuleerd voordat de worker startte." }
: { cancelRequestedAt: now },
}),
...(job.status === "queued" ? [prisma.analysisJobItem.updateMany({ where: { jobId: job.id, status: "pending" }, data: { status: "cancelled", completedAt: now } })] : []),
]);
return getAnalysisJob(actor, jobId);
};
export const cancelProjectAnalysisJob = async (projectId: string, jobId: string) => {
const job = await prisma.analysisJob.findFirst({ where: { id: jobId, items: { some: projectAnalysisItemScope(projectId) } }, select: { id: true, actor: true } });
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
return cancelAnalysisJob(job.actor, job.id);
};
export const cancelPortfolioAnalysisJob = async (jobId: string) => {
const job = await prisma.analysisJob.findUnique({ where: { id: jobId }, select: { actor: true } });
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
return cancelAnalysisJob(job.actor, jobId);
};
type AnalysisResultScope = { actor?: string; projectId?: string; portfolio?: boolean };
const getScopedAnalysisJobResults = async (
scope: AnalysisResultScope,
jobId: string,
input: { cursor?: string; pageSize?: number; characterBudget?: number } = {},
) => {
const where = scope.projectId
? { id: jobId, items: { some: projectAnalysisItemScope(scope.projectId) } }
: scope.portfolio
? { id: jobId }
: { id: jobId, actor: scope.actor ?? "" };
const job = await prisma.analysisJob.findFirst({ where, select: jobSelection });
if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND");
const cursor = Number.parseInt(input.cursor ?? "0", 10);
if (!Number.isInteger(cursor) || cursor < 0) throw new Error("ANALYSIS_CURSOR_INVALID");
const pageSize = Math.min(Math.max(input.pageSize ?? 5, 1), 10);
const characterBudget = Math.min(Math.max(input.characterBudget ?? 24_000, 4_000), 32_000);
const rows = await prisma.analysisJobItem.findMany({
where: { jobId, ...(scope.projectId ? projectAnalysisItemScope(scope.projectId) : {}) },
orderBy: { sequence: "asc" },
skip: cursor,
take: pageSize + 1,
});
let usedCharacters = 0;
const items: Array<Record<string, unknown>> = [];
for (const row of rows.slice(0, pageSize)) {
const detail = row.detailJson && typeof row.detailJson === "object" ? row.detailJson : undefined;
const detailCharacters = detail ? JSON.stringify(detail).length : 0;
const includeDetail = usedCharacters + detailCharacters <= characterBudget;
items.push({
repositoryId: row.repositoryLinkId ?? `gitea:${row.owner}/${row.repository}`,
projectId: row.projectId,
projectSlug: row.projectSlug,
projectName: row.projectName,
repository: `${row.owner}/${row.repository}`,
status: row.status,
summary: row.summary,
findingCount: row.findingCount,
errorCode: row.errorCode,
...(includeDetail && detail ? { detail } : { detailTruncated: Boolean(detail) }),
});
if (includeDetail) usedCharacters += detailCharacters;
}
const consumed = items.length;
const hasMore = rows.length > pageSize;
return {
job: serializeAnalysisJob(job),
items,
cursor: String(cursor),
pageSize,
characterBudget,
usedCharacters,
nextCursor: hasMore ? String(cursor + consumed) : undefined,
};
};
export const getAnalysisJobResults = async (
actor: string,
jobId: string,
input: { cursor?: string; pageSize?: number; characterBudget?: number } = {},
projectId?: string,
) => getScopedAnalysisJobResults(projectId ? { projectId } : { actor }, jobId, input);
export const getProjectAnalysisJobResults = async (
projectId: string,
jobId: string,
input: { cursor?: string; pageSize?: number; characterBudget?: number } = {},
) => getAnalysisJobResults("", jobId, input, projectId);
export const getPortfolioAnalysisJobResults = async (
jobId: string,
input: { cursor?: string; pageSize?: number; characterBudget?: number } = {},
) => getScopedAnalysisJobResults({ portfolio: true }, jobId, input);
@@ -0,0 +1,18 @@
export type AutonomyExecutionTarget = {
surface: "gitea";
claimMode: "branch";
completionMode: "validated_merge";
repositoryLinkId: string;
owner: string;
repository: string;
fullName: string;
defaultBranch: string;
branch: string;
};
export const hasGiteaAutonomyCompletionEvidence = (evidence: string[] | undefined) => {
const values = evidence ?? [];
return values.some((entry) => entry.startsWith("gitea:auto:merge:"))
&& values.some((entry) => entry.startsWith("gitea:auto:head:"))
&& values.some((entry) => entry.startsWith("gitea:auto:validation:"));
};
@@ -0,0 +1,797 @@
import { autonomyProfileSettings, type AutonomyProfile } from "@projectbrain/contracts";
import { randomUUID } from "node:crypto";
import type { Prisma } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import { autonomyRetryDelayMinutes, classifyAutonomyBlocker, recoverStoredBlockerKind } from "../services/autonomyRecovery.js";
import { addDiscoveredTasks, hasOpenAutonomyExecutionItems, isAutonomyItemInExecutionScope, orderAutonomyRepairs, workPrompt } from "../services/autonomyWork.js";
import { getFeatureDelivery } from "./featureDeliveryRepository.js";
import { createBrainstormForProject } from "./brainstormRepository.js";
import { parseRoadmapMarkdown } from "../services/autonomyRoadmap.js";
import { decideRecovery } from "../services/recoveryPolicy.js";
import { hasGiteaAutonomyCompletionEvidence, type AutonomyExecutionTarget } from "./autonomyExecutionEvidence.js";
export { parseRoadmapMarkdown };
export { autonomyRetryDelayMinutes, classifyAutonomyBlocker };
export { hasGiteaAutonomyCompletionEvidence };
export type { AutonomyExecutionTarget };
export type AutonomyStatus = "off" | "waiting_roadmap" | "running" | "blocked" | "completed";
export type AutonomyWorkKind = "brainstorm" | "implement" | "validate_phase";
export type AutonomyWorkOutcome = "success" | "failed" | "blocked";
export type RoadmapItemStatus = "pending" | "in_progress" | "completed" | "blocked";
export type RoadmapPhaseStatus = "pending" | "active" | "brainstorming" | "executing" | "validating" | "completed" | "blocked";
export type AutonomyRoadmapItem = {
id: string;
title: string;
instruction?: string;
source?: "roadmap" | "user_assignment" | "brainstorm" | "recovery" | "finding" | "delivery" | "release" | "remediation" | "feature" | "bug";
createdAt?: string;
status: RoadmapItemStatus;
attempts: number;
priority?: number;
scope?: "required" | "committed" | "stretch" | "future";
risk?: "low" | "normal" | "medium" | "high" | "critical";
estimatedScope?: number;
releaseBlocking?: boolean;
waitingExternal?: boolean;
touchedPaths?: string[];
domain?: string;
deliveryId?: string;
dependsOn?: string[];
lastResult?: string;
lastError?: string;
completionEvidence?: string[];
supersededBy?: string;
};
export type AutonomyRoadmapPhase = {
id: string;
title: string;
objective: string;
status: RoadmapPhaseStatus;
brainstormIterations: number;
brainstormConverged: boolean;
validationFailures: number;
items: AutonomyRoadmapItem[];
};
export type AutonomyRoadmap = {
id: string;
title: string;
sourceMarkdown: string;
status: "active" | "completed" | "blocked";
currentPhaseIndex: number;
phases: AutonomyRoadmapPhase[];
importedAt: string;
completedAt?: string;
};
export type AutonomyLease = {
token: string;
kind: AutonomyWorkKind;
actor: string;
runId?: string;
roadmapId: string;
phaseId: string;
itemId?: string;
createdAt: string;
expiresAt: string;
};
export type AutonomyCompletedLease = {
token: string;
outcome: AutonomyWorkOutcome;
summary: string;
addedTasks: string[];
completedAt: string;
};
export type AutonomyRun = {
id: string;
actor: string;
startedAt: string;
reportedWorkItems: number;
};
export type AutonomyEvent = {
id: string;
at: string;
type: string;
actor: string;
summary: string;
};
export type AutonomyState = {
version: 1;
profile: AutonomyProfile;
enabled: boolean;
status: AutonomyStatus;
autoBrainstorm: boolean;
maxBrainstormIterations: number;
maxConsecutiveFailures: number;
leaseMinutes: number;
maxWorkItemsPerRun: number;
maxRunMinutes: number;
pauseAfterCurrent: boolean;
schedulingPolicy: "balanced" | "downstream-impact";
roadmap?: AutonomyRoadmap;
activeLease?: AutonomyLease;
blocker?: { at: string; phaseId?: string; itemId?: string; reason: string; kind: "human" | "retryable"; retryAt?: string; recoveryStage?: "retry" | "cooldown" | "alternate_recovery" | "escalated"; recoveryAttempts?: number; recoveryStartedAt?: string };
recoveryStartedAt?: string;
recoveryAttempts?: number;
maxRecoveryMinutes?: number;
runs: AutonomyRun[];
completedLeases: AutonomyCompletedLease[];
events: AutonomyEvent[];
updatedAt: string;
updatedBy: string;
};
export type AutonomyWork = {
token: string;
kind: AutonomyWorkKind;
projectId: string;
roadmapId: string;
roadmapTitle: string;
phaseId: string;
phaseTitle: string;
phaseObjective: string;
itemId?: string;
itemTitle?: string;
deliveryId?: string;
execution?: AutonomyExecutionTarget;
prompt: string;
expiresAt: string;
};
export type AutonomyReportInput = {
token: string;
outcome: AutonomyWorkOutcome;
summary: string;
deliveryId?: string;
discoveredTasks?: string[];
brainstorm?: {
contextSummary?: string;
ideas?: Array<{ id: string; title: string; description: string; score?: number }>;
outcomes?: string[];
selectedIdeaIds?: string[];
nextActions?: string[];
};
blockerType?: "human" | "retryable";
retryAfterMinutes?: number;
};
export type EnqueueAutonomyTaskInput = { title?: string; instruction: string };
const DEFAULTS = {
profile: "normal" as const,
autoBrainstorm: true,
maxBrainstormIterations: 5,
maxConsecutiveFailures: 3,
leaseMinutes: 30,
maxWorkItemsPerRun: 8,
maxRunMinutes: 45,
maxRecoveryMinutes: 120,
} as const;
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
const asString = (value: unknown, fallback = "") => typeof value === "string" ? value : fallback;
const asBoolean = (value: unknown, fallback: boolean) => typeof value === "boolean" ? value : fallback;
const asInt = (value: unknown, fallback: number, min: number, max: number) => {
const parsed = typeof value === "number" && Number.isInteger(value) ? value : fallback;
return Math.max(min, Math.min(max, parsed));
};
const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
const nowIso = () => new Date().toISOString();
const event = (type: string, actor: string, summary: string): AutonomyEvent => ({ id: randomUUID(), at: nowIso(), type, actor, summary });
const appendEvent = (state: AutonomyState, next: AutonomyEvent): AutonomyState => ({ ...state, events: [...state.events, next].slice(-100) });
const defaultState = (actor = "system"): AutonomyState => ({
version: 1,
enabled: false,
status: "off",
profile: DEFAULTS.profile,
autoBrainstorm: DEFAULTS.autoBrainstorm,
maxBrainstormIterations: DEFAULTS.maxBrainstormIterations,
maxConsecutiveFailures: DEFAULTS.maxConsecutiveFailures,
leaseMinutes: DEFAULTS.leaseMinutes,
maxWorkItemsPerRun: DEFAULTS.maxWorkItemsPerRun,
maxRunMinutes: DEFAULTS.maxRunMinutes,
pauseAfterCurrent: false,
schedulingPolicy: "balanced",
maxRecoveryMinutes: DEFAULTS.maxRecoveryMinutes,
recoveryAttempts: 0,
runs: [],
completedLeases: [],
events: [],
updatedAt: nowIso(),
updatedBy: actor,
});
const normalizeItem = (value: unknown): AutonomyRoadmapItem | undefined => {
const row = asRecord(value);
if (!asString(row.id) || !asString(row.title)) return undefined;
const status = ["pending", "in_progress", "completed", "blocked"].includes(asString(row.status)) ? asString(row.status) as RoadmapItemStatus : "pending";
return {
id: asString(row.id), title: asString(row.title), status, attempts: asInt(row.attempts, 0, 0, 100),
...(asString(row.instruction) ? { instruction: asString(row.instruction) } : {}),
...(["roadmap", "user_assignment", "brainstorm", "recovery", "finding", "delivery", "release", "remediation", "feature", "bug"].includes(asString(row.source)) ? { source: asString(row.source) as AutonomyRoadmapItem["source"] } : {}),
...(asString(row.createdAt) ? { createdAt: asString(row.createdAt) } : {}),
...(asString(row.deliveryId) ? { deliveryId: asString(row.deliveryId) } : {}),
...(typeof row.priority === "number" ? { priority: asInt(row.priority, 50, 0, 100) } : {}),
...(["required", "committed", "stretch", "future"].includes(asString(row.scope)) ? { scope: asString(row.scope) as AutonomyRoadmapItem["scope"] } : {}),
...(["low", "normal", "medium", "high", "critical"].includes(asString(row.risk)) ? { risk: asString(row.risk) as AutonomyRoadmapItem["risk"] } : {}),
...(typeof row.estimatedScope === "number" ? { estimatedScope: asInt(row.estimatedScope, 0, 0, 10) } : {}),
...(typeof row.releaseBlocking === "boolean" ? { releaseBlocking: row.releaseBlocking } : {}),
...(typeof row.waitingExternal === "boolean" ? { waitingExternal: row.waitingExternal } : {}),
...(Array.isArray(row.touchedPaths) ? { touchedPaths: row.touchedPaths.filter((path): path is string => typeof path === "string").slice(0, 100) } : {}),
...(asString(row.domain) ? { domain: asString(row.domain) } : {}),
...(Array.isArray(row.dependsOn) ? { dependsOn: row.dependsOn.filter((id): id is string => typeof id === "string") } : {}),
...(asString(row.lastResult) ? { lastResult: asString(row.lastResult) } : {}),
...(asString(row.lastError) ? { lastError: asString(row.lastError) } : {}),
...(Array.isArray(row.completionEvidence) ? { completionEvidence: row.completionEvidence.filter((value): value is string => typeof value === "string").slice(0, 50) } : {}),
...(asString(row.supersededBy) ? { supersededBy: asString(row.supersededBy) } : {}),
};
};
const normalizePhase = (value: unknown): AutonomyRoadmapPhase | undefined => {
const row = asRecord(value);
if (!asString(row.id) || !asString(row.title)) return undefined;
const statuses = ["pending", "active", "brainstorming", "executing", "validating", "completed", "blocked"];
return {
id: asString(row.id),
title: asString(row.title),
objective: asString(row.objective, asString(row.title)),
status: statuses.includes(asString(row.status)) ? asString(row.status) as RoadmapPhaseStatus : "pending",
brainstormIterations: asInt(row.brainstormIterations, 0, 0, 100),
brainstormConverged: asBoolean(row.brainstormConverged, false),
validationFailures: asInt(row.validationFailures, 0, 0, 100),
items: Array.isArray(row.items) ? row.items.map(normalizeItem).filter((item): item is AutonomyRoadmapItem => Boolean(item)) : [],
};
};
const normalizeCompletedLease = (value: unknown): AutonomyCompletedLease | undefined => {
const row = asRecord(value);
const outcome = asString(row.outcome);
if (!asString(row.token) || !["success", "failed", "blocked"].includes(outcome)) return undefined;
return {
token: asString(row.token),
outcome: outcome as AutonomyWorkOutcome,
summary: asString(row.summary),
addedTasks: Array.isArray(row.addedTasks) ? row.addedTasks.filter((item): item is string => typeof item === "string") : [],
completedAt: asString(row.completedAt, nowIso()),
};
};
const normalizeRun = (value: unknown): AutonomyRun | undefined => {
const row = asRecord(value);
if (!asString(row.id) || !asString(row.actor) || !asString(row.startedAt)) return undefined;
return {
id: asString(row.id),
actor: asString(row.actor),
startedAt: asString(row.startedAt),
reportedWorkItems: asInt(row.reportedWorkItems, 0, 0, 10_000),
};
};
export const normalizeAutonomyState = (raw: unknown, actor = "system"): AutonomyState => {
const row = asRecord(raw);
if (!Object.keys(row).length) return defaultState(actor);
const roadmapRow = asRecord(row.roadmap);
const phases = Array.isArray(roadmapRow.phases) ? roadmapRow.phases.map(normalizePhase).filter((phase): phase is AutonomyRoadmapPhase => Boolean(phase)) : [];
const roadmap: AutonomyRoadmap | undefined = asString(roadmapRow.id) && phases.length ? {
id: asString(roadmapRow.id), title: asString(roadmapRow.title, "Roadmap"), sourceMarkdown: asString(roadmapRow.sourceMarkdown),
status: ["active", "completed", "blocked"].includes(asString(roadmapRow.status)) ? asString(roadmapRow.status) as AutonomyRoadmap["status"] : "active",
currentPhaseIndex: asInt(roadmapRow.currentPhaseIndex, 0, 0, Math.max(0, phases.length - 1)), phases,
importedAt: asString(roadmapRow.importedAt, nowIso()), ...(asString(roadmapRow.completedAt) ? { completedAt: asString(roadmapRow.completedAt) } : {}),
} : undefined;
const leaseRow = asRecord(row.activeLease);
const leaseKind = asString(leaseRow.kind);
const activeLease: AutonomyLease | undefined = asString(leaseRow.token) && asString(leaseRow.phaseId) && ["brainstorm", "implement", "validate_phase"].includes(leaseKind) ? {
token: asString(leaseRow.token), kind: leaseKind as AutonomyWorkKind, actor: asString(leaseRow.actor),
...(asString(leaseRow.runId) ? { runId: asString(leaseRow.runId) } : {}),
roadmapId: asString(leaseRow.roadmapId), phaseId: asString(leaseRow.phaseId), ...(asString(leaseRow.itemId) ? { itemId: asString(leaseRow.itemId) } : {}),
createdAt: asString(leaseRow.createdAt), expiresAt: asString(leaseRow.expiresAt),
} : undefined;
const blockerRow = asRecord(row.blocker);
const enabled = asBoolean(row.enabled, false);
const blockerKind = recoverStoredBlockerKind(asString(blockerRow.reason), asString(blockerRow.kind));
const blockedAt = Date.parse(asString(blockerRow.at));
const recoveryStage = ["retry", "cooldown", "alternate_recovery", "escalated"].includes(asString(blockerRow.recoveryStage)) ? asString(blockerRow.recoveryStage) as NonNullable<AutonomyState["blocker"]>["recoveryStage"] : undefined;
const blocker = asString(blockerRow.reason) ? {
at: asString(blockerRow.at, nowIso()),
reason: asString(blockerRow.reason),
kind: blockerKind,
...(blockerKind === "retryable" ? { retryAt: asString(blockerRow.retryAt) || new Date((Number.isFinite(blockedAt) ? blockedAt : Date.now()) + 15 * 60_000).toISOString() } : {}),
...(asString(blockerRow.phaseId) ? { phaseId: asString(blockerRow.phaseId) } : {}),
...(asString(blockerRow.itemId) ? { itemId: asString(blockerRow.itemId) } : {}),
...(recoveryStage ? { recoveryStage } : {}),
...(typeof blockerRow.recoveryAttempts === "number" ? { recoveryAttempts: asInt(blockerRow.recoveryAttempts, 0, 0, 100) } : {}),
...(asString(blockerRow.recoveryStartedAt) ? { recoveryStartedAt: asString(blockerRow.recoveryStartedAt) } : {}),
} : undefined;
const status: AutonomyStatus = !enabled
? "off"
: !roadmap
? "waiting_roadmap"
: blocker || roadmap.status === "blocked"
? "blocked"
: roadmap.status === "completed"
? "completed"
: "running";
const leaseMatchesRoadmap = Boolean(enabled && activeLease && roadmap && activeLease.roadmapId === roadmap.id && roadmap.phases.some((phase) => phase.id === activeLease.phaseId && (!activeLease.itemId || phase.items.some((item) => item.id === activeLease.itemId))));
return {
version: 1,
enabled, status,
profile: ["conservative", "normal", "autonomous", "custom"].includes(asString(row.profile)) ? asString(row.profile) as AutonomyProfile : DEFAULTS.profile,
autoBrainstorm: asBoolean(row.autoBrainstorm, DEFAULTS.autoBrainstorm),
maxBrainstormIterations: asInt(row.maxBrainstormIterations, DEFAULTS.maxBrainstormIterations, 1, 10),
maxConsecutiveFailures: asInt(row.maxConsecutiveFailures, DEFAULTS.maxConsecutiveFailures, 1, 10),
leaseMinutes: asInt(row.leaseMinutes, DEFAULTS.leaseMinutes, 5, 120),
maxWorkItemsPerRun: asInt(row.maxWorkItemsPerRun, DEFAULTS.maxWorkItemsPerRun, 1, 20),
maxRunMinutes: asInt(row.maxRunMinutes, DEFAULTS.maxRunMinutes, 5, 55),
pauseAfterCurrent: asBoolean(row.pauseAfterCurrent, false),
schedulingPolicy: row.schedulingPolicy === "downstream-impact" ? "downstream-impact" : "balanced",
maxRecoveryMinutes: asInt(row.maxRecoveryMinutes, DEFAULTS.maxRecoveryMinutes, 15, 1_440),
recoveryAttempts: asInt(row.recoveryAttempts, 0, 0, 100),
...(roadmap ? { roadmap } : {}), ...(leaseMatchesRoadmap && activeLease ? { activeLease } : {}),
...(blocker ? { blocker } : {}),
runs: Array.isArray(row.runs) ? row.runs.map(normalizeRun).filter((entry): entry is AutonomyRun => Boolean(entry)).slice(-50) : [],
completedLeases: Array.isArray(row.completedLeases) ? row.completedLeases.map(normalizeCompletedLease).filter((entry): entry is AutonomyCompletedLease => Boolean(entry)).slice(-50) : [],
events: Array.isArray(row.events) ? row.events.map((entry) => {
const e = asRecord(entry); return { id: asString(e.id, randomUUID()), at: asString(e.at, nowIso()), type: asString(e.type, "event"), actor: asString(e.actor, "system"), summary: asString(e.summary) };
}).slice(-100) : [],
updatedAt: asString(row.updatedAt, nowIso()), updatedBy: asString(row.updatedBy, actor),
};
};
const getProjectRow = async (projectIdOrSlug: string) => {
const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, include: { workspaceState: true } });
if (!project) throw new Error("PROJECT_NOT_FOUND");
return project;
};
export type AutonomyProjectRow = Awaited<ReturnType<typeof getProjectRow>>;
export const getAutonomyProject = getProjectRow;
const readState = (project: Awaited<ReturnType<typeof getProjectRow>>, actor = "system") => {
const notes = asRecord(project.workspaceState?.notesJson);
return normalizeAutonomyState(notes.autonomy, actor);
};
export const readAutonomyState = readState;
export const releaseActiveAutonomyLease = (state: AutonomyState, actor: string) => {
if (!state.activeLease || !state.roadmap) return state;
const next = structuredClone(state) as AutonomyState;
const lease = next.activeLease!;
const phase = next.roadmap!.phases.find((entry) => entry.id === lease.phaseId);
const item = lease.itemId ? phase?.items.find((entry) => entry.id === lease.itemId) : undefined;
if (item?.status === "in_progress") item.status = "pending";
if (phase && !["completed", "blocked"].includes(phase.status)) phase.status = "active";
delete next.activeLease;
return appendEvent(next, event("autonomy.lease.released", actor, `Released ${lease.kind} lease because Auto Mode was disabled.`));
};
// Update only autonomy with a monotonic CAS version; preserve unrelated notes.
const saveState = async (project: Awaited<ReturnType<typeof getProjectRow>>, state: AutonomyState, actor: string) => {
const storedAutonomy = asRecord(asRecord(project.workspaceState?.notesJson).autonomy);
const storedUpdatedAt = asString(storedAutonomy.updatedAt);
const priorTimestamp = Date.parse(storedUpdatedAt);
const savedAt = new Date(Math.max(Date.now(), Number.isFinite(priorTimestamp) ? priorTimestamp + 1 : 0));
const next: AutonomyState = { ...state, updatedAt: savedAt.toISOString(), updatedBy: actor };
const serialized = JSON.stringify(next);
if (project.workspaceState) {
const updated = storedUpdatedAt
? await prisma.$executeRaw`
UPDATE "WorkspaceState"
SET
"notesJson" = jsonb_set(COALESCE("notesJson"::jsonb, '{}'::jsonb), '{autonomy}', ${serialized}::jsonb, true),
"updatedBy" = ${actor},
"sourceType" = 'autonomy',
"updatedAt" = ${savedAt}
WHERE "projectId" = ${project.id}
AND "notesJson"->'autonomy'->>'updatedAt' = ${storedUpdatedAt}
`
: await prisma.$executeRaw`
UPDATE "WorkspaceState"
SET
"notesJson" = jsonb_set(COALESCE("notesJson"::jsonb, '{}'::jsonb), '{autonomy}', ${serialized}::jsonb, true),
"updatedBy" = ${actor},
"sourceType" = 'autonomy',
"updatedAt" = ${savedAt}
WHERE "projectId" = ${project.id}
AND COALESCE("notesJson"->'autonomy'->>'updatedAt', '') = ''
`;
if (updated !== 1) throw new Error("AUTO_MODE_CONFLICT");
} else {
try {
await prisma.workspaceState.create({
data: { projectId: project.id, primaryObjective: project.description, activeSection: "workspace", notesJson: json({ autonomy: next }), createdBy: actor, updatedBy: actor, sourceType: "autonomy", updatedAt: savedAt },
});
} catch (error) {
if (asString(asRecord(error).code) === "P2002") throw new Error("AUTO_MODE_CONFLICT", { cause: error });
throw error;
}
}
return next;
};
export const saveAutonomyState = saveState;
export const appendAutonomyEvent = appendEvent;
export const createAutonomyEvent = event;
const assignmentTitle = (input: EnqueueAutonomyTaskInput) => {
const explicit = input.title?.trim();
if (explicit) return explicit.slice(0, 200);
const firstLine = input.instruction.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "Nieuwe ProjectBrain-opdracht";
return firstLine.slice(0, 160);
};
export const enqueueAutonomyTaskInState = (current: AutonomyState, input: EnqueueAutonomyTaskInput, actor = "owner") => {
const next = structuredClone(current) as AutonomyState;
const createdAt = nowIso();
const task: AutonomyRoadmapItem = {
id: `assignment-${randomUUID()}`,
title: assignmentTitle(input),
instruction: input.instruction.trim(),
source: "user_assignment",
createdAt,
status: "pending",
attempts: 0,
};
const createAssignmentPhase = (): AutonomyRoadmapPhase => ({
id: `projectbrain-opdrachten-${randomUUID()}`,
title: "ProjectBrain-opdrachten",
objective: "Werk de opdrachten uit de interne ProjectBrain-takenlijst veilig, controleerbaar en in volgorde af.",
status: "active",
brainstormIterations: 0,
brainstormConverged: false,
validationFailures: 0,
items: [task],
});
if (!next.roadmap || next.roadmap.status === "completed") {
next.roadmap = {
id: `roadmap-${randomUUID()}`,
title: "ProjectBrain-opdrachten",
sourceMarkdown: `# ProjectBrain-opdrachten\n\n## Opdrachten\n- [ ] ${task.title}`,
status: "active",
currentPhaseIndex: 0,
phases: [createAssignmentPhase()],
importedAt: createdAt,
};
delete next.blocker;
delete next.activeLease;
} else {
const currentPhase = next.roadmap.phases[next.roadmap.currentPhaseIndex];
if (currentPhase && currentPhase.status !== "completed") {
currentPhase.items.unshift(task);
if (!["blocked", "executing", "brainstorming", "validating"].includes(currentPhase.status)) currentPhase.status = "active";
} else {
const phase = createAssignmentPhase();
next.roadmap.phases.splice(Math.min(next.roadmap.currentPhaseIndex + 1, next.roadmap.phases.length), 0, phase);
}
next.roadmap.sourceMarkdown = `${next.roadmap.sourceMarkdown.trim()}\n- [ ] ${task.title}`.trim();
}
if (next.enabled && next.roadmap.status !== "blocked" && !next.blocker) next.status = "running";
else if (!next.enabled) next.status = "off";
const state = appendEvent(next, event("autonomy.task.enqueued", actor, `Queued ProjectBrain assignment '${task.title}' with user priority.`));
return { state, task };
};
const expireLeaseIfNeeded = (state: AutonomyState) => {
if (!state.activeLease || new Date(state.activeLease.expiresAt).getTime() > Date.now()) return state;
const next = structuredClone(state) as AutonomyState;
const lease = next.activeLease!;
const phase = next.roadmap?.phases.find((entry) => entry.id === lease.phaseId);
const item = lease.itemId ? phase?.items.find((entry) => entry.id === lease.itemId) : undefined;
if (item?.status === "in_progress") item.status = "pending";
if (phase && !["completed", "blocked"].includes(phase.status)) phase.status = "active";
delete next.activeLease;
return appendEvent(next, event("autonomy.lease.expired", "system", `Expired ${lease.kind} lease for ${phase?.title ?? lease.phaseId}.`));
};
export const retryAutonomyBlockerIfDue = (state: AutonomyState, now = Date.now()) => {
if (!state.blocker || state.blocker.kind !== "retryable" || state.blocker.recoveryStage === "escalated" || !state.blocker.retryAt || Date.parse(state.blocker.retryAt) > now || !state.roadmap) return state;
const next = structuredClone(state) as AutonomyState;
const blocker = next.blocker!;
const recoveryStartedAt = Date.parse(blocker.recoveryStartedAt ?? next.recoveryStartedAt ?? blocker.at);
const recoveryLimit = Math.max(15, next.maxRecoveryMinutes ?? DEFAULTS.maxRecoveryMinutes) * 60_000;
if (Number.isFinite(recoveryStartedAt) && now - recoveryStartedAt >= recoveryLimit) {
const phase = blocker.phaseId ? next.roadmap!.phases.find((entry) => entry.id === blocker.phaseId) : undefined;
if (phase) phase.status = "blocked";
next.roadmap!.status = "blocked";
next.status = "blocked";
next.blocker = {
...blocker,
recoveryStage: "escalated",
recoveryAttempts: Math.max(blocker.recoveryAttempts ?? 0, next.recoveryAttempts ?? 0),
recoveryStartedAt: new Date(recoveryStartedAt).toISOString(),
reason: `${blocker.reason} Automatic recovery exceeded its wall-clock limit and needs escalation.`,
};
delete next.blocker.retryAt;
return appendEvent(next, event("autonomy.recovery.escalated", "system", "Automatic recovery exceeded its wall-clock limit."));
}
const phase = blocker.phaseId
? next.roadmap!.phases.find((entry) => entry.id === blocker.phaseId)
: next.roadmap!.phases[next.roadmap!.currentPhaseIndex];
const item = blocker.itemId ? phase?.items.find((entry) => entry.id === blocker.itemId) : undefined;
if (item?.status === "blocked") item.status = "pending";
if (phase?.status === "blocked") phase.status = "active";
next.roadmap!.status = "active";
next.status = next.enabled ? "running" : "off";
delete next.blocker;
delete next.activeLease;
return appendEvent(next, event("autonomy.blocker.retry_ready", "system", "Retryable blocker cooldown elapsed; work is claimable again."));
};
export const evaluateAutonomyRunBudget = (state: AutonomyState, actor: string, runId: string, now = Date.now()) => {
const run = state.runs.find((entry) => entry.id === runId);
if (run && run.actor !== actor) return { allowed: false, reason: "owner_mismatch" as const, run };
if (!run) return { allowed: true, reason: undefined, run: { id: runId, actor, startedAt: new Date(now).toISOString(), reportedWorkItems: 0 } satisfies AutonomyRun };
if (run.reportedWorkItems >= state.maxWorkItemsPerRun) return { allowed: false, reason: "item_limit" as const, run };
if (Date.parse(run.startedAt) + state.maxRunMinutes * 60_000 <= now) return { allowed: false, reason: "time_limit" as const, run };
return { allowed: true, reason: undefined, run };
};
export const getAutonomyState = async (projectIdOrSlug: string) => {
const project = await getProjectRow(projectIdOrSlug);
const original = readState(project);
const state = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(original));
if (original.activeLease?.token === state.activeLease?.token && original.blocker?.retryAt === state.blocker?.retryAt) return state;
return saveState(project, state, "system");
};
export const configureAutonomy = async (projectIdOrSlug: string, patch: Partial<Pick<AutonomyState, "enabled" | "profile" | "autoBrainstorm" | "maxBrainstormIterations" | "maxConsecutiveFailures" | "leaseMinutes" | "maxWorkItemsPerRun" | "maxRunMinutes" | "maxRecoveryMinutes" | "pauseAfterCurrent" | "schedulingPolicy">>, actor = "owner") => {
const project = await getProjectRow(projectIdOrSlug);
let state = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(readState(project, actor)));
const requestedProfile = patch.profile && Object.hasOwn(autonomyProfileSettings, patch.profile) ? patch.profile : undefined;
const selectedProfileSettings = requestedProfile ? autonomyProfileSettings[requestedProfile] : undefined;
state = {
...state,
...(requestedProfile ? { profile: requestedProfile } : {}),
...(selectedProfileSettings ?? {}),
...(typeof patch.autoBrainstorm === "boolean" ? { autoBrainstorm: patch.autoBrainstorm } : {}),
...(typeof patch.maxBrainstormIterations === "number" ? { maxBrainstormIterations: Math.max(1, Math.min(10, Math.trunc(patch.maxBrainstormIterations))) } : {}),
...(typeof patch.maxConsecutiveFailures === "number" ? { maxConsecutiveFailures: Math.max(1, Math.min(10, Math.trunc(patch.maxConsecutiveFailures))) } : {}),
...(typeof patch.leaseMinutes === "number" ? { leaseMinutes: Math.max(5, Math.min(120, Math.trunc(patch.leaseMinutes))) } : {}),
...(typeof patch.maxWorkItemsPerRun === "number" ? { maxWorkItemsPerRun: Math.max(1, Math.min(20, Math.trunc(patch.maxWorkItemsPerRun))) } : {}),
...(typeof patch.maxRunMinutes === "number" ? { maxRunMinutes: Math.max(5, Math.min(55, Math.trunc(patch.maxRunMinutes))) } : {}),
...(typeof patch.maxRecoveryMinutes === "number" ? { maxRecoveryMinutes: Math.max(15, Math.min(1_440, Math.trunc(patch.maxRecoveryMinutes))) } : {}),
...(typeof patch.pauseAfterCurrent === "boolean" ? { pauseAfterCurrent: patch.pauseAfterCurrent } : {}),
...(patch.schedulingPolicy === "balanced" || patch.schedulingPolicy === "downstream-impact" ? { schedulingPolicy: patch.schedulingPolicy } : {}),
};
if (typeof patch.enabled === "boolean") {
if (!patch.enabled && state.activeLease && state.roadmap) state = releaseActiveAutonomyLease(state, actor);
state.enabled = patch.enabled;
if (!patch.enabled) state.status = "off";
else if (state.roadmap?.status === "completed") state.status = "completed";
else if (state.blocker || state.roadmap?.status === "blocked") state.status = "blocked";
else state.status = state.roadmap ? "running" : "waiting_roadmap";
state = appendEvent(state, event(patch.enabled ? "autonomy.enabled" : "autonomy.disabled", actor, patch.enabled ? "Auto Mode enabled." : "Auto Mode disabled."));
}
return saveState(project, state, actor);
};
export const importAutonomyRoadmap = async (projectIdOrSlug: string, input: { title?: string; markdown: string }, actor = "owner") => {
const project = await getProjectRow(projectIdOrSlug);
let state = expireLeaseIfNeeded(readState(project, actor));
if (state.roadmap?.sourceMarkdown.trim() === input.markdown.trim()) {
if (input.title?.trim() && input.title.trim() !== state.roadmap.title) {
state.roadmap.title = input.title.trim();
return saveState(project, state, actor);
}
return state;
}
if (state.activeLease) throw new Error("AUTO_MODE_WORK_ACTIVE");
const roadmap = parseRoadmapMarkdown(input.markdown, input.title);
state = { ...state, roadmap, activeLease: undefined, blocker: undefined, completedLeases: [], status: state.enabled ? "running" : "off" };
state = appendEvent(state, event("autonomy.roadmap.imported", actor, `Imported '${roadmap.title}' with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`));
return saveState(project, state, actor);
};
export const resumeAutonomy = async (projectIdOrSlug: string, actor = "owner") => {
const project = await getProjectRow(projectIdOrSlug);
let state = expireLeaseIfNeeded(readState(project, actor));
if (!state.roadmap || state.roadmap.status === "completed") return state;
if (state.status !== "blocked" && state.roadmap.status !== "blocked" && !state.blocker) return state;
const blockedPhase = state.blocker?.phaseId ? state.roadmap.phases.find((phase) => phase.id === state.blocker?.phaseId) : state.roadmap.phases[state.roadmap.currentPhaseIndex];
if (blockedPhase) {
if (blockedPhase.status === "blocked") blockedPhase.status = "active";
blockedPhase.validationFailures = 0;
if (state.blocker?.itemId) {
const blockedItem = blockedPhase.items.find((item) => item.id === state.blocker?.itemId);
if (blockedItem?.status === "blocked") { blockedItem.status = "pending"; delete blockedItem.lastError; }
}
}
state.roadmap.status = "active";
delete state.blocker;
delete state.activeLease;
state.recoveryAttempts = 0;
delete state.recoveryStartedAt;
state.status = state.enabled ? "running" : "off";
state = appendEvent(state, event("autonomy.resumed", actor, "Auto Mode blocker cleared; roadmap is resumable."));
return saveState(project, state, actor);
};
export const canResumeAutonomyLease = (lease: AutonomyLease, actor: string, runId?: string) => {
if (lease.actor !== actor) return false;
if (lease.runId || runId) return Boolean(lease.runId && runId && lease.runId === runId);
return true;
};
export const isAutonomyLeaseOwner = (lease: AutonomyLease, actor: string) => lease.actor === actor;
const nextLease = (state: AutonomyState, actor: string, kind: AutonomyWorkKind, phase: AutonomyRoadmapPhase, item?: AutonomyRoadmapItem, runId?: string): AutonomyLease => {
const createdAt = new Date();
return { token: randomUUID(), kind, actor, ...(runId ? { runId } : {}), roadmapId: state.roadmap!.id, phaseId: phase.id, ...(item ? { itemId: item.id } : {}), createdAt: createdAt.toISOString(), expiresAt: new Date(createdAt.getTime() + state.leaseMinutes * 60_000).toISOString() };
};
export const createAutonomyLease = nextLease;
const applyRetryableBlocker = (state: AutonomyState, phase: AutonomyRoadmapPhase, summary: string, failureCount: number, item?: AutonomyRoadmapItem, requestedDelay?: number) => {
const blockedAt = Date.now();
const recoveryStartedAt = state.recoveryStartedAt ?? new Date(blockedAt).toISOString();
const recoveryAttempts = (state.recoveryAttempts ?? 0) + 1;
const decision = decideRecovery({
failureCount,
recoveryAttempts,
recoveryStartedAt,
maxRecoveryMinutes: state.maxRecoveryMinutes ?? DEFAULTS.maxRecoveryMinutes,
now: blockedAt,
});
state.recoveryStartedAt = recoveryStartedAt;
state.recoveryAttempts = recoveryAttempts;
if (decision.stage === "alternate_recovery") {
if (item) { item.status = "pending"; item.lastError = `${summary} Alternate recovery is being attempted.`; }
phase.status = "active";
state.roadmap!.status = "active";
state.status = state.enabled ? "running" : "off";
delete state.blocker;
return decision;
}
if (item) { item.status = "blocked"; item.lastError = summary; }
phase.status = "blocked";
state.roadmap!.status = "blocked";
state.status = "blocked";
const retryAfterMinutes = decision.exhausted
? undefined
: Math.max(5, Math.min(1_440, requestedDelay ?? decision.retryAfterMinutes ?? autonomyRetryDelayMinutes(failureCount)));
state.blocker = {
at: new Date(blockedAt).toISOString(), phaseId: phase.id, ...(item ? { itemId: item.id } : {}), reason: summary,
kind: "retryable", ...(retryAfterMinutes ? { retryAt: new Date(blockedAt + retryAfterMinutes * 60_000).toISOString() } : {}),
recoveryStage: decision.stage, recoveryAttempts, recoveryStartedAt,
};
return decision;
};
const applyHumanBlocker = (state: AutonomyState, phase: AutonomyRoadmapPhase, summary: string, item?: AutonomyRoadmapItem) => {
if (item) { item.status = "blocked"; item.lastError = summary; }
phase.status = "blocked";
state.roadmap!.status = "blocked";
state.status = "blocked";
state.blocker = { at: nowIso(), phaseId: phase.id, ...(item ? { itemId: item.id } : {}), reason: summary, kind: "human" };
};
export const reportAutonomyWork = async (projectIdOrSlug: string, input: AutonomyReportInput, actor = "chatgpt") => {
const project = await getProjectRow(projectIdOrSlug);
let state = readState(project, actor);
const replay = state.completedLeases.find((entry) => entry.token === input.token);
if (replay) return { state, addedTasks: replay.addedTasks, replayed: true, runId: undefined };
const lease = state.activeLease;
if (!lease || lease.token !== input.token) throw new Error("AUTO_MODE_LEASE_STALE");
if (!isAutonomyLeaseOwner(lease, actor)) throw new Error("AUTO_MODE_LEASE_OWNER_MISMATCH");
if (!state.roadmap || lease.roadmapId !== state.roadmap.id) throw new Error("AUTO_MODE_ROADMAP_CHANGED");
const phase = state.roadmap.phases.find((entry) => entry.id === lease.phaseId);
if (!phase) throw new Error("AUTO_MODE_PHASE_NOT_FOUND");
const item = lease.itemId ? phase.items.find((entry) => entry.id === lease.itemId) : undefined;
const deliveryId = input.deliveryId ?? item?.deliveryId;
const reconciledGiteaCompletion = item ? hasGiteaAutonomyCompletionEvidence(item.completionEvidence) : false;
if (lease.kind === "implement" && input.outcome === "success" && !deliveryId && !reconciledGiteaCompletion) throw new Error("AUTO_MODE_DELIVERY_REQUIRED");
if (deliveryId) {
const delivery = await getFeatureDelivery(project.slug, deliveryId);
if (item?.deliveryId && item.deliveryId !== delivery.id) throw new Error("AUTO_MODE_DELIVERY_ALREADY_LINKED");
if (input.outcome === "success" && lease.kind === "implement" && (!['merged', 'verified', 'released'].includes(delivery.spec.status) || !delivery.spec.pullRequest?.mergeCommitSha || !delivery.spec.validationEvidence)) throw new Error("AUTO_MODE_DELIVERY_NOT_MERGED");
if (item) item.deliveryId = delivery.id;
}
if (lease.kind === "validate_phase" && input.outcome === "success") {
for (const completed of phase.items.filter((entry) => entry.deliveryId && isAutonomyItemInExecutionScope(entry))) {
const delivery = await getFeatureDelivery(project.slug, completed.deliveryId!);
if (!["merged", "verified", "released"].includes(delivery.spec.status) || !delivery.spec.validationEvidence || !delivery.spec.pullRequest?.mergeCommitSha) throw new Error("AUTO_MODE_DELIVERY_NOT_MERGED");
}
}
const repairTasks = input.outcome === "success" ? [] : input.discoveredTasks ?? [];
const brainstormTasks = input.brainstorm?.nextActions ?? [];
const newTasks = [
...addDiscoveredTasks(phase, repairTasks, { scope: "committed", source: "recovery" }),
...addDiscoveredTasks(phase, brainstormTasks),
];
const hasRepairs = item && repairTasks.length > 0 && input.outcome !== "success" && classifyAutonomyBlocker(input.summary, input.blockerType) !== "human" && orderAutonomyRepairs(phase, item, repairTasks);
if (hasRepairs) {
item.status = "pending"; item.lastError = input.summary;
phase.status = "active"; state.status = "running"; state.roadmap.status = "active";
delete state.blocker;
} else if (input.outcome === "blocked") {
if (!item) phase.validationFailures += 1;
const failureCount = Math.max(1, item ? item.attempts : phase.validationFailures);
const blockerKind = classifyAutonomyBlocker(input.summary, input.blockerType);
if (blockerKind === "human") applyHumanBlocker(state, phase, input.summary, item);
else applyRetryableBlocker(state, phase, input.summary, failureCount, item, input.retryAfterMinutes);
} else if (lease.kind === "brainstorm") {
phase.brainstormIterations += 1;
if (input.outcome === "failed") {
phase.validationFailures += 1;
if (phase.validationFailures >= state.maxConsecutiveFailures) applyRetryableBlocker(state, phase, input.summary, phase.validationFailures, undefined, input.retryAfterMinutes);
else phase.status = "active";
} else if (newTasks.length && hasOpenAutonomyExecutionItems(phase)) {
phase.validationFailures = 0;
phase.brainstormConverged = false;
phase.status = "executing";
} else {
phase.validationFailures = 0;
phase.brainstormConverged = true;
phase.status = "validating";
}
await createBrainstormForProject(project.slug, {
title: `Auto brainstorm · ${phase.title} · round ${phase.brainstormIterations}`,
prompt: workPrompt("brainstorm", phase), scope: "roadmap_phase", mode: "autonomy_loop",
status: input.outcome === "failed" ? "failed" : hasOpenAutonomyExecutionItems(phase) ? "selected" : "converged", targetEntityId: phase.id, targetEntityType: "roadmap_phase", generatedBy: actor,
contextSummary: input.brainstorm?.contextSummary?.trim() || input.summary,
ideas: input.brainstorm?.ideas ?? [], clusters: [],
outcomes: (input.brainstorm?.outcomes ?? [input.summary]).map((summary, index) => ({ id: `outcome-${index + 1}`, summary })),
selectedIdeaIds: input.brainstorm?.selectedIdeaIds ?? [], nextActions: newTasks,
});
} else if (lease.kind === "implement") {
if (!item) throw new Error("AUTO_MODE_ITEM_NOT_FOUND");
if (input.outcome === "success") {
phase.validationFailures = 0;
item.status = "completed";
item.lastResult = input.summary;
delete item.lastError;
if (input.deliveryId) item.deliveryId = input.deliveryId;
phase.status = "active";
} else {
item.lastError = input.summary;
if (item.attempts >= state.maxConsecutiveFailures) applyRetryableBlocker(state, phase, input.summary, item.attempts, item, input.retryAfterMinutes);
else { item.status = "pending"; phase.status = "active"; }
}
} else {
if (input.outcome === "success" && !hasOpenAutonomyExecutionItems(phase)) {
phase.status = "completed";
phase.brainstormConverged = true;
phase.validationFailures = 0;
if (state.roadmap.currentPhaseIndex < state.roadmap.phases.length - 1) {
state.roadmap.currentPhaseIndex += 1;
const next = state.roadmap.phases[state.roadmap.currentPhaseIndex];
if (next.status !== "completed") next.status = "active";
} else {
state.roadmap.status = "completed";
state.roadmap.completedAt = nowIso();
state.status = "completed";
}
} else {
phase.validationFailures += 1;
phase.brainstormConverged = !hasOpenAutonomyExecutionItems(phase);
if (phase.validationFailures >= state.maxConsecutiveFailures && !hasOpenAutonomyExecutionItems(phase)) applyRetryableBlocker(state, phase, input.summary, phase.validationFailures, undefined, input.retryAfterMinutes);
else phase.status = hasOpenAutonomyExecutionItems(phase) ? "executing" : "active";
}
}
if (input.outcome === "success") {
state.recoveryAttempts = 0;
delete state.recoveryStartedAt;
}
delete state.activeLease;
if (lease.runId) {
const run = state.runs.find((entry) => entry.id === lease.runId);
if (run) run.reportedWorkItems += 1;
else state.runs = [...state.runs, { id: lease.runId, actor: lease.actor, startedAt: lease.createdAt, reportedWorkItems: 1 }].slice(-50);
}
state.completedLeases = [...state.completedLeases, { token: lease.token, outcome: input.outcome, summary: input.summary, addedTasks: newTasks, completedAt: nowIso() }].slice(-50);
state = appendEvent(state, event(`autonomy.work.${input.outcome}`, actor, `${lease.kind} · ${phase.title}: ${input.summary}${newTasks.length ? ` Added ${newTasks.length} new task(s).` : ""}`));
const completedRoadmap = state.roadmap;
if (completedRoadmap?.status === "completed") state = appendEvent(state, event("autonomy.roadmap.completed", actor, `Roadmap '${completedRoadmap.title}' completed.`));
state = await saveState(project, state, actor);
return { state, addedTasks: newTasks, replayed: false, runId: lease.runId };
};
@@ -0,0 +1,625 @@
import type { Prisma } from "@prisma/client";
import { ApprovalState, EvidenceStrength, ReviewStatus, VerificationState } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import { listContextPacksForProject } from "./contextPacksRepository.js";
import { getProjectBySlugOrId } from "./projectsRepository.js";
import {
createBlueprintImpactReportDto,
createBlueprintNodeRunPreviewDto,
createBlueprintPlanDto,
createBlueprintRepoExportBundleDto,
type BlueprintDepthDto,
type BlueprintNodeDto,
type BlueprintPlanDto,
} from "../services/blueprintEngine.js";
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
type BlueprintDelegate = {
create: (args: unknown) => Promise<unknown>;
findFirst: (args: unknown) => Promise<unknown>;
update: (args: unknown) => Promise<unknown>;
updateMany: (args: unknown) => Promise<unknown>;
};
type BlueprintPrisma = {
blueprintGraph: BlueprintDelegate;
blueprintNode: BlueprintDelegate;
blueprintNodeRun: BlueprintDelegate;
};
const blueprintDb = prisma as unknown as BlueprintPrisma;
const findProjectRecord = (projectIdOrSlug: string) => prisma.project.findFirst({
where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] },
select: { id: true, slug: true, activeContextPackId: true },
});
type BlueprintGraphRecord = {
id: string;
slug: string | null;
readableId: string | null;
projectId: string;
project?: { slug: string };
archetypeId: string;
archetypeLabel: string;
depth: string;
status: string;
documentRoot: string;
intakeSummary: string;
assumptionsJson: Prisma.JsonValue;
exportManifestJson: Prisma.JsonValue;
recommendedNextActionsJson: Prisma.JsonValue;
metadataJson: Prisma.JsonValue | null;
createdAt: Date;
updatedAt: Date;
nodes?: BlueprintNodeRecord[];
};
type BlueprintNodeRecord = {
id: string;
templateId: string;
kind: string;
title: string;
cluster: string;
status: string;
generationOrder: number;
requiredDepth: string;
dependsOnJson: Prisma.JsonValue;
dependencyTitlesJson: Prisma.JsonValue;
blockedByJson: Prisma.JsonValue;
outputPath: string;
purpose: string;
requiredContextJson: Prisma.JsonValue;
qualityChecksJson: Prisma.JsonValue;
producesJson: Prisma.JsonValue;
promptPreview: string;
contextSelectionJson: Prisma.JsonValue;
documentationItemId: string | null;
reviewItemId: string | null;
latestRunId: string | null;
staleReason: string | null;
createdAt: Date;
updatedAt: Date;
};
type BlueprintNodeRunRecord = {
id: string;
projectId: string;
graphId: string;
nodeId: string;
status: string;
generatedBy: string;
inputSnapshotJson: Prisma.JsonValue;
prompt: string;
draftMarkdown: string;
qualityReportJson: Prisma.JsonValue;
documentationItemId: string | null;
reviewItemId: string | null;
createdAt: Date;
updatedAt: Date;
};
const asContextSelection = (value: Prisma.JsonValue) => isRecord(value) ? value : { selectedContextPackIds: [], candidates: [], decisionSummary: "No context selection metadata stored." };
const recordToNodeDto = (record: BlueprintNodeRecord, projectSlug: string): BlueprintPlanDto["nodes"][number] => ({
id: record.templateId,
title: record.title,
cluster: record.cluster as BlueprintPlanDto["nodes"][number]["cluster"],
requiredDepth: record.requiredDepth as BlueprintDepthDto,
dependsOn: toStringArray(record.dependsOnJson),
outputPath: record.outputPath,
purpose: record.purpose,
requiredContext: toStringArray(record.requiredContextJson),
qualityChecks: toStringArray(record.qualityChecksJson),
produces: toStringArray(record.producesJson),
projectId: projectSlug,
dependencyTitles: toStringArray(record.dependencyTitlesJson),
blockedBy: toStringArray(record.blockedByJson),
generationOrder: record.generationOrder,
status: record.status,
contextSelection: asContextSelection(record.contextSelectionJson) as BlueprintNodeDto["contextSelection"],
promptPreview: record.promptPreview,
exportReady: record.status !== "blocked",
documentationItemId: record.documentationItemId ?? undefined,
reviewItemId: record.reviewItemId ?? undefined,
latestRunId: record.latestRunId ?? undefined,
staleReason: record.staleReason ?? undefined,
} as BlueprintPlanDto["nodes"][number]);
const recordToPlanDto = (record: BlueprintGraphRecord): BlueprintPlanDto => {
const projectSlug = record.project?.slug ?? record.projectId;
const metadata = isRecord(record.metadataJson) ? record.metadataJson : {};
const nodes = (record.nodes ?? []).sort((left, right) => left.generationOrder - right.generationOrder).map((node) => recordToNodeDto(node, projectSlug));
const clusterIds = [...new Set(nodes.map((node) => node.cluster))];
const clusters = clusterIds.map((clusterId) => {
const clusterNodes = nodes.filter((node) => node.cluster === clusterId);
return {
id: clusterId,
label: String(clusterId).split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "),
planned: clusterNodes.length,
ready: clusterNodes.filter((node) => node.status !== "blocked").length,
blocked: clusterNodes.filter((node) => node.status === "blocked").length,
exportPaths: clusterNodes.map((node) => node.outputPath),
};
});
return {
id: record.slug ?? record.readableId ?? record.id,
projectId: projectSlug,
archetype: {
id: record.archetypeId as BlueprintPlanDto["archetype"]["id"],
label: record.archetypeLabel,
summary: typeof metadata.archetypeSummary === "string" ? metadata.archetypeSummary : "Persisted blueprint archetype.",
signals: toStringArray(metadata.archetypeSignals),
recommendedDepth: (typeof metadata.recommendedDepth === "string" ? metadata.recommendedDepth : record.depth) as BlueprintDepthDto,
clusters: toStringArray(metadata.archetypeClusters) as BlueprintPlanDto["archetype"]["clusters"],
},
depth: record.depth as BlueprintDepthDto,
status: record.status as BlueprintPlanDto["status"],
generatedAt: record.createdAt.toISOString(),
intakeSummary: record.intakeSummary,
assumptions: toStringArray(record.assumptionsJson),
documentRoot: record.documentRoot,
totalNodes: nodes.length,
readyNodes: nodes.filter((node) => node.status !== "blocked").length,
blockedNodes: nodes.filter((node) => node.status === "blocked").length,
clusters,
nodes,
exportManifest: toStringArray(record.exportManifestJson),
recommendedNextActions: toStringArray(record.recommendedNextActionsJson),
};
};
export const createAndPersistBlueprintPlan = async (projectIdOrSlug: string, depth?: BlueprintDepthDto): Promise<BlueprintPlanDto | undefined> => {
const projectRecord = await findProjectRecord(projectIdOrSlug);
if (!projectRecord) return undefined;
const project = await getProjectBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
const packs = await listContextPacksForProject(projectIdOrSlug) ?? [];
const plan = createBlueprintPlanDto(project, packs, depth);
const now = Date.now().toString(36);
const slug = `bp-${projectRecord.slug}-${plan.archetype.id}-${plan.depth}-${now}`;
const graph = await blueprintDb.blueprintGraph.create({
data: {
projectId: projectRecord.id,
slug,
readableId: slug,
archetypeId: plan.archetype.id,
archetypeLabel: plan.archetype.label,
depth: plan.depth,
status: plan.status,
documentRoot: plan.documentRoot,
intakeSummary: plan.intakeSummary,
assumptionsJson: toJson(plan.assumptions),
exportManifestJson: toJson(plan.exportManifest),
recommendedNextActionsJson: toJson(plan.recommendedNextActions),
metadataJson: toJson({ archetypeSummary: plan.archetype.summary, archetypeSignals: plan.archetype.signals, archetypeClusters: plan.archetype.clusters, recommendedDepth: plan.archetype.recommendedDepth, sourcePlanId: plan.id }),
createdBy: "api",
updatedBy: "api",
sourceType: "blueprint_engine_v52",
nodes: {
create: plan.nodes.map((node) => ({
projectId: projectRecord.id,
templateId: node.id,
kind: node.id,
title: node.title,
cluster: node.cluster,
status: node.status,
generationOrder: node.generationOrder,
requiredDepth: node.requiredDepth,
dependsOnJson: toJson(node.dependsOn),
dependencyTitlesJson: toJson(node.dependencyTitles),
blockedByJson: toJson(node.blockedBy),
outputPath: node.outputPath,
purpose: node.purpose,
requiredContextJson: toJson(node.requiredContext),
qualityChecksJson: toJson(node.qualityChecks),
producesJson: toJson(node.produces),
promptPreview: node.promptPreview,
contextSelectionJson: toJson(node.contextSelection),
createdBy: "api",
updatedBy: "api",
sourceType: "blueprint_engine_v52",
})),
},
},
include: { project: { select: { slug: true } }, nodes: true },
}) as BlueprintGraphRecord;
await prisma.operatingLogEntry.create({
data: {
projectId: projectRecord.id,
slug: `log-${projectRecord.slug}-blueprint-plan-${Date.now().toString(36)}`,
readableId: `log-${projectRecord.slug}-blueprint-plan-${Date.now().toString(36)}`,
type: "blueprint_plan_created",
title: "Blueprint plan persisted",
summary: `${plan.totalNodes} documentation nodes planned for ${plan.archetype.label} at ${plan.depth} depth.`,
linkedEntityIdsJson: toJson([graph.slug ?? graph.id]),
source: "blueprint_engine_v52",
importance: "normal",
payloadJson: toJson({ graphId: graph.slug ?? graph.id, archetypeId: plan.archetype.id, depth: plan.depth, nodes: plan.totalNodes }),
},
});
return recordToPlanDto(graph);
};
export const getLatestBlueprintPlan = async (projectIdOrSlug: string): Promise<BlueprintPlanDto | undefined> => {
const projectRecord = await findProjectRecord(projectIdOrSlug);
if (!projectRecord) return undefined;
const graph = await blueprintDb.blueprintGraph.findFirst({
where: { projectId: projectRecord.id, deletedAt: null },
orderBy: { createdAt: "desc" },
include: { project: { select: { slug: true } }, nodes: true },
}) as BlueprintGraphRecord | null;
return graph ? recordToPlanDto(graph) : undefined;
};
export const getBlueprintPlanById = async (projectIdOrSlug: string, graphId: string): Promise<BlueprintPlanDto | undefined> => {
const projectRecord = await findProjectRecord(projectIdOrSlug);
if (!projectRecord) return undefined;
const graph = await blueprintDb.blueprintGraph.findFirst({
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
include: { project: { select: { slug: true } }, nodes: true },
}) as BlueprintGraphRecord | null;
return graph ? recordToPlanDto(graph) : undefined;
};
export const runBlueprintNode = async (projectIdOrSlug: string, graphId: string, nodeId: string, options: { createDocumentation?: boolean; sendToReview?: boolean } = {}) => {
const projectRecord = await findProjectRecord(projectIdOrSlug);
if (!projectRecord) return undefined;
const graphRecord = await blueprintDb.blueprintGraph.findFirst({
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
include: { project: { select: { slug: true } }, nodes: true },
}) as BlueprintGraphRecord | null;
if (!graphRecord) return undefined;
const plan = recordToPlanDto(graphRecord);
const node = plan.nodes.find((candidate) => candidate.id === nodeId || candidate.id === nodeId);
if (!node || node.status === "blocked") return undefined;
const preview = createBlueprintNodeRunPreviewDto(plan, node);
let documentationItemId: string | undefined;
let reviewItemId: string | undefined;
if (options.createDocumentation) {
const slug = `doc-${projectRecord.slug}-${node.id}-${Date.now().toString(36)}`;
const doc = await prisma.documentationItem.create({
data: {
projectId: projectRecord.id,
slug,
readableId: slug,
title: node.title,
kind: "blueprint_document",
status: options.sendToReview ? "draft_review" : "draft",
approvalState: ApprovalState.pending,
content: preview.draftMarkdown,
metadataJson: toJson({
blueprintGraphId: graphRecord.slug ?? graphRecord.id,
blueprintNodeId: node.id,
outputPath: node.outputPath,
linkedFeatureIds: [],
primaryContextPackId: node.contextSelection.primaryContextPackId,
selectedContextPackIds: node.contextSelection.selectedContextPackIds,
contextSelectionSummary: node.contextSelection.decisionSummary,
lastUpdated: new Date().toISOString(),
}),
trustJson: toJson({ source: "blueprint node run", confidence: preview.qualityReport.score, evidence: [], lastChecked: new Date().toISOString(), approvalState: "pending" }),
provenanceJson: toJson({ createdFrom: "blueprint_node_run", graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id }),
createdBy: "api",
updatedBy: "api",
sourceType: "blueprint_engine_v52",
},
});
documentationItemId = doc.slug ?? doc.id;
}
if (options.sendToReview) {
const slug = `rq-${projectRecord.slug}-blueprint-${node.id}-${Date.now().toString(36)}`;
const review = await prisma.reviewQueueItem.create({
data: {
projectId: projectRecord.id,
slug,
readableId: slug,
type: "documentation_update",
title: `Review blueprint draft: ${node.title}`,
description: preview.draftMarkdown.slice(0, 4000),
priority: "normal",
status: ReviewStatus.pending,
source: "blueprint_engine_v52",
confidence: preview.qualityReport.score,
approvalState: ApprovalState.pending,
evidenceStrength: EvidenceStrength.medium,
verificationState: VerificationState.needs_evidence,
suggestedAction: "Review, edit and accept this generated blueprint document before exporting or treating it as project truth.",
targetEntityType: "documentation_item",
targetEntityId: documentationItemId,
payloadJson: toJson({ graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id, outputPath: node.outputPath, draftMarkdown: preview.draftMarkdown, qualityReport: preview.qualityReport, contextSelection: node.contextSelection }),
provenanceJson: toJson({ createdFrom: "blueprint_node_run", graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id }),
createdBy: "api",
updatedBy: "api",
sourceType: "blueprint_engine_v52",
},
});
reviewItemId = review.slug ?? review.id;
}
const nodeRecord = (graphRecord.nodes ?? []).find((candidate) => candidate.templateId === node.id);
if (!nodeRecord) return undefined;
const run = await blueprintDb.blueprintNodeRun.create({
data: {
projectId: projectRecord.id,
graphId: graphRecord.id,
nodeId: nodeRecord.id,
status: "needs_review",
generatedBy: "deterministic",
inputSnapshotJson: toJson(preview.inputSnapshot),
prompt: preview.prompt,
draftMarkdown: preview.draftMarkdown,
qualityReportJson: toJson(preview.qualityReport),
documentationItemId,
reviewItemId,
metadataJson: toJson({ outputPath: node.outputPath, contextSelection: node.contextSelection }),
createdBy: "api",
updatedBy: "api",
sourceType: "blueprint_engine_v52",
},
}) as BlueprintNodeRunRecord;
await blueprintDb.blueprintNode.updateMany({
where: { graphId: graphRecord.id, templateId: node.id },
data: { status: options.sendToReview ? "needs_review" : "pending", latestRunId: run.id, documentationItemId, reviewItemId, updatedBy: "api" },
});
await prisma.operatingLogEntry.create({
data: {
projectId: projectRecord.id,
slug: `log-${projectRecord.slug}-blueprint-run-${Date.now().toString(36)}`,
readableId: `log-${projectRecord.slug}-blueprint-run-${Date.now().toString(36)}`,
type: "blueprint_node_run_created",
title: `Blueprint draft generated: ${node.title}`,
summary: `Generated a review-gated blueprint draft for ${node.outputPath}.`,
linkedEntityIdsJson: toJson([graphRecord.slug ?? graphRecord.id, node.id, documentationItemId, reviewItemId].filter(Boolean)),
source: "blueprint_engine_v52",
importance: "normal",
payloadJson: toJson({ graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id, documentationItemId, reviewItemId, qualityScore: preview.qualityReport.score }),
},
});
return {
...preview,
id: run.id,
graphId: graphRecord.slug ?? graphRecord.id,
documentationItemId,
reviewItemId,
generatedAt: run.createdAt.toISOString(),
};
};
export const getBlueprintRepoExportBundle = async (projectIdOrSlug: string, graphId: string, options: { includeDrafts?: boolean } = {}) => {
const projectRecord = await findProjectRecord(projectIdOrSlug);
if (!projectRecord) return undefined;
const graph = await blueprintDb.blueprintGraph.findFirst({
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
include: { project: { select: { slug: true } }, nodes: true },
}) as BlueprintGraphRecord | null;
if (!graph) return undefined;
const plan = recordToPlanDto(graph);
const docIds = (graph.nodes ?? []).map((node) => node.documentationItemId).filter((item): item is string => Boolean(item));
const docs = docIds.length
? await prisma.documentationItem.findMany({
where: { projectId: projectRecord.id, deletedAt: null, OR: docIds.flatMap((id) => [{ id }, { slug: id }, { readableId: id }]) },
})
: [];
const draftByNodeId: Record<string, string> = {};
for (const node of graph.nodes ?? []) {
const doc = docs.find((candidate) => candidate.id === node.documentationItemId || candidate.slug === node.documentationItemId || candidate.readableId === node.documentationItemId);
if (doc?.content) draftByNodeId[node.templateId] = doc.content;
}
const bundle = createBlueprintRepoExportBundleDto(plan, { includeDrafts: options.includeDrafts, draftByNodeId });
await prisma.operatingLogEntry.create({
data: {
projectId: projectRecord.id,
slug: `log-${projectRecord.slug}-blueprint-export-${Date.now().toString(36)}`,
readableId: `log-${projectRecord.slug}-blueprint-export-${Date.now().toString(36)}`,
type: "blueprint_export_prepared",
title: "Blueprint repo export prepared",
summary: `${bundle.files.length} blueprint export file(s) prepared; ${bundle.skippedCount} node(s) skipped.`,
linkedEntityIdsJson: toJson([graph.slug ?? graph.id, ...bundle.files.map((file) => file.nodeId)]),
source: "blueprint_engine_v53",
importance: bundle.skippedCount ? "important" : "normal",
payloadJson: toJson({ graphId: graph.slug ?? graph.id, includeDrafts: bundle.includeDrafts, files: bundle.files.map((file) => file.path), warnings: bundle.warnings }),
},
});
return bundle;
};
export const getBlueprintImpactReport = async (projectIdOrSlug: string, graphId: string, nodeId: string) => {
const plan = await getBlueprintPlanById(projectIdOrSlug, graphId);
if (!plan) return undefined;
if (!plan.nodes.some((node) => node.id === nodeId)) return undefined;
return createBlueprintImpactReportDto(plan, nodeId);
};
export const syncBlueprintReviewAction = async (projectIdOrSlug: string, reviewItemId: string, action: "accept" | "reject") => {
const projectRecord = await findProjectRecord(projectIdOrSlug);
if (!projectRecord) return undefined;
const review = await prisma.reviewQueueItem.findFirst({
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }] },
});
if (!review || review.source !== "blueprint_engine_v52") return undefined;
const payload = isRecord(review.payloadJson) ? review.payloadJson : {};
const graphId = typeof payload.graphId === "string" ? payload.graphId : undefined;
const nodeId = typeof payload.nodeId === "string" ? payload.nodeId : undefined;
if (!graphId || !nodeId) return undefined;
const graph = await blueprintDb.blueprintGraph.findFirst({
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
include: { nodes: true },
}) as BlueprintGraphRecord | null;
if (!graph) return undefined;
const node = (graph.nodes ?? []).find((candidate) => candidate.templateId === nodeId || candidate.id === nodeId);
if (!node) return undefined;
const nextStatus = action === "accept" ? "accepted" : "pending";
await blueprintDb.blueprintNode.update({
where: { id: node.id },
data: { status: nextStatus, staleReason: action === "reject" ? "Latest generated draft was rejected in the review queue." : null, updatedBy: "review_queue_sync_v53" },
});
await blueprintDb.blueprintNodeRun.updateMany({
where: { projectId: projectRecord.id, nodeId: node.id, reviewItemId: review.slug ?? review.id, deletedAt: null },
data: { status: action === "accept" ? "accepted" : "failed", updatedBy: "review_queue_sync_v53" },
});
if (node.documentationItemId) {
await prisma.documentationItem.updateMany({
where: { projectId: projectRecord.id, OR: [{ id: node.documentationItemId }, { slug: node.documentationItemId }, { readableId: node.documentationItemId }] },
data: { status: action === "accept" ? "accepted" : "draft_rejected", approvalState: action === "accept" ? ApprovalState.approved : ApprovalState.rejected, updatedBy: "review_queue_sync_v53" },
});
}
let staleNodeIds: string[] = [];
if (action === "accept") {
const plan = recordToPlanDto(graph);
const impact = createBlueprintImpactReportDto(plan, node.templateId);
staleNodeIds = impact.downstreamNodeIds;
if (staleNodeIds.length) {
await blueprintDb.blueprintNode.updateMany({
where: { graphId: graph.id, templateId: { in: staleNodeIds }, status: { in: ["accepted", "needs_review", "pending", "ready"] } },
data: { status: "stale", staleReason: `${node.title} was accepted and may affect this downstream document.`, updatedBy: "staleness_engine_v53" },
});
await blueprintDb.blueprintGraph.update({ where: { id: graph.id }, data: { status: "partially_stale", updatedBy: "staleness_engine_v53" } });
}
}
await prisma.operatingLogEntry.create({
data: {
projectId: projectRecord.id,
slug: `log-${projectRecord.slug}-blueprint-review-sync-${Date.now().toString(36)}`,
readableId: `log-${projectRecord.slug}-blueprint-review-sync-${Date.now().toString(36)}`,
type: "blueprint_review_sync",
title: action === "accept" ? `Blueprint document accepted: ${node.title}` : `Blueprint draft rejected: ${node.title}`,
summary: action === "accept" ? `Accepted review item updated the blueprint node and marked ${staleNodeIds.length} downstream node(s) stale.` : "Rejected review item returned the blueprint node to pending.",
linkedEntityIdsJson: toJson([graph.slug ?? graph.id, node.templateId, review.slug ?? review.id, ...staleNodeIds]),
source: "blueprint_engine_v53",
importance: staleNodeIds.length ? "important" : "normal",
payloadJson: toJson({ graphId: graph.slug ?? graph.id, nodeId: node.templateId, reviewItemId: review.slug ?? review.id, action, staleNodeIds }),
},
});
return { graphId: graph.slug ?? graph.id, nodeId: node.templateId, action, staleNodeIds };
};
export const regenerateBlueprintNode = async (
projectIdOrSlug: string,
graphId: string,
nodeId: string,
options: { createDocumentation?: boolean; sendToReview?: boolean } = {},
) => {
const projectRecord = await findProjectRecord(projectIdOrSlug);
if (!projectRecord) return undefined;
const graph = await blueprintDb.blueprintGraph.findFirst({
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
include: { project: { select: { slug: true } }, nodes: true },
}) as BlueprintGraphRecord | null;
if (!graph) return undefined;
const node = (graph.nodes ?? []).find((candidate) => candidate.templateId === nodeId || candidate.id === nodeId);
if (!node) return undefined;
const run = await runBlueprintNode(projectIdOrSlug, graph.slug ?? graph.id, node.templateId, {
createDocumentation: options.createDocumentation ?? true,
sendToReview: options.sendToReview ?? true,
});
if (!run) return undefined;
await prisma.operatingLogEntry.create({
data: {
projectId: projectRecord.id,
slug: `log-${projectRecord.slug}-blueprint-node-regenerate-${Date.now().toString(36)}`,
readableId: `log-${projectRecord.slug}-blueprint-node-regenerate-${Date.now().toString(36)}`,
type: "blueprint_node_regenerated",
title: `Blueprint node regenerated: ${node.title}`,
summary: `Regenerated ${node.title} into a review-gated draft for ${node.outputPath}.`,
linkedEntityIdsJson: toJson([graph.slug ?? graph.id, node.templateId, run.reviewItemId, run.documentationItemId].filter(Boolean)),
source: "blueprint_engine_v55",
importance: node.status === "stale" ? "important" : "normal",
payloadJson: toJson({ graphId: graph.slug ?? graph.id, nodeId: node.templateId, previousStatus: node.status, runId: run.id, reviewItemId: run.reviewItemId, documentationItemId: run.documentationItemId }),
},
});
return {
graphId: graph.slug ?? graph.id,
nodeId: node.templateId,
previousStatus: node.status,
run,
message: `Blueprint node '${node.title}' regenerated and sent through review gates.`,
};
};
export const regenerateStaleBlueprintNodes = async (
projectIdOrSlug: string,
graphId: string,
options: { limit?: number; createDocumentation?: boolean; sendToReview?: boolean } = {},
) => {
const projectRecord = await findProjectRecord(projectIdOrSlug);
if (!projectRecord) return undefined;
const graph = await blueprintDb.blueprintGraph.findFirst({
where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] },
include: { project: { select: { slug: true } }, nodes: true },
}) as BlueprintGraphRecord | null;
if (!graph) return undefined;
const staleNodes = (graph.nodes ?? [])
.filter((node) => node.status === "stale")
.sort((left, right) => left.generationOrder - right.generationOrder)
.slice(0, Math.max(1, options.limit ?? 10));
const runs = [];
for (const node of staleNodes) {
const run = await runBlueprintNode(projectIdOrSlug, graph.slug ?? graph.id, node.templateId, {
createDocumentation: options.createDocumentation ?? true,
sendToReview: options.sendToReview ?? true,
});
if (run) runs.push(run);
}
if (runs.length) {
await blueprintDb.blueprintGraph.update({
where: { id: graph.id },
data: { status: "running", updatedBy: "api" },
});
await prisma.operatingLogEntry.create({
data: {
projectId: projectRecord.id,
slug: `log-${projectRecord.slug}-blueprint-regenerate-${Date.now().toString(36)}`,
readableId: `log-${projectRecord.slug}-blueprint-regenerate-${Date.now().toString(36)}`,
type: "blueprint_stale_nodes_regenerated",
title: "Blueprint stale nodes regenerated",
summary: `${runs.length} stale blueprint node(s) were regenerated into review-gated drafts.`,
linkedEntityIdsJson: toJson([graph.slug ?? graph.id, ...runs.map((run) => run.nodeId)]),
source: "blueprint_engine_v54",
importance: "important",
payloadJson: toJson({ graphId: graph.slug ?? graph.id, regeneratedNodeIds: runs.map((run) => run.nodeId), remainingStaleCount: Math.max(0, staleNodes.length - runs.length) }),
},
});
}
return {
graphId: graph.slug ?? graph.id,
regeneratedCount: runs.length,
regeneratedNodeIds: runs.map((run) => run.nodeId),
createdReviewItemIds: runs.map((run) => run.reviewItemId).filter(Boolean),
createdDocumentationItemIds: runs.map((run) => run.documentationItemId).filter(Boolean),
skippedCount: Math.max(0, staleNodes.length - runs.length),
message: runs.length
? `${runs.length} stale blueprint node(s) regenerated and sent through review gates.`
: "No stale blueprint nodes were available for regeneration.",
};
};
@@ -0,0 +1,518 @@
import type { Prisma } from "@prisma/client";
import { ApprovalState, EntityType, EvidenceStrength, FeatureLifecycle, FeatureStatus, ReviewStatus, VerificationState } from "@prisma/client";
import { markInitiativePlannedFromReview } from "./initiativesRepository.js";
import { prisma } from "../db/prisma.js";
export type BrainstormSessionDto = {
id: string;
projectId: string;
title: string;
prompt: string;
scope: string;
mode: string;
status: string;
targetEntityId?: string;
targetEntityType?: string;
generatedBy: string;
contextSummary: string;
ideas: unknown[];
clusters: unknown[];
outcomes: unknown[];
selectedIdeaIds: string[];
nextActions: string[];
createdAt: string;
updatedAt: string;
};
export type BrainstormConversionTarget = "feature" | "documentation" | "context_pack" | "blueprint_node" | "initiative";
export type BrainstormConversionResultDto = {
success: true;
message: string;
target: BrainstormConversionTarget;
brainstorm: BrainstormSessionDto;
createdEntityId?: string;
reviewItemId?: string;
targetRoute?: string;
affectedEntities: string[];
};
export type BrainstormReviewSyncResultDto = {
applied: boolean;
action: "accept" | "reject";
reviewItemId: string;
brainstormId?: string;
acceptedTruthId?: string;
affectedEntities: string[];
message: string;
};
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
const toArray = (value: Prisma.JsonValue | null | undefined): unknown[] => Array.isArray(value) ? value : [];
const toStringArray = (value: Prisma.JsonValue | null | undefined): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const slugify = (value: string) => value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "brainstorm";
const getProject = async (projectIdOrSlug: string) => {
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }});
if (!project) throw new Error(`Project '${projectIdOrSlug}' not found`);
return project;
};
type BrainstormRecord = Prisma.BrainstormSessionGetPayload<{ include: { project: { select: { slug: true } } } }>;
const toDto = (session: BrainstormRecord): BrainstormSessionDto => ({
id: session.id,
projectId: session.project.slug,
title: session.title,
prompt: session.prompt,
scope: session.scope,
mode: session.mode,
status: session.status,
targetEntityId: session.targetEntityId ?? undefined,
targetEntityType: session.targetEntityType ?? undefined,
generatedBy: session.generatedBy,
contextSummary: session.contextSummary,
ideas: toArray(session.ideasJson),
clusters: toArray(session.clustersJson),
outcomes: toArray(session.outcomesJson),
selectedIdeaIds: toStringArray(session.selectedIdeaIdsJson),
nextActions: toStringArray(session.nextActionsJson),
createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
});
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
const getString = (value: unknown, fallback = ""): string => typeof value === "string" ? value : fallback;
const getNumber = (value: unknown, fallback = 72): number => typeof value === "number" && Number.isFinite(value) ? value : fallback;
const getSelectedIdeas = (session: BrainstormSessionDto) => {
const selected = new Set(session.selectedIdeaIds);
const ideas = session.ideas.filter(isRecord);
const selectedIdeas = ideas.filter((idea) => selected.has(getString(idea.id)));
return selectedIdeas.length ? selectedIdeas : ideas.slice(0, 3);
};
const getTopIdea = (session: BrainstormSessionDto): Record<string, unknown> | undefined =>
[...getSelectedIdeas(session)].sort((left, right) => getNumber(right.score) - getNumber(left.score))[0];
const averageScore = (session: BrainstormSessionDto): number => {
const ideas = session.ideas.filter(isRecord);
if (!ideas.length) return 72;
return Math.round(ideas.reduce((total, idea) => total + getNumber(idea.score), 0) / ideas.length);
};
const toSelectedIdeaMarkdown = (session: BrainstormSessionDto): string => {
const selected = getSelectedIdeas(session);
if (!selected.length) return "- No selected ideas yet.";
return selected.map((idea) => `- **${getString(idea.title, "Idea")}** (${getNumber(idea.score)}/100): ${getString(idea.description, "No description.")}`).join("\n");
};
const markOutcomeConverted = (session: BrainstormSessionDto, target: BrainstormConversionTarget, targetRoute?: string) => {
const outcomeTypeByTarget: Record<BrainstormConversionTarget, string[]> = {
feature: ["feature_candidate", "implementation_slice"],
documentation: ["documentation_candidate"],
context_pack: ["context_pack_seed"],
blueprint_node: ["documentation_candidate", "implementation_slice"],
initiative: ["initiative_candidate", "implementation_slice"],
};
const targetTypes = outcomeTypeByTarget[target];
return session.outcomes.map((outcome) => {
if (!isRecord(outcome)) return outcome;
const type = getString(outcome.type);
if (!targetTypes.includes(type)) return outcome;
return { ...outcome, conversionStatus: "converted", targetRoute };
});
};
const findSession = async (projectId: string, brainstormId: string) =>
prisma.brainstormSession.findFirst({
where: { projectId, deletedAt: null, OR: [{ id: brainstormId }, { slug: brainstormId }, { readableId: brainstormId }] },
include: { project: { select: { slug: true } } },
});
export const listBrainstormsForProject = async (projectIdOrSlug: string): Promise<BrainstormSessionDto[]> => {
const project = await getProject(projectIdOrSlug);
const sessions = await prisma.brainstormSession.findMany({
where: { projectId: project.id, deletedAt: null },
include: { project: { select: { slug: true }}},
orderBy: { updatedAt: "desc" },
});
return sessions.map(toDto);
};
export const createBrainstormForProject = async (projectIdOrSlug: string, input: {
title: string;
prompt: string;
scope: string;
mode: string;
status?: string;
targetEntityId?: string;
targetEntityType?: string;
generatedBy?: string;
contextSummary?: string;
ideas?: unknown[];
clusters?: unknown[];
outcomes?: unknown[];
selectedIdeaIds?: string[];
nextActions?: string[];
}): Promise<BrainstormSessionDto> => {
const project = await getProject(projectIdOrSlug);
const slugBase = slugify(input.title);
const session = await prisma.brainstormSession.create({
data: {
projectId: project.id,
slug: `${slugBase}-${Date.now().toString(36)}`,
title: input.title,
prompt: input.prompt,
scope: input.scope,
mode: input.mode,
status: input.status ?? "exploring",
targetEntityId: input.targetEntityId,
targetEntityType: input.targetEntityType,
generatedBy: input.generatedBy ?? "api",
contextSummary: input.contextSummary ?? `${project.name} • ${input.scope}/${input.mode}`,
ideasJson: toJson(input.ideas ?? []),
clustersJson: toJson(input.clusters ?? []),
outcomesJson: toJson(input.outcomes ?? []),
selectedIdeaIdsJson: toJson(input.selectedIdeaIds ?? []),
nextActionsJson: toJson(input.nextActions ?? []),
metadataJson: toJson({ reviewBeforeTruth: true }),
},
include: { project: { select: { slug: true }}},
});
return toDto(session);
};
export const updateBrainstormForProject = async (projectIdOrSlug: string, brainstormId: string, input: Partial<{
title: string;
prompt: string;
scope: string;
mode: string;
status: string;
targetEntityId: string;
targetEntityType: string;
ideas: unknown[];
clusters: unknown[];
outcomes: unknown[];
selectedIdeaIds: string[];
nextActions: string[];
}>): Promise<BrainstormSessionDto | undefined> => {
const project = await getProject(projectIdOrSlug);
const existing = await prisma.brainstormSession.findFirst({ where: { projectId: project.id, OR: [{ id: brainstormId }, { slug: brainstormId }, { readableId: brainstormId }], deletedAt: null }});
if (!existing) return undefined;
const session = await prisma.brainstormSession.update({
where: { id: existing.id },
data: {
title: input.title,
prompt: input.prompt,
scope: input.scope,
mode: input.mode,
status: input.status,
targetEntityId: input.targetEntityId,
targetEntityType: input.targetEntityType,
ideasJson: input.ideas ? toJson(input.ideas) : undefined,
clustersJson: input.clusters ? toJson(input.clusters) : undefined,
outcomesJson: input.outcomes ? toJson(input.outcomes) : undefined,
selectedIdeaIdsJson: input.selectedIdeaIds ? toJson(input.selectedIdeaIds) : undefined,
nextActionsJson: input.nextActions ? toJson(input.nextActions) : undefined,
},
include: { project: { select: { slug: true }}},
});
return toDto(session);
};
const createReviewForConversion = async (projectId: string, projectSlug: string, session: BrainstormSessionDto, target: BrainstormConversionTarget, createdEntityId?: string, targetRoute?: string) => {
const now = new Date();
const readableTarget = target.replace("_", " ");
const slug = `rq-brainstorm-${slugify(session.title)}-${target}-${now.getTime()}`;
const record = await prisma.reviewQueueItem.create({
data: {
projectId,
slug,
readableId: slug,
type: target === "feature" ? "feature" : target === "documentation" ? "documentation_update" : target === "context_pack" ? "knowledge" : target === "initiative" ? "initiative" : "documentation_update",
title: `Review brainstorm conversion: ${session.title}`,
description: `Brainstorm CPU proposes converting '${session.title}' into a ${readableTarget}.\n\nSelected ideas:\n${toSelectedIdeaMarkdown(session)}`,
priority: target === "feature" ? "important" : "normal",
status: ReviewStatus.pending,
source: "brainstorm_cpu",
confidence: averageScore(session),
approvalState: ApprovalState.pending,
evidenceStrength: EvidenceStrength.medium,
verificationState: VerificationState.needs_evidence,
suggestedAction: `Review the brainstorm output before accepting the ${readableTarget} as project truth.`,
createdBy: "api",
updatedBy: "api",
sourceType: "brainstorm_cpu_conversion",
createdEntityType: target === "feature" ? EntityType.feature : target === "documentation" ? EntityType.documentation_item : target === "context_pack" ? EntityType.context_pack : target === "initiative" ? EntityType.initiative : undefined,
createdEntityId,
targetEntityType: undefined,
targetEntityId: session.id,
payloadJson: toJson({ brainstormId: session.id, conversionTarget: target, createdEntityId, targetRoute, selectedIdeaIds: session.selectedIdeaIds }),
provenanceJson: toJson({ createdFrom: "Brainstorm CPU conversion", projectSlug }),
},
});
return record.slug ?? record.id;
};
export const convertBrainstormForProject = async (
projectIdOrSlug: string,
brainstormId: string,
target: BrainstormConversionTarget,
): Promise<BrainstormConversionResultDto | undefined> => {
const project = await getProject(projectIdOrSlug);
const record = await findSession(project.id, brainstormId);
if (!record) return undefined;
const session = toDto(record);
const topIdea = getTopIdea(session);
const score = averageScore(session);
const suffix = `${slugify(session.title)}-${Date.now().toString(36)}`;
let createdEntityId: string | undefined;
let targetRoute: string | undefined;
if (target === "feature") {
const group = await prisma.featureGroup.upsert({
where: { projectId_slug: { projectId: project.id, slug: "brainstorm-lab" } },
create: { projectId: project.id, slug: "brainstorm-lab", readableId: "brainstorm-lab", name: "Brainstorm Lab", description: "Feature candidates converted from Brainstorm CPU sessions.", focusArea: "Exploration" },
update: {},
});
const feature = await prisma.feature.create({
data: {
projectId: project.id,
featureGroupId: group.id,
slug: `brainstorm-feature-${suffix}`,
readableId: `brainstorm-feature-${suffix}`,
name: session.title,
description: getString(topIdea?.description, session.prompt),
status: FeatureStatus.todo,
lifecycle: FeatureLifecycle.feature_candidate,
intent: `Converted from Brainstorm CPU session ${session.id}. Review before implementation.`,
confidence: score,
health: 70,
approvalState: ApprovalState.pending,
evidenceStrength: EvidenceStrength.medium,
verificationState: VerificationState.needs_evidence,
trustJson: toJson({ source: "brainstorm_cpu", confidence: score, brainstormId: session.id }),
provenanceJson: toJson({ sourceBrainstormId: session.id, selectedIdeaIds: session.selectedIdeaIds }),
sourceType: "brainstorm_cpu_conversion",
},
});
createdEntityId = feature.slug ?? feature.id;
targetRoute = `/projects/${project.slug}/features/${feature.slug ?? feature.id}`;
}
if (target === "documentation") {
const doc = await prisma.documentationItem.create({
data: {
projectId: project.id,
slug: `brainstorm-doc-${suffix}`,
readableId: `brainstorm-doc-${suffix}`,
title: `Brainstorm documentation seed: ${session.title}`,
kind: "brainstorm_seed",
status: "draft",
approvalState: ApprovalState.pending,
content: `# ${session.title}\n\n${session.prompt}\n\n## Selected ideas\n${toSelectedIdeaMarkdown(session)}\n`,
metadataJson: toJson({ brainstormId: session.id, selectedIdeaIds: session.selectedIdeaIds }),
provenanceJson: toJson({ source: "brainstorm_cpu_conversion" }),
sourceType: "brainstorm_cpu_conversion",
},
});
createdEntityId = doc.slug ?? doc.id;
targetRoute = `/projects/${project.slug}/documentation`;
}
if (target === "context_pack") {
const pack = await prisma.contextPack.create({
data: {
projectId: project.id,
slug: `brainstorm-context-${suffix}`,
readableId: `brainstorm-context-${suffix}`,
title: `Brainstorm context: ${session.title}`,
category: "brainstorm",
summary: `Reusable context generated from Brainstorm CPU session '${session.title}'.`,
generatedBy: "brainstorm_cpu",
confidence: score,
approvalState: ApprovalState.pending,
qualityJson: toJson({ coverage: score, reviewBeforeTruth: true }),
trustJson: toJson({ source: "brainstorm_cpu", brainstormId: session.id }),
provenanceJson: toJson({ selectedIdeaIds: session.selectedIdeaIds }),
sourceType: "brainstorm_cpu_conversion",
sections: {
create: [
{ projectId: project.id, slug: "selected-ideas", title: "Selected ideas", sectionType: "signals", orderIndex: 1, contentJson: toJson(getSelectedIdeas(session)) },
{ projectId: project.id, slug: "outcomes", title: "Outcome candidates", sectionType: "outcomes", orderIndex: 2, contentJson: toJson(session.outcomes) },
{ projectId: project.id, slug: "next-actions", title: "Next actions", sectionType: "actions", orderIndex: 3, contentJson: toJson(session.nextActions) },
],
},
},
});
createdEntityId = pack.slug ?? pack.id;
targetRoute = `/projects/${project.slug}/context-packs`;
}
if (target === "initiative") {
const initiative = await prisma.initiative.create({
data: {
projectId: project.id,
slug: `brainstorm-initiative-${suffix}`,
readableId: `brainstorm-initiative-${suffix}`,
title: `Brainstorm initiative: ${session.title}`,
intent: `${session.prompt}\n\nSelected ideas:\n${toSelectedIdeaMarkdown(session)}`,
status: "proposed",
priority: getSelectedIdeas(session).some((idea) => getString(idea.impact) === "high") ? "important" : "normal",
confidence: score,
sourceBrainstormId: session.id,
featureIdsJson: toJson([]),
approvalState: ApprovalState.pending,
metadataJson: toJson({ sourceBrainstormId: session.id, selectedIdeaIds: session.selectedIdeaIds, reviewBeforeTruth: true }),
provenanceJson: toJson({ source: "brainstorm_cpu_conversion" }),
},
});
createdEntityId = initiative.slug ?? initiative.id;
targetRoute = `/projects/${project.slug}/initiatives`;
}
if (target === "blueprint_node") {
createdEntityId = `blueprint-seed-${suffix}`;
targetRoute = `/projects/${project.slug}/blueprint?brainstormId=${session.id}`;
}
const reviewItemId = await createReviewForConversion(project.id, project.slug, session, target, createdEntityId, targetRoute);
const updatedSession = await prisma.brainstormSession.update({
where: { id: record.id },
data: {
status: "converted",
outcomesJson: toJson(markOutcomeConverted(session, target, targetRoute)),
metadataJson: toJson({ reviewBeforeTruth: true, lastConversionTarget: target, lastConvertedEntityId: createdEntityId, lastReviewItemId: reviewItemId }),
},
include: { project: { select: { slug: true }}},
});
await prisma.operatingLogEntry.create({
data: {
projectId: project.id,
slug: `log-brainstorm-conversion-${suffix}`,
readableId: `log-brainstorm-conversion-${suffix}`,
type: "brainstorm conversion",
title: `Brainstorm converted: ${session.title}`,
summary: `Brainstorm CPU converted selected ideas into ${target.replace("_", " ")} and queued review before truth.`,
linkedEntityIdsJson: toJson([session.id, reviewItemId, createdEntityId].filter(Boolean)),
source: "brainstorm_cpu",
importance: target === "feature" ? "important" : "normal",
payloadJson: toJson({ target, targetRoute }),
provenanceJson: toJson({ sourceBrainstormId: session.id }),
},
});
return {
success: true,
message: `Brainstorm '${session.title}' converted to ${target.replace("_", " ")} candidate and queued for review.`,
target,
brainstorm: toDto(updatedSession),
createdEntityId,
reviewItemId,
targetRoute,
affectedEntities: [session.id, reviewItemId, createdEntityId].filter((item): item is string => Boolean(item)),
};
};
export const syncBrainstormReviewAction = async (
projectIdOrSlug: string,
reviewItemId: string,
action: "accept" | "reject",
): Promise<BrainstormReviewSyncResultDto | undefined> => {
const project = await getProject(projectIdOrSlug);
const review = await prisma.reviewQueueItem.findFirst({
where: { projectId: project.id, deletedAt: null, OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }] },
});
if (!review) return undefined;
if (review.source !== "brainstorm_cpu" && review.sourceType !== "brainstorm_cpu_conversion") return undefined;
const payload = isRecord(review.payloadJson) ? review.payloadJson : {};
const brainstormId = getString(payload.brainstormId, review.targetEntityType === EntityType.brainstorm_session ? review.targetEntityId ?? "" : "");
const affectedEntities = [review.slug ?? review.id];
if (brainstormId) {
await prisma.brainstormSession.updateMany({
where: { projectId: project.id, OR: [{ id: brainstormId }, { slug: brainstormId }, { readableId: brainstormId }] },
data: { status: action === "accept" ? "converted" : "shortlisted" },
});
affectedEntities.push(brainstormId);
}
if (action === "accept" && getString(payload.conversionTarget) === "initiative" && review.createdEntityId) {
const plannedInitiativeId = await markInitiativePlannedFromReview(project.id, review.createdEntityId, review.slug ?? review.id);
if (plannedInitiativeId) affectedEntities.push(plannedInitiativeId);
}
let acceptedTruthId: string | undefined;
if (action === "accept") {
const existing = await prisma.acceptedTruthItem.findFirst({ where: { projectId: project.id, sourceReviewItemId: review.id, deletedAt: null } });
if (existing) acceptedTruthId = existing.slug ?? existing.id;
else {
const now = new Date();
const slug = `truth-brainstorm-${slugify(review.title)}-${now.getTime()}`;
const truth = await prisma.acceptedTruthItem.create({
data: {
projectId: project.id,
slug,
readableId: slug,
sourceReviewItemId: review.id,
kind: review.type === "documentation_update" ? "documentation" : review.type,
title: review.title,
summary: review.description,
status: "accepted",
source: "brainstorm_cpu",
confidence: review.confidence,
approvalState: ApprovalState.approved,
evidenceStrength: review.evidenceStrength,
verificationState: VerificationState.verified,
payloadJson: toJson({
reviewItemId: review.id,
brainstormId,
conversionTarget: payload.conversionTarget,
createdEntityId: payload.createdEntityId,
targetRoute: payload.targetRoute,
candidateSummary: review.description,
nextAction: action === "accept" ? "Use this accepted brainstorm truth in context packs, roadmap, blueprint docs, or implementation planning." : undefined,
}),
provenanceJson: toJson({
createdFrom: "Brainstorm CPU review acceptance",
reviewSource: review.source,
reviewSourceType: review.sourceType,
reviewTargetEntityType: review.targetEntityType,
reviewTargetEntityId: review.targetEntityId,
}),
verifiedAt: now,
verifiedBy: "api",
},
});
acceptedTruthId = truth.slug ?? truth.id;
}
if (acceptedTruthId) affectedEntities.push(acceptedTruthId);
}
await prisma.operatingLogEntry.create({
data: {
projectId: project.id,
slug: `log-brainstorm-review-${slugify(review.title)}-${Date.now()}`,
readableId: `log-brainstorm-review-${slugify(review.title)}-${Date.now()}`,
type: "brainstorm review sync",
title: `Brainstorm review ${action === "accept" ? "accepted" : "rejected"}`,
summary: action === "accept" ? "Accepted brainstorm conversion was materialized as accepted truth." : "Rejected brainstorm conversion was returned to shortlist state.",
linkedEntityIdsJson: toJson(affectedEntities),
source: "brainstorm_cpu",
importance: action === "accept" ? "important" : "normal",
payloadJson: toJson({ action, brainstormId, acceptedTruthId }),
provenanceJson: toJson({ reviewItemId: review.id }),
},
});
return {
applied: true,
action,
reviewItemId: review.slug ?? review.id,
brainstormId,
acceptedTruthId,
affectedEntities,
message: action === "accept" ? "Brainstorm review accepted and materialized as accepted truth." : "Brainstorm review rejected and brainstorm returned to shortlist state.",
};
};
@@ -0,0 +1,235 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import type {
CaptureClassificationDto,
CaptureClassificationType,
CaptureItemDto,
CaptureItemListFilters,
CaptureItemStatus,
CaptureItemType,
CreateCaptureItemRequestDto,
} from "../types/api.js";
type CaptureRecord = Prisma.CaptureItemGetPayload<Record<string, never>>;
type ProjectRecord = { id: string; slug: string };
const toIso = (value: Date): string => value.toISOString();
const findProjectRecord = (projectIdOrSlug: string): Promise<ProjectRecord | null> =>
prisma.project.findFirst({
where: {
deletedAt: null,
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
},
select: { id: true, slug: true },
});
const toCaptureDto = (
record: CaptureRecord,
projectSlug: string,
): CaptureItemDto => ({
id: record.slug ?? record.readableId ?? record.id,
projectId: projectSlug,
type: record.type as CaptureItemType,
title: record.title,
content: record.content,
source: record.source,
status: record.status as CaptureItemStatus,
...(record.classificationType
? { classificationType: record.classificationType as CaptureClassificationType }
: {}),
...(record.confidence !== null ? { confidence: record.confidence } : {}),
...(record.evidenceStrength ? { evidenceStrength: record.evidenceStrength } : {}),
...(record.suggestedAction ? { suggestedAction: record.suggestedAction } : {}),
createdAt: toIso(record.createdAt),
updatedAt: toIso(record.updatedAt),
...(record.linkedReviewItemId ? { linkedReviewItemId: record.linkedReviewItemId } : {}),
});
const splitSearch = (value: string | undefined): string | undefined =>
value?.trim() || undefined;
const toWhere = (
projectId: string,
filters: CaptureItemListFilters = {},
): Prisma.CaptureItemWhereInput => ({
projectId,
deletedAt: null,
...(filters.status?.length ? { status: { in: filters.status } } : {}),
...(filters.type?.length ? { type: { in: filters.type } } : {}),
...(filters.source?.length ? { source: { in: filters.source } } : {}),
...(filters.confidence !== undefined ? { confidence: filters.confidence } : {}),
...(filters.classificationType?.length
? { classificationType: { in: filters.classificationType } }
: {}),
});
const classifyContent = (
item: CaptureItemDto,
): Omit<CaptureClassificationDto, "captureItemId"> => {
const text = `${item.type} ${item.content}`.toLowerCase();
if (text.includes("bug") || text.includes("error")) {
return {
classificationType: "bug",
confidence: 86,
evidenceStrength: "medium",
suggestedAction: "Send to review as bug candidate.",
};
}
if (text.includes("task") || text.includes("todo")) {
return {
classificationType: "task",
confidence: 80,
evidenceStrength: "medium",
suggestedAction: "Send to review as task candidate.",
};
}
if (text.includes("decision")) {
return {
classificationType: "decision",
confidence: 82,
evidenceStrength: "medium",
suggestedAction: "Send to review as decision candidate.",
};
}
if (text.includes("rule") || text.includes("must") || text.includes("do not")) {
return {
classificationType: "memory_rule",
confidence: 90,
evidenceStrength: "strong",
suggestedAction: "Send to review as memory rule.",
};
}
if (text.includes("docs") || text.includes("documentation")) {
return {
classificationType: "documentation_update",
confidence: 78,
evidenceStrength: "weak",
suggestedAction: "Send to review as documentation update.",
};
}
return {
classificationType: "knowledge",
confidence: 72,
evidenceStrength: "weak",
suggestedAction: "Send to review as knowledge item.",
};
};
export const listCaptureItemsFromDatabase = async (
projectIdOrSlug: string,
filters: CaptureItemListFilters,
options: { page: number; pageSize: number; cursor?: string; sort?: string },
) => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const where = toWhere(project.id, filters);
const total = await prisma.captureItem.count({ where });
const records = await prisma.captureItem.findMany({
where,
orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
skip: (options.page - 1) * options.pageSize,
take: options.pageSize,
});
const pageCount = Math.ceil(total / options.pageSize);
return {
items: records.map((record) => toCaptureDto(record, project.slug)),
total,
page: options.page,
pageSize: options.pageSize,
hasNextPage: options.page < pageCount,
hasPreviousPage: options.page > 1,
...(options.sort ? { sort: options.sort } : {}),
...(options.cursor ? { nextCursor: undefined, cursor: options.cursor } : {}),
filters,
};
};
export const getCaptureItemFromDatabase = async (
projectIdOrSlug: string,
captureItemId: string,
): Promise<CaptureItemDto | undefined> => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const record = await prisma.captureItem.findFirst({
where: {
projectId: project.id,
deletedAt: null,
OR: [{ id: captureItemId }, { slug: captureItemId }, { readableId: captureItemId }],
},
});
return record ? toCaptureDto(record, project.slug) : undefined;
};
export const createCaptureItemInDatabase = async (
projectIdOrSlug: string,
body: CreateCaptureItemRequestDto,
): Promise<CaptureItemDto | undefined> => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const createdAt = new Date();
const title = body.content.trim().slice(0, 64) || "Captured item";
const slug = `cap-${project.slug}-${createdAt.getTime()}`;
const record = await prisma.captureItem.create({
data: {
projectId: project.id,
slug,
readableId: slug,
type: body.type,
title,
content: body.content.trim(),
source: body.source?.trim() || "manual",
status: "captured",
sourceType: "runtime_api",
createdBy: "api",
updatedBy: "api",
createdAt,
metadataJson: {},
provenanceJson: { createdFrom: "P5 capture API" },
},
});
return toCaptureDto(record, project.slug);
};
export const classifyCaptureItemInDatabase = async (
projectIdOrSlug: string,
captureItemId: string,
): Promise<CaptureClassificationDto | undefined | "CAPTURE_ALREADY_IN_REVIEW"> => {
const item = await getCaptureItemFromDatabase(projectIdOrSlug, captureItemId);
if (!item) return undefined;
if (item.status === "in_review") return "CAPTURE_ALREADY_IN_REVIEW";
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const classification = classifyContent(item);
const update = await prisma.captureItem.updateMany({
where: {
projectId: project.id,
deletedAt: null,
status: { in: ["captured", "classified"] },
linkedReviewItemId: null,
OR: [{ id: captureItemId }, { slug: captureItemId }, { readableId: captureItemId }],
},
data: {
status: "classified",
classificationType: classification.classificationType,
confidence: classification.confidence,
evidenceStrength: classification.evidenceStrength,
suggestedAction: classification.suggestedAction,
updatedBy: "api",
},
});
if (update.count !== 1) return "CAPTURE_ALREADY_IN_REVIEW";
return {
captureItemId,
...classification,
};
};
@@ -0,0 +1,367 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import type {
ContextPackDto,
ContextPackSummaryDto,
CreateContextPackRequestDto,
EvidenceRefDto,
GeneratePromptRequestDto,
GeneratedPromptDto,
ProjectDna,
} from "../types/api.js";
const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) =>
value.slug ?? value.readableId ?? value.id;
const toJson = (value: unknown): Prisma.InputJsonValue =>
JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const asStringArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const asEvidenceRefs = (value: unknown): EvidenceRefDto[] =>
Array.isArray(value) ? (value as EvidenceRefDto[]) : [];
type ContextPackMetadata = {
dna?: ProjectDna;
assumptions?: string[];
constraints?: string[];
evidence?: EvidenceRefDto[];
signals?: string[];
featureIds?: string[];
bugIds?: string[];
taskIds?: string[];
decisionIds?: string[];
memoryRuleIds?: string[];
architectureNotes?: string[];
regressions?: string[];
risks?: string[];
currentFocus?: ContextPackDto["currentFocus"];
};
const toMetadata = (value: Prisma.JsonValue | null): ContextPackMetadata =>
isRecord(value) ? (value as ContextPackMetadata) : {};
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
prisma.project.findFirst({
where: {
deletedAt: null,
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
},
include: {
dna: true,
},
});
const toProjectDna = (project: NonNullable<Awaited<ReturnType<typeof findProjectRecordBySlugOrId>>>): ProjectDna => ({
vision: project.dna?.vision ?? "",
goals: asStringArray(project.dna?.goalsJson),
nonGoals: asStringArray(project.dna?.nonGoalsJson),
targetAudience: project.dna?.targetAudience ?? "",
principles: asStringArray(project.dna?.principlesJson),
successMetrics: Array.isArray(project.dna?.metricsJson) ? (project.dna?.metricsJson as ProjectDna["successMetrics"]) : [],
maturityStage: (project.dna?.maturityStage as ProjectDna["maturityStage"]) ?? "Discovery",
revision: project.dna?.revision ?? 1,
updatedAt: project.dna?.updatedAt.toISOString() ?? project.updatedAt.toISOString(),
});
type DbContextPackRecord = Awaited<ReturnType<typeof prisma.contextPack.findFirst>> & {
sections?: Array<{
id: string;
slug: string | null;
title: string;
sectionType: string;
orderIndex: number;
contentJson: Prisma.JsonValue;
metadataJson: Prisma.JsonValue | null;
}>;
};
const toContextPackDto = async (
contextPack: NonNullable<DbContextPackRecord>,
): Promise<ContextPackDto> => {
const project = await findProjectRecordBySlugOrId(contextPack.projectId);
const metadata = toMetadata(contextPack.metadataJson);
const quality = isRecord(contextPack.qualityJson) ? (contextPack.qualityJson as ContextPackDto["quality"]) : undefined;
const trust = isRecord(contextPack.trustJson)
? (contextPack.trustJson as ContextPackDto["trust"])
: {
source: "database context pack",
confidence: contextPack.confidence,
evidence: [],
lastChecked: contextPack.updatedAt.toISOString(),
approvalState: contextPack.approvalState,
};
return {
id: toPublicId(contextPack),
projectId: project?.slug ?? contextPack.projectId,
title: contextPack.title,
category: contextPack.category as ContextPackDto["category"],
dna: metadata.dna ?? (project ? toProjectDna(project) : {
vision: "",
goals: [],
nonGoals: [],
targetAudience: "",
principles: [],
successMetrics: [],
maturityStage: "Discovery",
revision: 0,
updatedAt: contextPack.updatedAt.toISOString(),
}),
summary: contextPack.summary,
assumptions: metadata.assumptions ?? [],
constraints: metadata.constraints ?? [],
evidence: metadata.evidence ?? [],
signals: metadata.signals ?? [],
featureIds: metadata.featureIds,
bugIds: metadata.bugIds,
taskIds: metadata.taskIds,
decisionIds: metadata.decisionIds,
memoryRuleIds: metadata.memoryRuleIds,
architectureNotes: metadata.architectureNotes,
regressions: metadata.regressions,
risks: metadata.risks,
quality,
currentFocus: metadata.currentFocus,
trust,
generatedBy: contextPack.generatedBy as ContextPackDto["generatedBy"],
createdAt: contextPack.createdAt.toISOString(),
updatedAt: contextPack.updatedAt.toISOString(),
};
};
export const projectExists = async (projectIdOrSlug: string): Promise<boolean> =>
Boolean(await findProjectRecordBySlugOrId(projectIdOrSlug));
export const listContextPacksForProject = async (
projectIdOrSlug: string,
): Promise<ContextPackDto[] | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
const contextPacks = await prisma.contextPack.findMany({
where: { projectId: project.id, deletedAt: null },
orderBy: [{ updatedAt: "desc" }],
include: { sections: { orderBy: { orderIndex: "asc" } } },
});
return Promise.all(contextPacks.map(toContextPackDto));
};
export const getContextPackBySlugOrId = async (
projectIdOrSlug: string,
contextPackIdOrSlug: string,
): Promise<ContextPackDto | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
const contextPack = await prisma.contextPack.findFirst({
where: {
projectId: project.id,
deletedAt: null,
OR: [
{ id: contextPackIdOrSlug },
{ slug: contextPackIdOrSlug },
{ readableId: contextPackIdOrSlug },
],
},
include: { sections: { orderBy: { orderIndex: "asc" } } },
});
return contextPack ? toContextPackDto(contextPack) : undefined;
};
export const getContextPackSummaryBySlugOrId = async (
projectIdOrSlug: string,
contextPackIdOrSlug: string,
): Promise<ContextPackSummaryDto | undefined> => {
const contextPack = await getContextPackBySlugOrId(projectIdOrSlug, contextPackIdOrSlug);
if (!contextPack) return undefined;
return {
projectId: contextPack.projectId,
contextPackId: contextPack.id,
title: contextPack.title,
category: contextPack.category,
summary: contextPack.summary,
counts: {
assumptions: contextPack.assumptions.length,
constraints: contextPack.constraints.length,
evidence: contextPack.evidence.length,
features: contextPack.featureIds?.length ?? 0,
bugs: contextPack.bugIds?.length ?? 0,
tasks: contextPack.taskIds?.length ?? 0,
decisions: contextPack.decisionIds?.length ?? 0,
memoryRules: contextPack.memoryRuleIds?.length ?? 0,
risks: contextPack.risks?.length ?? 0,
},
qualityScore: contextPack.quality?.score,
createdAt: contextPack.createdAt,
updatedAt: contextPack.updatedAt,
};
};
const toGeneratedBy = (sourceType: CreateContextPackRequestDto["sourceType"]): ContextPackDto["generatedBy"] => {
if (sourceType === "check") return "check-run";
if (sourceType === "manual") return "manual";
return "user";
};
export const createContextPackForProject = async (
projectIdOrSlug: string,
request: CreateContextPackRequestDto,
): Promise<ContextPackDto | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
const dna = toProjectDna(project);
const features = await prisma.feature.findMany({
where: { projectId: project.id, deletedAt: null },
orderBy: { name: "asc" },
});
const evidence = await prisma.evidence.findMany({
where: { projectId: project.id, deletedAt: null },
orderBy: { lastSeen: "desc" },
});
const featureIds =
request.includeFeatures === false
? []
: request.sourceType === "feature" && request.sourceId
? [request.sourceId]
: features.map((feature) => feature.slug ?? feature.readableId ?? feature.id);
const evidenceRefs: EvidenceRefDto[] =
request.includeEvidence === false
? []
: evidence.slice(0, 10).map((item) => ({
id: `er-${item.slug ?? item.readableId ?? item.id}`,
type: "analysis",
refId: item.slug ?? item.readableId ?? item.id,
label: item.reason,
}));
const createdAt = new Date();
const slug = `cp-${project.slug}-${request.sourceType}-${createdAt.getTime().toString(36)}`;
const title = request.title?.trim() || `${project.name} ${request.sourceType.replace("_", " ")} context pack`;
const metadata: ContextPackMetadata = {
dna,
assumptions: request.sourceType === "manual" ? ["Manual context source requires human review."] : ["Database-generated context should be verified before use."],
constraints: ["Context pack generated from database-backed project data."],
evidence: evidenceRefs,
signals: ["database-generated", request.sourceType],
featureIds,
memoryRuleIds: [],
architectureNotes: request.includeArchitecture === false ? [] : [`Database-backed architecture context for ${project.name}.`],
risks: request.includeRisks === false ? [] : ["Database-generated context still needs review before implementation."],
regressions: request.includeRegressions === false ? [] : [],
};
const quality = {
dnaIncluded: true,
constitutionIncluded: false,
constraintsIncluded: true,
featuresIncluded: request.includeFeatures !== false,
evidenceIncluded: request.includeEvidence !== false,
risksIncluded: request.includeRisks !== false,
memoryIncluded: request.includeMemoryRules !== false,
doNotBreakIncluded: true,
verificationIncluded: false,
score: request.includeEvidence === false ? 58 : 74,
};
const trust = {
source: "database context-pack create",
confidence: request.includeEvidence === false ? 64 : 82,
evidence: evidenceRefs,
lastChecked: createdAt.toISOString(),
approvalState: "pending" as const,
};
const contextPack = await prisma.contextPack.create({
data: {
projectId: project.id,
slug,
readableId: slug,
title,
category: request.sourceType === "feature" ? "feature" : "quality",
summary: `Created from database project ${project.slug} via ${request.sourceType} context source. DNA is always included.`,
generatedBy: toGeneratedBy(request.sourceType),
confidence: trust.confidence,
approvalState: "pending",
qualityJson: toJson(quality),
trustJson: toJson(trust),
metadataJson: toJson(metadata),
createdBy: "runtime",
updatedBy: "runtime",
sourceType: "database_runtime",
sections: {
create: [
{
projectId: project.id,
slug: "dna",
title: "Project DNA",
sectionType: "dna",
orderIndex: 1,
contentJson: toJson(dna),
},
{
projectId: project.id,
slug: "features",
title: "Relevant features",
sectionType: "features",
orderIndex: 2,
contentJson: toJson(featureIds),
},
{
projectId: project.id,
slug: "evidence",
title: "Evidence references",
sectionType: "evidence",
orderIndex: 3,
contentJson: toJson(evidenceRefs),
},
],
},
},
include: { sections: { orderBy: { orderIndex: "asc" } } },
});
return toContextPackDto(contextPack);
};
export const generatePromptFromContextPack = async (
projectIdOrSlug: string,
request: GeneratePromptRequestDto,
): Promise<GeneratedPromptDto | undefined> => {
const contextPack = await getContextPackBySlugOrId(projectIdOrSlug, request.contextPackId);
if (!contextPack) return undefined;
const createdAt = new Date().toISOString();
const promptId = `prompt-${contextPack.projectId}-${Date.now().toString(36)}`;
const expectedOutput = request.expectedOutput?.trim() || "Clear implementation notes and validation steps.";
const generatedText = [
`Generated from context pack ${contextPack.id} (${contextPack.title}).`,
`Target tool: ${request.targetTool}.`,
`Persona: ${request.persona}.`,
`Objective: ${request.objective.trim()}.`,
`Expected output: ${expectedOutput}`,
`Context summary: ${contextPack.summary}`,
`Project vision: ${contextPack.dna.vision}`,
`Relevant features: ${(contextPack.featureIds ?? []).join(", ") || "none"}.`,
`Risks: ${(contextPack.risks ?? []).join(" ") || "none listed"}.`,
"Use only the supplied context pack facts and mark uncertainty explicitly.",
].join("\n");
return {
id: promptId,
projectId: contextPack.projectId,
contextPackId: contextPack.id,
targetTool: request.targetTool,
persona: request.persona,
objective: request.objective.trim(),
expectedOutput,
generatedText,
generatedFrom: "context-pack",
createdAt,
};
};
@@ -0,0 +1,119 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import type { ApprovalState, EvidenceDto, EvidenceType } from "../types/api.js";
const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) =>
value.slug ?? value.readableId ?? value.id;
type EvidenceMetadata = {
linkedEntity?: EvidenceDto["linkedEntity"];
featureIds?: string[];
route?: string;
};
type DbEvidenceRecord = {
id: string;
projectId: string;
slug: string | null;
readableId: string | null;
type: string;
source: string;
confidence: number;
approvalState: string;
reason: string;
metadataJson: Prisma.JsonValue | null;
lastSeen: Date;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const toEvidenceMetadata = (value: Prisma.JsonValue | null): EvidenceMetadata => {
if (!isRecord(value)) return {};
return value as EvidenceMetadata;
};
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
prisma.project.findFirst({
where: {
deletedAt: null,
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
},
});
const toEvidenceDto = (
evidence: DbEvidenceRecord,
projectSlug: string,
): EvidenceDto => {
const metadata = toEvidenceMetadata(evidence.metadataJson);
return {
id: toPublicId(evidence),
projectId: projectSlug,
type: evidence.type as EvidenceType,
linkedEntity: metadata.linkedEntity ?? {
type: "feature",
id: "unknown",
label: "Unknown linked entity",
},
featureIds: metadata.featureIds ?? [],
source: evidence.source,
confidence: evidence.confidence,
reason: evidence.reason,
lastSeen: evidence.lastSeen.toISOString(),
approvalState: evidence.approvalState as ApprovalState,
route: metadata.route,
};
};
export const listEvidenceForProject = async (
projectIdOrSlug: string,
): Promise<EvidenceDto[] | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
const evidence = await prisma.evidence.findMany({
where: {
projectId: project.id,
deletedAt: null,
},
orderBy: [{ lastSeen: "desc" }],
});
return evidence.map((item) => toEvidenceDto(item, project.slug));
};
export const getEvidenceBySlugOrId = async (
projectIdOrSlug: string,
evidenceIdOrSlug: string,
): Promise<EvidenceDto | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
const evidence = await prisma.evidence.findFirst({
where: {
projectId: project.id,
deletedAt: null,
OR: [{ id: evidenceIdOrSlug }, { slug: evidenceIdOrSlug }, { readableId: evidenceIdOrSlug }],
},
});
if (!evidence) return undefined;
return toEvidenceDto(evidence, project.slug);
};
export const listEvidenceForFeature = async (
projectIdOrSlug: string,
featureIdOrSlug: string,
): Promise<EvidenceDto[] | undefined> => {
const evidence = await listEvidenceForProject(projectIdOrSlug);
if (!evidence) return undefined;
return evidence.filter((item) => item.featureIds.includes(featureIdOrSlug));
};
export const projectExists = async (projectIdOrSlug: string): Promise<boolean> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
return Boolean(project);
};
@@ -0,0 +1,360 @@
import {
ApprovalState,
DeliveryChangeOperation,
DeliveryStatus,
DeliveryValidationStatus,
type Prisma,
} from "@prisma/client";
import { randomUUID } from "node:crypto";
import { prisma } from "../db/prisma.js";
import { createInitiativeForProject } from "./initiativesRepository.js";
export type FeatureDeliveryStatus =
| "draft" | "awaiting_approval" | "approved" | "executing" | "validating"
| "pull_request_open" | "merged" | "verified" | "released" | "blocked"
| "failed" | "cancelled" | "superseded";
export type FeatureDeliveryRiskLevel = "low" | "medium" | "high" | "destructive";
export type FeatureValidationResult = { command: string; status: "passed" | "failed" | "not_run"; summary?: string };
export type FeatureValidationEvidence = {
provider: "gitea_actions"; runId: number; runNumber: number; url: string; workflowPath: string;
headSha: string; conclusion: "success"; verifiedAt: string;
};
export type FeatureDeliverySpec = {
version: number; problem: string; desiredOutcome: string; users: string[]; goals: string[]; nonGoals: string[];
acceptanceCriteria: string[]; implementationPlan: string[]; risks: string[]; validationCommands: string[];
status: FeatureDeliveryStatus; branch?: string; repositoryId?: string; changedFiles: string[]; progressNotes: string[];
reviewFeedback?: string;
validationResults: FeatureValidationResult[]; validationEvidence?: FeatureValidationEvidence;
pullRequest?: { number: number; url: string; title: string; state: string; mergedAt?: string; mergeCommitSha?: string };
updatedBy: string; updatedAt: string;
};
export type FeatureDeliveryDto = {
id: string; projectId: string; title: string; intent: string; priority: "critical" | "important" | "normal" | "low";
approvalState: string; spec: FeatureDeliverySpec; createdAt: string; updatedAt: string;
};
export type FeatureDeliveryInput = {
title: string; intent: string; priority?: FeatureDeliveryDto["priority"]; problem: string; desiredOutcome: string;
users?: string[]; goals: string[]; nonGoals?: string[]; acceptanceCriteria: string[]; implementationPlan: string[];
risks?: string[]; validationCommands: string[]; riskLevel?: FeatureDeliveryRiskLevel;
};
export type DeliveryChangeInput = {
path: string; operation: "create" | "update" | "delete" | "rename"; previousPath?: string;
beforeSha?: string; afterSha?: string; commitSha?: string;
};
const deliveryTransitions: Record<FeatureDeliveryStatus, readonly FeatureDeliveryStatus[]> = {
draft: ["awaiting_approval", "approved", "cancelled"],
awaiting_approval: ["awaiting_approval", "approved", "cancelled"],
approved: ["approved", "executing", "awaiting_approval", "cancelled"],
executing: ["executing", "validating", "blocked", "failed", "cancelled"],
validating: ["validating", "pull_request_open", "blocked", "failed", "cancelled"],
pull_request_open: ["pull_request_open", "merged", "failed", "blocked", "cancelled"],
merged: ["merged", "verified", "superseded"],
verified: ["verified", "released", "superseded"],
released: ["released", "superseded"],
blocked: ["blocked", "approved", "cancelled"],
failed: ["failed", "approved", "cancelled"],
cancelled: ["cancelled"],
superseded: ["superseded"],
};
export const canTransitionFeatureDeliveryStatus = (from: FeatureDeliveryStatus, to: FeatureDeliveryStatus) => deliveryTransitions[from]?.includes(to) ?? false;
export const assertFeatureDeliveryTransition = (from: FeatureDeliveryStatus, to: FeatureDeliveryStatus) => {
if (!canTransitionFeatureDeliveryStatus(from, to)) throw new Error("FEATURE_DELIVERY_INVALID_TRANSITION");
};
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const publicId = (value: { id: string; slug?: string | null }) => value.slug ?? value.id;
const toPriority = (value: string): FeatureDeliveryDto["priority"] =>
(["critical", "important", "normal", "low"].includes(value) ? value : "normal") as FeatureDeliveryDto["priority"];
const validationStatus = (status: DeliveryValidationStatus): FeatureValidationResult["status"] =>
status === DeliveryValidationStatus.passed ? "passed" : status === DeliveryValidationStatus.failed ? "failed" : "not_run";
export const actionValidationCommand = (workflowPath: string) => `Gitea Actions: ${workflowPath}`;
export const reportedValidationResult = (entry: { command: string; status: DeliveryValidationStatus; provider: string | null; summary: string | null }): FeatureValidationResult => {
// Old syncs copied one workflow result onto every planned command, including
// native PR and live-provider checks that the workflow had never executed.
const inferredLegacyResult = entry.provider === "gitea_actions" && entry.status === DeliveryValidationStatus.passed && entry.command !== actionValidationCommand(entry.summary ?? "");
return { command: entry.command, status: inferredLegacyResult ? "not_run" : validationStatus(entry.status),
...(inferredLegacyResult ? { summary: `Legacy inferred result; verify this check independently. Observed workflow: ${entry.summary ?? "unknown"}` } : entry.summary ? { summary: entry.summary } : {}) };
};
const hasStatus = (status: DeliveryStatus, allowed: DeliveryStatus[]) => allowed.includes(status);
const normalizedRiskLevel = (value: FeatureDeliveryRiskLevel | undefined): FeatureDeliveryRiskLevel => value ?? "medium";
const requiresHumanDecision = (riskLevel: FeatureDeliveryRiskLevel) => riskLevel === "high" || riskLevel === "destructive";
const authorizationMode = (riskLevel: FeatureDeliveryRiskLevel) => riskLevel === "low" ? "auto_allowed" : "auto_allowed_with_audit";
const getProject = async (projectIdOrSlug: string) => {
const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] } });
if (!project) throw new Error("PROJECT_NOT_FOUND");
return project;
};
const planInclude = {
initiative: true,
runs: { orderBy: { createdAt: "desc" as const }, include: {
repositoryLink: true,
changes: { orderBy: { createdAt: "asc" as const } },
validations: { orderBy: { createdAt: "asc" as const } },
} },
} satisfies Prisma.DeliveryPlanInclude;
type PlanWithRuntime = Prisma.DeliveryPlanGetPayload<{ include: typeof planInclude }>;
const toDto = (projectSlug: string, plan: PlanWithRuntime): FeatureDeliveryDto => {
const run = plan.runs[0];
const actionValidation = run?.validations.find((entry) => entry.provider === "gitea_actions" && entry.status === DeliveryValidationStatus.passed && entry.headSha);
const validationEvidence = actionValidation?.externalRunId && actionValidation.externalUrl && actionValidation.headSha && actionValidation.verifiedAt
? { provider: "gitea_actions" as const, runId: Number(actionValidation.externalRunId), runNumber: Number(actionValidation.checkName?.replace(/^run-/, "") ?? actionValidation.externalRunId), url: actionValidation.externalUrl, workflowPath: actionValidation.summary ?? "gitea-actions", headSha: actionValidation.headSha, conclusion: "success" as const, verifiedAt: actionValidation.verifiedAt.toISOString() }
: undefined;
const pullRequest = run?.pullRequestNumber && run.pullRequestUrl && run.pullRequestTitle
? { number: run.pullRequestNumber, url: run.pullRequestUrl, title: run.pullRequestTitle, state: run.pullRequestState ?? "open", ...(run.mergedAt ? { mergedAt: run.mergedAt.toISOString() } : {}), ...(run.mergeCommitSha ? { mergeCommitSha: run.mergeCommitSha } : {}) }
: undefined;
return {
id: publicId(plan), projectId: projectSlug, title: plan.title, intent: plan.intent, priority: toPriority(plan.priority), approvalState: plan.approvalState,
spec: {
version: plan.version, problem: plan.problem, desiredOutcome: plan.desiredOutcome, users: strings(plan.usersJson), goals: strings(plan.goalsJson),
nonGoals: strings(plan.nonGoalsJson), acceptanceCriteria: strings(plan.acceptanceCriteriaJson), implementationPlan: strings(plan.implementationPlanJson),
risks: strings(plan.risksJson), validationCommands: strings(plan.validationCommandsJson), status: plan.status, branch: run?.branch,
repositoryId: run?.repositoryLinkId, changedFiles: Array.from(new Set(run?.changes.map((change) => change.path) ?? [])),
progressNotes: run?.changes.map((change) => `${change.operation}: ${change.previousPath ? `${change.previousPath} -> ` : ""}${change.path}`) ?? [],
validationResults: run?.validations.map(reportedValidationResult) ?? [],
...(plan.reviewFeedback ? { reviewFeedback: plan.reviewFeedback } : {}),
validationEvidence, pullRequest, updatedBy: plan.updatedBy ?? plan.createdBy, updatedAt: plan.updatedAt.toISOString(),
},
createdAt: plan.createdAt.toISOString(), updatedAt: plan.updatedAt.toISOString(),
};
};
const getDeliveryRow = async (projectIdOrSlug: string, deliveryId: string) => {
const project = await getProject(projectIdOrSlug);
const plan = await prisma.deliveryPlan.findFirst({ where: { projectId: project.id, OR: [{ id: deliveryId }, { slug: deliveryId }] }, include: planInclude });
if (!plan) throw new Error("FEATURE_DELIVERY_NOT_FOUND");
return { project, plan };
};
const emit = async (tx: Prisma.TransactionClient, projectId: string, aggregateId: string, eventType: string, payload: unknown) => {
await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId, aggregateType: "delivery", aggregateId, eventType, payloadJson: toJson(payload) } });
};
export const assertFeatureDeliveryPlanComplete = (spec: Pick<FeatureDeliverySpec, "problem" | "desiredOutcome" | "goals" | "acceptanceCriteria" | "implementationPlan" | "validationCommands">) => {
if (!spec.problem.trim() || !spec.desiredOutcome.trim() || !spec.goals.length || !spec.acceptanceCriteria.length || !spec.implementationPlan.length || !spec.validationCommands.length) throw new Error("FEATURE_DELIVERY_PLAN_INCOMPLETE");
};
export const assertFeatureDeliveryActionEvidence = (evidence: FeatureValidationEvidence | undefined, headSha: string) => {
if (!evidence || evidence.provider !== "gitea_actions" || evidence.conclusion !== "success" || evidence.headSha !== headSha) throw new Error("FEATURE_DELIVERY_CI_EVIDENCE_REQUIRED");
};
export const canSyncFeatureDeliveryValidation = (status: FeatureDeliveryStatus): boolean =>
["executing", "validating", "pull_request_open"].includes(status);
export const statusAfterFeatureDeliveryValidationSync = (status: FeatureDeliveryStatus): "validating" | "pull_request_open" =>
status === "pull_request_open" ? "pull_request_open" : "validating";
export const createFeatureDelivery = async (projectIdOrSlug: string, input: FeatureDeliveryInput, actor = "chatgpt") => {
assertFeatureDeliveryPlanComplete({ problem: input.problem, desiredOutcome: input.desiredOutcome, goals: input.goals, acceptanceCriteria: input.acceptanceCriteria, implementationPlan: input.implementationPlan, validationCommands: input.validationCommands });
const riskLevel = normalizedRiskLevel(input.riskLevel);
const humanDecisionRequired = requiresHumanDecision(riskLevel);
const initiative = await createInitiativeForProject(projectIdOrSlug, { title: input.title, intent: input.intent, priority: input.priority, sourceType: "chatgpt_feature_delivery" });
const project = await getProject(projectIdOrSlug);
const initiativeRow = await prisma.initiative.findFirstOrThrow({ where: { projectId: project.id, OR: [{ id: initiative.id }, { slug: initiative.id }] } });
const now = new Date();
const plan = await prisma.$transaction(async (tx) => {
const created = await tx.deliveryPlan.create({ data: {
projectId: project.id, initiativeId: initiativeRow.id, slug: publicId(initiativeRow), title: input.title, intent: input.intent, priority: input.priority ?? "normal",
problem: input.problem, desiredOutcome: input.desiredOutcome, usersJson: toJson(input.users ?? []), goalsJson: toJson(input.goals), nonGoalsJson: toJson(input.nonGoals ?? []),
acceptanceCriteriaJson: toJson(input.acceptanceCriteria), implementationPlanJson: toJson(input.implementationPlan), risksJson: toJson(input.risks ?? []), validationCommandsJson: toJson(input.validationCommands),
status: humanDecisionRequired ? DeliveryStatus.awaiting_approval : DeliveryStatus.approved,
approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved,
approvedAt: humanDecisionRequired ? null : now,
approvedBy: humanDecisionRequired ? null : actor,
createdBy: actor, updatedBy: actor,
}, include: planInclude });
await tx.initiative.update({ where: { id: initiativeRow.id }, data: {
status: humanDecisionRequired ? "proposed" : "planned",
approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved,
updatedBy: actor,
} });
await emit(
tx,
project.id,
created.id,
humanDecisionRequired ? "delivery.plan.user_decision_required" : "delivery.plan.auto_authorized",
humanDecisionRequired
? { actor, slug: created.slug, riskLevel, policyDecision: "USER_DECISION_REQUIRED" }
: { actor, slug: created.slug, riskLevel, policyDecision: riskLevel === "low" ? "AUTO_ALLOWED" : "AUTO_ALLOWED_WITH_AUDIT", mode: authorizationMode(riskLevel) },
);
return created;
});
return toDto(project.slug, plan);
};
export const listFeatureDeliveries = async (projectIdOrSlug: string) => {
const project = await getProject(projectIdOrSlug);
const plans = await prisma.deliveryPlan.findMany({ where: { projectId: project.id }, orderBy: { updatedAt: "desc" }, include: planInclude });
return plans.map((plan) => toDto(project.slug, plan));
};
export const getFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string) => {
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
return toDto(project.slug, plan);
};
export const updateFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, patch: Partial<FeatureDeliveryInput>, actor = "chatgpt") => {
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
if (!hasStatus(plan.status, [DeliveryStatus.draft, DeliveryStatus.awaiting_approval, DeliveryStatus.approved])) throw new Error("FEATURE_DELIVERY_PLAN_LOCKED");
if (plan.approvalState === ApprovalState.blocked) throw new Error("FEATURE_DELIVERY_BLOCKED");
const riskLevel = normalizedRiskLevel(patch.riskLevel ?? (plan.approvalState === ApprovalState.pending ? "high" : "medium"));
const humanDecisionRequired = requiresHumanDecision(riskLevel);
assertFeatureDeliveryTransition(plan.status, humanDecisionRequired ? DeliveryStatus.awaiting_approval : DeliveryStatus.approved);
const now = new Date();
const updated = await prisma.$transaction(async (tx) => {
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: {
title: patch.title, intent: patch.intent, priority: patch.priority, problem: patch.problem, desiredOutcome: patch.desiredOutcome,
usersJson: patch.users ? toJson(patch.users) : undefined, goalsJson: patch.goals ? toJson(patch.goals) : undefined, nonGoalsJson: patch.nonGoals ? toJson(patch.nonGoals) : undefined,
acceptanceCriteriaJson: patch.acceptanceCriteria ? toJson(patch.acceptanceCriteria) : undefined, implementationPlanJson: patch.implementationPlan ? toJson(patch.implementationPlan) : undefined,
risksJson: patch.risks ? toJson(patch.risks) : undefined, validationCommandsJson: patch.validationCommands ? toJson(patch.validationCommands) : undefined,
status: humanDecisionRequired ? DeliveryStatus.awaiting_approval : DeliveryStatus.approved,
approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved,
approvedAt: humanDecisionRequired ? null : now,
approvedBy: humanDecisionRequired ? null : actor,
reviewFeedback: null, version: { increment: 1 }, updatedBy: actor,
}, include: planInclude });
if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: {
status: humanDecisionRequired ? "proposed" : "planned",
approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved,
updatedBy: actor,
} });
await emit(
tx,
project.id,
plan.id,
humanDecisionRequired ? "delivery.plan.user_decision_required" : "delivery.plan.auto_authorized",
humanDecisionRequired
? { actor, version: row.version, riskLevel, policyDecision: "USER_DECISION_REQUIRED" }
: { actor, version: row.version, riskLevel, policyDecision: riskLevel === "low" ? "AUTO_ALLOWED" : "AUTO_ALLOWED_WITH_AUDIT", mode: authorizationMode(riskLevel) },
);
return row;
});
return toDto(project.slug, updated);
};
export const approveFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, actor = "owner") => {
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
assertFeatureDeliveryPlanComplete({ problem: plan.problem, desiredOutcome: plan.desiredOutcome, goals: strings(plan.goalsJson), acceptanceCriteria: strings(plan.acceptanceCriteriaJson), implementationPlan: strings(plan.implementationPlanJson), validationCommands: strings(plan.validationCommandsJson) });
if (!hasStatus(plan.status, [DeliveryStatus.draft, DeliveryStatus.awaiting_approval, DeliveryStatus.approved])) throw new Error("FEATURE_DELIVERY_APPROVAL_LOCKED");
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.approved);
const updated = await prisma.$transaction(async (tx) => {
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.approved, approvalState: ApprovalState.approved, approvedAt: new Date(), approvedBy: actor, reviewFeedback: null, updatedBy: actor }, include: planInclude });
if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { status: "planned", approvalState: ApprovalState.approved, updatedBy: actor } });
await emit(tx, project.id, plan.id, "delivery.plan.approved", { actor, version: row.version });
return row;
});
return toDto(project.slug, updated);
};
export const rejectFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, actor = "owner", reason?: string) => {
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
if (!hasStatus(plan.status, [DeliveryStatus.draft, DeliveryStatus.awaiting_approval])) throw new Error("FEATURE_DELIVERY_REJECTION_LOCKED");
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.awaiting_approval);
return prisma.$transaction(async (tx) => {
const feedback = reason?.trim() || "Herwerk dit plan voor een nieuwe uitvoeringspoging.";
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.awaiting_approval, approvalState: ApprovalState.rejected, reviewFeedback: feedback, updatedBy: actor }, include: planInclude });
if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { approvalState: ApprovalState.rejected, updatedBy: actor } });
await emit(tx, project.id, plan.id, "delivery.plan.revision_requested", { actor, version: row.version, reason: feedback });
return toDto(project.slug, row);
});
};
export const startFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, branch: string, actor = "chatgpt", input: { repositoryLinkId?: string; baseCommitSha?: string; headSha?: string } = {}) => {
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
assertFeatureDeliveryPlanComplete({ problem: plan.problem, desiredOutcome: plan.desiredOutcome, goals: strings(plan.goalsJson), acceptanceCriteria: strings(plan.acceptanceCriteriaJson), implementationPlan: strings(plan.implementationPlanJson), validationCommands: strings(plan.validationCommandsJson) });
if (plan.approvalState !== ApprovalState.approved || plan.status !== DeliveryStatus.approved) throw new Error("FEATURE_DELIVERY_APPROVAL_REQUIRED");
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.executing);
const repository = input.repositoryLinkId
? await prisma.giteaRepositoryLink.findFirst({ where: { id: input.repositoryLinkId, projectId: project.id } })
: await prisma.giteaRepositoryLink.findFirst({ where: { projectId: project.id }, orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] });
if (!repository) throw new Error("GITEA_LINK_NOT_FOUND");
const updated = await prisma.$transaction(async (tx) => {
const run = await tx.deliveryRun.create({ data: { deliveryPlanId: plan.id, repositoryLinkId: repository.id, branch, baseBranch: repository.defaultBranch, baseCommitSha: input.baseCommitSha, headSha: input.headSha, status: DeliveryStatus.executing, createdBy: actor, updatedBy: actor } });
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.executing, approvalState: ApprovalState.approved, approvedAt: plan.approvedAt ?? new Date(), approvedBy: plan.approvedBy ?? actor, updatedBy: actor }, include: planInclude });
const investigation = await tx.findingInvestigation.findUnique({ where: { deliveryPlanId: plan.id } });
if (investigation) {
await tx.findingInvestigation.update({ where: { id: investigation.id }, data: { status: "fixing" } });
await tx.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: "fixing" } });
}
if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { status: "active", approvalState: ApprovalState.approved, updatedBy: actor } });
await emit(tx, project.id, plan.id, "delivery.run.started", { actor, runId: run.id, repositoryLinkId: repository.id, branch, authorization: "projectbrain_policy" });
return row;
});
return toDto(project.slug, updated);
};
export const recordFeatureDeliveryProgress = async (projectIdOrSlug: string, deliveryId: string, input: { changedFiles?: string[]; changes?: DeliveryChangeInput[]; note?: string; validationResults?: FeatureValidationResult[]; readyForReview?: boolean; headSha?: string }, actor = "chatgpt") => {
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
const run = plan.runs[0];
if (!run || plan.approvalState !== ApprovalState.approved || !hasStatus(plan.status, [DeliveryStatus.executing, DeliveryStatus.validating, DeliveryStatus.pull_request_open])) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN");
const changes: DeliveryChangeInput[] = input.changes ?? (input.changedFiles ?? []).map((path) => ({ path, operation: "update" }));
const updated = await prisma.$transaction(async (tx) => {
for (const change of changes) await tx.deliveryFileChange.create({ data: { deliveryRunId: run.id, path: change.path, previousPath: change.previousPath, operation: change.operation as DeliveryChangeOperation, beforeSha: change.beforeSha, afterSha: change.afterSha, commitSha: change.commitSha, actor } });
if (changes.length || (input.headSha && input.headSha !== run.headSha)) await tx.deliveryValidation.deleteMany({ where: { deliveryRunId: run.id } });
if (input.validationResults) {
await tx.deliveryValidation.deleteMany({ where: { deliveryRunId: run.id, provider: null } });
for (const result of input.validationResults) await tx.deliveryValidation.create({ data: { deliveryRunId: run.id, command: result.command, status: result.status === "passed" ? DeliveryValidationStatus.passed : result.status === "failed" ? DeliveryValidationStatus.failed : DeliveryValidationStatus.skipped, summary: result.summary } });
}
const status = plan.status === DeliveryStatus.pull_request_open ? plan.status : input.readyForReview ? DeliveryStatus.validating : plan.status;
assertFeatureDeliveryTransition(plan.status, status);
await tx.deliveryRun.update({ where: { id: run.id }, data: { status, headSha: input.headSha, updatedBy: actor } });
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status, updatedBy: actor }, include: planInclude });
await emit(tx, project.id, plan.id, "delivery.progress.recorded", { actor, runId: run.id, note: input.note, changeCount: changes.length, status });
return row;
});
return toDto(project.slug, updated);
};
export const recordFeatureDeliveryActionValidation = async (projectIdOrSlug: string, deliveryId: string, evidence: FeatureValidationEvidence, actor = "chatgpt") => {
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
const run = plan.runs[0];
if (!run || plan.approvalState !== ApprovalState.approved || !canSyncFeatureDeliveryValidation(plan.status)) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN");
const nextStatus = statusAfterFeatureDeliveryValidationSync(plan.status) === "pull_request_open"
? DeliveryStatus.pull_request_open
: DeliveryStatus.validating;
assertFeatureDeliveryTransition(plan.status, nextStatus);
const command = actionValidationCommand(evidence.workflowPath);
const updated = await prisma.$transaction(async (tx) => {
await tx.deliveryValidation.deleteMany({ where: { deliveryRunId: run.id, provider: "gitea_actions" } });
await tx.deliveryValidation.create({ data: { deliveryRunId: run.id, command, status: DeliveryValidationStatus.passed, provider: "gitea_actions", externalRunId: String(evidence.runId), externalUrl: evidence.url, checkName: `run-${evidence.runNumber}`, headSha: evidence.headSha, summary: evidence.workflowPath, verifiedAt: new Date(evidence.verifiedAt) } });
await tx.deliveryRun.update({ where: { id: run.id }, data: { status: nextStatus, headSha: evidence.headSha, updatedBy: actor } });
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: nextStatus, updatedBy: actor }, include: planInclude });
await emit(tx, project.id, plan.id, "delivery.validation.passed", { actor, runId: run.id, headSha: evidence.headSha, externalRunId: evidence.runId });
return row;
});
return toDto(project.slug, updated);
};
export const openFeatureDeliveryPullRequest = async (projectIdOrSlug: string, deliveryId: string, pullRequest: { number: number; url: string; title: string }, actor = "chatgpt") => {
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
const run = plan.runs[0];
if (!run || plan.approvalState !== ApprovalState.approved || plan.status !== DeliveryStatus.validating) throw new Error("FEATURE_DELIVERY_COMPLETION_FORBIDDEN");
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.pull_request_open);
const updated = await prisma.$transaction(async (tx) => {
await tx.deliveryRun.update({ where: { id: run.id }, data: { status: DeliveryStatus.pull_request_open, pullRequestNumber: pullRequest.number, pullRequestUrl: pullRequest.url, pullRequestTitle: pullRequest.title, pullRequestState: "open", pullRequestOpenedAt: new Date(), updatedBy: actor } });
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.pull_request_open, updatedBy: actor }, include: planInclude });
await emit(tx, project.id, plan.id, "delivery.pull_request.opened", { actor, runId: run.id, pullRequest });
return row;
});
return toDto(project.slug, updated);
};
/** Compatibility export: opening a PR no longer marks the delivery completed. */
export const completeFeatureDelivery = openFeatureDeliveryPullRequest;
export const reconcileFeatureDeliveryPullRequest = async (input: { owner: string; repository: string; number: number; state: "open" | "closed"; merged: boolean; mergeCommitSha?: string; headSha?: string; mergedAt?: string; actor?: string }) => {
const run = await prisma.deliveryRun.findFirst({ where: { pullRequestNumber: input.number, repositoryLink: { owner: input.owner, repository: input.repository } }, include: { deliveryPlan: true } });
if (!run) return undefined;
const actor = input.actor ?? "gitea-webhook";
const status = input.merged ? DeliveryStatus.merged : input.state === "closed" ? DeliveryStatus.failed : DeliveryStatus.pull_request_open;
assertFeatureDeliveryTransition(run.deliveryPlan.status, status);
await prisma.$transaction(async (tx) => {
await tx.deliveryRun.update({ where: { id: run.id }, data: { status, pullRequestState: input.merged ? "merged" : input.state, mergeCommitSha: input.mergeCommitSha, headSha: input.headSha, mergedAt: input.merged ? new Date(input.mergedAt ?? Date.now()) : undefined, completedAt: input.merged ? new Date(input.mergedAt ?? Date.now()) : input.state === "closed" ? new Date() : undefined, updatedBy: actor } });
await tx.deliveryPlan.update({ where: { id: run.deliveryPlanId }, data: { status, updatedBy: actor } });
if (run.deliveryPlan.initiativeId) await tx.initiative.update({ where: { id: run.deliveryPlan.initiativeId }, data: { status: input.merged ? "complete" : input.state === "closed" ? "paused" : "active", updatedBy: actor } });
await emit(tx, run.deliveryPlan.projectId, run.deliveryPlanId, input.merged ? "delivery.pull_request.merged" : input.state === "closed" ? "delivery.pull_request.closed" : "delivery.pull_request.updated", { actor, runId: run.id, ...input });
});
return getFeatureDelivery(run.deliveryPlan.projectId, run.deliveryPlanId);
};
export const markFeatureDeliveryVerified = async (projectIdOrSlug: string, deliveryId: string, actor = "system") => {
const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId);
const run = plan.runs[0];
if (!run || plan.status !== DeliveryStatus.merged) throw new Error("FEATURE_DELIVERY_NOT_MERGED");
assertFeatureDeliveryTransition(plan.status, DeliveryStatus.verified);
const updated = await prisma.$transaction(async (tx) => {
await tx.deliveryRun.update({ where: { id: run.id }, data: { status: DeliveryStatus.verified, verifiedAt: new Date(), completedAt: new Date(), updatedBy: actor } });
const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.verified, updatedBy: actor }, include: planInclude });
await emit(tx, project.id, plan.id, "delivery.verified", { actor, runId: run.id, mergeCommitSha: run.mergeCommitSha });
return row;
});
return toDto(project.slug, updated);
};
@@ -0,0 +1,43 @@
import { prisma } from "../db/prisma.js";
import type { FeatureGroupDto, FocusAreaPurpose } from "../types/api.js";
const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) =>
value.slug ?? value.readableId ?? value.id;
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
prisma.project.findFirst({
where: {
deletedAt: null,
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
},
});
export const listFeatureGroupsForProject = async (
projectIdOrSlug: string,
): Promise<FeatureGroupDto[] | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
const groups = await prisma.featureGroup.findMany({
where: {
projectId: project.id,
deletedAt: null,
},
orderBy: [{ name: "asc" }],
include: {
features: {
where: { deletedAt: null },
orderBy: { name: "asc" },
},
},
});
return groups.map((group) => ({
id: toPublicId(group),
projectId: project.slug,
name: group.name,
description: group.description ?? undefined,
focusArea: group.focusArea as FocusAreaPurpose | undefined,
featureIds: group.features.map(toPublicId),
}));
};
@@ -0,0 +1,138 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import type {
CodeEvidenceDto,
EvidenceStrength,
FeatureDto,
FeatureStatus,
} from "../types/api.js";
const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) =>
value.slug ?? value.readableId ?? value.id;
type FeatureMetadata = {
bugCount?: number;
taskCount?: number;
decisionCount?: number;
codeEvidence?: CodeEvidenceDto[];
aiSuggestionCount?: number;
evidenceIds?: string[];
initiativeId?: string;
};
type DbFeatureRecord = {
id: string;
projectId: string;
featureGroupId: string;
slug: string | null;
readableId: string | null;
name: string;
description: string;
status: string;
lifecycle: FeatureDto["lifecycle"];
intent: string | null;
confidence: number;
health: number;
evidenceStrength: string | null;
trustJson: Prisma.JsonValue | null;
featureGroup?: {
id: string;
slug: string | null;
readableId: string | null;
} | null;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const toFeatureMetadata = (value: Prisma.JsonValue | null): FeatureMetadata => {
if (!isRecord(value)) return {};
return value as FeatureMetadata;
};
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
prisma.project.findFirst({
where: {
deletedAt: null,
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
},
});
const mapFeatureStatus = (status: string): FeatureStatus => {
if (status === "done") return "Done";
if (status === "in_progress") return "In Progress";
return "Todo";
};
const toFeatureDto = (feature: DbFeatureRecord, projectSlug: string): FeatureDto => {
const metadata = toFeatureMetadata(feature.trustJson);
return {
id: toPublicId(feature),
projectId: projectSlug,
groupId: feature.featureGroup ? toPublicId(feature.featureGroup) : feature.featureGroupId,
name: feature.name,
description: feature.description,
status: mapFeatureStatus(feature.status),
lifecycle: feature.lifecycle,
intent: feature.intent ?? undefined,
initiativeId: metadata.initiativeId,
evidenceStrength: feature.evidenceStrength as EvidenceStrength | undefined,
confidence: feature.confidence,
health: feature.health,
bugCount: metadata.bugCount ?? 0,
taskCount: metadata.taskCount ?? 0,
decisionCount: metadata.decisionCount ?? 0,
codeEvidence: metadata.codeEvidence ?? [],
aiSuggestionCount: metadata.aiSuggestionCount ?? 0,
evidenceIds: metadata.evidenceIds ?? [],
};
};
export const listFeaturesForProject = async (
projectIdOrSlug: string,
): Promise<FeatureDto[] | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
const features = await prisma.feature.findMany({
where: {
projectId: project.id,
deletedAt: null,
},
orderBy: [{ name: "asc" }],
include: {
featureGroup: true,
},
});
return features.map((feature) => toFeatureDto(feature, project.slug));
};
export const getFeatureBySlugOrId = async (
projectIdOrSlug: string,
featureIdOrSlug: string,
): Promise<FeatureDto | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
const feature = await prisma.feature.findFirst({
where: {
projectId: project.id,
deletedAt: null,
OR: [{ id: featureIdOrSlug }, { slug: featureIdOrSlug }, { readableId: featureIdOrSlug }],
},
include: {
featureGroup: true,
},
});
if (!feature) return undefined;
return toFeatureDto(feature, project.slug);
};
export const projectExists = async (projectIdOrSlug: string): Promise<boolean> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
return Boolean(project);
};
@@ -0,0 +1,455 @@
import { createHash, randomUUID } from "node:crypto";
import { FindingSeverity, FindingStatus, InvestigationStatus, IssueSyncMode, type Prisma } from "@prisma/client";
import type { AppEnv } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { getGiteaIssueClient } from "../integrations/gitea/service.js";
import type { GiteaIssue, GiteaLabel } from "../integrations/gitea/types.js";
import { createFeatureDelivery, markFeatureDeliveryVerified, type FeatureDeliveryInput } from "./featureDeliveryRepository.js";
import { createPortfolioAnalysisJob } from "./analysisJobRepository.js";
export type FindingEvidence = Record<string, unknown>;
export type DetectedFinding = { severity: "info" | "warning" | "critical"; code: string; message: string; subject?: string; evidence?: FindingEvidence };
type FindingScope = {
jobId: string;
analysisJobItemId: string;
projectId?: string;
repositoryLinkId?: string;
owner: string;
repository: string;
defaultBranch: string;
findings: DetectedFinding[];
evaluatedRuleCodes?: string[];
};
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
const severityRank: Record<FindingSeverity, number> = { info: 1, warning: 2, critical: 3 };
const baseRiskBySeverity: Record<FindingSeverity, number> = { info: 20, warning: 60, critical: 90 };
const terminalInvestigationStatuses: InvestigationStatus[] = [InvestigationStatus.resolved, InvestigationStatus.failed, InvestigationStatus.cancelled];
const projectByIdOrSlug = async (projectIdOrSlug: string) => {
const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] } });
if (!project) throw new Error("PROJECT_NOT_FOUND");
return project;
};
const subjectFor = (finding: DetectedFinding, defaultBranch: string) => finding.subject ?? (finding.code === "DEFAULT_BRANCH_UNPROTECTED" ? defaultBranch : "repository");
export const repositoryFindingFingerprint = (owner: string, repository: string, code: string, subject: string) => createHash("sha256").update(`${owner.toLowerCase()}/${repository.toLowerCase()}:${code}:${subject}`).digest("hex");
const titleFor = (finding: DetectedFinding, owner: string, repository: string) => `[ProjectBrain] ${finding.code.replaceAll("_", " ").toLowerCase()} in ${owner}/${repository}`;
const evidenceNumber = (finding: DetectedFinding, key: string) => {
const value = finding.evidence?.[key];
return typeof value === "number" ? value : 0;
};
const evidenceString = (finding: DetectedFinding, key: string) => {
const value = finding.evidence?.[key];
return typeof value === "string" ? value : undefined;
};
export const findingRiskScore = (finding: DetectedFinding) => {
const scopeBonus = evidenceString(finding, "scope") === "default_branch" ? 8 : evidenceString(finding, "scope") === "scheduled" ? 4 : 0;
const persistenceBonus = Math.min(4, Math.max(0, evidenceNumber(finding, "consecutiveFailures") - 1));
const ruleBonus = finding.code === "DEFAULT_BRANCH_ACTION_FAILED" ? 2 : finding.code === "DEFAULT_BRANCH_IS_AGENT_BRANCH" ? 3 : 0;
return Math.min(100, baseRiskBySeverity[finding.severity as FindingSeverity] + scopeBonus + persistenceBonus + ruleBonus);
};
const legacyMigrationCandidate = (finding: DetectedFinding) => finding.severity === "critical" && new Set(["DEFAULT_BRANCH_ACTION_FAILED", "SCHEDULED_ACTION_FAILED"]).has(finding.code);
export const reconcileRepositoryFindings = async (scope: FindingScope) => {
const now = new Date();
const observedFingerprints = scope.findings.map((finding) => repositoryFindingFingerprint(scope.owner, scope.repository, finding.code, subjectFor(finding, scope.defaultBranch)));
return prisma.$transaction(async (tx) => {
const reconciled = [];
for (let index = 0; index < scope.findings.length; index += 1) {
const detected = scope.findings[index];
const severity = detected.severity as FindingSeverity;
const subject = subjectFor(detected, scope.defaultBranch);
const fingerprint = observedFingerprints[index];
let existing = await tx.repositoryFinding.findUnique({ where: { fingerprint } });
if (!existing && legacyMigrationCandidate(detected)) {
const legacy = await tx.repositoryFinding.findFirst({
where: {
owner: { equals: scope.owner, mode: "insensitive" },
repository: { equals: scope.repository, mode: "insensitive" },
ruleCode: "LATEST_ACTION_FAILED",
status: { notIn: [FindingStatus.resolved, FindingStatus.suppressed] },
},
orderBy: { lastSeenAt: "desc" },
});
if (legacy) {
existing = await tx.repositoryFinding.update({
where: { id: legacy.id },
data: {
fingerprint,
ruleCode: detected.code,
subject,
status: legacy.status === FindingStatus.issue_open ? FindingStatus.triaged : legacy.status,
},
});
}
}
const suppressionExpired = existing?.status === FindingStatus.suppressed && existing.suppressedUntil && existing.suppressedUntil <= now;
const nextStatus = existing?.status === FindingStatus.resolved || suppressionExpired ? FindingStatus.regressed : existing?.status ?? FindingStatus.new;
const finding = existing
? await tx.repositoryFinding.update({ where: { id: existing.id }, data: {
projectId: scope.projectId || existing.projectId, repositoryLinkId: scope.repositoryLinkId || existing.repositoryLinkId,
owner: scope.owner, repository: scope.repository,
title: titleFor(detected, scope.owner, scope.repository), message: detected.message, severity, riskScore: findingRiskScore(detected),
status: nextStatus, occurrenceCount: existing.lastAnalysisJobId === scope.jobId ? existing.occurrenceCount : { increment: 1 }, clearCount: 0, lastSeenAt: now, lastAnalysisJobId: scope.jobId,
resolvedAt: null, ...(suppressionExpired ? { suppressedAt: null, suppressedUntil: null, suppressionReason: null } : {}),
} })
: await tx.repositoryFinding.create({ data: {
fingerprint, projectId: scope.projectId || undefined, repositoryLinkId: scope.repositoryLinkId,
owner: scope.owner, repository: scope.repository, ruleCode: detected.code, subject,
title: titleFor(detected, scope.owner, scope.repository), message: detected.message, severity,
confidence: 95, riskScore: findingRiskScore(detected), status: FindingStatus.new, firstSeenAt: now, lastSeenAt: now, lastAnalysisJobId: scope.jobId,
} });
await tx.findingOccurrence.upsert({
where: { findingId_analysisJobItemId: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId } },
create: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId, observed: true, detailJson: toJson(detected) },
update: { observed: true, detailJson: toJson(detected) },
});
reconciled.push(finding);
}
const missing = await tx.repositoryFinding.findMany({
where: {
owner: { equals: scope.owner, mode: "insensitive" },
repository: { equals: scope.repository, mode: "insensitive" },
fingerprint: { notIn: observedFingerprints },
status: { notIn: [FindingStatus.resolved, FindingStatus.suppressed] },
},
});
for (const finding of missing) {
if (!scope.evaluatedRuleCodes?.includes(finding.ruleCode)) continue;
if (finding.lastAnalysisJobId === scope.jobId) continue;
const clearCount = finding.clearCount + 1;
await tx.repositoryFinding.update({ where: { id: finding.id }, data: {
clearCount,
...(clearCount >= 2 ? { status: FindingStatus.resolved, resolvedAt: now } : {}),
lastAnalysisJobId: scope.jobId,
} });
await tx.findingOccurrence.upsert({
where: { findingId_analysisJobItemId: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId } },
create: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId, observed: false },
update: { observed: false },
});
}
return reconciled;
});
};
const findingInclude = {
project: { select: { id: true, slug: true, name: true } },
repositoryLink: true,
issueLink: true,
investigations: { orderBy: { createdAt: "desc" as const }, take: 3, include: { deliveryPlan: { select: { id: true, slug: true, title: true, status: true, approvalState: true } } } },
occurrences: { where: { observed: true }, orderBy: [{ createdAt: "desc" as const }, { id: "desc" as const }], take: 1, select: { observed: true, detailJson: true, createdAt: true } },
} satisfies Prisma.RepositoryFindingInclude;
export const listRepositoryFindings = async (input: { projectId?: string; statuses?: FindingStatus[]; severities?: FindingSeverity[]; limit?: number; cursor?: string }) => {
const project = input.projectId ? await projectByIdOrSlug(input.projectId) : undefined;
const cursor = Number.parseInt(input.cursor ?? "0", 10);
if (!Number.isInteger(cursor) || cursor < 0) throw new Error("FINDING_CURSOR_INVALID");
const limit = Math.min(Math.max(input.limit ?? 20, 1), 50);
const rows = await prisma.repositoryFinding.findMany({
where: { ...(project ? { projectId: project.id } : {}), ...(input.statuses?.length ? { status: { in: input.statuses } } : {}), ...(input.severities?.length ? { severity: { in: input.severities } } : {}) },
orderBy: [{ riskScore: "desc" }, { lastSeenAt: "desc" }, { id: "asc" }], skip: cursor, take: limit + 1, include: findingInclude,
});
return { items: rows.slice(0, limit), nextCursor: rows.length > limit ? String(cursor + limit) : undefined };
};
export const getRepositoryFinding = async (findingId: string) => {
const finding = await prisma.repositoryFinding.findUnique({ where: { id: findingId }, include: {
...findingInclude,
occurrences: { orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: 20, include: { analysisJobItem: { select: { jobId: true, status: true, summary: true, createdAt: true } } } },
} });
if (!finding) throw new Error("FINDING_NOT_FOUND");
return finding;
};
export const suppressRepositoryFinding = async (findingId: string, reason: string, until?: Date) => {
const finding = await prisma.repositoryFinding.update({ where: { id: findingId }, data: { status: FindingStatus.suppressed, suppressionReason: reason, suppressedAt: new Date(), suppressedUntil: until } });
return finding;
};
export const getFindingAutomationPolicy = async (projectIdOrSlug: string) => {
const project = await projectByIdOrSlug(projectIdOrSlug);
return prisma.findingAutomationPolicy.upsert({ where: { projectId: project.id }, create: { projectId: project.id }, update: {} });
};
export const updateFindingAutomationPolicy = async (projectIdOrSlug: string, input: {
enabled?: boolean; issueSyncMode?: IssueSyncMode; minimumSeverity?: FindingSeverity; minimumOccurrences?: number; maxIssuesPerRun?: number;
autoCloseResolved?: boolean; autoReopenRegressed?: boolean; scheduledAnalysis?: boolean; scheduleMinutes?: number; updatedBy: string;
}) => {
const project = await projectByIdOrSlug(projectIdOrSlug);
return prisma.findingAutomationPolicy.upsert({
where: { projectId: project.id },
create: { projectId: project.id, ...input },
update: input,
});
};
export const findingIssueMarker = (finding: { id: string; fingerprint: string }) => `<!-- projectbrain:finding=${finding.id} fingerprint=${finding.fingerprint} -->`;
const latestObservedDetection = (finding: Awaited<ReturnType<typeof getRepositoryFinding>>): DetectedFinding | undefined => {
const detail = finding.occurrences.find((occurrence) => occurrence.observed)?.detailJson;
return detail && typeof detail === "object" && !Array.isArray(detail) ? detail as DetectedFinding : undefined;
};
export const renderFindingEvidence = (evidence?: FindingEvidence) => {
if (!evidence) return "";
const entries = [
["Workflow", evidence.workflow],
["Branch", evidence.branch],
["Event", evidence.event],
["Conclusie", evidence.conclusion],
["Opeenvolgende failures", evidence.consecutiveFailures],
["Voltooid", evidence.completedAt],
].filter((entry): entry is [string, string | number] => typeof entry[1] === "string" || typeof entry[1] === "number");
const runUrl = typeof evidence.runUrl === "string" ? evidence.runUrl : undefined;
if (runUrl) entries.unshift(["Gitea Actions-run", `[open run](${runUrl})`]);
if (!entries.length) return "";
return `\n## Bewijs\n\n${entries.map(([label, value]) => `- **${label}:** ${value}`).join("\n")}\n`;
};
const issueBodyFor = (finding: Awaited<ReturnType<typeof getRepositoryFinding>>) => `${findingIssueMarker(finding)}
## ProjectBrain-bevinding
${finding.message}
- **Repository:** \`${finding.owner}/${finding.repository}\`
- **Regel:** \`${finding.ruleCode}\`
- **Ernst:** ${finding.severity}
- **Vertrouwen:** ${finding.confidence}%
- **Eerste detectie:** ${finding.firstSeenAt.toISOString()}
- **Laatste detectie:** ${finding.lastSeenAt.toISOString()}
- **Waarnemingen:** ${finding.occurrenceCount}
${renderFindingEvidence(latestObservedDetection(finding)?.evidence)}
Dit issue wordt idempotent beheerd door ProjectBrain. Onderzoek en codewijzigingen blijven onder de ProjectBrain-deliverygoedkeuring vallen.`;
const labelDefinitions = (finding: { severity: FindingSeverity; ruleCode: string }) => [
{ name: "projectbrain", color: "6d28d9", description: "Automatisch beheerd door ProjectBrain" },
{ name: `severity:${finding.severity}`, color: finding.severity === FindingSeverity.critical ? "dc2626" : finding.severity === FindingSeverity.warning ? "f59e0b" : "2563eb", description: "ProjectBrain finding severity" },
{ name: `finding:${finding.ruleCode.toLowerCase().replaceAll("_", "-")}`, color: "475569", description: `ProjectBrain rule ${finding.ruleCode}` },
];
const ensureLabels = async (env: AppEnv, owner: string, repository: string, finding: { severity: FindingSeverity; ruleCode: string }): Promise<GiteaLabel[]> => {
const client = getGiteaIssueClient(env);
const existing = await client.listLabels(owner, repository);
const labels: GiteaLabel[] = [];
for (const definition of labelDefinitions(finding)) {
const label = existing.find((candidate) => candidate.name === definition.name) ?? await client.createLabel(owner, repository, definition);
labels.push(label);
}
return labels;
};
const persistIssueLink = async (finding: Awaited<ReturnType<typeof getRepositoryFinding>>, issue: GiteaIssue, body: string) => prisma.$transaction(async (tx) => {
const bodyFingerprint = createHash("sha256").update(body).digest("hex");
const link = await tx.giteaIssueLink.upsert({
where: { findingId: finding.id },
create: { findingId: finding.id, owner: finding.owner, repository: finding.repository, issueNumber: issue.number, issueUrl: issue.html_url, state: issue.state, title: issue.title, bodyFingerprint, lastSyncedAt: new Date(), closedAt: issue.state === "closed" ? new Date(issue.closed_at ?? Date.now()) : undefined },
update: { issueNumber: issue.number, issueUrl: issue.html_url, state: issue.state, title: issue.title, bodyFingerprint, lastSyncedAt: new Date(), closedAt: issue.state === "closed" ? new Date(issue.closed_at ?? Date.now()) : null },
});
if (finding.projectId) await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId: finding.projectId, aggregateType: "finding", aggregateId: finding.id, eventType: "finding.issue.synced", payloadJson: toJson({ issueNumber: issue.number, issueUrl: issue.html_url, state: issue.state }) } });
await tx.repositoryFinding.update({ where: { id: finding.id }, data: { status: issue.state === "open" ? FindingStatus.issue_open : finding.status } });
return link;
});
export const syncFindingIssue = async (env: AppEnv, findingId: string, actor = "system") => {
const finding = await getRepositoryFinding(findingId);
if (!finding.repositoryLink || finding.repositoryLink.accessMode !== "read_write") throw new Error("FINDING_REPOSITORY_WRITE_LINK_REQUIRED");
if (finding.status === FindingStatus.resolved || finding.status === FindingStatus.suppressed) throw new Error("FINDING_ISSUE_CREATION_FORBIDDEN");
const client = getGiteaIssueClient(env);
const body = issueBodyFor(finding);
const marker = findingIssueMarker(finding);
const labels = await ensureLabels(env, finding.owner, finding.repository, finding);
let issue: GiteaIssue | undefined;
if (finding.issueLink) {
issue = await client.getIssue(finding.owner, finding.repository, finding.issueLink.issueNumber);
} else {
issue = (await client.listIssues(finding.owner, finding.repository, "all", 1, 100)).find((candidate) => candidate.body?.includes(marker));
}
if (!issue) issue = await client.createIssue(finding.owner, finding.repository, { title: finding.title, body, labels: labels.map((label) => label.id) });
else {
issue = await client.editIssue(finding.owner, finding.repository, issue.number, { title: finding.title, body, ...(issue.state === "closed" ? { state: "open" as const } : {}) });
await client.replaceIssueLabels(finding.owner, finding.repository, issue.number, labels.map((label) => label.id));
}
const link = await persistIssueLink(finding, issue, body);
return { finding: await getRepositoryFinding(finding.id), issue, link, actor };
};
export const closeResolvedFindingIssue = async (env: AppEnv, findingId: string, summary: string) => {
const finding = await getRepositoryFinding(findingId);
if (!finding.issueLink || finding.issueLink.state === "closed") return finding;
const client = getGiteaIssueClient(env);
await client.createIssueComment(finding.owner, finding.repository, finding.issueLink.issueNumber, `ProjectBrain-verificatie: ${summary}`);
const issue = await client.editIssue(finding.owner, finding.repository, finding.issueLink.issueNumber, { state: "closed" });
await persistIssueLink(finding, issue, issueBodyFor(finding));
return getRepositoryFinding(finding.id);
};
export const closeAutomaticallyResolvedFindingIssues = async (env: AppEnv) => {
const findings = await prisma.repositoryFinding.findMany({
where: { status: FindingStatus.resolved, projectId: { not: null }, issueLink: { state: "open" } },
include: { project: { include: { findingAutomationPolicy: true } }, issueLink: true },
orderBy: [{ resolvedAt: "asc" }, { id: "asc" }],
take: 25,
});
const closed: string[] = [];
for (const finding of findings) {
const policy = finding.project?.findingAutomationPolicy ?? (finding.projectId ? await getFindingAutomationPolicy(finding.projectId) : undefined);
if (!policy?.autoCloseResolved) continue;
try {
await closeResolvedFindingIssue(env, finding.id, "Twee opeenvolgende analyses vonden de bevinding niet meer.");
closed.push(finding.id);
} catch (error) {
console.warn(JSON.stringify({ event: "finding_issue_close_failed", findingId: finding.id, code: error instanceof Error ? error.message : "FINDING_ISSUE_CLOSE_FAILED" }));
}
}
return closed;
};
export const reconcileFindingIssueWebhook = async (input: { owner: string; repository: string; issueNumber: number; state: string; title: string; url: string; actor: string }) => {
const link = await prisma.giteaIssueLink.findUnique({ where: { owner_repository_issueNumber: { owner: input.owner, repository: input.repository, issueNumber: input.issueNumber } }, include: { finding: true } });
if (!link) return undefined;
return prisma.$transaction(async (tx) => {
await tx.giteaIssueLink.update({ where: { id: link.id }, data: { state: input.state, title: input.title, issueUrl: input.url, lastSyncedAt: new Date(), closedAt: input.state === "closed" ? new Date() : null } });
const wasResolved = link.finding.status === FindingStatus.resolved || Boolean(link.finding.resolvedAt);
const status = input.state === "open" ? FindingStatus.issue_open : wasResolved ? FindingStatus.resolved : FindingStatus.suppressed;
const finding = await tx.repositoryFinding.update({ where: { id: link.findingId }, data: {
status,
...(status === FindingStatus.resolved ? { suppressedAt: null, suppressedUntil: null, suppressionReason: null } : {}),
...(status === FindingStatus.suppressed ? { suppressedAt: new Date(), suppressionReason: `Gitea issue handmatig gesloten door ${input.actor}` } : {}),
} });
if (finding.projectId) await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId: finding.projectId, aggregateType: "finding", aggregateId: finding.id, eventType: "finding.issue.webhook", payloadJson: toJson(input) } });
return finding;
});
};
export const startFindingInvestigation = async (env: AppEnv, findingId: string, actor: string) => {
const finding = await getRepositoryFinding(findingId);
const active = finding.investigations.find((investigation) => !terminalInvestigationStatuses.includes(investigation.status));
if (active) return active;
const client = getGiteaIssueClient(env);
const [repository, branches, pullRequests, actionRuns, issue, comments, root] = await Promise.all([
client.getRepository(finding.owner, finding.repository),
client.listBranches(finding.owner, finding.repository),
client.listPullRequests(finding.owner, finding.repository, "open"),
client.listActionRuns(finding.owner, finding.repository, { limit: 10 }),
finding.issueLink ? client.getIssue(finding.owner, finding.repository, finding.issueLink.issueNumber) : Promise.resolve(undefined),
finding.issueLink ? client.listIssueComments(finding.owner, finding.repository, finding.issueLink.issueNumber) : Promise.resolve([]),
client.getContents(finding.owner, finding.repository, "", finding.repositoryLink?.defaultBranch ?? repositoryDefault(finding)).catch(() => []),
]);
const rootEntries = (Array.isArray(root) ? root : [root]).slice(0, 50).map(({ content: _content, ...entry }) => entry);
const context = {
finding: { id: finding.id, title: finding.title, message: finding.message, ruleCode: finding.ruleCode, severity: finding.severity, confidence: finding.confidence, occurrenceCount: finding.occurrenceCount, firstSeenAt: finding.firstSeenAt, lastSeenAt: finding.lastSeenAt },
repository: { fullName: `${finding.owner}/${finding.repository}`, defaultBranch: repository.default_branch, archived: repository.archived, private: repository.private },
branches: branches.slice(0, 30), pullRequests: pullRequests.slice(0, 10), actionRuns: actionRuns.workflow_runs.slice(0, 10), rootEntries,
issue: issue ? { number: issue.number, title: issue.title, body: issue.body.slice(0, 10_000), state: issue.state, url: issue.html_url, comments: comments.slice(-10).map((comment) => ({ body: comment.body.slice(0, 2_000), author: comment.user?.login, createdAt: comment.created_at })) } : undefined,
};
const investigation = await prisma.$transaction(async (tx) => {
const created = await tx.findingInvestigation.create({ data: { findingId: finding.id, projectId: finding.projectId, actor, status: InvestigationStatus.ready, contextJson: toJson(context) } });
await tx.repositoryFinding.update({ where: { id: finding.id }, data: { status: FindingStatus.investigating } });
if (finding.projectId) await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId: finding.projectId, aggregateType: "investigation", aggregateId: created.id, eventType: "finding.investigation.ready", payloadJson: toJson({ findingId: finding.id, actor }) } });
return created;
});
return investigation;
};
const repositoryDefault = (finding: Awaited<ReturnType<typeof getRepositoryFinding>>) => finding.repositoryLink?.defaultBranch ?? "main";
export const getFindingInvestigation = async (investigationId: string) => {
const investigation = await prisma.findingInvestigation.findUnique({ where: { id: investigationId }, include: { finding: { include: { issueLink: true, repositoryLink: true } }, deliveryPlan: true } });
if (!investigation) throw new Error("FINDING_INVESTIGATION_NOT_FOUND");
return investigation;
};
export const saveFindingInvestigationConclusion = async (investigationId: string, input: { diagnosis: string; rootCause: string; proposedChanges: string[]; confidence: number; conclusion: string }) => {
const investigation = await getFindingInvestigation(investigationId);
if (terminalInvestigationStatuses.includes(investigation.status)) throw new Error("FINDING_INVESTIGATION_LOCKED");
return prisma.$transaction(async (tx) => {
const updated = await tx.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.concluded, diagnosis: input.diagnosis, rootCause: input.rootCause, proposedChangesJson: toJson(input.proposedChanges), confidence: input.confidence, conclusion: input.conclusion, concludedAt: new Date() } });
await tx.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.triaged } });
return updated;
});
};
export const createDeliveryFromFindingInvestigation = async (investigationId: string, input: FeatureDeliveryInput, actor: string) => {
const investigation = await getFindingInvestigation(investigationId);
if (!investigation.projectId) throw new Error("FINDING_PROJECT_LINK_REQUIRED");
if (investigation.deliveryPlanId) throw new Error("FINDING_DELIVERY_ALREADY_EXISTS");
if (investigation.status !== InvestigationStatus.concluded) throw new Error("FINDING_INVESTIGATION_CONCLUSION_REQUIRED");
const delivery = await createFeatureDelivery(investigation.projectId, input, actor);
const plan = await prisma.deliveryPlan.findFirstOrThrow({ where: { projectId: investigation.projectId, OR: [{ id: delivery.id }, { slug: delivery.id }] } });
await prisma.$transaction([
prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { deliveryPlanId: plan.id, status: InvestigationStatus.planned } }),
prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.planned } }),
]);
return { delivery, investigation: await getFindingInvestigation(investigation.id) };
};
export const queueFindingVerificationForDelivery = async (env: AppEnv, projectIdOrSlug: string, deliveryId: string) => {
const project = await projectByIdOrSlug(projectIdOrSlug);
const plan = await prisma.deliveryPlan.findFirst({ where: { projectId: project.id, OR: [{ id: deliveryId }, { slug: deliveryId }] }, include: { findingInvestigation: { include: { finding: true } } } });
const investigation = plan?.findingInvestigation;
if (!plan || !investigation?.finding.repositoryLinkId) return undefined;
if (new Set<InvestigationStatus>([InvestigationStatus.verifying, InvestigationStatus.resolved, InvestigationStatus.failed, InvestigationStatus.cancelled]).has(investigation.status)) return undefined;
const result = await createPortfolioAnalysisJob(env, `system:verification:${plan.id}`, { repositoryIds: [investigation.finding.repositoryLinkId], includeBranches: true, includePullRequests: true, includeActions: true });
await prisma.$transaction([
prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.verifying } }),
prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.verifying } }),
]);
return { job: result.job, investigationId: investigation.id, findingId: investigation.findingId };
};
export const finalizeFindingVerificationForJob = async (env: AppEnv, jobId: string) => {
const items = await prisma.analysisJobItem.findMany({ where: { jobId }, include: { findingOccurrences: true } });
const completed = [];
for (const item of items) {
if (item.status !== "succeeded") continue;
const investigations = await prisma.findingInvestigation.findMany({ where: { status: InvestigationStatus.verifying, finding: { owner: item.owner, repository: item.repository } }, include: { finding: { include: { issueLink: true } }, deliveryPlan: true } });
for (const investigation of investigations) {
const observed = item.findingOccurrences.some((occurrence) => occurrence.findingId === investigation.findingId && occurrence.observed);
if (observed) {
await prisma.$transaction([
prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.failed, completedAt: new Date(), conclusion: "De bevinding is na de merge opnieuw waargenomen." } }),
prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.regressed } }),
]);
if (investigation.finding.issueLink) await getGiteaIssueClient(env).createIssueComment(item.owner, item.repository, investigation.finding.issueLink.issueNumber, "ProjectBrain-verificatie mislukt: de bevinding is na de merge opnieuw waargenomen.").catch(() => undefined);
completed.push({ findingId: investigation.findingId, resolved: false });
} else if (item.findingOccurrences.some((occurrence) => occurrence.findingId === investigation.findingId && occurrence.observed === false)) {
await prisma.$transaction([
prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.resolved, completedAt: new Date(), conclusion: "Gerichte heranalyse bevestigt dat de bevinding verdwenen is." } }),
prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.resolved, resolvedAt: new Date(), clearCount: { increment: 1 } } }),
]);
if (investigation.deliveryPlan) await markFeatureDeliveryVerified(investigation.deliveryPlan.projectId, investigation.deliveryPlan.id, "projectbrain-verifier").catch(() => undefined);
const policy = investigation.finding.projectId ? await getFindingAutomationPolicy(investigation.finding.projectId) : undefined;
if (policy?.autoCloseResolved) await closeResolvedFindingIssue(env, investigation.findingId, "Gerichte heranalyse na de merge vond de bevinding niet meer.").catch(() => undefined);
completed.push({ findingId: investigation.findingId, resolved: true });
}
}
}
return completed;
};
export const syncAutomaticFindingIssues = async (env: AppEnv, jobId: string) => {
const findings = await prisma.repositoryFinding.findMany({ where: { lastAnalysisJobId: jobId, projectId: { not: null }, repositoryLinkId: { not: null }, status: { in: [FindingStatus.new, FindingStatus.regressed, FindingStatus.triaged] } }, include: { project: { include: { findingAutomationPolicy: true } }, issueLink: true } });
const created: string[] = [];
const counts = new Map<string, number>();
for (const finding of findings) {
const policy = finding.project?.findingAutomationPolicy ?? (finding.projectId ? await getFindingAutomationPolicy(finding.projectId) : undefined);
if (!policy?.enabled || policy.issueSyncMode !== IssueSyncMode.automatic || severityRank[finding.severity] < severityRank[policy.minimumSeverity] || finding.occurrenceCount < policy.minimumOccurrences) continue;
const projectCount = counts.get(finding.projectId!) ?? 0;
if (projectCount >= policy.maxIssuesPerRun) continue;
if (finding.status === FindingStatus.regressed && finding.issueLink?.state === "closed" && !policy.autoReopenRegressed) continue;
try {
await syncFindingIssue(env, finding.id, "system:analysis-worker");
counts.set(finding.projectId!, projectCount + 1);
created.push(finding.id);
} catch (error) {
console.warn(JSON.stringify({ event: "finding_issue_sync_failed", findingId: finding.id, code: error instanceof Error ? error.message : "FINDING_ISSUE_SYNC_FAILED" }));
}
}
return created;
};
@@ -0,0 +1,406 @@
import { ApprovalState, EntityType, EvidenceStrength, FeatureLifecycle, FeatureStatus, ReviewStatus, VerificationState } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import type { Initiative, Prisma } from "@prisma/client";
export type InitiativeDto = {
id: string;
projectId: string;
title: string;
intent: string;
status: "proposed" | "planned" | "active" | "complete" | "paused";
featureIds: string[];
priority: "critical" | "important" | "normal" | "low";
confidence?: number;
approvalState?: string;
sourceBrainstormId?: string;
reviewItemId?: string;
sourceType?: string;
metadata?: unknown;
provenance?: unknown;
createdAt?: string;
updatedAt?: string;
};
const slugify = (value: string) =>
value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "initiative";
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
export type CreateInitiativeInput = {
title: string;
intent: string;
priority?: InitiativeDto["priority"];
status?: InitiativeDto["status"];
confidence?: number;
featureIds?: string[];
sourceType?: string;
};
export type UpdateInitiativeInput = Partial<CreateInitiativeInput> & {
approvalState?: string;
};
const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const toStatus = (value: string): InitiativeDto["status"] => ["proposed", "planned", "active", "complete", "paused"].includes(value) ? value as InitiativeDto["status"] : "proposed";
const toPriority = (value: string): InitiativeDto["priority"] => ["critical", "important", "normal", "low"].includes(value) ? value as InitiativeDto["priority"] : "normal";
const getProject = async (projectIdOrSlug: string) => {
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }});
if (!project) throw new Error(`Project '${projectIdOrSlug}' not found`);
return project;
};
const toDto = (projectSlug: string, initiative: Initiative): InitiativeDto => ({
id: initiative.slug ?? initiative.id,
projectId: projectSlug,
title: initiative.title,
intent: initiative.intent,
status: toStatus(initiative.status),
featureIds: toStringArray(initiative.featureIdsJson),
priority: toPriority(initiative.priority),
confidence: initiative.confidence,
approvalState: initiative.approvalState,
sourceBrainstormId: initiative.sourceBrainstormId ?? undefined,
reviewItemId: initiative.reviewItemId ?? undefined,
sourceType: initiative.sourceType,
metadata: initiative.metadataJson ?? undefined,
provenance: initiative.provenanceJson ?? undefined,
createdAt: initiative.createdAt.toISOString(),
updatedAt: initiative.updatedAt.toISOString(),
});
export const listInitiativesForProject = async (projectIdOrSlug: string): Promise<InitiativeDto[]> => {
const project = await getProject(projectIdOrSlug);
const items = await prisma.initiative.findMany({
where: { projectId: project.id, deletedAt: null },
orderBy: [{ status: "asc" }, { updatedAt: "desc" }],
});
return items.map((item) => toDto(project.slug, item));
};
export const markInitiativePlannedFromReview = async (projectId: string, initiativeId: string, reviewItemId: string): Promise<string | undefined> => {
const existing = await prisma.initiative.findFirst({
where: { projectId, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] },
});
if (!existing) return undefined;
const updated = await prisma.initiative.update({
where: { id: existing.id },
data: {
status: "planned",
approvalState: ApprovalState.approved,
reviewItemId,
updatedBy: "review_queue",
},
});
return updated.slug ?? updated.id;
};
export const getInitiativeForProject = async (projectIdOrSlug: string, initiativeId: string): Promise<InitiativeDto | undefined> => {
const project = await getProject(projectIdOrSlug);
const item = await prisma.initiative.findFirst({
where: { projectId: project.id, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] },
});
return item ? toDto(project.slug, item) : undefined;
};
export const createInitiativeForProject = async (projectIdOrSlug: string, input: CreateInitiativeInput): Promise<InitiativeDto> => {
const project = await getProject(projectIdOrSlug);
const slug = `${slugify(input.title)}-${Date.now().toString(36)}`;
const item = await prisma.initiative.create({
data: {
projectId: project.id,
slug,
readableId: slug,
title: input.title,
intent: input.intent,
priority: input.priority ?? "normal",
status: input.status ?? "proposed",
confidence: input.confidence ?? 72,
featureIdsJson: toJson(input.featureIds ?? []),
approvalState: ApprovalState.pending,
sourceType: input.sourceType ?? "manual_initiative",
metadataJson: toJson({ reviewBeforeTruth: true, createdFrom: "initiative_endpoint" }),
provenanceJson: toJson({ source: input.sourceType ?? "manual_initiative" }),
},
});
await prisma.operatingLogEntry.create({
data: {
projectId: project.id,
slug: `log-initiative-created-${slug}`,
readableId: `log-initiative-created-${slug}`,
type: "initiative created",
title: `Initiative created: ${input.title}`,
summary: "A roadmap initiative was created as a review-gated planning object.",
linkedEntityIdsJson: toJson([item.slug ?? item.id]),
source: "initiative_center",
importance: input.priority === "critical" ? "critical" : input.priority === "important" ? "important" : "normal",
payloadJson: toJson({ initiativeId: item.slug ?? item.id, status: item.status }),
provenanceJson: toJson({ source: input.sourceType ?? "manual_initiative" }),
},
});
return toDto(project.slug, item);
};
export const updateInitiativeForProject = async (projectIdOrSlug: string, initiativeId: string, input: UpdateInitiativeInput): Promise<InitiativeDto | undefined> => {
const project = await getProject(projectIdOrSlug);
const existing = await prisma.initiative.findFirst({
where: { projectId: project.id, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] },
});
if (!existing) return undefined;
const updated = await prisma.initiative.update({
where: { id: existing.id },
data: {
title: input.title,
intent: input.intent,
priority: input.priority,
status: input.status,
confidence: input.confidence,
featureIdsJson: input.featureIds ? toJson(input.featureIds) : undefined,
approvalState: input.approvalState === "approved" ? ApprovalState.approved : input.approvalState === "rejected" ? ApprovalState.rejected : undefined,
updatedBy: "initiative_endpoint",
},
});
return toDto(project.slug, updated);
};
const suggestedFeatureTitlesFor = (initiative: InitiativeDto): string[] => {
const text = `${initiative.title} ${initiative.intent}`.toLowerCase();
if (text.includes("brainstorm")) return ["Brainstorm review detail", "Outcome conversion guardrails", "Idea graph comparison panel"];
if (text.includes("blueprint") || text.includes("documentation")) return ["Blueprint acceptance workflow", "Documentation export audit", "Stale document regeneration queue"];
if (text.includes("database") || text.includes("backend")) return ["Database smoke evidence panel", "API contract regression pack", "Persistence recovery checklist"];
if (text.includes("ux") || text.includes("usability")) return ["Guided empty-state actions", "Workflow-based navigation polish", "Mobile action rail refinement"];
return ["Initiative scope brief", "Feature candidate breakdown", "Acceptance criteria draft"];
};
export type InitiativeFeatureCandidateDto = {
id: string;
title: string;
confidence: number;
reviewItemId: string;
targetRoute: string;
materializedFeatureId?: string;
};
export type InitiativeReviewSyncResultDto = {
applied: boolean;
action: "accept" | "reject";
reviewItemId: string;
initiativeId?: string;
featureId?: string;
acceptedTruthId?: string;
affectedEntities: string[];
message: string;
};
export const createFeatureCandidatesForInitiative = async (projectIdOrSlug: string, initiativeId: string): Promise<{ initiative: InitiativeDto; candidates: InitiativeFeatureCandidateDto[] } | undefined> => {
const project = await getProject(projectIdOrSlug);
const initiative = await getInitiativeForProject(projectIdOrSlug, initiativeId);
if (!initiative) return undefined;
const titles = suggestedFeatureTitlesFor(initiative);
const candidates: InitiativeFeatureCandidateDto[] = [];
for (const [index, title] of titles.entries()) {
const slug = `rq-initiative-feature-${slugify(initiative.title)}-${index}-${Date.now().toString(36)}`;
const review = await prisma.reviewQueueItem.create({
data: {
projectId: project.id,
slug,
readableId: slug,
type: "feature",
title: `Feature candidate: ${title}`,
description: `Initiative '${initiative.title}' proposes this feature candidate.\n\nIntent: ${initiative.intent}`,
priority: initiative.priority === "critical" ? "critical" : initiative.priority === "important" ? "important" : "normal",
status: ReviewStatus.pending,
source: "initiative_center",
confidence: Math.max(62, (initiative.confidence ?? 72) - index * 6),
approvalState: ApprovalState.pending,
evidenceStrength: EvidenceStrength.medium,
verificationState: VerificationState.needs_evidence,
suggestedAction: "Review whether this feature should be promoted into the Feature Map.",
createdBy: "api",
updatedBy: "api",
sourceType: "initiative_feature_candidate",
targetEntityType: undefined,
targetEntityId: initiative.id,
payloadJson: toJson({ initiativeId: initiative.id, candidateTitle: title, candidateIndex: index }),
provenanceJson: toJson({ createdFrom: "Initiative feature generation", initiativeId: initiative.id }),
},
});
candidates.push({ id: `${initiative.id}-candidate-${index}`, title, confidence: Math.max(62, (initiative.confidence ?? 72) - index * 6), reviewItemId: review.slug ?? review.id, targetRoute: `/projects/${project.slug}/review-queue` });
}
await prisma.operatingLogEntry.create({
data: {
projectId: project.id,
slug: `log-initiative-feature-candidates-${slugify(initiative.title)}-${Date.now().toString(36)}`,
readableId: `log-initiative-feature-candidates-${slugify(initiative.title)}-${Date.now().toString(36)}`,
type: "initiative planning",
title: `Feature candidates generated for ${initiative.title}`,
summary: `${candidates.length} feature candidates were generated and queued for review.`,
linkedEntityIdsJson: toJson([initiative.id, ...candidates.map((item) => item.reviewItemId)]),
source: "initiative_center",
importance: initiative.priority === "critical" ? "critical" : initiative.priority === "important" ? "important" : "normal",
payloadJson: toJson({ initiativeId: initiative.id, candidateCount: candidates.length }),
provenanceJson: toJson({ source: "initiative_feature_generation" }),
},
});
return { initiative, candidates };
};
const safeRecord = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
const getString = (value: unknown, fallback = ""): string => typeof value === "string" && value.trim() ? value : fallback;
const acceptedTruthKindForInitiativeReview = (reviewType: string) =>
reviewType === "feature" ? "feature" : reviewType;
const ensureInitiativeFeatureGroup = async (projectId: string) =>
prisma.featureGroup.upsert({
where: { projectId_slug: { projectId, slug: "initiative-roadmap" } },
create: {
projectId,
slug: "initiative-roadmap",
readableId: "initiative-roadmap",
name: "Initiative Roadmap",
description: "Features materialized from accepted roadmap initiative candidates.",
focusArea: "Roadmap",
},
update: {},
});
export const syncInitiativeReviewAction = async (
projectIdOrSlug: string,
reviewItemId: string,
action: "accept" | "reject",
): Promise<InitiativeReviewSyncResultDto | undefined> => {
const project = await getProject(projectIdOrSlug);
const review = await prisma.reviewQueueItem.findFirst({
where: {
projectId: project.id,
deletedAt: null,
OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }],
},
});
if (!review || review.sourceType !== "initiative_feature_candidate") return undefined;
const payload = safeRecord(review.payloadJson);
const initiativeId = getString(payload.initiativeId, review.targetEntityId ?? "");
const candidateTitle = getString(payload.candidateTitle, review.title.replace(/^Feature candidate:\s*/i, ""));
const affectedEntities = [review.slug ?? review.id];
let featureId: string | undefined;
let acceptedTruthId: string | undefined;
if (initiativeId) affectedEntities.push(initiativeId);
if (action === "accept") {
const group = await ensureInitiativeFeatureGroup(project.id);
const featureSlug = `initiative-feature-${slugify(candidateTitle)}-${Date.now().toString(36)}`;
const feature = await prisma.feature.create({
data: {
projectId: project.id,
featureGroupId: group.id,
slug: featureSlug,
readableId: featureSlug,
name: candidateTitle,
description: review.description,
status: FeatureStatus.todo,
lifecycle: FeatureLifecycle.planned,
intent: `Materialized from accepted roadmap initiative candidate. Initiative: ${initiativeId || "unknown"}.`,
confidence: review.confidence,
health: 65,
approvalState: ApprovalState.approved,
evidenceStrength: review.evidenceStrength,
verificationState: VerificationState.needs_evidence,
trustJson: toJson({ initiativeId, reviewItemId: review.slug ?? review.id, materializedFrom: "initiative_feature_candidate" }),
provenanceJson: toJson({ source: "initiative_review_accept", initiativeId, reviewItemId: review.id, candidateTitle }),
sourceType: "initiative_review_accept",
createdBy: "review_queue",
updatedBy: "review_queue",
},
});
featureId = feature.slug ?? feature.id;
affectedEntities.push(featureId);
if (initiativeId) {
const initiative = await prisma.initiative.findFirst({
where: { projectId: project.id, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] },
});
if (initiative) {
const existingFeatureIds = toStringArray(initiative.featureIdsJson);
await prisma.initiative.update({
where: { id: initiative.id },
data: {
status: initiative.status === "proposed" ? "planned" : initiative.status,
approvalState: ApprovalState.approved,
featureIdsJson: toJson(Array.from(new Set([...existingFeatureIds, featureId]))),
updatedBy: "review_queue",
},
});
}
}
const existingTruth = await prisma.acceptedTruthItem.findFirst({ where: { projectId: project.id, sourceReviewItemId: review.id, deletedAt: null } });
if (existingTruth) acceptedTruthId = existingTruth.slug ?? existingTruth.id;
else {
const slug = `truth-initiative-feature-${slugify(candidateTitle)}-${Date.now().toString(36)}`;
const truth = await prisma.acceptedTruthItem.create({
data: {
projectId: project.id,
slug,
readableId: slug,
sourceReviewItemId: review.id,
kind: acceptedTruthKindForInitiativeReview(review.type),
title: `Accepted initiative feature: ${candidateTitle}`,
summary: review.description,
status: "accepted",
source: "initiative_center",
confidence: review.confidence,
approvalState: ApprovalState.approved,
evidenceStrength: review.evidenceStrength,
verificationState: VerificationState.verified,
payloadJson: toJson({ initiativeId, featureId, reviewItemId: review.slug ?? review.id, candidateTitle, targetRoute: `/projects/${project.slug}/features/${featureId}` }),
provenanceJson: toJson({ createdFrom: "Initiative feature candidate review acceptance", sourceType: review.sourceType }),
verifiedAt: new Date(),
verifiedBy: "api",
},
});
acceptedTruthId = truth.slug ?? truth.id;
}
if (acceptedTruthId) affectedEntities.push(acceptedTruthId);
}
await prisma.operatingLogEntry.create({
data: {
projectId: project.id,
slug: `log-initiative-review-${slugify(candidateTitle)}-${Date.now().toString(36)}`,
readableId: `log-initiative-review-${slugify(candidateTitle)}-${Date.now().toString(36)}`,
type: "initiative review sync",
title: action === "accept" ? `Initiative feature accepted: ${candidateTitle}` : `Initiative feature rejected: ${candidateTitle}`,
summary: action === "accept" ? "Accepted initiative feature candidate was materialized into the Feature Map and Accepted Truth." : "Rejected initiative feature candidate was left out of the Feature Map.",
linkedEntityIdsJson: toJson(affectedEntities),
source: "initiative_center",
importance: action === "accept" ? "important" : "normal",
payloadJson: toJson({ action, initiativeId, featureId, acceptedTruthId }),
provenanceJson: toJson({ reviewItemId: review.id, sourceType: review.sourceType }),
},
});
return {
applied: true,
action,
reviewItemId: review.slug ?? review.id,
initiativeId,
featureId,
acceptedTruthId,
affectedEntities,
message: action === "accept"
? "Initiative feature candidate accepted and materialized into Feature Map."
: "Initiative feature candidate rejected without materializing a feature.",
};
};
@@ -0,0 +1,568 @@
import { ApprovalState, type Prisma } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import { listContextPacksForProject } from "./contextPacksRepository.js";
import { selectContextPacks } from "../services/contextPackSelector.js";
import type {
CodexRunImportDto,
CodexTaskDto,
CodexTaskListFilters,
CreateCodexTaskFromHandoffRequestDto,
CreateHandoffBundleRequestDto,
CurrentStateReportDto,
CurrentStateReportListFilters,
GenerateCurrentStateReportRequestDto,
HandoffBundleDto,
HandoffBundleListFilters,
HandoffTarget,
ImportCodexOutputRequestDto,
P7MutationResponseDto,
} from "../types/api.js";
type ProjectRecord = { id: string; slug: string; activeContextPackId: string | null };
type CurrentStateRecord = Prisma.CurrentStateReportGetPayload<Record<string, never>>;
type HandoffRecord = Prisma.HandoffBundleGetPayload<Record<string, never>>;
type CodexTaskRecord = Prisma.CodexTaskGetPayload<Record<string, never>>;
type CodexImportRecord = Prisma.CodexRunImportGetPayload<Record<string, never>>;
type PageOptions = { page: number; pageSize: number; sort?: string };
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
const asStrings = (value: Prisma.JsonValue | null | undefined): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const asRecord = (value: Prisma.JsonValue | null | undefined): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
const publicId = (record: { id: string; slug: string | null; readableId: string | null }) => record.slug ?? record.readableId ?? record.id;
type StoredContextSelection = {
primaryContextPackId?: string;
selectedContextPackIds?: string[];
decisionSummary?: string;
candidateCount?: number;
};
const readStoredContextSelection = (value: Prisma.JsonValue | null | undefined): StoredContextSelection | undefined => {
const record = asRecord(value);
const rawSelection = asRecord(record?.contextSelection as Prisma.JsonValue | undefined);
if (!rawSelection) return undefined;
const primaryContextPackId = typeof rawSelection.primaryContextPackId === "string" ? rawSelection.primaryContextPackId : undefined;
const selectedContextPackIds = asStrings(rawSelection.selectedContextPackIds as Prisma.JsonValue | undefined);
const decisionSummary = typeof rawSelection.decisionSummary === "string" ? rawSelection.decisionSummary : undefined;
const candidateCount = typeof rawSelection.candidateCount === "number" ? rawSelection.candidateCount : undefined;
if (!primaryContextPackId && !selectedContextPackIds.length && !decisionSummary) return undefined;
return { primaryContextPackId, selectedContextPackIds, decisionSummary, candidateCount };
};
const findProjectRecord = (projectIdOrSlug: string): Promise<ProjectRecord | null> =>
prisma.project.findFirst({
where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }] },
select: { id: true, slug: true, activeContextPackId: true },
});
const findHandoffRecord = (projectId: string, idOrSlug: string) =>
prisma.handoffBundle.findFirst({
where: { projectId, deletedAt: null, OR: [{ id: idOrSlug }, { slug: idOrSlug }, { readableId: idOrSlug }] },
});
const findCodexTaskRecord = (projectId: string, idOrSlug: string) =>
prisma.codexTask.findFirst({
where: { projectId, deletedAt: null, OR: [{ id: idOrSlug }, { slug: idOrSlug }, { readableId: idOrSlug }] },
});
const paginateMeta = <T>(items: T[], total: number, page: number, pageSize: number, sort?: string, filters?: Record<string, unknown>) => ({
items,
total,
page,
pageSize,
hasNextPage: page < Math.ceil(total / pageSize),
hasPreviousPage: page > 1,
...(sort ? { sort } : {}),
...(filters ? { filters: filters as Record<string, string | number | boolean | string[]> } : {}),
});
const toCurrentStateDto = (record: CurrentStateRecord, projectSlug: string): CurrentStateReportDto => ({
id: publicId(record),
projectId: projectSlug,
generatedAt: record.generatedAt.toISOString(),
exists: asStrings(record.existsJson),
works: asStrings(record.worksJson),
uncertain: asStrings(record.uncertainJson),
broken: asStrings(record.brokenJson),
currentRisks: asStrings(record.currentRisksJson),
pendingReviewItemIds: asStrings(record.pendingReviewItemIdsJson),
...(record.activeContextPackId ? { activeContextPackId: record.activeContextPackId } : {}),
recommendedNextActions: asStrings(record.recommendedNextActionsJson),
source: record.source,
approvalState: record.approvalState,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
});
const toHandoffDto = (record: HandoffRecord, projectSlug: string): HandoffBundleDto => {
const contextSelection = readStoredContextSelection(record.payloadJson);
return {
id: publicId(record),
projectId: projectSlug,
target: record.target as HandoffTarget,
title: record.title,
markdown: record.markdown,
qualityScore: record.qualityScore,
...(contextSelection?.primaryContextPackId ? { primaryContextPackId: contextSelection.primaryContextPackId } : {}),
...(contextSelection?.selectedContextPackIds?.length ? { selectedContextPackIds: contextSelection.selectedContextPackIds } : {}),
sourceTruthItemIds: asStrings(record.sourceTruthItemIdsJson),
sourceReviewItemIds: asStrings(record.sourceReviewItemIdsJson),
sourceCaptureItemIds: asStrings(record.sourceCaptureItemIdsJson),
sourceProjectUpdateIds: asStrings(record.sourceProjectUpdateIdsJson),
sourceOperatingLogEntryIds: asStrings(record.sourceOperatingLogEntryIdsJson),
status: record.status as HandoffBundleDto["status"],
approvalState: record.approvalState,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
};
const toCodexTaskDto = (record: CodexTaskRecord, projectSlug: string): CodexTaskDto => {
const contextSelection = readStoredContextSelection(record.payloadJson);
return {
id: publicId(record),
projectId: projectSlug,
...(record.handoffBundleId ? { handoffBundleId: record.handoffBundleId } : {}),
goal: record.goal,
...(record.contextPackId ? { contextPackId: record.contextPackId } : {}),
...(contextSelection?.selectedContextPackIds?.length ? { selectedContextPackIds: contextSelection.selectedContextPackIds } : {}),
...(contextSelection?.decisionSummary ? { contextSelectionSummary: contextSelection.decisionSummary } : {}),
constraints: asStrings(record.constraintsJson),
relevantFiles: asStrings(record.relevantFilesJson),
doNotBreakRules: asStrings(record.doNotBreakRulesJson),
validationCommands: asStrings(record.validationCommandsJson),
expectedReportFormat: record.expectedReportFormat,
outputImportInstructions: record.outputImportInstructions,
approvalState: record.approvalState,
status: record.status as CodexTaskDto["status"],
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
};
const toCodexImportDto = (record: CodexImportRecord, projectSlug: string): CodexRunImportDto => {
const contextSelection = readStoredContextSelection(record.payloadJson);
return {
id: publicId(record),
projectId: projectSlug,
...(record.codexTaskId ? { codexTaskId: record.codexTaskId } : {}),
sourceRun: record.sourceRun,
summary: record.summary,
...(contextSelection?.primaryContextPackId ? { contextPackId: contextSelection.primaryContextPackId } : {}),
...(contextSelection?.selectedContextPackIds?.length ? { selectedContextPackIds: contextSelection.selectedContextPackIds } : {}),
...(contextSelection?.decisionSummary ? { contextSelectionSummary: contextSelection.decisionSummary } : {}),
extractedReviewItemIds: asStrings(record.extractedReviewItemIdsJson),
status: record.status as CodexRunImportDto["status"],
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
};
const resolveRuntimeContextSelection = async (args: {
projectSlug: string;
activeContextPackId?: string | null;
target: HandoffTarget | "codex-task";
title: string;
intent?: string;
explicitContextPackId?: string;
existingSelection?: StoredContextSelection;
}): Promise<StoredContextSelection | undefined> => {
if (args.explicitContextPackId) {
return {
primaryContextPackId: args.explicitContextPackId,
selectedContextPackIds: [args.explicitContextPackId],
decisionSummary: `Explicit context pack '${args.explicitContextPackId}' was supplied by the caller.`,
candidateCount: 1,
};
}
if (args.existingSelection?.primaryContextPackId || args.existingSelection?.selectedContextPackIds?.length) {
return args.existingSelection;
}
const contextPacks = await listContextPacksForProject(args.projectSlug);
if (!contextPacks?.length) return undefined;
const mode = args.target === "codex-task" ? "codex" : args.target === "current-state" ? "validation" : "workspace";
const selection = selectContextPacks(
args.projectSlug,
contextPacks,
{
mode,
intent: args.intent?.trim() || `${args.title}. Runtime handoff should preserve accepted truth, review discipline and current project constraints.`,
targetType: args.target === "codex-task" ? "build" : "project",
maxPacks: 3,
},
args.activeContextPackId ?? undefined,
);
return {
primaryContextPackId: selection.primaryContextPackId,
selectedContextPackIds: selection.selectedContextPackIds,
decisionSummary: selection.decisionSummary,
candidateCount: selection.candidates.length,
};
};
const mkSlug = (prefix: string, seed: string) => `${prefix}-${seed.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 42)}-${Date.now().toString(36)}`;
export const listCurrentStateReportsFromDatabase = async (projectIdOrSlug: string, filters: CurrentStateReportListFilters, options: PageOptions) => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const where: Prisma.CurrentStateReportWhereInput = {
projectId: project.id,
deletedAt: null,
...(filters.approvalState?.length ? { approvalState: { in: filters.approvalState } } : {}),
...(filters.source?.length ? { source: { in: filters.source } } : {}),
};
const total = await prisma.currentStateReport.count({ where });
const records = await prisma.currentStateReport.findMany({
where,
orderBy: { generatedAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
skip: (options.page - 1) * options.pageSize,
take: options.pageSize,
});
return paginateMeta(records.map((record) => toCurrentStateDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
};
export const generateCurrentStateReportInDatabase = async (
projectIdOrSlug: string,
_body: GenerateCurrentStateReportRequestDto,
): Promise<CurrentStateReportDto | undefined> => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const [truth, updates, review, logs, contextPack] = await Promise.all([
prisma.acceptedTruthItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }),
prisma.projectUpdate.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }),
prisma.reviewQueueItem.findMany({ where: { projectId: project.id, deletedAt: null, status: "pending" }, orderBy: { createdAt: "desc" }, take: 12 }),
prisma.operatingLogEntry.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 8 }),
prisma.contextPack.findFirst({ where: { projectId: project.id, deletedAt: null }, orderBy: { updatedAt: "desc" } }),
]);
const exists = ["Project", "Workspace", "Project DNA", "Feature/Evidence DB parity", "Capture/Review DB parity", "Accepted Truth/Project Updates/Operating Log DB parity"];
const works = [
...truth.slice(0, 3).map((item) => `Accepted truth: ${item.title}`),
...updates.slice(0, 3).map((item) => `Project update ${item.status}: ${item.title}`),
...logs.slice(0, 2).map((item) => `Operating log: ${item.title}`),
];
const uncertain = review.length ? [`${review.length} pending Review Queue item(s) still require approval.`] : [];
const broken = updates.filter((item) => item.status !== "applied").length ? ["Some project updates are not applied yet."] : [];
const currentRisks = [
"Generated reports remain reviewable artifacts; they do not mutate project truth silently.",
...(review.length ? ["Pending review items may hide unaccepted build or product facts."] : []),
];
const recommendedNextActions = [
updates.some((item) => item.status !== "applied") ? "Apply ready project updates after review." : "Prepare the next context handoff from accepted truth.",
"Create a Codex handoff bundle from the latest accepted truth and current state.",
"Import Codex/build output through Capture and Review Queue, not directly into truth.",
];
const record = await prisma.currentStateReport.create({
data: {
projectId: project.id,
slug: mkSlug("current-state", project.slug),
readableId: mkSlug("csr", project.slug),
existsJson: toJson(exists),
worksJson: toJson(works.length ? works : ["Backend persistence gates up to P6 are available."]),
uncertainJson: toJson(uncertain),
brokenJson: toJson(broken),
currentRisksJson: toJson(currentRisks),
pendingReviewItemIdsJson: toJson(review.map((item) => item.slug ?? item.readableId ?? item.id)),
activeContextPackId: project.activeContextPackId ?? contextPack?.slug ?? contextPack?.readableId ?? contextPack?.id,
recommendedNextActionsJson: toJson(recommendedNextActions),
source: "database-runtime-generator",
approvalState: ApprovalState.pending,
provenanceJson: toJson({ createdFrom: "P7 current state generator" }),
},
});
await prisma.operatingLogEntry.create({
data: {
projectId: project.id,
slug: mkSlug("log-current-state", project.slug),
readableId: mkSlug("log", project.slug),
type: "build summary",
title: "Current State report generated",
summary: `Generated ${publicId(record)} from accepted truth, project updates and operating log.`,
linkedEntityIdsJson: toJson([record.id]),
source: "p7-current-state",
importance: "important",
},
});
return toCurrentStateDto(record, project.slug);
};
const markdownForHandoff = (args: {
projectSlug: string;
target: HandoffTarget;
title: string;
truth: Array<{ id: string; slug: string | null; readableId: string | null; title: string; kind: string; summary: string }>;
updates: Array<{ id: string; slug: string | null; readableId: string | null; title: string; targetArea: string; status: string; summary: string }>;
logs: Array<{ id: string; slug: string | null; readableId: string | null; title: string; type: string; summary: string }>;
contextSelection?: StoredContextSelection;
}) => [
`# ${args.title}`,
"",
`Project: ${args.projectSlug}`,
`Target: ${args.target}`,
`Generated: ${new Date().toISOString()}`,
"",
"## Automatic context pack selection",
args.contextSelection?.primaryContextPackId ? `Primary pack: ${args.contextSelection.primaryContextPackId}` : "Primary pack: none",
args.contextSelection?.selectedContextPackIds?.length ? `Selected packs: ${args.contextSelection.selectedContextPackIds.join(", ")}` : "Selected packs: none",
args.contextSelection?.decisionSummary ? `Decision: ${args.contextSelection.decisionSummary}` : "Decision: no selector result attached",
typeof args.contextSelection?.candidateCount === "number" ? `Candidates considered: ${args.contextSelection.candidateCount}` : "Candidates considered: 0",
"",
"## Accepted truth",
...(args.truth.length ? args.truth.map((item) => `- [${item.kind}] ${item.title}: ${item.summary}`) : ["- None yet."]),
"",
"## Project updates",
...(args.updates.length ? args.updates.map((item) => `- [${item.status}] [${item.targetArea}] ${item.title}: ${item.summary}`) : ["- None yet."]),
"",
"## Operating log context",
...(args.logs.length ? args.logs.map((item) => `- [${item.type}] ${item.title}: ${item.summary}`) : ["- None yet."]),
"",
"## Guardrails",
"- Do not mutate project truth directly from AI/build output.",
"- Route output back through Capture and Review Queue.",
"- Preserve existing API contracts and fixture mode unless explicitly changing a persistence gate.",
].join("\n");
export const listHandoffBundlesFromDatabase = async (projectIdOrSlug: string, filters: HandoffBundleListFilters, options: PageOptions) => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const where: Prisma.HandoffBundleWhereInput = {
projectId: project.id,
deletedAt: null,
...(filters.target?.length ? { target: { in: filters.target } } : {}),
...(filters.status?.length ? { status: { in: filters.status } } : {}),
...(filters.approvalState?.length ? { approvalState: { in: filters.approvalState } } : {}),
};
const total = await prisma.handoffBundle.count({ where });
const records = await prisma.handoffBundle.findMany({ where, orderBy: { createdAt: "desc" }, skip: (options.page - 1) * options.pageSize, take: options.pageSize });
return paginateMeta(records.map((record) => toHandoffDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
};
export const createHandoffBundleInDatabase = async (projectIdOrSlug: string, body: CreateHandoffBundleRequestDto): Promise<HandoffBundleDto | undefined> => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const [truth, updates, review, captures, logs] = await Promise.all([
prisma.acceptedTruthItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 12 }),
prisma.projectUpdate.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 12 }),
prisma.reviewQueueItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }),
prisma.captureItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }),
prisma.operatingLogEntry.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 8 }),
]);
const target = body.target ?? "codex-task";
const title = body.title?.trim() || `${target} handoff for ${project.slug}`;
const contextSelection = await resolveRuntimeContextSelection({
projectSlug: project.slug,
activeContextPackId: project.activeContextPackId,
target,
title,
intent: body.selectionIntent,
explicitContextPackId: body.contextPackId,
});
const qualityScore = Math.min(98, 45 + truth.length * 10 + updates.length * 5 + logs.length * 2 + (contextSelection?.primaryContextPackId ? 6 : 0));
const markdown = markdownForHandoff({ projectSlug: project.slug, target, title, truth, updates, logs, contextSelection });
const record = await prisma.handoffBundle.create({
data: {
projectId: project.id,
slug: mkSlug("handoff", title),
readableId: mkSlug("hb", project.slug),
target,
title,
markdown,
qualityScore,
sourceTruthItemIdsJson: toJson(truth.map(publicId)),
sourceReviewItemIdsJson: toJson(review.map(publicId)),
sourceCaptureItemIdsJson: toJson(captures.map(publicId)),
sourceProjectUpdateIdsJson: toJson(updates.map(publicId)),
sourceOperatingLogEntryIdsJson: toJson(logs.map(publicId)),
status: "ready",
approvalState: ApprovalState.pending,
payloadJson: toJson({ contextSelection }),
provenanceJson: toJson({ createdFrom: "P7 handoff bundle generator", contextSelectionSummary: contextSelection?.decisionSummary }),
},
});
await prisma.operatingLogEntry.create({
data: {
projectId: project.id,
slug: mkSlug("log-handoff", project.slug),
readableId: mkSlug("log", project.slug),
type: "project update",
title: "Handoff bundle created",
summary: `${title} created for ${target} with ${qualityScore}% quality.`,
linkedEntityIdsJson: toJson([record.id]),
source: "p7-handoff",
importance: qualityScore >= 75 ? "important" : "normal",
},
});
return toHandoffDto(record, project.slug);
};
export const listCodexTasksFromDatabase = async (projectIdOrSlug: string, filters: CodexTaskListFilters, options: PageOptions) => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const handoffFilter = filters.handoffBundleId ? await findHandoffRecord(project.id, filters.handoffBundleId) : undefined;
if (filters.handoffBundleId && !handoffFilter) {
return paginateMeta([], 0, options.page, options.pageSize, options.sort, filters);
}
const where: Prisma.CodexTaskWhereInput = {
projectId: project.id,
deletedAt: null,
...(filters.status?.length ? { status: { in: filters.status } } : {}),
...(filters.approvalState?.length ? { approvalState: { in: filters.approvalState } } : {}),
...(handoffFilter ? { handoffBundleId: handoffFilter.id } : {}),
};
const total = await prisma.codexTask.count({ where });
const records = await prisma.codexTask.findMany({ where, orderBy: { createdAt: "desc" }, skip: (options.page - 1) * options.pageSize, take: options.pageSize });
return paginateMeta(records.map((record) => toCodexTaskDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters);
};
export const createCodexTaskFromHandoffInDatabase = async (
projectIdOrSlug: string,
handoffBundleId: string,
body: CreateCodexTaskFromHandoffRequestDto,
): Promise<CodexTaskDto | undefined | "HANDOFF_NOT_FOUND"> => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const handoff = await findHandoffRecord(project.id, handoffBundleId);
if (!handoff) return "HANDOFF_NOT_FOUND";
const goal = body.goal?.trim() || `Implement ${handoff.title}`;
const existingSelection = readStoredContextSelection(handoff.payloadJson);
const contextSelection = body.autoSelectContext === false
? await resolveRuntimeContextSelection({
projectSlug: project.slug,
activeContextPackId: project.activeContextPackId,
target: "codex-task",
title: goal,
explicitContextPackId: body.contextPackId ?? existingSelection?.primaryContextPackId ?? project.activeContextPackId ?? undefined,
existingSelection,
})
: await resolveRuntimeContextSelection({
projectSlug: project.slug,
activeContextPackId: project.activeContextPackId,
target: "codex-task",
title: goal,
intent: body.selectionIntent ?? `${goal}. ${handoff.title}.`,
explicitContextPackId: body.contextPackId,
existingSelection,
});
const selectedContextPackId = body.contextPackId ?? contextSelection?.primaryContextPackId ?? project.activeContextPackId ?? undefined;
const record = await prisma.codexTask.create({
data: {
projectId: project.id,
slug: mkSlug("codex-task", goal),
readableId: mkSlug("ct", project.slug),
handoffBundleId: handoff.id,
goal,
contextPackId: selectedContextPackId,
constraintsJson: toJson(["Preserve fixture mode.", "Do not write AI output directly into project truth.", "Keep review-before-truth semantics."]),
relevantFilesJson: toJson(["backend/src/routes", "backend/src/repositories", "src/features/operations", "docs"]),
doNotBreakRulesJson: toJson(["No Vite middleware expansion.", "No frontend default switch to real mode.", "No silent truth mutation."]),
validationCommandsJson: toJson(body.validationCommands?.length ? body.validationCommands : ["pnpm run validate:all", "pnpm run audit:runtime", "pnpm run audit:release"]),
expectedReportFormat: "Changed files, validation commands, remaining warnings, smoke status and next checklist.",
outputImportInstructions: "Import Codex/build output as Capture, extract Review Queue items, and only materialize approved truth after review.",
approvalState: ApprovalState.pending,
status: "ready",
payloadJson: toJson({ contextSelection }),
provenanceJson: toJson({ handoffBundleId: publicId(handoff), createdFrom: "P7 handoff", contextSelectionSummary: contextSelection?.decisionSummary }),
},
});
await prisma.handoffBundle.update({ where: { id: handoff.id }, data: { status: "used" } });
await prisma.operatingLogEntry.create({
data: {
projectId: project.id,
slug: mkSlug("log-codex-task", project.slug),
readableId: mkSlug("log", project.slug),
type: "codex run",
title: "Codex task created from handoff",
summary: `${goal} was created from ${handoff.title}.${selectedContextPackId ? ` Context pack: ${selectedContextPackId}.` : ""}`,
linkedEntityIdsJson: toJson([handoff.id, record.id]),
source: "p7-codex-task",
importance: "important",
},
});
return toCodexTaskDto(record, project.slug);
};
export const importCodexOutputInDatabase = async (
projectIdOrSlug: string,
codexTaskId: string,
body: ImportCodexOutputRequestDto,
): Promise<{ importDto: CodexRunImportDto; mutation: P7MutationResponseDto } | undefined | "CODEX_TASK_NOT_FOUND"> => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const task = await findCodexTaskRecord(project.id, codexTaskId);
if (!task) return "CODEX_TASK_NOT_FOUND";
const contextSelection = readStoredContextSelection(task.payloadJson) ?? (task.contextPackId
? {
primaryContextPackId: task.contextPackId,
selectedContextPackIds: [task.contextPackId],
decisionSummary: `Imported output inherits Codex task context pack '${task.contextPackId}'.`,
candidateCount: 1,
}
: undefined);
const review = await prisma.reviewQueueItem.create({
data: {
projectId: project.id,
slug: mkSlug("review-codex-output", project.slug),
readableId: mkSlug("rq", project.slug),
type: "documentation_update",
title: `Review Codex output for ${task.goal}`,
description: body.summary,
priority: "important",
status: "pending",
source: body.sourceRun ?? task.slug ?? task.id,
confidence: 72,
approvalState: ApprovalState.pending,
evidenceStrength: "medium",
verificationState: "unverified",
suggestedAction: "Review generated changes, then accept only verified feature, documentation, memory or regression updates.",
createdBy: "api",
updatedBy: "api",
sourceType: "codex_output_import",
payloadJson: toJson({ outputText: body.outputText ?? "", codexTaskId: task.id, contextSelection }),
provenanceJson: toJson({ createdFrom: "P7 codex import", contextSelectionSummary: contextSelection?.decisionSummary }),
},
});
const record = await prisma.codexRunImport.create({
data: {
projectId: project.id,
slug: mkSlug("codex-import", project.slug),
readableId: mkSlug("cri", project.slug),
codexTaskId: task.id,
sourceRun: body.sourceRun ?? `manual-${Date.now().toString(36)}`,
summary: body.summary,
extractedReviewItemIdsJson: toJson([publicId(review)]),
status: "imported",
payloadJson: toJson({ outputText: body.outputText ?? "", contextSelection }),
provenanceJson: toJson({ createdFrom: "P7 codex output import", contextSelectionSummary: contextSelection?.decisionSummary }),
},
});
await prisma.codexTask.update({ where: { id: task.id }, data: { status: "imported" } });
await prisma.operatingLogEntry.create({
data: {
projectId: project.id,
slug: mkSlug("log-codex-import", project.slug),
readableId: mkSlug("log", project.slug),
type: "codex run",
title: "Codex output imported to Review Queue",
summary: `${body.summary}${contextSelection?.primaryContextPackId ? ` Context pack: ${contextSelection.primaryContextPackId}.` : ""}`,
linkedEntityIdsJson: toJson([task.id, record.id, review.id]),
source: "p7-codex-import",
importance: "important",
},
});
return {
importDto: toCodexImportDto(record, project.slug),
mutation: {
success: true,
message: "Codex output imported as a Review Queue item.",
createdEntityId: publicId(review),
updatedEntityId: publicId(task),
affectedEntities: [publicId(task), publicId(record), publicId(review)],
},
};
};
@@ -0,0 +1,270 @@
import type { Prisma } from "@prisma/client";
import { ApprovalState, EvidenceStrength, VerificationState } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import type {
KnowledgeItemDto,
KnowledgeListFilters,
KnowledgeMutationRequestDto,
MemoryRuleDto,
MemoryRuleListFilters,
MemoryRuleMutationRequestDto,
} from "../types/api.js";
export type DocumentationItemDto = {
id: string;
projectId: string;
title: string;
kind: string;
status: string;
linkedFeatureIds: string[];
lastUpdated?: string;
primaryContextPackId?: string;
selectedContextPackIds?: string[];
contextSelectionSummary?: string;
approvalState: "pending" | "approved" | "rejected" | "blocked";
content?: string;
createdAt: string;
updatedAt: string;
};
type ApprovalStateInput = ApprovalState | "pending" | "approved" | "rejected" | "blocked";
export type DocumentationListFilters = {
kind?: string[];
status?: string[];
approvalState?: string[];
search?: string;
};
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
export const projectExists = async (projectIdOrSlug: string): Promise<boolean> => {
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }, select: { id: true }});
return Boolean(project);
};
const getProject = async (projectIdOrSlug: string) => {
const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }});
if (!project) throw new Error(`Project '${projectIdOrSlug}' not found`);
return project;
};
const metadata = (value: Prisma.JsonValue | null | undefined): Record<string, unknown> => isRecord(value) ? value : {};
const trust = (source: string, confidence: number, approvalState: ApprovalState, createdAt: Date) => ({
source,
confidence,
evidence: [],
lastChecked: createdAt.toISOString(),
approvalState,
});
export const toKnowledgeDto = (item: {
slug: string | null; readableId: string | null; project: { slug: string }; title: string; category: string; content: string; source: string; confidence: number; metadataJson: Prisma.JsonValue | null; trustJson: Prisma.JsonValue | null; approvalState: ApprovalState; createdAt: Date; updatedAt: Date;
}): KnowledgeItemDto => {
const meta = metadata(item.metadataJson);
const trustJson = isRecord(item.trustJson) ? item.trustJson : trust(item.source, item.confidence, item.approvalState, item.updatedAt);
return {
id: item.slug ?? item.readableId ?? item.title,
projectId: item.project.slug,
title: item.title,
category: item.category,
content: item.content,
linkedFeatureIds: toStringArray(meta.linkedFeatureIds),
source: item.source,
confidence: item.confidence,
includeInContextPacks: toStringArray(meta.includeInContextPacks),
trust: trustJson as KnowledgeItemDto["trust"],
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
};
};
export const toMemoryRuleDto = (rule: {
slug: string | null; readableId: string | null; project: { slug: string }; title: string; rule: string; scope: string; category: string; severity: string; includeInContextPacks: boolean; confidence: number; trustJson: Prisma.JsonValue | null; provenanceJson: Prisma.JsonValue | null; approvalState: ApprovalState; createdAt: Date; updatedAt: Date;
}): MemoryRuleDto => {
const meta = metadata(rule.provenanceJson);
const trustJson = isRecord(rule.trustJson) ? rule.trustJson : trust("database memory rule", rule.confidence, rule.approvalState, rule.updatedAt);
return {
id: rule.slug ?? rule.readableId ?? rule.title,
projectId: rule.project.slug,
title: rule.title,
rule: rule.rule,
scope: rule.scope as MemoryRuleDto["scope"],
category: rule.category as MemoryRuleDto["category"],
severity: rule.severity as MemoryRuleDto["severity"],
linkedFeatureIds: toStringArray(meta.linkedFeatureIds),
includeInContextPacks: toStringArray(meta.includeInContextPacks),
includeInContextPacksDefault: rule.includeInContextPacks,
trust: trustJson as MemoryRuleDto["trust"],
createdAt: rule.createdAt.toISOString(),
updatedAt: rule.updatedAt.toISOString(),
};
};
export const toDocumentationDto = (doc: {
slug: string | null; readableId: string | null; project: { slug: string }; title: string; kind: string; status: string; approvalState: ApprovalState; content: string | null; metadataJson: Prisma.JsonValue | null; createdAt: Date; updatedAt: Date;
}): DocumentationItemDto => {
const meta = metadata(doc.metadataJson);
return {
id: doc.slug ?? doc.readableId ?? doc.title,
projectId: doc.project.slug,
title: doc.title,
kind: doc.kind,
status: doc.status,
linkedFeatureIds: toStringArray(meta.linkedFeatureIds),
lastUpdated: typeof meta.lastUpdated === "string" ? meta.lastUpdated : doc.updatedAt.toISOString(),
primaryContextPackId: typeof meta.primaryContextPackId === "string" ? meta.primaryContextPackId : undefined,
selectedContextPackIds: toStringArray(meta.selectedContextPackIds),
contextSelectionSummary: typeof meta.contextSelectionSummary === "string" ? meta.contextSelectionSummary : undefined,
approvalState: doc.approvalState,
content: doc.content ?? undefined,
createdAt: doc.createdAt.toISOString(),
updatedAt: doc.updatedAt.toISOString(),
};
};
const filterSearch = (haystack: string, search?: string) => !search || haystack.toLowerCase().includes(search.toLowerCase());
export const listKnowledgeForProject = async (projectId: string, filters: KnowledgeListFilters = {}): Promise<KnowledgeItemDto[]> => {
const project = await getProject(projectId);
const rows = await prisma.knowledgeItem.findMany({ where: { projectId: project.id, deletedAt: null }, include: { project: { select: { slug: true } } }, orderBy: { updatedAt: "desc" }});
return rows.map(toKnowledgeDto).filter((item) =>
(!filters.category?.length || filters.category.includes(item.category)) &&
(!filters.source?.length || filters.source.includes(item.source)) &&
(filters.confidence === undefined || item.confidence === filters.confidence) &&
(!filters.featureId || item.linkedFeatureIds.includes(filters.featureId)) &&
filterSearch(`${item.title} ${item.content} ${item.category}`, filters.search)
);
};
export const getKnowledgeBySlugOrId = async (projectId: string, knowledgeId: string): Promise<KnowledgeItemDto | undefined> => {
const project = await getProject(projectId);
const row = await prisma.knowledgeItem.findFirst({ where: { projectId: project.id, OR: [{ id: knowledgeId }, { slug: knowledgeId }, { readableId: knowledgeId }], deletedAt: null }, include: { project: { select: { slug: true } } }});
return row ? toKnowledgeDto(row) : undefined;
};
export const createKnowledgeItem = async (projectId: string, body: KnowledgeMutationRequestDto & { title: string; content: string }): Promise<KnowledgeItemDto> => {
const project = await getProject(projectId);
const now = new Date();
const confidence = body.confidence ?? 75;
const slug = `kn-${project.slug}-${Date.now().toString(36)}`;
const row = await prisma.knowledgeItem.create({ data: { projectId: project.id, slug, readableId: slug, title: body.title.trim(), category: body.category?.trim() || "Technical", content: body.content.trim(), source: body.source?.trim() || "manual", confidence, includeInContextPacks: false, approvalState: ApprovalState.pending, evidenceStrength: EvidenceStrength.medium, verificationState: VerificationState.unverified, metadataJson: toJson({ linkedFeatureIds: body.linkedFeatureIds ?? [], includeInContextPacks: [] }), trustJson: toJson(trust("database knowledge create", confidence, ApprovalState.pending, now)), createdBy: "api", updatedBy: "api", sourceType: "database_api" }, include: { project: { select: { slug: true } } }});
return toKnowledgeDto(row);
};
export const updateKnowledgeItem = async (projectId: string, knowledgeId: string, body: KnowledgeMutationRequestDto): Promise<KnowledgeItemDto | undefined> => {
const project = await getProject(projectId);
const current = await prisma.knowledgeItem.findFirst({ where: { projectId: project.id, OR: [{ id: knowledgeId }, { slug: knowledgeId }, { readableId: knowledgeId }], deletedAt: null }});
if (!current) return undefined;
const meta = metadata(current.metadataJson);
const confidence = body.confidence ?? current.confidence;
const row = await prisma.knowledgeItem.update({ where: { id: current.id }, data: { ...(body.title !== undefined ? { title: body.title.trim() } : {}), ...(body.category !== undefined ? { category: body.category.trim() } : {}), ...(body.content !== undefined ? { content: body.content.trim() } : {}), ...(body.source !== undefined ? { source: body.source.trim() } : {}), ...(body.confidence !== undefined ? { confidence } : {}), ...(body.linkedFeatureIds !== undefined ? { metadataJson: toJson({ ...meta, linkedFeatureIds: body.linkedFeatureIds }) } : {}), updatedBy: "api", trustJson: toJson(trust("database knowledge update", confidence, current.approvalState, new Date())) }, include: { project: { select: { slug: true } } }});
return toKnowledgeDto(row);
};
export const includeKnowledgeInContextPack = async (projectId: string, knowledgeId: string, contextPackId: string): Promise<KnowledgeItemDto | undefined> => {
const project = await getProject(projectId);
const contextPack = await prisma.contextPack.findFirst({ where: { projectId: project.id, OR: [{ id: contextPackId }, { slug: contextPackId }, { readableId: contextPackId }], deletedAt: null }});
if (!contextPack) throw new Error("CONTEXT_PACK_NOT_FOUND");
const current = await prisma.knowledgeItem.findFirst({ where: { projectId: project.id, OR: [{ id: knowledgeId }, { slug: knowledgeId }, { readableId: knowledgeId }], deletedAt: null }});
if (!current) return undefined;
const meta = metadata(current.metadataJson);
const list = new Set(toStringArray(meta.includeInContextPacks));
list.add(contextPack.slug ?? contextPack.readableId ?? contextPack.id);
const row = await prisma.knowledgeItem.update({ where: { id: current.id }, data: { includeInContextPacks: true, metadataJson: toJson({ ...meta, includeInContextPacks: [...list] }), updatedBy: "api" }, include: { project: { select: { slug: true } } }});
return toKnowledgeDto(row);
};
export const listMemoryRulesForProject = async (projectId: string, filters: MemoryRuleListFilters = {}): Promise<MemoryRuleDto[]> => {
const project = await getProject(projectId);
const rows = await prisma.memoryRule.findMany({ where: { projectId: project.id, deletedAt: null }, include: { project: { select: { slug: true } } }, orderBy: { updatedAt: "desc" }});
return rows.map(toMemoryRuleDto).filter((item) =>
(!filters.scope?.length || filters.scope.includes(item.scope)) &&
(!filters.category?.length || filters.category.includes(item.category)) &&
(!filters.severity?.length || filters.severity.includes(item.severity)) &&
(filters.includeInContextPacks === undefined || item.includeInContextPacksDefault === filters.includeInContextPacks) &&
(!filters.featureId || item.linkedFeatureIds.includes(filters.featureId)) &&
filterSearch(`${item.title} ${item.rule} ${item.category}`, filters.search)
);
};
export const getMemoryRuleBySlugOrId = async (projectId: string, ruleId: string): Promise<MemoryRuleDto | undefined> => {
const project = await getProject(projectId);
const row = await prisma.memoryRule.findFirst({ where: { projectId: project.id, OR: [{ id: ruleId }, { slug: ruleId }, { readableId: ruleId }], deletedAt: null }, include: { project: { select: { slug: true } } }});
return row ? toMemoryRuleDto(row) : undefined;
};
export const createMemoryRuleItem = async (projectId: string, body: MemoryRuleMutationRequestDto & { rule: string }): Promise<MemoryRuleDto> => {
const project = await getProject(projectId);
const slug = `R-${project.slug.toUpperCase()}-${Date.now().toString(36)}`;
const confidence = 78;
const now = new Date();
const row = await prisma.memoryRule.create({ data: { projectId: project.id, slug, readableId: slug, title: body.title?.trim() || body.rule.trim().slice(0, 60), rule: body.rule.trim(), scope: body.scope ?? "global", category: body.category ?? "Technical", severity: body.severity ?? "important", includeInContextPacks: body.includeInContextPacks ?? false, confidence, approvalState: ApprovalState.pending, evidenceStrength: EvidenceStrength.medium, verificationState: VerificationState.unverified, provenanceJson: toJson({ linkedFeatureIds: body.linkedFeatureIds ?? [], includeInContextPacks: [] }), trustJson: toJson(trust("database memory rule create", confidence, ApprovalState.pending, now)), createdBy: "api", updatedBy: "api", sourceType: "database_api" }, include: { project: { select: { slug: true } } }});
return toMemoryRuleDto(row);
};
export const updateMemoryRuleItem = async (projectId: string, ruleId: string, body: MemoryRuleMutationRequestDto): Promise<MemoryRuleDto | undefined> => {
const project = await getProject(projectId);
const current = await prisma.memoryRule.findFirst({ where: { projectId: project.id, OR: [{ id: ruleId }, { slug: ruleId }, { readableId: ruleId }], deletedAt: null }});
if (!current) return undefined;
const meta = metadata(current.provenanceJson);
const row = await prisma.memoryRule.update({ where: { id: current.id }, data: { ...(body.title !== undefined ? { title: body.title.trim() } : {}), ...(body.rule !== undefined ? { rule: body.rule.trim() } : {}), ...(body.scope !== undefined ? { scope: body.scope } : {}), ...(body.category !== undefined ? { category: body.category } : {}), ...(body.severity !== undefined ? { severity: body.severity } : {}), ...(body.includeInContextPacks !== undefined ? { includeInContextPacks: body.includeInContextPacks } : {}), ...(body.linkedFeatureIds !== undefined ? { provenanceJson: toJson({ ...meta, linkedFeatureIds: body.linkedFeatureIds }) } : {}), updatedBy: "api", trustJson: toJson(trust("database memory rule update", current.confidence, current.approvalState, new Date())) }, include: { project: { select: { slug: true } } }});
return toMemoryRuleDto(row);
};
export const includeMemoryRuleInContextPack = async (projectId: string, ruleId: string, contextPackId: string): Promise<MemoryRuleDto | undefined> => {
const project = await getProject(projectId);
const contextPack = await prisma.contextPack.findFirst({ where: { projectId: project.id, OR: [{ id: contextPackId }, { slug: contextPackId }, { readableId: contextPackId }], deletedAt: null }});
if (!contextPack) throw new Error("CONTEXT_PACK_NOT_FOUND");
const current = await prisma.memoryRule.findFirst({ where: { projectId: project.id, OR: [{ id: ruleId }, { slug: ruleId }, { readableId: ruleId }], deletedAt: null }});
if (!current) return undefined;
const meta = metadata(current.provenanceJson);
const list = new Set(toStringArray(meta.includeInContextPacks));
list.add(contextPack.slug ?? contextPack.readableId ?? contextPack.id);
const row = await prisma.memoryRule.update({ where: { id: current.id }, data: { includeInContextPacks: true, provenanceJson: toJson({ ...meta, includeInContextPacks: [...list] }), updatedBy: "api" }, include: { project: { select: { slug: true } } }});
return toMemoryRuleDto(row);
};
export const listDocumentationForProject = async (projectId: string, filters: DocumentationListFilters = {}): Promise<DocumentationItemDto[]> => {
const project = await getProject(projectId);
const rows = await prisma.documentationItem.findMany({ where: { projectId: project.id, deletedAt: null }, include: { project: { select: { slug: true } } }, orderBy: { updatedAt: "desc" }});
return rows.map(toDocumentationDto).filter((item) =>
(!filters.kind?.length || filters.kind.includes(item.kind)) &&
(!filters.status?.length || filters.status.includes(item.status)) &&
(!filters.approvalState?.length || filters.approvalState.includes(item.approvalState)) &&
filterSearch(`${item.title} ${item.kind} ${item.content ?? ""}`, filters.search)
);
};
export const getDocumentationBySlugOrId = async (projectId: string, docId: string): Promise<DocumentationItemDto | undefined> => {
const project = await getProject(projectId);
const row = await prisma.documentationItem.findFirst({ where: { projectId: project.id, OR: [{ id: docId }, { slug: docId }, { readableId: docId }], deletedAt: null }, include: { project: { select: { slug: true } } }});
return row ? toDocumentationDto(row) : undefined;
};
export const createDocumentationItem = async (projectId: string, body: { title: string; kind?: string; status?: string; linkedFeatureIds?: string[]; content?: string; approvalState?: ApprovalStateInput; primaryContextPackId?: string; selectedContextPackIds?: string[]; contextSelectionSummary?: string }): Promise<DocumentationItemDto> => {
const project = await getProject(projectId);
const slug = `doc-${project.slug}-${Date.now().toString(36)}`;
const now = new Date();
const row = await prisma.documentationItem.create({ data: { projectId: project.id, slug, readableId: slug, title: body.title.trim(), kind: body.kind?.trim() || "current state report", status: body.status?.trim() || "draft", approvalState: body.approvalState ?? ApprovalState.pending, content: body.content?.trim(), metadataJson: toJson({ linkedFeatureIds: body.linkedFeatureIds ?? [], lastUpdated: now.toISOString(), primaryContextPackId: body.primaryContextPackId, selectedContextPackIds: body.selectedContextPackIds ?? [], contextSelectionSummary: body.contextSelectionSummary }), trustJson: toJson(trust("database documentation create", 75, body.approvalState ?? ApprovalState.pending, now)), provenanceJson: toJson({ source: "database_api" }), createdBy: "api", updatedBy: "api", sourceType: "database_api" }, include: { project: { select: { slug: true } } }});
return toDocumentationDto(row);
};
export const updateDocumentationItem = async (projectId: string, docId: string, body: { title?: string; kind?: string; status?: string; linkedFeatureIds?: string[]; content?: string; approvalState?: ApprovalStateInput; primaryContextPackId?: string; selectedContextPackIds?: string[]; contextSelectionSummary?: string }): Promise<DocumentationItemDto | undefined> => {
const project = await getProject(projectId);
const current = await prisma.documentationItem.findFirst({ where: { projectId: project.id, OR: [{ id: docId }, { slug: docId }, { readableId: docId }], deletedAt: null }});
if (!current) return undefined;
const meta = metadata(current.metadataJson);
const row = await prisma.documentationItem.update({ where: { id: current.id }, data: { ...(body.title !== undefined ? { title: body.title.trim() } : {}), ...(body.kind !== undefined ? { kind: body.kind.trim() } : {}), ...(body.status !== undefined ? { status: body.status.trim() } : {}), ...(body.approvalState !== undefined ? { approvalState: body.approvalState } : {}), ...(body.content !== undefined ? { content: body.content.trim() } : {}), metadataJson: toJson({
...meta,
...(body.linkedFeatureIds !== undefined ? { linkedFeatureIds: body.linkedFeatureIds } : {}),
...(body.primaryContextPackId !== undefined ? { primaryContextPackId: body.primaryContextPackId } : {}),
...(body.selectedContextPackIds !== undefined ? { selectedContextPackIds: body.selectedContextPackIds } : {}),
...(body.contextSelectionSummary !== undefined ? { contextSelectionSummary: body.contextSelectionSummary } : {}),
lastUpdated: new Date().toISOString(),
}), updatedBy: "api" }, include: { project: { select: { slug: true } } }});
return toDocumentationDto(row);
};
@@ -0,0 +1,50 @@
const persistedSetupKeys = [
"activeContextPackId",
"featureGroups",
"memoryRules",
"promptTemplates",
"constraints",
"principles",
"firstRun",
"starterContextPacks",
"starterDocumentationItems",
"starterReviewItems",
"starterOperatingLog",
"brainstorms",
"codexSkills",
"disabledCodexSkillIds",
"workspaceState",
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const sanitizeProjectSetupState = (value: unknown): Record<string, unknown> => {
if (!isRecord(value)) return {};
return Object.fromEntries(
persistedSetupKeys.flatMap((key) =>
Object.prototype.hasOwnProperty.call(value, key) ? [[key, value[key]]] : [],
),
);
};
export const projectSetupWorkspaceState = (value: unknown): Record<string, unknown> | undefined => {
const setup = sanitizeProjectSetupState(value);
return isRecord(setup.workspaceState) ? setup.workspaceState : undefined;
};
export const projectSetupActiveContextPackId = (value: unknown): string | undefined => {
const setup = sanitizeProjectSetupState(value);
return typeof setup.activeContextPackId === "string" && setup.activeContextPackId.trim()
? setup.activeContextPackId
: undefined;
};
export const mergeProjectSetupState = <T extends Record<string, unknown>>(
setupState: unknown,
canonical: T,
): T => ({
...sanitizeProjectSetupState(setupState),
...canonical,
}) as T;
@@ -0,0 +1,514 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import type {
CurrentStateSummaryDto,
FocusAreaDto,
HealthSummaryDto,
ProjectDetailDto,
ProjectDna,
ProjectStatus,
ProjectSummaryDto,
CreateProjectRequestDto,
UpdateActiveContextPackRequestDto,
UpdateProjectRequestDto,
WorkspaceReadDto,
WorkspaceStateDto,
} from "../types/api.js";
import {
mergeProjectSetupState,
projectSetupActiveContextPackId,
projectSetupWorkspaceState,
sanitizeProjectSetupState,
} from "./projectSetupState.js";
import { humanAttentionReviewWhere } from "./reviewAttentionPolicy.js";
import { projectHealth } from "../services/projectHealthSemantics.js";
const projectRelationsInclude = {
dna: true,
workspaceState: true,
giteaRepositories: { select: { id: true, syncStatus: true, lastSyncAt: true } },
sourceItems: { orderBy: { updatedAt: "desc" as const } },
repositoryFindings: { where: { status: { notIn: ["resolved", "suppressed"] } }, select: { severity: true, status: true, lastSeenAt: true } },
_count: {
select: {
features: { where: { deletedAt: null } },
contextPacks: { where: { deletedAt: null } },
deliveryPlans: true,
documentationItems: { where: { deletedAt: null } },
knowledgeItems: { where: { deletedAt: null } },
reviewQueueItems: { where: humanAttentionReviewWhere },
initiatives: { where: { deletedAt: null, reviewItemId: null, approvalState: { in: ["pending", "blocked"] } } },
},
},
} as const satisfies Prisma.ProjectInclude;
type ProjectWithP2Relations = Prisma.ProjectGetPayload<{ include: typeof projectRelationsInclude }>;
type WorkspaceJsonPayload = {
workspaceState?: WorkspaceStateDto;
focusAreas?: FocusAreaDto[];
activeFocusAreaId?: string;
activeContextPackId?: string;
currentStateSummary?: CurrentStateSummaryDto;
healthSummary?: HealthSummaryDto;
setupState?: Record<string, unknown>;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const slugify = (value: string): string => {
const slug = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "project";
};
const toStringArray = (value: Prisma.JsonValue): string[] =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
const isDisposableProject = (project: Pick<ProjectWithP2Relations, "name" | "description">) =>
/\b(smoke|disposable|test project|proof project)\b/i.test(`${project.name} ${project.description}`);
const toDna = (
dna: NonNullable<ProjectWithP2Relations["dna"]>,
): ProjectDna => ({
vision: dna.vision,
goals: toStringArray(dna.goalsJson),
nonGoals: toStringArray(dna.nonGoalsJson),
targetAudience: dna.targetAudience,
principles: toStringArray(dna.principlesJson),
successMetrics: Array.isArray(dna.metricsJson)
? (dna.metricsJson as ProjectDna["successMetrics"])
: [],
maturityStage: dna.maturityStage as ProjectDna["maturityStage"],
revision: dna.revision,
updatedAt: dna.updatedAt.toISOString(),
});
const projectLastSync = (project: ProjectWithP2Relations) => {
const timestamps = [
project.updatedAt.getTime(),
...project.giteaRepositories.flatMap((repository) => repository.lastSyncAt ? [repository.lastSyncAt.getTime()] : []),
...project.repositoryFindings.map((finding) => finding.lastSeenAt.getTime()),
];
return new Date(Math.max(...timestamps)).toISOString();
};
const toProjectSummary = (
project: ProjectWithP2Relations,
): ProjectSummaryDto => {
const health = projectHealth(project);
const workspacePayload = project.workspaceState ? toWorkspacePayload(project.workspaceState.notesJson) : {};
const configuredSkills = Array.isArray(workspacePayload.setupState?.codexSkills)
? workspacePayload.setupState.codexSkills
: [];
return {
id: project.slug,
name: project.name,
description: project.description,
health: health.score,
healthStatus: health.healthStatus,
lastSync: projectLastSync(project),
domain: project.domain,
status: project.status as ProjectStatus,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(),
activeContextPackId: project.activeContextPackId ?? undefined,
resourceCounts: {
repositories: project.giteaRepositories.length,
readyRepositories: project.giteaRepositories.filter((repository) => repository.syncStatus === "ready").length,
sources: project.sourceItems.filter((source) => source.status !== "archived").length,
documentation: project._count.documentationItems,
pendingReviews: project._count.reviewQueueItems,
attentionItems: project._count.reviewQueueItems + project._count.initiatives,
activeFindings: health.activeFindingCount,
criticalFindings: health.criticalCount,
warningFindings: health.warningCount,
regressedFindings: health.regressedCount,
contextPacks: project._count.contextPacks,
features: project._count.features,
deliveryPlans: project._count.deliveryPlans,
knowledge: project._count.knowledgeItems,
enabledSkills: configuredSkills.filter((skill) => isRecord(skill) && skill.enabled === true).length,
},
};
};
const toWorkspacePayload = (
value: Prisma.JsonValue,
): WorkspaceJsonPayload => {
if (!isRecord(value)) return {};
return value as WorkspaceJsonPayload;
};
const toWorkspaceState = (
project: ProjectWithP2Relations,
): WorkspaceStateDto | undefined => {
if (!project.workspaceState) return undefined;
const payload = toWorkspacePayload(project.workspaceState.notesJson);
if (payload.workspaceState) return payload.workspaceState;
return {
projectId: project.slug,
activeContextPackId:
project.workspaceState.activeContextPackId ?? undefined,
primaryObjective: project.workspaceState.primaryObjective,
focusAreas: [],
openTasksCount: 0,
openBugCount: 0,
activeSection: project.workspaceState
.activeSection as WorkspaceStateDto["activeSection"],
updatedAt: project.workspaceState.updatedAt.toISOString(),
notes: [],
};
};
const toProjectDetail = (
project: ProjectWithP2Relations,
): ProjectDetailDto | undefined => {
if (!project.dna) return undefined;
const workspacePayload = project.workspaceState
? toWorkspacePayload(project.workspaceState.notesJson)
: {};
return mergeProjectSetupState(workspacePayload.setupState, {
...toProjectSummary(project),
dna: toDna(project.dna),
constraints: Array.isArray(workspacePayload.setupState?.constraints)
? workspacePayload.setupState.constraints as ProjectDetailDto["constraints"]
: [],
principles: Array.isArray(workspacePayload.setupState?.principles)
? workspacePayload.setupState.principles as ProjectDetailDto["principles"]
: [],
initiatives: [],
featureGroups: Array.isArray(workspacePayload.setupState?.featureGroups)
? workspacePayload.setupState.featureGroups as ProjectDetailDto["featureGroups"]
: [],
memoryRules: Array.isArray(workspacePayload.setupState?.memoryRules)
? workspacePayload.setupState.memoryRules as ProjectDetailDto["memoryRules"]
: [],
promptTemplates: Array.isArray(workspacePayload.setupState?.promptTemplates)
? workspacePayload.setupState.promptTemplates as ProjectDetailDto["promptTemplates"]
: [],
sourceLibrary: project.sourceItems.map((source) => ({
id: source.id,
projectId: project.slug,
title: source.title,
kind: source.kind,
inputMode: source.inputMode,
originalName: source.path ?? undefined,
url: source.url ?? undefined,
text: source.content,
summary: source.summary,
status: source.status,
suggestedDocumentationTargets: [],
suggestedContextPackIds: [],
extractedAssetIds: [],
createdAt: source.createdAt.toISOString(),
updatedAt: source.updatedAt.toISOString(),
repositoryPath: source.path ?? undefined,
repositoryBranch: source.branch ?? undefined,
commitSha: source.commitSha ?? undefined,
lastSyncedAt: source.lastSyncedAt?.toISOString(),
attribution: { createdBy: "imported", sourceRun: source.commitSha ?? undefined, approvalState: "approved" },
})),
workspaceState: toWorkspaceState(project),
} as ProjectDetailDto & Record<string, unknown>);
};
const defaultDnaForProject = (
input: CreateProjectRequestDto,
): Omit<ProjectDna, "updatedAt"> => ({
vision: input.dna?.vision || input.primaryGoal || input.description,
goals: input.dna?.goals ?? (input.primaryGoal ? [input.primaryGoal] : []),
nonGoals: input.dna?.nonGoals ?? [],
targetAudience: input.dna?.targetAudience || input.targetAudience || "Delivery teams",
principles: input.dna?.principles ?? [],
successMetrics: input.dna?.successMetrics ?? [],
maturityStage: input.dna?.maturityStage ?? "Discovery",
revision: input.dna?.revision ?? 1,
});
const defaultWorkspacePayload = (
projectSlug: string,
input: CreateProjectRequestDto,
): WorkspaceJsonPayload => {
const setupWorkspace = projectSetupWorkspaceState(input.setupState);
const workspaceState = {
...setupWorkspace,
projectId: projectSlug,
primaryObjective: typeof setupWorkspace?.primaryObjective === "string"
? setupWorkspace.primaryObjective
: input.primaryGoal || input.description,
focusAreas: Array.isArray(setupWorkspace?.focusAreas) ? setupWorkspace.focusAreas : [],
openTasksCount: typeof setupWorkspace?.openTasksCount === "number" ? setupWorkspace.openTasksCount : 0,
openBugCount: typeof setupWorkspace?.openBugCount === "number" ? setupWorkspace.openBugCount : 0,
activeSection: typeof setupWorkspace?.activeSection === "string" ? setupWorkspace.activeSection : "workspace",
updatedAt: new Date().toISOString(),
notes: Array.isArray(setupWorkspace?.notes) ? setupWorkspace.notes : [],
...(projectSetupActiveContextPackId(input.setupState)
? { activeContextPackId: projectSetupActiveContextPackId(input.setupState) }
: {}),
} as WorkspaceStateDto;
return {
workspaceState,
focusAreas: workspaceState.focusAreas,
healthSummary: {
health: 100,
healthStatus: "healthy",
openTasksCount: workspaceState.openTasksCount,
openBugCount: workspaceState.openBugCount,
riskCount: 0,
pendingReviewCount: 0,
},
setupState: sanitizeProjectSetupState(input.setupState),
};
};
const findProjectRecordBySlugOrId = (projectIdOrSlug: string) =>
prisma.project.findFirst({
where: {
deletedAt: null,
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
},
include: projectRelationsInclude,
});
export const listProjects = async (): Promise<ProjectSummaryDto[]> => {
const projects = await prisma.project.findMany({
where: {
deletedAt: null,
},
orderBy: {
updatedAt: "desc",
},
include: projectRelationsInclude,
});
return projects.filter((project) => !isDisposableProject(project)).map(toProjectSummary);
};
export const createProject = async (
input: CreateProjectRequestDto,
): Promise<ProjectDetailDto> => {
const slug = slugify(input.id || input.name);
const existing = await prisma.project.findFirst({
where: {
slug,
deletedAt: null,
},
});
if (existing) {
throw new Error("PROJECT_SLUG_ALREADY_EXISTS");
}
const dna = defaultDnaForProject(input);
const workspacePayload = defaultWorkspacePayload(slug, input);
const project = await prisma.project.create({
data: {
slug,
readableId: slug,
name: input.name.trim(),
description: input.description.trim(),
domain: input.domain?.trim() || "general",
status: "active",
health: 100,
healthStatus: "healthy",
activeContextPackId: projectSetupActiveContextPackId(input.setupState),
createdBy: "frontend",
updatedBy: "frontend",
sourceType: "project_setup",
dna: {
create: {
vision: dna.vision,
goalsJson: dna.goals,
nonGoalsJson: dna.nonGoals,
targetAudience: dna.targetAudience,
principlesJson: dna.principles,
metricsJson: dna.successMetrics,
maturityStage: dna.maturityStage,
revision: dna.revision,
createdBy: "frontend",
updatedBy: "frontend",
sourceType: "project_setup",
},
},
workspaceState: {
create: {
primaryObjective: workspacePayload.workspaceState?.primaryObjective ?? input.primaryGoal ?? input.description.trim(),
activeContextPackId: projectSetupActiveContextPackId(input.setupState),
activeSection: workspacePayload.workspaceState?.activeSection ?? "workspace",
notesJson: workspacePayload as Prisma.InputJsonValue,
createdBy: "frontend",
updatedBy: "frontend",
sourceType: "project_setup",
},
},
},
include: projectRelationsInclude,
});
const detail = toProjectDetail(project);
if (!detail) {
throw new Error("PROJECT_CREATE_FAILED");
}
return detail;
};
export const updateProject = async (
projectIdOrSlug: string,
input: UpdateProjectRequestDto,
): Promise<ProjectDetailDto | undefined> => {
const existing = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!existing) return undefined;
const project = await prisma.project.update({
where: {
id: existing.id,
},
data: {
...(input.name !== undefined ? { name: input.name.trim() } : {}),
...(input.description !== undefined ? { description: input.description.trim() } : {}),
...(input.domain !== undefined ? { domain: input.domain.trim() || "general" } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
updatedBy: "frontend",
},
include: projectRelationsInclude,
});
return toProjectDetail(project);
};
export const updateActiveContextPack = async (
projectIdOrSlug: string,
input: UpdateActiveContextPackRequestDto,
): Promise<WorkspaceReadDto | undefined> => {
const existing = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!existing?.dna) return undefined;
const currentPayload = existing.workspaceState
? toWorkspacePayload(existing.workspaceState.notesJson)
: {};
const currentWorkspace = toWorkspaceState(existing);
const updatedAt = new Date().toISOString();
const nextWorkspaceState: WorkspaceStateDto = {
projectId: existing.slug,
primaryObjective:
currentWorkspace?.primaryObjective ??
existing.workspaceState?.primaryObjective ??
existing.description,
focusAreas: currentWorkspace?.focusAreas ?? currentPayload.focusAreas ?? [],
openTasksCount: currentWorkspace?.openTasksCount ?? 0,
openBugCount: currentWorkspace?.openBugCount ?? 0,
activeSection: currentWorkspace?.activeSection ?? "context-packs",
updatedAt,
notes: currentWorkspace?.notes ?? [],
activeContextPackId: input.contextPackId,
};
const payload: WorkspaceJsonPayload = {
...currentPayload,
workspaceState: nextWorkspaceState,
focusAreas: currentPayload.focusAreas ?? nextWorkspaceState.focusAreas,
activeContextPackId: input.contextPackId,
};
await prisma.project.update({
where: {
id: existing.id,
},
data: {
activeContextPackId: input.contextPackId,
updatedBy: "frontend",
workspaceState: {
upsert: {
create: {
activeContextPackId: input.contextPackId,
primaryObjective: nextWorkspaceState.primaryObjective,
activeSection: nextWorkspaceState.activeSection,
notesJson: payload as Prisma.InputJsonValue,
createdBy: "frontend",
updatedBy: "frontend",
sourceType: "workspace_update",
},
update: {
activeContextPackId: input.contextPackId,
primaryObjective: nextWorkspaceState.primaryObjective,
activeSection: nextWorkspaceState.activeSection,
notesJson: payload as Prisma.InputJsonValue,
updatedBy: "frontend",
sourceType: "workspace_update",
},
},
},
},
});
return getProjectWorkspace(existing.slug);
};
export const getProjectBySlugOrId = async (
projectIdOrSlug: string,
): Promise<ProjectDetailDto | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project) return undefined;
return toProjectDetail(project);
};
export const getProjectDNA = async (
projectIdOrSlug: string,
): Promise<ProjectDna | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project?.dna) return undefined;
return toDna(project.dna);
};
export const getProjectWorkspace = async (
projectIdOrSlug: string,
): Promise<WorkspaceReadDto | undefined> => {
const project = await findProjectRecordBySlugOrId(projectIdOrSlug);
if (!project?.dna) return undefined;
const workspaceState = toWorkspaceState(project);
const payload = project.workspaceState
? toWorkspacePayload(project.workspaceState.notesJson)
: {};
const activeContextPackId =
workspaceState?.activeContextPackId ??
project.activeContextPackId ??
undefined;
const operationalHealth = projectHealth(project);
return {
project: toProjectSummary(project),
dna: toDna(project.dna),
activeFocusAreaId: payload.activeFocusAreaId,
activeContextPackId,
focusAreas: payload.focusAreas ?? workspaceState?.focusAreas ?? [],
workspaceState,
currentStateSummary: payload.currentStateSummary,
healthSummary: {
...(payload.healthSummary ?? {}),
health: operationalHealth.score,
healthStatus: operationalHealth.healthStatus,
openTasksCount: workspaceState?.openTasksCount ?? 0,
openBugCount: workspaceState?.openBugCount ?? 0,
riskCount: payload.healthSummary?.riskCount ?? 0,
pendingReviewCount: payload.healthSummary?.pendingReviewCount ?? 0,
},
};
};
@@ -0,0 +1,211 @@
import {
ApprovalState,
EntityType,
EvidenceStrength,
VerificationState,
type Prisma,
} from "@prisma/client";
import { prisma } from "../db/prisma.js";
import type {
ApprovalState as ApprovalStateDto,
CreateReleaseEvidenceRecordRequestDto,
EvidenceStrength as EvidenceStrengthDto,
ReleaseEvidenceKind,
ReleaseEvidenceListFilters,
ReleaseEvidenceRecordDto,
ReleaseEvidenceStatus,
VerificationState as VerificationStateDto,
} from "../types/api.js";
type ProjectRecord = { id: string; slug: string };
type ReleaseEvidenceRecord = Prisma.ReleaseEvidenceRecordGetPayload<Record<string, never>>;
const findProjectRecord = (projectIdOrSlug: string): Promise<ProjectRecord | null> =>
prisma.project.findFirst({
where: {
deletedAt: null,
OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }],
},
select: { id: true, slug: true },
});
const toEvidenceStrengthEnum = (
value: EvidenceStrengthDto | string | null | undefined,
): EvidenceStrength => {
if (value === "verified") return EvidenceStrength.verified;
if (value === "strong") return EvidenceStrength.strong;
if (value === "medium") return EvidenceStrength.medium;
return EvidenceStrength.weak;
};
const toVerificationStateEnum = (
value: VerificationStateDto | string | null | undefined,
): VerificationState => {
if (value === "blocked") return VerificationState.blocked;
if (value === "needs evidence" || value === "needs_evidence") return VerificationState.needs_evidence;
if (value === "unverified") return VerificationState.unverified;
return VerificationState.verified;
};
const toVerificationStateDto = (value: VerificationState): VerificationStateDto =>
value === VerificationState.needs_evidence ? "needs evidence" : value;
const toDto = (record: ReleaseEvidenceRecord, projectSlug: string): ReleaseEvidenceRecordDto => ({
id: record.slug ?? record.readableId ?? record.id,
projectId: projectSlug,
capturedAt: record.capturedAt.toISOString(),
kind: record.kind as ReleaseEvidenceKind,
title: record.title,
summary: record.summary,
status: record.status as ReleaseEvidenceStatus,
...(record.command ? { command: record.command } : {}),
...(record.outputText ? { outputText: record.outputText } : {}),
...(record.route ? { route: record.route } : {}),
source: record.source,
confidence: record.confidence,
approvalState: record.approvalState as ApprovalStateDto,
evidenceStrength: record.evidenceStrength,
verificationState: toVerificationStateDto(record.verificationState),
createdBy: record.createdBy,
...(record.verifiedAt ? { verifiedAt: record.verifiedAt.toISOString() } : {}),
...(record.verifiedBy ? { verifiedBy: record.verifiedBy } : {}),
...(record.metadataJson && typeof record.metadataJson === "object" && !Array.isArray(record.metadataJson)
? { metadata: record.metadataJson as Record<string, unknown> }
: {}),
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
});
const paginateMeta = <T>(
items: T[],
total: number,
page: number,
pageSize: number,
sort?: string,
filters?: Record<string, unknown>,
) => ({
items,
total,
page,
pageSize,
hasNextPage: page < Math.ceil(total / pageSize),
hasPreviousPage: page > 1,
...(sort ? { sort } : {}),
...(filters ? { filters: filters as Record<string, string | number | boolean | string[]> } : {}),
});
export const listReleaseEvidenceRecordsFromDatabase = async (
projectIdOrSlug: string,
filters: ReleaseEvidenceListFilters,
options: { page: number; pageSize: number; sort?: string },
) => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const where: Prisma.ReleaseEvidenceRecordWhereInput = {
projectId: project.id,
deletedAt: null,
...(filters.kind?.length ? { kind: { in: filters.kind } } : {}),
...(filters.status?.length ? { status: { in: filters.status } } : {}),
...(filters.evidenceStrength?.length
? { evidenceStrength: { in: filters.evidenceStrength.map(toEvidenceStrengthEnum) } }
: {}),
...(filters.verificationState?.length
? { verificationState: { in: filters.verificationState.map(toVerificationStateEnum) } }
: {}),
...(filters.source?.length ? { source: { in: filters.source } } : {}),
...(filters.createdBy?.length ? { createdBy: { in: filters.createdBy } } : {}),
};
const total = await prisma.releaseEvidenceRecord.count({ where });
const records = await prisma.releaseEvidenceRecord.findMany({
where,
orderBy: { capturedAt: options.sort?.endsWith(":asc") ? "asc" : "desc" },
skip: (options.page - 1) * options.pageSize,
take: options.pageSize,
});
return paginateMeta(
records.map((record) => toDto(record, project.slug)),
total,
options.page,
options.pageSize,
options.sort,
filters,
);
};
export const getReleaseEvidenceRecordFromDatabase = async (
projectIdOrSlug: string,
releaseEvidenceId: string,
) => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const record = await prisma.releaseEvidenceRecord.findFirst({
where: {
projectId: project.id,
deletedAt: null,
OR: [
{ id: releaseEvidenceId },
{ slug: releaseEvidenceId },
{ readableId: releaseEvidenceId },
],
},
});
if (!record) return "RELEASE_EVIDENCE_NOT_FOUND" as const;
return toDto(record, project.slug);
};
export const createReleaseEvidenceRecordInDatabase = async (
projectIdOrSlug: string,
body: CreateReleaseEvidenceRecordRequestDto,
): Promise<ReleaseEvidenceRecordDto | undefined> => {
const project = await findProjectRecord(projectIdOrSlug);
if (!project) return undefined;
const capturedAt = new Date();
const slug = `rel-${project.slug}-${capturedAt.getTime()}-${Math.random().toString(36).slice(2, 8)}`;
const verificationState = toVerificationStateEnum(body.verificationState ?? "verified");
const evidenceStrength = toEvidenceStrengthEnum(body.evidenceStrength ?? "verified");
const verifiedAt = verificationState === VerificationState.verified ? capturedAt : undefined;
const record = await prisma.releaseEvidenceRecord.create({
data: {
projectId: project.id,
slug,
readableId: slug,
capturedAt,
kind: body.kind,
title: body.title,
summary: body.summary,
status: body.status ?? "captured",
command: body.command,
outputText: body.outputText,
route: body.route,
source: body.source ?? "runtime_readiness",
confidence: body.confidence ?? 100,
approvalState: ApprovalState.approved,
evidenceStrength,
verificationState,
metadataJson: (body.metadata ?? {}) as Prisma.InputJsonValue,
provenanceJson: {
createdFrom: "release-evidence-api",
sourceType: "runtime_readiness",
},
createdBy: body.createdBy ?? "api",
verifiedAt,
verifiedBy: body.verifiedBy ?? (verifiedAt ? "api" : undefined),
},
});
await prisma.activityEvent.create({
data: {
projectId: project.id,
entityType: EntityType.release_evidence_record,
entityId: record.id,
eventType: "release_evidence_captured",
actor: body.createdBy ?? "api",
payloadJson: {
kind: record.kind,
title: record.title,
status: record.status,
route: record.route,
},
},
});
return toDto(record, project.slug);
};
@@ -0,0 +1,22 @@
import { ReviewStatus, type Prisma, type ReviewQueueItem } from "@prisma/client";
type ReviewAttentionRecord = Pick<ReviewQueueItem, "status" | "source" | "sourceType" | "createdBy">;
const isSyntheticSource = (value: string | null | undefined) => value?.toLowerCase().includes("smoke") ?? false;
/** Pending test and seed artefacts are evidence, not owner decisions. */
export const reviewRequiresHumanAttention = (record: ReviewAttentionRecord) =>
record.status === ReviewStatus.pending &&
record.createdBy !== "seed" &&
!isSyntheticSource(record.source) &&
!isSyntheticSource(record.sourceType);
export const humanAttentionReviewWhere = {
deletedAt: null,
status: ReviewStatus.pending,
NOT: [
{ createdBy: "seed" },
{ source: { contains: "smoke", mode: "insensitive" } },
{ sourceType: { contains: "smoke", mode: "insensitive" } },
],
} satisfies Prisma.ReviewQueueItemWhereInput;
Loaded 100 of 700 files, more files were not shown because too many files have changed in this diff. Show more