# Phase 1: Core Skeleton — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the foundational Project-Manager Next.js app with auth, project CRUD, auto-scan of `/data/video-pipeline/LXC4103/project/`, and a dashboard listing all detected projects.

**Architecture:** Next.js 15 App Router (TypeScript) + Prisma + SQLite + bcrypt-hashed single password + JWT session cookie. UI uses shadcn/ui + Tailwind, dark mode default. No queue/Redis/SSE/Docker yet — those come in Phase 2 and 3.

**Tech Stack:** Next.js 15, TypeScript, Prisma 5, SQLite, bcryptjs, jose (JWT), shadcn/ui, Tailwind CSS, Vitest, Playwright, simple-git.

**Working Artifact After Phase 1:**
- Login page with single password.
- Dashboard listing all auto-scanned projects from `/data/video-pipeline/LXC4103/project/`.
- Project detail page showing name, path, git remote, branch, version.
- Manual "Add Project" form (path → scan → save).
- All tests passing, deployable as `npm run build && npm start`.

---

## File Structure

```
project-manager/
├── package.json
├── tsconfig.json
├── next.config.mjs
├── tailwind.config.ts
├── postcss.config.mjs
├── vitest.config.ts
├── playwright.config.ts
├── .env.example
├── .gitignore
├── prisma/
│   ├── schema.prisma
│   └── migrations/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx                       # dashboard
│   │   ├── globals.css
│   │   ├── login/page.tsx
│   │   ├── projects/[id]/page.tsx
│   │   ├── projects/new/page.tsx
│   │   └── api/
│   │       ├── auth/login/route.ts
│   │       ├── auth/logout/route.ts
│   │       ├── projects/route.ts          # GET list, POST create
│   │       ├── projects/[id]/route.ts     # GET / PATCH / DELETE
│   │       └── scan/route.ts              # POST trigger scan
│   ├── lib/
│   │   ├── db.ts                          # Prisma singleton
│   │   ├── auth/
│   │   │   ├── password.ts                # bcrypt hash/verify
│   │   │   ├── session.ts                 # JWT sign/verify
│   │   │   └── middleware.ts              # auth guard helper
│   │   ├── git/
│   │   │   └── scanner.ts                 # read git state from a path
│   │   ├── scanner/
│   │   │   └── auto-scan.ts               # walk dir, detect projects
│   │   └── settings.ts                    # get/set Setting key
│   ├── components/
│   │   ├── ui/                            # shadcn components
│   │   ├── project-card.tsx
│   │   └── nav.tsx
│   └── middleware.ts                      # Next middleware: auth gate
├── tests/
│   ├── unit/
│   │   ├── password.test.ts
│   │   ├── session.test.ts
│   │   ├── git-scanner.test.ts
│   │   └── auto-scan.test.ts
│   ├── integration/
│   │   ├── auth.api.test.ts
│   │   └── projects.api.test.ts
│   └── e2e/
│       └── login-and-dashboard.spec.ts
└── scripts/
    └── seed-password.ts                   # CLI: set initial password
```

---

## Task 1: Project Bootstrap

**Files:**
- Create: `package.json`, `tsconfig.json`, `next.config.mjs`, `.gitignore`, `.env.example`

- [ ] **Step 1: Initialize Next.js with TypeScript**

Run from `/data/video-pipeline/LXC4103/project/project-manager`:

```bash
npm init -y
npm install next@15 react@19 react-dom@19 typescript @types/node @types/react @types/react-dom
```

- [ ] **Step 2: Write `package.json` scripts and dependencies**

```json
{
  "name": "project-manager",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3000",
    "build": "next build",
    "start": "next start -p 3000",
    "lint": "next lint",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "playwright test",
    "db:migrate": "prisma migrate dev",
    "db:generate": "prisma generate",
    "db:push": "prisma db push",
    "seed:password": "tsx scripts/seed-password.ts"
  },
  "dependencies": {
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "@prisma/client": "^5.22.0",
    "bcryptjs": "^2.4.3",
    "jose": "^5.9.0",
    "simple-git": "^3.27.0",
    "zod": "^3.23.0",
    "clsx": "^2.1.0",
    "tailwind-merge": "^2.5.0",
    "lucide-react": "^0.468.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@types/bcryptjs": "^2.4.6",
    "typescript": "^5.6.0",
    "prisma": "^5.22.0",
    "tsx": "^4.19.0",
    "tailwindcss": "^3.4.0",
    "postcss": "^8.4.0",
    "autoprefixer": "^10.4.0",
    "vitest": "^2.1.0",
    "@vitest/ui": "^2.1.0",
    "supertest": "^7.0.0",
    "@types/supertest": "^6.0.0",
    "@playwright/test": "^1.48.0",
    "eslint": "^8.57.0",
    "eslint-config-next": "^15.0.0"
  }
}
```

Then run:
```bash
npm install
```

- [ ] **Step 3: Write `tsconfig.json`**

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "ES2022"],
    "allowJs": false,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
```

- [ ] **Step 4: Write `next.config.mjs`**

```js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  experimental: { serverActions: { allowedOrigins: ['localhost:3000', '192.168.40.20:3000'] } }
};
export default nextConfig;
```

- [ ] **Step 5: Write `.gitignore`**

```
node_modules/
.next/
out/
.env
.env.local
*.db
*.db-journal
prisma/dev.db*
test-results/
playwright-report/
playwright/.cache/
coverage/
```

- [ ] **Step 6: Write `.env.example`**

```
DATABASE_URL="file:./dev.db"
PM_SESSION_SECRET="change-me-32-byte-hex"
PM_ENCRYPTION_KEY="change-me-32-byte-hex"
PM_WEBHOOK_TOKEN="change-me-random-32-char"
PM_SCAN_PATH="/data/video-pipeline/LXC4103/project"
```

- [ ] **Step 7: Verify build setup**

```bash
mkdir -p src/app
cat > src/app/page.tsx <<'EOF'
export default function Home() { return <main>PM bootstrap</main>; }
EOF
cat > src/app/layout.tsx <<'EOF'
export default function Root({ children }: { children: React.ReactNode }) {
  return <html><body>{children}</body></html>;
}
EOF
npm run build
```

Expected: build succeeds, prints "Compiled successfully".

- [ ] **Step 8: Commit**

```bash
git add .
git commit -m "feat(phase1): bootstrap Next.js + TypeScript project"
```

---

## Task 2: Tailwind + shadcn/ui Setup

**Files:**
- Create: `tailwind.config.ts`, `postcss.config.mjs`, `src/app/globals.css`, `src/lib/utils.ts`

- [ ] **Step 1: Init Tailwind**

```bash
npx tailwindcss init -p
```

- [ ] **Step 2: Write `tailwind.config.ts`**

```ts
import type { Config } from "tailwindcss";

