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 (
-
- )
-}
-
-// ─── 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 (
-
- )
-}
-
-export const Route = createFileRoute('/tasks/$id')({ component: TaskDetailPage })
diff --git a/kanban/frontend/packages/local-web/src/routes/welcome.tsx b/kanban/frontend/packages/local-web/src/routes/welcome.tsx
index a5bc0b5..30e0d58 100644
--- a/kanban/frontend/packages/local-web/src/routes/welcome.tsx
+++ b/kanban/frontend/packages/local-web/src/routes/welcome.tsx
@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { LightningIcon, MagnifyingGlassIcon, CheckCircleIcon, WarningCircleIcon, CaretDownIcon } from '@phosphor-icons/react'
import { Popover, PopoverTrigger, PopoverContent } from '@radix-ui/react-popover'
-import { authHeaders } from '@vibe/app-core'
+import { authHeaders, isAuthenticated } from '@vibe/app-core'
import { useTranslation } from 'react-i18next'
import DotGrid from '@web/components/DotGrid'
import LangToggle from '@web/components/LangToggle'
@@ -236,11 +236,11 @@ function StepSelectRepo({
)
}
-// ─── Step 3: 初始化确认 ───────────────────────────────────────────────────────
+// ─── Step 3: Bind repo ─────────────────────────────────────────────────────
-type InitStatus = 'loading' | 'success' | 'error'
+type BindStatus = 'loading' | 'success' | 'error'
-function StepInit({
+function StepBind({
repo,
onSuccess,
}: {
@@ -249,32 +249,13 @@ function StepInit({
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
- const [status, setStatus] = useState('loading')
+ const [status, setStatus] = useState('loading')
const [errorMsg, setErrorMsg] = useState('')
- const runInit = async () => {
+ const runBind = async () => {
setStatus('loading')
setErrorMsg('')
try {
- const [owner, repoName] = repo.full_name.split('/')
-
- const statusRes = await fetch(
- `/api/projects/github/repos/${owner}/${repoName}/init-status`,
- { headers: authHeaders() }
- )
- const { initialized } = await statusRes.json()
-
- if (!initialized) {
- const initRes = await fetch(`/api/projects/github/repos/${owner}/${repoName}/init`, {
- method: 'POST',
- headers: authHeaders(),
- })
- if (!initRes.ok) {
- const err = await initRes.json()
- throw new Error(err.error ?? t('welcome.step3.initFailed'))
- }
- }
-
await fetch('/api/projects', {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
@@ -290,7 +271,7 @@ function StepInit({
}
}
- useEffect(() => { runInit() }, [])
+ useEffect(() => { runBind() }, [])
return (
- {t('welcome.step3.initializing')}
+ {t('welcome.step3.binding')}
)}
@@ -335,7 +316,7 @@ function StepInit({
{errorMsg}