diff --git a/.gitignore b/.gitignore
index 5ffc648..026d65f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,9 @@
.orch/
bin/
dist/
+node_modules/
+apps/web/dist/
+apps/web/.vite/
*.db
coverage.out
.DS_Store
diff --git a/api/events.md b/api/events.md
new file mode 100644
index 0000000..7e7a516
--- /dev/null
+++ b/api/events.md
@@ -0,0 +1,77 @@
+# Event Stream Contract
+
+## Status
+
+Planned for the next milestone.
+The Phase 1 web skeleton does not implement the event stream yet.
+
+## Intended Endpoint
+
+- `GET /api/events/stream`
+
+## Transport Choice
+
+- start with Server-Sent Events rather than websockets
+- keep the stream read-only and cursor-based
+- let the frontend use events to invalidate or refresh TanStack Query caches
+
+## Query Parameters
+
+- `after_event_id`: optional monotonic cursor
+- `run_id`: optional run filter for run-scoped pages
+- `thread_id`: optional thread filter for timeline views
+
+## Event Envelope
+
+Each SSE message should carry one JSON object with this shape:
+
+```json
+{
+ "event_id": 42,
+ "event_type": "task_blocked",
+ "run_id": "run_web_001",
+ "task_id": "T1",
+ "thread_id": "thr_123",
+ "summary": "Need the API shape",
+ "payload": {
+ "source": "orch"
+ },
+ "created_at": "2026-03-20T09:30:00Z"
+}
+```
+
+## Expected First Event Types
+
+- `task_added`
+- `task_ready`
+- `task_dispatched`
+- `task_running`
+- `task_blocked`
+- `task_answered`
+- `task_done`
+- `task_failed`
+- `thread_claim`
+- `thread_update`
+- `thread_reply`
+- `thread_result`
+- `council_tallied`
+- `council_report_persisted`
+
+## Cursor Rules
+
+- `event_id` is the only resume cursor
+- the server should emit events in ascending `event_id` order
+- reconnect clients should pass the last processed `event_id`
+- the server should not depend on in-memory subscriptions for correctness
+
+## Frontend Usage
+
+- run detail pages should subscribe with `run_id`
+- thread views may subscribe with `thread_id`
+- the client should prefer refetch/invalidate behavior over complex local reducers in the first realtime slice
+
+## Backend Notes
+
+- source data should come from the existing shared `events` table
+- `orchd` should treat the stream as a projection over persisted events, not as a separate ephemeral bus
+- if filtering becomes expensive later, move filtering logic into a dedicated query layer without changing the wire contract
diff --git a/api/openapi.yaml b/api/openapi.yaml
new file mode 100644
index 0000000..1312849
--- /dev/null
+++ b/api/openapi.yaml
@@ -0,0 +1,363 @@
+openapi: 3.1.0
+info:
+ title: Orch Web API
+ version: 0.1.0
+ summary: Initial read-only web API for the orchestration control plane.
+servers:
+ - url: http://localhost:8080
+paths:
+ /health:
+ get:
+ summary: Health check
+ operationId: getHealth
+ responses:
+ '200':
+ description: Service is healthy
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [status]
+ properties:
+ status:
+ type: string
+ example: ok
+ /api/runs:
+ get:
+ summary: List orchestration runs
+ operationId: listRuns
+ responses:
+ '200':
+ description: Run summaries
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [runs]
+ properties:
+ runs:
+ type: array
+ items:
+ $ref: '#/components/schemas/RunListItem'
+ /api/runs/{runID}:
+ get:
+ summary: Get run detail
+ operationId: getRunDetail
+ parameters:
+ - $ref: '#/components/parameters/RunID'
+ responses:
+ '200':
+ description: Run detail including tasks and blocked-task summaries
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [run]
+ properties:
+ run:
+ $ref: '#/components/schemas/RunDetail'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ /api/runs/{runID}/tasks:
+ get:
+ summary: List tasks for a run
+ operationId: listRunTasks
+ parameters:
+ - $ref: '#/components/parameters/RunID'
+ responses:
+ '200':
+ description: Task list
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [tasks]
+ properties:
+ tasks:
+ type: array
+ items:
+ $ref: '#/components/schemas/Task'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ /api/runs/{runID}/blocked:
+ get:
+ summary: List blocked tasks for a run
+ operationId: listBlockedTasks
+ parameters:
+ - $ref: '#/components/parameters/RunID'
+ responses:
+ '200':
+ description: Blocked task list
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [blocked]
+ properties:
+ blocked:
+ type: array
+ items:
+ $ref: '#/components/schemas/BlockedTask'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ /api/threads/{threadID}:
+ get:
+ summary: Get thread timeline
+ operationId: getThreadDetail
+ parameters:
+ - $ref: '#/components/parameters/ThreadID'
+ responses:
+ '200':
+ description: Thread detail with messages and artifacts
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [thread]
+ properties:
+ thread:
+ $ref: '#/components/schemas/ThreadDetail'
+ '404':
+ $ref: '#/components/responses/NotFound'
+components:
+ parameters:
+ RunID:
+ name: runID
+ in: path
+ required: true
+ schema:
+ type: string
+ ThreadID:
+ name: threadID
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ NotFound:
+ description: Requested resource was not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ schemas:
+ ErrorEnvelope:
+ type: object
+ required: [error]
+ properties:
+ error:
+ type: object
+ required: [code, message]
+ properties:
+ code:
+ type: string
+ example: not_found
+ message:
+ type: string
+ Run:
+ type: object
+ required: [run_id, goal, summary, status, created_at, updated_at]
+ properties:
+ run_id:
+ type: string
+ goal:
+ type: string
+ summary:
+ type: string
+ status:
+ type: string
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ RunListItem:
+ type: object
+ required: [run, task_counts, total_tasks]
+ properties:
+ run:
+ $ref: '#/components/schemas/Run'
+ task_counts:
+ type: object
+ additionalProperties:
+ type: integer
+ total_tasks:
+ type: integer
+ RunDetail:
+ type: object
+ required: [run, task_counts, total_tasks, tasks, blocked_tasks]
+ properties:
+ run:
+ $ref: '#/components/schemas/Run'
+ task_counts:
+ type: object
+ additionalProperties:
+ type: integer
+ total_tasks:
+ type: integer
+ tasks:
+ type: array
+ items:
+ $ref: '#/components/schemas/Task'
+ blocked_tasks:
+ type: array
+ items:
+ $ref: '#/components/schemas/BlockedTask'
+ Task:
+ type: object
+ required:
+ [run_id, task_id, title, summary, status, priority, acceptance_json, created_at, updated_at]
+ properties:
+ run_id:
+ type: string
+ task_id:
+ type: string
+ title:
+ type: string
+ summary:
+ type: string
+ status:
+ type: string
+ default_to:
+ type: string
+ priority:
+ type: string
+ acceptance_json:
+ description: Raw JSON acceptance criteria payload
+ latest_attempt_no:
+ type: integer
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ TaskAttempt:
+ type: object
+ required: [run_id, task_id, attempt_no, assigned_to, thread_id, status, created_at, updated_at]
+ properties:
+ run_id:
+ type: string
+ task_id:
+ type: string
+ attempt_no:
+ type: integer
+ assigned_to:
+ type: string
+ thread_id:
+ type: string
+ base_ref:
+ type: string
+ base_commit:
+ type: string
+ branch_name:
+ type: string
+ worktree_path:
+ type: string
+ workspace_status:
+ type: string
+ result_commit:
+ type: string
+ status:
+ type: string
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ Artifact:
+ type: object
+ required: [artifact_id, message_id, path, kind, metadata_json, created_at]
+ properties:
+ artifact_id:
+ type: string
+ message_id:
+ type: string
+ path:
+ type: string
+ kind:
+ type: string
+ metadata_json:
+ description: Raw JSON artifact metadata
+ created_at:
+ type: string
+ format: date-time
+ Message:
+ type: object
+ required:
+ [message_id, thread_id, from_agent, to_agent, kind, summary, body, payload_json, created_at]
+ properties:
+ message_id:
+ type: string
+ thread_id:
+ type: string
+ from_agent:
+ type: string
+ to_agent:
+ type: string
+ kind:
+ type: string
+ summary:
+ type: string
+ body:
+ type: string
+ payload_json:
+ description: Raw JSON message payload
+ created_at:
+ type: string
+ format: date-time
+ artifacts:
+ type: array
+ items:
+ $ref: '#/components/schemas/Artifact'
+ Thread:
+ type: object
+ required:
+ [thread_id, run_id, task_id, subject, created_by, assigned_to, status, priority, created_at, updated_at]
+ properties:
+ thread_id:
+ type: string
+ run_id:
+ type: string
+ task_id:
+ type: string
+ subject:
+ type: string
+ created_by:
+ type: string
+ assigned_to:
+ type: string
+ status:
+ type: string
+ priority:
+ type: string
+ latest_message_id:
+ type: string
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ ThreadDetail:
+ type: object
+ required: [thread, messages]
+ properties:
+ thread:
+ $ref: '#/components/schemas/Thread'
+ messages:
+ type: array
+ items:
+ $ref: '#/components/schemas/Message'
+ BlockedTask:
+ type: object
+ required: [task, attempt, question]
+ properties:
+ task:
+ $ref: '#/components/schemas/Task'
+ attempt:
+ $ref: '#/components/schemas/TaskAttempt'
+ question:
+ $ref: '#/components/schemas/Message'
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..359d8a1
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Orch Control Plane
+
+
+
+
+
+
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..103acf7
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@ai-workflow-skill/web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.91.2",
+ "@tanstack/react-router": "^1.167.5",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "typescript": "^5.9.3",
+ "vite": "^8.0.1"
+ }
+}
diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx
new file mode 100644
index 0000000..471d129
--- /dev/null
+++ b/apps/web/src/app.tsx
@@ -0,0 +1,104 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ Link,
+ Outlet,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '@tanstack/react-router';
+
+const queryClient = new QueryClient();
+
+const rootRoute = createRootRoute({
+ component: RootLayout,
+});
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: HomePage,
+});
+
+const routeTree = rootRoute.addChildren([indexRoute]);
+
+const router = createRouter({
+ routeTree,
+});
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: typeof router;
+ }
+}
+
+export function App() {
+ return (
+
+
+
+ );
+}
+
+function RootLayout() {
+ return (
+
+ );
+}
+
+function HomePage() {
+ return (
+
+
+ Current slice
+ Read-only operator shell for a future multi-user web product.
+
+ The monorepo now has a dedicated React app, a Go HTTP service, and a
+ first API contract for runs, blocked work, and thread history.
+
+
+
+
+ Backend spine
+
+ - `cmd/orchd` serves `chi` routes against the existing SQLite state.
+ - `internal/query` shapes run, blocked-task, and thread reads.
+ - `api/openapi.yaml` is the contract anchor for future typed clients.
+
+
+
+
+ Frontend posture
+
+ React, Vite, TanStack Router, and TanStack Query are present now so
+ Phase 2 can focus on actual operator views instead of build plumbing.
+
+
+
+
+ Next UI targets
+
+ - Runs dashboard with task-count health signals.
+ - Run detail board grouped by orchestration state.
+ - Blocked queue and thread timeline wired to live refresh.
+
+
+
+ );
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000..6258229
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,17 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { App } from './app';
+import './styles.css';
+
+const rootElement = document.getElementById('root');
+
+if (!rootElement) {
+ throw new Error('Missing root element');
+}
+
+createRoot(rootElement).render(
+
+
+ ,
+);
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
new file mode 100644
index 0000000..8c13677
--- /dev/null
+++ b/apps/web/src/styles.css
@@ -0,0 +1,154 @@
+:root {
+ color-scheme: light;
+ font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+ background:
+ radial-gradient(circle at top left, rgba(255, 196, 98, 0.32), transparent 28%),
+ radial-gradient(circle at bottom right, rgba(8, 145, 178, 0.16), transparent 26%),
+ linear-gradient(135deg, #f4efe3 0%, #f9f7f1 55%, #eef3f4 100%);
+ color: #172026;
+ --border: rgba(23, 32, 38, 0.12);
+ --panel: rgba(255, 255, 255, 0.82);
+ --panel-strong: rgba(255, 255, 255, 0.92);
+ --ink-soft: rgba(23, 32, 38, 0.7);
+ --accent: #c96f20;
+ --accent-deep: #0f766e;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+#root {
+ min-height: 100vh;
+}
+
+.shell {
+ min-height: 100vh;
+ padding: 32px 20px 48px;
+}
+
+.masthead {
+ display: grid;
+ gap: 18px;
+ align-items: end;
+ max-width: 1200px;
+ margin: 0 auto 28px;
+}
+
+.brand {
+ display: inline-block;
+ font-family: "Iowan Old Style", "Palatino Linotype", serif;
+ font-size: clamp(2rem, 4vw, 3.2rem);
+ letter-spacing: -0.04em;
+}
+
+.eyebrow {
+ margin: 0 0 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ font-size: 0.78rem;
+ color: var(--accent-deep);
+}
+
+.masthead-copy {
+ max-width: 34rem;
+ margin: 0;
+ color: var(--ink-soft);
+}
+
+.content {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.hero-grid {
+ display: grid;
+ gap: 18px;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+}
+
+.hero-card {
+ grid-column: span 12;
+ padding: 24px;
+ border: 1px solid var(--border);
+ border-radius: 24px;
+ background: var(--panel);
+ backdrop-filter: blur(12px);
+ box-shadow: 0 20px 50px rgba(23, 32, 38, 0.08);
+}
+
+.hero-card-primary {
+ background:
+ linear-gradient(145deg, rgba(255, 255, 255, 0.92), rgba(255, 248, 237, 0.94));
+}
+
+.hero-card-secondary {
+ background:
+ linear-gradient(145deg, rgba(240, 249, 255, 0.95), rgba(240, 253, 250, 0.9));
+}
+
+.hero-card h1,
+.hero-card h2 {
+ margin: 0 0 12px;
+ font-family: "Iowan Old Style", "Palatino Linotype", serif;
+ letter-spacing: -0.03em;
+}
+
+.hero-card h1 {
+ font-size: clamp(2rem, 4.4vw, 4.4rem);
+ line-height: 0.94;
+ max-width: 12ch;
+}
+
+.hero-card h2 {
+ font-size: 1.5rem;
+}
+
+.lede,
+.hero-card p {
+ margin: 0;
+ max-width: 48rem;
+ color: var(--ink-soft);
+}
+
+.detail-list {
+ margin: 0;
+ padding-left: 18px;
+ color: var(--ink-soft);
+}
+
+.detail-list li + li {
+ margin-top: 10px;
+}
+
+@media (min-width: 840px) {
+ .masthead {
+ grid-template-columns: 1.2fr 0.8fr;
+ }
+
+ .hero-card-primary {
+ grid-column: span 7;
+ min-height: 420px;
+ }
+
+ .hero-card-secondary {
+ grid-column: span 5;
+ }
+
+ .hero-card:not(.hero-card-primary):not(.hero-card-secondary) {
+ grid-column: span 6;
+ }
+}
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..a3d0976
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..9cfb0ce
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,14 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+ proxy: {
+ '/api': 'http://localhost:8080',
+ '/health': 'http://localhost:8080',
+ },
+ },
+});
diff --git a/cmd/orchd/main.go b/cmd/orchd/main.go
new file mode 100644
index 0000000..71a4948
--- /dev/null
+++ b/cmd/orchd/main.go
@@ -0,0 +1,66 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "ai-workflow-skill/internal/app"
+ "ai-workflow-skill/internal/db"
+ "ai-workflow-skill/internal/httpapi"
+)
+
+func main() {
+ var (
+ dbPath string
+ listen string
+ shutdown time.Duration
+ )
+
+ flag.StringVar(&dbPath, "db", ".agents/coord.db", "SQLite database path")
+ flag.StringVar(&listen, "listen", ":8080", "HTTP listen address")
+ flag.DurationVar(&shutdown, "shutdown-timeout", 5*time.Second, "Graceful shutdown timeout")
+ flag.Parse()
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ sqlDB, err := db.Open(ctx, dbPath)
+ if err != nil {
+ log.Fatalf("open database: %v", err)
+ }
+ defer sqlDB.Close()
+
+ if err := db.ApplyMigrations(ctx, sqlDB); err != nil {
+ log.Fatalf("apply migrations: %v", err)
+ }
+
+ webApp := app.NewWebService(sqlDB)
+ server := &http.Server{
+ Addr: listen,
+ Handler: httpapi.NewRouter(webApp),
+ ReadHeaderTimeout: 5 * time.Second,
+ }
+
+ go func() {
+ <-ctx.Done()
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdown)
+ defer cancel()
+
+ if err := server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Printf("http shutdown: %v", err)
+ }
+ }()
+
+ log.Printf("orchd listening on %s", listen)
+ if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatalf("serve http api: %v", err)
+ }
+}
diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md
index 92da1c8..4284228 100644
--- a/docs/implementation-roadmap.md
+++ b/docs/implementation-roadmap.md
@@ -31,6 +31,9 @@ As of now:
- a council-review skill forward-test plan directory now exists under `docs/tests/council-review-skill/`, with a shared execution contract and nine council workflow scenarios covering end-to-end flow, unanimous-only defaults, timeout/before-tally errors, explicit minority reporting, invalid report filters, strict tally semantics, malformed reviewer JSON, and target-file inputs
- an execution-roadmap workflow now exists under `docs/roadmaps/active/` and `docs/roadmaps/archive/` for agent-level work traces and completion archives
- a forward-looking web product monorepo plan now exists under `docs/web-product-monorepo.md`, defining the recommended React frontend, `chi` HTTP service, `cmd/orchd` entrypoint, and shared application/query layering for future web work
+- the Phase 1 web-product skeleton is now in place, including root `pnpm` workspace files, a standalone React app under `apps/web`, an initial OpenAPI/events contract under `api/`, and a new `cmd/orchd` HTTP service backed by `internal/app`, `internal/query`, and `internal/httpapi`
+- `orchd` now serves a minimal read-only web API with `chi`, including `/health`, runs list/detail, run task list, blocked-task list, and thread detail endpoints backed by the existing SQLite state
+- HTTP tests now cover the initial read-only `orchd` slice, and the new frontend workspace builds successfully with `pnpm run web:build`
- a repo-local `scripts/package_skill_clis.sh` packaging flow now builds bundled skill CLI assets for `inbox`, `orch`, and `council-review`
- `orch` now implements `run init/show`, `task add`, `dep add`, `ready`, `dispatch`, `reconcile`, `wait`, `blocked`, `answer`, `retry`, `reassign`, `cancel`, `cleanup`, and `status`
- `orch` can create runs, gate tasks through dependencies, dispatch work through `inbox`, reconcile worker thread state back into task state, answer blocked tasks, retry or reassign work, cancel tasks or runs, clean attempt worktrees, and create per-attempt Git worktrees during strict dispatch
@@ -88,8 +91,9 @@ Current implementation status:
- `Milestone 5: Strict Worktree Support` is complete
- `Milestone 6: Waiting Primitives` is complete
- `Milestone 7: Council Review` is complete
+- `Milestone 8: Web Product Phase 1 Skeleton` is complete
-The council review v1 surface is now complete, including final report rendering and metadata persistence.
+The council review v1 surface is complete, and the first web-product skeleton now exists as a separate monorepo workspace plus read-only HTTP backend slice.
### Milestone 1: Go Skeleton
@@ -367,16 +371,59 @@ Remaining:
- none for the v1 council workflow
+### Milestone 8: Web Product Phase 1 Skeleton
+
+Goal:
+
+- create the first durable web-product backbone without replacing the existing CLI workflows
+
+Add:
+
+- root `pnpm` workspace files
+- `apps/web`
+- `api/openapi.yaml`
+- `api/events.md`
+- `cmd/orchd`
+- `internal/app`
+- `internal/query`
+- `internal/httpapi`
+
+Definition of done:
+
+- the repository contains the agreed monorepo skeleton
+- `orchd` can serve a small read-only HTTP API against the existing database
+- the frontend workspace builds and can evolve independently in later milestones
+
+Status:
+
+- completed
+
+Completed so far:
+
+- root `package.json`, `pnpm-workspace.yaml`, and `pnpm-lock.yaml` now define the monorepo JS workspace
+- `apps/web` now contains a Vite + React + TypeScript + TanStack Router + TanStack Query frontend shell
+- `cmd/orchd` now opens the shared SQLite database, applies migrations, and serves a `chi` router with graceful shutdown handling
+- `internal/query` now exposes run list/detail, run tasks, blocked-task, and thread-detail read models for the web surface
+- `internal/app` now provides a thin web-service boundary over the new read service
+- `internal/httpapi` now owns HTTP routing, JSON/error helpers, and the initial read-only endpoints
+- `api/openapi.yaml` now documents the implemented read-only endpoints and response shapes
+- `api/events.md` now captures the planned SSE contract for the next realtime slice
+- `go test ./...` covers the new HTTP slice, and `pnpm run web:build` succeeds for the frontend workspace
+
+Remaining:
+
+- Phase 2 should turn the frontend shell into actual run, task-board, blocked-queue, and thread-detail pages using the new HTTP contract
+
## Immediate Next Task
If a new agent is taking over now, the next concrete step should be:
-1. treat `Milestone 7: Council Review` as complete unless a new user request introduces a new council capability
-2. keep the authored inbox test-plan set in `docs/tests/inbox/` synchronized if future `orch` work changes shared CLI behavior
-3. if the next milestone is the web product, use `docs/web-product-monorepo.md` as the starting implementation shape for the monorepo, `cmd/orchd`, and `apps/web`
-4. choose the next milestone explicitly instead of reopening the completed council v1 slice
+1. treat `Milestone 8: Web Product Phase 1 Skeleton` as complete unless a new user request reopens the backend skeleton itself
+2. keep the authored inbox test-plan set in `docs/tests/inbox/` synchronized if future `orch` or web work changes shared CLI-visible behavior
+3. start `Phase 2: Read-Only Web UI` on top of the existing `apps/web` and `orchd` contract, beginning with runs list, run detail, blocked queue, and thread timeline views
+4. keep `api/openapi.yaml`, `api/events.md`, and `docs/web-product-monorepo.md` synchronized as the web surface expands
-The inbox implementation and its human-readable test-plan set are already in place, and `orch` now supports the main scheduler loop plus the complete council start/wait/tally/report workflow, so any next step should be a new milestone rather than unfinished council v1 work.
+The inbox implementation and its human-readable test-plan set are already in place, `orch` supports the main scheduler loop plus the complete council start/wait/tally/report workflow, and the web-product Phase 1 skeleton now exists, so the next step should be Phase 2 product surface work rather than reopening earlier milestones.
## Recommended Driver Choices
diff --git a/docs/roadmaps/archive/web-product-phase1-skeleton.md b/docs/roadmaps/archive/web-product-phase1-skeleton.md
new file mode 100644
index 0000000..ae91033
--- /dev/null
+++ b/docs/roadmaps/archive/web-product-phase1-skeleton.md
@@ -0,0 +1,74 @@
+# Web Product Phase 1 Skeleton
+
+## Status
+
+- `completed`
+
+## Owner
+
+- Codex
+
+## Started At
+
+- `2026-03-20`
+
+## Goal
+
+- Implement the first web-product milestone slice described in `docs/web-product-monorepo.md` by creating the monorepo skeleton, a minimal `orchd` HTTP service, and the first read-oriented backend boundaries.
+
+## Scope
+
+- add an active execution trace for this web implementation workstream
+- add the initial monorepo workspace files and `apps/web` scaffold
+- add `cmd/orchd`, `internal/httpapi`, `internal/app`, and `internal/query`
+- expose a small read-only HTTP API for health, runs, run detail, blocked tasks, and thread detail
+- add initial API contract docs under `api/`
+- keep `docs/implementation-roadmap.md` synchronized with the new implementation state
+
+## Checklist
+
+- [x] create the active execution roadmap for the Phase 1 web skeleton workstream
+- [x] scaffold the monorepo workspace files and `apps/web`
+- [x] add `cmd/orchd` with a minimal `chi`-based HTTP server
+- [x] introduce initial shared app/query boundaries for read-only web endpoints
+- [x] add initial API contract documents under `api/`
+- [x] validate the new slice with builds or targeted tests
+- [x] update `docs/implementation-roadmap.md`
+- [x] archive this execution roadmap with a completion summary if the slice is fully complete
+
+## Files
+
+- `docs/roadmaps/active/web-product-phase1-skeleton.md`
+- `docs/implementation-roadmap.md`
+- `docs/web-product-monorepo.md`
+- `cmd/orchd/main.go`
+- `internal/httpapi/`
+- `internal/app/`
+- `internal/query/`
+- `internal/store/`
+- `api/openapi.yaml`
+- `api/events.md`
+- `apps/web/`
+- `package.json`
+- `pnpm-workspace.yaml`
+
+## Decisions
+
+- implement a narrow read-only backend slice first instead of starting with frontend pages
+- keep the first HTTP layer thin and let query/app packages own the web-facing backend boundary
+- preserve the existing CLI entrypoints while introducing the new service
+
+## Blockers
+
+- none
+
+## Next Step
+
+- start Phase 2 on top of the new `apps/web` and `orchd` skeleton by wiring real runs, run-detail, blocked-queue, and thread-detail screens to the read-only API
+
+## Completion Summary
+
+- added the first web-product skeleton described in `docs/web-product-monorepo.md`, including root `pnpm` workspace files, a standalone React frontend shell under `apps/web`, initial API contract documents under `api/`, and a new `cmd/orchd` HTTP service
+- introduced `internal/app`, `internal/query`, and `internal/httpapi` so the web backend has an explicit read-oriented boundary instead of letting handlers query storage ad hoc
+- implemented and validated the first read-only HTTP endpoints for health, runs, run detail, run tasks, blocked tasks, and thread detail
+- verified the slice with `go test ./...` and `pnpm run web:build`, then synchronized `docs/implementation-roadmap.md`
diff --git a/go.mod b/go.mod
index 698f24a..f66f5e4 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module ai-workflow-skill
go 1.26
require (
+ github.com/go-chi/chi/v5 v5.2.5
github.com/spf13/cobra v1.10.1
modernc.org/sqlite v1.40.1
)
diff --git a/go.sum b/go.sum
index 91c8044..056a4f3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
+github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
diff --git a/internal/app/web.go b/internal/app/web.go
new file mode 100644
index 0000000..f8d66fa
--- /dev/null
+++ b/internal/app/web.go
@@ -0,0 +1,39 @@
+package app
+
+import (
+ "context"
+ "database/sql"
+
+ "ai-workflow-skill/internal/query"
+ "ai-workflow-skill/internal/store"
+)
+
+type WebService struct {
+ reads *query.ReadService
+}
+
+func NewWebService(db *sql.DB) *WebService {
+ return &WebService{
+ reads: query.NewReadService(db),
+ }
+}
+
+func (s *WebService) ListRuns(ctx context.Context) ([]query.RunListItem, error) {
+ return s.reads.ListRuns(ctx)
+}
+
+func (s *WebService) GetRunDetail(ctx context.Context, runID string) (query.RunDetail, error) {
+ return s.reads.GetRunDetail(ctx, runID)
+}
+
+func (s *WebService) ListRunTasks(ctx context.Context, runID string) ([]store.Task, error) {
+ return s.reads.ListRunTasks(ctx, runID)
+}
+
+func (s *WebService) ListBlockedTasks(ctx context.Context, runID string) ([]store.BlockedTask, error) {
+ return s.reads.ListBlockedTasks(ctx, runID)
+}
+
+func (s *WebService) GetThreadDetail(ctx context.Context, threadID string) (store.ThreadDetail, error) {
+ return s.reads.GetThreadDetail(ctx, threadID)
+}
diff --git a/internal/httpapi/response.go b/internal/httpapi/response.go
new file mode 100644
index 0000000..123691e
--- /dev/null
+++ b/internal/httpapi/response.go
@@ -0,0 +1,58 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "ai-workflow-skill/internal/store"
+)
+
+type errorEnvelope struct {
+ Error errorPayload `json:"error"`
+}
+
+type errorPayload struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func writeJSON(w http.ResponseWriter, status int, payload any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ _ = enc.Encode(payload)
+}
+
+func writeError(w http.ResponseWriter, err error) {
+ status, code := classifyError(err)
+ writeJSON(w, status, errorEnvelope{
+ Error: errorPayload{
+ Code: code,
+ Message: errorMessage(err),
+ },
+ })
+}
+
+func classifyError(err error) (int, string) {
+ switch {
+ case errors.Is(err, store.ErrInvalidInput):
+ return http.StatusBadRequest, "invalid_input"
+ case errors.Is(err, store.ErrRunNotFound), errors.Is(err, store.ErrTaskNotFound), errors.Is(err, store.ErrThreadNotFound):
+ return http.StatusNotFound, "not_found"
+ case errors.Is(err, store.ErrInvalidState):
+ return http.StatusConflict, "invalid_state"
+ default:
+ return http.StatusInternalServerError, "internal_error"
+ }
+}
+
+func errorMessage(err error) string {
+ if err == nil {
+ return "unknown error"
+ }
+ return fmt.Sprintf("%v", err)
+}
diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go
new file mode 100644
index 0000000..f581cac
--- /dev/null
+++ b/internal/httpapi/router.go
@@ -0,0 +1,87 @@
+package httpapi
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+
+ "ai-workflow-skill/internal/query"
+ "ai-workflow-skill/internal/store"
+)
+
+type readService interface {
+ ListRuns(ctx context.Context) ([]query.RunListItem, error)
+ GetRunDetail(ctx context.Context, runID string) (query.RunDetail, error)
+ ListRunTasks(ctx context.Context, runID string) ([]store.Task, error)
+ ListBlockedTasks(ctx context.Context, runID string) ([]store.BlockedTask, error)
+ GetThreadDetail(ctx context.Context, threadID string) (store.ThreadDetail, error)
+}
+
+func NewRouter(service readService) http.Handler {
+ router := chi.NewRouter()
+ router.Use(middleware.RequestID)
+ router.Use(middleware.Recoverer)
+ router.Use(middleware.Timeout(30 * time.Second))
+
+ router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "status": "ok",
+ })
+ })
+
+ router.Route("/api", func(r chi.Router) {
+ r.Get("/runs", func(w http.ResponseWriter, r *http.Request) {
+ runs, err := service.ListRuns(r.Context())
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
+ })
+
+ r.Get("/runs/{runID}", func(w http.ResponseWriter, r *http.Request) {
+ runID := chi.URLParam(r, "runID")
+ run, err := service.GetRunDetail(r.Context(), runID)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"run": run})
+ })
+
+ r.Get("/runs/{runID}/tasks", func(w http.ResponseWriter, r *http.Request) {
+ runID := chi.URLParam(r, "runID")
+ tasks, err := service.ListRunTasks(r.Context(), runID)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
+ })
+
+ r.Get("/runs/{runID}/blocked", func(w http.ResponseWriter, r *http.Request) {
+ runID := chi.URLParam(r, "runID")
+ blocked, err := service.ListBlockedTasks(r.Context(), runID)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"blocked": blocked})
+ })
+
+ r.Get("/threads/{threadID}", func(w http.ResponseWriter, r *http.Request) {
+ threadID := chi.URLParam(r, "threadID")
+ thread, err := service.GetThreadDetail(r.Context(), threadID)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"thread": thread})
+ })
+ })
+
+ return router
+}
diff --git a/internal/httpapi/router_test.go b/internal/httpapi/router_test.go
new file mode 100644
index 0000000..f01c66f
--- /dev/null
+++ b/internal/httpapi/router_test.go
@@ -0,0 +1,196 @@
+package httpapi
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "ai-workflow-skill/internal/app"
+ dbpkg "ai-workflow-skill/internal/db"
+ "ai-workflow-skill/internal/store"
+)
+
+func TestRouterExposesReadOnlyWebEndpoints(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ dbPath := filepath.Join(t.TempDir(), "coord.db")
+ sqlDB, err := dbpkg.Open(ctx, dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer sqlDB.Close()
+
+ if err := dbpkg.ApplyMigrations(ctx, sqlDB); err != nil {
+ t.Fatalf("apply migrations: %v", err)
+ }
+
+ orchStore := store.NewOrchStore(sqlDB)
+ inboxStore := store.NewInboxStore(sqlDB)
+
+ _, err = orchStore.CreateRun(ctx, store.CreateRunInput{
+ RunID: "run_web_001",
+ Goal: "Build the web control plane",
+ Summary: "Initial HTTP slice",
+ })
+ if err != nil {
+ t.Fatalf("create run: %v", err)
+ }
+
+ _, err = orchStore.AddTask(ctx, store.AddTaskInput{
+ RunID: "run_web_001",
+ TaskID: "T1",
+ Title: "Implement read API",
+ Summary: "Expose run state over HTTP",
+ DefaultTo: "worker-a",
+ })
+ if err != nil {
+ t.Fatalf("add task T1: %v", err)
+ }
+
+ _, err = orchStore.AddTask(ctx, store.AddTaskInput{
+ RunID: "run_web_001",
+ TaskID: "T2",
+ Title: "Build React shell",
+ Summary: "Scaffold the frontend workspace",
+ DefaultTo: "worker-b",
+ })
+ if err != nil {
+ t.Fatalf("add task T2: %v", err)
+ }
+
+ dispatch, err := orchStore.DispatchTask(ctx, store.DispatchInput{
+ RunID: "run_web_001",
+ TaskID: "T1",
+ ToAgent: "worker-a",
+ Body: "Expose the initial HTTP API.",
+ })
+ if err != nil {
+ t.Fatalf("dispatch task: %v", err)
+ }
+
+ if _, err := inboxStore.ClaimThread(ctx, store.ClaimInput{
+ ThreadID: dispatch.Attempt.ThreadID,
+ Agent: "worker-a",
+ LeaseSeconds: 300,
+ }); err != nil {
+ t.Fatalf("claim thread: %v", err)
+ }
+
+ if _, _, err := inboxStore.UpdateThreadStatus(ctx, store.UpdateInput{
+ ThreadID: dispatch.Attempt.ThreadID,
+ Agent: "worker-a",
+ Status: "blocked",
+ Summary: "Need the API shape",
+ Body: "Confirm whether run detail should include blocked tasks.",
+ }); err != nil {
+ t.Fatalf("mark thread blocked: %v", err)
+ }
+
+ if _, err := orchStore.ReconcileRun(ctx, "run_web_001"); err != nil {
+ t.Fatalf("reconcile run: %v", err)
+ }
+
+ handler := NewRouter(app.NewWebService(sqlDB))
+
+ assertStatusAndJSONField(t, handler, "/health", http.StatusOK, []string{"status"}, "ok")
+ assertStatusAndJSONField(t, handler, "/api/runs", http.StatusOK, []string{"runs", "0", "run", "run_id"}, "run_web_001")
+ assertStatusAndJSONField(t, handler, "/api/runs/run_web_001", http.StatusOK, []string{"run", "run", "run_id"}, "run_web_001")
+ assertStatusAndJSONField(t, handler, "/api/runs/run_web_001/tasks", http.StatusOK, []string{"tasks", "0", "task_id"}, "T1")
+ assertStatusAndJSONField(t, handler, "/api/runs/run_web_001/blocked", http.StatusOK, []string{"blocked", "0", "task", "task_id"}, "T1")
+ assertStatusAndJSONField(t, handler, "/api/threads/"+dispatch.Attempt.ThreadID, http.StatusOK, []string{"thread", "thread", "thread_id"}, dispatch.Attempt.ThreadID)
+}
+
+func TestRouterMapsNotFoundErrors(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ dbPath := filepath.Join(t.TempDir(), "coord.db")
+ sqlDB, err := dbpkg.Open(ctx, dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer sqlDB.Close()
+
+ if err := dbpkg.ApplyMigrations(ctx, sqlDB); err != nil {
+ t.Fatalf("apply migrations: %v", err)
+ }
+
+ handler := NewRouter(app.NewWebService(sqlDB))
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/runs/missing-run", nil)
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rec.Code)
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+
+ code := nestedString(t, payload, "error", "code")
+ if code != "not_found" {
+ t.Fatalf("expected not_found error code, got %q", code)
+ }
+}
+
+func assertStatusAndJSONField(t *testing.T, handler http.Handler, path string, wantStatus int, fieldPath []string, want string) {
+ t.Helper()
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != wantStatus {
+ t.Fatalf("GET %s: expected status %d, got %d", path, wantStatus, rec.Code)
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
+ t.Fatalf("GET %s: decode response: %v", path, err)
+ }
+
+ got := nestedString(t, payload, fieldPath...)
+ if got != want {
+ t.Fatalf("GET %s: expected %q at %v, got %q", path, want, fieldPath, got)
+ }
+}
+
+func nestedString(t *testing.T, value any, path ...string) string {
+ t.Helper()
+
+ current := value
+ for _, part := range path {
+ switch typed := current.(type) {
+ case map[string]any:
+ current = typed[part]
+ case []any:
+ if len(part) != 1 || part[0] < '0' || part[0] > '9' {
+ t.Fatalf("path segment %q is not a numeric index", part)
+ }
+ index := int(part[0] - '0')
+ if index >= len(typed) {
+ t.Fatalf("index %d out of range for path %v", index, path)
+ }
+ current = typed[index]
+ default:
+ t.Fatalf("unsupported type %T at path %v", current, path)
+ }
+ }
+
+ got, ok := current.(string)
+ if !ok {
+ t.Fatalf("expected string at path %v, got %T", path, current)
+ }
+ return got
+}
diff --git a/internal/query/read_service.go b/internal/query/read_service.go
new file mode 100644
index 0000000..4abb5eb
--- /dev/null
+++ b/internal/query/read_service.go
@@ -0,0 +1,211 @@
+package query
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ "ai-workflow-skill/internal/store"
+)
+
+type ReadService struct {
+ db *sql.DB
+ orch *store.OrchStore
+ inbox *store.InboxStore
+}
+
+type RunListItem struct {
+ Run store.Run `json:"run"`
+ TaskCounts map[string]int `json:"task_counts"`
+ TotalTasks int `json:"total_tasks"`
+}
+
+type RunDetail struct {
+ Run store.Run `json:"run"`
+ TaskCounts map[string]int `json:"task_counts"`
+ TotalTasks int `json:"total_tasks"`
+ Tasks []store.Task `json:"tasks"`
+ BlockedTasks []store.BlockedTask `json:"blocked_tasks"`
+}
+
+func NewReadService(db *sql.DB) *ReadService {
+ return &ReadService{
+ db: db,
+ orch: store.NewOrchStore(db),
+ inbox: store.NewInboxStore(db),
+ }
+}
+
+func (s *ReadService) ListRuns(ctx context.Context) ([]RunListItem, error) {
+ rows, err := s.db.QueryContext(
+ ctx,
+ `SELECT run_id, goal, summary, status, created_at, updated_at
+ FROM runs
+ ORDER BY updated_at DESC, created_at DESC`,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("query runs: %w", err)
+ }
+ defer rows.Close()
+
+ var runs []store.Run
+ runIDs := make([]string, 0)
+ for rows.Next() {
+ var (
+ run store.Run
+ createdAt, updated string
+ )
+ if err := rows.Scan(
+ &run.RunID,
+ &run.Goal,
+ &run.Summary,
+ &run.Status,
+ &createdAt,
+ &updated,
+ ); err != nil {
+ return nil, fmt.Errorf("scan run list row: %w", err)
+ }
+
+ run.CreatedAt = parseRFC3339(createdAt)
+ run.UpdatedAt = parseRFC3339(updated)
+ runs = append(runs, run)
+ runIDs = append(runIDs, run.RunID)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate runs: %w", err)
+ }
+
+ countsByRunID, err := s.collectTaskCounts(ctx, runIDs)
+ if err != nil {
+ return nil, err
+ }
+
+ items := make([]RunListItem, 0, len(runs))
+ for _, run := range runs {
+ taskCounts := countsByRunID[run.RunID]
+ if taskCounts == nil {
+ taskCounts = map[string]int{}
+ }
+
+ items = append(items, RunListItem{
+ Run: run,
+ TaskCounts: taskCounts,
+ TotalTasks: totalTasks(taskCounts),
+ })
+ }
+
+ return items, nil
+}
+
+func (s *ReadService) GetRunDetail(ctx context.Context, runID string) (RunDetail, error) {
+ overview, err := s.orch.GetRunOverview(ctx, runID)
+ if err != nil {
+ return RunDetail{}, err
+ }
+
+ blocked, err := s.orch.ListBlockedTasks(ctx, runID)
+ if err != nil {
+ return RunDetail{}, err
+ }
+
+ return RunDetail{
+ Run: overview.Run,
+ TaskCounts: overview.TaskCounts,
+ TotalTasks: totalTasks(overview.TaskCounts),
+ Tasks: overview.Tasks,
+ BlockedTasks: blocked,
+ }, nil
+}
+
+func (s *ReadService) ListRunTasks(ctx context.Context, runID string) ([]store.Task, error) {
+ detail, err := s.GetRunDetail(ctx, runID)
+ if err != nil {
+ return nil, err
+ }
+ return detail.Tasks, nil
+}
+
+func (s *ReadService) ListBlockedTasks(ctx context.Context, runID string) ([]store.BlockedTask, error) {
+ return s.orch.ListBlockedTasks(ctx, runID)
+}
+
+func (s *ReadService) GetThreadDetail(ctx context.Context, threadID string) (store.ThreadDetail, error) {
+ return s.inbox.GetThread(ctx, threadID)
+}
+
+func (s *ReadService) collectTaskCounts(ctx context.Context, runIDs []string) (map[string]map[string]int, error) {
+ result := make(map[string]map[string]int, len(runIDs))
+ if len(runIDs) == 0 {
+ return result, nil
+ }
+
+ args := make([]any, 0, len(runIDs))
+ for _, runID := range runIDs {
+ args = append(args, runID)
+ }
+
+ rows, err := s.db.QueryContext(
+ ctx,
+ `SELECT run_id, status, COUNT(*)
+ FROM tasks
+ WHERE run_id IN (`+placeholders(len(runIDs))+`)
+ GROUP BY run_id, status`,
+ args...,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("query task counts for runs: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var (
+ runID string
+ status string
+ count int
+ )
+ if err := rows.Scan(&runID, &status, &count); err != nil {
+ return nil, fmt.Errorf("scan run task count: %w", err)
+ }
+ if result[runID] == nil {
+ result[runID] = make(map[string]int)
+ }
+ result[runID][status] = count
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate run task counts: %w", err)
+ }
+
+ return result, nil
+}
+
+func totalTasks(counts map[string]int) int {
+ total := 0
+ for _, count := range counts {
+ total += count
+ }
+ return total
+}
+
+func placeholders(count int) string {
+ if count <= 0 {
+ return ""
+ }
+
+ buf := make([]byte, 0, count*2-1)
+ for i := 0; i < count; i++ {
+ if i > 0 {
+ buf = append(buf, ',')
+ }
+ buf = append(buf, '?')
+ }
+ return string(buf)
+}
+
+func parseRFC3339(value string) time.Time {
+ parsed, err := time.Parse(time.RFC3339Nano, value)
+ if err != nil {
+ return time.Time{}
+ }
+ return parsed
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..058c1a9
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "ai-workflow-skill",
+ "private": true,
+ "packageManager": "pnpm@10.25.0",
+ "scripts": {
+ "web:dev": "pnpm --filter @ai-workflow-skill/web dev",
+ "web:build": "pnpm --filter @ai-workflow-skill/web build",
+ "web:preview": "pnpm --filter @ai-workflow-skill/web preview",
+ "web:typecheck": "pnpm --filter @ai-workflow-skill/web typecheck"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..d728640
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,697 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .: {}
+
+ apps/web:
+ dependencies:
+ '@tanstack/react-query':
+ specifier: ^5.91.2
+ version: 5.91.2(react@19.2.4)
+ '@tanstack/react-router':
+ specifier: ^1.167.5
+ version: 1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react:
+ specifier: ^19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.2.4
+ version: 19.2.4(react@19.2.4)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.1)
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^8.0.1
+ version: 8.0.1
+
+packages:
+
+ '@emnapi/core@1.9.1':
+ resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
+
+ '@emnapi/runtime@1.9.1':
+ resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==}
+
+ '@emnapi/wasi-threads@1.2.0':
+ resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
+
+ '@napi-rs/wasm-runtime@1.1.1':
+ resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
+
+ '@oxc-project/types@0.120.0':
+ resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==}
+
+ '@rolldown/binding-android-arm64@1.0.0-rc.10':
+ resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.10':
+ resolution: {integrity: sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.0.0-rc.10':
+ resolution: {integrity: sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.10':
+ resolution: {integrity: sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10':
+ resolution: {integrity: sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10':
+ resolution: {integrity: sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10':
+ resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10':
+ resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10':
+ resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10':
+ resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.10':
+ resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.10':
+ resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.10':
+ resolution: {integrity: sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10':
+ resolution: {integrity: sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10':
+ resolution: {integrity: sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.0-rc.10':
+ resolution: {integrity: sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==}
+
+ '@rolldown/pluginutils@1.0.0-rc.7':
+ resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
+
+ '@tanstack/history@1.161.6':
+ resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
+ engines: {node: '>=20.19'}
+
+ '@tanstack/query-core@5.91.2':
+ resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==}
+
+ '@tanstack/react-query@5.91.2':
+ resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==}
+ peerDependencies:
+ react: ^18 || ^19
+
+ '@tanstack/react-router@1.167.5':
+ resolution: {integrity: sha512-s1nP6l/7BYZfSwhoNbB7/rUmZ07q/AvkmhBoiDQl3tgy5dpb9Q1qjtIapYdvCOrao1aA/QCaWqxcbGc2Ct1bvQ==}
+ engines: {node: '>=20.19'}
+ peerDependencies:
+ react: '>=18.0.0 || >=19.0.0'
+ react-dom: '>=18.0.0 || >=19.0.0'
+
+ '@tanstack/react-store@0.9.2':
+ resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tanstack/router-core@1.167.5':
+ resolution: {integrity: sha512-8fRgJ0zNJf77R4grCaJQ5Imatjyc4YT5v8rlsPkYYYeUlcFNLbuFRhLlAMdND9gRUMznpnbRDXngpTPgx2K7HQ==}
+ engines: {node: '>=20.19'}
+ hasBin: true
+
+ '@tanstack/store@0.9.2':
+ resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==}
+
+ '@tybys/wasm-util@0.10.1':
+ resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.14':
+ resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+
+ '@vitejs/plugin-react@6.0.1':
+ resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+
+ cookie-es@2.0.0:
+ resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ isbot@5.1.36:
+ resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
+ engines: {node: '>=18'}
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.3:
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.8:
+ resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ react-dom@19.2.4:
+ resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+ peerDependencies:
+ react: ^19.2.4
+
+ react@19.2.4:
+ resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ engines: {node: '>=0.10.0'}
+
+ rolldown@1.0.0-rc.10:
+ resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ seroval-plugins@1.5.1:
+ resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
+ seroval@1.5.1:
+ resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
+ engines: {node: '>=10'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
+ tiny-warning@1.0.3:
+ resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
+
+ tinyglobby@0.2.15:
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ engines: {node: '>=12.0.0'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ vite@8.0.1:
+ resolution: {integrity: sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.0
+ esbuild: ^0.27.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+snapshots:
+
+ '@emnapi/core@1.9.1':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.0
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.9.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@napi-rs/wasm-runtime@1.1.1':
+ dependencies:
+ '@emnapi/core': 1.9.1
+ '@emnapi/runtime': 1.9.1
+ '@tybys/wasm-util': 0.10.1
+ optional: true
+
+ '@oxc-project/types@0.120.0': {}
+
+ '@rolldown/binding-android-arm64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.10':
+ dependencies:
+ '@napi-rs/wasm-runtime': 1.1.1
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.0-rc.10': {}
+
+ '@rolldown/pluginutils@1.0.0-rc.7': {}
+
+ '@tanstack/history@1.161.6': {}
+
+ '@tanstack/query-core@5.91.2': {}
+
+ '@tanstack/react-query@5.91.2(react@19.2.4)':
+ dependencies:
+ '@tanstack/query-core': 5.91.2
+ react: 19.2.4
+
+ '@tanstack/react-router@1.167.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@tanstack/history': 1.161.6
+ '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@tanstack/router-core': 1.167.5
+ isbot: 5.1.36
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ tiny-invariant: 1.3.3
+ tiny-warning: 1.0.3
+
+ '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@tanstack/store': 0.9.2
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ use-sync-external-store: 1.6.0(react@19.2.4)
+
+ '@tanstack/router-core@1.167.5':
+ dependencies:
+ '@tanstack/history': 1.161.6
+ '@tanstack/store': 0.9.2
+ cookie-es: 2.0.0
+ seroval: 1.5.1
+ seroval-plugins: 1.5.1(seroval@1.5.1)
+ tiny-invariant: 1.3.3
+ tiny-warning: 1.0.3
+
+ '@tanstack/store@0.9.2': {}
+
+ '@tybys/wasm-util@0.10.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/react-dom@19.2.3(@types/react@19.2.14)':
+ dependencies:
+ '@types/react': 19.2.14
+
+ '@types/react@19.2.14':
+ dependencies:
+ csstype: 3.2.3
+
+ '@vitejs/plugin-react@6.0.1(vite@8.0.1)':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.0-rc.7
+ vite: 8.0.1
+
+ cookie-es@2.0.0: {}
+
+ csstype@3.2.3: {}
+
+ detect-libc@2.1.2: {}
+
+ fdir@6.5.0(picomatch@4.0.3):
+ optionalDependencies:
+ picomatch: 4.0.3
+
+ fsevents@2.3.3:
+ optional: true
+
+ isbot@5.1.36: {}
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ nanoid@3.3.11: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.3: {}
+
+ postcss@8.5.8:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ react-dom@19.2.4(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ scheduler: 0.27.0
+
+ react@19.2.4: {}
+
+ rolldown@1.0.0-rc.10:
+ dependencies:
+ '@oxc-project/types': 0.120.0
+ '@rolldown/pluginutils': 1.0.0-rc.10
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.0.0-rc.10
+ '@rolldown/binding-darwin-arm64': 1.0.0-rc.10
+ '@rolldown/binding-darwin-x64': 1.0.0-rc.10
+ '@rolldown/binding-freebsd-x64': 1.0.0-rc.10
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.10
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.10
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.10
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.10
+ '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.10
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.10
+ '@rolldown/binding-linux-x64-musl': 1.0.0-rc.10
+ '@rolldown/binding-openharmony-arm64': 1.0.0-rc.10
+ '@rolldown/binding-wasm32-wasi': 1.0.0-rc.10
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.10
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.10
+
+ scheduler@0.27.0: {}
+
+ seroval-plugins@1.5.1(seroval@1.5.1):
+ dependencies:
+ seroval: 1.5.1
+
+ seroval@1.5.1: {}
+
+ source-map-js@1.2.1: {}
+
+ tiny-invariant@1.3.3: {}
+
+ tiny-warning@1.0.3: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ tslib@2.8.1:
+ optional: true
+
+ typescript@5.9.3: {}
+
+ use-sync-external-store@1.6.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
+ vite@8.0.1:
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.3
+ postcss: 8.5.8
+ rolldown: 1.0.0-rc.10
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ fsevents: 2.3.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..852bf6b
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+packages:
+ - apps/*