Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5858d4c
feat(design-system): consolidate tokens into design-system/tokens.css
ovdmar May 26, 2026
39791ec
chore(format): biome auto-format daemon drift
ovdmar May 26, 2026
4c07a15
feat(design-system): extend Button + Badge variants
ovdmar May 26, 2026
ad81746
feat(design-system): add Card, Panel, EmptyState, Skeleton primitives
ovdmar May 26, 2026
f1dc868
feat(design-system): add form-field primitives
ovdmar May 26, 2026
787c8dc
feat(design-system): add Tabs primitive (Radix)
ovdmar May 26, 2026
6b408ba
feat(design-system): add Dialog primitive (Radix)
ovdmar May 26, 2026
4072f43
feat(design-system): add Tooltip primitive (Radix) with cockpit-tuned…
ovdmar May 26, 2026
cf332ee
feat(design-system): add Chip, IconButton, Toast primitives
ovdmar May 26, 2026
ca67708
feat(design-system): /design-system showcase route (dev-only)
ovdmar May 26, 2026
77e07f0
refactor(modals): migrate modals.tsx to Dialog primitive
ovdmar May 26, 2026
e4c58ae
refactor(inspector): extract inspector-tabs and migrate to Tabs primi…
ovdmar May 26, 2026
332e375
refactor(workspace-card): migrate namespace pill to Chip primitive
ovdmar May 26, 2026
97b4929
refactor(scheduled-agent-form): migrate Name field to FormField + Input
ovdmar May 26, 2026
320cd34
docs(spec): annotate B.8 #4 as partial — first-wave primitives shipped
ovdmar May 26, 2026
dd73df5
fix(design-system): self-review fixes
ovdmar May 26, 2026
422ddef
Merge remote-tracking branch 'origin/main' into agent/design-system-1…
ovdmar May 26, 2026
32fd1ec
fix(e2e): filter fonts.gstatic.com CORS noise from design-system spec
ovdmar May 26, 2026
0b542a8
Merge remote-tracking branch 'origin/main' into agent/design-system-1…
ovdmar May 28, 2026
36523f4
docs(plan): add design-system implementation plan
ovdmar May 28, 2026
884c4b7
fix(check): restore green gate after main merge
ovdmar Jun 6, 2026
248665a
Merge remote-tracking branch 'origin/main' into agent/design-system-1…
ovdmar Jun 9, 2026
4b5b345
Merge remote-tracking branch 'origin/main' into agent/design-system-1…
ovdmar Jun 9, 2026
d50f49f
docs(repo): polish public project overview
ovdmar Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
421 changes: 421 additions & 0 deletions .agents/plans/design-system.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Contributing

Citadel is a local-first TypeScript monorepo. Contributions should preserve the package boundaries, local-state safety, and operator-focused UI model described in the docs.

## Start Here

1. Read [README.md](README.md) for the product and architecture overview.
2. Read [docs/README.md](docs/README.md) for the docs map.
3. Read [docs/contributors/v2-engineering-standards.md](docs/contributors/v2-engineering-standards.md) before changing code.

## Development Loop

```bash
make setup
make deploy
```

`make deploy` starts a worktree-scoped daemon + Vite stack and prints the cockpit URL. It keeps local development separate from the long-term systemd install.

## Required Checks

Run the focused tests for your change, then run:

```bash
make check
```

For UI or workflow changes, also run the relevant Playwright spec:

```bash
pnpm e2e:isolated --project=desktop e2e/<spec>.spec.ts
```

Use the isolated wrappers when possible. They allocate temporary state and avoid writing into the operator's real Citadel database/config.

## Architecture Rules

- `apps/web` imports shared contracts, not daemon internals.
- `packages/core` stays pure and does not import implementation packages.
- cleanup paths must not delete dirty worktrees without explicit force behavior.
- provider-backed features must degrade clearly when provider health is unavailable.
- dependency changes require lockfile review and must pass dependency policy.
- UI changes should stay dense, accessible, keyboard-friendly, and consistent with the local design-system tokens/components.

## Documentation

Update docs when changing product behavior, install/runtime behavior, package boundaries, config shape, or operator-visible workflows.

Use product-facing docs for product explanations. Keep agent/process notes in the process-artifact areas documented in [docs/README.md](docs/README.md).
186 changes: 159 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,186 @@
# Citadel