const config: Config = {
  darkMode: ["class"],
  content: ["./src/**/*.{ts,tsx}"],
  theme: {
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        secondary: {
          DEFAULT: "hsl(var(--secondary))",
          foreground: "hsl(var(--secondary-foreground))",
        },
        destructive: {
          DEFAULT: "hsl(var(--destructive))",
          foreground: "hsl(var(--destructive-foreground))",
        },
        muted: {
          DEFAULT: "hsl(var(--muted))",
          foreground: "hsl(var(--muted-foreground))",
        },
        accent: {
          DEFAULT: "hsl(var(--accent))",
          foreground: "hsl(var(--accent-foreground))",
        },
        card: {
          DEFAULT: "hsl(var(--card))",
          foreground: "hsl(var(--card-foreground))",
        },
      },
      borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)" },
    },
  },
  plugins: [],
};
export default config;
```

- [ ] **Step 3: Write `src/app/globals.css`**

```css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 240 10% 4%;
    --foreground: 0 0% 98%;
    --card: 240 10% 6%;
    --card-foreground: 0 0% 98%;
    --primary: 217 91% 60%;
    --primary-foreground: 0 0% 100%;
    --secondary: 240 4% 16%;
    --secondary-foreground: 0 0% 98%;
    --muted: 240 4% 16%;
    --muted-foreground: 240 5% 65%;
    --accent: 240 4% 16%;
    --accent-foreground: 0 0% 98%;
    --destructive: 0 63% 50%;
    --destructive-foreground: 0 0% 98%;
    --border: 240 4% 16%;
    --input: 240 4% 16%;
    --ring: 217 91% 60%;
    --radius: 0.5rem;
  }
  body { @apply bg-background text-foreground; }
  html { @apply dark; }
}
```

- [ ] **Step 4: Update `src/app/layout.tsx`**

```tsx
import "./globals.css";
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Project Manager",
  description: "Internal project tracker",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className="dark">
      <body className="min-h-screen bg-background text-foreground antialiased">
        {children}
      </body>
    </html>
  );
}
```

- [ ] **Step 5: Write `src/lib/utils.ts`**

```ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

- [ ] **Step 6: Add minimal shadcn primitives (button, card, input, label) inline**

Create `src/components/ui/button.tsx`:

```tsx
import * as React from "react";
import { cn } from "@/lib/utils";

export const Button = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "outline" | "ghost" | "destructive" }
>(({ className, variant = "default", ...props }, ref) => {
  const variants = {
    default: "bg-primary text-primary-foreground hover:bg-primary/90",
    outline: "border border-input bg-transparent hover:bg-accent",
    ghost: "hover:bg-accent",
    destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
  };
  return (
    <button
      ref={ref}
      className={cn(
        "inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 transition-colors disabled:opacity-50",
        variants[variant],
        className
      )}
      {...props}
    />
  );
});
Button.displayName = "Button";
```

Create `src/components/ui/input.tsx`:

```tsx
import * as React from "react";
import { cn } from "@/lib/utils";

export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
  ({ className, ...props }, ref) => (
    <input
      ref={ref}
      className={cn(
        "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm",
        "placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
        "disabled:cursor-not-allowed disabled:opacity-50",
        className
      )}
      {...props}
    />
  )
);
Input.displayName = "Input";
```

Create `src/components/ui/card.tsx`:

```tsx
import * as React from "react";
import { cn } from "@/lib/utils";

export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  return <div className={cn("rounded-lg border bg-card text-card-foreground shadow", className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  return <div className={cn("p-6 pt-0", className)} {...props} />;
}
```

Create `src/components/ui/label.tsx`:

```tsx
import * as React from "react";
import { cn } from "@/lib/utils";

export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
  ({ className, ...props }, ref) => (
    <label ref={ref} className={cn("text-sm font-medium leading-none", className)} {...props} />
  )
);
Label.displayName = "Label";
```

- [ ] **Step 7: Smoke test build**

```bash
npm run build
```

Expected: build succeeds.

- [ ] **Step 8: Commit**

```bash
git add .
git commit -m "feat(phase1): add Tailwind + shadcn primitives, dark mode"
```

---

## Task 3: Prisma Schema + Migration (Phase 1 subset)

**Files:**
- Create: `prisma/schema.prisma`, `src/lib/db.ts`

- [ ] **Step 1: Write `prisma/schema.prisma` (Phase 1 subset only)**

```prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Project {
  id                String   @id @default(cuid())
  name              String   @unique
  path              String
  source            String   // "auto-scan" | "manual"
  gitRemote         String?
  branch            String?
  version           String?
  lastCommitHash    String?
  lastCommitMessage String?
  lastCommitTime    DateTime?
  notes             String?
  tags              String?
  priority          Int      @default(0)
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt
}

model Setting {
  key   String @id
  value String
}
```

> Note: full schema (Tasks, LogEntry, DeployTarget, etc.) is added in Phase 2/3. Keep Phase 1 minimal.

- [ ] **Step 2: Initial migration**

```bash
echo 'DATABASE_URL="file:./dev.db"' > .env
echo 'PM_SESSION_SECRET="'$(openssl rand -hex 32)'"' >> .env
npx prisma migrate dev --name init
```

Expected: creates `prisma/migrations/<timestamp>_init/migration.sql` and `prisma/dev.db`.

- [ ] **Step 3: Write `src/lib/db.ts` (Prisma singleton)**

```ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
```

- [ ] **Step 4: Verify Prisma client generates**

```bash
npx prisma generate
```

Expected: "Generated Prisma Client".

- [ ] **Step 5: Commit**

```bash
git add prisma/ src/lib/db.ts .env.example
git commit -m "feat(phase1): prisma schema + initial migration"
```

> Do NOT commit `.env` — it has secrets.

---

## Task 4: Vitest Setup

**Files:**
- Create: `vitest.config.ts`, `tests/setup.ts`

- [ ] **Step 1: Write `vitest.config.ts`**

```ts
import { defineConfig } from "vitest/config";
import path from "path";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    include: ["tests/**/*.test.ts"],
    setupFiles: ["./tests/setup.ts"],
    poolOptions: { threads: { singleThread: true } },
  },
  resolve: {
    alias: { "@": path.resolve(__dirname, "./src") },
  },
});
```

- [ ] **Step 2: Write `tests/setup.ts`**

```ts
import { beforeAll } from "vitest";

beforeAll(() => {
  process.env.PM_SESSION_SECRET = "test-secret-must-be-at-least-32-bytes-long-xx";
  process.env.DATABASE_URL = "file:./test.db";
});
```

- [ ] **Step 3: Sanity test**

Create `tests/unit/sanity.test.ts`:

```ts
import { describe, it, expect } from "vitest";

describe("sanity", () => {
  it("runs", () => {
    expect(1 + 1).toBe(2);
  });
});
```

Run:

```bash
npm test
```

Expected: 1 passed.

- [ ] **Step 4: Commit**

```bash
git add vitest.config.ts tests/
git commit -m "feat(phase1): vitest setup with sanity test"
```

---

## Task 5: Password Hashing (TDD)

