diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302b0f0..3cc93b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,3 @@ jobs: - name: Test app-core run: cd kanban/frontend/packages/app-core && pnpm run test - - - name: Bundle API (verify esbuild) - run: cd kanban && npm run build:api diff --git a/.gitignore b/.gitignore index cc14105..f18468e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,17 @@ +# Dependencies node_modules/ -.worktrees/ + +# Build output dist/ + +# Environment .env .env.local *.local + +# macOS +.DS_Store + +# Project +.worktrees/ +.ref diff --git a/Makefile b/Makefile index b1c0cd1..4c6676e 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ help: # ── Install ────────────────────────────────────────────── install: + cd kanban && npm install cd kanban/backend && bun install cd kanban/frontend && pnpm install diff --git a/README.md b/README.md index 09f25c0..361e806 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,60 @@ # SuperCrew -SuperCrew combines two things: a structured AI development workflow (powered by superpowers skills) and a kanban-style team management app built with those same workflows. +SuperCrew combines two things: a structured AI development workflow (powered by superpowers skills) and a kanban-style feature management app built with those same workflows. ## What's Inside +### `plugins/supercrew/` — The Claude Code Plugin + +AI-driven feature lifecycle management. Track features from idea to done using structured `.supercrew/features/` directories in your repo. + +**Install in Claude Code:** + +```bash +# 1. Add the local marketplace (use absolute path) +/plugin marketplace add /path/to/supercrew/plugins/supercrew + +# 2. Install the plugin +/plugin install supercrew@supercrew-dev + +# 3. Verify +/plugin list +``` + +**Commands:** + +| Command | Description | +|---|---| +| `/supercrew:new-feature` | Create a new feature with meta.yaml, design.md, plan.md, log.md | +| `/supercrew:feature-status` | Show all features status table | +| `/supercrew:work-on` | Switch active feature for this session | + +**Feature lifecycle:** + +``` +planning → designing → ready → active → blocked → done +``` + +Each feature lives in `.supercrew/features//` with four files: + +| File | Purpose | +|---|---| +| `meta.yaml` | ID, title, status, priority, owner, dates | +| `design.md` | Requirements, architecture, constraints | +| `plan.md` | Task breakdown with checklist & progress | +| `log.md` | Chronological progress entries | + +The plugin's SessionStart hook auto-detects `feature/` branches and loads context. + ### `kanban/` — The Crew App -A lightweight team kanban board with GitHub integration. Features: +A read-only kanban board that visualizes features from `.supercrew/features/`. Connect a GitHub repo and see your feature lifecycle at a glance. + +Features: - GitHub OAuth login -- Connect a GitHub repo to your project -- Board, People, Knowledge, Decisions pages +- Connect a GitHub repo with `.supercrew/features/` +- 6-column kanban board: Planning → Designing → Ready → Active → Blocked → Done +- Feature detail page with Overview / Design / Plan tabs - i18n (English / Chinese) - Dark mode diff --git a/kanban/backend/bun.lock b/kanban/backend/bun.lock index 680dae4..52b647d 100644 --- a/kanban/backend/bun.lock +++ b/kanban/backend/bun.lock @@ -10,10 +10,12 @@ "gray-matter": "^4.0.3", "hono": "^4.12.3", "jose": "^6.1.3", + "js-yaml": "^4.1.1", "typescript": "^5.0.0", }, "devDependencies": { "@types/bun": "latest", + "@types/js-yaml": "^4.0.9", "chokidar": "^4.0.3", "vitest": "^3.2.4", }, @@ -132,6 +134,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], "@upstash/redis": ["@upstash/redis@1.36.3", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wxo1ei4OHDHm4UGMgrNVz9QUEela9N/Iwi4p1JlHNSowQiPi+eljlGnfbZVkV0V4PIrjGtGFJt5GjWM5k28enA=="], @@ -152,7 +156,7 @@ "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -196,7 +200,7 @@ "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": "bin/js-yaml.js" }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -265,5 +269,9 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "bun-types/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + + "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": "bin/js-yaml.js" }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], } } diff --git a/kanban/backend/package.json b/kanban/backend/package.json index 86df596..71c2eba 100644 --- a/kanban/backend/package.json +++ b/kanban/backend/package.json @@ -17,10 +17,12 @@ "gray-matter": "^4.0.3", "hono": "^4.12.3", "jose": "^6.1.3", + "js-yaml": "^4.1.1", "typescript": "^5.0.0" }, "devDependencies": { "@types/bun": "latest", + "@types/js-yaml": "^4.0.9", "chokidar": "^4.0.3", "vitest": "^3.2.4" } diff --git a/kanban/backend/src/__tests__/github-store.test.ts b/kanban/backend/src/__tests__/github-store.test.ts index dd1074d..c74b691 100644 --- a/kanban/backend/src/__tests__/github-store.test.ts +++ b/kanban/backend/src/__tests__/github-store.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const mockFetch = vi.fn() global.fetch = mockFetch as any -import { listTasksGH, readTaskGH } from '../store/github-store.js' +import { listFeaturesGH, getFeatureMetaGH } from '../store/github-store.js' const TOKEN = 'ghp_test' const OWNER = 'testowner' @@ -12,37 +12,38 @@ const REPO = 'testrepo' describe('github-store', () => { beforeEach(() => { mockFetch.mockReset() }) - it('listTasksGH returns empty array when directory missing', async () => { + it('listFeaturesGH returns empty array when directory missing', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404 } as any) - const result = await listTasksGH(TOKEN, OWNER, REPO) + const result = await listFeaturesGH(TOKEN, OWNER, REPO) expect(result).toEqual([]) }) - it('listTasksGH skips template files', async () => { + it('listFeaturesGH lists feature directories and loads meta', async () => { + // First call: list features directory mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [ - { name: '_template.md', type: 'file' }, - { name: 'ENG-001.md', type: 'file' }, + { name: 'feat-001', type: 'dir' }, + { name: 'README.md', type: 'file' }, // should be skipped ], } as any) + // Second call: ghGet for feat-001/meta.yaml mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ - content: btoa('---\ntitle: Test Task\nstatus: backlog\npriority: P2\ncreated: 2026-01-01\nupdated: 2026-01-01\ntags: []\nblocks: []\nblocked_by: []\n---\nTask body'), - sha: 'abc123', + content: btoa('id: feat-001\ntitle: Test Feature\nstatus: planning\nowner: alice\npriority: P1\ncreated: "2026-01-01"\nupdated: "2026-01-01"\n'), }), } as any) - const result = await listTasksGH(TOKEN, OWNER, REPO) + const result = await listFeaturesGH(TOKEN, OWNER, REPO) expect(result).toHaveLength(1) - expect(result[0].id).toBe('ENG-001') - expect(result[0].title).toBe('Test Task') + expect(result[0].id).toBe('feat-001') + expect(result[0].title).toBe('Test Feature') }) - it('readTaskGH returns null when file missing', async () => { + it('getFeatureMetaGH returns null when file missing', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404 } as any) - const result = await readTaskGH(TOKEN, OWNER, REPO, 'ENG-999') + const result = await getFeatureMetaGH(TOKEN, OWNER, REPO, 'missing-feat') expect(result).toBeNull() }) }) diff --git a/kanban/backend/src/__tests__/routes-tasks.test.ts b/kanban/backend/src/__tests__/routes-tasks.test.ts index be18251..c602b53 100644 --- a/kanban/backend/src/__tests__/routes-tasks.test.ts +++ b/kanban/backend/src/__tests__/routes-tasks.test.ts @@ -6,9 +6,9 @@ process.env.JWT_SECRET = 'test-jwt-secret' const { app } = await import('../index.js') -describe('GET /api/tasks', () => { +describe('GET /api/features', () => { it('returns 401 without auth', async () => { - const res = await app.request('/api/tasks') + const res = await app.request('/api/features') expect(res.status).toBe(401) }) }) diff --git a/kanban/backend/src/__tests__/store.test.ts b/kanban/backend/src/__tests__/store.test.ts index 8b67f2c..07dea34 100644 --- a/kanban/backend/src/__tests__/store.test.ts +++ b/kanban/backend/src/__tests__/store.test.ts @@ -2,119 +2,132 @@ import { test, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { readTask, writeTask, updateTaskStatus, readPerson } from '../store/index.js' -import type { Task } from '../types/index.js' +import { listFeatures, getFeatureMeta, getFeatureDesign, getFeaturePlan, getFeature, checkSupercrewExists } from '../store/index.js' let tempDir: string beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'crew-test-')) - process.env.TEAM_DIR = tempDir + process.env.SUPERCREW_DIR = tempDir }) afterEach(() => { rmSync(tempDir, { recursive: true, force: true }) - delete process.env.TEAM_DIR + delete process.env.SUPERCREW_DIR }) -// ─── readTask ──────────────────────────────────────────────────────────────── +// ─── listFeatures ──────────────────────────────────────────────────────────── -test('readTask returns null for non-existent file', () => { - expect(readTask('MISSING-001')).toBeNull() +test('listFeatures returns empty array when features dir missing', () => { + expect(listFeatures()).toEqual([]) }) -test('writeTask + readTask round-trip preserves all fields', () => { - const task: Task = { - id: 'ENG-001', - title: 'Test task', - status: 'todo', - priority: 'P1', - created: '2026-01-01', - updated: '2026-01-01', - tags: ['backend'], - blocks: ['ENG-002'], - blocked_by: [], - body: 'This is the task body.', - } - writeTask(task) - const result = readTask('ENG-001') - expect(result).not.toBeNull() - expect(result!.title).toBe('Test task') - expect(result!.status).toBe('todo') - expect(result!.priority).toBe('P1') - expect(result!.tags).toEqual(['backend']) - expect(result!.blocks).toEqual(['ENG-002']) - expect(result!.body).toBe('This is the task body.') +test('listFeatures reads feature directories', () => { + const featDir = join(tempDir, 'features', 'feat-001') + mkdirSync(featDir, { recursive: true }) + writeFileSync( + join(featDir, 'meta.yaml'), + 'id: feat-001\ntitle: Test Feature\nstatus: planning\nowner: alice\npriority: P1\ncreated: "2026-01-01"\nupdated: "2026-01-01"\n', + ) + const result = listFeatures() + expect(result).toHaveLength(1) + expect(result[0].id).toBe('feat-001') + expect(result[0].title).toBe('Test Feature') + expect(result[0].priority).toBe('P1') +}) + +// ─── getFeatureMeta ────────────────────────────────────────────────────────── + +test('getFeatureMeta returns null for non-existent feature', () => { + expect(getFeatureMeta('MISSING-001')).toBeNull() }) -test('readTask applies default status and priority when frontmatter omits them', () => { - mkdirSync(join(tempDir, 'tasks'), { recursive: true }) +test('getFeatureMeta parses meta.yaml correctly', () => { + const featDir = join(tempDir, 'features', 'feat-002') + mkdirSync(featDir, { recursive: true }) writeFileSync( - join(tempDir, 'tasks', 'MIN-001.md'), - '---\ntitle: Minimal task\n---\n\nBody here.', + join(featDir, 'meta.yaml'), + 'id: feat-002\ntitle: Another Feature\nstatus: active\nowner: bob\npriority: P0\nteams:\n - backend\ntags:\n - infra\ncreated: "2026-01-01"\nupdated: "2026-01-02"\n', ) - const result = readTask('MIN-001') + const result = getFeatureMeta('feat-002') expect(result).not.toBeNull() - expect(result!.status).toBe('backlog') - expect(result!.priority).toBe('P2') - expect(result!.tags).toEqual([]) - expect(result!.blocks).toEqual([]) - expect(result!.blocked_by).toEqual([]) + expect(result!.title).toBe('Another Feature') + expect(result!.status).toBe('active') + expect(result!.priority).toBe('P0') + expect(result!.teams).toEqual(['backend']) + expect(result!.tags).toEqual(['infra']) }) -test('updateTaskStatus changes status and persists to disk', () => { - const task: Task = { - id: 'ENG-002', - title: 'Status test', - status: 'todo', - priority: 'P2', - created: '2026-01-01', - updated: '2026-01-01', - tags: [], - blocks: [], - blocked_by: [], - body: '', - } - writeTask(task) - const updated = updateTaskStatus('ENG-002', 'in-progress') - expect(updated).not.toBeNull() - expect(updated!.status).toBe('in-progress') - expect(readTask('ENG-002')!.status).toBe('in-progress') +// ─── getFeatureDesign ──────────────────────────────────────────────────────── + +test('getFeatureDesign returns null when file missing', () => { + expect(getFeatureDesign('MISSING-001')).toBeNull() }) -test('updateTaskStatus returns null for non-existent task', () => { - expect(updateTaskStatus('MISSING-001', 'done')).toBeNull() +test('getFeatureDesign parses design.md correctly', () => { + const featDir = join(tempDir, 'features', 'feat-003') + mkdirSync(featDir, { recursive: true }) + writeFileSync( + join(featDir, 'design.md'), + '---\nstatus: in-review\nreviewers:\n - carol\n---\nDesign body content.', + ) + const result = getFeatureDesign('feat-003') + expect(result).not.toBeNull() + expect(result!.status).toBe('in-review') + expect(result!.reviewers).toEqual(['carol']) + expect(result!.body).toBe('Design body content.') }) -// ─── readPerson ─────────────────────────────────────────────────────────────── +// ─── getFeaturePlan ────────────────────────────────────────────────────────── -test('readPerson returns null for non-existent file', () => { - expect(readPerson('nobody')).toBeNull() +test('getFeaturePlan returns null when file missing', () => { + expect(getFeaturePlan('MISSING-001')).toBeNull() }) -test('readPerson parses person correctly', () => { - mkdirSync(join(tempDir, 'people'), { recursive: true }) +test('getFeaturePlan parses plan.md correctly', () => { + const featDir = join(tempDir, 'features', 'feat-004') + mkdirSync(featDir, { recursive: true }) writeFileSync( - join(tempDir, 'people', 'alice.md'), - [ - '---', - 'name: Alice', - 'team: eng', - 'updated: "2026-01-01"', - 'current_task: ENG-001', - 'completed_today:', - ' - "Wrote tests"', - '---', - '', - "Alice's notes.", - ].join('\n'), + join(featDir, 'plan.md'), + '---\ntotal_tasks: 5\ncompleted_tasks: 2\nprogress: 40\n---\nPlan body.', ) - const result = readPerson('alice') + const result = getFeaturePlan('feat-004') + expect(result).not.toBeNull() + expect(result!.total_tasks).toBe(5) + expect(result!.completed_tasks).toBe(2) + expect(result!.progress).toBe(40) + expect(result!.body).toBe('Plan body.') +}) + +// ─── getFeature ────────────────────────────────────────────────────────────── + +test('getFeature returns null for non-existent feature', () => { + expect(getFeature('MISSING-001')).toBeNull() +}) + +test('getFeature returns full feature with all documents', () => { + const featDir = join(tempDir, 'features', 'feat-005') + mkdirSync(featDir, { recursive: true }) + writeFileSync(join(featDir, 'meta.yaml'), 'id: feat-005\ntitle: Full Feature\nstatus: designing\nowner: dave\npriority: P2\ncreated: "2026-01-01"\nupdated: "2026-01-01"\n') + writeFileSync(join(featDir, 'design.md'), '---\nstatus: draft\nreviewers: []\n---\nDesign content.') + writeFileSync(join(featDir, 'plan.md'), '---\ntotal_tasks: 3\ncompleted_tasks: 1\nprogress: 33\n---\nPlan content.') + writeFileSync(join(featDir, 'log.md'), 'Log content here.') + + const result = getFeature('feat-005') expect(result).not.toBeNull() - expect(result!.username).toBe('alice') - expect(result!.name).toBe('Alice') - expect(result!.team).toBe('eng') - expect(result!.current_task).toBe('ENG-001') - expect(result!.completed_today).toEqual(['Wrote tests']) - expect(result!.body).toBe("Alice's notes.") + expect(result!.meta.title).toBe('Full Feature') + expect(result!.design!.status).toBe('draft') + expect(result!.plan!.total_tasks).toBe(3) + expect(result!.log!.body).toBe('Log content here.') +}) + +// ─── checkSupercrewExists ──────────────────────────────────────────────────── + +test('checkSupercrewExists returns false when dir missing', () => { + expect(checkSupercrewExists()).toBe(false) +}) + +test('checkSupercrewExists returns true when features dir exists', () => { + mkdirSync(join(tempDir, 'features'), { recursive: true }) + expect(checkSupercrewExists()).toBe(true) }) diff --git a/kanban/backend/src/index.ts b/kanban/backend/src/index.ts index 9e93f53..14f956c 100644 --- a/kanban/backend/src/index.ts +++ b/kanban/backend/src/index.ts @@ -4,13 +4,9 @@ import { KVRegistry } from './registry/kv-registry.js' import { FileRegistry } from './registry/file-registry.js' import { createAuthRouter } from './routes/auth.js' import { createProjectsRouter } from './routes/projects.js' -import { createTasksRouter } from './routes/tasks.js' -import { createSprintsRouter } from './routes/sprints.js' -import { createPeopleRouter } from './routes/people.js' -import { createKnowledgeRouter } from './routes/knowledge.js' -import { createDecisionsRouter } from './routes/decisions.js' +import { createFeaturesRouter, buildFeatureBoard } from './routes/features.js' import { getGitHubContext } from './lib/get-github-context.js' -import { listTasksGH, listSprintsGH, listPeopleGH } from './store/github-store.js' +import { listFeaturesGH } from './store/github-store.js' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { env } from './lib/env.js' @@ -35,22 +31,14 @@ app.use('*', cors({ app.route('/auth', createAuthRouter(registry)) app.route('/api/projects', createProjectsRouter(registry)) -app.route('/api/tasks', createTasksRouter(registry)) -app.route('/api/sprints', createSprintsRouter(registry)) -app.route('/api/people', createPeopleRouter(registry)) -app.route('/api/knowledge', createKnowledgeRouter(registry)) -app.route('/api/decisions', createDecisionsRouter(registry)) +app.route('/api/features', createFeaturesRouter(registry)) -// Board: aggregate endpoint +// Board: aggregate endpoint — features grouped by status app.get('/api/board', async (c) => { try { const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const [tasks, sprints, people] = await Promise.all([ - listTasksGH(ctx.accessToken, ctx.owner, ctx.repo), - listSprintsGH(ctx.accessToken, ctx.owner, ctx.repo), - listPeopleGH(ctx.accessToken, ctx.owner, ctx.repo), - ]) - return c.json({ tasks, sprints, people }) + const features = await listFeaturesGH(ctx.accessToken, ctx.owner, ctx.repo) + return c.json(buildFeatureBoard(features)) } catch (e: any) { return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) } diff --git a/kanban/backend/src/routes/decisions.ts b/kanban/backend/src/routes/decisions.ts deleted file mode 100644 index 4e2f1c6..0000000 --- a/kanban/backend/src/routes/decisions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Hono } from 'hono' -import { listDecisionsGH } from '../store/github-store.js' -import { getGitHubContext } from '../lib/get-github-context.js' -import type { UserRegistry } from '../registry/types.js' - -export function createDecisionsRouter(registry: UserRegistry) { - const app = new Hono() - - app.get('/', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - return c.json(await listDecisionsGH(ctx.accessToken, ctx.owner, ctx.repo)) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - return app -} diff --git a/kanban/backend/src/routes/features.ts b/kanban/backend/src/routes/features.ts new file mode 100644 index 0000000..da28e78 --- /dev/null +++ b/kanban/backend/src/routes/features.ts @@ -0,0 +1,81 @@ +import { Hono } from 'hono' +import { + listFeaturesGH, + getFeatureMetaGH, + getFeatureGH, + getFeatureDesignGH, + getFeaturePlanGH, +} from '../store/github-store.js' +import { getGitHubContext } from '../lib/get-github-context.js' +import type { UserRegistry } from '../registry/types.js' +import type { SupercrewStatus } from '../types/index.js' + +const ALL_STATUSES: SupercrewStatus[] = [ + 'planning', 'designing', 'ready', 'active', 'blocked', 'done', +] + +export function createFeaturesRouter(registry: UserRegistry) { + const app = new Hono() + + // GET /api/features — list all features (meta summary) + app.get('/', async (c) => { + try { + const ctx = await getGitHubContext(c.req.header('Authorization'), registry) + const features = await listFeaturesGH(ctx.accessToken, ctx.owner, ctx.repo) + return c.json(features) + } catch (e: any) { + return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) + } + }) + + // GET /api/features/:id — get single feature complete info + app.get('/:id', async (c) => { + try { + const ctx = await getGitHubContext(c.req.header('Authorization'), registry) + const feature = await getFeatureGH(ctx.accessToken, ctx.owner, ctx.repo, c.req.param('id')) + if (!feature) return c.json({ error: 'Not found' }, 404) + return c.json(feature) + } catch (e: any) { + return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) + } + }) + + // GET /api/features/:id/design — get design.md + app.get('/:id/design', async (c) => { + try { + const ctx = await getGitHubContext(c.req.header('Authorization'), registry) + const design = await getFeatureDesignGH(ctx.accessToken, ctx.owner, ctx.repo, c.req.param('id')) + if (!design) return c.json({ error: 'Not found' }, 404) + return c.json(design) + } catch (e: any) { + return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) + } + }) + + // GET /api/features/:id/plan — get plan.md (with progress) + app.get('/:id/plan', async (c) => { + try { + const ctx = await getGitHubContext(c.req.header('Authorization'), registry) + const plan = await getFeaturePlanGH(ctx.accessToken, ctx.owner, ctx.repo, c.req.param('id')) + if (!plan) return c.json({ error: 'Not found' }, 404) + return c.json(plan) + } catch (e: any) { + return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) + } + }) + + return app +} + +/** Build board aggregate — features grouped by status */ +export function buildFeatureBoard(features: import('../types/index.js').FeatureMeta[]) { + const featuresByStatus: Record = { + planning: [], designing: [], ready: [], active: [], blocked: [], done: [], + } + for (const f of features) { + if (featuresByStatus[f.status]) { + featuresByStatus[f.status].push(f) + } + } + return { features, featuresByStatus } +} diff --git a/kanban/backend/src/routes/knowledge.ts b/kanban/backend/src/routes/knowledge.ts deleted file mode 100644 index 8fa3468..0000000 --- a/kanban/backend/src/routes/knowledge.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Hono } from 'hono' -import { listKnowledgeGH } from '../store/github-store.js' -import { getGitHubContext } from '../lib/get-github-context.js' -import type { UserRegistry } from '../registry/types.js' - -export function createKnowledgeRouter(registry: UserRegistry) { - const app = new Hono() - - app.get('/', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - return c.json(await listKnowledgeGH(ctx.accessToken, ctx.owner, ctx.repo)) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - return app -} diff --git a/kanban/backend/src/routes/people.ts b/kanban/backend/src/routes/people.ts deleted file mode 100644 index f880b69..0000000 --- a/kanban/backend/src/routes/people.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Hono } from 'hono' -import { listPeopleGH, readPersonGH, writePersonGH } from '../store/github-store.js' -import { getGitHubContext } from '../lib/get-github-context.js' -import type { UserRegistry } from '../registry/types.js' -import type { Person } from '../types/index.js' - -export function createPeopleRouter(registry: UserRegistry) { - const app = new Hono() - - app.get('/', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - return c.json(await listPeopleGH(ctx.accessToken, ctx.owner, ctx.repo)) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.get('/:username', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const person = await readPersonGH(ctx.accessToken, ctx.owner, ctx.repo, c.req.param('username')) - if (!person) return c.json({ error: 'Not found' }, 404) - return c.json(person) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.post('/', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const body = await c.req.json>() - if (!body.username) return c.json({ error: 'username is required' }, 400) - const existing = await readPersonGH(ctx.accessToken, ctx.owner, ctx.repo, body.username) - if (existing) return c.json({ error: `Person ${body.username} already exists` }, 409) - const person: Person = { - username: body.username, - name: body.name ?? body.username, - team: body.team ?? '', - updated: new Date().toISOString().split('T')[0], - current_task: body.current_task, - blocked_by: body.blocked_by, - completed_today: body.completed_today ?? [], - body: body.body ?? '', - } - await writePersonGH(ctx.accessToken, ctx.owner, ctx.repo, person) - return c.json(person, 201) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.patch('/:username', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const username = c.req.param('username') - const existing = await readPersonGH(ctx.accessToken, ctx.owner, ctx.repo, username) - if (!existing) return c.json({ error: 'Not found' }, 404) - const body = await c.req.json>() - const updated = { ...existing, ...body, username } - await writePersonGH(ctx.accessToken, ctx.owner, ctx.repo, updated) - return c.json(updated) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - return app -} diff --git a/kanban/backend/src/routes/projects.ts b/kanban/backend/src/routes/projects.ts index 8b2be2c..3c1e34d 100644 --- a/kanban/backend/src/routes/projects.ts +++ b/kanban/backend/src/routes/projects.ts @@ -1,8 +1,7 @@ import { Hono } from 'hono' import { verify } from 'hono/jwt' import type { UserRegistry } from '../registry/types.js' - -const JWT_SECRET = process.env.JWT_SECRET! +import { env } from '../lib/env.js' async function getPayload(authHeader: string | undefined) { if (!authHeader?.startsWith('Bearer ')) { @@ -10,9 +9,9 @@ async function getPayload(authHeader: string | undefined) { throw new Error('Unauthorized') } try { - return await verify(authHeader.slice(7), JWT_SECRET, 'HS256') as any + return await verify(authHeader.slice(7), env.JWT_SECRET, 'HS256') as any } catch (e: any) { - console.log('[getPayload] verify failed:', e?.message, 'JWT_SECRET set:', !!JWT_SECRET) + console.log('[getPayload] verify failed:', e?.message, 'JWT_SECRET set:', !!env.JWT_SECRET) throw e } } @@ -57,7 +56,7 @@ export function createProjectsRouter(registry: UserRegistry) { const payload = await getPayload(c.req.header('Authorization')) const res = await fetch( 'https://api.github.com/user/repos?sort=updated&per_page=100&affiliation=owner,collaborator,organization_member', - { headers: { Authorization: `Bearer ${payload.access_token}`, 'User-Agent': 'jingxia-kanban' } } + { headers: { Authorization: `Bearer ${payload.access_token}`, 'User-Agent': 'supercrew-app' } } ) const repos = await res.json() console.log('[github/repos] status:', res.status, 'count:', Array.isArray(repos) ? repos.length : repos) @@ -65,49 +64,18 @@ export function createProjectsRouter(registry: UserRegistry) { } catch { return c.json({ error: 'Unauthorized' }, 401) } }) - // 检查 repo 是否已有 .team/ 目录 + // 检查 repo 是否已有 .supercrew/features/ 目录 app.get('/github/repos/:owner/:repo/init-status', async (c) => { try { const payload = await getPayload(c.req.header('Authorization')) const { owner, repo } = c.req.param() const res = await fetch( - `https://api.github.com/repos/${owner}/${repo}/contents/.team`, - { headers: { Authorization: `Bearer ${payload.access_token}`, 'User-Agent': 'jingxia-kanban' } } + `https://api.github.com/repos/${owner}/${repo}/contents/.supercrew/features`, + { headers: { Authorization: `Bearer ${payload.access_token}`, 'User-Agent': 'supercrew-app' } } ) return c.json({ initialized: res.ok }) } catch { return c.json({ error: 'Unauthorized' }, 401) } }) - // 初始化 .team/ 目录 - app.post('/github/repos/:owner/:repo/init', async (c) => { - try { - const payload = await getPayload(c.req.header('Authorization')) - const { owner, repo } = c.req.param() - const headers = { - Authorization: `Bearer ${payload.access_token}`, - 'User-Agent': 'jingxia-kanban', - 'Content-Type': 'application/json', - } - const base = `https://api.github.com/repos/${owner}/${repo}/contents` - const readme = btoa(`# .team\n\nThis directory stores project data for Jingxia Kanban.\n\nFiles here are managed automatically. Do not edit manually.\n`) - - const initRes = await fetch(`${base}/.team/README.md`, { - method: 'PUT', - headers, - body: JSON.stringify({ message: 'chore: initialize .team directory', content: readme }), - signal: AbortSignal.timeout(30_000), - }) - - if (!initRes.ok) { - const err = await initRes.json() as any - throw new Error(err.message ?? 'GitHub API error') - } - - return c.json({ ok: true }) - } catch (e: any) { - return c.json({ error: e.message ?? 'Failed to initialize' }, 500) - } - }) - return app } diff --git a/kanban/backend/src/routes/sprints.ts b/kanban/backend/src/routes/sprints.ts deleted file mode 100644 index 3f93769..0000000 --- a/kanban/backend/src/routes/sprints.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Hono } from 'hono' -import { listSprintsGH, writeSprintGH } from '../store/github-store.js' -import { getGitHubContext } from '../lib/get-github-context.js' -import type { UserRegistry } from '../registry/types.js' -import type { Sprint } from '../types/index.js' - -export function createSprintsRouter(registry: UserRegistry) { - const app = new Hono() - - app.get('/', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - return c.json(await listSprintsGH(ctx.accessToken, ctx.owner, ctx.repo)) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.post('/', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const body = await c.req.json() - await writeSprintGH(ctx.accessToken, ctx.owner, ctx.repo, body) - return c.json(body, 201) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.patch('/:id', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const body = await c.req.json>() - const sprints = await listSprintsGH(ctx.accessToken, ctx.owner, ctx.repo) - const sprint = sprints.find(s => s.id === parseInt(c.req.param('id'))) - if (!sprint) return c.json({ error: 'Not found' }, 404) - const updated = { ...sprint, ...body } - await writeSprintGH(ctx.accessToken, ctx.owner, ctx.repo, updated) - return c.json(updated) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - return app -} diff --git a/kanban/backend/src/routes/tasks.ts b/kanban/backend/src/routes/tasks.ts deleted file mode 100644 index 7fb6648..0000000 --- a/kanban/backend/src/routes/tasks.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Hono } from 'hono' -import { listTasksGH, readTaskGH, writeTaskGH, deleteTaskGH } from '../store/github-store.js' -import { getGitHubContext } from '../lib/get-github-context.js' -import type { UserRegistry } from '../registry/types.js' -import type { Task, TaskStatus } from '../types/index.js' - -export function createTasksRouter(registry: UserRegistry) { - const app = new Hono() - - app.get('/', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const tasks = await listTasksGH(ctx.accessToken, ctx.owner, ctx.repo) - return c.json(tasks) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.get('/:id', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const task = await readTaskGH(ctx.accessToken, ctx.owner, ctx.repo, c.req.param('id')) - if (!task) return c.json({ error: 'Not found' }, 404) - return c.json(task) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.post('/', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const body = await c.req.json>() - if (!body.id || !body.title) return c.json({ error: 'id and title are required' }, 400) - const existing = await readTaskGH(ctx.accessToken, ctx.owner, ctx.repo, body.id) - if (existing) return c.json({ error: `Task ${body.id} already exists` }, 409) - const task: Task = { - id: body.id, - title: body.title, - status: body.status ?? 'backlog', - priority: body.priority ?? 'P2', - assignee: body.assignee, - team: body.team, - sprint: body.sprint, - created: new Date().toISOString().split('T')[0], - updated: new Date().toISOString().split('T')[0], - tags: body.tags ?? [], - blocks: body.blocks ?? [], - blocked_by: body.blocked_by ?? [], - plan_doc: body.plan_doc, - pr_url: body.pr_url, - body: body.body ?? '', - } - await writeTaskGH(ctx.accessToken, ctx.owner, ctx.repo, task) - return c.json(task, 201) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.patch('/:id', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const id = c.req.param('id') - const existing = await readTaskGH(ctx.accessToken, ctx.owner, ctx.repo, id) - if (!existing) return c.json({ error: 'Not found' }, 404) - const body = await c.req.json>() - const updated: Task = { ...existing, ...body, id } - await writeTaskGH(ctx.accessToken, ctx.owner, ctx.repo, updated) - return c.json(updated) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.put('/:id/status', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const id = c.req.param('id') - const { status } = await c.req.json<{ status: TaskStatus }>() - const existing = await readTaskGH(ctx.accessToken, ctx.owner, ctx.repo, id) - if (!existing) return c.json({ error: 'Not found' }, 404) - const updated = { ...existing, status } - await writeTaskGH(ctx.accessToken, ctx.owner, ctx.repo, updated) - return c.json(updated) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - app.delete('/:id', async (c) => { - try { - const ctx = await getGitHubContext(c.req.header('Authorization'), registry) - const deleted = await deleteTaskGH(ctx.accessToken, ctx.owner, ctx.repo, c.req.param('id')) - if (!deleted) return c.json({ error: 'Not found' }, 404) - return c.json({ ok: true }) - } catch (e: any) { - return c.json({ error: e.message }, e.message === 'Unauthorized' ? 401 : 400) - } - }) - - return app -} diff --git a/kanban/backend/src/store/github-store.ts b/kanban/backend/src/store/github-store.ts index debe2d8..b36f75b 100644 --- a/kanban/backend/src/store/github-store.ts +++ b/kanban/backend/src/store/github-store.ts @@ -1,5 +1,8 @@ import matter from 'gray-matter' -import type { Task, Sprint, Person, KnowledgeEntry, Decision } from '../types/index.js' +import yaml from 'js-yaml' +import type { + FeatureMeta, DesignDoc, PlanDoc, FeatureLog, Feature, SupercrewStatus, +} from '../types/index.js' const GH_API = 'https://api.github.com' const UA = 'supercrew-app' @@ -28,187 +31,114 @@ async function ghGet(token: string, owner: string, repo: string, path: string) { return res.json() as Promise<{ content: string; sha: string }> } -async function ghPut( - token: string, - owner: string, - repo: string, - path: string, - content: string, - message: string, - sha?: string, -) { - const body: Record = { message, content: btoa(unescape(encodeURIComponent(content))) } - if (sha) body.sha = sha - const res = await fetch(`${GH_API}/repos/${owner}/${repo}/contents/${path}`, { - method: 'PUT', - headers: ghHeaders(token), - body: JSON.stringify(body), - }) - if (!res.ok) { - const err = await res.json() as any - throw new Error(err.message ?? 'GitHub write failed') - } -} - -async function ghDelete( - token: string, - owner: string, - repo: string, - path: string, - message: string, -) { - const file = await ghGet(token, owner, repo, path) - if (!file) return false - const res = await fetch(`${GH_API}/repos/${owner}/${repo}/contents/${path}`, { - method: 'DELETE', - headers: ghHeaders(token), - body: JSON.stringify({ message, sha: file.sha }), - }) - return res.ok -} - function decodeContent(b64: string): string { return decodeURIComponent(escape(atob(b64.replace(/\n/g, '')))) } -// ─── Tasks ──────────────────────────────────────────────────────────────────── +// ─── Features (read-only) ───────────────────────────────────────────────────── + +const FEATURES_PATH = '.supercrew/features' -export async function listTasksGH(token: string, owner: string, repo: string): Promise { - const files = await ghList(token, owner, repo, '.team/tasks') - if (!files) return [] - const taskFiles = files.filter(f => f.name.endsWith('.md') && !f.name.startsWith('_')) - const tasks = await Promise.all( - taskFiles.map(f => readTaskGH(token, owner, repo, f.name.replace('.md', ''))) +/** List all feature directories */ +export async function listFeaturesGH( + token: string, owner: string, repo: string, +): Promise { + const dirs = await ghList(token, owner, repo, FEATURES_PATH) + if (!dirs) return [] + const featureDirs = dirs.filter(d => d.type === 'dir') + const metas = await Promise.all( + featureDirs.map(d => getFeatureMetaGH(token, owner, repo, d.name)) ) - return tasks.filter(Boolean) as Task[] + return metas.filter(Boolean) as FeatureMeta[] } -export async function readTaskGH(token: string, owner: string, repo: string, id: string): Promise { - const file = await ghGet(token, owner, repo, `.team/tasks/${id}.md`) +/** Read meta.yaml for a single feature */ +export async function getFeatureMetaGH( + token: string, owner: string, repo: string, id: string, +): Promise { + const file = await ghGet(token, owner, repo, `${FEATURES_PATH}/${id}/meta.yaml`) + if (!file) return null + const raw = yaml.load(decodeContent(file.content)) as Record + return { + id: raw.id ?? id, + title: raw.title ?? '', + status: raw.status ?? 'planning', + owner: raw.owner ?? '', + priority: raw.priority ?? 'P2', + teams: raw.teams ?? [], + target_release: raw.target_release, + created: raw.created ?? '', + updated: raw.updated ?? '', + tags: raw.tags ?? [], + blocked_by: raw.blocked_by ?? [], + } as FeatureMeta +} + +/** Read design.md for a single feature */ +export async function getFeatureDesignGH( + token: string, owner: string, repo: string, id: string, +): Promise { + const file = await ghGet(token, owner, repo, `${FEATURES_PATH}/${id}/design.md`) if (!file) return null const { data, content } = matter(decodeContent(file.content)) return { - id, - title: data.title ?? '', - status: data.status ?? 'backlog', - priority: data.priority ?? 'P2', - assignee: data.assignee, - team: data.team, - sprint: data.sprint, - created: data.created ?? new Date().toISOString().split('T')[0], - updated: data.updated ?? new Date().toISOString().split('T')[0], - tags: data.tags ?? [], - blocks: data.blocks ?? [], - blocked_by: data.blocked_by ?? [], - plan_doc: data.plan_doc, - pr_url: data.pr_url, + status: data.status ?? 'draft', + reviewers: data.reviewers ?? [], + approved_by: data.approved_by, body: content.trim(), - } as Task + } as DesignDoc } -export async function writeTaskGH(token: string, owner: string, repo: string, task: Task): Promise { - const { body, ...frontmatter } = task as any - frontmatter.updated = new Date().toISOString().split('T')[0] - const clean = Object.fromEntries(Object.entries(frontmatter).filter(([, v]) => v !== undefined)) - const content = matter.stringify(body ?? '', clean) - const existing = await ghGet(token, owner, repo, `.team/tasks/${task.id}.md`) - await ghPut(token, owner, repo, `.team/tasks/${task.id}.md`, content, `chore: update task ${task.id}`, existing?.sha) -} - -export async function deleteTaskGH(token: string, owner: string, repo: string, id: string): Promise { - return ghDelete(token, owner, repo, `.team/tasks/${id}.md`, `chore: delete task ${id}`) -} - -// ─── Sprints ────────────────────────────────────────────────────────────────── - -export async function listSprintsGH(token: string, owner: string, repo: string): Promise { - const files = await ghList(token, owner, repo, '.team/sprints') - if (!files) return [] - const sprintFiles = files.filter(f => f.name.endsWith('.json')) - const sprints = await Promise.all( - sprintFiles.map(async f => { - const file = await ghGet(token, owner, repo, `.team/sprints/${f.name}`) - if (!file) return null - return JSON.parse(decodeContent(file.content)) as Sprint - }) - ) - return (sprints.filter(Boolean) as Sprint[]).sort((a, b) => b.id - a.id) -} - -export async function writeSprintGH(token: string, owner: string, repo: string, sprint: Sprint): Promise { - const content = JSON.stringify(sprint, null, 2) - const existing = await ghGet(token, owner, repo, `.team/sprints/sprint-${sprint.id}.json`) - await ghPut(token, owner, repo, `.team/sprints/sprint-${sprint.id}.json`, content, `chore: update sprint ${sprint.id}`, existing?.sha) -} - -// ─── People ─────────────────────────────────────────────────────────────────── - -export async function listPeopleGH(token: string, owner: string, repo: string): Promise { - const files = await ghList(token, owner, repo, '.team/people') - if (!files) return [] - const personFiles = files.filter(f => f.name.endsWith('.md') && !f.name.startsWith('_')) - const people = await Promise.all( - personFiles.map(f => readPersonGH(token, owner, repo, f.name.replace('.md', ''))) - ) - return people.filter(Boolean) as Person[] -} - -export async function readPersonGH(token: string, owner: string, repo: string, username: string): Promise { - const file = await ghGet(token, owner, repo, `.team/people/${username}.md`) +/** Read plan.md for a single feature */ +export async function getFeaturePlanGH( + token: string, owner: string, repo: string, id: string, +): Promise { + const file = await ghGet(token, owner, repo, `${FEATURES_PATH}/${id}/plan.md`) if (!file) return null const { data, content } = matter(decodeContent(file.content)) return { - username, - name: data.name ?? username, - team: data.team ?? '', - updated: data.updated ?? '', - current_task: data.current_task, - blocked_by: data.blocked_by, - completed_today: data.completed_today ?? [], + total_tasks: data.total_tasks ?? 0, + completed_tasks: data.completed_tasks ?? 0, + progress: data.progress ?? 0, body: content.trim(), - } + } as PlanDoc } -export async function writePersonGH(token: string, owner: string, repo: string, person: Person): Promise { - const { body, ...frontmatter } = person - frontmatter.updated = new Date().toISOString().split('T')[0] - const content = matter.stringify(body ?? '', frontmatter) - const existing = await ghGet(token, owner, repo, `.team/people/${person.username}.md`) - await ghPut(token, owner, repo, `.team/people/${person.username}.md`, content, `chore: update person ${person.username}`, existing?.sha) -} - -// ─── Knowledge ──────────────────────────────────────────────────────────────── - -export async function listKnowledgeGH(token: string, owner: string, repo: string): Promise { - const files = await ghList(token, owner, repo, '.team/knowledge') - if (!files) return [] - const knowledgeFiles = files.filter(f => f.name.endsWith('.md') && !f.name.startsWith('_')) - const entries = await Promise.all( - knowledgeFiles.map(async f => { - const file = await ghGet(token, owner, repo, `.team/knowledge/${f.name}`) - if (!file) return null - const slug = f.name.replace('.md', '') - const { data, content } = matter(decodeContent(file.content)) - return { slug, title: data.title ?? slug, tags: data.tags ?? [], author: data.author ?? '', date: data.date ?? '', body: content.trim() } as KnowledgeEntry - }) - ) - return entries.filter(Boolean) as KnowledgeEntry[] +/** Read log.md for a single feature */ +export async function getFeatureLogGH( + token: string, owner: string, repo: string, id: string, +): Promise { + const file = await ghGet(token, owner, repo, `${FEATURES_PATH}/${id}/log.md`) + if (!file) return null + return { body: decodeContent(file.content).trim() } as FeatureLog +} + +/** Get full feature with all documents */ +export async function getFeatureGH( + token: string, owner: string, repo: string, id: string, +): Promise { + const meta = await getFeatureMetaGH(token, owner, repo, id) + if (!meta) return null + const [design, plan, log] = await Promise.all([ + getFeatureDesignGH(token, owner, repo, id), + getFeaturePlanGH(token, owner, repo, id), + getFeatureLogGH(token, owner, repo, id), + ]) + return { + meta, + design: design ?? undefined, + plan: plan ?? undefined, + log: log ?? undefined, + } } -// ─── Decisions ──────────────────────────────────────────────────────────────── - -export async function listDecisionsGH(token: string, owner: string, repo: string): Promise { - const files = await ghList(token, owner, repo, '.team/decisions') - if (!files) return [] - const decisionFiles = files.filter(f => f.name.endsWith('.md') && !f.name.startsWith('_')) - const decisions = await Promise.all( - decisionFiles.map(async f => { - const file = await ghGet(token, owner, repo, `.team/decisions/${f.name}`) - if (!file) return null - const id = f.name.replace('.md', '') - const { data, content } = matter(decodeContent(file.content)) - return { id, title: data.title ?? '', date: data.date ?? '', author: data.author ?? '', status: data.status ?? 'proposed', body: content.trim() } as Decision - }) +/** Check if .supercrew/features/ exists in a repo */ +export async function checkSupercrewExistsGH( + token: string, owner: string, repo: string, +): Promise { + const res = await fetch( + `${GH_API}/repos/${owner}/${repo}/contents/${FEATURES_PATH}`, + { headers: ghHeaders(token) }, ) - return (decisions.filter(Boolean) as Decision[]).sort((a, b) => a.id.localeCompare(b.id)) + return res.ok } diff --git a/kanban/backend/src/store/index.ts b/kanban/backend/src/store/index.ts index 5f372a7..858cd28 100644 --- a/kanban/backend/src/store/index.ts +++ b/kanban/backend/src/store/index.ts @@ -1,184 +1,100 @@ -// File-based store — reads and writes .team/ directory as MD/JSON -// All data lives in the git repo alongside code +// File-based store — reads .supercrew/features/ directory (read-only) +// Used for local development / debug only -import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, unlinkSync } from 'fs' -import { join, basename } from 'path' +import { readFileSync, readdirSync, existsSync } from 'fs' +import { join } from 'path' import matter from 'gray-matter' -import type { Task, Sprint, Person, KnowledgeEntry, Decision, TaskStatus } from '../types/index.js' - -// Root of the .team/ directory — resolved relative to the project being managed -// Can be overridden via TEAM_DIR env var -function getTeamDir(): string { - return process.env.TEAM_DIR ?? join(process.cwd(), '../..', '.team') +import yaml from 'js-yaml' +import type { + FeatureMeta, DesignDoc, PlanDoc, FeatureLog, Feature, SupercrewStatus, +} from '../types/index.js' + +// Root of the .supercrew/ directory — resolved relative to the project being managed +function getSupercrewDir(): string { + return process.env.SUPERCREW_DIR ?? join(process.cwd(), '../..', '.supercrew') } -function ensureDir(path: string) { - if (!existsSync(path)) mkdirSync(path, { recursive: true }) -} +const FEATURES_PATH = 'features' -// ─── Tasks ──────────────────────────────────────────────────────────────────── +// ─── Features (read-only) ───────────────────────────────────────────────────── -export function listTasks(): Task[] { - const dir = join(getTeamDir(), 'tasks') +/** List all features from local .supercrew/features/ */ +export function listFeatures(): FeatureMeta[] { + const dir = join(getSupercrewDir(), FEATURES_PATH) if (!existsSync(dir)) return [] - return readdirSync(dir) - .filter(f => f.endsWith('.md') && !f.startsWith('_')) - .map(f => readTask(basename(f, '.md'))) - .filter(Boolean) as Task[] + return readdirSync(dir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => getFeatureMeta(d.name)) + .filter(Boolean) as FeatureMeta[] } -export function readTask(id: string): Task | null { - const file = join(getTeamDir(), 'tasks', `${id}.md`) +/** Read meta.yaml for a single feature */ +export function getFeatureMeta(id: string): FeatureMeta | null { + const file = join(getSupercrewDir(), FEATURES_PATH, id, 'meta.yaml') if (!existsSync(file)) return null + const raw = yaml.load(readFileSync(file, 'utf8')) as Record + return { + id: raw.id ?? id, + title: raw.title ?? '', + status: raw.status ?? 'planning', + owner: raw.owner ?? '', + priority: raw.priority ?? 'P2', + teams: raw.teams ?? [], + target_release: raw.target_release, + created: raw.created ?? '', + updated: raw.updated ?? '', + tags: raw.tags ?? [], + blocked_by: raw.blocked_by ?? [], + } as FeatureMeta +} +/** Read design.md for a single feature */ +export function getFeatureDesign(id: string): DesignDoc | null { + const file = join(getSupercrewDir(), FEATURES_PATH, id, 'design.md') + if (!existsSync(file)) return null const { data, content } = matter(readFileSync(file, 'utf8')) return { - id, - title: data.title ?? '', - status: data.status ?? 'backlog', - priority: data.priority ?? 'P2', - assignee: data.assignee, - team: data.team, - sprint: data.sprint, - created: data.created ?? new Date().toISOString().split('T')[0], - updated: data.updated ?? new Date().toISOString().split('T')[0], - tags: data.tags ?? [], - blocks: data.blocks ?? [], - blocked_by: data.blocked_by ?? [], - plan_doc: data.plan_doc, - pr_url: data.pr_url, + status: data.status ?? 'draft', + reviewers: data.reviewers ?? [], + approved_by: data.approved_by, body: content.trim(), - } -} - -export function writeTask(task: Task): void { - ensureDir(join(getTeamDir(), 'tasks')) - const { body, ...frontmatter } = task - frontmatter.updated = new Date().toISOString().split('T')[0] - const clean = Object.fromEntries(Object.entries(frontmatter).filter(([, v]) => v !== undefined)) - const file = join(getTeamDir(), 'tasks', `${task.id}.md`) - writeFileSync(file, matter.stringify(body, clean)) -} - -export function updateTaskStatus(id: string, status: TaskStatus): Task | null { - const task = readTask(id) - if (!task) return null - task.status = status - writeTask(task) - return task -} - -export function deleteTask(id: string): boolean { - const file = join(getTeamDir(), 'tasks', `${id}.md`) - if (!existsSync(file)) return false - unlinkSync(file) - return true -} - -// ─── Sprints ────────────────────────────────────────────────────────────────── - -export function listSprints(): Sprint[] { - const dir = join(getTeamDir(), 'sprints') - if (!existsSync(dir)) return [] - - return readdirSync(dir) - .filter(f => f.endsWith('.json')) - .map(f => { - const raw = readFileSync(join(dir, f), 'utf8') - return JSON.parse(raw) as Sprint - }) - .sort((a, b) => b.id - a.id) -} - -export function activeSprint(): Sprint | null { - return listSprints().find(s => s.status === 'active') ?? null + } as DesignDoc } -export function writeSprint(sprint: Sprint): void { - ensureDir(join(getTeamDir(), 'sprints')) - const file = join(getTeamDir(), 'sprints', `sprint-${sprint.id}.json`) - writeFileSync(file, JSON.stringify(sprint, null, 2)) -} - -// ─── People ─────────────────────────────────────────────────────────────────── - -export function listPeople(): Person[] { - const dir = join(getTeamDir(), 'people') - if (!existsSync(dir)) return [] - - return readdirSync(dir) - .filter(f => f.endsWith('.md') && !f.startsWith('_')) - .map(f => readPerson(basename(f, '.md'))) - .filter(Boolean) as Person[] -} - -export function readPerson(username: string): Person | null { - const file = join(getTeamDir(), 'people', `${username}.md`) +/** Read plan.md for a single feature */ +export function getFeaturePlan(id: string): PlanDoc | null { + const file = join(getSupercrewDir(), FEATURES_PATH, id, 'plan.md') if (!existsSync(file)) return null - const { data, content } = matter(readFileSync(file, 'utf8')) return { - username, - name: data.name ?? username, - team: data.team ?? '', - updated: data.updated ?? '', - current_task: data.current_task, - blocked_by: data.blocked_by, - completed_today: data.completed_today ?? [], + total_tasks: data.total_tasks ?? 0, + completed_tasks: data.completed_tasks ?? 0, + progress: data.progress ?? 0, body: content.trim(), - } + } as PlanDoc } -export function writePerson(person: Person): void { - ensureDir(join(getTeamDir(), 'people')) - const { body, ...frontmatter } = person - frontmatter.updated = new Date().toISOString().split('T')[0] - const file = join(getTeamDir(), 'people', `${person.username}.md`) - writeFileSync(file, matter.stringify(body, frontmatter)) +/** Read log.md for a single feature */ +export function getFeatureLog(id: string): FeatureLog | null { + const file = join(getSupercrewDir(), FEATURES_PATH, id, 'log.md') + if (!existsSync(file)) return null + return { body: readFileSync(file, 'utf8').trim() } as FeatureLog } -// ─── Knowledge ──────────────────────────────────────────────────────────────── - -export function listKnowledge(): KnowledgeEntry[] { - const dir = join(getTeamDir(), 'knowledge') - if (!existsSync(dir)) return [] - - return readdirSync(dir) - .filter(f => f.endsWith('.md') && !f.startsWith('_')) - .map(f => { - const slug = basename(f, '.md') - const { data, content } = matter(readFileSync(join(dir, f), 'utf8')) - return { - slug, - title: data.title ?? slug, - tags: data.tags ?? [], - author: data.author ?? '', - date: data.date ?? '', - body: content.trim(), - } as KnowledgeEntry - }) +/** Get full feature with all documents */ +export function getFeature(id: string): Feature | null { + const meta = getFeatureMeta(id) + if (!meta) return null + return { + meta, + design: getFeatureDesign(id) ?? undefined, + plan: getFeaturePlan(id) ?? undefined, + log: getFeatureLog(id) ?? undefined, + } } -// ─── Decisions (ADR) ────────────────────────────────────────────────────────── - -export function listDecisions(): Decision[] { - const dir = join(getTeamDir(), 'decisions') - if (!existsSync(dir)) return [] - - return readdirSync(dir) - .filter(f => f.endsWith('.md') && !f.startsWith('_')) - .map(f => { - const id = basename(f, '.md') - const { data, content } = matter(readFileSync(join(dir, f), 'utf8')) - return { - id, - title: data.title ?? '', - date: data.date ?? '', - author: data.author ?? '', - status: data.status ?? 'proposed', - body: content.trim(), - } as Decision - }) - .sort((a, b) => a.id.localeCompare(b.id)) +/** Check if .supercrew/features/ exists locally */ +export function checkSupercrewExists(): boolean { + return existsSync(join(getSupercrewDir(), FEATURES_PATH)) } diff --git a/kanban/backend/src/types/index.ts b/kanban/backend/src/types/index.ts index f240485..980114b 100644 --- a/kanban/backend/src/types/index.ts +++ b/kanban/backend/src/types/index.ts @@ -1,67 +1,56 @@ -// Core data types — mirrors what lives in .team/ as MD/JSON files +// ─── SuperCrew Feature-Centric Types ────────────────────────────────────────── +// Data lives in .supercrew/features/ as YAML/MD files in the user's repo -export type TaskStatus = 'backlog' | 'todo' | 'in-progress' | 'in-review' | 'done' -export type TaskPriority = 'P0' | 'P1' | 'P2' | 'P3' +export type SupercrewStatus = 'planning' | 'designing' | 'ready' | 'active' | 'blocked' | 'done' +export type FeaturePriority = 'P0' | 'P1' | 'P2' | 'P3' +export type DesignStatus = 'draft' | 'in-review' | 'approved' | 'rejected' -export interface Task { - id: string // e.g. ENG-001 +/** meta.yaml — feature metadata */ +export interface FeatureMeta { + id: string title: string - status: TaskStatus - priority: TaskPriority - assignee?: string // username - team?: string - sprint?: number - created: string // ISO date - updated: string - tags: string[] - blocks: string[] // task IDs this blocks - blocked_by: string[] // task IDs blocking this - plan_doc?: string // path to docs/plans//implementation-plan.md - pr_url?: string - body: string // markdown body (background, acceptance criteria, discussion) + status: SupercrewStatus + owner: string + priority: FeaturePriority + teams?: string[] + target_release?: string + created: string // ISO date + updated: string // ISO date + tags?: string[] + blocked_by?: string[] } -export interface Sprint { - id: number - name: string - start: string // ISO date - end: string - goal: string - status: 'planned' | 'active' | 'completed' +/** design.md — frontmatter + markdown body */ +export interface DesignDoc { + status: DesignStatus + reviewers: string[] + approved_by?: string + body: string // markdown body } -export interface Person { - username: string - name: string - team: string - updated: string - current_task?: string // task ID - blocked_by?: string // task ID or description - completed_today: string[] - body: string // markdown notes +/** plan.md — frontmatter + tasks breakdown markdown */ +export interface PlanDoc { + total_tasks: number + completed_tasks: number + progress: number // 0–100 + body: string // markdown body with task checklist } -export interface KnowledgeEntry { - slug: string // filename without .md - title: string - tags: string[] - author: string - date: string - body: string +/** log.md — pure markdown */ +export interface FeatureLog { + body: string // markdown content } -export interface Decision { - id: string // ADR-001 - title: string - date: string - author: string - status: 'proposed' | 'accepted' | 'deprecated' | 'superseded' - body: string +/** Aggregated feature for API responses */ +export interface Feature { + meta: FeatureMeta + design?: DesignDoc + plan?: PlanDoc + log?: FeatureLog } -// API response shapes -export interface KanbanBoard { - tasks: Task[] - sprints: Sprint[] - people: Person[] +/** Board response — features grouped by status */ +export interface FeatureBoard { + features: FeatureMeta[] + featuresByStatus: Record } diff --git a/kanban/frontend/packages/app-core/src/__tests__/api.test.ts b/kanban/frontend/packages/app-core/src/__tests__/api.test.ts index 04496c7..bc542a5 100644 --- a/kanban/frontend/packages/app-core/src/__tests__/api.test.ts +++ b/kanban/frontend/packages/app-core/src/__tests__/api.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { fetchBoard, createTask } from '../api.js' +import { fetchBoard } from '../api.js' describe('API error handling', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }) }) afterEach(() => { @@ -18,7 +23,7 @@ describe('API error handling', () => { }) it('returns parsed JSON on 200', async () => { - const board = { tasks: [], sprints: [], people: [] } + const board = { features: [], columns: {} } vi.mocked(globalThis.fetch).mockResolvedValue( new Response(JSON.stringify(board), { status: 200 }), ) @@ -26,36 +31,3 @@ describe('API error handling', () => { expect(result).toEqual(board) }) }) - -describe('createTask', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('sends POST /api/tasks with JSON body', async () => { - const payload = { - id: 'ENG-001', - title: 'Test task', - status: 'backlog' as const, - priority: 'P2' as const, - tags: [], - blocks: [], - blocked_by: [], - body: '', - } - const mockResponse = { ...payload, created: '2026-01-01', updated: '2026-01-01' } - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }), - ) - await createTask(payload) - expect(globalThis.fetch).toHaveBeenCalledWith('/api/tasks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - }) -}) diff --git a/kanban/frontend/packages/app-core/src/api.ts b/kanban/frontend/packages/app-core/src/api.ts index ccec585..1b1dafe 100644 --- a/kanban/frontend/packages/app-core/src/api.ts +++ b/kanban/frontend/packages/app-core/src/api.ts @@ -1,77 +1,42 @@ -import type { Task, Sprint, Person, Board, TaskStatus } from './types.js' +import type { FeatureBoard, FeatureMeta, Feature, DesignDoc, PlanDoc } from './types.js' +import { authHeaders, clearToken } from './auth.js' const BASE = '/api' +let redirecting = false + async function json(res: Response): Promise { - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + if (!res.ok) { + if (res.status === 401 && !redirecting) { + redirecting = true + clearToken() + window.location.href = '/login' + } + throw new Error(`${res.status} ${res.statusText}`) + } return res.json() as Promise } -// ─── Board ──────────────────────────────────────────────────────────────────── - -export const fetchBoard = (): Promise => - fetch(`${BASE}/board`).then(json) - -// ─── Tasks ──────────────────────────────────────────────────────────────────── - -export const fetchTasks = (): Promise => - fetch(`${BASE}/tasks`).then(json) - -export const fetchTask = (id: string): Promise => - fetch(`${BASE}/tasks/${id}`).then(json) - -export const createTask = (task: Omit): Promise => - fetch(`${BASE}/tasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(task), - }).then(json) - -export const updateTask = (id: string, patch: Partial): Promise => - fetch(`${BASE}/tasks/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }).then(json) - -export const updateTaskStatus = (id: string, status: TaskStatus): Promise => - fetch(`${BASE}/tasks/${id}/status`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status }), - }).then(json) - -export const deleteTask = (id: string): Promise<{ ok: boolean }> => - fetch(`${BASE}/tasks/${id}`, { method: 'DELETE' }).then(json<{ ok: boolean }>) - -// ─── Sprints ────────────────────────────────────────────────────────────────── - -export const fetchSprints = (): Promise => - fetch(`${BASE}/sprints`).then(json) +/** Merge auth + extra headers */ +function headers(extra?: Record): Record { + return { ...authHeaders(), ...extra } +} -export const updateSprint = (id: number, patch: Partial): Promise => - fetch(`${BASE}/sprints/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }).then(json) +// ─── Board (aggregate) ─────────────────────────────────────────────────────── -// ─── People ─────────────────────────────────────────────────────────────────── +export const fetchBoard = (): Promise => + fetch(`${BASE}/board`, { headers: headers() }).then(json) -export const fetchPeople = (): Promise => - fetch(`${BASE}/people`).then(json) +// ─── Features (read-only) ──────────────────────────────────────────────────── -export const updatePerson = (username: string, patch: Partial): Promise => - fetch(`${BASE}/people/${username}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }).then(json) +export const fetchFeatures = (): Promise => + fetch(`${BASE}/features`, { headers: headers() }).then(json) -// ─── Knowledge & Decisions ──────────────────────────────────────────────────── +export const fetchFeature = (id: string): Promise => + fetch(`${BASE}/features/${id}`, { headers: headers() }).then(json) -export const fetchKnowledge = (): Promise => - fetch(`${BASE}/knowledge`).then(json) +export const fetchFeatureDesign = (id: string): Promise => + fetch(`${BASE}/features/${id}/design`, { headers: headers() }).then(json) -export const fetchDecisions = (): Promise => - fetch(`${BASE}/decisions`).then(json) +export const fetchFeaturePlan = (id: string): Promise => + fetch(`${BASE}/features/${id}/plan`, { headers: headers() }).then(json) diff --git a/kanban/frontend/packages/app-core/src/auth.ts b/kanban/frontend/packages/app-core/src/auth.ts index fb61ff7..08196a4 100644 --- a/kanban/frontend/packages/app-core/src/auth.ts +++ b/kanban/frontend/packages/app-core/src/auth.ts @@ -27,3 +27,21 @@ export function authHeaders(): Record { const token = getToken() return token ? { Authorization: `Bearer ${token}` } : {} } + +/** + * Server-side token verification via /auth/me. + * Returns true if the token is valid (signature + expiry), false otherwise. + * On failure, automatically clears the invalid token from localStorage. + */ +export async function verifyToken(): Promise { + if (!isAuthenticated()) return false + try { + const res = await fetch('/auth/me', { headers: authHeaders() }) + if (res.ok) return true + clearToken() + return false + } catch { + clearToken() + return false + } +} diff --git a/kanban/frontend/packages/app-core/src/hooks/useBoard.ts b/kanban/frontend/packages/app-core/src/hooks/useBoard.ts index 3ba05bc..e870f5b 100644 --- a/kanban/frontend/packages/app-core/src/hooks/useBoard.ts +++ b/kanban/frontend/packages/app-core/src/hooks/useBoard.ts @@ -1,11 +1,16 @@ import { useEffect } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { fetchBoard } from '../api.js' -import type { Board } from '../types.js' +import type { FeatureBoard } from '../types.js' export const BOARD_KEY = ['board'] as const -const EMPTY_BOARD: Board = { tasks: [], sprints: [], people: [] } +const EMPTY_BOARD: FeatureBoard = { + features: [], + featuresByStatus: { + planning: [], designing: [], ready: [], active: [], blocked: [], done: [], + }, +} export function useBoard() { const queryClient = useQueryClient() @@ -27,10 +32,8 @@ export function useBoard() { const board = data ?? EMPTY_BOARD return { board, - tasks: board.tasks, - sprints: board.sprints, - people: board.people, - activeSprint: board.sprints.find(s => s.status === 'active') ?? null, + features: board.features, + featuresByStatus: board.featuresByStatus, isLoading, error, } diff --git a/kanban/frontend/packages/app-core/src/hooks/useMutations.ts b/kanban/frontend/packages/app-core/src/hooks/useMutations.ts index 32ed4cc..66316f2 100644 --- a/kanban/frontend/packages/app-core/src/hooks/useMutations.ts +++ b/kanban/frontend/packages/app-core/src/hooks/useMutations.ts @@ -1,95 +1,4 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { - createTask, - updateTask, - updateTaskStatus, - deleteTask, - updateSprint, - updatePerson, -} from '../api.js' -import type { Task, TaskStatus } from '../types.js' -import { BOARD_KEY } from './useBoard.js' +// No mutations — kanban is read-only. +// All data changes happen via the Claude Code plugin in the user's repo. -function useInvalidateBoard() { - const qc = useQueryClient() - return () => qc.invalidateQueries({ queryKey: BOARD_KEY }) -} - -// ─── Task mutations ─────────────────────────────────────────────────────────── - -export function useCreateTask() { - const invalidate = useInvalidateBoard() - return useMutation({ - mutationFn: (task: Omit) => createTask(task), - onSuccess: invalidate, - }) -} - -export function useUpdateTask() { - const invalidate = useInvalidateBoard() - return useMutation({ - mutationFn: ({ id, patch }: { id: string; patch: Partial }) => - updateTask(id, patch), - onSuccess: invalidate, - }) -} - -export function useUpdateTaskStatus() { - const qc = useQueryClient() - return useMutation({ - mutationFn: ({ id, status }: { id: string; status: TaskStatus }) => - updateTaskStatus(id, status), - // Optimistic update — flip status in cache immediately - onMutate: async ({ id, status }) => { - await qc.cancelQueries({ queryKey: BOARD_KEY }) - const prev = qc.getQueryData(BOARD_KEY) - qc.setQueryData(BOARD_KEY, (old: { tasks: Task[] } | undefined) => { - if (!old) return old - return { - ...old, - tasks: old.tasks.map(t => (t.id === id ? { ...t, status } : t)), - } - }) - return { prev } - }, - onError: (_err, _vars, ctx) => { - if (ctx?.prev) qc.setQueryData(BOARD_KEY, ctx.prev) - }, - onSettled: () => qc.invalidateQueries({ queryKey: BOARD_KEY }), - }) -} - -export function useDeleteTask() { - const invalidate = useInvalidateBoard() - return useMutation({ - mutationFn: (id: string) => deleteTask(id), - onSuccess: invalidate, - }) -} - -// ─── Sprint mutations ───────────────────────────────────────────────────────── - -export function useUpdateSprint() { - const invalidate = useInvalidateBoard() - return useMutation({ - mutationFn: ({ id, patch }: { id: number; patch: Parameters[1] }) => - updateSprint(id, patch), - onSuccess: invalidate, - }) -} - -// ─── Person mutations ───────────────────────────────────────────────────────── - -export function useUpdatePerson() { - const invalidate = useInvalidateBoard() - return useMutation({ - mutationFn: ({ - username, - patch, - }: { - username: string - patch: Parameters[1] - }) => updatePerson(username, patch), - onSuccess: invalidate, - }) -} +export {} diff --git a/kanban/frontend/packages/app-core/src/index.ts b/kanban/frontend/packages/app-core/src/index.ts index f318328..0108324 100644 --- a/kanban/frontend/packages/app-core/src/index.ts +++ b/kanban/frontend/packages/app-core/src/index.ts @@ -1,6 +1,5 @@ export * from './types.js' export * from './api.js' export * from './hooks/useBoard.js' -export * from './hooks/useMutations.js' export * from './auth.js' export * from './hooks/useAuth.js' diff --git a/kanban/frontend/packages/app-core/src/types.ts b/kanban/frontend/packages/app-core/src/types.ts index cd43bdd..8647a23 100644 --- a/kanban/frontend/packages/app-core/src/types.ts +++ b/kanban/frontend/packages/app-core/src/types.ts @@ -1,66 +1,49 @@ // Mirror of backend types — keep in sync with kanban/backend/src/types/index.ts -export type TaskStatus = 'backlog' | 'todo' | 'in-progress' | 'in-review' | 'done' -export type TaskPriority = 'P0' | 'P1' | 'P2' | 'P3' +export type SupercrewStatus = 'planning' | 'designing' | 'ready' | 'active' | 'blocked' | 'done' +export type FeaturePriority = 'P0' | 'P1' | 'P2' | 'P3' +export type DesignStatus = 'draft' | 'in-review' | 'approved' | 'rejected' -export interface Task { +export interface FeatureMeta { id: string title: string - status: TaskStatus - priority: TaskPriority - assignee?: string - team?: string - sprint?: number + status: SupercrewStatus + owner: string + priority: FeaturePriority + teams?: string[] + target_release?: string created: string updated: string - tags: string[] - blocks: string[] - blocked_by: string[] - plan_doc?: string - pr_url?: string - body: string + tags?: string[] + blocked_by?: string[] } -export interface Sprint { - id: number - name: string - start: string - end: string - goal: string - status: 'planned' | 'active' | 'completed' +export interface DesignDoc { + status: DesignStatus + reviewers: string[] + approved_by?: string + body: string } -export interface Person { - username: string - name: string - team: string - updated: string - current_task?: string - blocked_by?: string - completed_today: string[] +export interface PlanDoc { + total_tasks: number + completed_tasks: number + progress: number body: string } -export interface KnowledgeEntry { - slug: string - title: string - tags: string[] - author: string - date: string +export interface FeatureLog { body: string } -export interface Decision { - id: string - title: string - date: string - author: string - status: 'proposed' | 'accepted' | 'deprecated' | 'superseded' - body: string +export interface Feature { + meta: FeatureMeta + design?: DesignDoc + plan?: PlanDoc + log?: FeatureLog } -export interface Board { - tasks: Task[] - sprints: Sprint[] - people: Person[] +export interface FeatureBoard { + features: FeatureMeta[] + featuresByStatus: Record } diff --git a/kanban/frontend/packages/local-web/src/locales/en.json b/kanban/frontend/packages/local-web/src/locales/en.json index a7d71ee..d3b9f9e 100644 --- a/kanban/frontend/packages/local-web/src/locales/en.json +++ b/kanban/frontend/packages/local-web/src/locales/en.json @@ -1,9 +1,6 @@ { "nav": { - "board": "Board", - "people": "People", - "knowledge": "Knowledge", - "decisions": "Decisions" + "board": "Board" }, "sidebar": { "lightMode": "Light mode", @@ -14,13 +11,12 @@ }, "board": { "loading": "Loading…", - "activeSprint": "Active Sprint", - "addTo": "Add to {{name}}", "columns": { - "backlog": "Backlog", - "todo": "To Do", - "inProgress": "In Progress", - "inReview": "In Review", + "planning": "Planning", + "designing": "Designing", + "ready": "Ready", + "active": "Active", + "blocked": "Blocked", "done": "Done" } }, @@ -54,11 +50,11 @@ "private": "private" }, "step3": { - "initializing": "Initializing .team/ directory…", - "success": "Initialized successfully, entering board…", + "binding": "Binding repo…", + "success": "Repo bound successfully, entering board…", "retry": "Retry", "noRepo": "Please go back and select a repo first", - "initFailed": "Initialization failed", + "bindFailed": "Binding failed", "unknownError": "Unknown error" }, "nav": { @@ -66,39 +62,39 @@ "next": "Next" } }, - "people": { - "title": "Team", - "empty": "No people found. Add .md files to .team/people/", - "workingOn": "Working on", - "today": "Today" - }, - "knowledge": { - "title": "Knowledge Base", - "loading": "Loading…", - "empty": "No entries yet. Add .md files to .team/knowledge/" - }, - "decisions": { - "title": "Architecture Decisions", + "feature": { + "notFound": "Feature {{id}} not found", "loading": "Loading…", - "empty": "No ADRs yet. Add .md files to .team/decisions/" - }, - "task": { - "notFound": "Task {{id}} not found", + "tabs": { + "overview": "Overview", + "design": "Design", + "plan": "Plan" + }, "props": { "status": "Status", "priority": "Priority", - "assignee": "Assignee", - "sprint": "Sprint", + "owner": "Owner", + "teams": "Teams", "tags": "Tags", - "pr": "PR", - "blockedBy": "Blocked by", - "blocks": "Blocks" + "targetRelease": "Target Release", + "created": "Created", + "updated": "Updated", + "blockedBy": "Blocked by" + }, + "design": { + "status": "Design Status", + "reviewers": "Reviewers", + "approvedBy": "Approved by", + "noDesign": "No design document yet" }, - "sprintLabel": "Sprint {{n}}", - "description": "Description", - "clickToEdit": "Click to edit", - "addDescription": "Click to add description…", - "save": "Save", - "cancel": "Cancel" + "plan": { + "progress": "Progress", + "tasks": "Tasks", + "noPlan": "No implementation plan yet" + } + }, + "empty": { + "title": "No features yet", + "description": "Install the SuperCrew plugin in your repo and use /new-feature to create your first feature." } } diff --git a/kanban/frontend/packages/local-web/src/locales/zh.json b/kanban/frontend/packages/local-web/src/locales/zh.json index e0eab18..86c22c4 100644 --- a/kanban/frontend/packages/local-web/src/locales/zh.json +++ b/kanban/frontend/packages/local-web/src/locales/zh.json @@ -1,9 +1,6 @@ { "nav": { - "board": "看板", - "people": "成员", - "knowledge": "知识库", - "decisions": "决策记录" + "board": "看板" }, "sidebar": { "lightMode": "亮色模式", @@ -14,13 +11,12 @@ }, "board": { "loading": "加载中…", - "activeSprint": "当前 Sprint", - "addTo": "添加到 {{name}}", "columns": { - "backlog": "Backlog", - "todo": "待办", - "inProgress": "进行中", - "inReview": "审查中", + "planning": "规划中", + "designing": "设计中", + "ready": "就绪", + "active": "进行中", + "blocked": "阻塞", "done": "已完成" } }, @@ -54,11 +50,11 @@ "private": "私有" }, "step3": { - "initializing": "正在初始化 .team/ 目录…", - "success": "初始化成功,即将进入看板…", + "binding": "正在绑定 Repo…", + "success": "绑定成功,即将进入看板…", "retry": "重试", "noRepo": "请先返回上一步选择 Repo", - "initFailed": "初始化失败", + "bindFailed": "绑定失败", "unknownError": "未知错误" }, "nav": { @@ -66,39 +62,39 @@ "next": "下一步" } }, - "people": { - "title": "团队", - "empty": "暂无成员。在 .team/people/ 中添加 .md 文件", - "workingOn": "正在做", - "today": "今日完成" - }, - "knowledge": { - "title": "知识库", - "loading": "加载中…", - "empty": "暂无条目。在 .team/knowledge/ 中添加 .md 文件" - }, - "decisions": { - "title": "架构决策", + "feature": { + "notFound": "找不到 Feature {{id}}", "loading": "加载中…", - "empty": "暂无 ADR。在 .team/decisions/ 中添加 .md 文件" - }, - "task": { - "notFound": "找不到任务 {{id}}", + "tabs": { + "overview": "概览", + "design": "设计", + "plan": "计划" + }, "props": { "status": "状态", "priority": "优先级", - "assignee": "负责人", - "sprint": "Sprint", + "owner": "负责人", + "teams": "团队", "tags": "标签", - "pr": "PR", - "blockedBy": "被阻塞", - "blocks": "阻塞" + "targetRelease": "目标版本", + "created": "创建时间", + "updated": "更新时间", + "blockedBy": "被阻塞" + }, + "design": { + "status": "设计状态", + "reviewers": "审阅者", + "approvedBy": "批准人", + "noDesign": "暂无设计文档" }, - "sprintLabel": "Sprint {{n}}", - "description": "描述", - "clickToEdit": "点击编辑", - "addDescription": "点击添加描述…", - "save": "保存", - "cancel": "取消" + "plan": { + "progress": "进度", + "tasks": "任务", + "noPlan": "暂无实施计划" + } + }, + "empty": { + "title": "暂无 Feature", + "description": "请在你的 repo 中安装 SuperCrew 插件,并使用 /new-feature 创建第一个 feature。" } } diff --git a/kanban/frontend/packages/local-web/src/routeTree.gen.ts b/kanban/frontend/packages/local-web/src/routeTree.gen.ts index 30b2250..2a2fe9e 100644 --- a/kanban/frontend/packages/local-web/src/routeTree.gen.ts +++ b/kanban/frontend/packages/local-web/src/routeTree.gen.ts @@ -10,12 +10,9 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WelcomeRouteImport } from './routes/welcome' -import { Route as PeopleRouteImport } from './routes/people' import { Route as LoginRouteImport } from './routes/login' -import { Route as KnowledgeRouteImport } from './routes/knowledge' -import { Route as DecisionsRouteImport } from './routes/decisions' import { Route as IndexRouteImport } from './routes/index' -import { Route as TasksIdRouteImport } from './routes/tasks.$id' +import { Route as FeaturesIdRouteImport } from './routes/features.$id' import { Route as AuthCallbackRouteImport } from './routes/auth.callback' const WelcomeRoute = WelcomeRouteImport.update({ @@ -23,34 +20,19 @@ const WelcomeRoute = WelcomeRouteImport.update({ path: '/welcome', getParentRoute: () => rootRouteImport, } as any) -const PeopleRoute = PeopleRouteImport.update({ - id: '/people', - path: '/people', - getParentRoute: () => rootRouteImport, -} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', getParentRoute: () => rootRouteImport, } as any) -const KnowledgeRoute = KnowledgeRouteImport.update({ - id: '/knowledge', - path: '/knowledge', - getParentRoute: () => rootRouteImport, -} as any) -const DecisionsRoute = DecisionsRouteImport.update({ - id: '/decisions', - path: '/decisions', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) -const TasksIdRoute = TasksIdRouteImport.update({ - id: '/tasks/$id', - path: '/tasks/$id', +const FeaturesIdRoute = FeaturesIdRouteImport.update({ + id: '/features/$id', + path: '/features/$id', getParentRoute: () => rootRouteImport, } as any) const AuthCallbackRoute = AuthCallbackRouteImport.update({ @@ -61,77 +43,46 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/decisions': typeof DecisionsRoute - '/knowledge': typeof KnowledgeRoute '/login': typeof LoginRoute - '/people': typeof PeopleRoute '/welcome': typeof WelcomeRoute '/auth/callback': typeof AuthCallbackRoute - '/tasks/$id': typeof TasksIdRoute + '/features/$id': typeof FeaturesIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/decisions': typeof DecisionsRoute - '/knowledge': typeof KnowledgeRoute '/login': typeof LoginRoute - '/people': typeof PeopleRoute '/welcome': typeof WelcomeRoute '/auth/callback': typeof AuthCallbackRoute - '/tasks/$id': typeof TasksIdRoute + '/features/$id': typeof FeaturesIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/decisions': typeof DecisionsRoute - '/knowledge': typeof KnowledgeRoute '/login': typeof LoginRoute - '/people': typeof PeopleRoute '/welcome': typeof WelcomeRoute '/auth/callback': typeof AuthCallbackRoute - '/tasks/$id': typeof TasksIdRoute + '/features/$id': typeof FeaturesIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/' - | '/decisions' - | '/knowledge' - | '/login' - | '/people' - | '/welcome' - | '/auth/callback' - | '/tasks/$id' + fullPaths: '/' | '/login' | '/welcome' | '/auth/callback' | '/features/$id' fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/decisions' - | '/knowledge' - | '/login' - | '/people' - | '/welcome' - | '/auth/callback' - | '/tasks/$id' + to: '/' | '/login' | '/welcome' | '/auth/callback' | '/features/$id' id: | '__root__' | '/' - | '/decisions' - | '/knowledge' | '/login' - | '/people' | '/welcome' | '/auth/callback' - | '/tasks/$id' + | '/features/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - DecisionsRoute: typeof DecisionsRoute - KnowledgeRoute: typeof KnowledgeRoute LoginRoute: typeof LoginRoute - PeopleRoute: typeof PeopleRoute WelcomeRoute: typeof WelcomeRoute AuthCallbackRoute: typeof AuthCallbackRoute - TasksIdRoute: typeof TasksIdRoute + FeaturesIdRoute: typeof FeaturesIdRoute } declare module '@tanstack/react-router' { @@ -143,13 +94,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WelcomeRouteImport parentRoute: typeof rootRouteImport } - '/people': { - id: '/people' - path: '/people' - fullPath: '/people' - preLoaderRoute: typeof PeopleRouteImport - parentRoute: typeof rootRouteImport - } '/login': { id: '/login' path: '/login' @@ -157,20 +101,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } - '/knowledge': { - id: '/knowledge' - path: '/knowledge' - fullPath: '/knowledge' - preLoaderRoute: typeof KnowledgeRouteImport - parentRoute: typeof rootRouteImport - } - '/decisions': { - id: '/decisions' - path: '/decisions' - fullPath: '/decisions' - preLoaderRoute: typeof DecisionsRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -178,11 +108,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/tasks/$id': { - id: '/tasks/$id' - path: '/tasks/$id' - fullPath: '/tasks/$id' - preLoaderRoute: typeof TasksIdRouteImport + '/features/$id': { + id: '/features/$id' + path: '/features/$id' + fullPath: '/features/$id' + preLoaderRoute: typeof FeaturesIdRouteImport parentRoute: typeof rootRouteImport } '/auth/callback': { @@ -197,13 +127,10 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - DecisionsRoute: DecisionsRoute, - KnowledgeRoute: KnowledgeRoute, LoginRoute: LoginRoute, - PeopleRoute: PeopleRoute, WelcomeRoute: WelcomeRoute, AuthCallbackRoute: AuthCallbackRoute, - TasksIdRoute: TasksIdRoute, + FeaturesIdRoute: FeaturesIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/kanban/frontend/packages/local-web/src/routes/__root.tsx b/kanban/frontend/packages/local-web/src/routes/__root.tsx index 82c946c..cf8d05a 100644 --- a/kanban/frontend/packages/local-web/src/routes/__root.tsx +++ b/kanban/frontend/packages/local-web/src/routes/__root.tsx @@ -2,13 +2,13 @@ import { Outlet, createRootRoute, useRouterState, useNavigate } from '@tanstack/ import { useEffect, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { - SquaresFourIcon, UsersIcon, BookOpenIcon, - LightbulbIcon, LightningIcon, + SquaresFourIcon, + LightningIcon, } from '@phosphor-icons/react' import { useTranslation } from 'react-i18next' import AppHeader from '@web/components/AppHeader' import Dock from '@web/components/Dock' -import { isAuthenticated, authHeaders, clearToken } from '@vibe/app-core' +import { isAuthenticated, authHeaders, clearToken, verifyToken } from '@vibe/app-core' import type { DockItemConfig } from '@web/components/Dock' const PUBLIC_PATHS = ['/login', '/auth/callback', '/welcome'] @@ -37,10 +37,19 @@ function RootLayout() { const navigate = useNavigate() // Route guard: redirect to /login if not authenticated on protected routes + // Also verifies token server-side on mount to catch JWT_SECRET rotation useEffect(() => { - if (!PUBLIC_PATHS.includes(pathname) && !isAuthenticated()) { + if (PUBLIC_PATHS.includes(pathname)) return + if (!isAuthenticated()) { navigate({ to: '/login', search: { error: undefined, token: undefined } }) + return } + // Server-side verification (runs once on mount + on path change) + verifyToken().then(valid => { + if (!valid) { + navigate({ to: '/login', search: { error: undefined, token: undefined } }) + } + }) }, [pathname]) // FRE detection: if authenticated but no projects, go to /welcome @@ -111,24 +120,6 @@ function RootLayout() { onClick: () => navigate({ to: '/' }), className: isActive('/', true) ? 'dock-item-active' : '', }, - { - icon: , - label: t('nav.people'), - onClick: () => navigate({ to: '/people' }), - className: isActive('/people') ? 'dock-item-active' : '', - }, - { - icon: , - label: t('nav.knowledge'), - onClick: () => navigate({ to: '/knowledge' }), - className: isActive('/knowledge') ? 'dock-item-active' : '', - }, - { - icon: , - label: t('nav.decisions'), - onClick: () => navigate({ to: '/decisions' }), - className: isActive('/decisions') ? 'dock-item-active' : '', - }, ] return ( diff --git a/kanban/frontend/packages/local-web/src/routes/decisions.tsx b/kanban/frontend/packages/local-web/src/routes/decisions.tsx deleted file mode 100644 index 7ab2326..0000000 --- a/kanban/frontend/packages/local-web/src/routes/decisions.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' -import { fetchDecisions } from '@app/api' -import type { Decision } from '@app/types' - -function DecisionsPage() { - const { t } = useTranslation() - const { data: decisions = [], isLoading } = useQuery({ - queryKey: ['decisions'], - queryFn: fetchDecisions, - }) - - if (isLoading) { - return ( -
- {t('decisions.loading')} -
- ) - } - - return ( -
-
-

- {t('decisions.title')} -

- -
- {decisions.map(d => )} - {decisions.length === 0 && ( -

- {t('decisions.empty')} -

- )} -
-
-
- ) -} - -function DecisionCard({ decision }: { decision: Decision }) { - return ( -
- {/* Header */} -
-
- - {decision.id} - -

- {decision.title} -

-
- -
- - {decision.status} - - - {decision.date} - -
-
- - {/* Body excerpt */} -

- {decision.body.slice(0, 500)}{decision.body.length > 500 && '…'} -

-
- ) -} - -export const Route = createFileRoute('/decisions')({ component: DecisionsPage }) diff --git a/kanban/frontend/packages/local-web/src/routes/features.$id.tsx b/kanban/frontend/packages/local-web/src/routes/features.$id.tsx new file mode 100644 index 0000000..57e282b --- /dev/null +++ b/kanban/frontend/packages/local-web/src/routes/features.$id.tsx @@ -0,0 +1,347 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { ArrowLeftIcon } from '@phosphor-icons/react' +import { fetchFeature, fetchFeatureDesign, fetchFeaturePlan } from '@app/api' +import type { Feature, SupercrewStatus, DesignStatus, DesignDoc, PlanDoc } from '@app/types' + +export const Route = createFileRoute('/features/$id')({ + component: FeatureDetailPage, +}) + +/* ─── Status helpers ────────────────────────────────────────────────────────── */ + +const STATUS_COLOR: Record = { + planning: '#a78bfa', + designing: '#60a5fa', + ready: '#34d399', + active: '#fbbf24', + blocked: '#f87171', + done: '#6ee7b7', +} + +const DESIGN_STATUS_COLOR: Record = { + draft: '#9ca3af', + 'in-review': '#60a5fa', + approved: '#34d399', + rejected: '#f87171', +} + +const PRIORITY_COLOR: Record = { + P0: '#ef4444', + P1: '#f59e0b', + P2: '#3b82f6', + P3: '#9ca3af', +} + +/* ─── Page ──────────────────────────────────────────────────────────────────── */ + +function FeatureDetailPage() { + const { id } = Route.useParams() + const navigate = useNavigate() + const { t } = useTranslation() + const [tab, setTab] = useState<'overview' | 'design' | 'plan'>('overview') + + const { data: feature, isLoading, error } = useQuery({ + queryKey: ['feature', id], + queryFn: () => fetchFeature(id), + }) + + const { data: designDoc } = useQuery({ + queryKey: ['feature-design', id], + queryFn: () => fetchFeatureDesign(id), + enabled: tab === 'design', + }) + + const { data: planDoc } = useQuery({ + queryKey: ['feature-plan', id], + queryFn: () => fetchFeaturePlan(id), + enabled: tab === 'plan', + }) + + if (isLoading) { + return ( +
+ {t('common.loading', 'Loading…')} +
+ ) + } + + if (error || !feature) { + return ( +
+ {t('common.error', 'Failed to load feature')} +
+ ) + } + + const meta = feature.meta + + return ( +
+ {/* Header */} +
+ + + {meta.status} + {meta.priority && ( + {meta.priority} + )} + +

+ {meta.title} +

+
+ + {/* Tab bar */} +
+ {(['overview', 'design', 'plan'] as const).map((t2) => ( + + ))} +
+ + {/* Tab content */} +
+ {tab === 'overview' && } + {tab === 'design' && } + {tab === 'plan' && } +
+
+ ) +} + +/* ─── Overview Tab ──────────────────────────────────────────────────────────── */ + +function OverviewTab({ feature }: { feature: Feature }) { + const { t } = useTranslation() + const meta = feature.meta + + return ( +
+ {/* Basic info */} +
+ + + {meta.status} + + {meta.priority && ( + + {meta.priority} + + )} + {meta.owner && ( + {meta.owner} + )} + {meta.tags && meta.tags.length > 0 && ( + +
+ {meta.tags.map((tag) => ( + {tag} + ))} +
+
+ )} +
+
+ + {/* Teams & Release */} + {(meta.teams || meta.target_release || meta.blocked_by) && ( +
+ + {meta.teams && meta.teams.length > 0 && ( + + {meta.teams.join(', ')} + + )} + {meta.target_release && ( + + {meta.target_release} + + )} + {meta.blocked_by && meta.blocked_by.length > 0 && ( + + {meta.blocked_by.join(', ')} + + )} + +
+ )} + + {/* Design summary */} + {feature.design && ( +
+ + + + {feature.design.status} + + + {feature.design.reviewers && feature.design.reviewers.length > 0 && ( + + {feature.design.reviewers.join(', ')} + + )} + +
+ )} + + {/* Plan summary */} + {feature.plan && ( +
+ + + {feature.plan.total_tasks} + + + {feature.plan.completed_tasks} + + + {Math.round(feature.plan.progress * 100)}% + + +
+ )} + + {/* Log */} + {feature.log && feature.log.body && ( +
+
+ {feature.log.body} +
+
+ )} +
+ ) +} + +/* ─── Shared components ─────────────────────────────────────────────────────── */ + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ) +} + +function PropGrid({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function PropCell({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +function Badge({ color, children }: { color: string; children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function MarkdownBody({ md }: { md?: string }) { + if (!md) { + return + } + return ( +
+ {md} +
+ ) +} + +function EmptyState() { + const { t } = useTranslation() + return ( +
+ {t('common.empty', 'No content yet')} +
+ ) +} diff --git a/kanban/frontend/packages/local-web/src/routes/index.tsx b/kanban/frontend/packages/local-web/src/routes/index.tsx index ab54ab3..39d4fbf 100644 --- a/kanban/frontend/packages/local-web/src/routes/index.tsx +++ b/kanban/frontend/packages/local-web/src/routes/index.tsx @@ -1,16 +1,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' -import { useCallback, useEffect, useMemo, useState } from 'react' -import type { DropResult } from '@hello-pangea/dnd' -import { - KanbanProvider, - KanbanCards, - KanbanCard, -} from '@vibe/ui/components/KanbanBoard' -import { PlusIcon } from '@phosphor-icons/react' import { useTranslation } from 'react-i18next' import { useBoard } from '@app/hooks/useBoard' -import { useUpdateTaskStatus } from '@app/hooks/useMutations' -import type { Task, TaskPriority, TaskStatus } from '@app/types' +import type { FeatureMeta, FeaturePriority, SupercrewStatus } from '@app/types' import SpotlightCard from '@web/components/SpotlightCard' import CountUp from '@web/components/CountUp' import ClickSpark from '@web/components/ClickSpark' @@ -18,23 +9,24 @@ import AnimatedCard from '@web/components/AnimatedCard' // ─── Column config ────────────────────────────────────────────────────────── -const STATUS_COLUMN_IDS: TaskStatus[] = ['backlog', 'todo', 'in-progress', 'in-review', 'done'] - -const STATUS_KEY_MAP: Record = { - 'backlog': 'board.columns.backlog', - 'todo': 'board.columns.todo', - 'in-progress': 'board.columns.inProgress', - 'in-review': 'board.columns.inReview', - 'done': 'board.columns.done', +const STATUS_COLUMN_IDS: SupercrewStatus[] = [ + 'planning', 'designing', 'ready', 'active', 'blocked', 'done', +] + +const STATUS_KEY_MAP: Record = { + 'planning': 'board.columns.planning', + 'designing': 'board.columns.designing', + 'ready': 'board.columns.ready', + 'active': 'board.columns.active', + 'blocked': 'board.columns.blocked', + 'done': 'board.columns.done', } -function getStatusKey(status: TaskStatus): string { - if (status === 'in-progress') return 'progress' - if (status === 'in-review') return 'review' +function getStatusKey(status: SupercrewStatus): string { return status } -const PRI_CLASS: Record = { +const PRI_CLASS: Record = { P0: 'rb-p0', P1: 'rb-p1', P2: 'rb-p2', P3: 'rb-p3', } @@ -42,8 +34,7 @@ const PRI_CLASS: Record = { function BoardPage() { const { t } = useTranslation() - const { tasks, activeSprint, isLoading } = useBoard() - const updateStatus = useUpdateTaskStatus() + const { featuresByStatus, isLoading } = useBoard() const navigate = useNavigate() const STATUS_COLUMNS = STATUS_COLUMN_IDS.map(id => ({ @@ -51,54 +42,6 @@ function BoardPage() { name: t(STATUS_KEY_MAP[id]), })) - const [columns, setColumns] = useState>({}) - - useEffect(() => { - const grouped: Record = {} - for (const col of STATUS_COLUMNS) grouped[col.id] = [] - for (const task of tasks) { - if (grouped[task.status]) grouped[task.status].push(task.id) - } - setColumns(grouped) - }, [tasks]) - - const taskMap = useMemo(() => { - const m: Record = {} - for (const t of tasks) m[t.id] = t - return m - }, [tasks]) - - const handleDragEnd = useCallback( - (result: DropResult) => { - const { source, destination } = result - if (!destination) return - if ( - source.droppableId === destination.droppableId && - source.index === destination.index - ) return - - const srcId = source.droppableId as TaskStatus - const dstId = destination.droppableId as TaskStatus - - setColumns(prev => { - const srcItems = [...(prev[srcId] ?? [])] - const [moved] = srcItems.splice(source.index, 1) - if (srcId === dstId) { - srcItems.splice(destination.index, 0, moved) - return { ...prev, [srcId]: srcItems } - } - const dstItems = [...(prev[dstId] ?? [])] - dstItems.splice(destination.index, 0, moved) - return { ...prev, [srcId]: srcItems, [dstId]: dstItems } - }) - - if (srcId !== dstId) { - updateStatus.mutate({ id: result.draggableId, status: dstId }) - } - }, - [updateStatus], - ) - const isDark = document.documentElement.classList.contains('dark') if (isLoading) { @@ -117,56 +60,26 @@ function BoardPage() { return (
- {/* ── Sprint header ── */} - {activeSprint && ( -
-
{t('board.activeSprint')}
-
-

- {activeSprint.name} -

- {activeSprint.goal && ( - - {activeSprint.goal} - - )} -
-
- )} - {/* ── Kanban board with ClickSpark ── */}
- - -
- {STATUS_COLUMNS.map(col => ( - void navigate({ to: '/tasks/$id', params: { id } })} - onAdd={() => void navigate({ to: '/' })} - /> - ))} -
-
-
+ +
+ {STATUS_COLUMNS.map(col => ( + void navigate({ to: '/features/$id', params: { id } })} + /> + ))} +
+
) @@ -176,20 +89,15 @@ function BoardPage() { function Column({ col, - taskIds, - taskMap, + features, isDark, onCardClick, - onAdd, }: { - col: { id: TaskStatus; name: string } - taskIds: string[] - taskMap: Record + col: { id: SupercrewStatus; name: string } + features: FeatureMeta[] isDark: boolean onCardClick: (id: string) => void - onAdd: () => void }) { - const { t } = useTranslation() const sk = getStatusKey(col.id) return ( @@ -215,59 +123,36 @@ function Column({ }}> {col.name} - {/* CountUp — re-triggers animation when count changes */} - + - - {/* Droppable area */} + {/* Card area (read-only, no drag) */}
- -
- {taskIds.map((taskId, index) => { - const task = taskMap[taskId] - if (!task) return null - return ( - onCardClick(task.id)} - > - {/* AnimatedCard entry animation (CSS-only, no DnD conflict) */} - - - - - ) - })} -
-
+ {features.map((feature, index) => ( + +
onCardClick(feature.id)} style={{ cursor: 'pointer' }}> + +
+
+ ))}
) } -// ─── Task card visual (SpotlightCard) ─────────────────────────────────────── +// ─── Feature card visual (SpotlightCard) ──────────────────────────────────── -function TaskCard({ task, statusKey, isDark }: { task: Task; statusKey: string; isDark: boolean }) { +function FeatureCard({ feature, statusKey, isDark }: { feature: FeatureMeta; statusKey: string; isDark: boolean }) { return ( - {task.id} + {feature.id} - {task.priority && ( - - {task.priority} + {feature.priority && ( + + {feature.priority} )} @@ -302,20 +187,23 @@ function TaskCard({ task, statusKey, isDark }: { task: Task; statusKey: string; margin: 0, fontFamily: 'Instrument Sans, sans-serif', }}> - {task.title} + {feature.title}

- {/* Tags */} - {task.tags.length > 0 && ( + {/* Tags + Teams */} + {((feature.tags && feature.tags.length > 0) || (feature.teams && feature.teams.length > 0)) && (
- {task.tags.slice(0, 3).map(t => ( - {t} + {(feature.teams ?? []).slice(0, 2).map(team => ( + {team} + ))} + {(feature.tags ?? []).slice(0, 2).map(tag => ( + {tag} ))}
)} - {/* Assignee */} - {task.assignee && ( + {/* Owner */} + {feature.owner && (
- {task.assignee.charAt(0).toUpperCase()} + {feature.owner.charAt(0).toUpperCase()}
- {task.assignee} + {feature.owner} )} diff --git a/kanban/frontend/packages/local-web/src/routes/knowledge.tsx b/kanban/frontend/packages/local-web/src/routes/knowledge.tsx deleted file mode 100644 index accc891..0000000 --- a/kanban/frontend/packages/local-web/src/routes/knowledge.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' -import { fetchKnowledge } from '@app/api' -import type { KnowledgeEntry } from '@app/types' - -function KnowledgePage() { - const { t } = useTranslation() - const { data: entries = [], isLoading } = useQuery({ - queryKey: ['knowledge'], - queryFn: fetchKnowledge, - }) - - if (isLoading) { - return ( -
- {t('knowledge.loading')} -
- ) - } - - return ( -
-
-

- {t('knowledge.title')} -

- -
- {entries.map(entry => )} - {entries.length === 0 && ( -

- {t('knowledge.empty')} -

- )} -
-
-
- ) -} - -function KnowledgeCard({ entry }: { entry: KnowledgeEntry }) { - return ( -
- {/* Header row */} -
-

- {entry.title} -

- - {entry.date} - -
- - {/* Tags */} - {entry.tags.length > 0 && ( -
- {entry.tags.map(t => ( - {t} - ))} -
- )} - - {/* Body excerpt */} -

- {entry.body.slice(0, 400)}{entry.body.length > 400 && '…'} -

-
- ) -} - -export const Route = createFileRoute('/knowledge')({ component: KnowledgePage }) diff --git a/kanban/frontend/packages/local-web/src/routes/people.tsx b/kanban/frontend/packages/local-web/src/routes/people.tsx deleted file mode 100644 index e278a8f..0000000 --- a/kanban/frontend/packages/local-web/src/routes/people.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useTranslation } from 'react-i18next' -import { useBoard } from '@app/hooks/useBoard' -import type { Person, Task } from '@app/types' - -function PeoplePage() { - const { people, tasks } = useBoard() - const { t } = useTranslation() - - return ( -
-
-

- {t('people.title')} -

- -
- {people.map(person => ( - - ))} - {people.length === 0 && ( -

- {t('people.empty')} -

- )} -
-
-
- ) -} - -function PersonCard({ person, tasks }: { person: Person; tasks: Task[] }) { - const { t } = useTranslation() - const currentTask = tasks.find(task => task.id === person.current_task) - const initial = person.name.charAt(0).toUpperCase() - - return ( -
- {/* Avatar + name */} -
-
- {initial} -
-
-
- {person.name} -
-
- - @{person.username} - - {person.team && ( - <> - · - - {person.team} - - - )} -
-
-
- - {/* Blocked */} - {person.blocked_by && ( -
- - - {person.blocked_by} - -
- )} - - {/* Current task */} - {currentTask && ( -
-
{t('people.workingOn')}
-
- - {currentTask.id} - - - {currentTask.title} - -
-
- )} - - {/* Completed today */} - {person.completed_today.length > 0 && ( -
-
{t('people.today')}
-
    - {person.completed_today.map((item, i) => ( -
  • - - {item} -
  • - ))} -
-
- )} -
- ) -} - -export const Route = createFileRoute('/people')({ component: PeoplePage }) diff --git a/kanban/frontend/packages/local-web/src/routes/tasks.$id.tsx b/kanban/frontend/packages/local-web/src/routes/tasks.$id.tsx deleted file mode 100644 index 5047a93..0000000 --- a/kanban/frontend/packages/local-web/src/routes/tasks.$id.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { XIcon, PencilSimpleIcon, CheckIcon } from '@phosphor-icons/react' -import { useBoard } from '@app/hooks/useBoard' -import { useUpdateTask, useUpdateTaskStatus } from '@app/hooks/useMutations' -import type { TaskStatus, TaskPriority } from '@app/types' - -const STATUS_OPTIONS: TaskStatus[] = ['backlog', 'todo', 'in-progress', 'in-review', 'done'] -const PRIORITY_OPTIONS: TaskPriority[] = ['P0', 'P1', 'P2', 'P3'] - -function getStatusKey(status: TaskStatus): string { - if (status === 'in-progress') return 'progress' - if (status === 'in-review') return 'review' - return status -} - -const PRIORITY_CLASS: Record = { - P0: 'rb-p0', P1: 'rb-p1', P2: 'rb-p2', P3: 'rb-p3', -} - -function TaskDetailPage() { - const { id } = Route.useParams() - const navigate = useNavigate() - const { t } = useTranslation() - const { tasks } = useBoard() - const updateTask = useUpdateTask() - const updateStatus = useUpdateTaskStatus() - - const task = tasks.find(tk => tk.id === id) - const [editingTitle, setEditingTitle] = useState(false) - const [titleDraft, setTitleDraft] = useState('') - - if (!task) { - return ( -
- {t('task.notFound', { id })} -
- ) - } - - const sk = getStatusKey(task.status) - - return ( -
-
- - {/* ── Header ── */} -
-
- - {task.id} - - - {editingTitle ? ( -
- setTitleDraft(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - updateTask.mutate({ id: task.id, patch: { title: titleDraft } }) - setEditingTitle(false) - } - if (e.key === 'Escape') setEditingTitle(false) - }} - /> - -
- ) : ( -

{ setTitleDraft(task.title); setEditingTitle(true) }} - title={t('task.clickToEdit')} - > - {task.title} -

- )} -
- - -
- - {/* ── Properties grid ── */} -
- {/* Status */} - -
- - -
-
- - {/* Priority */} - - - - - {/* Assignee */} - - - {task.assignee ?? '—'} - - - - {/* Sprint */} - - - {task.sprint != null ? t('task.sprintLabel', { n: task.sprint }) : '—'} - - - - {/* Tags */} - {task.tags.length > 0 && ( - -
- {task.tags.map(t => ( - {t} - ))} -
-
- )} - - {/* PR */} - {task.pr_url && ( - - - {task.pr_url} - - - )} - - {/* Blocked by */} - {task.blocked_by.length > 0 && ( - - - {task.blocked_by.join(', ')} - - - )} - - {/* Blocks */} - {task.blocks.length > 0 && ( - - - {task.blocks.join(', ')} - - - )} -
- - {/* ── Description ── */} -
-
- - {t('task.description')} - - -
- updateTask.mutate({ id: task.id, patch: { body } })} - /> -
- -
-
- ) -} - -// ─── PropCell ──────────────────────────────────────────────────────────────── - -function PropCell({ - label, - children, - full, -}: { - label: string - children: React.ReactNode - full?: boolean -}) { - return ( -
-
{label}
- {children} -
- ) -} - -// ─── BodyEditor ────────────────────────────────────────────────────────────── - -function BodyEditor({ body, onSave }: { body: string; onSave: (body: string) => void }) { - const { t } = useTranslation() - const [editing, setEditing] = useState(false) - const [draft, setDraft] = useState(body) - - if (!editing) { - return ( -
{ setDraft(body); setEditing(true) }} - > - {body || {t('task.addDescription')}} -
- ) - } - - return ( -
-