Citadel v2 is a local-first Linux operator cockpit for repositories, workspaces, tmux-backed agent runtimes, providers, hooks, MCP, and operational activity.
[![CI](https://github.com/ovdmar/citadel/actions/workflows/ci.yml/badge.svg)](https://github.com/ovdmar/citadel/actions/workflows/ci.yml)

## Quickstart (dev)
Citadel is a local-first cockpit for managing AI coding agents across git workspaces, durable terminals, pull requests, checks, issue trackers, hooks, and local preview surfaces.

It is built for the workflow where several long-running agents are working in different local worktrees and the operator needs one place to answer: what is running, what changed, what is blocked, what is ready for review, and what should happen next?

![Citadel desktop cockpit](docs/campaigns/screenshot-desktop-cockpit.png)

## What It Does

- Registers local repositories and creates isolated git workspaces.
- Starts durable shell-backed agent sessions inside those workspaces.
- Keeps terminal sessions alive across browser refreshes and daemon restarts.
- Shows workspace state, branch state, PR/check status, issue links, local apps, operations, and activity in one cockpit.
- Exposes a local daemon API over REST, SSE, terminal WebSocket, and MCP tools.
- Runs locally by default with SQLite persistence and loopback-only networking.

Citadel is not a hosted SaaS product. It is a developer-machine control plane for local agent work.

## Status

Citadel is an active source-available project. The core local workflow is implemented and tested, but the product is still evolving.

Stable today:

- local daemon + React cockpit
- repository/workspace lifecycle
- SQLite persistence and forward migrations
- tmux-backed agent and terminal sessions
- PR/check/provider summaries through local provider adapters
- local install, upgrade, doctor, smoke, and e2e verification paths
- architecture boundary checks and package-level TypeScript project references

In progress:

- broader design-system migration across older cockpit surfaces
- PTY-daemon terminal performance work
- richer human-review and provider workflows
- expanded first-run and configuration UX

## Screenshots

| Cockpit | Settings |
|---|---|
| ![Cockpit screenshot](docs/campaigns/screenshot-desktop-cockpit.png) | ![Settings screenshot](docs/campaigns/screenshot-desktop-settings.png) |

| Dark theme | Light theme |
|---|---|
| ![Dark theme cockpit](docs/campaigns/theme-dark-desktop-cockpit.png) | ![Light theme cockpit](docs/campaigns/theme-light-desktop-cockpit.png) |

## Architecture

Citadel is a TypeScript monorepo with a local daemon, browser cockpit, and small domain packages.

| Area | Purpose |
|---|---|
| `apps/web` | React, Vite, TanStack Router/Query cockpit UI |
| `apps/daemon` | local REST/SSE/WebSocket daemon and operation runner |
| `apps/cli` | thin local helper surface |
| `packages/contracts` | Zod API/event DTOs shared by daemon and web |
| `packages/core` | pure domain logic and state helpers |
| `packages/db` | SQLite schema, migrations, and persistence helpers |
| `packages/operations` | side-effectful workspace, session, and lifecycle workflows |
| `packages/terminal` | tmux/PTY terminal adapters and WebSocket protocol |
| `packages/providers` | version-control, PR, CI, issue, and usage provider adapters |
| `packages/runtimes` | agent runtime contracts and launch profiles |
| `packages/hooks` | repo hook discovery, execution, and output contracts |
| `packages/mcp` | normalized local MCP tools/resources |
| `packages/testing` | deterministic test fixtures and fake providers |

The codebase enforces boundaries with static checks:

- `packages/core` stays pure and does not import implementation packages.
- `apps/web` talks to the daemon through shared contracts, not daemon internals.
- workspace cleanup paths must preserve dirty work unless an explicit force policy is used.
- dependency changes are checked by a local dependency-policy gate.

See [Citadel v2 Architecture](docs/architecture/citadel-v2-architecture.md) and [Technical Decisions](docs/architecture/technical-decisions.md).

## Technical Choices

Citadel deliberately chooses a local-first architecture:

- **Local daemon + browser UI, not hosted SaaS:** agent work, repositories, terminals, and provider CLIs already live on the developer machine.
- **SQLite, not a remote database:** state is local, inspectable, easy to back up, and enough for one operator's workbench.
- **Zod contracts between packages:** the daemon and browser share typed request/response/event contracts without importing each other's internals.
- **tmux and PTY-backed terminals:** sessions survive browser refreshes and daemon restarts while preserving real terminal behavior.
- **Provider adapters, not provider-specific UI:** GitHub/Jira-style integrations are implementations behind normalized PR, check, issue, and provider-health surfaces.
- **Checks as product infrastructure:** architecture boundaries, size limits, typecheck, lint, coverage, dependency policy, build, smoke, and Playwright e2e are part of the normal gate.

## Quickstart

Development loop:

```bash
make setup # pnpm install
make deploy # detached HMR stack (daemon + vite); prints the cockpit URL
make setup
make deploy
```

- Daemon (REST/SSE/WebSocket, serves built web): `http://127.0.0.1:4010` for the systemd long-term install; worktree dev uses derived ports `4110–4209`.
- Vite HMR cockpit (worktree dev): `5210–5309`.
- Default config: `~/.local/share/citadel/citadel.config.json`
- Default SQLite DB: `~/.local/share/citadel/citadel.sqlite`
`make deploy` starts a detached worktree-scoped HMR stack: daemon under `tsx watch` plus Vite. It prints the cockpit URL.

Default long-term install:

The daemon binds `127.0.0.1` by default for local-first use. See [docs/operations/worktree-development.md](docs/operations/worktree-development.md) for the full mental model.
```bash
make install
make doctor
```

## Install (long-term)
Useful commands:

```bash
make install # install latest released tag and refresh systemd --user units
make upgrade # reinstall/upgrade to latest released tag
make install REF=main # development install from latest origin/main
make upgrade REF=v0.3.0 # install a specific annotated release tag
make doctor # verify everything is configured
make check # architecture, size, typecheck, lint, coverage, deps, build
make smoke # local daemon smoke checks
make e2e # Playwright suite
make doctor # installed-system verification
```

See [docs/operations/install.md](docs/operations/install.md) for pre-requisites, HTTPS setup (mkcert recipe), and verification details.
Runtime defaults:

- long-term daemon: `http://127.0.0.1:4010`
- worktree daemon ports: `4110-4209`
- worktree Vite ports: `5210-5309`
- config: `~/.local/share/citadel/citadel.config.json`
- SQLite DB: `~/.local/share/citadel/citadel.sqlite`

## Checks
See [Install](docs/operations/install.md), [Runbook](docs/operations/runbook.md), and [Worktree Development](docs/operations/worktree-development.md).

## Quality Bar

The main local gate is:

```bash
make check
make smoke
```

It runs:

- architecture boundary checks
- file-size checks
- TypeScript project-reference typecheck
- Biome lint/format check
- Vitest coverage
- dependency policy
- production build

UI and integration confidence comes from Playwright:

```bash
make e2e
pnpm e2e:isolated
```

Citadel uses pnpm, strict TypeScript project references, Biome, Vitest, Playwright, SQLite, tmux, REST/SSE for app state, and a dedicated WebSocket for terminal I/O.
The isolated wrappers allocate temporary state so tests do not write into the operator's real Citadel database or config.

## License
## For Reviewers

Citadel is source-available under the [PolyForm Perimeter License 1.0.1](LICENSE). Commercial use is allowed when it does not provide a product or service that competes with Citadel. Redistributions must preserve the required notice pointing to the original repository.
If you are evaluating the repository, start here:

1. Read this README for the product and architecture overview.
2. Skim [User Journeys](docs/architecture/user-journeys.md) for the intended operator workflow.
3. Read [Citadel v2 Architecture](docs/architecture/citadel-v2-architecture.md) for package boundaries.
4. Open `apps/web/src/main.tsx` and `apps/daemon/src/app.ts` to see the browser/daemon entry points.
5. Review `packages/contracts/src/index.ts` for the API/event model.
6. Run `make check` to exercise the normal quality gate.
7. Browse `e2e/` for the user-facing regression coverage.

## Docs
The repo also contains agent/process artifacts used to build the project. They are documented separately in [Docs Map](docs/README.md) so they do not need to be read as product documentation.

- [Product specs](specs/README.md)
## Documentation

- [Docs Map](docs/README.md)
- [Product Specs](specs/README.md)
- [Architecture](docs/architecture/citadel-v2-architecture.md)
- [Technical Decisions](docs/architecture/technical-decisions.md)
- [User Journeys](docs/architecture/user-journeys.md)
- [Install](docs/operations/install.md)
- [Hook examples](docs/operations/hook-examples.md)
- [Runbook](docs/operations/runbook.md)
- [Worktree development](docs/operations/worktree-development.md)
- [Engineering standards](docs/contributors/v2-engineering-standards.md)
- [Campaign log](docs/campaigns/citadel-v2-implementation-log.md)
- [Config Reference](docs/operations/config-reference.md)
- [Engineering Standards](docs/contributors/v2-engineering-standards.md)
- [Contributing](CONTRIBUTING.md)

## License

Citadel is source-available under the [PolyForm Perimeter License 1.0.1](LICENSE). Commercial use is allowed when it does not provide a product or service that competes with Citadel. Redistributions must preserve the required notice pointing to the original repository.
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
"@citadel/contracts": "workspace:*",
"@citadel/core": "workspace:*",
"@pierre/diffs": "1.2.7",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-router": "^1.132.0",
"@xterm/addon-fit": "^0.10.0",
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/components/ui/badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import { Badge } from "./badge.js";
import { render } from "./test-utils.js";

let cleanup: (() => void) | null = null;
afterEach(() => {
cleanup?.();
cleanup = null;
});

function mount(node: React.ReactNode) {
const result = render(node);
cleanup = result.unmount;
return result;
}

describe("Badge", () => {
it("renders a <span> by default", () => {
const { container } = mount(<Badge>Idle</Badge>);
const badge = container.querySelector("span");
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe("Idle");
});

it("applies the neutral variant by default", () => {
const { container } = mount(<Badge>Default</Badge>);
const badge = container.querySelector("span");
expect(badge?.getAttribute("data-variant")).toBe("neutral");
});

it("applies a known variant via data-variant for stable test targeting", () => {
for (const variant of ["neutral", "ready", "blocked", "info", "warn", "merged", "neutral-strong"] as const) {
const { container, unmount } = render(<Badge variant={variant}>{variant}</Badge>);
const badge = container.querySelector("span");
expect(badge?.getAttribute("data-variant")).toBe(variant);
unmount();
}
});

it("renders a leading dot when dot is true", () => {
const { container } = mount(
<Badge variant="ready" dot>
Live
</Badge>,
);
expect(container.querySelector('[data-slot="badge-dot"]')).not.toBeNull();
});

it("does not render a dot when dot is omitted", () => {
const { container } = mount(<Badge variant="ready">Live</Badge>);
expect(container.querySelector('[data-slot="badge-dot"]')).toBeNull();
});

it("merges the className prop", () => {
const { container } = mount(
<Badge className="my-extra" variant="info">
Extra
</Badge>,
);
const badge = container.querySelector("span");
expect(badge?.className).toMatch(/my-extra/);
});
});
42 changes: 38 additions & 4 deletions apps/web/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,53 @@ import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import { cn } from "../../lib/utils.js";

const badgeVariants = cva("inline-flex items-center rounded-full px-2 py-1 text-xs font-medium leading-none", {
const badgeVariants = cva("inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-xs font-medium leading-none", {
variants: {
variant: {
neutral: "bg-[color-mix(in_srgb,CanvasText_8%,transparent)] text-[CanvasText]",
ready: "bg-[color-mix(in_srgb,var(--color-success)_13%,transparent)] text-[var(--color-success)]",
blocked: "bg-[color-mix(in_srgb,var(--color-danger)_12%,transparent)] text-[var(--color-danger)]",
info: "bg-[color-mix(in_srgb,var(--c-info)_15%,transparent)] text-[var(--c-info)]",
warn: "bg-[color-mix(in_srgb,var(--color-warning)_13%,transparent)] text-[var(--color-warning)]",
merged: "bg-[color-mix(in_srgb,var(--color-merged)_13%,transparent)] text-[var(--color-merged)]",
"neutral-strong": "bg-[color-mix(in_srgb,CanvasText_18%,transparent)] text-[CanvasText]",
},
},
defaultVariants: { variant: "neutral" },
});

export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>["variant"]>;

export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
const dotTone: Record<BadgeVariant, string> = {
neutral: "bg-[color-mix(in_srgb,CanvasText_55%,transparent)]",
ready: "bg-[var(--color-success)]",
blocked: "bg-[var(--color-danger)]",
info: "bg-[var(--c-info)]",
warn: "bg-[var(--color-warning)]",
merged: "bg-[var(--color-merged)]",
"neutral-strong": "bg-[CanvasText]",
};

export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {
dot?: boolean;
}

export function Badge({ className, variant, dot, children, ...props }: BadgeProps) {
const resolvedVariant: BadgeVariant = variant ?? "neutral";
return (
<span
data-variant={resolvedVariant}
className={cn(badgeVariants({ variant: resolvedVariant }), className)}
{...props}
>
{dot ? (
<span
data-slot="badge-dot"
aria-hidden="true"
className={cn("inline-block h-1.5 w-1.5 rounded-full", dotTone[resolvedVariant])}
/>
) : null}
{children}
</span>
);
}
Loading
Loading