**Files:**
- Create: `src/lib/auth/password.ts`
- Test: `tests/unit/password.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// tests/unit/password.test.ts
import { describe, it, expect } from "vitest";
import { hashPassword, verifyPassword } from "@/lib/auth/password";

describe("password", () => {
  it("hashes a password to a non-empty string different from the input", async () => {
    const hash = await hashPassword("secret123");
    expect(hash).toBeTypeOf("string");
    expect(hash.length).toBeGreaterThan(20);
    expect(hash).not.toBe("secret123");
  });

  it("verifies a correct password", async () => {
    const hash = await hashPassword("secret123");
    expect(await verifyPassword("secret123", hash)).toBe(true);
  });

  it("rejects an incorrect password", async () => {
    const hash = await hashPassword("secret123");
    expect(await verifyPassword("wrong", hash)).toBe(false);
  });
});
```

- [ ] **Step 2: Run test, verify it fails**

```bash
npm test -- password
```

Expected: FAIL — `Cannot find module '@/lib/auth/password'`.

- [ ] **Step 3: Write minimal implementation**

```ts
// src/lib/auth/password.ts
import bcrypt from "bcryptjs";

export async function hashPassword(plain: string): Promise<string> {
  return bcrypt.hash(plain, 12);
}

export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
  return bcrypt.compare(plain, hash);
}
```

- [ ] **Step 4: Run test, verify it passes**

```bash
npm test -- password
```

Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/lib/auth/password.ts tests/unit/password.test.ts
git commit -m "feat(phase1): bcrypt password hash + verify"
```

---

## Task 6: JWT Session (TDD)

**Files:**
- Create: `src/lib/auth/session.ts`
- Test: `tests/unit/session.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// tests/unit/session.test.ts
import { describe, it, expect } from "vitest";
import { signSession, verifySession } from "@/lib/auth/session";

describe("session", () => {
  it("signs a session token containing the subject", async () => {
    const token = await signSession({ sub: "admin" });
    expect(token).toBeTypeOf("string");
    expect(token.split(".")).toHaveLength(3); // JWT has 3 parts
  });

  it("verifies a signed token and returns the payload", async () => {
    const token = await signSession({ sub: "admin" });
    const payload = await verifySession(token);
    expect(payload?.sub).toBe("admin");
  });

  it("returns null for tampered token", async () => {
    const token = await signSession({ sub: "admin" });
    const tampered = token.slice(0, -1) + (token.slice(-1) === "a" ? "b" : "a");
    const payload = await verifySession(tampered);
    expect(payload).toBeNull();
  });
});
```

- [ ] **Step 2: Run test, verify it fails**

```bash
npm test -- session
```

Expected: FAIL — module not found.

- [ ] **Step 3: Write implementation**

```ts
// src/lib/auth/session.ts
import { SignJWT, jwtVerify } from "jose";

const SECRET = new TextEncoder().encode(process.env.PM_SESSION_SECRET ?? "");

export interface SessionPayload {
  sub: string;
  iat?: number;
  exp?: number;
}

export async function signSession(payload: { sub: string }): Promise<string> {
  return await new SignJWT({ sub: payload.sub })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("7d")
    .sign(SECRET);
}

export async function verifySession(token: string): Promise<SessionPayload | null> {
  try {
    const { payload } = await jwtVerify(token, SECRET);
    if (typeof payload.sub !== "string") return null;
    return payload as SessionPayload;
  } catch {
    return null;
  }
}
```

- [ ] **Step 4: Run test, verify it passes**

```bash
npm test -- session
```

Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/lib/auth/session.ts tests/unit/session.test.ts
git commit -m "feat(phase1): JWT session sign + verify"
```

---

## Task 7: Git Scanner (TDD)

**Files:**
- Create: `src/lib/git/scanner.ts`
- Test: `tests/unit/git-scanner.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// tests/unit/git-scanner.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { readGitState } from "@/lib/git/scanner";

let repoDir: string;

beforeAll(() => {
  repoDir = mkdtempSync(join(tmpdir(), "pm-git-"));
  execSync("git init -b main", { cwd: repoDir });
  execSync("git config user.email t@t.io", { cwd: repoDir });
  execSync("git config user.name tester", { cwd: repoDir });
  writeFileSync(join(repoDir, "package.json"), JSON.stringify({ name: "demo", version: "1.2.3" }));
  execSync("git add . && git commit -m 'init commit'", { cwd: repoDir });
  execSync("git remote add origin https://example.com/demo.git", { cwd: repoDir });
});

afterAll(() => {
  rmSync(repoDir, { recursive: true, force: true });
});

describe("readGitState", () => {
  it("reads branch, remote, last commit, version from package.json", async () => {
    const state = await readGitState(repoDir);
    expect(state.branch).toBe("main");
    expect(state.gitRemote).toBe("https://example.com/demo.git");
    expect(state.lastCommitHash).toMatch(/^[0-9a-f]{40}$/);
    expect(state.lastCommitMessage).toContain("init commit");
    expect(state.version).toBe("1.2.3");
    expect(state.lastCommitTime).toBeInstanceOf(Date);
  });

  it("returns null fields for non-git directory", async () => {
    const empty = mkdtempSync(join(tmpdir(), "pm-empty-"));
    const state = await readGitState(empty);
    expect(state.branch).toBeNull();
    expect(state.lastCommitHash).toBeNull();
    rmSync(empty, { recursive: true, force: true });
  });

  it("reads version from pyproject.toml when no package.json", async () => {
    const py = mkdtempSync(join(tmpdir(), "pm-py-"));
    execSync("git init -b main", { cwd: py });
    execSync("git config user.email t@t.io", { cwd: py });
    execSync("git config user.name tester", { cwd: py });
    writeFileSync(join(py, "pyproject.toml"), '[project]\nname = "x"\nversion = "9.9.9"\n');
    execSync("git add . && git commit -m init", { cwd: py });
    const state = await readGitState(py);
    expect(state.version).toBe("9.9.9");
    rmSync(py, { recursive: true, force: true });
  });
});
```

- [ ] **Step 2: Run test, verify it fails**

```bash
npm test -- git-scanner
```

Expected: FAIL — module not found.

- [ ] **Step 3: Write implementation**

```ts
// src/lib/git/scanner.ts
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import simpleGit from "simple-git";

export interface GitState {
  branch: string | null;
  gitRemote: string | null;
  lastCommitHash: string | null;
  lastCommitMessage: string | null;
  lastCommitTime: Date | null;
  version: string | null;
}

async function fileExists(p: string): Promise<boolean> {
  try {
    await stat(p);
    return true;
  } catch {
    return false;
  }
}

async function readVersion(repoPath: string): Promise<string | null> {
  const pkgJsonPath = join(repoPath, "package.json");
  if (await fileExists(pkgJsonPath)) {
    try {
      const pkg = JSON.parse(await readFile(pkgJsonPath, "utf8"));
      if (typeof pkg.version === "string") return pkg.version;
    } catch {
      /* ignore */
    }
  }
  const pyProjPath = join(repoPath, "pyproject.toml");
  if (await fileExists(pyProjPath)) {
    const content = await readFile(pyProjPath, "utf8");
    const match = content.match(/^version\s*=\s*"([^"]+)"/m);
    if (match) return match[1];
  }
  return null;
}

export async function readGitState(repoPath: string): Promise<GitState> {
  const isGit = await fileExists(join(repoPath, ".git"));
  if (!isGit) {
    return { branch: null, gitRemote: null, lastCommitHash: null, lastCommitMessage: null, lastCommitTime: null, version: await readVersion(repoPath) };
  }
  const git = simpleGit(repoPath);
  const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
  let gitRemote: string | null = null;
  try {
    gitRemote = (await git.remote(["get-url", "origin"]) || "").toString().trim() || null;
  } catch {
    gitRemote = null;
  }
  const log = await git.log({ maxCount: 1 }).catch(() => null);
  const head = log?.latest ?? null;
  return {
    branch,
    gitRemote,
    lastCommitHash: head?.hash ?? null,
    lastCommitMessage: head?.message ?? null,
    lastCommitTime: head?.date ? new Date(head.date) : null,
    version: await readVersion(repoPath),
  };
}
```

- [ ] **Step 4: Run test, verify it passes**

```bash
npm test -- git-scanner
```

Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/lib/git/scanner.ts tests/unit/git-scanner.test.ts
git commit -m "feat(phase1): git state scanner (branch, remote, commit, version)"
```

---

## Task 8: Auto-Scan Directory (TDD)

**Files:**
- Create: `src/lib/scanner/auto-scan.ts`
- Test: `tests/unit/auto-scan.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// tests/unit/auto-scan.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { scanProjectsInDir } from "@/lib/scanner/auto-scan";

let root: string;

beforeAll(() => {
  root = mkdtempSync(join(tmpdir(), "pm-scan-"));
  for (const name of ["alpha", "beta"]) {
    const dir = join(root, name);
    mkdirSync(dir);
    execSync("git init -b main", { cwd: dir });
    execSync("git config user.email t@t.io", { cwd: dir });
    execSync("git config user.name tester", { cwd: dir });
    writeFileSync(join(dir, "package.json"), JSON.stringify({ name, version: "0.1.0" }));
    execSync("git add . && git commit -m 'init'", { cwd: dir });
  }
  // a non-git directory should be skipped
  mkdirSync(join(root, "not-a-repo"));
  writeFileSync(join(root, "not-a-repo", "readme.md"), "hi");
});

afterAll(() => {
  rmSync(root, { recursive: true, force: true });
});

describe("scanProjectsInDir", () => {
  it("finds only git repos under the root", async () => {
    const results = await scanProjectsInDir(root);
    const names = results.map((r) => r.name).sort();
    expect(names).toEqual(["alpha", "beta"]);
  });

  it("returns name + path + git state for each project", async () => {
    const results = await scanProjectsInDir(root);
    const alpha = results.find((r) => r.name === "alpha");
    expect(alpha).toBeDefined();
    expect(alpha!.path).toBe(join(root, "alpha"));
    expect(alpha!.branch).toBe("main");
    expect(alpha!.version).toBe("0.1.0");
    expect(alpha!.lastCommitHash).toMatch(/^[0-9a-f]{40}$/);
  });

  it("returns empty array if root does not exist", async () => {
    const results = await scanProjectsInDir("/nonexistent/path/xyz");
    expect(results).toEqual([]);
  });
});
```

- [ ] **Step 2: Run test, verify it fails**

```bash
npm test -- auto-scan
```

Expected: FAIL — module not found.

- [ ] **Step 3: Write implementation**

```ts
// src/lib/scanner/auto-scan.ts
import { readdir, stat } from "node:fs/promises";
import { join, basename } from "node:path";
import { readGitState, type GitState } from "@/lib/git/scanner";

export interface ScannedProject extends GitState {
  name: string;
  path: string;
}

async function dirExists(p: string): Promise<boolean> {
  try {
    const s = await stat(p);
    return s.isDirectory();
  } catch {
    return false;
  }
}

export async function scanProjectsInDir(rootPath: string): Promise<ScannedProject[]> {
  if (!(await dirExists(rootPath))) return [];
  const entries = await readdir(rootPath, { withFileTypes: true });
  const results: ScannedProject[] = [];
  for (const e of entries) {
    if (!e.isDirectory()) continue;
    const full = join(rootPath, e.name);
    const isGit = await dirExists(join(full, ".git"));
    if (!isGit) continue;
    const state = await readGitState(full);
    results.push({ name: basename(full), path: full, ...state });
  }
  return results;
}
```

- [ ] **Step 4: Run test, verify it passes**

```bash
npm test -- auto-scan
```

Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/lib/scanner/auto-scan.ts tests/unit/auto-scan.test.ts
git commit -m "feat(phase1): auto-scan directory for git repos"
```

---

## Task 9: Settings Helper

**Files:**
- Create: `src/lib/settings.ts`

- [ ] **Step 1: Write `src/lib/settings.ts`**

```ts
import { prisma } from "@/lib/db";

export async function getSetting(key: string): Promise<string | null> {
  const row = await prisma.setting.findUnique({ where: { key } });
  return row?.value ?? null;
}

export async function setSetting(key: string, value: string): Promise<void> {
  await prisma.setting.upsert({
    where: { key },
    update: { value },
    create: { key, value },
  });
}

export const SETTINGS = {
  PASSWORD_HASH: "auth.password_hash",
} as const;
```

- [ ] **Step 2: Smoke compile**

```bash
npx tsc --noEmit
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add src/lib/settings.ts
git commit -m "feat(phase1): settings key/value helper"
```

---

## Task 10: Seed Password Script

**Files:**
- Create: `scripts/seed-password.ts`

- [ ] **Step 1: Write the script**

```ts
// scripts/seed-password.ts
import { config } from "dotenv";
config();

import { hashPassword } from "../src/lib/auth/password";
import { setSetting, SETTINGS } from "../src/lib/settings";

async function main() {
  const pw = process.argv[2];
  if (!pw || pw.length < 6) {
    console.error("Usage: npm run seed:password -- <password (min 6 chars)>");
    process.exit(1);
  }
  const hash = await hashPassword(pw);
  await setSetting(SETTINGS.PASSWORD_HASH, hash);
  console.log("Password set.");
  process.exit(0);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
```

Add `dotenv` dep:

```bash
npm install dotenv
```

- [ ] **Step 2: Run it**

```bash
npm run seed:password -- pmpassword123
```

Expected: prints "Password set."

- [ ] **Step 3: Verify in DB**

```bash
npx prisma studio --browser none --port 5555 &
SLEEP=$!
sleep 2
sqlite3 prisma/dev.db "SELECT key, substr(value,1,10) FROM Setting;"
kill $SLEEP 2>/dev/null
```

Expected: row `auth.password_hash | $2a$12$...`.

- [ ] **Step 4: Commit**

```bash
git add scripts/seed-password.ts package.json package-lock.json
git commit -m "feat(phase1): seed-password CLI script"
```

---

## Task 11: Login API Route (TDD-style integration)

**Files:**
- Create: `src/app/api/auth/login/route.ts`, `src/app/api/auth/logout/route.ts`
- Test: `tests/integration/auth.api.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// tests/integration/auth.api.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { POST as loginPOST } from "@/app/api/auth/login/route";
import { POST as logoutPOST } from "@/app/api/auth/logout/route";
import { prisma } from "@/lib/db";
import { hashPassword } from "@/lib/auth/password";
import { SETTINGS } from "@/lib/settings";

beforeAll(async () => {
  await prisma.setting.upsert({
    where: { key: SETTINGS.PASSWORD_HASH },
    update: { value: await hashPassword("pmtestpass") },
    create: { key: SETTINGS.PASSWORD_HASH, value: await hashPassword("pmtestpass") },
  });
});

afterAll(async () => {
  await prisma.$disconnect();
});

function makeReq(body: unknown): Request {
  return new Request("http://localhost/api/auth/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

describe("POST /api/auth/login", () => {
  it("returns 200 + Set-Cookie on correct password", async () => {
    const res = await loginPOST(makeReq({ password: "pmtestpass" }));
    expect(res.status).toBe(200);
    const cookie = res.headers.get("set-cookie");
    expect(cookie).toMatch(/pm_session=/);
    expect(cookie).toMatch(/HttpOnly/);
  });

  it("returns 401 on wrong password", async () => {
    const res = await loginPOST(makeReq({ password: "wrong" }));
    expect(res.status).toBe(401);
  });

  it("returns 400 on missing password", async () => {
    const res = await loginPOST(makeReq({}));
    expect(res.status).toBe(400);
  });
});

describe("POST /api/auth/logout", () => {
  it("clears the session cookie", async () => {
    const res = await logoutPOST();
    expect(res.status).toBe(200);
    const cookie = res.headers.get("set-cookie");
    expect(cookie).toMatch(/pm_session=;/);
    expect(cookie).toMatch(/Max-Age=0/);
  });
});
```

- [ ] **Step 2: Run test, verify it fails**

```bash
npm test -- auth.api
```

Expected: FAIL.

- [ ] **Step 3: Write `src/app/api/auth/login/route.ts`**

```ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { verifyPassword } from "@/lib/auth/password";
import { signSession } from "@/lib/auth/session";
import { getSetting, SETTINGS } from "@/lib/settings";

const Body = z.object({ password: z.string().min(1) });

export async function POST(req: Request) {
  let parsed;
  try {
    parsed = Body.safeParse(await req.json());
  } catch {
    return NextResponse.json({ error: "invalid_json" }, { status: 400 });
  }
  if (!parsed.success) return NextResponse.json({ error: "missing_password" }, { status: 400 });

  const hash = await getSetting(SETTINGS.PASSWORD_HASH);
  if (!hash) return NextResponse.json({ error: "no_password_set" }, { status: 500 });

  const ok = await verifyPassword(parsed.data.password, hash);
  if (!ok) return NextResponse.json({ error: "invalid_credentials" }, { status: 401 });

  const token = await signSession({ sub: "admin" });
  const res = NextResponse.json({ ok: true });
  res.headers.set(
    "Set-Cookie",
    `pm_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 7}`
  );
  return res;
}
```

- [ ] **Step 4: Write `src/app/api/auth/logout/route.ts`**

```ts
import { NextResponse } from "next/server";

export async function POST() {
  const res = NextResponse.json({ ok: true });
  res.headers.set("Set-Cookie", "pm_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
  return res;
}
```

- [ ] **Step 5: Run tests**

```bash
npm test -- auth.api
```

Expected: 4 passed.

- [ ] **Step 6: Commit**

```bash
git add src/app/api/auth/ tests/integration/auth.api.test.ts
git commit -m "feat(phase1): login + logout API routes"
```

---

## Task 12: Auth Middleware

**Files:**
- Create: `src/middleware.ts`, `src/lib/auth/middleware.ts`

- [ ] **Step 1: Write `src/lib/auth/middleware.ts` (helper for API routes)**

```ts
import { verifySession } from "@/lib/auth/session";

export async function requireAuth(req: Request): Promise<{ ok: true } | { ok: false; status: number }> {
  const cookie = req.headers.get("cookie") ?? "";
  const match = cookie.match(/(?:^|;\s*)pm_session=([^;]+)/);
  if (!match) return { ok: false, status: 401 };
  const payload = await verifySession(match[1]);
  if (!payload) return { ok: false, status: 401 };
  return { ok: true };
}
```

- [ ] **Step 2: Write `src/middleware.ts` (Next middleware: gate UI routes)**

```ts
import { NextRequest, NextResponse } from "next/server";
import { verifySession } from "@/lib/auth/session";

const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/logout", "/_next", "/favicon.ico"];

export async function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname;
  if (PUBLIC_PATHS.some((p) => path === p || path.startsWith(p + "/"))) return NextResponse.next();

  const token = req.cookies.get("pm_session")?.value;
  if (!token) return NextResponse.redirect(new URL("/login", req.url));

  const payload = await verifySession(token);
  if (!payload) {
    const res = NextResponse.redirect(new URL("/login", req.url));
    res.cookies.delete("pm_session");
    return res;
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
```

- [ ] **Step 3: Smoke test**

```bash
npm run build
```

Expected: build succeeds.

- [ ] **Step 4: Commit**

```bash
git add src/middleware.ts src/lib/auth/middleware.ts
git commit -m "feat(phase1): auth middleware (gate UI + helper for APIs)"
```

---

## Task 13: Login Page

**Files:**
- Create: `src/app/login/page.tsx`

- [ ] **Step 1: Write the page**

```tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export default function LoginPage() {
  const [password, setPassword] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setError(null);
    const res = await fetch("/api/auth/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ password }),
    });
    setLoading(false);
    if (res.ok) {
      router.push("/");
      router.refresh();
    } else {
      setError("Invalid password");
    }
  }

  return (
    <main className="flex min-h-screen items-center justify-center p-4">
      <Card className="w-full max-w-sm">
        <CardHeader>
          <CardTitle>Project Manager</CardTitle>
        </CardHeader>
        <CardContent>
          <form onSubmit={onSubmit} className="space-y-4">
            <div className="space-y-2">
              <Label htmlFor="password">Password</Label>
              <Input
                id="password"
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                autoFocus
                required
              />
            </div>
            {error && <p className="text-sm text-destructive">{error}</p>}
            <Button type="submit" className="w-full" disabled={loading}>
              {loading ? "Signing in..." : "Sign in"}
            </Button>
          </form>
        </CardContent>
      </Card>
    </main>
  );
}
```

- [ ] **Step 2: Manual smoke test**

```bash
npm run dev &
DEVPID=$!
sleep 4
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/login
kill $DEVPID
```

Expected: `200`.

- [ ] **Step 3: Commit**

```bash
git add src/app/login/
git commit -m "feat(phase1): login page UI"
```

---

## Task 14: Projects API (list + create)

**Files:**
- Create: `src/app/api/projects/route.ts`, `src/app/api/projects/[id]/route.ts`
- Test: `tests/integration/projects.api.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// tests/integration/projects.api.test.ts
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { GET as listGET, POST as listPOST } from "@/app/api/projects/route";
import { GET as detailGET, DELETE as detailDELETE } from "@/app/api/projects/[id]/route";
import { prisma } from "@/lib/db";
import { signSession } from "@/lib/auth/session";

let cookieHeader: string;

beforeAll(async () => {
  process.env.PM_SESSION_SECRET = "test-secret-must-be-at-least-32-bytes-long-xx";
  const token = await signSession({ sub: "admin" });
  cookieHeader = `pm_session=${token}`;
});

afterEach(async () => {
  await prisma.project.deleteMany({});
});

afterAll(async () => {
  await prisma.$disconnect();
});

function authedReq(url: string, init: RequestInit = {}): Request {
  return new Request(url, {
    ...init,
    headers: { ...(init.headers ?? {}), cookie: cookieHeader, "Content-Type": "application/json" },
  });
}

describe("/api/projects", () => {
  it("requires auth (no cookie -> 401)", async () => {
    const res = await listGET(new Request("http://localhost/api/projects"));
    expect(res.status).toBe(401);
  });

  it("returns empty list when no projects", async () => {
    const res = await listGET(authedReq("http://localhost/api/projects"));
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.projects).toEqual([]);
  });

  it("creates a project from a manual path payload", async () => {
    const res = await listPOST(
      authedReq("http://localhost/api/projects", {
        method: "POST",
        body: JSON.stringify({ name: "test-proj", path: "/tmp/test-proj", source: "manual" }),
      })
    );
    expect(res.status).toBe(201);
    const body = await res.json();
    expect(body.project.name).toBe("test-proj");
    expect(body.project.id).toBeTypeOf("string");
  });

  it("returns 409 on duplicate name", async () => {
    await prisma.project.create({ data: { name: "dup", path: "/tmp/dup", source: "manual" } });
    const res = await listPOST(
      authedReq("http://localhost/api/projects", {
        method: "POST",
        body: JSON.stringify({ name: "dup", path: "/tmp/dup", source: "manual" }),
      })
    );
    expect(res.status).toBe(409);
  });

  it("GET /:id returns project, DELETE removes it", async () => {
    const p = await prisma.project.create({ data: { name: "gone", path: "/tmp/gone", source: "manual" } });
    const get = await detailGET(authedReq(`http://localhost/api/projects/${p.id}`), { params: Promise.resolve({ id: p.id }) });
    expect(get.status).toBe(200);
    const del = await detailDELETE(authedReq(`http://localhost/api/projects/${p.id}`, { method: "DELETE" }), {
      params: Promise.resolve({ id: p.id }),
    });
    expect(del.status).toBe(200);
    const after = await prisma.project.findUnique({ where: { id: p.id } });
    expect(after).toBeNull();
  });
});
```

- [ ] **Step 2: Run test, verify it fails**

```bash
npm test -- projects.api
```

Expected: FAIL — module not found.

- [ ] **Step 3: Write `src/app/api/projects/route.ts`**

```ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth/middleware";

export async function GET(req: Request) {
  const auth = await requireAuth(req);
  if (!auth.ok) return NextResponse.json({ error: "unauthorized" }, { status: auth.status });
  const projects = await prisma.project.findMany({ orderBy: { createdAt: "desc" } });
  return NextResponse.json({ projects });
}

const CreateBody = z.object({
  name: z.string().min(1).max(120),
  path: z.string().min(1),
  source: z.enum(["manual", "auto-scan"]).default("manual"),
  gitRemote: z.string().nullable().optional(),
  branch: z.string().nullable().optional(),
  version: z.string().nullable().optional(),
  lastCommitHash: z.string().nullable().optional(),
  lastCommitMessage: z.string().nullable().optional(),
  lastCommitTime: z.coerce.date().nullable().optional(),
});

export async function POST(req: Request) {
  const auth = await requireAuth(req);
  if (!auth.ok) return NextResponse.json({ error: "unauthorized" }, { status: auth.status });

  let parsed;
  try {
    parsed = CreateBody.safeParse(await req.json());
  } catch {
    return NextResponse.json({ error: "invalid_json" }, { status: 400 });
  }
  if (!parsed.success) return NextResponse.json({ error: "invalid_body", details: parsed.error.format() }, { status: 400 });

  const dup = await prisma.project.findUnique({ where: { name: parsed.data.name } });
  if (dup) return NextResponse.json({ error: "duplicate_name" }, { status: 409 });

  const project = await prisma.project.create({ data: parsed.data });
  return NextResponse.json({ project }, { status: 201 });
}
```

- [ ] **Step 4: Write `src/app/api/projects/[id]/route.ts`**

```ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth/middleware";

type Ctx = { params: Promise<{ id: string }> };

export async function GET(req: Request, ctx: Ctx) {
  const auth = await requireAuth(req);
  if (!auth.ok) return NextResponse.json({ error: "unauthorized" }, { status: auth.status });
  const { id } = await ctx.params;
  const project = await prisma.project.findUnique({ where: { id } });
  if (!project) return NextResponse.json({ error: "not_found" }, { status: 404 });
  return NextResponse.json({ project });
}

const PatchBody = z.object({
  notes: z.string().nullable().optional(),
  tags: z.string().nullable().optional(),
  priority: z.number().int().optional(),
});

export async function PATCH(req: Request, ctx: Ctx) {
  const auth = await requireAuth(req);
  if (!auth.ok) return NextResponse.json({ error: "unauthorized" }, { status: auth.status });
  const { id } = await ctx.params;
  let parsed;
  try {
    parsed = PatchBody.safeParse(await req.json());
  } catch {
    return NextResponse.json({ error: "invalid_json" }, { status: 400 });
  }
  if (!parsed.success) return NextResponse.json({ error: "invalid_body" }, { status: 400 });
  const project = await prisma.project.update({ where: { id }, data: parsed.data });
  return NextResponse.json({ project });
}

export async function DELETE(req: Request, ctx: Ctx) {
  const auth = await requireAuth(req);
  if (!auth.ok) return NextResponse.json({ error: "unauthorized" }, { status: auth.status });
  const { id } = await ctx.params;
  await prisma.project.delete({ where: { id } });
  return NextResponse.json({ ok: true });
}
```

- [ ] **Step 5: Run tests**

```bash
npm test -- projects.api
```

Expected: 5 passed.

- [ ] **Step 6: Commit**

```bash
git add src/app/api/projects/ tests/integration/projects.api.test.ts
git commit -m "feat(phase1): projects API (list/create/get/patch/delete)"
```

---

## Task 15: Scan API + Trigger

**Files:**
- Create: `src/app/api/scan/route.ts`

- [ ] **Step 1: Write `src/app/api/scan/route.ts`**

```ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth/middleware";
import { scanProjectsInDir } from "@/lib/scanner/auto-scan";

export async function POST(req: Request) {
  const auth = await requireAuth(req);
  if (!auth.ok) return NextResponse.json({ error: "unauthorized" }, { status: auth.status });

  const scanPath = process.env.PM_SCAN_PATH ?? "/data/video-pipeline/LXC4103/project";
  const scanned = await scanProjectsInDir(scanPath);

  let added = 0;
  let updated = 0;
  for (const proj of scanned) {
    const existing = await prisma.project.findUnique({ where: { name: proj.name } });
    if (existing) {
      await prisma.project.update({
        where: { id: existing.id },
        data: {
          path: proj.path,
          gitRemote: proj.gitRemote,
          branch: proj.branch,
          version: proj.version,
          lastCommitHash: proj.lastCommitHash,
          lastCommitMessage: proj.lastCommitMessage,
          lastCommitTime: proj.lastCommitTime,
        },
      });
      updated++;
    } else {
      await prisma.project.create({
        data: {
          name: proj.name,
          path: proj.path,
          source: "auto-scan",
          gitRemote: proj.gitRemote,
          branch: proj.branch,
          version: proj.version,
          lastCommitHash: proj.lastCommitHash,
          lastCommitMessage: proj.lastCommitMessage,
          lastCommitTime: proj.lastCommitTime,
        },
      });
      added++;
    }
  }
  return NextResponse.json({ ok: true, scanned: scanned.length, added, updated, scanPath });
}
```

- [ ] **Step 2: Smoke test build**

```bash
npm run build
```

Expected: build succeeds.

- [ ] **Step 3: Commit**

```bash
git add src/app/api/scan/
git commit -m "feat(phase1): scan API endpoint"
```

---

## Task 16: Dashboard Page

**Files:**
- Create: `src/components/project-card.tsx`, `src/components/nav.tsx`, `src/app/page.tsx` (replace bootstrap)

- [ ] **Step 1: Write `src/components/project-card.tsx`**

```tsx
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export interface ProjectCardProps {
  id: string;
  name: string;
  branch: string | null;
  version: string | null;
  lastCommitMessage: string | null;
  source: string;
}

export function ProjectCard({ id, name, branch, version, lastCommitMessage, source }: ProjectCardProps) {
  return (
    <Link href={`/projects/${id}`}>
      <Card className="hover:border-primary transition-colors h-full">
        <CardHeader>
          <CardTitle className="flex items-center justify-between">
            <span className="truncate">{name}</span>
            <span className="text-xs text-muted-foreground font-normal">{version ?? "—"}</span>
          </CardTitle>
        </CardHeader>
        <CardContent className="space-y-1 text-sm">
          <div className="text-muted-foreground">
            <span className="text-xs uppercase tracking-wide">branch</span>{" "}
            <span className="text-foreground">{branch ?? "—"}</span>
          </div>
          <div className="truncate text-muted-foreground">{lastCommitMessage ?? "no commits"}</div>
          <div className="pt-2 text-xs text-muted-foreground">
            <span className="rounded border px-1.5 py-0.5">{source}</span>
          </div>
        </CardContent>
      </Card>
    </Link>
  );
}
```

- [ ] **Step 2: Write `src/components/nav.tsx`**

```tsx
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";

export function Nav() {
  async function logout() {
    await fetch("/api/auth/logout", { method: "POST" });
    window.location.href = "/login";
  }
  async function rescan() {
    await fetch("/api/scan", { method: "POST" });
    window.location.reload();
  }
  return (
    <nav className="border-b">
      <div className="container mx-auto flex items-center justify-between p-4">
        <Link href="/" className="font-semibold">
          Project Manager
        </Link>
        <div className="flex gap-2">
          <Button variant="outline" onClick={rescan}>
            Rescan
          </Button>
          <Link href="/projects/new">
            <Button variant="outline">Add Project</Button>
          </Link>
          <Button variant="ghost" onClick={logout}>
            Logout
          </Button>
        </div>
      </div>
    </nav>
  );
}
```

- [ ] **Step 3: Replace `src/app/page.tsx`**

```tsx
import { prisma } from "@/lib/db";
import { Nav } from "@/components/nav";
import { ProjectCard } from "@/components/project-card";

export const dynamic = "force-dynamic";

export default async function HomePage() {
  const projects = await prisma.project.findMany({ orderBy: { createdAt: "desc" } });
  return (
    <div className="min-h-screen">
      <Nav />
      <main className="container mx-auto p-4">
        <h1 className="text-2xl font-semibold mb-4">Projects ({projects.length})</h1>
        {projects.length === 0 ? (
          <div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
            No projects yet. Click <span className="text-foreground">Rescan</span> to auto-detect, or{" "}
            <span className="text-foreground">Add Project</span> manually.
          </div>
        ) : (
          <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
            {projects.map((p) => (
              <ProjectCard
                key={p.id}
                id={p.id}
                name={p.name}
                branch={p.branch}
                version={p.version}
                lastCommitMessage={p.lastCommitMessage}
                source={p.source}
              />
            ))}
          </div>
        )}
      </main>
    </div>
  );
}
```

- [ ] **Step 4: Build smoke test**

```bash
npm run build
```

Expected: build succeeds.

- [ ] **Step 5: Commit**

```bash
git add src/app/page.tsx src/components/
git commit -m "feat(phase1): dashboard page with project cards + nav"
```

---

## Task 17: Project Detail Page

**Files:**
- Create: `src/app/projects/[id]/page.tsx`

- [ ] **Step 1: Write the page**

```tsx
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { Nav } from "@/components/nav";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export const dynamic = "force-dynamic";

export default async function ProjectDetail({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const project = await prisma.project.findUnique({ where: { id } });
  if (!project) notFound();

  const fields: [string, string | null][] = [
    ["Path", project.path],
    ["Source", project.source],
    ["Branch", project.branch],
    ["Version", project.version],
    ["Git remote", project.gitRemote],
    ["Last commit hash", project.lastCommitHash],
    ["Last commit message", project.lastCommitMessage],
    ["Last commit time", project.lastCommitTime?.toISOString() ?? null],
  ];

  return (
    <div className="min-h-screen">
      <Nav />
      <main className="container mx-auto p-4 space-y-4">
        <h1 className="text-2xl font-semibold">{project.name}</h1>
        <Card>
          <CardHeader>
            <CardTitle>Overview</CardTitle>
          </CardHeader>
          <CardContent className="space-y-2 text-sm">
            {fields.map(([label, value]) => (
              <div key={label} className="grid grid-cols-[160px_1fr] gap-2">
                <span className="text-muted-foreground">{label}</span>
                <span className="font-mono break-all">{value ?? "—"}</span>
              </div>
            ))}
          </CardContent>
        </Card>
      </main>
    </div>
  );
}
```

- [ ] **Step 2: Build smoke test**

```bash
npm run build
```

Expected: build succeeds.

- [ ] **Step 3: Commit**

```bash
git add src/app/projects/
git commit -m "feat(phase1): project detail page"
```

---

## Task 18: Add Project Page (Manual)

**Files:**
- Create: `src/app/projects/new/page.tsx`

- [ ] **Step 1: Write the page**

```tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Nav } from "@/components/nav";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export default function NewProjectPage() {
  const [name, setName] = useState("");
  const [path, setPath] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [submitting, setSubmitting] = useState(false);
  const router = useRouter();

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    setSubmitting(true);
    setError(null);
    const res = await fetch("/api/projects", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name, path, source: "manual" }),
    });
    if (res.ok) {
      const body = await res.json();
      router.push(`/projects/${body.project.id}`);
    } else {
      const body = await res.json().catch(() => ({}));
      setError(body.error ?? "failed");
      setSubmitting(false);
    }
  }

  return (
    <div className="min-h-screen">
      <Nav />
      <main className="container mx-auto p-4">
        <Card className="max-w-xl">
          <CardHeader>
            <CardTitle>Add Project</CardTitle>
          </CardHeader>
          <CardContent>
            <form onSubmit={onSubmit} className="space-y-4">
              <div className="space-y-2">
                <Label htmlFor="name">Name</Label>
                <Input id="name" value={name} onChange={(e) => setName(e.target.value)} required />
              </div>
              <div className="space-y-2">
                <Label htmlFor="path">Path</Label>
                <Input id="path" value={path} onChange={(e) => setPath(e.target.value)} placeholder="/absolute/path" required />
              </div>
              {error && <p className="text-sm text-destructive">{error}</p>}
              <Button type="submit" disabled={submitting}>
                {submitting ? "Saving..." : "Save"}
              </Button>
            </form>
          </CardContent>
        </Card>
      </main>
    </div>
  );
}
```

- [ ] **Step 2: Build smoke test**

```bash
npm run build
```

Expected: build succeeds.

- [ ] **Step 3: Commit**

```bash
git add src/app/projects/new/
git commit -m "feat(phase1): add-project manual form"
```

---

## Task 19: E2E Smoke Test

**Files:**
- Create: `playwright.config.ts`, `tests/e2e/login-and-dashboard.spec.ts`

- [ ] **Step 1: Install Playwright browsers**

```bash
npx playwright install chromium
```

- [ ] **Step 2: Write `playwright.config.ts`**

```ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./tests/e2e",
  timeout: 30_000,
  retries: 0,
  use: { baseURL: "http://localhost:3000", headless: true },
  webServer: {
    command: "npm run start",
    url: "http://localhost:3000/login",
    timeout: 60_000,
    reuseExistingServer: false,
  },
});
```

- [ ] **Step 3: Write `tests/e2e/login-and-dashboard.spec.ts`**

```ts
import { test, expect } from "@playwright/test";

test("login redirects to dashboard, dashboard requires auth", async ({ page }) => {
  await page.goto("/");
  // unauthenticated -> middleware redirects to /login
  await expect(page).toHaveURL(/\/login$/);

  // wrong password
  await page.fill("#password", "wrong");
  await page.click('button[type="submit"]');
  await expect(page.locator("text=Invalid password")).toBeVisible();

  // correct password (set via seed-password)
  await page.fill("#password", "pmpassword123");
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL(/\/$/);
  await expect(page.locator("h1")).toContainText("Projects");
});
```

- [ ] **Step 4: Build + run E2E**

```bash
npm run build
npm run test:e2e
```

Expected: 1 passed.

- [ ] **Step 5: Commit**

```bash
git add playwright.config.ts tests/e2e/
git commit -m "test(phase1): e2e login + dashboard smoke"
```

---

## Task 20: README + Final Verification

**Files:**
- Create: `README.md`

- [ ] **Step 1: Write `README.md`**

```markdown
# Project Manager (Phase 1: Core Skeleton)

Internal webapp tracking projects in `/data/video-pipeline/LXC4103/project/` and any manually-added paths.

## Setup

```bash
cp .env.example .env
# edit .env, generate secrets:
echo "PM_SESSION_SECRET=\"$(openssl rand -hex 32)\"" >> .env
echo "PM_ENCRYPTION_KEY=\"$(openssl rand -hex 32)\"" >> .env

npm install
npx prisma migrate deploy
npm run seed:password -- yourpassword
npm run build
npm start
```

App runs on `:3000`.

## Tests

```bash
npm test            # unit + integration (Vitest)
npm run test:e2e    # E2E (Playwright)
```

## Phase 1 features
- Single-password login (bcrypt + JWT cookie)
- Auto-scan `/data/video-pipeline/LXC4103/project/` for git repos
- Manual add-project form
- Project dashboard + detail view
```

- [ ] **Step 2: Run full test suite**

```bash
npm test
```

Expected: all unit + integration tests pass.

- [ ] **Step 3: Run build**

```bash
npm run build
```

Expected: succeeds, no type errors.

- [ ] **Step 4: Manual end-to-end smoke**

```bash
npm run seed:password -- testpass123
npm run build
npm start &
SVCPID=$!
sleep 4
# unauthenticated -> 307 redirect
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/
# login
curl -s -c /tmp/pm-cookie -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" -d '{"password":"testpass123"}'
# scan
curl -s -b /tmp/pm-cookie -X POST http://localhost:3000/api/scan
# list
curl -s -b /tmp/pm-cookie http://localhost:3000/api/projects | head -c 500
kill $SVCPID
```

Expected: redirect 307, login `{"ok":true}`, scan returns counts, list returns projects array.

- [ ] **Step 5: Commit**

```bash
git add README.md
git commit -m "docs(phase1): README + setup instructions"
```

- [ ] **Step 6: Tag phase 1 complete**

```bash
git tag phase-1-core-skeleton
```

---

## Self-Review

**Spec coverage check:**
- ✅ Single-password auth (Tasks 5, 6, 11, 12, 13)
- ✅ Auto-scan + manual add (Tasks 7, 8, 15, 18)
- ✅ Dashboard + detail (Tasks 16, 17)
- ✅ SQLite + Prisma (Task 3)
- ✅ Dark mode shadcn UI (Task 2)
- ✅ Tests (Tasks 4, 5–8, 11, 14, 19)
- ⏭ Out of Phase 1 (deferred to Phase 2/3): SSE, BullMQ/Redis, webhook endpoint, deploy targets, registry, builder, Docker integration, env-var encryption, logs viewer, tasks/notes UI, health checks. These are separate plans.

**Placeholder scan:** No "TBD" / "TODO" / "Add X here" in tasks. Every code step has full code.

**Type consistency:** `GitState` defined in Task 7 reused in Task 8 via import. Prisma Project model fields match what the API and pages reference. Session payload `{ sub: string }` consistent across Tasks 6, 11, 12.

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-05-29-phase-1-core-skeleton.md`. Two execution options:

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.

**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.

Which approach?
