From 17bc185ce5b17af3e6ad515ec065986113f6693d Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 17:53:40 -0800 Subject: [PATCH 01/29] feat: add Windows project registry API and persistence --- package.json | 2 +- src/app/api/projects/route.ts | 60 +++++++++++++ src/lib/registry.ts | 140 +++++++++++++++++++++++++++++++ tests/api/projects-route.test.ts | 109 ++++++++++++++++++++++++ tests/lib/registry.test.ts | 86 +++++++++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/app/api/projects/route.ts create mode 100644 src/lib/registry.ts create mode 100644 tests/api/projects-route.test.ts create mode 100644 tests/lib/registry.test.ts diff --git a/package.json b/package.json index c1860d3..0936d32 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs" }, "dependencies": { "next": "15.5.7", diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..240c7c4 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; + +import { addProject, listProjects, RegistryValidationError, removeProject } from '../../../lib/registry'; + +export const runtime = 'nodejs'; + +function projectsPayload(projects: Array<{ path: string }>): { projects: Array<{ path: string }> } { + return { + projects: projects.map((project) => ({ path: project.path })), + }; +} + +async function readPathFromBody(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + throw new RegistryValidationError('Request body must be valid JSON.'); + } + + const path = (body as { path?: unknown }).path; + if (typeof path !== 'string' || path.trim().length === 0) { + throw new RegistryValidationError('`path` is required and must be a non-empty string.'); + } + + return path; +} + +export async function GET(): Promise { + const projects = await listProjects(); + return NextResponse.json(projectsPayload(projects), { status: 200 }); +} + +export async function POST(request: Request): Promise { + try { + const projectPath = await readPathFromBody(request); + const result = await addProject(projectPath); + return NextResponse.json(projectsPayload(result.projects), { status: result.added ? 201 : 200 }); + } catch (error) { + if (error instanceof RegistryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to add project.' }, { status: 500 }); + } +} + +export async function DELETE(request: Request): Promise { + try { + const projectPath = await readPathFromBody(request); + const result = await removeProject(projectPath); + return NextResponse.json({ removed: result.removed, ...projectsPayload(result.projects) }, { status: 200 }); + } catch (error) { + if (error instanceof RegistryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to remove project.' }, { status: 500 }); + } +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts new file mode 100644 index 0000000..eacd45b --- /dev/null +++ b/src/lib/registry.ts @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing'; + +export interface RegistryProject { + path: string; + key: string; +} + +interface RegistryDocument { + version: 1; + projects: RegistryProject[]; +} + +export class RegistryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'RegistryValidationError'; + } +} + +function userProfileRoot(): string { + return process.env.USERPROFILE?.trim() || os.homedir(); +} + +export function registryFilePath(): string { + return path.join(userProfileRoot(), '.beadboard', 'projects.json'); +} + +function ensureWindowsAbsolutePath(input: string): string { + const normalized = canonicalizeWindowsPath(input.trim()); + if (!/^[A-Za-z]:\\/.test(normalized)) { + throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).'); + } + + return normalized; +} + +function normalizeProject(input: string): RegistryProject { + const normalized = ensureWindowsAbsolutePath(input); + return { + path: toDisplayPath(normalized), + key: windowsPathKey(normalized), + }; +} + +function normalizeProjects(input: unknown): RegistryProject[] { + if (!Array.isArray(input)) { + return []; + } + + const seen = new Set(); + const normalized: RegistryProject[] = []; + + for (const item of input) { + if (!item || typeof item !== 'object') { + continue; + } + + const candidate = item as { path?: unknown }; + if (typeof candidate.path !== 'string') { + continue; + } + + try { + const project = normalizeProject(candidate.path); + if (!seen.has(project.key)) { + seen.add(project.key); + normalized.push(project); + } + } catch { + continue; + } + } + + return normalized; +} + +async function readRegistryDocument(): Promise { + const filePath = registryFilePath(); + + try { + const raw = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as { projects?: unknown }; + return { + version: 1, + projects: normalizeProjects(parsed.projects), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { version: 1, projects: [] }; + } + + throw error; + } +} + +async function writeRegistryDocument(document: RegistryDocument): Promise { + const filePath = registryFilePath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); +} + +export async function listProjects(): Promise { + const document = await readRegistryDocument(); + return document.projects; +} + +export async function addProject(projectPath: string): Promise<{ added: boolean; projects: RegistryProject[] }> { + const document = await readRegistryDocument(); + const project = normalizeProject(projectPath); + + if (document.projects.some((entry) => entry.key === project.key)) { + return { added: false, projects: document.projects }; + } + + document.projects.push(project); + await writeRegistryDocument(document); + return { added: true, projects: document.projects }; +} + +export async function removeProject(projectPath: string): Promise<{ removed: boolean; projects: RegistryProject[] }> { + const document = await readRegistryDocument(); + const project = normalizeProject(projectPath); + const nextProjects = document.projects.filter((entry) => entry.key !== project.key); + + if (nextProjects.length === document.projects.length) { + return { removed: false, projects: document.projects }; + } + + const nextDocument: RegistryDocument = { + version: 1, + projects: nextProjects, + }; + + await writeRegistryDocument(nextDocument); + return { removed: true, projects: nextDocument.projects }; +} diff --git a/tests/api/projects-route.test.ts b/tests/api/projects-route.test.ts new file mode 100644 index 0000000..0f4d77e --- /dev/null +++ b/tests/api/projects-route.test.ts @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { DELETE, GET, POST } from '../../src/app/api/projects/route'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-api-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function readJson(response: Response): Promise { + return response.json(); +} + +test('GET /api/projects returns empty list initially', async () => { + await withTempUserProfile(async () => { + const response = await GET(); + assert.equal(response.status, 200); + + const body = (await readJson(response)) as { projects: unknown[] }; + assert.deepEqual(body.projects, []); + }); +}); + +test('POST /api/projects validates payload and path', async () => { + await withTempUserProfile(async () => { + const missing = await POST(new Request('http://localhost/api/projects', { method: 'POST', body: '{}' })); + assert.equal(missing.status, 400); + + const missingBody = (await readJson(missing)) as { error: string }; + assert.match(missingBody.error, /path/i); + + const invalidPath = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: '/tmp/project' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(invalidPath.status, 400); + }); +}); + +test('POST deduplicates and GET returns normalized path', async () => { + await withTempUserProfile(async () => { + const first = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'c:/Users/Zenchant/codex/beadboard/' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(first.status, 201); + + const dup = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'C:\\users\\zenchant\\codex\\beadboard' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(dup.status, 200); + + const list = await GET(); + const body = (await readJson(list)) as { projects: Array<{ path: string }> }; + assert.deepEqual(body.projects, [{ path: 'C:/Users/Zenchant/codex/beadboard' }]); + }); +}); + +test('DELETE /api/projects removes by normalized path', async () => { + await withTempUserProfile(async () => { + await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'D:/Repos/One' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + const removed = await DELETE( + new Request('http://localhost/api/projects', { + method: 'DELETE', + body: JSON.stringify({ path: 'd:\\repos\\one\\' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(removed.status, 200); + + const list = await GET(); + const body = (await readJson(list)) as { projects: unknown[] }; + assert.deepEqual(body.projects, []); + }); +}); diff --git a/tests/lib/registry.test.ts b/tests/lib/registry.test.ts new file mode 100644 index 0000000..15537df --- /dev/null +++ b/tests/lib/registry.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + addProject, + listProjects, + removeProject, + registryFilePath, + type RegistryProject, +} from '../../src/lib/registry'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => { + await withTempUserProfile(async (userProfile) => { + const result = registryFilePath(); + assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json')); + }); +}); + +test('listProjects returns empty when registry does not exist', async () => { + await withTempUserProfile(async () => { + const result = await listProjects(); + assert.deepEqual(result, []); + }); +}); + +test('addProject persists normalized path and deduplicates case/separators', async () => { + await withTempUserProfile(async () => { + const first = await addProject('c:/Work/Alpha/'); + assert.equal(first.added, true); + + const second = await addProject('C:\\work\\alpha'); + assert.equal(second.added, false); + + const listed = await listProjects(); + assert.equal(listed.length, 1); + assert.equal(listed[0].path, 'C:/Work/Alpha'); + + const file = await fs.readFile(registryFilePath(), 'utf8'); + const parsed = JSON.parse(file) as { projects: RegistryProject[] }; + assert.equal(parsed.projects.length, 1); + }); +}); + +test('removeProject removes matching normalized path', async () => { + await withTempUserProfile(async () => { + await addProject('D:/Repos/One'); + await addProject('D:/Repos/Two'); + + const removed = await removeProject('d:\\repos\\one\\'); + assert.equal(removed.removed, true); + + const listed = await listProjects(); + assert.deepEqual( + listed.map((project) => project.path), + ['D:/Repos/Two'], + ); + }); +}); + +test('addProject rejects non-Windows absolute paths', async () => { + await withTempUserProfile(async () => { + await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i); + await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i); + }); +}); From 2c8026525861ff68d8ca3cfda61bef2679bb9f9f Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 19:46:02 -0800 Subject: [PATCH 02/29] Add bd exec bridge and mutation API routes with tests --- .beads/issues.jsonl | 10 +- package.json | 2 +- src/app/api/beads/_shared.ts | 51 +++++ src/app/api/beads/close/route.ts | 5 + src/app/api/beads/comment/route.ts | 5 + src/app/api/beads/create/route.ts | 5 + src/app/api/beads/reopen/route.ts | 5 + src/app/api/beads/update/route.ts | 5 + src/lib/bd-path.ts | 78 ++++++++ src/lib/bridge.ts | 163 ++++++++++++++++ src/lib/mutations.ts | 295 +++++++++++++++++++++++++++++ tests/api/mutations-routes.test.ts | 55 ++++++ tests/lib/bd-path.test.ts | 43 +++++ tests/lib/bridge.test.ts | 86 +++++++++ tests/lib/mutations.test.ts | 101 ++++++++++ 15 files changed, 904 insertions(+), 5 deletions(-) create mode 100644 src/app/api/beads/_shared.ts create mode 100644 src/app/api/beads/close/route.ts create mode 100644 src/app/api/beads/comment/route.ts create mode 100644 src/app/api/beads/create/route.ts create mode 100644 src/app/api/beads/reopen/route.ts create mode 100644 src/app/api/beads/update/route.ts create mode 100644 src/lib/bd-path.ts create mode 100644 src/lib/bridge.ts create mode 100644 src/lib/mutations.ts create mode 100644 tests/api/mutations-routes.test.ts create mode 100644 tests/lib/bd-path.test.ts create mode 100644 tests/lib/bridge.test.ts create mode 100644 tests/lib/mutations.test.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a1b22ff..46a9cb9 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -3,12 +3,13 @@ {"id":"bb-29x.2","title":"Implement API integration tests for read, mutate, and SSE routes","description":"Validate route contracts and interaction boundaries across read/write/realtime layers.","acceptance_criteria":"Integration suite verifies route behavior and error semantics.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:17.4912736-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:17.4912736-08:00","labels":["integration","tests"],"dependencies":[{"issue_id":"bb-29x.2","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:17.4923012-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.2","depends_on_id":"bb-29x.1","type":"blocks","created_at":"2026-02-11T17:12:38.9423299-08:00","created_by":"zenchantlive"}]} {"id":"bb-29x.3","title":"Record parser and realtime performance baseline against PRD targets","description":"Measure parse latency and update propagation using realistic sample sizes and document outcomes.","acceptance_criteria":"Performance report exists with methodology and observed timings.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:18.3210495-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:18.3210495-08:00","labels":["benchmark","perf"],"dependencies":[{"issue_id":"bb-29x.3","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:18.3220949-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.3","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.4534943-08:00","created_by":"zenchantlive"}]} {"id":"bb-29x.4","title":"Document operational runbook and boundary rationale","description":"Write architecture docs covering scanner policy, bd bridge behavior, and consistency guardrails for future maintainers.","acceptance_criteria":"Runbook documents startup, troubleshooting, and boundary rules.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:19.1385778-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:19.1385778-08:00","labels":["docs","runbook"],"dependencies":[{"issue_id":"bb-29x.4","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:19.1402086-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.4","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.9591458-08:00","created_by":"zenchantlive"}]} +{"id":"bb-3pr","title":"Smoke test mutation lifecycle 2","description":"Temporary issue for API mutation smoke test","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:44:10.9737485-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:44:16.4912473-08:00","closed_at":"2026-02-11T19:44:16.4912473-08:00","close_reason":"Cleanup after API smoke test","labels":["api","smoke"],"comments":[{"id":1,"issue_id":"bb-3pr","author":"zenchantlive","text":"Smoke test comment via API route","created_at":"2026-02-12T03:44:13Z"},{"id":2,"issue_id":"bb-3pr","author":"zenchantlive","text":"Smoke test reopen","created_at":"2026-02-12T03:44:15Z"}]} {"id":"bb-6aj","title":"Project Registry and Multi-Project Scanner","description":"Support multiple Windows project roots using profile-scoped registry storage and safe discovery scanning tuned for developer machines.","acceptance_criteria":"Projects can be added/removed/listed and discovered via scanner with deterministic normalization.","status":"open","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:47.7205517-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:47.7205517-08:00","labels":["multi-project","scanner"],"dependencies":[{"issue_id":"bb-6aj","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:19.6374139-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.1","title":"Persist project registry in %USERPROFILE%\\\\.beadboard\\\\projects.json","description":"Implement read/write management for registry file in user profile path, isolated from repository files and safe for local machine usage.","acceptance_criteria":"Registry file is created lazily and survives app restarts.","status":"closed","priority":0,"issue_type":"task","assignee":"agent-a","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:48.5403111-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:53:17.2085722-08:00","closed_at":"2026-02-11T17:53:17.2085722-08:00","close_reason":"Implemented %USERPROFILE%/.beadboard/projects.json registry persistence with Windows-safe normalization and dedupe.","labels":["config","registry"],"dependencies":[{"issue_id":"bb-6aj.1","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:48.5419102-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.2","title":"Implement registry API for add/remove/list operations","description":"Expose robust API endpoints with path validation and normalized identity checks to prevent duplicates.","acceptance_criteria":"API supports add, remove, list and returns clear validation errors.","status":"closed","priority":0,"issue_type":"task","assignee":"agent-a","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:49.3542564-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:53:23.9298353-08:00","closed_at":"2026-02-11T17:53:23.9298353-08:00","close_reason":"Implemented /api/projects GET/POST/DELETE with validation, normalization, and registry integration.","labels":["api","registry"],"dependencies":[{"issue_id":"bb-6aj.2","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:49.3558158-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.2","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:26.7117348-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.3","title":"Build scanner with profile-root default and depth/ignore controls","description":"Scan %USERPROFILE% and user-defined roots for .beads directories with bounded recursion and ignore patterns to protect performance.","acceptance_criteria":"Scanner discovers projects without traversing entire drives by default.","status":"open","priority":0,"issue_type":"task","assignee":"agent-c","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:50.1925005-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:43:32.4095636-08:00","labels":["performance","scanner"],"dependencies":[{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:50.1940841-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:27.2225981-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.3.1","title":"Add explicit full-drive scan mode for C:/D: by user action","description":"Provide an opt-in scan mode for full drive enumeration while retaining safe defaults and progress reporting expectations.","acceptance_criteria":"Full-drive scan is only activated explicitly, never by default startup logic.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.0244174-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:51.0244174-08:00","labels":["optional","scanner"],"dependencies":[{"issue_id":"bb-6aj.3.1","depends_on_id":"bb-6aj.3","type":"parent-child","created_at":"2026-02-11T17:11:51.0259617-08:00","created_by":"zenchantlive"}]} -{"id":"bb-6aj.4","title":"Implement aggregate project issue context model","description":"Define normalized project identity payload attached to every issue for cross-project Kanban, timeline, and session views.","acceptance_criteria":"Aggregated read output always includes stable project metadata.","status":"in_progress","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.8518922-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:31:12.4614879-08:00","labels":["aggregation","data-model"],"dependencies":[{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:51.8534893-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:27.7270195-08:00","created_by":"zenchantlive"}]} +{"id":"bb-6aj.4","title":"Implement aggregate project issue context model","description":"Define normalized project identity payload attached to every issue for cross-project Kanban, timeline, and session views.","acceptance_criteria":"Aggregated read output always includes stable project metadata.","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.8518922-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:21.5826669-08:00","closed_at":"2026-02-11T19:45:21.5826669-08:00","close_reason":"Added project context model and attached to read issues.","labels":["aggregation","data-model"],"dependencies":[{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:51.8534893-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:27.7270195-08:00","created_by":"zenchantlive"}]} {"id":"bb-92d","title":"Foundation and Read/Write Boundary","description":"Establish the Windows-native Next.js foundation, canonical Beads schema handling, and strict data boundaries: read from JSONL, write only via bd.exe. This epic defines the non-negotiable invariants that all later work must preserve.","acceptance_criteria":"App boots on Windows, schema/parser contracts exist, and no direct issues.jsonl write path exists in code.","status":"closed","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:41.0756295-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:28:27.8108066-08:00","closed_at":"2026-02-11T17:28:27.8108066-08:00","close_reason":"Completed foundation milestone: bootstrap, licensing/docs, schema contracts, parser, windows path normalization, and write-boundary guardrails.","labels":["beadboard","foundation","windows"]} {"id":"bb-92d.1","title":"Bootstrap Next.js 15 + React 19 + TypeScript strict","description":"Initialize project scaffold with strict TypeScript, App Router baseline, and repeatable scripts for lint/typecheck/test in PowerShell.","acceptance_criteria":"npm install and dev startup work on Windows; strict type checking enabled.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:41.9363647-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:23:14.0089901-08:00","closed_at":"2026-02-11T17:23:14.0089901-08:00","close_reason":"Bootstrapped Next.js 15 + React 19 + strict TypeScript; install/typecheck/dev startup verified on Windows.","labels":["foundation","nextjs"],"dependencies":[{"issue_id":"bb-92d.1","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:41.9379355-08:00","created_by":"zenchantlive"}]} {"id":"bb-92d.2","title":"Add MIT license and baseline repository docs","description":"Add LICENSE and baseline docs that state Windows-native support, read/write boundaries, and required runtime dependencies.","acceptance_criteria":"MIT license present and docs describe core architecture constraints.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:42.7699961-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:23:50.7519159-08:00","closed_at":"2026-02-11T17:23:50.7519159-08:00","close_reason":"Added MIT license and baseline repository documentation with architecture boundary rules.","labels":["docs","license"],"dependencies":[{"issue_id":"bb-92d.2","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:42.7715653-08:00","created_by":"zenchantlive"}]} @@ -24,6 +25,7 @@ {"id":"bb-bc4.3","title":"Redesign tokenized theme and visual hierarchy","description":"Upgrade visual system quality using semantic tokens for surface/text/status/priority states, stronger typography hierarchy, and improved contrast. Move away from flat/basic palette while preserving clarity and performance.","acceptance_criteria":"UI theme shows clear hierarchy and contrast, aligns with premium demo quality expectations, and remains consistent across components.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:44.8548956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.0348391-08:00","closed_at":"2026-02-11T18:59:19.0348391-08:00","close_reason":"Redesigned semantic tokens/theme contrast and hierarchy to improve production visual quality.","labels":["design-system","theme","tokens"],"dependencies":[{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:44.8564376-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4.1","type":"blocks","created_at":"2026-02-11T18:50:44.8606805-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.4","title":"Implement mobile/tablet detail panel interaction model","description":"Adapt detail panel behavior for small screens (overlay or drawer model) with safe viewport sizing, accessible dismissal, and non-destructive navigation. Desktop retains efficient side-panel behavior.","acceptance_criteria":"Detail view is usable on mobile/tablet and does not trap or obscure board interaction irrecoverably.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:45.8342573-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.8911935-08:00","closed_at":"2026-02-11T18:59:19.8911935-08:00","close_reason":"Implemented mobile detail overlay flow while preserving desktop sticky side-detail behavior.","labels":["detail-panel","mobile","ux"],"dependencies":[{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:45.8360334-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.2","type":"blocks","created_at":"2026-02-11T18:51:10.0929812-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:10.9352149-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.5","title":"Playwright multi-breakpoint visual verification","description":"Capture and review before/after screenshots at 390x844, 768x1024, and 1440x900 to validate reachability, clipping, control usability, and detail-panel behavior. Store artifacts under artifacts/ with explicit naming conventions.","acceptance_criteria":"Required six screenshots exist (before/after x 3 breakpoints) and observations confirm responsive/visual acceptance criteria.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:47.0018379-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:20.7427588-08:00","closed_at":"2026-02-11T18:59:20.7427588-08:00","close_reason":"Captured required Playwright before/after screenshots at mobile/tablet/desktop and validated layout usability.","labels":["playwright","verification","visual"],"dependencies":[{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:47.0034039-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.4","type":"blocks","created_at":"2026-02-11T18:51:11.7817934-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:12.6236762-08:00","created_by":"zenchantlive"}]} +{"id":"bb-bq6","title":"Smoke test mutation lifecycle","description":"Temporary issue for API mutation smoke test","status":"open","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:43:52.1686473-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:43:52.1686473-08:00","labels":["api","smoke"]} {"id":"bb-bvn","title":"Dependency Graph (React Flow)","description":"Visualize issue relationships and blocked chains through an interactive graph backed by parsed dependency edges.","acceptance_criteria":"Graph renders dependencies correctly and supports navigation to issue details.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:09.2057278-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:09.2057278-08:00","labels":["graph","react-flow"],"dependencies":[{"issue_id":"bb-bvn","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:22.6642419-08:00","created_by":"zenchantlive"}]} {"id":"bb-bvn.1","title":"Parse dependency edges and build adjacency structures","description":"Extract edges for blocks, parent, relates_to, duplicates, and supersedes to support graph rendering and analysis.","acceptance_criteria":"Adjacency output is complete and consistent for all supported edge types.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.0434044-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.0434044-08:00","labels":["graph","parser"],"dependencies":[{"issue_id":"bb-bvn.1","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.0449367-08:00","created_by":"zenchantlive"}]} {"id":"bb-bvn.2","title":"Implement React Flow graph view with pan/zoom/select interactions","description":"Render nodes and edges with interactive navigation and issue selection integration.","acceptance_criteria":"Users can pan, zoom, and select nodes to inspect linked issue context.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.8683725-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.8683725-08:00","labels":["graph","ui"],"dependencies":[{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.8694189-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn.1","type":"blocks","created_at":"2026-02-11T17:12:36.8736785-08:00","created_by":"zenchantlive"}]} @@ -47,8 +49,8 @@ {"id":"bb-xhm.2","title":"Implement snapshot diffing for derived timeline events","description":"Compare periodic snapshots and watcher updates to infer meaningful change events without requiring write interception.","acceptance_criteria":"Diff engine emits deterministic event records for relevant field changes.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:07.5007059-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:07.5007059-08:00","labels":["diff","timeline"],"dependencies":[{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:07.501756-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm.1","type":"blocks","created_at":"2026-02-11T17:12:35.3430513-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:35.8495336-08:00","created_by":"zenchantlive"}]} {"id":"bb-xhm.3","title":"Build timeline UI with date grouping and project/assignee/event filters","description":"Render reverse-chronological feed suitable for morning review workflows with practical filter controls.","acceptance_criteria":"Timeline view supports grouping and filter combinations with acceptable performance.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:08.3834905-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:08.3834905-08:00","labels":["timeline","ui"],"dependencies":[{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:08.3851144-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm.2","type":"blocks","created_at":"2026-02-11T17:12:36.3627477-08:00","created_by":"zenchantlive"}]} {"id":"bb-ymg","title":"CLI Write-Back via bd.exe","description":"Enable safe issue mutations from UI by routing all write operations through bd.exe and reflecting results through realtime reconciliation.","acceptance_criteria":"No direct JSONL writes exist; all mutations use bd commands and recover cleanly from failures.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:00.9164956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:00.9164956-08:00","labels":["bd-cli","mutation"],"dependencies":[{"issue_id":"bb-ymg","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:21.1512868-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:21.6536312-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.1","title":"Implement bd bridge using child_process.execFile with project-scoped cwd","description":"Wrap bd execution with command argument safety, Windows path compatibility, stdout/stderr parsing, and project-specific current working directory.","acceptance_criteria":"Bridge executes supported bd commands and returns structured result/error payloads.","status":"in_progress","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:01.7327732-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:35:15.8003769-08:00","labels":["bridge","execfile"],"dependencies":[{"issue_id":"bb-ymg.1","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:01.7343468-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:32.3039711-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.1.1","title":"Resolve bd.exe location from PATH and configuration fallback","description":"Add detection logic for bd executable and actionable errors when not found, including setup guidance.","acceptance_criteria":"Missing bd path returns clear setup instructions and diagnostics.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:02.5593205-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:02.5593205-08:00","labels":["bridge","setup"],"dependencies":[{"issue_id":"bb-ymg.1.1","depends_on_id":"bb-ymg.1","type":"parent-child","created_at":"2026-02-11T17:12:02.5603636-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.2","title":"Implement mutation API for create/update/close/reopen/comment operations","description":"Expose strict server-side mutation endpoints translating UI actions to corresponding bd commands with validated arguments.","acceptance_criteria":"All required mutation operations execute via bd and return normalized responses.","status":"open","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:03.3757503-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:03.3757503-08:00","labels":["api","mutation"],"dependencies":[{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:03.377343-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1","type":"blocks","created_at":"2026-02-11T17:12:32.810993-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1.1","type":"blocks","created_at":"2026-02-11T17:12:33.313807-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.1","title":"Implement bd bridge using child_process.execFile with project-scoped cwd","description":"Wrap bd execution with command argument safety, Windows path compatibility, stdout/stderr parsing, and project-specific current working directory.","acceptance_criteria":"Bridge executes supported bd commands and returns structured result/error payloads.","notes":"Implemented src/lib/bridge.ts with execFile-based bd runner, project-scoped cwd, timeout support, structured command result payload, and failure classification (not_found, timeout, bad_args, non_zero_exit, unknown). Added RED-\u003eGREEN tests in tests/lib/bridge.test.ts.","status":"closed","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:01.7327732-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:16.7478549-08:00","closed_at":"2026-02-11T19:45:16.7478549-08:00","close_reason":"Bridge implemented with structured result/error classification and project-scoped execFile command execution; tests added.","labels":["bridge","execfile"],"dependencies":[{"issue_id":"bb-ymg.1","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:01.7343468-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:32.3039711-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.1.1","title":"Resolve bd.exe location from PATH and configuration fallback","description":"Add detection logic for bd executable and actionable errors when not found, including setup guidance.","acceptance_criteria":"Missing bd path returns clear setup instructions and diagnostics.","notes":"Implemented src/lib/bd-path.ts executable resolution with config-first then PATH lookup (bd.exe/bd.cmd/bd.bat/bd), plus actionable setup guidance when missing. Added tests/lib/bd-path.test.ts for success/failure cases.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:02.5593205-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:44:57.3720854-08:00","closed_at":"2026-02-11T19:44:57.3720854-08:00","close_reason":"Executable resolution implemented with config/PATH fallback and actionable missing-bd guidance; tests added.","labels":["bridge","setup"],"dependencies":[{"issue_id":"bb-ymg.1.1","depends_on_id":"bb-ymg.1","type":"parent-child","created_at":"2026-02-11T17:12:02.5603636-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.2","title":"Implement mutation API for create/update/close/reopen/comment operations","description":"Expose strict server-side mutation endpoints translating UI actions to corresponding bd commands with validated arguments.","acceptance_criteria":"All required mutation operations execute via bd and return normalized responses.","notes":"Implemented mutation validation/mapping/execution layer in src/lib/mutations.ts and App Router endpoints: /api/beads/create|update|close|reopen|comment. Added payload validation tests, route validation tests, and smoke-tested create/update/comment/close/reopen lifecycle via API.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:03.3757503-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:26.3234246-08:00","closed_at":"2026-02-11T19:45:26.3234246-08:00","close_reason":"Mutation API implemented for create/update/close/reopen/comment with payload validation, command mapping, normalized error shape, and verified smoke lifecycle via API.","labels":["api","mutation"],"dependencies":[{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:03.377343-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1","type":"blocks","created_at":"2026-02-11T17:12:32.810993-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1.1","type":"blocks","created_at":"2026-02-11T17:12:33.313807-08:00","created_by":"zenchantlive"}]} {"id":"bb-ymg.3","title":"Add optimistic updates with rollback and SSE reconciliation","description":"Apply immediate UI updates for responsiveness, rollback on command failure, and reconcile with watcher-triggered authoritative state updates.","acceptance_criteria":"Failed mutations restore previous UI state and emit meaningful error feedback.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:04.1956393-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:04.1956393-08:00","labels":["optimistic","state"],"dependencies":[{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:04.1966728-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:33.8246167-08:00","created_by":"zenchantlive"}]} {"id":"bb-ymg.4","title":"Implement drag-and-drop status transitions mapped to bd commands","description":"Map card moves to valid status transitions and use close/reopen semantics where applicable instead of direct file manipulation.","acceptance_criteria":"DnD transitions call proper bd commands and reject invalid transitions safely.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.0129676-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:05.0129676-08:00","labels":["dnd","kanban"],"dependencies":[{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:05.014527-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:34.329788-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:34.8422542-08:00","created_by":"zenchantlive"}]} diff --git a/package.json b/package.json index aa81b5e..adefd10 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" }, "dependencies": { "framer-motion": "^11.18.2", diff --git a/src/app/api/beads/_shared.ts b/src/app/api/beads/_shared.ts new file mode 100644 index 0000000..e5f5c50 --- /dev/null +++ b/src/app/api/beads/_shared.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; + +import { executeMutation, MutationValidationError, validateMutationPayload, type MutationOperation } from '../../../lib/mutations'; + +function badRequest(message: string, operation: MutationOperation) { + return NextResponse.json( + { + ok: false, + operation, + error: { + classification: 'bad_args', + message, + }, + }, + { status: 400 }, + ); +} + +export async function handleMutationRequest(request: Request, operation: MutationOperation): Promise { + let body: unknown; + + try { + body = await request.json(); + } catch { + return badRequest('Invalid JSON body.', operation); + } + + try { + const payload = validateMutationPayload(operation, body); + const result = await executeMutation(operation, payload); + + const status = result.ok ? 200 : result.error?.classification === 'not_found' ? 404 : 400; + return NextResponse.json(result, { status }); + } catch (error) { + if (error instanceof MutationValidationError) { + return badRequest(error.message, operation); + } + + return NextResponse.json( + { + ok: false, + operation, + error: { + classification: 'unknown', + message: error instanceof Error ? error.message : 'Unknown mutation error.', + }, + }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/beads/close/route.ts b/src/app/api/beads/close/route.ts new file mode 100644 index 0000000..e2cfbaf --- /dev/null +++ b/src/app/api/beads/close/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'close'); +} diff --git a/src/app/api/beads/comment/route.ts b/src/app/api/beads/comment/route.ts new file mode 100644 index 0000000..9e84164 --- /dev/null +++ b/src/app/api/beads/comment/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'comment'); +} diff --git a/src/app/api/beads/create/route.ts b/src/app/api/beads/create/route.ts new file mode 100644 index 0000000..5b2feba --- /dev/null +++ b/src/app/api/beads/create/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'create'); +} diff --git a/src/app/api/beads/reopen/route.ts b/src/app/api/beads/reopen/route.ts new file mode 100644 index 0000000..efae9dc --- /dev/null +++ b/src/app/api/beads/reopen/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'reopen'); +} diff --git a/src/app/api/beads/update/route.ts b/src/app/api/beads/update/route.ts new file mode 100644 index 0000000..a4ada91 --- /dev/null +++ b/src/app/api/beads/update/route.ts @@ -0,0 +1,5 @@ +import { handleMutationRequest } from '../_shared'; + +export async function POST(request: Request): Promise { + return handleMutationRequest(request, 'update'); +} diff --git a/src/lib/bd-path.ts b/src/lib/bd-path.ts new file mode 100644 index 0000000..6ab3be3 --- /dev/null +++ b/src/lib/bd-path.ts @@ -0,0 +1,78 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface ResolveBdExecutableOptions { + explicitPath?: string | null; + env?: NodeJS.ProcessEnv; +} + +export interface BdExecutableResolution { + executable: string; + source: 'config' | 'path'; +} + +export class BdExecutableNotFoundError extends Error { + readonly code = 'BD_NOT_FOUND'; + + constructor(message: string) { + super(message); + this.name = 'BdExecutableNotFoundError'; + } +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function splitEnvPath(env: NodeJS.ProcessEnv = process.env): string[] { + const value = env.Path ?? env.PATH ?? ''; + if (!value.trim()) { + return []; + } + + return value.split(';').map((segment) => segment.trim()).filter(Boolean); +} + +function executableCandidates(directory: string): string[] { + return ['bd.exe', 'bd.cmd', 'bd.bat', 'bd'].map((name) => path.join(directory, name)); +} + +function buildNotFoundMessage(explicitPath?: string | null): string { + const lines = [ + 'bd.exe was not found.', + 'Install it with: npm install -g @beads/bd', + 'Or configure an explicit executable path in request payload/config.', + ]; + + if (explicitPath) { + lines.push(`Configured path was not found: ${explicitPath}`); + } + + return lines.join(' '); +} + +export async function resolveBdExecutable(options: ResolveBdExecutableOptions = {}): Promise { + if (options.explicitPath && options.explicitPath.trim()) { + const explicit = path.resolve(options.explicitPath); + if (await fileExists(explicit)) { + return { executable: explicit, source: 'config' }; + } + + throw new BdExecutableNotFoundError(buildNotFoundMessage(options.explicitPath)); + } + + for (const dir of splitEnvPath(options.env)) { + for (const candidate of executableCandidates(dir)) { + if (await fileExists(candidate)) { + return { executable: candidate, source: 'path' }; + } + } + } + + throw new BdExecutableNotFoundError(buildNotFoundMessage()); +} diff --git a/src/lib/bridge.ts b/src/lib/bridge.ts new file mode 100644 index 0000000..2779e39 --- /dev/null +++ b/src/lib/bridge.ts @@ -0,0 +1,163 @@ +import { execFile as nodeExecFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path'; + +const execFileAsync = promisify(nodeExecFile); + +export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown'; + +export interface RunBdCommandOptions { + projectRoot: string; + args: string[]; + timeoutMs?: number; + explicitBdPath?: string | null; +} + +export interface RunBdCommandResult { + success: boolean; + classification: BdFailureClassification | null; + command: string; + args: string[]; + cwd: string; + stdout: string; + stderr: string; + code: number | null; + durationMs: number; + error: string | null; +} + +type ExecFileOptions = { + cwd: string; + timeout: number; + windowsHide: boolean; + env: NodeJS.ProcessEnv; +}; + +type ExecFileLike = ( + command: string, + args: string[], + options: ExecFileOptions, +) => Promise<{ stdout: string; stderr: string }>; + +interface RunBdCommandDeps { + resolveBdExecutable: typeof resolveBdExecutable; + execFile: ExecFileLike; + env: NodeJS.ProcessEnv; +} + +function normalizeOutput(text: unknown): string { + if (typeof text !== 'string') { + return ''; + } + + return text.replaceAll('\r\n', '\n').trim(); +} + +function toErrorMessage(value: unknown): string { + if (value instanceof Error) { + return value.message; + } + return String(value ?? 'Unknown error'); +} + +function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification { + if (error.code === 'ENOENT') { + return 'not_found'; + } + + if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') { + return 'timeout'; + } + + const stderr = normalizeOutput(error.stderr); + if (typeof error.code === 'number') { + if (/(unknown|invalid|required|usage)/i.test(stderr)) { + return 'bad_args'; + } + return 'non_zero_exit'; + } + + return 'unknown'; +} + +export async function runBdCommand( + options: RunBdCommandOptions, + injectedDeps?: Partial, +): Promise { + const startedAt = Date.now(); + const timeoutMs = options.timeoutMs ?? 30_000; + const cwd = options.projectRoot; + const args = [...options.args]; + + const deps: RunBdCommandDeps = { + resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable, + execFile: injectedDeps?.execFile ?? execFileAsync, + env: injectedDeps?.env ?? process.env, + }; + + let command = options.explicitBdPath ?? 'bd.exe'; + + try { + const resolved = await deps.resolveBdExecutable({ + explicitPath: options.explicitBdPath, + env: deps.env, + }); + command = resolved.executable; + + const { stdout, stderr } = await deps.execFile(command, args, { + cwd, + timeout: timeoutMs, + windowsHide: true, + env: deps.env, + }); + + return { + success: true, + classification: null, + command, + args, + cwd, + stdout: normalizeOutput(stdout), + stderr: normalizeOutput(stderr), + code: 0, + durationMs: Date.now() - startedAt, + error: null, + }; + } catch (rawError) { + if (rawError instanceof BdExecutableNotFoundError) { + return { + success: false, + classification: 'not_found', + command, + args, + cwd, + stdout: '', + stderr: '', + code: null, + durationMs: Date.now() - startedAt, + error: rawError.message, + }; + } + + const error = rawError as NodeJS.ErrnoException & { + stderr?: string; + stdout?: string; + killed?: boolean; + signal?: string; + }; + + return { + success: false, + classification: classifyFailure(error), + command, + args, + cwd, + stdout: normalizeOutput(error.stdout), + stderr: normalizeOutput(error.stderr), + code: typeof error.code === 'number' ? error.code : null, + durationMs: Date.now() - startedAt, + error: toErrorMessage(error), + }; + } +} diff --git a/src/lib/mutations.ts b/src/lib/mutations.ts new file mode 100644 index 0000000..d92f7d7 --- /dev/null +++ b/src/lib/mutations.ts @@ -0,0 +1,295 @@ +import { runBdCommand, type RunBdCommandResult } from './bridge'; + +export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment'; +export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed'; + +interface MutationBasePayload { + projectRoot: string; + bdPath?: string; +} + +export interface CreateMutationPayload extends MutationBasePayload { + title: string; + description?: string; + priority?: number; + issueType?: string; + assignee?: string; + labels?: string[]; +} + +export interface UpdateMutationPayload extends MutationBasePayload { + id: string; + title?: string; + description?: string; + status?: MutationStatus; + priority?: number; + assignee?: string; + labels?: string[]; +} + +export interface CloseMutationPayload extends MutationBasePayload { + id: string; + reason?: string; +} + +export interface ReopenMutationPayload extends MutationBasePayload { + id: string; + reason?: string; +} + +export interface CommentMutationPayload extends MutationBasePayload { + id: string; + text: string; +} + +export type MutationPayload = + | CreateMutationPayload + | UpdateMutationPayload + | CloseMutationPayload + | ReopenMutationPayload + | CommentMutationPayload; + +export interface MutationErrorShape { + classification: 'bad_args' | 'not_found' | 'timeout' | 'non_zero_exit' | 'unknown'; + message: string; +} + +export interface MutationResponse { + ok: boolean; + operation: MutationOperation; + command: RunBdCommandResult; + error?: MutationErrorShape; +} + +export class MutationValidationError extends Error { + readonly code = 'MUTATION_VALIDATION_ERROR'; + + constructor(message: string) { + super(message); + this.name = 'MutationValidationError'; + } +} + +function asNonEmptyString(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new MutationValidationError(`"${field}" is required.`); + } + return value.trim(); +} + +function asOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== 'string') { + throw new MutationValidationError('Expected a string value.'); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function asOptionalPriority(value: unknown): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 4) { + throw new MutationValidationError('"priority" must be a number between 0 and 4.'); + } + return value; +} + +function asOptionalLabels(value: unknown): string[] | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (!Array.isArray(value)) { + throw new MutationValidationError('"labels" must be an array of strings.'); + } + const labels = value.map((label) => { + if (typeof label !== 'string' || !label.trim()) { + throw new MutationValidationError('"labels" must be an array of non-empty strings.'); + } + return label.trim(); + }); + + return labels.length ? labels : undefined; +} + +function asOptionalStatus(value: unknown): MutationStatus | undefined { + if (value === undefined || value === null) { + return undefined; + } + const status = asNonEmptyString(value, 'status'); + if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status)) { + throw new MutationValidationError('"status" is invalid.'); + } + return status as MutationStatus; +} + +function parseBasePayload(raw: unknown): MutationBasePayload { + if (!raw || typeof raw !== 'object') { + throw new MutationValidationError('Payload must be a JSON object.'); + } + + const data = raw as Record; + return { + projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'), + bdPath: asOptionalString(data.bdPath), + }; +} + +export function validateMutationPayload(operation: MutationOperation, payload: unknown): MutationPayload { + const base = parseBasePayload(payload); + const data = payload as Record; + + if (operation === 'create') { + return { + ...base, + title: asNonEmptyString(data.title, 'title'), + description: asOptionalString(data.description), + priority: asOptionalPriority(data.priority), + issueType: asOptionalString(data.issueType), + assignee: asOptionalString(data.assignee), + labels: asOptionalLabels(data.labels), + }; + } + + if (operation === 'update') { + const mapped: UpdateMutationPayload = { + ...base, + id: asNonEmptyString(data.id, 'id'), + title: asOptionalString(data.title), + description: asOptionalString(data.description), + status: asOptionalStatus(data.status), + priority: asOptionalPriority(data.priority), + assignee: asOptionalString(data.assignee), + labels: asOptionalLabels(data.labels), + }; + + if (!mapped.title && !mapped.description && !mapped.status && mapped.priority === undefined && !mapped.assignee && !mapped.labels) { + throw new MutationValidationError('At least one update field is required.'); + } + + return mapped; + } + + if (operation === 'close') { + return { + ...base, + id: asNonEmptyString(data.id, 'id'), + reason: asOptionalString(data.reason), + }; + } + + if (operation === 'reopen') { + return { + ...base, + id: asNonEmptyString(data.id, 'id'), + reason: asOptionalString(data.reason), + }; + } + + return { + ...base, + id: asNonEmptyString(data.id, 'id'), + text: asNonEmptyString(data.text, 'text'), + }; +} + +function pushOptionalArg(args: string[], flag: string, value: string | undefined): void { + if (value) { + args.push(flag, value); + } +} + +function pushOptionalLabels(args: string[], labels: string[] | undefined): void { + if (labels && labels.length > 0) { + args.push('-l', labels.join(',')); + } +} + +export function buildBdMutationArgs(operation: MutationOperation, payload: MutationPayload): string[] { + if (operation === 'create') { + const data = payload as CreateMutationPayload; + const args = ['create', data.title]; + pushOptionalArg(args, '-d', data.description); + if (data.priority !== undefined) { + args.push('-p', String(data.priority)); + } + pushOptionalArg(args, '-t', data.issueType); + pushOptionalArg(args, '-a', data.assignee); + pushOptionalLabels(args, data.labels); + args.push('--json'); + return args; + } + + if (operation === 'update') { + const data = payload as UpdateMutationPayload; + const args = ['update', data.id]; + pushOptionalArg(args, '--title', data.title); + pushOptionalArg(args, '-d', data.description); + pushOptionalArg(args, '-s', data.status); + if (data.priority !== undefined) { + args.push('-p', String(data.priority)); + } + pushOptionalArg(args, '-a', data.assignee); + pushOptionalLabels(args, data.labels); + args.push('--json'); + return args; + } + + if (operation === 'close') { + const data = payload as CloseMutationPayload; + const args = ['close', data.id]; + pushOptionalArg(args, '-r', data.reason); + args.push('--json'); + return args; + } + + if (operation === 'reopen') { + const data = payload as ReopenMutationPayload; + const args = ['reopen', data.id]; + pushOptionalArg(args, '-r', data.reason); + args.push('--json'); + return args; + } + + const data = payload as CommentMutationPayload; + return ['comments', 'add', data.id, data.text, '--json']; +} + +interface ExecuteMutationDeps { + runBdCommand: typeof runBdCommand; +} + +export async function executeMutation( + operation: MutationOperation, + payload: MutationPayload, + deps: Partial = {}, +): Promise { + const runner = deps.runBdCommand ?? runBdCommand; + const args = buildBdMutationArgs(operation, payload); + const command = await runner({ + projectRoot: payload.projectRoot, + args, + explicitBdPath: payload.bdPath, + }); + + if (!command.success) { + return { + ok: false, + operation, + command, + error: { + classification: command.classification ?? 'unknown', + message: command.error ?? (command.stderr || 'Mutation command failed.'), + }, + }; + } + + return { + ok: true, + operation, + command, + }; +} diff --git a/tests/api/mutations-routes.test.ts b/tests/api/mutations-routes.test.ts new file mode 100644 index 0000000..6d1fe3b --- /dev/null +++ b/tests/api/mutations-routes.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { POST as createPost } from '../../src/app/api/beads/create/route'; +import { POST as reopenPost } from '../../src/app/api/beads/reopen/route'; +import { POST as commentPost } from '../../src/app/api/beads/comment/route'; + +async function readJson(response: Response): Promise { + const text = await response.text(); + return text ? JSON.parse(text) : {}; +} + +test('create route returns 400 for invalid payload', async () => { + const response = await createPost( + new Request('http://localhost/api/beads/create', { + method: 'POST', + body: JSON.stringify({ projectRoot: '', title: '' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + assert.equal(response.status, 400); + const data = await readJson(response); + assert.equal(data.ok, false); + assert.equal(data.error.classification, 'bad_args'); +}); + +test('reopen route returns 400 for missing id', async () => { + const response = await reopenPost( + new Request('http://localhost/api/beads/reopen', { + method: 'POST', + body: JSON.stringify({ projectRoot: 'C:/repo' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + assert.equal(response.status, 400); + const data = await readJson(response); + assert.equal(data.ok, false); +}); + +test('comment route returns 400 for missing comment text', async () => { + const response = await commentPost( + new Request('http://localhost/api/beads/comment', { + method: 'POST', + body: JSON.stringify({ projectRoot: 'C:/repo', id: 'bb-1' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + assert.equal(response.status, 400); + const data = await readJson(response); + assert.equal(data.ok, false); + assert.equal(typeof data.error.message, 'string'); +}); diff --git a/tests/lib/bd-path.test.ts b/tests/lib/bd-path.test.ts new file mode 100644 index 0000000..bc7d5ed --- /dev/null +++ b/tests/lib/bd-path.test.ts @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { BdExecutableNotFoundError, resolveBdExecutable } from '../../src/lib/bd-path'; + +test('resolveBdExecutable prefers explicit configured path when provided', async () => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-bd-path-')); + const explicit = path.join(temp, 'tools', 'bd.exe'); + await fs.mkdir(path.dirname(explicit), { recursive: true }); + await fs.writeFile(explicit, ''); + + const resolved = await resolveBdExecutable({ explicitPath: explicit, env: { Path: '', NODE_ENV: 'test' } }); + + assert.equal(resolved.executable, explicit); + assert.equal(resolved.source, 'config'); +}); + +test('resolveBdExecutable finds bd.exe on PATH when explicit path is not set', async () => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-bd-path-env-')); + const candidate = path.join(temp, 'bd.exe'); + await fs.writeFile(candidate, ''); + + const resolved = await resolveBdExecutable({ env: { Path: temp, NODE_ENV: 'test' } }); + + assert.equal(resolved.executable, candidate); + assert.equal(resolved.source, 'path'); +}); + +test('resolveBdExecutable throws actionable setup guidance when executable is missing', async () => { + await assert.rejects( + () => resolveBdExecutable({ env: { Path: '', NODE_ENV: 'test' } }), + (error: unknown) => { + assert.equal(error instanceof BdExecutableNotFoundError, true); + const message = String((error as Error).message).toLowerCase(); + assert.equal(message.includes('npm install -g @beads/bd'), true); + assert.equal(message.includes('bd.exe'), true); + return true; + }, + ); +}); diff --git a/tests/lib/bridge.test.ts b/tests/lib/bridge.test.ts new file mode 100644 index 0000000..98f6d75 --- /dev/null +++ b/tests/lib/bridge.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { runBdCommand } from '../../src/lib/bridge'; + +test('runBdCommand returns structured success payload from execFile output', async () => { + const result = await runBdCommand( + { + projectRoot: 'C:/repo/project', + args: ['list', '--json'], + timeoutMs: 2000, + explicitBdPath: 'C:/tools/bd.exe', + }, + { + resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }), + execFile: async (command, args, options) => { + assert.equal(command, 'C:/tools/bd.exe'); + assert.deepEqual(args, ['list', '--json']); + assert.equal(options.cwd, 'C:/repo/project'); + return { stdout: '[{"id":"bb-1"}]\r\n', stderr: '' }; + }, + }, + ); + + assert.equal(result.success, true); + assert.equal(result.classification, null); + assert.equal(result.stdout, '[{"id":"bb-1"}]'); +}); + +test('runBdCommand classifies missing executable as not_found', async () => { + const result = await runBdCommand( + { projectRoot: 'C:/repo/project', args: ['list'] }, + { + resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }), + execFile: async () => { + const error = new Error('spawn ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }, + }, + ); + + assert.equal(result.success, false); + assert.equal(result.classification, 'not_found'); +}); + +test('runBdCommand classifies timeout failures', async () => { + const result = await runBdCommand( + { projectRoot: 'C:/repo/project', args: ['list'], timeoutMs: 5 }, + { + resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }), + execFile: async () => { + const error = new Error('timed out') as NodeJS.ErrnoException & { killed?: boolean; signal?: string }; + error.code = 'ETIMEDOUT'; + error.killed = true; + error.signal = 'SIGTERM'; + throw error; + }, + }, + ); + + assert.equal(result.success, false); + assert.equal(result.classification, 'timeout'); +}); + +test('runBdCommand classifies non-zero bad-argument exits', async () => { + const result = await runBdCommand( + { projectRoot: 'C:/repo/project', args: ['update', '--bad-flag'] }, + { + resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }), + execFile: async () => { + const error = new Error('exit code 1') as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + }; + (error as any).code = 1; + error.stderr = 'unknown flag: --bad-flag'; + error.stdout = ''; + throw error; + }, + }, + ); + + assert.equal(result.success, false); + assert.equal(result.classification, 'bad_args'); +}); diff --git a/tests/lib/mutations.test.ts b/tests/lib/mutations.test.ts new file mode 100644 index 0000000..ecb37f6 --- /dev/null +++ b/tests/lib/mutations.test.ts @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MutationValidationError, + buildBdMutationArgs, + validateMutationPayload, + executeMutation, + type MutationOperation, +} from '../../src/lib/mutations'; + +const root = 'C:/Users/Zenchant/codex/beadboard'; + +test('validateMutationPayload rejects invalid payloads', () => { + assert.throws( + () => validateMutationPayload('create', { projectRoot: '', title: '' }), + (error: unknown) => error instanceof MutationValidationError, + ); +}); + +test('buildBdMutationArgs maps reopen correctly', () => { + const payload = validateMutationPayload('reopen', { + projectRoot: root, + id: 'bb-123', + reason: 'retry work', + }); + + const args = buildBdMutationArgs('reopen', payload); + assert.deepEqual(args, ['reopen', 'bb-123', '-r', 'retry work', '--json']); +}); + +test('buildBdMutationArgs maps comment correctly', () => { + const payload = validateMutationPayload('comment', { + projectRoot: root, + id: 'bb-123', + text: 'Added notes', + }); + + const args = buildBdMutationArgs('comment', payload); + assert.deepEqual(args, ['comments', 'add', 'bb-123', 'Added notes', '--json']); +}); + +test('executeMutation surfaces bridge failures in normalized response', async () => { + const payload = validateMutationPayload('close', { + projectRoot: root, + id: 'bb-123', + reason: 'completed', + }); + + const result = await executeMutation('close', payload, { + runBdCommand: async ({ args }) => { + assert.deepEqual(args, ['close', 'bb-123', '-r', 'completed', '--json']); + return { + success: false, + classification: 'non_zero_exit', + command: 'bd.exe', + args, + cwd: root, + stdout: '', + stderr: 'cannot close', + code: 1, + durationMs: 3, + error: 'cannot close', + }; + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.error?.classification, 'non_zero_exit'); +}); + +test('executeMutation returns successful normalized response', async () => { + const payload = validateMutationPayload('update', { + projectRoot: root, + id: 'bb-123', + status: 'in_progress', + priority: 1, + }); + + const result = await executeMutation('update', payload, { + runBdCommand: async ({ args }) => { + assert.deepEqual(args, ['update', 'bb-123', '-s', 'in_progress', '-p', '1', '--json']); + return { + success: true, + classification: null, + command: 'bd.exe', + args, + cwd: root, + stdout: '{"id":"bb-123"}', + stderr: '', + code: 0, + durationMs: 2, + error: null, + }; + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.operation, 'update'); + assert.equal(result.command.success, true); +}); From cc616c1543042183f85dfdb2ebc80a710fd2ac37 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 19:59:55 -0800 Subject: [PATCH 03/29] Add optimistic writeback flow with kanban drag-drop transitions --- .beads/issues.jsonl | 5 +- package.json | 2 +- src/app/api/beads/read/route.ts | 24 ++++ src/app/page.tsx | 2 +- src/components/kanban/kanban-board.tsx | 147 +++++++++++++++++++++---- src/components/kanban/kanban-card.tsx | 19 +++- src/components/kanban/kanban-page.tsx | 138 ++++++++++++++++++++--- src/lib/writeback.ts | 56 ++++++++++ tests/lib/writeback.test.ts | 55 +++++++++ 9 files changed, 403 insertions(+), 45 deletions(-) create mode 100644 src/app/api/beads/read/route.ts create mode 100644 src/lib/writeback.ts create mode 100644 tests/lib/writeback.test.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 46a9cb9..932462e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -19,6 +19,7 @@ {"id":"bb-92d.5","title":"Implement Windows path normalization utilities","description":"Create centralized helpers for canonical path keys, display formatting, and cross-drive normalization to avoid duplicate project identities.","acceptance_criteria":"Canonicalization is consistent for C:\\ and D:\\ style paths.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:46.0751161-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:27:27.7164974-08:00","closed_at":"2026-02-11T17:27:27.7164974-08:00","close_reason":"Implemented Windows path normalization utilities with canonicalization, keying, and display transformations.","labels":["paths","windows"],"dependencies":[{"issue_id":"bb-92d.5","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:46.0767429-08:00","created_by":"zenchantlive"}]} {"id":"bb-92d.6","title":"Add guardrail test preventing direct writes to .beads/issues.jsonl","description":"Enforce read/write boundary by scanning source for forbidden direct file write patterns targeting Beads issue files.","acceptance_criteria":"Guardrail test fails on boundary violations and passes when write path uses bd bridge only.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:46.9013352-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:28:27.4699395-08:00","closed_at":"2026-02-11T17:28:27.4699395-08:00","close_reason":"Added guardrail scanner and automated test to block direct writes to .beads/issues.jsonl.","labels":["guardrail","safety"],"dependencies":[{"issue_id":"bb-92d.6","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:46.9029535-08:00","created_by":"zenchantlive"}]} {"id":"bb-ag8","title":"TEMP_DELETE_ME","status":"closed","priority":4,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:10:04.5765506-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:10:10.3812634-08:00","closed_at":"2026-02-11T17:10:10.3812634-08:00","close_reason":"cleanup temp test issue"} +{"id":"bb-atl","title":"Writeback phase smoke","description":"Temp for optimistic and transition smoke","status":"closed","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:58:24.0374092-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:58:29.147102-08:00","closed_at":"2026-02-11T19:58:29.147102-08:00","close_reason":"cleanup writeback smoke","labels":["smoke","writeback"],"comments":[{"id":3,"issue_id":"bb-atl","author":"zenchantlive","text":"transition smoke reopen","created_at":"2026-02-12T03:58:27Z"}]} {"id":"bb-bc4","title":"Kanban Responsive Design Hardening","description":"Refine tracer-bullet Kanban into a production-grade, responsive experience across mobile/tablet/desktop using tokenized Tailwind styling and strict architecture boundaries. Scope includes layout reachability, card/column sizing integrity, improved visual language, and small-screen detail-panel behavior.","acceptance_criteria":"At 390x844, 768x1024, and 1440x900 all status columns are reachable, cards are not clipped, controls remain usable, and detail interactions work without direct JSONL write-path regressions.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:41.814041-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:21.5796629-08:00","closed_at":"2026-02-11T18:59:21.5796629-08:00","close_reason":"Responsive design hardening scope completed with tests and Playwright evidence.","labels":["design-system","kanban","responsive","ui"],"dependencies":[{"issue_id":"bb-bc4","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T18:50:41.817863-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T18:51:20.344-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.1","title":"Rework board responsiveness and horizontal reachability","description":"Implement intentional responsive board behavior: fluid column sizing, explicit horizontal board scrolling strategy, and viewport-safe wrappers so every status column is reachable without layout breakage. Use relative sizing constraints and avoid rigid fixed-width assumptions.","acceptance_criteria":"Board supports reliable horizontal reachability at all target breakpoints; no hidden/unreachable status columns.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:42.8356269-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:17.3199003-08:00","closed_at":"2026-02-11T18:59:17.3199003-08:00","close_reason":"Implemented fluid horizontal board reachability with snap and overflow containment across breakpoints.","labels":["kanban","layout","responsive"],"dependencies":[{"issue_id":"bb-bc4.1","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:42.837217-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.2","title":"Fix column/card sizing and overflow behavior","description":"Correct card and column sizing to prevent clipping, overflow artifacts, and unreadable metadata blocks. Ensure card internals wrap/truncate intentionally and columns maintain consistent density and scroll behavior.","acceptance_criteria":"Cards remain fully readable within columns, no clipped card content, and column internals scroll predictably.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:43.8439541-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:18.1823946-08:00","closed_at":"2026-02-11T18:59:18.1823946-08:00","close_reason":"Fixed card/column overflow and sizing with clamp-based widths, scroll-safe columns, and improved text wrapping.","labels":["cards","kanban","overflow"],"dependencies":[{"issue_id":"bb-bc4.2","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:43.8457677-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.2","depends_on_id":"bb-bc4.1","type":"blocks","created_at":"2026-02-11T18:50:43.8490043-08:00","created_by":"zenchantlive"}]} @@ -52,5 +53,5 @@ {"id":"bb-ymg.1","title":"Implement bd bridge using child_process.execFile with project-scoped cwd","description":"Wrap bd execution with command argument safety, Windows path compatibility, stdout/stderr parsing, and project-specific current working directory.","acceptance_criteria":"Bridge executes supported bd commands and returns structured result/error payloads.","notes":"Implemented src/lib/bridge.ts with execFile-based bd runner, project-scoped cwd, timeout support, structured command result payload, and failure classification (not_found, timeout, bad_args, non_zero_exit, unknown). Added RED-\u003eGREEN tests in tests/lib/bridge.test.ts.","status":"closed","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:01.7327732-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:16.7478549-08:00","closed_at":"2026-02-11T19:45:16.7478549-08:00","close_reason":"Bridge implemented with structured result/error classification and project-scoped execFile command execution; tests added.","labels":["bridge","execfile"],"dependencies":[{"issue_id":"bb-ymg.1","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:01.7343468-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:32.3039711-08:00","created_by":"zenchantlive"}]} {"id":"bb-ymg.1.1","title":"Resolve bd.exe location from PATH and configuration fallback","description":"Add detection logic for bd executable and actionable errors when not found, including setup guidance.","acceptance_criteria":"Missing bd path returns clear setup instructions and diagnostics.","notes":"Implemented src/lib/bd-path.ts executable resolution with config-first then PATH lookup (bd.exe/bd.cmd/bd.bat/bd), plus actionable setup guidance when missing. Added tests/lib/bd-path.test.ts for success/failure cases.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:02.5593205-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:44:57.3720854-08:00","closed_at":"2026-02-11T19:44:57.3720854-08:00","close_reason":"Executable resolution implemented with config/PATH fallback and actionable missing-bd guidance; tests added.","labels":["bridge","setup"],"dependencies":[{"issue_id":"bb-ymg.1.1","depends_on_id":"bb-ymg.1","type":"parent-child","created_at":"2026-02-11T17:12:02.5603636-08:00","created_by":"zenchantlive"}]} {"id":"bb-ymg.2","title":"Implement mutation API for create/update/close/reopen/comment operations","description":"Expose strict server-side mutation endpoints translating UI actions to corresponding bd commands with validated arguments.","acceptance_criteria":"All required mutation operations execute via bd and return normalized responses.","notes":"Implemented mutation validation/mapping/execution layer in src/lib/mutations.ts and App Router endpoints: /api/beads/create|update|close|reopen|comment. Added payload validation tests, route validation tests, and smoke-tested create/update/comment/close/reopen lifecycle via API.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:03.3757503-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:26.3234246-08:00","closed_at":"2026-02-11T19:45:26.3234246-08:00","close_reason":"Mutation API implemented for create/update/close/reopen/comment with payload validation, command mapping, normalized error shape, and verified smoke lifecycle via API.","labels":["api","mutation"],"dependencies":[{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:03.377343-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1","type":"blocks","created_at":"2026-02-11T17:12:32.810993-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1.1","type":"blocks","created_at":"2026-02-11T17:12:33.313807-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.3","title":"Add optimistic updates with rollback and SSE reconciliation","description":"Apply immediate UI updates for responsiveness, rollback on command failure, and reconcile with watcher-triggered authoritative state updates.","acceptance_criteria":"Failed mutations restore previous UI state and emit meaningful error feedback.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:04.1956393-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:04.1956393-08:00","labels":["optimistic","state"],"dependencies":[{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:04.1966728-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:33.8246167-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.4","title":"Implement drag-and-drop status transitions mapped to bd commands","description":"Map card moves to valid status transitions and use close/reopen semantics where applicable instead of direct file manipulation.","acceptance_criteria":"DnD transitions call proper bd commands and reject invalid transitions safely.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.0129676-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:05.0129676-08:00","labels":["dnd","kanban"],"dependencies":[{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:05.014527-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:34.329788-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:34.8422542-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.3","title":"Add optimistic updates with rollback and SSE reconciliation","description":"Apply immediate UI updates for responsiveness, rollback on command failure, and reconcile with watcher-triggered authoritative state updates.","acceptance_criteria":"Failed mutations restore previous UI state and emit meaningful error feedback.","notes":"Implemented optimistic status updates with rollback in Kanban page, per-issue pending state, and authoritative reconciliation via new GET /api/beads/read endpoint after successful mutations.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:04.1956393-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:59:02.289739-08:00","closed_at":"2026-02-11T19:59:02.289739-08:00","close_reason":"Optimistic board updates with rollback and authoritative post-mutation reconciliation via read route implemented and validated.","labels":["optimistic","state"],"dependencies":[{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:04.1966728-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:33.8246167-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.4","title":"Implement drag-and-drop status transitions mapped to bd commands","description":"Map card moves to valid status transitions and use close/reopen semantics where applicable instead of direct file manipulation.","acceptance_criteria":"DnD transitions call proper bd commands and reject invalid transitions safely.","notes":"Implemented lane drag-and-drop interactions in Kanban board, status transition planning (including closed -\u003e reopen+update), and mapped transitions to bd mutation API routes with pending-state safeguards.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.0129676-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:59:21.7655834-08:00","closed_at":"2026-02-11T19:59:21.7655834-08:00","close_reason":"Kanban lane drag-and-drop transitions now map to bd-backed close/reopen/update mutations with transition planner tests and runtime smoke validation.","labels":["dnd","kanban"],"dependencies":[{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:05.014527-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:34.329788-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:34.8422542-08:00","created_by":"zenchantlive"}]} diff --git a/package.json b/package.json index adefd10..564f5fa 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" }, "dependencies": { "framer-motion": "^11.18.2", diff --git a/src/app/api/beads/read/route.ts b/src/app/api/beads/read/route.ts new file mode 100644 index 0000000..a3510bc --- /dev/null +++ b/src/app/api/beads/read/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +import { readIssuesFromDisk } from '../../../../lib/read-issues'; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd(); + + try { + const issues = await readIssuesFromDisk({ projectRoot }); + return NextResponse.json({ ok: true, issues }); + } catch (error) { + return NextResponse.json( + { + ok: false, + error: { + classification: 'unknown', + message: error instanceof Error ? error.message : 'Failed to read issues.', + }, + }, + { status: 500 }, + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index a3d12e7..694d171 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,5 +3,5 @@ import { readIssuesFromDisk } from '../lib/read-issues'; export default async function Page() { const issues = await readIssuesFromDisk(); - return ; + return ; } diff --git a/src/components/kanban/kanban-board.tsx b/src/components/kanban/kanban-board.tsx index ef98b85..39f0151 100644 --- a/src/components/kanban/kanban-board.tsx +++ b/src/components/kanban/kanban-board.tsx @@ -1,8 +1,9 @@ 'use client'; import { AnimatePresence } from 'framer-motion'; +import type { DragEvent } from 'react'; -import { KANBAN_STATUSES } from '../../lib/kanban'; +import { KANBAN_STATUSES, type KanbanStatus } from '../../lib/kanban'; import type { BeadIssue } from '../../lib/types'; import { KanbanCard } from './kanban-card'; @@ -10,47 +11,153 @@ import { KanbanCard } from './kanban-card'; interface KanbanBoardProps { columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>; selectedIssueId: string | null; + pendingIssueIds: Set; + activeStatus: KanbanStatus | null; + onActivateStatus: (status: KanbanStatus | null) => void; + onMoveIssue: (issue: BeadIssue, targetStatus: KanbanStatus) => void; onSelect: (issue: BeadIssue) => void; } const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot: string }> = { - open: { label: 'Open', dot: 'bg-sky-300' }, + open: { label: 'Open', dot: 'bg-zinc-300' }, in_progress: { label: 'In Progress', dot: 'bg-amber-300' }, blocked: { label: 'Blocked', dot: 'bg-rose-300' }, - deferred: { label: 'Deferred', dot: 'bg-slate-300' }, + deferred: { label: 'Deferred', dot: 'bg-stone-400' }, closed: { label: 'Done', dot: 'bg-emerald-300' }, }; const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = { - open: 'bg-sky-500/10', + open: 'bg-zinc-500/10', in_progress: 'bg-amber-500/10', blocked: 'bg-rose-500/10', - deferred: 'bg-slate-500/10', + deferred: 'bg-stone-500/10', closed: 'bg-emerald-500/10', }; -export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) { +export function KanbanBoard({ columns, selectedIssueId, pendingIssueIds, activeStatus, onActivateStatus, onMoveIssue, onSelect }: KanbanBoardProps) { + const allIssues = KANBAN_STATUSES.flatMap((status) => columns[status]); + + const issueLookup = new Map(allIssues.map((issue) => [issue.id, issue])); + + const handleExpandAndSelect = (status: KanbanStatus, issue: BeadIssue) => { + onActivateStatus(status); + onSelect(issue); + }; + + const onDragStart = (issue: BeadIssue, event: DragEvent) => { + event.dataTransfer.setData('application/x-bead-id', issue.id); + event.dataTransfer.setData('application/x-bead-status', issue.status); + event.dataTransfer.effectAllowed = 'move'; + }; + + const onDropLane = (targetStatus: KanbanStatus, event: DragEvent) => { + event.preventDefault(); + const issueId = event.dataTransfer.getData('application/x-bead-id'); + const sourceStatus = event.dataTransfer.getData('application/x-bead-status') as KanbanStatus; + if (!issueId || !sourceStatus || sourceStatus === targetStatus) { + return; + } + + const issue = issueLookup.get(issueId); + if (!issue) { + return; + } + + onMoveIssue(issue, targetStatus); + }; + return ( -
+
{KANBAN_STATUSES.map((status) => (
event.preventDefault()} + onDrop={(event) => onDropLane(status, event)} + className={`rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5 transition ${ + activeStatus === status ? 'shadow-card' : 'opacity-90' + }`} > -
- - - {STATUS_META[status].label} - - {columns[status].length} +
+ + {activeStatus === status ? ( + + ) : null}
-
- - {columns[status].map((issue) => ( - + {activeStatus === status ? ( +
+ + {columns[status].map((issue) => ( + + ))} + + {columns[status].length === 0 ? ( +
+ No beads +
+ ) : null} +
+ ) : ( +
+ {columns[status].slice(0, 6).map((issue) => ( + ))} - -
+ {columns[status].length > 6 ? ( + + ) : null} + {columns[status].length === 0 ? ( + + No beads + + ) : null} +
+ )}
))}
diff --git a/src/components/kanban/kanban-card.tsx b/src/components/kanban/kanban-card.tsx index f2642a1..c105666 100644 --- a/src/components/kanban/kanban-card.tsx +++ b/src/components/kanban/kanban-card.tsx @@ -1,6 +1,7 @@ 'use client'; import { motion } from 'framer-motion'; +import type { DragEvent } from 'react'; import type { BeadIssue } from '../../lib/types'; @@ -9,6 +10,9 @@ import { Chip } from '../shared/chip'; interface KanbanCardProps { issue: BeadIssue; selected: boolean; + pending?: boolean; + draggable?: boolean; + onNativeDragStart?: (issue: BeadIssue, event: DragEvent) => void; onSelect: (issue: BeadIssue) => void; } @@ -19,7 +23,7 @@ function priorityClass(priority: number): string { case 1: return 'border-amber-300/40 bg-amber-500/20 text-amber-50'; case 2: - return 'border-sky-300/40 bg-sky-500/20 text-sky-50'; + return 'border-teal-300/40 bg-teal-500/20 text-teal-50'; case 3: return 'border-slate-300/35 bg-slate-500/22 text-slate-50'; default: @@ -27,9 +31,9 @@ function priorityClass(priority: number): string { } } -export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { +export function KanbanCard({ issue, selected, pending = false, draggable = false, onNativeDragStart, onSelect }: KanbanCardProps) { const selectedClass = selected - ? 'border-cyan-300/80 bg-surface-raised shadow-card ring-1 ring-cyan-300/35' + ? 'border-amber-200/60 bg-surface-raised shadow-card ring-1 ring-amber-200/20' : 'border-border-soft bg-surface/95 shadow-[0_6px_18px_rgba(4,8,17,0.5)] hover:border-border-strong hover:bg-surface-raised/95'; return ( @@ -37,8 +41,12 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { layout transition={{ duration: 0.18, ease: 'easeOut' }} type="button" + draggable={draggable} + onDragStartCapture={(event) => onNativeDragStart?.(issue, event)} onClick={() => onSelect(issue)} - className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass}`} + className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass} ${ + pending ? 'opacity-70' : '' + }`} >
{issue.id}
{issue.title}
@@ -51,7 +59,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { {issue.issue_type} deps {issue.dependencies.length} -
+
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
{issue.labels.length > 0 ? ( @@ -61,6 +69,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { ))}
) : null} + {pending ?
Saving…
: null} ); } diff --git a/src/components/kanban/kanban-page.tsx b/src/components/kanban/kanban-page.tsx index 5bcb194..1eb04af 100644 --- a/src/components/kanban/kanban-page.tsx +++ b/src/components/kanban/kanban-page.tsx @@ -1,11 +1,12 @@ 'use client'; import { motion } from 'framer-motion'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import type { KanbanFilterOptions } from '../../lib/kanban'; +import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban'; import { buildKanbanColumns, buildKanbanStats, filterKanbanIssues } from '../../lib/kanban'; import type { BeadIssue } from '../../lib/types'; +import { applyOptimisticStatus, planStatusTransition } from '../../lib/writeback'; import { KanbanBoard } from './kanban-board'; import { KanbanControls } from './kanban-controls'; @@ -13,49 +14,154 @@ import { KanbanDetail } from './kanban-detail'; interface KanbanPageProps { issues: BeadIssue[]; + projectRoot: string; } -export function KanbanPage({ issues }: KanbanPageProps) { +type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment'; + +interface MutationErrorResponse { + error?: { message?: string }; +} + +async function postMutation(operation: MutationOperation, body: Record) { + const response = await fetch(`/api/beads/${operation}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const payload = (await response.json()) as { ok: boolean; error?: { message?: string } }; + + if (!response.ok || !payload.ok) { + throw new Error(payload.error?.message ?? `${operation} failed`); + } +} + +async function fetchIssues(projectRoot: string): Promise { + const response = await fetch(`/api/beads/read?projectRoot=${encodeURIComponent(projectRoot)}`, { + cache: 'no-store', + }); + const payload = (await response.json()) as { ok: boolean; issues?: BeadIssue[] } & MutationErrorResponse; + if (!response.ok || !payload.ok || !payload.issues) { + throw new Error(payload.error?.message ?? 'Failed to refresh issues'); + } + return payload.issues; +} + +export function KanbanPage({ issues, projectRoot }: KanbanPageProps) { + const [localIssues, setLocalIssues] = useState(issues); const [filters, setFilters] = useState({ query: '', type: '', priority: '', - showClosed: false, + showClosed: true, }); - const [selectedIssueId, setSelectedIssueId] = useState(issues[0]?.id ?? null); + const [selectedIssueId, setSelectedIssueId] = useState(null); const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + const [activeStatus, setActiveStatus] = useState('open'); + const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false); + const [pendingIssueIds, setPendingIssueIds] = useState>(new Set()); + const [mutationError, setMutationError] = useState(null); - const filteredIssues = useMemo(() => filterKanbanIssues(issues, filters), [issues, filters]); + useEffect(() => { + setLocalIssues(issues); + }, [issues]); + + const filteredIssues = useMemo(() => filterKanbanIssues(localIssues, filters), [localIssues, filters]); const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]); const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]); - const selectedIssue = useMemo( - () => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? filteredIssues[0] ?? null, - [filteredIssues, selectedIssueId], - ); + const selectedIssue = useMemo(() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? null, [filteredIssues, selectedIssueId]); + const showDesktopDetail = Boolean(selectedIssue) && !desktopDetailMinimized; + + const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => { + const steps = planStatusTransition(issue, targetStatus); + if (steps.length === 0) { + return; + } + + setMutationError(null); + const previous = localIssues; + setPendingIssueIds((value) => new Set(value).add(issue.id)); + setLocalIssues((current) => applyOptimisticStatus(current, issue.id, targetStatus)); + + try { + for (const step of steps) { + await postMutation(step.operation, { + projectRoot, + ...step.payload, + }); + } + + const reconciled = await fetchIssues(projectRoot); + setLocalIssues(reconciled); + } catch (error) { + setLocalIssues(previous); + setMutationError(error instanceof Error ? error.message : 'Mutation failed'); + } finally { + setPendingIssueIds((value) => { + const next = new Set(value); + next.delete(issue.id); + return next; + }); + } + }; return (
-

BeadBoard

+

BeadBoard

Kanban Dashboard

Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.

-
- + {mutationError ? ( +
{mutationError}
+ ) : null} +
+ { setSelectedIssueId(issue.id); + setDesktopDetailMinimized(false); setMobileDetailOpen(true); }} /> -
- -
+ {showDesktopDetail ? ( +
+ +
+ ) : null}
{mobileDetailOpen && selectedIssue ? ( diff --git a/src/lib/writeback.ts b/src/lib/writeback.ts new file mode 100644 index 0000000..ca61e85 --- /dev/null +++ b/src/lib/writeback.ts @@ -0,0 +1,56 @@ +import type { BeadIssue, BeadStatus } from './types'; + +export type MutationStep = + | { operation: 'close'; payload: { id: string; reason?: string } } + | { operation: 'reopen'; payload: { id: string; reason?: string } } + | { operation: 'update'; payload: { id: string; status: 'open' | 'in_progress' | 'blocked' | 'deferred' } }; + +function isBoardStatus(status: BeadStatus): status is 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed' { + return ['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status); +} + +export function planStatusTransition( + issue: Pick, + targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed', +): MutationStep[] { + if (!isBoardStatus(issue.status) || issue.status === targetStatus) { + return []; + } + + if (targetStatus === 'closed') { + return [{ operation: 'close', payload: { id: issue.id, reason: 'Moved to closed via board drag-and-drop' } }]; + } + + if (issue.status === 'closed') { + if (targetStatus === 'open') { + return [{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } }]; + } + + return [ + { operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } }, + { operation: 'update', payload: { id: issue.id, status: targetStatus } }, + ]; + } + + return [{ operation: 'update', payload: { id: issue.id, status: targetStatus } }]; +} + +export function applyOptimisticStatus( + issues: BeadIssue[], + issueId: string, + targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed', + atIso: string = new Date().toISOString(), +): BeadIssue[] { + return issues.map((issue) => { + if (issue.id !== issueId) { + return issue; + } + + return { + ...issue, + status: targetStatus, + updated_at: atIso, + closed_at: targetStatus === 'closed' ? atIso : null, + }; + }); +} diff --git a/tests/lib/writeback.test.ts b/tests/lib/writeback.test.ts new file mode 100644 index 0000000..d0ab061 --- /dev/null +++ b/tests/lib/writeback.test.ts @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { applyOptimisticStatus, planStatusTransition } from '../../src/lib/writeback'; +import type { BeadIssue } from '../../src/lib/types'; + +test('planStatusTransition maps open -> closed to close command', () => { + const steps = planStatusTransition({ id: 'bb-1', status: 'open' }, 'closed'); + assert.deepEqual(steps, [{ operation: 'close', payload: { id: 'bb-1', reason: 'Moved to closed via board drag-and-drop' } }]); +}); + +test('planStatusTransition maps closed -> in_progress to reopen + update', () => { + const steps = planStatusTransition({ id: 'bb-2', status: 'closed' }, 'in_progress'); + assert.deepEqual(steps, [ + { operation: 'reopen', payload: { id: 'bb-2', reason: 'Moved from closed via board drag-and-drop' } }, + { operation: 'update', payload: { id: 'bb-2', status: 'in_progress' } }, + ]); +}); + +test('planStatusTransition maps non-closed transitions to update', () => { + const steps = planStatusTransition({ id: 'bb-3', status: 'blocked' }, 'open'); + assert.deepEqual(steps, [{ operation: 'update', payload: { id: 'bb-3', status: 'open' } }]); +}); + +test('applyOptimisticStatus updates selected issue status and timestamps', () => { + const issues: BeadIssue[] = [ + { + id: 'bb-1', + title: 'One', + description: null, + status: 'open', + priority: 2, + issue_type: 'task', + assignee: null, + owner: null, + labels: [], + dependencies: [], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + closed_at: null, + close_reason: null, + closed_by_session: null, + created_by: null, + due_at: null, + estimated_minutes: null, + external_ref: null, + metadata: {}, + }, + ]; + + const updated = applyOptimisticStatus(issues, 'bb-1', 'closed', '2026-02-12T00:00:00Z'); + assert.equal(updated[0].status, 'closed'); + assert.equal(updated[0].closed_at, '2026-02-12T00:00:00Z'); + assert.equal(updated[0].updated_at, '2026-02-12T00:00:00Z'); +}); From c836be46cf035be34c622e6a63a3253a0514269e Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 17:53:40 -0800 Subject: [PATCH 04/29] feat: add Windows project registry API and persistence --- package.json | 2 +- src/app/api/projects/route.ts | 60 +++++++++++++ src/lib/registry.ts | 140 +++++++++++++++++++++++++++++++ tests/api/projects-route.test.ts | 109 ++++++++++++++++++++++++ tests/lib/registry.test.ts | 86 +++++++++++++++++++ 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/app/api/projects/route.ts create mode 100644 src/lib/registry.ts create mode 100644 tests/api/projects-route.test.ts create mode 100644 tests/lib/registry.test.ts diff --git a/package.json b/package.json index 564f5fa..199f89e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" }, "dependencies": { "framer-motion": "^11.18.2", diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..240c7c4 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; + +import { addProject, listProjects, RegistryValidationError, removeProject } from '../../../lib/registry'; + +export const runtime = 'nodejs'; + +function projectsPayload(projects: Array<{ path: string }>): { projects: Array<{ path: string }> } { + return { + projects: projects.map((project) => ({ path: project.path })), + }; +} + +async function readPathFromBody(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + throw new RegistryValidationError('Request body must be valid JSON.'); + } + + const path = (body as { path?: unknown }).path; + if (typeof path !== 'string' || path.trim().length === 0) { + throw new RegistryValidationError('`path` is required and must be a non-empty string.'); + } + + return path; +} + +export async function GET(): Promise { + const projects = await listProjects(); + return NextResponse.json(projectsPayload(projects), { status: 200 }); +} + +export async function POST(request: Request): Promise { + try { + const projectPath = await readPathFromBody(request); + const result = await addProject(projectPath); + return NextResponse.json(projectsPayload(result.projects), { status: result.added ? 201 : 200 }); + } catch (error) { + if (error instanceof RegistryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to add project.' }, { status: 500 }); + } +} + +export async function DELETE(request: Request): Promise { + try { + const projectPath = await readPathFromBody(request); + const result = await removeProject(projectPath); + return NextResponse.json({ removed: result.removed, ...projectsPayload(result.projects) }, { status: 200 }); + } catch (error) { + if (error instanceof RegistryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to remove project.' }, { status: 500 }); + } +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts new file mode 100644 index 0000000..eacd45b --- /dev/null +++ b/src/lib/registry.ts @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing'; + +export interface RegistryProject { + path: string; + key: string; +} + +interface RegistryDocument { + version: 1; + projects: RegistryProject[]; +} + +export class RegistryValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'RegistryValidationError'; + } +} + +function userProfileRoot(): string { + return process.env.USERPROFILE?.trim() || os.homedir(); +} + +export function registryFilePath(): string { + return path.join(userProfileRoot(), '.beadboard', 'projects.json'); +} + +function ensureWindowsAbsolutePath(input: string): string { + const normalized = canonicalizeWindowsPath(input.trim()); + if (!/^[A-Za-z]:\\/.test(normalized)) { + throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).'); + } + + return normalized; +} + +function normalizeProject(input: string): RegistryProject { + const normalized = ensureWindowsAbsolutePath(input); + return { + path: toDisplayPath(normalized), + key: windowsPathKey(normalized), + }; +} + +function normalizeProjects(input: unknown): RegistryProject[] { + if (!Array.isArray(input)) { + return []; + } + + const seen = new Set(); + const normalized: RegistryProject[] = []; + + for (const item of input) { + if (!item || typeof item !== 'object') { + continue; + } + + const candidate = item as { path?: unknown }; + if (typeof candidate.path !== 'string') { + continue; + } + + try { + const project = normalizeProject(candidate.path); + if (!seen.has(project.key)) { + seen.add(project.key); + normalized.push(project); + } + } catch { + continue; + } + } + + return normalized; +} + +async function readRegistryDocument(): Promise { + const filePath = registryFilePath(); + + try { + const raw = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw) as { projects?: unknown }; + return { + version: 1, + projects: normalizeProjects(parsed.projects), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { version: 1, projects: [] }; + } + + throw error; + } +} + +async function writeRegistryDocument(document: RegistryDocument): Promise { + const filePath = registryFilePath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); +} + +export async function listProjects(): Promise { + const document = await readRegistryDocument(); + return document.projects; +} + +export async function addProject(projectPath: string): Promise<{ added: boolean; projects: RegistryProject[] }> { + const document = await readRegistryDocument(); + const project = normalizeProject(projectPath); + + if (document.projects.some((entry) => entry.key === project.key)) { + return { added: false, projects: document.projects }; + } + + document.projects.push(project); + await writeRegistryDocument(document); + return { added: true, projects: document.projects }; +} + +export async function removeProject(projectPath: string): Promise<{ removed: boolean; projects: RegistryProject[] }> { + const document = await readRegistryDocument(); + const project = normalizeProject(projectPath); + const nextProjects = document.projects.filter((entry) => entry.key !== project.key); + + if (nextProjects.length === document.projects.length) { + return { removed: false, projects: document.projects }; + } + + const nextDocument: RegistryDocument = { + version: 1, + projects: nextProjects, + }; + + await writeRegistryDocument(nextDocument); + return { removed: true, projects: nextDocument.projects }; +} diff --git a/tests/api/projects-route.test.ts b/tests/api/projects-route.test.ts new file mode 100644 index 0000000..0f4d77e --- /dev/null +++ b/tests/api/projects-route.test.ts @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { DELETE, GET, POST } from '../../src/app/api/projects/route'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-api-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function readJson(response: Response): Promise { + return response.json(); +} + +test('GET /api/projects returns empty list initially', async () => { + await withTempUserProfile(async () => { + const response = await GET(); + assert.equal(response.status, 200); + + const body = (await readJson(response)) as { projects: unknown[] }; + assert.deepEqual(body.projects, []); + }); +}); + +test('POST /api/projects validates payload and path', async () => { + await withTempUserProfile(async () => { + const missing = await POST(new Request('http://localhost/api/projects', { method: 'POST', body: '{}' })); + assert.equal(missing.status, 400); + + const missingBody = (await readJson(missing)) as { error: string }; + assert.match(missingBody.error, /path/i); + + const invalidPath = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: '/tmp/project' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(invalidPath.status, 400); + }); +}); + +test('POST deduplicates and GET returns normalized path', async () => { + await withTempUserProfile(async () => { + const first = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'c:/Users/Zenchant/codex/beadboard/' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(first.status, 201); + + const dup = await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'C:\\users\\zenchant\\codex\\beadboard' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(dup.status, 200); + + const list = await GET(); + const body = (await readJson(list)) as { projects: Array<{ path: string }> }; + assert.deepEqual(body.projects, [{ path: 'C:/Users/Zenchant/codex/beadboard' }]); + }); +}); + +test('DELETE /api/projects removes by normalized path', async () => { + await withTempUserProfile(async () => { + await POST( + new Request('http://localhost/api/projects', { + method: 'POST', + body: JSON.stringify({ path: 'D:/Repos/One' }), + headers: { 'content-type': 'application/json' }, + }), + ); + + const removed = await DELETE( + new Request('http://localhost/api/projects', { + method: 'DELETE', + body: JSON.stringify({ path: 'd:\\repos\\one\\' }), + headers: { 'content-type': 'application/json' }, + }), + ); + assert.equal(removed.status, 200); + + const list = await GET(); + const body = (await readJson(list)) as { projects: unknown[] }; + assert.deepEqual(body.projects, []); + }); +}); diff --git a/tests/lib/registry.test.ts b/tests/lib/registry.test.ts new file mode 100644 index 0000000..15537df --- /dev/null +++ b/tests/lib/registry.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + addProject, + listProjects, + removeProject, + registryFilePath, + type RegistryProject, +} from '../../src/lib/registry'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => { + await withTempUserProfile(async (userProfile) => { + const result = registryFilePath(); + assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json')); + }); +}); + +test('listProjects returns empty when registry does not exist', async () => { + await withTempUserProfile(async () => { + const result = await listProjects(); + assert.deepEqual(result, []); + }); +}); + +test('addProject persists normalized path and deduplicates case/separators', async () => { + await withTempUserProfile(async () => { + const first = await addProject('c:/Work/Alpha/'); + assert.equal(first.added, true); + + const second = await addProject('C:\\work\\alpha'); + assert.equal(second.added, false); + + const listed = await listProjects(); + assert.equal(listed.length, 1); + assert.equal(listed[0].path, 'C:/Work/Alpha'); + + const file = await fs.readFile(registryFilePath(), 'utf8'); + const parsed = JSON.parse(file) as { projects: RegistryProject[] }; + assert.equal(parsed.projects.length, 1); + }); +}); + +test('removeProject removes matching normalized path', async () => { + await withTempUserProfile(async () => { + await addProject('D:/Repos/One'); + await addProject('D:/Repos/Two'); + + const removed = await removeProject('d:\\repos\\one\\'); + assert.equal(removed.removed, true); + + const listed = await listProjects(); + assert.deepEqual( + listed.map((project) => project.path), + ['D:/Repos/Two'], + ); + }); +}); + +test('addProject rejects non-Windows absolute paths', async () => { + await withTempUserProfile(async () => { + await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i); + await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i); + }); +}); From 50d38337667459b191afdc28f59e82a6cc461baa Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 20:41:39 -0800 Subject: [PATCH 05/29] feat: add project scanner with full-drive mode --- package.json | 2 +- src/app/api/scan/route.ts | 44 ++++++++ src/lib/scanner.ts | 223 ++++++++++++++++++++++++++++++++++++++ tests/lib/scanner.test.ts | 68 ++++++++++++ 4 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 src/app/api/scan/route.ts create mode 100644 src/lib/scanner.ts create mode 100644 tests/lib/scanner.test.ts diff --git a/package.json b/package.json index 199f89e..fb80d6e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/lib/scanner.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" }, "dependencies": { "framer-motion": "^11.18.2", diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts new file mode 100644 index 0000000..390f122 --- /dev/null +++ b/src/app/api/scan/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; + +import { scanForProjects } from '../../../lib/scanner'; +import type { ScanMode } from '../../../lib/scanner'; + +export const runtime = 'nodejs'; + +function parseMode(value: string | null): ScanMode { + if (!value || value === 'default') { + return 'default'; + } + + if (value === 'full-drive') { + return 'full-drive'; + } + + throw new Error('Invalid scan mode. Use mode=default or mode=full-drive.'); +} + +function parseDepth(value: string | null): number | undefined { + if (!value) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error('Depth must be a non-negative integer.'); + } + + return parsed; +} + +export async function GET(request: Request): Promise { + try { + const url = new URL(request.url); + const mode = parseMode(url.searchParams.get('mode')); + const maxDepth = parseDepth(url.searchParams.get('depth')); + const result = await scanForProjects({ mode, maxDepth }); + return NextResponse.json(result, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to scan projects.'; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts new file mode 100644 index 0000000..aa24225 --- /dev/null +++ b/src/lib/scanner.ts @@ -0,0 +1,223 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing'; +import { listProjects } from './registry'; + +export type ScanMode = 'default' | 'full-drive'; + +export interface ScannerProject { + root: string; + key: string; + displayPath: string; +} + +export interface ScanStats { + scannedDirectories: number; + ignoredDirectories: number; + skippedDirectories: number; + elapsedMs: number; +} + +export interface ScanOptions { + mode?: ScanMode; + maxDepth?: number; + roots?: string[]; + ignoreDirectories?: string[]; +} + +export interface ScanResult { + mode: ScanMode; + roots: string[]; + projects: ScannerProject[]; + stats: ScanStats; +} + +const DEFAULT_MAX_DEPTH = 6; +const DEFAULT_IGNORE_DIRECTORIES = [ + 'node_modules', + '.git', + '.next', + 'dist', + 'build', + 'out', + 'coverage', + 'artifacts', + 'logs', + '.worktrees', // TODO: confirm whether worktrees should be scan targets. +]; + +function userProfileRoot(): string { + return process.env.USERPROFILE?.trim() || os.homedir(); +} + +function toCanonicalRoot(input: string): string { + return canonicalizeWindowsPath(input); +} + +function shouldSkipFsError(error: NodeJS.ErrnoException): boolean { + return error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES' || error.code === 'EPERM'; +} + +async function ensureDirectoryExists(input: string): Promise { + try { + const stat = await fs.stat(input); + return stat.isDirectory() ? input : null; + } catch (error) { + if (shouldSkipFsError(error as NodeJS.ErrnoException)) { + return null; + } + throw error; + } +} + +async function resolveFullDriveRoots(): Promise { + const candidates = ['C:\\', 'D:\\']; + const roots: string[] = []; + + for (const candidate of candidates) { + const existing = await ensureDirectoryExists(candidate); + if (existing) { + roots.push(existing); + } + } + + return roots; +} + +export async function resolveScanRoots(options: ScanOptions = {}): Promise { + const mode: ScanMode = options.mode ?? 'default'; + const registryProjects = await listProjects(); + const roots = [ + userProfileRoot(), + ...registryProjects.map((project) => project.path), + ...(options.roots ?? []), + ]; + + if (mode === 'full-drive') { + roots.push(...(await resolveFullDriveRoots())); + } + + const seen = new Set(); + const normalizedRoots: string[] = []; + + for (const root of roots) { + const normalized = toCanonicalRoot(root); + const key = windowsPathKey(normalized); + if (seen.has(key)) { + continue; + } + + const existing = await ensureDirectoryExists(normalized); + if (!existing) { + continue; + } + + seen.add(key); + normalizedRoots.push(existing); + } + + return normalizedRoots; +} + +function buildIgnoreSet(additional: string[] = []): Set { + return new Set( + [...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean), + ); +} + +function recordProject(projects: Map, root: string): void { + const normalized = toCanonicalRoot(root); + const key = windowsPathKey(normalized); + if (!projects.has(key)) { + projects.set(key, { + root: normalized, + key, + displayPath: toDisplayPath(normalized), + }); + } +} + +async function scanRoot( + root: string, + maxDepth: number, + ignoreSet: Set, + projects: Map, + stats: ScanStats, +): Promise { + const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + + stats.scannedDirectories += 1; + let entries: fs.Dirent[]; + try { + entries = await fs.readdir(current.dir, { withFileTypes: true }); + } catch (error) { + if (shouldSkipFsError(error as NodeJS.ErrnoException)) { + stats.skippedDirectories += 1; + continue; + } + throw error; + } + + let hasBeads = false; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + if (entry.name === '.beads') { + hasBeads = true; + continue; + } + + const entryName = entry.name.toLowerCase(); + if (ignoreSet.has(entryName)) { + stats.ignoredDirectories += 1; + continue; + } + + if (current.depth < maxDepth) { + queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 }); + } + } + + if (hasBeads) { + recordProject(projects, current.dir); + } + } +} + +export async function scanForProjects(options: ScanOptions = {}): Promise { + const mode: ScanMode = options.mode ?? 'default'; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; + const ignoreSet = buildIgnoreSet(options.ignoreDirectories); + const roots = await resolveScanRoots(options); + const projects = new Map(); + const stats: ScanStats = { + scannedDirectories: 0, + ignoredDirectories: 0, + skippedDirectories: 0, + elapsedMs: 0, + }; + const start = Date.now(); + + for (const root of roots) { + await scanRoot(root, maxDepth, ignoreSet, projects, stats); + } + + stats.elapsedMs = Date.now() - start; + + return { + mode, + roots, + projects: Array.from(projects.values()), + stats, + }; +} diff --git a/tests/lib/scanner.test.ts b/tests/lib/scanner.test.ts new file mode 100644 index 0000000..3b3a1c6 --- /dev/null +++ b/tests/lib/scanner.test.ts @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { addProject } from '../../src/lib/registry'; +import { scanForProjects, resolveScanRoots } from '../../src/lib/scanner'; +import { canonicalizeWindowsPath, sameWindowsPath, windowsPathKey } from '../../src/lib/pathing'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scan-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('resolveScanRoots includes profile and registry roots by default', async () => { + await withTempUserProfile(async (userProfile) => { + const registryRoot = path.join(userProfile, 'Registered'); + await fs.mkdir(registryRoot, { recursive: true }); + await addProject(registryRoot); + + const roots = await resolveScanRoots(); + + assert.equal(roots.some((root) => sameWindowsPath(root, userProfile)), true); + assert.equal(roots.some((root) => sameWindowsPath(root, registryRoot)), true); + assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), false); + }); +}); + +test('resolveScanRoots includes full-drive roots only when requested', async () => { + await withTempUserProfile(async () => { + const roots = await resolveScanRoots({ mode: 'full-drive' }); + assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), true); + }); +}); + +test('scanForProjects respects depth limits and ignore list', async () => { + await withTempUserProfile(async (userProfile) => { + const projectRoot = path.join(userProfile, 'ProjectA'); + await fs.mkdir(path.join(projectRoot, '.beads'), { recursive: true }); + + const ignoredRoot = path.join(userProfile, 'node_modules', 'Ignored'); + await fs.mkdir(path.join(ignoredRoot, '.beads'), { recursive: true }); + + const deepRoot = path.join(userProfile, 'Deep', 'Level1', 'Level2', 'ProjectDeep'); + await fs.mkdir(path.join(deepRoot, '.beads'), { recursive: true }); + + const result = await scanForProjects({ maxDepth: 1 }); + const keys = result.projects.map((project) => project.key); + + assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(projectRoot))), true); + assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(ignoredRoot))), false); + assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(deepRoot))), false); + }); +}); From 3f2ae384f59e1266ebea712d3cb064aafaf8354b Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 21:05:27 -0800 Subject: [PATCH 06/29] Add realtime watcher+SSE transport with tests and lock-retry read path --- .beads/issues.jsonl | 73 +++++++++-------- package-lock.json | 101 +++++++++++++++-------- package.json | 3 +- src/app/api/events/route.ts | 85 +++++++++++++++++++ src/components/kanban/kanban-page.tsx | 38 ++++++++- src/lib/coalescer.ts | 76 +++++++++++++++++ src/lib/read-issues.ts | 4 +- src/lib/read-text-retry.ts | 41 +++++++++ src/lib/realtime.ts | 82 ++++++++++++++++++ src/lib/watcher.ts | 114 ++++++++++++++++++++++++++ tests/api/events-route.test.ts | 34 ++++++++ tests/lib/coalescer.test.ts | 33 ++++++++ tests/lib/read-text-retry.test.ts | 27 ++++++ tests/lib/realtime.test.ts | 46 +++++++++++ tests/lib/watcher.test.ts | 45 ++++++++++ 15 files changed, 727 insertions(+), 75 deletions(-) create mode 100644 src/app/api/events/route.ts create mode 100644 src/lib/coalescer.ts create mode 100644 src/lib/read-text-retry.ts create mode 100644 src/lib/realtime.ts create mode 100644 src/lib/watcher.ts create mode 100644 tests/api/events-route.test.ts create mode 100644 tests/lib/coalescer.test.ts create mode 100644 tests/lib/read-text-retry.test.ts create mode 100644 tests/lib/realtime.test.ts create mode 100644 tests/lib/watcher.test.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 932462e..5d3160b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,15 +1,17 @@ -{"id":"bb-29x","title":"Quality Gates, Testing, and Performance Validation","description":"Establish verification confidence through unit/integration tests, boundary tests, and performance baselines for parser and realtime workflows.","acceptance_criteria":"Core functionality is covered by automated checks and target baselines are recorded.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:15.8368971-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:15.8368971-08:00","labels":["perf","quality","testing"],"dependencies":[{"issue_id":"bb-29x","depends_on_id":"bb-ymg","type":"blocks","created_at":"2026-02-11T17:12:23.6722466-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-xhm","type":"blocks","created_at":"2026-02-11T17:12:24.1823625-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-bvn","type":"blocks","created_at":"2026-02-11T17:12:24.6873031-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-u6f","type":"blocks","created_at":"2026-02-11T17:12:25.193566-08:00","created_by":"zenchantlive"}]} -{"id":"bb-29x.1","title":"Implement unit tests for parser, pathing, scanner, and bd bridge","description":"Add focused fast tests for foundational modules and error handling paths.","acceptance_criteria":"Unit tests cover nominal and edge-case logic for each foundational module.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:16.6578316-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:16.6578316-08:00","labels":["tests","unit"],"dependencies":[{"issue_id":"bb-29x.1","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:16.6594181-08:00","created_by":"zenchantlive"}]} -{"id":"bb-29x.2","title":"Implement API integration tests for read, mutate, and SSE routes","description":"Validate route contracts and interaction boundaries across read/write/realtime layers.","acceptance_criteria":"Integration suite verifies route behavior and error semantics.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:17.4912736-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:17.4912736-08:00","labels":["integration","tests"],"dependencies":[{"issue_id":"bb-29x.2","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:17.4923012-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.2","depends_on_id":"bb-29x.1","type":"blocks","created_at":"2026-02-11T17:12:38.9423299-08:00","created_by":"zenchantlive"}]} -{"id":"bb-29x.3","title":"Record parser and realtime performance baseline against PRD targets","description":"Measure parse latency and update propagation using realistic sample sizes and document outcomes.","acceptance_criteria":"Performance report exists with methodology and observed timings.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:18.3210495-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:18.3210495-08:00","labels":["benchmark","perf"],"dependencies":[{"issue_id":"bb-29x.3","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:18.3220949-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.3","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.4534943-08:00","created_by":"zenchantlive"}]} -{"id":"bb-29x.4","title":"Document operational runbook and boundary rationale","description":"Write architecture docs covering scanner policy, bd bridge behavior, and consistency guardrails for future maintainers.","acceptance_criteria":"Runbook documents startup, troubleshooting, and boundary rules.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:19.1385778-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:19.1385778-08:00","labels":["docs","runbook"],"dependencies":[{"issue_id":"bb-29x.4","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:19.1402086-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.4","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.9591458-08:00","created_by":"zenchantlive"}]} +{"id":"bb-29x","title":"Quality Gates, Testing, and Performance Validation","description":"Establish verification confidence through unit/integration tests, boundary tests, and performance baselines for parser and realtime workflows.","acceptance_criteria":"Feature lanes are only closed after passing tests, capturing visual evidence, and documenting smoke-check results.","notes":"Definition of done locked (2026-02-12): every completed feature lane requires automated tests + visual screenshots + runtime smoke checks before close.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:15.8368971-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:54:11.1739286-08:00","labels":["perf","quality","testing"],"dependencies":[{"issue_id":"bb-29x","depends_on_id":"bb-ymg","type":"blocks","created_at":"2026-02-11T17:12:23.6722466-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-xhm","type":"blocks","created_at":"2026-02-11T17:12:24.1823625-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-bvn","type":"blocks","created_at":"2026-02-11T17:12:24.6873031-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-u6f","type":"blocks","created_at":"2026-02-11T17:12:25.193566-08:00","created_by":"zenchantlive"}]} +{"id":"bb-29x.1","title":"Implement unit tests for parser, pathing, scanner, and bd bridge","description":"Add focused fast tests for foundational modules and error handling paths.","acceptance_criteria":"Unit tests cover nominal and edge-case logic for each foundational module.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:16.6578316-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:16.6578316-08:00","labels":["tests","unit"],"dependencies":[{"issue_id":"bb-29x.1","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:16.6594181-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.1","depends_on_id":"bb-29x.5","type":"blocks","created_at":"2026-02-11T20:10:11.5066258-08:00","created_by":"zenchantlive"}]} +{"id":"bb-29x.2","title":"Implement API integration tests for read, mutate, and SSE routes","description":"Validate route contracts and interaction boundaries across read/write/realtime layers.","acceptance_criteria":"Integration suite verifies route behavior and error semantics.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:17.4912736-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:17.4912736-08:00","labels":["integration","tests"],"dependencies":[{"issue_id":"bb-29x.2","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:17.4923012-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.2","depends_on_id":"bb-29x.1","type":"blocks","created_at":"2026-02-11T17:12:38.9423299-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.2","depends_on_id":"bb-29x.5","type":"blocks","created_at":"2026-02-11T20:10:10.6325422-08:00","created_by":"zenchantlive"}]} +{"id":"bb-29x.3","title":"Record parser and realtime performance baseline against PRD targets","description":"Measure parse latency and update propagation using realistic sample sizes and document outcomes.","acceptance_criteria":"Performance report exists with methodology and observed timings.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:18.3210495-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:18.3210495-08:00","labels":["benchmark","perf"],"dependencies":[{"issue_id":"bb-29x.3","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:18.3220949-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.3","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.4534943-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.3","depends_on_id":"bb-29x.5","type":"blocks","created_at":"2026-02-11T20:10:13.1864837-08:00","created_by":"zenchantlive"}]} +{"id":"bb-29x.4","title":"Document operational runbook and boundary rationale","description":"Write architecture docs covering scanner policy, bd bridge behavior, and consistency guardrails for future maintainers.","acceptance_criteria":"Runbook documents startup, troubleshooting, and boundary rules.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:19.1385778-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:19.1385778-08:00","labels":["docs","runbook"],"dependencies":[{"issue_id":"bb-29x.4","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:19.1402086-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.4","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.9591458-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.4","depends_on_id":"bb-29x.5","type":"blocks","created_at":"2026-02-11T20:10:12.3474801-08:00","created_by":"zenchantlive"}]} +{"id":"bb-29x.5","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-29x before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:42.1507616-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:09:42.1507616-08:00","dependencies":[{"issue_id":"bb-29x.5","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T20:09:42.1525436-08:00","created_by":"zenchantlive"}]} {"id":"bb-3pr","title":"Smoke test mutation lifecycle 2","description":"Temporary issue for API mutation smoke test","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:44:10.9737485-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:44:16.4912473-08:00","closed_at":"2026-02-11T19:44:16.4912473-08:00","close_reason":"Cleanup after API smoke test","labels":["api","smoke"],"comments":[{"id":1,"issue_id":"bb-3pr","author":"zenchantlive","text":"Smoke test comment via API route","created_at":"2026-02-12T03:44:13Z"},{"id":2,"issue_id":"bb-3pr","author":"zenchantlive","text":"Smoke test reopen","created_at":"2026-02-12T03:44:15Z"}]} -{"id":"bb-6aj","title":"Project Registry and Multi-Project Scanner","description":"Support multiple Windows project roots using profile-scoped registry storage and safe discovery scanning tuned for developer machines.","acceptance_criteria":"Projects can be added/removed/listed and discovered via scanner with deterministic normalization.","status":"open","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:47.7205517-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:47.7205517-08:00","labels":["multi-project","scanner"],"dependencies":[{"issue_id":"bb-6aj","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:19.6374139-08:00","created_by":"zenchantlive"}]} +{"id":"bb-6aj","title":"Project Registry and Multi-Project Scanner","description":"Deliver a Windows-first multi-project registry and discovery pipeline: persist project roots in the user profile, expose add/remove/list APIs, and scan safe roots to find .beads directories. Normalize all paths to stable identity keys and support aggregate views without full-drive traversal by default.","acceptance_criteria":"Projects can be added/removed/listed and discovered via scanner with deterministic normalization.","status":"open","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:47.7205517-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:16:49.4354917-08:00","labels":["multi-project","scanner"],"dependencies":[{"issue_id":"bb-6aj","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:19.6374139-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.1","title":"Persist project registry in %USERPROFILE%\\\\.beadboard\\\\projects.json","description":"Implement read/write management for registry file in user profile path, isolated from repository files and safe for local machine usage.","acceptance_criteria":"Registry file is created lazily and survives app restarts.","status":"closed","priority":0,"issue_type":"task","assignee":"agent-a","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:48.5403111-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:53:17.2085722-08:00","closed_at":"2026-02-11T17:53:17.2085722-08:00","close_reason":"Implemented %USERPROFILE%/.beadboard/projects.json registry persistence with Windows-safe normalization and dedupe.","labels":["config","registry"],"dependencies":[{"issue_id":"bb-6aj.1","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:48.5419102-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.2","title":"Implement registry API for add/remove/list operations","description":"Expose robust API endpoints with path validation and normalized identity checks to prevent duplicates.","acceptance_criteria":"API supports add, remove, list and returns clear validation errors.","status":"closed","priority":0,"issue_type":"task","assignee":"agent-a","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:49.3542564-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:53:23.9298353-08:00","closed_at":"2026-02-11T17:53:23.9298353-08:00","close_reason":"Implemented /api/projects GET/POST/DELETE with validation, normalization, and registry integration.","labels":["api","registry"],"dependencies":[{"issue_id":"bb-6aj.2","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:49.3558158-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.2","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:26.7117348-08:00","created_by":"zenchantlive"}]} -{"id":"bb-6aj.3","title":"Build scanner with profile-root default and depth/ignore controls","description":"Scan %USERPROFILE% and user-defined roots for .beads directories with bounded recursion and ignore patterns to protect performance.","acceptance_criteria":"Scanner discovers projects without traversing entire drives by default.","status":"open","priority":0,"issue_type":"task","assignee":"agent-c","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:50.1925005-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:43:32.4095636-08:00","labels":["performance","scanner"],"dependencies":[{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:50.1940841-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:27.2225981-08:00","created_by":"zenchantlive"}]} -{"id":"bb-6aj.3.1","title":"Add explicit full-drive scan mode for C:/D: by user action","description":"Provide an opt-in scan mode for full drive enumeration while retaining safe defaults and progress reporting expectations.","acceptance_criteria":"Full-drive scan is only activated explicitly, never by default startup logic.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.0244174-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:51.0244174-08:00","labels":["optional","scanner"],"dependencies":[{"issue_id":"bb-6aj.3.1","depends_on_id":"bb-6aj.3","type":"parent-child","created_at":"2026-02-11T17:11:51.0259617-08:00","created_by":"zenchantlive"}]} +{"id":"bb-6aj.3","title":"Build scanner with profile-root default and depth/ignore controls","description":"Implement a scanner that searches for .beads directories under %USERPROFILE% and any user-added roots. Enforce bounded recursion depth, ignore patterns (e.g., node_modules, .git, .next, dist, build), and de-duplicate results by normalized path. Return discovered project roots with source metadata and summary counts while avoiding drive-wide enumeration.","acceptance_criteria":"Scanner discovers projects without traversing entire drives by default.","status":"closed","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:50.1925005-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:47:56.2978358-08:00","closed_at":"2026-02-11T20:47:56.2978358-08:00","close_reason":"Implemented scanner + /api/scan with safe defaults and full-drive mode.","labels":["performance","scanner"],"dependencies":[{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:50.1940841-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:27.2225981-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj.5","type":"blocks","created_at":"2026-02-11T20:10:09.155154-08:00","created_by":"zenchantlive"}]} +{"id":"bb-6aj.3.1","title":"Add explicit full-drive scan mode for C:/D: by user action","description":"Add an explicit opt-in scan mode that enumerates entire drives (C:\\ and D:\\) only when the user requests it. Provide progress feedback and guardrails so this mode never runs on startup or default scan paths, and clearly label it as potentially slow.","acceptance_criteria":"Full-drive scan is only activated explicitly, never by default startup logic.","status":"closed","priority":2,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.0244174-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:42:04.4870337-08:00","closed_at":"2026-02-11T20:42:04.4870337-08:00","close_reason":"Added explicit full-drive scan mode gated by mode=full-drive.","labels":["optional","scanner"],"dependencies":[{"issue_id":"bb-6aj.3.1","depends_on_id":"bb-6aj.3","type":"parent-child","created_at":"2026-02-11T17:11:51.0259617-08:00","created_by":"zenchantlive"}]} {"id":"bb-6aj.4","title":"Implement aggregate project issue context model","description":"Define normalized project identity payload attached to every issue for cross-project Kanban, timeline, and session views.","acceptance_criteria":"Aggregated read output always includes stable project metadata.","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.8518922-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:21.5826669-08:00","closed_at":"2026-02-11T19:45:21.5826669-08:00","close_reason":"Added project context model and attached to read issues.","labels":["aggregation","data-model"],"dependencies":[{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:51.8534893-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:27.7270195-08:00","created_by":"zenchantlive"}]} +{"id":"bb-6aj.5","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-6aj before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","design":"Intent: Provide Windows-native multi-project discovery using registry + scanner with safe defaults; never scan full drives unless explicitly requested.\n\nDecisions:\n- Scan roots: %USERPROFILE% + registry entries; optional full-drive mode adds C:\\ and D:\\ only when mode=full-drive.\n- Bounded recursion (default maxDepth=6) and ignore list to protect performance.\n- Normalize paths with canonicalizeWindowsPath/windowsPathKey; dedupe by key.\n- API contract: GET /api/scan?mode=default|full-drive\u0026depth=\u003cint\u003e returns { mode, roots, projects, stats }.\n\nEdge cases:\n- Missing/unreadable directories are skipped (ENOENT/ENOTDIR/EACCES/EPERM) without aborting scan.\n- Invalid mode/depth returns 400.\n\nWindows constraints:\n- Use drive-letter paths only; no Unix assumptions.\n\nTesting:\n- scanner.test.ts covers default roots, full-drive roots, ignore list, and depth limits.\n- npm test to verify.\n\nNon-goals:\n- No background watcher or SSE here.\n- No default full-drive scan.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:37.50785-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:47:55.9830645-08:00","closed_at":"2026-02-11T20:47:55.9830645-08:00","close_reason":"Captured scanner design/contract and verification plan.","dependencies":[{"issue_id":"bb-6aj.5","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T20:09:37.509509-08:00","created_by":"zenchantlive"}]} {"id":"bb-92d","title":"Foundation and Read/Write Boundary","description":"Establish the Windows-native Next.js foundation, canonical Beads schema handling, and strict data boundaries: read from JSONL, write only via bd.exe. This epic defines the non-negotiable invariants that all later work must preserve.","acceptance_criteria":"App boots on Windows, schema/parser contracts exist, and no direct issues.jsonl write path exists in code.","status":"closed","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:41.0756295-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:28:27.8108066-08:00","closed_at":"2026-02-11T17:28:27.8108066-08:00","close_reason":"Completed foundation milestone: bootstrap, licensing/docs, schema contracts, parser, windows path normalization, and write-boundary guardrails.","labels":["beadboard","foundation","windows"]} {"id":"bb-92d.1","title":"Bootstrap Next.js 15 + React 19 + TypeScript strict","description":"Initialize project scaffold with strict TypeScript, App Router baseline, and repeatable scripts for lint/typecheck/test in PowerShell.","acceptance_criteria":"npm install and dev startup work on Windows; strict type checking enabled.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:41.9363647-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:23:14.0089901-08:00","closed_at":"2026-02-11T17:23:14.0089901-08:00","close_reason":"Bootstrapped Next.js 15 + React 19 + strict TypeScript; install/typecheck/dev startup verified on Windows.","labels":["foundation","nextjs"],"dependencies":[{"issue_id":"bb-92d.1","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:41.9379355-08:00","created_by":"zenchantlive"}]} {"id":"bb-92d.2","title":"Add MIT license and baseline repository docs","description":"Add LICENSE and baseline docs that state Windows-native support, read/write boundaries, and required runtime dependencies.","acceptance_criteria":"MIT license present and docs describe core architecture constraints.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:42.7699961-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:23:50.7519159-08:00","closed_at":"2026-02-11T17:23:50.7519159-08:00","close_reason":"Added MIT license and baseline repository documentation with architecture boundary rules.","labels":["docs","license"],"dependencies":[{"issue_id":"bb-92d.2","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:42.7715653-08:00","created_by":"zenchantlive"}]} @@ -26,32 +28,37 @@ {"id":"bb-bc4.3","title":"Redesign tokenized theme and visual hierarchy","description":"Upgrade visual system quality using semantic tokens for surface/text/status/priority states, stronger typography hierarchy, and improved contrast. Move away from flat/basic palette while preserving clarity and performance.","acceptance_criteria":"UI theme shows clear hierarchy and contrast, aligns with premium demo quality expectations, and remains consistent across components.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:44.8548956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.0348391-08:00","closed_at":"2026-02-11T18:59:19.0348391-08:00","close_reason":"Redesigned semantic tokens/theme contrast and hierarchy to improve production visual quality.","labels":["design-system","theme","tokens"],"dependencies":[{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:44.8564376-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4.1","type":"blocks","created_at":"2026-02-11T18:50:44.8606805-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.4","title":"Implement mobile/tablet detail panel interaction model","description":"Adapt detail panel behavior for small screens (overlay or drawer model) with safe viewport sizing, accessible dismissal, and non-destructive navigation. Desktop retains efficient side-panel behavior.","acceptance_criteria":"Detail view is usable on mobile/tablet and does not trap or obscure board interaction irrecoverably.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:45.8342573-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.8911935-08:00","closed_at":"2026-02-11T18:59:19.8911935-08:00","close_reason":"Implemented mobile detail overlay flow while preserving desktop sticky side-detail behavior.","labels":["detail-panel","mobile","ux"],"dependencies":[{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:45.8360334-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.2","type":"blocks","created_at":"2026-02-11T18:51:10.0929812-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:10.9352149-08:00","created_by":"zenchantlive"}]} {"id":"bb-bc4.5","title":"Playwright multi-breakpoint visual verification","description":"Capture and review before/after screenshots at 390x844, 768x1024, and 1440x900 to validate reachability, clipping, control usability, and detail-panel behavior. Store artifacts under artifacts/ with explicit naming conventions.","acceptance_criteria":"Required six screenshots exist (before/after x 3 breakpoints) and observations confirm responsive/visual acceptance criteria.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:47.0018379-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:20.7427588-08:00","closed_at":"2026-02-11T18:59:20.7427588-08:00","close_reason":"Captured required Playwright before/after screenshots at mobile/tablet/desktop and validated layout usability.","labels":["playwright","verification","visual"],"dependencies":[{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:47.0034039-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.4","type":"blocks","created_at":"2026-02-11T18:51:11.7817934-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:12.6236762-08:00","created_by":"zenchantlive"}]} -{"id":"bb-bq6","title":"Smoke test mutation lifecycle","description":"Temporary issue for API mutation smoke test","status":"open","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:43:52.1686473-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:43:52.1686473-08:00","labels":["api","smoke"]} -{"id":"bb-bvn","title":"Dependency Graph (React Flow)","description":"Visualize issue relationships and blocked chains through an interactive graph backed by parsed dependency edges.","acceptance_criteria":"Graph renders dependencies correctly and supports navigation to issue details.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:09.2057278-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:09.2057278-08:00","labels":["graph","react-flow"],"dependencies":[{"issue_id":"bb-bvn","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:22.6642419-08:00","created_by":"zenchantlive"}]} -{"id":"bb-bvn.1","title":"Parse dependency edges and build adjacency structures","description":"Extract edges for blocks, parent, relates_to, duplicates, and supersedes to support graph rendering and analysis.","acceptance_criteria":"Adjacency output is complete and consistent for all supported edge types.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.0434044-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.0434044-08:00","labels":["graph","parser"],"dependencies":[{"issue_id":"bb-bvn.1","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.0449367-08:00","created_by":"zenchantlive"}]} -{"id":"bb-bvn.2","title":"Implement React Flow graph view with pan/zoom/select interactions","description":"Render nodes and edges with interactive navigation and issue selection integration.","acceptance_criteria":"Users can pan, zoom, and select nodes to inspect linked issue context.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.8683725-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.8683725-08:00","labels":["graph","ui"],"dependencies":[{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.8694189-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn.1","type":"blocks","created_at":"2026-02-11T17:12:36.8736785-08:00","created_by":"zenchantlive"}]} -{"id":"bb-bvn.3","title":"Add blocked-chain highlighting and cycle anomaly signaling","description":"Improve graph decision support by emphasizing blocked paths and flagging unexpected cycle conditions.","acceptance_criteria":"Blocked paths and cycle warnings are visible and actionable.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:11.687878-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:11.687878-08:00","labels":["analysis","graph"],"dependencies":[{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:11.6890831-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn.2","type":"blocks","created_at":"2026-02-11T17:12:37.378326-08:00","created_by":"zenchantlive"}]} -{"id":"bb-tpc","title":"Live File Watching and SSE Transport","description":"Deliver real-time dashboard updates by watching Beads issue files and streaming one-way change notifications via SSE.","acceptance_criteria":"File changes trigger UI refresh without manual reload and reconnect behavior is stable.","status":"open","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:52.6737283-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:52.6737283-08:00","labels":["realtime","sse","watcher"],"dependencies":[{"issue_id":"bb-tpc","depends_on_id":"bb-6aj","type":"blocks","created_at":"2026-02-11T17:12:20.1444149-08:00","created_by":"zenchantlive"}]} -{"id":"bb-tpc.1","title":"Implement chokidar watch manager for registered projects","description":"Start/stop watchers per active project and ensure watcher lifecycle tracks registry changes without leaking handles.","acceptance_criteria":"Watcher list updates correctly when projects are added or removed.","status":"open","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:53.5050717-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:53.5050717-08:00","labels":["chokidar","watcher"],"dependencies":[{"issue_id":"bb-tpc.1","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:53.5071586-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:28.2304516-08:00","created_by":"zenchantlive"}]} -{"id":"bb-tpc.2","title":"Add debounce/coalescing and transient lock handling for file change bursts","description":"Coalesce rapid updates from agent activity and handle temporary read lock contention without surfacing noisy errors.","acceptance_criteria":"Burst writes produce stable event cadence and no hard failures from temporary locks.","status":"open","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:54.315119-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:54.315119-08:00","labels":["stability","watcher"],"dependencies":[{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:54.3172104-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc.1","type":"blocks","created_at":"2026-02-11T17:12:28.7308524-08:00","created_by":"zenchantlive"}]} -{"id":"bb-tpc.3","title":"Implement SSE events API endpoint with heartbeat and event IDs","description":"Create SSE route supporting keepalive heartbeats and resumable event consumption patterns for browser clients.","acceptance_criteria":"SSE stream remains alive and clients can reconnect automatically.","status":"open","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:55.1518352-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:55.1518352-08:00","labels":["api","sse"],"dependencies":[{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:55.1533991-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:29.2599782-08:00","created_by":"zenchantlive"}]} -{"id":"bb-tpc.4","title":"Build frontend SSE client with scoped React Query invalidation","description":"Consume server events and invalidate only affected query keys, limiting unnecessary re-fetches in multi-project mode.","acceptance_criteria":"Changed project views refresh while unrelated views remain stable.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:56.0008015-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:56.0008015-08:00","labels":["frontend","react-query"],"dependencies":[{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:56.0024218-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc.3","type":"blocks","created_at":"2026-02-11T17:12:29.768818-08:00","created_by":"zenchantlive"}]} -{"id":"bb-trz","title":"Kanban Experience (Baseline Dashboard)","description":"Ship a production-ready Kanban baseline inspired by prototype behavior but backed by real Beads project data and strict typing.","acceptance_criteria":"Users can inspect and filter live Beads issues through stable Kanban workflows.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:56.8115491-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:51.4226568-08:00","closed_at":"2026-02-11T17:56:51.4226568-08:00","close_reason":"Kanban epic complete for tracer bullet 1","labels":["kanban","ui"],"dependencies":[{"issue_id":"bb-trz","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:20.6480287-08:00","created_by":"zenchantlive"}]} +{"id":"bb-bq6","title":"Smoke test mutation lifecycle","description":"Temporary issue for API mutation smoke test","status":"open","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:43:52.1686473-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:40:02.1018374-08:00","labels":["api","smoke"],"comments":[{"id":4,"issue_id":"bb-bq6","author":"zenchantlive","text":"UI visibility test complete: reopening","created_at":"2026-02-12T04:40:02Z"}]} +{"id":"bb-bvn","title":"Dependency Graph (React Flow)","description":"Visualize issue relationships and blocked chains through an interactive graph backed by parsed dependency edges.","acceptance_criteria":"Graph defaults to 2-hop focused context and remains readable; deterministic layout, typed edges, and depth controls are implemented; mobile fallback is simplified and usable.","notes":"Product baseline locked (2026-02-12): Graph UX will use React Flow with deterministic DAG layout (no chaotic freeform). Default depth is 2 hops from selected issue with controls for 1 hop / 2 hops / full. Mobile uses simplified dependency focus view (selected + immediate blockers/dependents); desktop/tablet uses full graph workspace.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:09.2057278-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:54:10.3326048-08:00","labels":["graph","react-flow"],"dependencies":[{"issue_id":"bb-bvn","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:22.6642419-08:00","created_by":"zenchantlive"}]} +{"id":"bb-bvn.1","title":"Parse dependency edges and build adjacency structures","description":"Build graph data preparation pipeline for dependency workspace.\n\nScope:\n- Input: parsed Bead issues from read layer only (`readIssuesFromDisk`).\n- Build normalized node map keyed by issue id.\n- Build typed edge list from `dependencies[]` supporting: blocks, parent, relates_to, duplicates, supersedes.\n- Include reverse index (incoming/outgoing) to support focus queries.\n- Preserve issue metadata needed by UI nodes: id, title, status, priority, issue_type, assignee, updated_at.\n\nRules:\n- Ignore dependency edges that point to missing issue IDs but record count for diagnostics.\n- Deduplicate duplicate edges (same source, target, type).\n- Treat path/project context as explicit API argument for future multi-project support.\n- Do not mutate source issues.\n\nOutput contracts:\n- `GraphModel = { nodes, edges, adjacency, diagnostics }`\n- `adjacency` includes incoming/outgoing arrays per node.\n- `diagnostics` includes counts for missing targets and dropped duplicates.\n\nTest plan:\n- Unit tests for edge extraction across all supported types.\n- Unit tests for dedupe and missing-target behavior.\n- Unit tests for adjacency correctness and deterministic ordering.\r\n","acceptance_criteria":"- Graph model contains all valid nodes and typed edges from issue dependencies.\n- Duplicate edges are removed deterministically.\n- Missing-target edges do not crash model generation and are surfaced in diagnostics.\n- Adjacency maps are correct for incoming/outgoing lookups.\n- Unit tests cover all supported dependency types and edge cases.\r\n","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.0434044-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:58:31.5313317-08:00","labels":["graph","parser"],"dependencies":[{"issue_id":"bb-bvn.1","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.0449367-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.1","depends_on_id":"bb-bvn.4","type":"blocks","created_at":"2026-02-11T20:10:02.7644711-08:00","created_by":"zenchantlive"}]} +{"id":"bb-bvn.2","title":"Implement React Flow graph view with pan/zoom/select interactions","description":"Implement deterministic React Flow graph UI (non-chaotic workspace mode).\n\nScope:\n- New graph page/view with React Flow canvas.\n- Deterministic auto-layout (DAG style) for stable mental model:\n - selected node centered in focus mode\n - upstream blockers left, downstream dependents right\n- Use card-like nodes (not bubbles) with minimal status accent.\n- Edge styling by dependency type:\n - blocks: solid\n - parent: thicker muted\n - relates_to: dashed\n - duplicates/supersedes: distinct but subtle styles\n\nInteraction:\n- Click node opens shared detail panel.\n- Controls: hop depth switch (1/2/full), collapse closed, fit-to-selection.\n- Disable freeform drag by default to avoid n8n-like chaos (optional manual toggle can be deferred).\n\nResponsive behavior:\n- Desktop/tablet: full canvas + detail panel split.\n- Mobile: simplified dependency focus mode (selected + immediate blockers/dependents list) instead of dense full canvas.\n\nIntegration:\n- Read-only against graph model from bb-bvn.1.\n- No writeback from graph lane.\n\nTest/verification:\n- Component tests for control toggles and selected-node behavior.\n- Guard test for responsive fallback contract.\n- Playwright screenshots: mobile/tablet/desktop graph view.\r\n","acceptance_criteria":"- Graph renders with deterministic layout and typed edges.\n- Default depth is 2 hops with controls for 1/2/full.\n- Node selection opens detail panel and fit-to-selection works.\n- Mobile shows simplified focus view (no unusable dense canvas).\n- Visual verification screenshots captured for mobile/tablet/desktop.\r\n","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.8683725-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:58:46.8359811-08:00","labels":["graph","ui"],"dependencies":[{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.8694189-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn.1","type":"blocks","created_at":"2026-02-11T17:12:36.8736785-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn.4","type":"blocks","created_at":"2026-02-11T20:10:04.4783802-08:00","created_by":"zenchantlive"}]} +{"id":"bb-bvn.3","title":"Add blocked-chain highlighting and cycle anomaly signaling","description":"Add analysis overlays for blocker triage and anomaly visibility.\n\nScope:\n- Compute and highlight blocked chains from selected node.\n- Show concise blocker summary:\n - open blocker count\n - in-progress blocker count\n - first actionable blocker\n- Cycle/anomaly signaling:\n - detect cycles in dependency graph\n - mark involved nodes/edges with warning style and explanation text\n\nUI behavior:\n- \"Show blocking path only\" toggle to reduce noise.\n- Hovering a node/edge highlights direct dependency chain.\n- Keep styling subtle and readable; avoid visual overload.\n\nRules:\n- Analysis is read-only and derived from current graph model.\n- Must not fail hard on malformed dependency data; degrade with warnings.\n\nTest plan:\n- Unit tests for blocked-chain derivation and cycle detection logic.\n- UI tests for toggle behavior and warning visibility.\n- Screenshot verification for normal and anomaly cases.\r\n","acceptance_criteria":"- Selected issue can display clear blocked-chain context.\n- Cycle/anomaly conditions are detected and visibly flagged.\n- Blocking-path-only mode materially reduces graph noise.\n- Analysis features remain performant and do not break base graph rendering.\n- Tests and screenshots verify normal + anomaly paths.\r\n","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:11.687878-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:59:01.6815133-08:00","labels":["analysis","graph"],"dependencies":[{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:11.6890831-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn.2","type":"blocks","created_at":"2026-02-11T17:12:37.378326-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn.4","type":"blocks","created_at":"2026-02-11T20:10:03.6326727-08:00","created_by":"zenchantlive"}]} +{"id":"bb-bvn.4","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-bvn before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","notes":"Graph design gate completed: agreed React Flow deterministic UX, default 2-hop depth controls, mobile simplified fallback, typed edge semantics, and verification contract (tests + screenshots + smoke). Child tasks bb-bvn.1/.2/.3 updated with execution-grade details.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:40.290642-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:59:12.4823711-08:00","closed_at":"2026-02-11T20:59:12.4823711-08:00","close_reason":"Design gate complete: bb-bvn child tasks now contain concrete scope, contracts, dependencies, and testable acceptance criteria.","dependencies":[{"issue_id":"bb-bvn.4","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T20:09:40.2922349-08:00","created_by":"zenchantlive"}]} +{"id":"bb-sse-smoke","title":"SSE smoke 1770870992420","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T04:36:32.42Z","updated_at":"2026-02-12T04:36:32.422Z"} +{"id":"bb-tpc","title":"Live File Watching and SSE Transport","description":"Realtime transport epic to deliver deterministic file-change propagation from .beads/issues.jsonl(.new) into the Kanban UI.\n\nScope boundaries:\n- Read source remains disk JSONL via read-issues; no bd CLI reads.\n- Mutation/write path remains bd.exe only (already implemented in bb-ymg).\n- This epic adds one-way change detection + push invalidation, not business-rule mutation logic.\n\nImplementation contract:\n1) Watch manager (`src/lib/watcher.ts`)\n- Uses chokidar to monitor `\u003cprojectRoot\u003e/.beads/issues.jsonl` and `.beads/issues.jsonl.new`.\n- Normalizes project roots with existing Windows path helpers.\n- Supports start/stop per project and global cleanup for tests/dev reloads.\n- Emits typed change events with monotonic event ids and timestamps.\n\n2) Burst and lock stability (`bb-tpc.2`)\n- Debounce/coalesce rapid write bursts into one logical event per project window.\n- Treat transient lock/read contention as retryable (EBUSY/EPERM) in read path.\n\n3) SSE server (`src/app/api/events/route.ts`)\n- `text/event-stream` endpoint with heartbeat and `id:` fields.\n- Optional `projectRoot` filter for scoped subscribers.\n- Cleans up subscriptions and timers on disconnect.\n\n4) Frontend subscriber (`bb-tpc.4`)\n- EventSource client reconnect behavior handled by browser defaults.\n- On event, re-fetch affected project issues and reconcile local state.\n- No direct JSONL polling loops after SSE is active.\n\nNon-goals in this epic:\n- WebSocket transport.\n- Cross-process durable event bus.\n- React Query migration (deferred; current lane keeps existing local fetch/reconcile pattern).\r\n","acceptance_criteria":"- Editing `.beads/issues.jsonl` externally triggers UI refresh in \u003c1s without manual reload.\n- SSE stream remains connected with periodic heartbeat; reconnect path resumes safely.\n- Event stream and watcher code use Windows-safe path normalization.\n- No direct JSONL writes introduced (guard still passes).\n- `npm run typecheck`, `npm run test`, `npm run dev` pass.\r\n","notes":"Decoupled bb-tpc baseline from scanner epic: current implementation is project-scoped via query projectRoot and does not require registry integration. Multi-project watcher orchestration remains under scanner follow-up tasks.","status":"closed","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:52.6737283-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:37:49.787539-08:00","closed_at":"2026-02-11T20:37:49.787539-08:00","close_reason":"Completed watcher/SSE tracer end-to-end for project-scoped realtime updates with tests and smoke checks.","labels":["realtime","sse","watcher"]} +{"id":"bb-tpc.1","title":"Implement chokidar watch manager for registered projects","description":"Implement `src/lib/watcher.ts` watch manager for project-scoped issue files.\n\nScope:\n- Watch both `\u003cprojectRoot\u003e/.beads/issues.jsonl` and `\u003cprojectRoot\u003e/.beads/issues.jsonl.new`.\n- Support startWatch(projectRoot), stopWatch(projectRoot), stopAll().\n- Ensure idempotent start behavior (no duplicate watchers for same canonical root).\n- Emit typed events into in-process realtime bus with:\n - id (monotonic)\n - projectRoot (canonical path)\n - kind (changed|renamed)\n - path\n - at (ISO timestamp)\n\nImplementation notes:\n- chokidar with `ignoreInitial: true` and Windows-safe normalized paths.\n- Maintain internal map keyed by windowsPathKey(projectRoot).\n- Route event -\u003e coalescer (bb-tpc.2), not direct SSE writes.\n\nTest plan:\n- Unit tests verify idempotent lifecycle and key normalization behavior.\n- Unit tests verify events from both jsonl candidates are accepted.\r\n","acceptance_criteria":"- Starting watch for same project twice creates one active watcher.\n- Stopping watch removes watcher and prevents further events.\n- Events include canonical project root and unique monotonic event id.\n- Watch target includes both `.beads/issues.jsonl` and `.beads/issues.jsonl.new`.\r\n","notes":"Implemented src/lib/watcher.ts with chokidar manager, idempotent start/stop lifecycle, windowsPathKey normalization, and dual-file watch targets (.jsonl + .jsonl.new). Added tests/lib/watcher.test.ts.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:53.5050717-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:36:50.2745024-08:00","closed_at":"2026-02-11T20:36:50.2745024-08:00","close_reason":"Watcher lifecycle manager implemented with canonical project scoping and tested watch behavior.","labels":["chokidar","watcher"],"dependencies":[{"issue_id":"bb-tpc.1","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:53.5071586-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:28.2304516-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.1","depends_on_id":"bb-tpc.5","type":"blocks","created_at":"2026-02-11T20:10:00.4246352-08:00","created_by":"zenchantlive"}]} +{"id":"bb-tpc.2","title":"Add debounce/coalescing and transient lock handling for file change bursts","description":"Implement debounce/coalescing + transient lock resilience for realtime updates.\n\nScope:\n- Coalesce rapid filesystem bursts into a single logical change event per project in a short window (e.g. 100-250ms).\n- Suppress duplicate events for same project/path within the same window.\n- Handle transient file lock contention in read layer with bounded retry for EBUSY/EPERM before surfacing failure.\n\nIntegration points:\n- Coalescer sits between watcher and SSE broadcaster.\n- Read retry applied in `readIssuesFromDisk` path used by UI reconciliation.\n\nTest plan:\n- Unit tests for coalescer burst behavior (N events =\u003e 1 broadcast).\n- Unit tests for lock retry logic and eventual success/failure behavior.\r\n","acceptance_criteria":"- Burst writes within debounce window produce one emitted project event.\n- Distinct project events are not incorrectly merged.\n- Transient EBUSY/EPERM reads are retried with bounded backoff.\n- Permanent read errors still surface as explicit failures.\r\n","notes":"Implemented src/lib/coalescer.ts for burst event coalescing and integrated in watcher manager. Added src/lib/read-text-retry.ts and wired readIssuesFromDisk to retry transient lock errors (EBUSY/EPERM). Added tests/lib/coalescer.test.ts and tests/lib/read-text-retry.test.ts.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:54.315119-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:36:50.8832053-08:00","closed_at":"2026-02-11T20:36:50.8832053-08:00","close_reason":"Burst coalescing and transient lock retry behavior implemented and covered by tests.","labels":["stability","watcher"],"dependencies":[{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:54.3172104-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc.1","type":"blocks","created_at":"2026-02-11T17:12:28.7308524-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc.5","type":"blocks","created_at":"2026-02-11T20:09:59.5779123-08:00","created_by":"zenchantlive"}]} +{"id":"bb-tpc.3","title":"Implement SSE events API endpoint with heartbeat and event IDs","description":"Implement SSE endpoint at `src/app/api/events/route.ts` backed by in-process event bus.\n\nScope:\n- Response headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache, no-transform`, `Connection: keep-alive`.\n- Emit named events (`event: issues`) with `id:` and JSON payload.\n- Heartbeat comments at fixed cadence to keep intermediaries alive.\n- Support optional `projectRoot` query filter so client receives only scoped updates.\n- Cleanup subscriber + heartbeat resources on request abort.\n\nEvent payload contract:\n{\n id: number,\n projectRoot: string,\n changedPath?: string,\n at: string\n}\n\nTest plan:\n- Unit tests for SSE formatting helper and filter matching.\n- Route-level test ensures proper content-type and streaming status.\r\n","acceptance_criteria":"- SSE endpoint responds with valid event-stream headers.\n- Each issue update includes event id + timestamp payload.\n- Subscriber cleanup occurs on disconnect without leaks.\n- Project filter limits event delivery to matching subscribers.\r\n","notes":"Implemented SSE route at src/app/api/events/route.ts with event-stream headers, connected frame, heartbeats, issue event frames, projectRoot filtering via bus subscription, and cleanup on abort/cancel. Added tests/api/events-route.test.ts + tests/lib/realtime.test.ts.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:55.1518352-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:36:51.5000671-08:00","closed_at":"2026-02-11T20:36:51.5000671-08:00","close_reason":"SSE transport endpoint implemented with heartbeat/id frames and lifecycle cleanup.","labels":["api","sse"],"dependencies":[{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:55.1533991-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:29.2599782-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc.5","type":"blocks","created_at":"2026-02-11T20:09:58.6992189-08:00","created_by":"zenchantlive"}]} +{"id":"bb-tpc.4","title":"Build frontend SSE client with scoped React Query invalidation","description":"Implement frontend realtime subscriber in Kanban page.\n\nScope:\n- Create EventSource subscription to `/api/events?projectRoot=...`.\n- Listen for `issues` events and trigger authoritative refresh (`/api/beads/read`).\n- Guard against duplicate subscriptions and ensure cleanup on unmount/project change.\n- Preserve current optimistic mutation flow and reconcile after both mutation success and realtime events.\n\nFailure handling:\n- Do not hard-fail UI on temporary SSE disconnect.\n- Keep page usable while EventSource auto-reconnects.\n\nTest plan:\n- Unit test(s) for event payload parsing and refresh trigger behavior.\n- Guard checks confirm no direct JSONL writes and existing UI contracts remain intact.\r\n","acceptance_criteria":"- Kanban refreshes automatically after external issue file changes.\n- EventSource subscription lifecycle is clean across mount/unmount.\n- Mutation UX remains functional with realtime enabled.\n- No regression to existing guard/test suite.\r\n","notes":"Integrated EventSource subscription in src/components/kanban/kanban-page.tsx. On issues events, Kanban performs authoritative refresh from /api/beads/read while preserving optimistic mutation flow.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:56.0008015-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:36:52.0959253-08:00","closed_at":"2026-02-11T20:36:52.0959253-08:00","close_reason":"Frontend SSE subscriber implemented with auto-refresh reconciliation and clean subscription lifecycle.","labels":["frontend","react-query"],"dependencies":[{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:56.0024218-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc.3","type":"blocks","created_at":"2026-02-11T17:12:29.768818-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc.5","type":"blocks","created_at":"2026-02-11T20:10:01.2739557-08:00","created_by":"zenchantlive"}]} +{"id":"bb-tpc.5","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-tpc before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","notes":"Readiness pass complete: child tasks now include scope boundaries, contracts, failure handling, and test plans. Execution order: .1 watcher lifecycle -\u003e .2 coalescing/retry -\u003e .3 SSE endpoint -\u003e .4 frontend subscriber.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:38.4238327-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:27:50.5456542-08:00","closed_at":"2026-02-11T20:27:50.5456542-08:00","close_reason":"Design gate satisfied with execution-grade contracts and explicit verification strategy for watcher/SSE lane.","dependencies":[{"issue_id":"bb-tpc.5","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T20:09:38.4249429-08:00","created_by":"zenchantlive"}]} +{"id":"bb-trz","title":"Kanban Experience (Baseline Dashboard)","description":"Ship a production-ready Kanban baseline inspired by prototype behavior but backed by real Beads project data and strict typing.","acceptance_criteria":"Users can inspect and filter live Beads issues through stable Kanban workflows.","notes":"Product baseline locked (2026-02-12): Default landing view is Kanban for fast triage. Primary user is solo dev supervising multi-agent work. Project scope defaults to one project with explicit aggregate toggle.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:56.8115491-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:54:10.0562801-08:00","closed_at":"2026-02-11T17:56:51.4226568-08:00","close_reason":"Kanban epic complete for tracer bullet 1","labels":["kanban","ui"],"dependencies":[{"issue_id":"bb-trz","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:20.6480287-08:00","created_by":"zenchantlive"}]} {"id":"bb-trz.1","title":"Implement Kanban column layout for Beads statuses","description":"Render columns for open, in_progress, blocked, deferred, and closed with responsive behavior and clear status counts.","acceptance_criteria":"All statuses map correctly and render with stable ordering.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-b","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:57.6278082-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:50.8105288-08:00","closed_at":"2026-02-11T17:56:50.8105288-08:00","close_reason":"Tracer bullet 1 Kanban baseline implemented and verified","labels":["columns","kanban"],"dependencies":[{"issue_id":"bb-trz.1","depends_on_id":"bb-trz","type":"parent-child","created_at":"2026-02-11T17:11:57.6288535-08:00","created_by":"zenchantlive"},{"issue_id":"bb-trz.1","depends_on_id":"bb-92d.4","type":"blocks","created_at":"2026-02-11T17:12:30.2796473-08:00","created_by":"zenchantlive"}]} {"id":"bb-trz.2","title":"Build bead cards with priority/type/labels/assignee/dependency metadata","description":"Design compact cards exposing the most actionable issue metadata while preserving readability at high board density.","acceptance_criteria":"Cards show id, priority, type, labels, assignee, and dependency indicators.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-b","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:58.4435327-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:50.8141656-08:00","closed_at":"2026-02-11T17:56:50.8141656-08:00","close_reason":"Tracer bullet 1 Kanban baseline implemented and verified","labels":["cards","kanban"],"dependencies":[{"issue_id":"bb-trz.2","depends_on_id":"bb-trz","type":"parent-child","created_at":"2026-02-11T17:11:58.4450798-08:00","created_by":"zenchantlive"},{"issue_id":"bb-trz.2","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:30.7837277-08:00","created_by":"zenchantlive"}]} {"id":"bb-trz.3","title":"Implement detail slide-out panel with full issue metadata","description":"Add focused issue detail panel showing description, timestamps, dependencies, and lifecycle fields used by power users.","acceptance_criteria":"Selecting a card opens detail panel with complete issue context.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-b","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:59.2746013-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:50.8161639-08:00","closed_at":"2026-02-11T17:56:50.8161639-08:00","close_reason":"Tracer bullet 1 Kanban baseline implemented and verified","labels":["details","kanban"],"dependencies":[{"issue_id":"bb-trz.3","depends_on_id":"bb-trz","type":"parent-child","created_at":"2026-02-11T17:11:59.2756402-08:00","created_by":"zenchantlive"},{"issue_id":"bb-trz.3","depends_on_id":"bb-trz.2","type":"blocks","created_at":"2026-02-11T17:12:31.2944-08:00","created_by":"zenchantlive"}]} {"id":"bb-trz.4","title":"Add search/filter/stats controls for status/type/priority/labels","description":"Provide fast filtering and at-a-glance counts, including critical issue indicators, for daily planning and triage workflows.","acceptance_criteria":"Search and filters apply consistently across board and counts.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-b","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:00.0927161-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:50.8186688-08:00","closed_at":"2026-02-11T17:56:50.8186688-08:00","close_reason":"Tracer bullet 1 Kanban baseline implemented and verified","labels":["filters","stats"],"dependencies":[{"issue_id":"bb-trz.4","depends_on_id":"bb-trz","type":"parent-child","created_at":"2026-02-11T17:12:00.0942721-08:00","created_by":"zenchantlive"},{"issue_id":"bb-trz.4","depends_on_id":"bb-trz.2","type":"blocks","created_at":"2026-02-11T17:12:31.798413-08:00","created_by":"zenchantlive"}]} -{"id":"bb-u6f","title":"Agent Session Views and Metrics","description":"Group work by agent session and actor fields to provide auditability and practical productivity insights for asynchronous coding workflows.","acceptance_criteria":"Session-based summaries and detail views are available per project and aggregate contexts.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:12.5083912-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:12.5083912-08:00","labels":["agents","sessions"],"dependencies":[{"issue_id":"bb-u6f","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:23.1727361-08:00","created_by":"zenchantlive"}]} -{"id":"bb-u6f.1","title":"Extract and normalize session identity fields from issue data","description":"Derive session grouping from closed_by_session, assignee, and created_by with robust fallback semantics.","acceptance_criteria":"Issues are consistently assigned to session buckets when data exists.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:13.3239834-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:13.3239834-08:00","labels":["agents","data"],"dependencies":[{"issue_id":"bb-u6f.1","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:13.3255058-08:00","created_by":"zenchantlive"}]} -{"id":"bb-u6f.2","title":"Build session list and detail views for claimed/completed/open outcomes","description":"Present session-level issue outcomes and navigation for operational review and accountability.","acceptance_criteria":"Users can inspect session summaries and drill into individual session issue sets.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:14.1559358-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:14.1559358-08:00","labels":["agents","ui"],"dependencies":[{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:14.157502-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f.1","type":"blocks","created_at":"2026-02-11T17:12:37.9045555-08:00","created_by":"zenchantlive"}]} -{"id":"bb-u6f.3","title":"Add baseline productivity metrics (completion rate, throughput, active span)","description":"Compute lightweight operational metrics from session issue events and timestamps.","acceptance_criteria":"Metrics are available with documented definitions and caveats.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:15.0144056-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:15.0144056-08:00","labels":["agents","metrics"],"dependencies":[{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:15.0155323-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f.2","type":"blocks","created_at":"2026-02-11T17:12:38.4424336-08:00","created_by":"zenchantlive"}]} -{"id":"bb-xhm","title":"Timeline and Activity Feed","description":"Provide a chronological activity view derived from issue snapshots and updates, enabling users to review agent/system activity over time.","acceptance_criteria":"Users can inspect chronological issue lifecycle events with useful filtering.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.8525088-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:05.8525088-08:00","labels":["activity","timeline"],"dependencies":[{"issue_id":"bb-xhm","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:22.1602338-08:00","created_by":"zenchantlive"}]} -{"id":"bb-xhm.1","title":"Define activity event model for created/updated/closed/reopened actions","description":"Create stable event schema to represent issue lifecycle transitions and their project/session attribution.","acceptance_criteria":"Event model supports all required timeline activity types.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:06.6781387-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:06.6781387-08:00","labels":["model","timeline"],"dependencies":[{"issue_id":"bb-xhm.1","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:06.6791721-08:00","created_by":"zenchantlive"}]} -{"id":"bb-xhm.2","title":"Implement snapshot diffing for derived timeline events","description":"Compare periodic snapshots and watcher updates to infer meaningful change events without requiring write interception.","acceptance_criteria":"Diff engine emits deterministic event records for relevant field changes.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:07.5007059-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:07.5007059-08:00","labels":["diff","timeline"],"dependencies":[{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:07.501756-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm.1","type":"blocks","created_at":"2026-02-11T17:12:35.3430513-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:35.8495336-08:00","created_by":"zenchantlive"}]} -{"id":"bb-xhm.3","title":"Build timeline UI with date grouping and project/assignee/event filters","description":"Render reverse-chronological feed suitable for morning review workflows with practical filter controls.","acceptance_criteria":"Timeline view supports grouping and filter combinations with acceptable performance.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:08.3834905-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:08.3834905-08:00","labels":["timeline","ui"],"dependencies":[{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:08.3851144-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm.2","type":"blocks","created_at":"2026-02-11T17:12:36.3627477-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg","title":"CLI Write-Back via bd.exe","description":"Enable safe issue mutations from UI by routing all write operations through bd.exe and reflecting results through realtime reconciliation.","acceptance_criteria":"No direct JSONL writes exist; all mutations use bd commands and recover cleanly from failures.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:00.9164956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:00.9164956-08:00","labels":["bd-cli","mutation"],"dependencies":[{"issue_id":"bb-ymg","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:21.1512868-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:21.6536312-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.1","title":"Implement bd bridge using child_process.execFile with project-scoped cwd","description":"Wrap bd execution with command argument safety, Windows path compatibility, stdout/stderr parsing, and project-specific current working directory.","acceptance_criteria":"Bridge executes supported bd commands and returns structured result/error payloads.","notes":"Implemented src/lib/bridge.ts with execFile-based bd runner, project-scoped cwd, timeout support, structured command result payload, and failure classification (not_found, timeout, bad_args, non_zero_exit, unknown). Added RED-\u003eGREEN tests in tests/lib/bridge.test.ts.","status":"closed","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:01.7327732-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:16.7478549-08:00","closed_at":"2026-02-11T19:45:16.7478549-08:00","close_reason":"Bridge implemented with structured result/error classification and project-scoped execFile command execution; tests added.","labels":["bridge","execfile"],"dependencies":[{"issue_id":"bb-ymg.1","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:01.7343468-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:32.3039711-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.1.1","title":"Resolve bd.exe location from PATH and configuration fallback","description":"Add detection logic for bd executable and actionable errors when not found, including setup guidance.","acceptance_criteria":"Missing bd path returns clear setup instructions and diagnostics.","notes":"Implemented src/lib/bd-path.ts executable resolution with config-first then PATH lookup (bd.exe/bd.cmd/bd.bat/bd), plus actionable setup guidance when missing. Added tests/lib/bd-path.test.ts for success/failure cases.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:02.5593205-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:44:57.3720854-08:00","closed_at":"2026-02-11T19:44:57.3720854-08:00","close_reason":"Executable resolution implemented with config/PATH fallback and actionable missing-bd guidance; tests added.","labels":["bridge","setup"],"dependencies":[{"issue_id":"bb-ymg.1.1","depends_on_id":"bb-ymg.1","type":"parent-child","created_at":"2026-02-11T17:12:02.5603636-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.2","title":"Implement mutation API for create/update/close/reopen/comment operations","description":"Expose strict server-side mutation endpoints translating UI actions to corresponding bd commands with validated arguments.","acceptance_criteria":"All required mutation operations execute via bd and return normalized responses.","notes":"Implemented mutation validation/mapping/execution layer in src/lib/mutations.ts and App Router endpoints: /api/beads/create|update|close|reopen|comment. Added payload validation tests, route validation tests, and smoke-tested create/update/comment/close/reopen lifecycle via API.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:03.3757503-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:26.3234246-08:00","closed_at":"2026-02-11T19:45:26.3234246-08:00","close_reason":"Mutation API implemented for create/update/close/reopen/comment with payload validation, command mapping, normalized error shape, and verified smoke lifecycle via API.","labels":["api","mutation"],"dependencies":[{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:03.377343-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1","type":"blocks","created_at":"2026-02-11T17:12:32.810993-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1.1","type":"blocks","created_at":"2026-02-11T17:12:33.313807-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.3","title":"Add optimistic updates with rollback and SSE reconciliation","description":"Apply immediate UI updates for responsiveness, rollback on command failure, and reconcile with watcher-triggered authoritative state updates.","acceptance_criteria":"Failed mutations restore previous UI state and emit meaningful error feedback.","notes":"Implemented optimistic status updates with rollback in Kanban page, per-issue pending state, and authoritative reconciliation via new GET /api/beads/read endpoint after successful mutations.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:04.1956393-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:59:02.289739-08:00","closed_at":"2026-02-11T19:59:02.289739-08:00","close_reason":"Optimistic board updates with rollback and authoritative post-mutation reconciliation via read route implemented and validated.","labels":["optimistic","state"],"dependencies":[{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:04.1966728-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:33.8246167-08:00","created_by":"zenchantlive"}]} -{"id":"bb-ymg.4","title":"Implement drag-and-drop status transitions mapped to bd commands","description":"Map card moves to valid status transitions and use close/reopen semantics where applicable instead of direct file manipulation.","acceptance_criteria":"DnD transitions call proper bd commands and reject invalid transitions safely.","notes":"Implemented lane drag-and-drop interactions in Kanban board, status transition planning (including closed -\u003e reopen+update), and mapped transitions to bd mutation API routes with pending-state safeguards.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.0129676-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:59:21.7655834-08:00","closed_at":"2026-02-11T19:59:21.7655834-08:00","close_reason":"Kanban lane drag-and-drop transitions now map to bd-backed close/reopen/update mutations with transition planner tests and runtime smoke validation.","labels":["dnd","kanban"],"dependencies":[{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:05.014527-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:34.329788-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:34.8422542-08:00","created_by":"zenchantlive"}]} +{"id":"bb-u6f","title":"Agent Session Views and Metrics","description":"Group work by agent session and actor fields to provide auditability and practical productivity insights for asynchronous coding workflows.","acceptance_criteria":"Session-based summaries and detail views are available per project and aggregate contexts.","notes":"Product baseline locked (2026-02-12): Agent-session features should optimize for solo supervisor workflows (who changed what, when, and why) with clear per-agent accountability and low-noise summaries.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:12.5083912-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:54:10.8939662-08:00","labels":["agents","sessions"],"dependencies":[{"issue_id":"bb-u6f","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:23.1727361-08:00","created_by":"zenchantlive"}]} +{"id":"bb-u6f.1","title":"Extract and normalize session identity fields from issue data","description":"Derive session grouping from closed_by_session, assignee, and created_by with robust fallback semantics.","acceptance_criteria":"Issues are consistently assigned to session buckets when data exists.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:13.3239834-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:13.3239834-08:00","labels":["agents","data"],"dependencies":[{"issue_id":"bb-u6f.1","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:13.3255058-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.1","depends_on_id":"bb-u6f.4","type":"blocks","created_at":"2026-02-11T20:09:55.5193741-08:00","created_by":"zenchantlive"}]} +{"id":"bb-u6f.2","title":"Build session list and detail views for claimed/completed/open outcomes","description":"Present session-level issue outcomes and navigation for operational review and accountability.","acceptance_criteria":"Users can inspect session summaries and drill into individual session issue sets.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:14.1559358-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:14.1559358-08:00","labels":["agents","ui"],"dependencies":[{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:14.157502-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f.1","type":"blocks","created_at":"2026-02-11T17:12:37.9045555-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f.4","type":"blocks","created_at":"2026-02-11T20:09:57.2147927-08:00","created_by":"zenchantlive"}]} +{"id":"bb-u6f.3","title":"Add baseline productivity metrics (completion rate, throughput, active span)","description":"Compute lightweight operational metrics from session issue events and timestamps.","acceptance_criteria":"Metrics are available with documented definitions and caveats.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:15.0144056-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:15.0144056-08:00","labels":["agents","metrics"],"dependencies":[{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:15.0155323-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f.2","type":"blocks","created_at":"2026-02-11T17:12:38.4424336-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f.4","type":"blocks","created_at":"2026-02-11T20:09:56.3707709-08:00","created_by":"zenchantlive"}]} +{"id":"bb-u6f.4","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-u6f before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:41.2150441-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:09:41.2150441-08:00","dependencies":[{"issue_id":"bb-u6f.4","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T20:09:41.216603-08:00","created_by":"zenchantlive"}]} +{"id":"bb-xhm","title":"Timeline and Activity Feed","description":"Provide a chronological activity view derived from issue snapshots and updates, enabling users to review agent/system activity over time.","acceptance_criteria":"Users can inspect chronological issue lifecycle events with useful filtering.","notes":"Product baseline locked (2026-02-12): Timeline is secondary to Kanban (not default landing). It should support solo-dev live supervision and focus on actionable event stream clarity rather than exhaustive noise.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.8525088-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:54:10.6071785-08:00","labels":["activity","timeline"],"dependencies":[{"issue_id":"bb-xhm","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:22.1602338-08:00","created_by":"zenchantlive"}]} +{"id":"bb-xhm.1","title":"Define activity event model for created/updated/closed/reopened actions","description":"Create stable event schema to represent issue lifecycle transitions and their project/session attribution.","acceptance_criteria":"Event model supports all required timeline activity types.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:06.6781387-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:06.6781387-08:00","labels":["model","timeline"],"dependencies":[{"issue_id":"bb-xhm.1","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:06.6791721-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.1","depends_on_id":"bb-xhm.4","type":"blocks","created_at":"2026-02-11T20:10:05.9709567-08:00","created_by":"zenchantlive"}]} +{"id":"bb-xhm.2","title":"Implement snapshot diffing for derived timeline events","description":"Compare periodic snapshots and watcher updates to infer meaningful change events without requiring write interception.","acceptance_criteria":"Diff engine emits deterministic event records for relevant field changes.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:07.5007059-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:07.5007059-08:00","labels":["diff","timeline"],"dependencies":[{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:07.501756-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm.1","type":"blocks","created_at":"2026-02-11T17:12:35.3430513-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:35.8495336-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm.4","type":"blocks","created_at":"2026-02-11T20:10:07.6688195-08:00","created_by":"zenchantlive"}]} +{"id":"bb-xhm.3","title":"Build timeline UI with date grouping and project/assignee/event filters","description":"Render reverse-chronological feed suitable for morning review workflows with practical filter controls.","acceptance_criteria":"Timeline view supports grouping and filter combinations with acceptable performance.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:08.3834905-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:08.3834905-08:00","labels":["timeline","ui"],"dependencies":[{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:08.3851144-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm.2","type":"blocks","created_at":"2026-02-11T17:12:36.3627477-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm.4","type":"blocks","created_at":"2026-02-11T20:10:06.8100606-08:00","created_by":"zenchantlive"}]} +{"id":"bb-xhm.4","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-xhm before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:39.3625154-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:09:39.3625154-08:00","dependencies":[{"issue_id":"bb-xhm.4","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T20:09:39.3645827-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg","title":"CLI Write-Back via bd.exe","description":"Write-back architecture for BeadBoard.\n\nScope:\n- All issue mutations must execute through bd.exe commands.\n- No direct writes to .beads/issues.jsonl are permitted anywhere in app code.\n- Mutation flow includes create/update/close/reopen/comment.\n\nDesign decisions:\n- Process execution uses child_process.execFile with arg arrays (no shell interpolation).\n- Commands run with project-scoped cwd so each request targets the intended repo.\n- Executable resolution supports explicit configured path and PATH fallback.\n- API responses are normalized with stable ok/error shape for frontend and tests.\n- UI writeback uses optimistic updates with rollback and authoritative re-read.\n\nImplemented artifacts:\n- src/lib/bd-path.ts\n- src/lib/bridge.ts\n- src/lib/mutations.ts\n- src/app/api/beads/{create,update,close,reopen,comment}/route.ts\n- src/app/api/beads/read/route.ts\n- src/lib/writeback.ts\n- Kanban drag-and-drop transition wiring in components.","acceptance_criteria":"Acceptance contract:\n1) Source tree has no direct issues.jsonl write path (guard test passes).\n2) Bridge returns structured command result including classification for timeout/not_found/non_zero_exit/bad_args.\n3) Mutation routes validate payloads and map operations to bd commands.\n4) Reopen and comment flows are supported and verified.\n5) Optimistic status updates rollback on failure and reconcile from authoritative read endpoint.\n6) typecheck + test + dev + mutation smoke lifecycle all pass.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:00.9164956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:37:50.0859031-08:00","closed_at":"2026-02-11T20:37:50.0859031-08:00","close_reason":"Write-back epic unblocked and complete: bridge, mutation API, optimistic transitions, and drag/drop flows are implemented and verified.","labels":["bd-cli","mutation"],"dependencies":[{"issue_id":"bb-ymg","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:21.1512868-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:21.6536312-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.1","title":"Implement bd bridge using child_process.execFile with project-scoped cwd","description":"Bridge layer requirements and implementation.\n\nCommand execution contract:\n- Uses execFile(command, args, options) with windowsHide and timeout.\n- Uses projectRoot as cwd for all commands.\n- Returns structured payload: success, classification, command, args, cwd, stdout, stderr, code, durationMs, error.\n\nError model:\n- not_found: executable missing / ENOENT.\n- timeout: ETIMEDOUT, killed process, or SIGTERM timeout path.\n- bad_args: non-zero exits with invalid/unknown/usage style stderr.\n- non_zero_exit: non-zero exits not classified as bad_args.\n- unknown: fallback classification.\n\nVerification:\n- tests/lib/bridge.test.ts covers success and all key failure classes.","acceptance_criteria":"Acceptance contract:\n- Bridge command execution is shell-safe and Windows-path-safe.\n- Structured result schema is stable and consumed by mutation layer.\n- Timeout and failure classes are deterministic under test.","notes":"Implemented src/lib/bridge.ts with execFile-based bd runner, project-scoped cwd, timeout support, structured command result payload, and failure classification (not_found, timeout, bad_args, non_zero_exit, unknown). Added RED-\u003eGREEN tests in tests/lib/bridge.test.ts.","status":"closed","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:01.7327732-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:14:33.7176637-08:00","closed_at":"2026-02-11T19:45:16.7478549-08:00","close_reason":"Bridge implemented with structured result/error classification and project-scoped execFile command execution; tests added.","labels":["bridge","execfile"],"dependencies":[{"issue_id":"bb-ymg.1","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:01.7343468-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:32.3039711-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.1.1","title":"Resolve bd.exe location from PATH and configuration fallback","description":"Executable resolution contract.\n\nResolution order:\n1) explicit configured executable path (request/config)\n2) PATH scan for bd.exe, bd.cmd, bd.bat, bd\n\nFailure behavior:\n- Throw actionable guidance when missing, including install command:\n npm install -g @beads/bd\n- Error message explicitly mentions explicit path when provided but invalid.\n\nVerification:\n- tests/lib/bd-path.test.ts validates config-first behavior, PATH lookup, and missing executable guidance.","acceptance_criteria":"Acceptance contract:\n- Resolver is deterministic for config and PATH inputs.\n- Missing executable guidance is actionable and user-readable.","notes":"Implemented src/lib/bd-path.ts executable resolution with config-first then PATH lookup (bd.exe/bd.cmd/bd.bat/bd), plus actionable setup guidance when missing. Added tests/lib/bd-path.test.ts for success/failure cases.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:02.5593205-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:14:34.4905469-08:00","closed_at":"2026-02-11T19:44:57.3720854-08:00","close_reason":"Executable resolution implemented with config/PATH fallback and actionable missing-bd guidance; tests added.","labels":["bridge","setup"],"dependencies":[{"issue_id":"bb-ymg.1.1","depends_on_id":"bb-ymg.1","type":"parent-child","created_at":"2026-02-11T17:12:02.5603636-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.2","title":"Implement mutation API for create/update/close/reopen/comment operations","description":"Mutation API contract and mapping.\n\nSupported operations:\n- create, update, close, reopen, comment\n\nValidation:\n- Payload shape validation enforces required fields and basic constraints.\n- update requires at least one mutable field.\n- status values are constrained to board-supported statuses.\n\nCommand mapping:\n- create -\u003e bd create \u003ctitle\u003e [flags] --json\n- update -\u003e bd update \u003cid\u003e [flags] --json\n- close -\u003e bd close \u003cid\u003e [-r reason] --json\n- reopen -\u003e bd reopen \u003cid\u003e [-r reason] --json\n- comment -\u003e bd comments add \u003cid\u003e \u003ctext\u003e --json\n\nResponse shape:\n- { ok, operation, command, error? }\n- command field always includes normalized bridge result.\n\nVerification:\n- tests/lib/mutations.test.ts and tests/api/mutations-routes.test.ts\n- Runtime smoke lifecycle executed across create/update/close/reopen/comment.","acceptance_criteria":"Acceptance contract:\n- Every mutation route maps to bd.exe only.\n- Invalid payloads return explicit bad_args responses.\n- Reopen and comment operations are first-class and tested.","notes":"Implemented mutation validation/mapping/execution layer in src/lib/mutations.ts and App Router endpoints: /api/beads/create|update|close|reopen|comment. Added payload validation tests, route validation tests, and smoke-tested create/update/comment/close/reopen lifecycle via API.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:03.3757503-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:14:35.2552257-08:00","closed_at":"2026-02-11T19:45:26.3234246-08:00","close_reason":"Mutation API implemented for create/update/close/reopen/comment with payload validation, command mapping, normalized error shape, and verified smoke lifecycle via API.","labels":["api","mutation"],"dependencies":[{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:03.377343-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1","type":"blocks","created_at":"2026-02-11T17:12:32.810993-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1.1","type":"blocks","created_at":"2026-02-11T17:12:33.313807-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.3","title":"Add optimistic updates with rollback and SSE reconciliation","description":"Optimistic writeback behavior.\n\nFrontend mutation strategy:\n- Apply optimistic status update to local issue state.\n- Mark issue as pending during command execution.\n- On failure: rollback to previous issue snapshot and surface mutation error.\n- On success: fetch authoritative issue list from /api/beads/read and replace local state.\n\nRationale:\n- Preserves responsive UX without violating source-of-truth boundary.\n- Reconciliation avoids stale local drift when external agents mutate files.\n\nVerification:\n- tests/lib/writeback.test.ts for transition planning and optimistic updater helpers.\n- Runtime mutation smoke tests confirm end-to-end lifecycle.","acceptance_criteria":"Acceptance contract:\n- Failed mutation restores prior local state.\n- Successful mutation reconciles to authoritative read response.\n- Pending state prevents repeated conflicting transitions.","notes":"Implemented optimistic status updates with rollback in Kanban page, per-issue pending state, and authoritative reconciliation via new GET /api/beads/read endpoint after successful mutations.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:04.1956393-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:14:36.0400918-08:00","closed_at":"2026-02-11T19:59:02.289739-08:00","close_reason":"Optimistic board updates with rollback and authoritative post-mutation reconciliation via read route implemented and validated.","labels":["optimistic","state"],"dependencies":[{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:04.1966728-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:33.8246167-08:00","created_by":"zenchantlive"}]} +{"id":"bb-ymg.4","title":"Implement drag-and-drop status transitions mapped to bd commands","description":"Kanban drag-and-drop transition semantics.\n\nTransition planner rules:\n- Any -\u003e closed: use close command.\n- closed -\u003e open: use reopen command.\n- closed -\u003e in_progress|blocked|deferred: reopen then update target status.\n- open|in_progress|blocked|deferred between non-closed states: update status.\n\nUI behavior:\n- Drag start attaches issue id/status metadata.\n- Drop lane executes planned mutation steps in order.\n- Lane card pending state is shown while mutation is in flight.\n\nVerification:\n- tests/lib/writeback.test.ts transition planning cases.\n- Runtime smoke checks for close/reopen/update transition chain.","acceptance_criteria":"Acceptance contract:\n- DnD invokes valid bd command sequence for each source-\u003etarget status.\n- Invalid/no-op transitions do not emit unnecessary commands.\n- Pending safeguards prevent duplicate conflicting moves.","notes":"Implemented lane drag-and-drop interactions in Kanban board, status transition planning (including closed -\u003e reopen+update), and mapped transitions to bd mutation API routes with pending-state safeguards.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.0129676-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:14:36.8114668-08:00","closed_at":"2026-02-11T19:59:21.7655834-08:00","close_reason":"Kanban lane drag-and-drop transitions now map to bd-backed close/reopen/update mutations with transition planner tests and runtime smoke validation.","labels":["dnd","kanban"],"dependencies":[{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:05.014527-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:34.329788-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:34.8422542-08:00","created_by":"zenchantlive"}]} diff --git a/package-lock.json b/package-lock.json index 2de47bd..623f11e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "chokidar": "^5.0.0", "framer-motion": "^11.18.2", "next": "15.5.7", "react": "19.2.1", @@ -1390,41 +1391,18 @@ "license": "CC-BY-4.0" }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" } }, "node_modules/client-only": { @@ -2248,16 +2226,16 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/resolve": { @@ -2496,6 +2474,44 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tailwindcss/node_modules/postcss-load-config": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", @@ -2532,6 +2548,19 @@ } } }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", diff --git a/package.json b/package.json index 564f5fa..6f4eb88 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-text-retry.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/realtime.test.ts && node --import tsx --test tests/lib/coalescer.test.ts && node --import tsx --test tests/lib/watcher.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --import tsx --test tests/api/events-route.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs" }, "dependencies": { + "chokidar": "^5.0.0", "framer-motion": "^11.18.2", "next": "15.5.7", "react": "19.2.1", diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts new file mode 100644 index 0000000..4d1cfc9 --- /dev/null +++ b/src/app/api/events/route.ts @@ -0,0 +1,85 @@ +import { canonicalizeWindowsPath } from '../../../lib/pathing'; +import { issuesEventBus, SSE_CONNECTED_FRAME, SSE_HEARTBEAT_FRAME, toSseFrame } from '../../../lib/realtime'; +import { getIssuesWatchManager } from '../../../lib/watcher'; + +const encoder = new TextEncoder(); +const HEARTBEAT_MS = 15_000; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const projectRoot = canonicalizeWindowsPath(url.searchParams.get('projectRoot') ?? process.cwd()); + + try { + getIssuesWatchManager().startWatch(projectRoot); + } catch (error) { + return Response.json( + { + ok: false, + error: { + classification: 'unknown', + message: error instanceof Error ? error.message : 'Failed to initialize watcher.', + }, + }, + { status: 500 }, + ); + } + + let cleanup = () => {}; + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + const write = (payload: string) => { + if (closed) { + return; + } + controller.enqueue(encoder.encode(payload)); + }; + + write(SSE_CONNECTED_FRAME); + + const unsubscribe = issuesEventBus.subscribe( + (event) => { + write(toSseFrame(event)); + }, + { projectRoot }, + ); + + const heartbeat = setInterval(() => { + write(SSE_HEARTBEAT_FRAME); + }, HEARTBEAT_MS); + + const close = () => { + if (closed) { + return; + } + + closed = true; + clearInterval(heartbeat); + unsubscribe(); + try { + controller.close(); + } catch { + // stream already closed + } + }; + cleanup = close; + + request.signal.addEventListener('abort', close); + }, + cancel() { + // Called when client closes EventSource/reader. + // Ensures heartbeat + subscriber cleanup always runs. + cleanup(); + return Promise.resolve(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} diff --git a/src/components/kanban/kanban-page.tsx b/src/components/kanban/kanban-page.tsx index 1eb04af..8d4cd05 100644 --- a/src/components/kanban/kanban-page.tsx +++ b/src/components/kanban/kanban-page.tsx @@ -1,7 +1,7 @@ 'use client'; import { motion } from 'framer-motion'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban'; import { buildKanbanColumns, buildKanbanStats, filterKanbanIssues } from '../../lib/kanban'; @@ -61,6 +61,7 @@ export function KanbanPage({ issues, projectRoot }: KanbanPageProps) { const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false); const [pendingIssueIds, setPendingIssueIds] = useState>(new Set()); const [mutationError, setMutationError] = useState(null); + const refreshInFlightRef = useRef(false); useEffect(() => { setLocalIssues(issues); @@ -73,6 +74,38 @@ export function KanbanPage({ issues, projectRoot }: KanbanPageProps) { const selectedIssue = useMemo(() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? null, [filteredIssues, selectedIssueId]); const showDesktopDetail = Boolean(selectedIssue) && !desktopDetailMinimized; + const refreshIssues = useCallback(async (options: { silent?: boolean } = {}) => { + if (refreshInFlightRef.current) { + return; + } + + refreshInFlightRef.current = true; + try { + const reconciled = await fetchIssues(projectRoot); + setLocalIssues(reconciled); + } catch (error) { + if (!options.silent) { + throw error; + } + } finally { + refreshInFlightRef.current = false; + } + }, [projectRoot]); + + useEffect(() => { + const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`); + const onIssues = () => { + void refreshIssues({ silent: true }); + }; + + source.addEventListener('issues', onIssues as EventListener); + + return () => { + source.removeEventListener('issues', onIssues as EventListener); + source.close(); + }; + }, [projectRoot, refreshIssues]); + const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => { const steps = planStatusTransition(issue, targetStatus); if (steps.length === 0) { @@ -92,8 +125,7 @@ export function KanbanPage({ issues, projectRoot }: KanbanPageProps) { }); } - const reconciled = await fetchIssues(projectRoot); - setLocalIssues(reconciled); + await refreshIssues(); } catch (error) { setLocalIssues(previous); setMutationError(error instanceof Error ? error.message : 'Mutation failed'); diff --git a/src/lib/coalescer.ts b/src/lib/coalescer.ts new file mode 100644 index 0000000..6f29ede --- /dev/null +++ b/src/lib/coalescer.ts @@ -0,0 +1,76 @@ +import { windowsPathKey } from './pathing'; + +export interface CoalescedEventInput { + projectRoot: string; + payload: T; +} + +interface PendingEvent { + timer: NodeJS.Timeout; + projectRoot: string; + payload: T; +} + +export class ProjectEventCoalescer { + private readonly pending = new Map>(); + + private readonly debounceMs: number; + + private readonly onFlush: (event: CoalescedEventInput) => void; + + constructor(debounceMs: number, onFlush: (event: CoalescedEventInput) => void) { + this.debounceMs = debounceMs; + this.onFlush = onFlush; + } + + queue(projectRoot: string, payload: T): void { + const projectKey = windowsPathKey(projectRoot); + const existing = this.pending.get(projectKey); + if (existing) { + clearTimeout(existing.timer); + existing.projectRoot = projectRoot; + existing.payload = payload; + existing.timer = setTimeout(() => this.flush(projectKey), this.debounceMs); + return; + } + + this.pending.set(projectKey, { + projectRoot, + payload, + timer: setTimeout(() => this.flush(projectKey), this.debounceMs), + }); + } + + cancel(projectRoot: string): void { + const projectKey = windowsPathKey(projectRoot); + const pending = this.pending.get(projectKey); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pending.delete(projectKey); + } + + cancelAll(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + } + this.pending.clear(); + } + + pendingCount(): number { + return this.pending.size; + } + + private flush(projectKey: string): void { + const pending = this.pending.get(projectKey); + if (!pending) { + return; + } + this.pending.delete(projectKey); + this.onFlush({ + projectRoot: pending.projectRoot, + payload: pending.payload, + }); + } +} diff --git a/src/lib/read-issues.ts b/src/lib/read-issues.ts index 6dca081..820f18e 100644 --- a/src/lib/read-issues.ts +++ b/src/lib/read-issues.ts @@ -1,8 +1,8 @@ -import fs from 'node:fs/promises'; import path from 'node:path'; import { parseIssuesJsonl } from './parser'; import { canonicalizeWindowsPath } from './pathing'; +import { readTextFileWithRetry } from './read-text-retry'; import type { BeadIssue } from './types'; export interface ReadIssuesOptions { @@ -26,7 +26,7 @@ export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promi for (const issuesPath of candidates) { try { - const jsonl = await fs.readFile(issuesPath, 'utf8'); + const jsonl = await readTextFileWithRetry(issuesPath); return parseIssuesJsonl(jsonl, { includeTombstones: options.includeTombstones ?? false, }); diff --git a/src/lib/read-text-retry.ts b/src/lib/read-text-retry.ts new file mode 100644 index 0000000..a438d9c --- /dev/null +++ b/src/lib/read-text-retry.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises'; + +const DEFAULT_RETRY_CODES = new Set(['EBUSY', 'EPERM']); + +export interface ReadTextRetryOptions { + retries?: number; + delayMs?: number; + retryCodes?: Set; +} + +function sleep(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function shouldRetry(error: unknown, retryCodes: Set): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return typeof code === 'string' && retryCodes.has(code); +} + +export async function readTextFileWithRetry( + filePath: string, + options: ReadTextRetryOptions = {}, +): Promise { + const retries = options.retries ?? 2; + const delayMs = options.delayMs ?? 40; + const retryCodes = options.retryCodes ?? DEFAULT_RETRY_CODES; + + let attempt = 0; + while (true) { + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error) { + if (attempt >= retries || !shouldRetry(error, retryCodes)) { + throw error; + } + + attempt += 1; + await sleep(delayMs); + } + } +} diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts new file mode 100644 index 0000000..d690493 --- /dev/null +++ b/src/lib/realtime.ts @@ -0,0 +1,82 @@ +import { canonicalizeWindowsPath, windowsPathKey } from './pathing'; + +export type IssuesChangeKind = 'changed' | 'renamed'; + +export interface IssuesChangedEvent { + id: number; + projectRoot: string; + changedPath?: string; + kind: IssuesChangeKind; + at: string; +} + +interface Subscriber { + projectKey?: string; + listener: (event: IssuesChangedEvent) => void; +} + +export interface SubscribeOptions { + projectRoot?: string; +} + +export class IssuesEventBus { + private nextEventId = 1; + + private readonly subscribers = new Map(); + + private nextSubscriberId = 1; + + emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent { + const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot); + const projectKey = windowsPathKey(canonicalProjectRoot); + const event: IssuesChangedEvent = { + id: this.nextEventId, + projectRoot: canonicalProjectRoot, + changedPath: changedPath ? canonicalizeWindowsPath(changedPath) : undefined, + kind, + at: new Date().toISOString(), + }; + this.nextEventId += 1; + + for (const subscriber of this.subscribers.values()) { + if (!subscriber.projectKey || subscriber.projectKey === projectKey) { + subscriber.listener(event); + } + } + + return event; + } + + subscribe(listener: (event: IssuesChangedEvent) => void, options: SubscribeOptions = {}): () => void { + const id = this.nextSubscriberId; + this.nextSubscriberId += 1; + + this.subscribers.set(id, { + listener, + projectKey: options.projectRoot ? windowsPathKey(options.projectRoot) : undefined, + }); + + return () => { + this.subscribers.delete(id); + }; + } + + getSubscriberCount(): number { + return this.subscribers.size; + } + + resetForTests(): void { + this.subscribers.clear(); + this.nextSubscriberId = 1; + this.nextEventId = 1; + } +} + +export const issuesEventBus = new IssuesEventBus(); + +export function toSseFrame(event: IssuesChangedEvent): string { + return `id: ${event.id}\nevent: issues\ndata: ${JSON.stringify(event)}\n\n`; +} + +export const SSE_HEARTBEAT_FRAME = ': heartbeat\n\n'; +export const SSE_CONNECTED_FRAME = ': connected\n\n'; diff --git a/src/lib/watcher.ts b/src/lib/watcher.ts new file mode 100644 index 0000000..e7a2623 --- /dev/null +++ b/src/lib/watcher.ts @@ -0,0 +1,114 @@ +import chokidar, { type FSWatcher } from 'chokidar'; + +import { ProjectEventCoalescer } from './coalescer'; +import { windowsPathKey } from './pathing'; +import { issuesEventBus, type IssuesChangeKind, type IssuesEventBus } from './realtime'; +import { resolveIssuesJsonlPathCandidates } from './read-issues'; + +type FileEventName = 'add' | 'change' | 'unlink'; + +interface WatchRegistration { + projectRoot: string; + watcher: FSWatcher; +} + +export interface WatchManagerOptions { + debounceMs?: number; + eventBus?: IssuesEventBus; +} + +export class IssuesWatchManager { + private readonly registrations = new Map(); + + private readonly eventBus: IssuesEventBus; + + private readonly coalescer: ProjectEventCoalescer<{ + changedPath?: string; + kind: IssuesChangeKind; + }>; + + constructor(options: WatchManagerOptions = {}) { + const debounceMs = options.debounceMs ?? 150; + this.eventBus = options.eventBus ?? issuesEventBus; + this.coalescer = new ProjectEventCoalescer(debounceMs, ({ projectRoot, payload }) => { + this.eventBus.emit(projectRoot, payload.changedPath, payload.kind); + }); + } + + startWatch(projectRoot: string): void { + const projectKey = windowsPathKey(projectRoot); + if (this.registrations.has(projectKey)) { + return; + } + + const watchedPaths = resolveIssuesJsonlPathCandidates(projectRoot); + const watcher = chokidar.watch(watchedPaths, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 15, + }, + }); + + const onFileEvent = (eventName: FileEventName, changedPath: string) => { + const kind: IssuesChangeKind = eventName === 'unlink' ? 'renamed' : 'changed'; + this.queueCoalescedEvent(projectRoot, changedPath, kind); + }; + + watcher.on('add', (changedPath) => onFileEvent('add', changedPath)); + watcher.on('change', (changedPath) => onFileEvent('change', changedPath)); + watcher.on('unlink', (changedPath) => onFileEvent('unlink', changedPath)); + + this.registrations.set(projectKey, { + projectRoot, + watcher, + }); + } + + async stopWatch(projectRoot: string): Promise { + const projectKey = windowsPathKey(projectRoot); + const registration = this.registrations.get(projectKey); + if (!registration) { + return; + } + + this.coalescer.cancel(projectRoot); + this.registrations.delete(projectKey); + await registration.watcher.close(); + } + + async stopAll(): Promise { + const closeOps: Promise[] = []; + + for (const registration of this.registrations.values()) { + closeOps.push(registration.watcher.close()); + } + + this.coalescer.cancelAll(); + this.registrations.clear(); + await Promise.all(closeOps); + } + + getWatchedProjectCount(): number { + return this.registrations.size; + } + + private queueCoalescedEvent(projectRoot: string, changedPath: string, kind: IssuesChangeKind): void { + this.coalescer.queue(projectRoot, { + changedPath, + kind, + }); + } +} + +const globalRegistry = globalThis as typeof globalThis & { + __beadboardWatchManager?: IssuesWatchManager; +}; + +export function getIssuesWatchManager(): IssuesWatchManager { + if (!globalRegistry.__beadboardWatchManager) { + globalRegistry.__beadboardWatchManager = new IssuesWatchManager(); + } + + return globalRegistry.__beadboardWatchManager; +} diff --git a/tests/api/events-route.test.ts b/tests/api/events-route.test.ts new file mode 100644 index 0000000..2c50470 --- /dev/null +++ b/tests/api/events-route.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { GET as eventsGet } from '../../src/app/api/events/route'; +import { getIssuesWatchManager } from '../../src/lib/watcher'; + +test.after(async () => { + await getIssuesWatchManager().stopAll(); +}); + +test('events route returns SSE response with expected headers', async () => { + const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test')); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type')?.includes('text/event-stream'), true); + assert.equal(response.headers.get('cache-control')?.includes('no-cache'), true); + + const reader = response.body?.getReader(); + if (reader) { + await reader.cancel(); + } +}); + +test('events route emits initial connected frame', async () => { + const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test')); + const reader = response.body?.getReader(); + assert.equal(Boolean(reader), true); + + const first = await reader!.read(); + const chunk = new TextDecoder().decode(first.value); + assert.equal(chunk.includes(': connected'), true); + + await reader!.cancel(); +}); diff --git a/tests/lib/coalescer.test.ts b/tests/lib/coalescer.test.ts new file mode 100644 index 0000000..4aba50f --- /dev/null +++ b/tests/lib/coalescer.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { ProjectEventCoalescer } from '../../src/lib/coalescer'; + +test('coalescer emits latest payload once per project within debounce window', async () => { + const flushed: Array<{ projectRoot: string; payload: { value: string } }> = []; + const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => { + flushed.push(event); + }); + + coalescer.queue('C:/Repo/One', { value: 'first' }); + coalescer.queue('c:\\repo\\one', { value: 'second' }); + + await new Promise((resolve) => setTimeout(resolve, 45)); + + assert.equal(flushed.length, 1); + assert.equal(flushed[0].payload.value, 'second'); +}); + +test('coalescer keeps distinct projects separated', async () => { + const flushed: Array<{ projectRoot: string; payload: { value: string } }> = []; + const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => { + flushed.push(event); + }); + + coalescer.queue('C:/Repo/One', { value: 'one' }); + coalescer.queue('D:/Repo/Two', { value: 'two' }); + + await new Promise((resolve) => setTimeout(resolve, 45)); + + assert.equal(flushed.length, 2); +}); diff --git a/tests/lib/read-text-retry.test.ts b/tests/lib/read-text-retry.test.ts new file mode 100644 index 0000000..4d1f105 --- /dev/null +++ b/tests/lib/read-text-retry.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { readTextFileWithRetry } from '../../src/lib/read-text-retry'; + +test('readTextFileWithRetry reads file content', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-retry-read-')); + const target = path.join(root, 'sample.txt'); + await fs.writeFile(target, 'ok', 'utf8'); + + const content = await readTextFileWithRetry(target); + assert.equal(content, 'ok'); +}); + +test('readTextFileWithRetry does not retry non-retryable errors', async () => { + await assert.rejects( + () => readTextFileWithRetry('C:/definitely/missing/file.txt', { retries: 3, delayMs: 1 }), + (error: unknown) => { + const code = (error as NodeJS.ErrnoException).code; + assert.equal(code, 'ENOENT'); + return true; + }, + ); +}); diff --git a/tests/lib/realtime.test.ts b/tests/lib/realtime.test.ts new file mode 100644 index 0000000..582cfc1 --- /dev/null +++ b/tests/lib/realtime.test.ts @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { IssuesEventBus, toSseFrame } from '../../src/lib/realtime'; + +test('IssuesEventBus emits monotonically increasing IDs', () => { + const bus = new IssuesEventBus(); + const seen: number[] = []; + const unsubscribe = bus.subscribe((event) => seen.push(event.id)); + + bus.emit('C:/Repo/One'); + bus.emit('C:/Repo/One'); + unsubscribe(); + + assert.deepEqual(seen, [1, 2]); +}); + +test('IssuesEventBus filters by project root', () => { + const bus = new IssuesEventBus(); + const one: number[] = []; + const two: number[] = []; + const stopOne = bus.subscribe((event) => one.push(event.id), { projectRoot: 'C:/Repo/One' }); + const stopTwo = bus.subscribe((event) => two.push(event.id), { projectRoot: 'D:/Repo/Two' }); + + bus.emit('c:\\repo\\one'); + bus.emit('D:/Repo/Two'); + + stopOne(); + stopTwo(); + + assert.deepEqual(one, [1]); + assert.deepEqual(two, [2]); +}); + +test('toSseFrame includes id, event name, and data payload', () => { + const frame = toSseFrame({ + id: 9, + projectRoot: 'C:\\Repo\\One', + kind: 'changed', + at: '2026-02-12T01:00:00.000Z', + }); + + assert.equal(frame.includes('id: 9'), true); + assert.equal(frame.includes('event: issues'), true); + assert.equal(frame.includes('"projectRoot":"C:\\\\Repo\\\\One"'), true); +}); diff --git a/tests/lib/watcher.test.ts b/tests/lib/watcher.test.ts new file mode 100644 index 0000000..86bb24e --- /dev/null +++ b/tests/lib/watcher.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { IssuesEventBus } from '../../src/lib/realtime'; +import { IssuesWatchManager } from '../../src/lib/watcher'; + +test('IssuesWatchManager startWatch is idempotent per project', async () => { + const bus = new IssuesEventBus(); + const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 20 }); + + manager.startWatch('C:/Repo/One'); + manager.startWatch('c:\\repo\\one'); + + assert.equal(manager.getWatchedProjectCount(), 1); + await manager.stopAll(); +}); + +test('IssuesWatchManager emits event after file change in watched .beads path', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-')); + const beadsDir = path.join(root, '.beads'); + const issuesPath = path.join(beadsDir, 'issues.jsonl'); + await fs.mkdir(beadsDir, { recursive: true }); + await fs.writeFile(issuesPath, '', 'utf8'); + + const bus = new IssuesEventBus(); + const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 }); + + const events: string[] = []; + const stop = bus.subscribe((event) => { + events.push(event.projectRoot); + }); + + manager.startWatch(root); + + await fs.writeFile(issuesPath, `${JSON.stringify({ id: 'bb-1', title: 'watch' })}\n`, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 220)); + + stop(); + await manager.stopAll(); + + assert.equal(events.length >= 1, true); +}); From e599640d3bb743818d01db737c399396fd1805f9 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 21:25:46 -0800 Subject: [PATCH 07/29] Persist root UI/tailwind/responsive guard refinements from stash backlog --- .gitignore | 3 +++ src/app/globals.css | 27 +++++++++---------- src/app/layout.tsx | 13 ++++++++- src/components/kanban/kanban-controls.tsx | 4 +-- src/components/shared/chip.tsx | 2 +- tailwind.config.ts | 4 +-- .../kanban-responsive-contract.test.mjs | 9 ++++--- 7 files changed, 39 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index eb35607..596f42a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ *.tsbuildinfo .worktrees/ worktrees/ + +# bv (beads viewer) local config and caches +.bv/ diff --git a/src/app/globals.css b/src/app/globals.css index d17e938..a474080 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,15 +3,15 @@ @tailwind utilities; :root { - --color-bg: #090c14; - --color-surface: #101827; - --color-surface-muted: #192336; - --color-surface-raised: #22314a; - --color-text-strong: #f6f8ff; - --color-text-body: #d8e0f1; - --color-text-muted: #9caccc; - --color-border-soft: rgba(145, 166, 204, 0.3); - --color-border-strong: rgba(187, 209, 246, 0.62); + --color-bg: #090909; + --color-surface: #161616; + --color-surface-muted: #212121; + --color-surface-raised: #2a2a2a; + --color-text-strong: #f5f5f5; + --color-text-body: #d0d0d0; + --color-text-muted: #9a9a9a; + --color-border-soft: rgba(255, 255, 255, 0.15); + --color-border-strong: rgba(255, 255, 255, 0.3); --status-open: #60a5fa; --status-progress: #fbbf24; @@ -38,10 +38,9 @@ body { body { background: - radial-gradient(circle at 10% 12%, rgba(12, 138, 215, 0.34), transparent 36%), - radial-gradient(circle at 84% 20%, rgba(250, 122, 91, 0.18), transparent 30%), - radial-gradient(circle at 68% 88%, rgba(57, 189, 154, 0.14), transparent 36%), - linear-gradient(155deg, #05070d 0%, #0b1322 42%, #121e34 100%); + radial-gradient(circle at 14% 12%, rgba(255, 255, 255, 0.05), transparent 36%), + radial-gradient(circle at 84% 18%, rgba(255, 180, 80, 0.06), transparent 32%), + linear-gradient(160deg, #070707 0%, #101010 48%, #161616 100%); color: var(--color-text-body); - font-family: 'Segoe UI', 'Aptos', Inter, system-ui, sans-serif; + font-family: 'DM Sans', 'Segoe UI', Inter, system-ui, sans-serif; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ff1ad90..1417e77 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,18 @@ import type { Metadata } from 'next'; +import { DM_Sans, JetBrains_Mono } from 'next/font/google'; import type { ReactNode } from 'react'; import './globals.css'; +const dmSans = DM_Sans({ + subsets: ['latin'], + variable: '--font-ui', +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-mono', +}); + export const metadata: Metadata = { title: 'BeadBoard', description: 'Windows-native Beads dashboard', @@ -10,7 +21,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + {children} ); } diff --git a/src/components/kanban/kanban-controls.tsx b/src/components/kanban/kanban-controls.tsx index 78b09f4..e1e04f9 100644 --- a/src/components/kanban/kanban-controls.tsx +++ b/src/components/kanban/kanban-controls.tsx @@ -14,7 +14,7 @@ interface KanbanControlsProps { export function KanbanControls({ filters, stats, onFiltersChange }: KanbanControlsProps) { const inputClass = - 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-cyan-300/70 focus:ring-2 focus:ring-cyan-300/20'; + 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-border-strong focus:ring-2 focus:ring-white/10'; return (
@@ -57,7 +57,7 @@ export function KanbanControls({ filters, stats, onFiltersChange }: KanbanContro type="checkbox" checked={filters.showClosed ?? false} onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })} - className="h-4 w-4 accent-cyan-400" + className="h-4 w-4 accent-amber-400" /> Show closed diff --git a/src/components/shared/chip.tsx b/src/components/shared/chip.tsx index c1637e6..e29d49d 100644 --- a/src/components/shared/chip.tsx +++ b/src/components/shared/chip.tsx @@ -7,7 +7,7 @@ interface ChipProps { const CHIP_TONE_CLASS: Record, string> = { default: 'border-border-soft bg-surface-muted/75 text-text-body', - status: 'border-cyan-300/30 bg-cyan-500/20 text-cyan-50', + status: 'border-zinc-300/30 bg-zinc-500/20 text-zinc-100', priority: 'border-amber-300/30 bg-amber-500/20 text-amber-50', }; diff --git a/tailwind.config.ts b/tailwind.config.ts index 5ad9067..953965c 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -5,8 +5,8 @@ const config: Config = { theme: { extend: { fontFamily: { - ui: ['Segoe UI', 'Inter', 'system-ui', 'sans-serif'], - mono: ['JetBrains Mono', 'Consolas', 'monospace'], + ui: ['var(--font-ui)', 'Segoe UI', 'Inter', 'system-ui', 'sans-serif'], + mono: ['var(--font-mono)', 'Consolas', 'monospace'], }, colors: { bg: 'var(--color-bg)', diff --git a/tests/guards/kanban-responsive-contract.test.mjs b/tests/guards/kanban-responsive-contract.test.mjs index 4e02f28..3efabf4 100644 --- a/tests/guards/kanban-responsive-contract.test.mjs +++ b/tests/guards/kanban-responsive-contract.test.mjs @@ -9,11 +9,12 @@ async function read(relativePath) { return fs.readFile(path.join(ROOT, relativePath), 'utf8'); } -test('kanban board uses intentional horizontal scroll affordances', async () => { +test('kanban board uses expandable vertical swimlanes', async () => { const board = await read('src/components/kanban/kanban-board.tsx'); - assert.match(board, /snap-x/); - assert.match(board, /overflow-x-auto/); + assert.match(board, /aria-expanded/); + assert.match(board, /onActivateStatus/); + assert.match(board, /max-h-\[50vh\]/); }); test('kanban page defines mobile detail drawer behavior', async () => { @@ -21,6 +22,8 @@ test('kanban page defines mobile detail drawer behavior', async () => { assert.match(page, /fixed inset-0/); assert.match(page, /lg:hidden/); + assert.match(page, /lg:grid-cols-\[minmax\(0,1fr\)_minmax\(22rem,26rem\)\]/); + assert.match(page, /lg:border-l/); }); test('kanban controls use fluid full-width sizing on small viewports', async () => { From 89876b18621012621314e78e9eda311b5d802d45 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 21:27:01 -0800 Subject: [PATCH 08/29] Preserve recovered design-plan doc and ignore local beads lock artifact --- .gitignore | 3 + .../2026-02-12-kanban-design-foundation.md | 134 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 docs/plans/2026-02-12-kanban-design-foundation.md diff --git a/.gitignore b/.gitignore index 596f42a..ee7bef2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ worktrees/ # bv (beads viewer) local config and caches .bv/ + +# beads lock artifact +.beads/.bv.lock diff --git a/docs/plans/2026-02-12-kanban-design-foundation.md b/docs/plans/2026-02-12-kanban-design-foundation.md new file mode 100644 index 0000000..0e304f8 --- /dev/null +++ b/docs/plans/2026-02-12-kanban-design-foundation.md @@ -0,0 +1,134 @@ +# BeadBoard Kanban Design System Plan + +**Goal** +Ship a production-ready baseline visual system for Tracer Bullet 1 (Kanban) before further feature expansion. + +**Primary decision** +Use **Tailwind CSS v4 + CSS tokens (`@theme`) + Framer Motion**. + +## 1. Why This Stack + +### Tailwind v4 is the right baseline +- Fastest path to a coherent, reusable system in Next.js 15. +- CSS-first token model (`@theme`) fits our need for semantic design tokens. +- Lets us avoid scattered inline styles and ad-hoc CSS files. + +### Framer Motion should be scoped +- Use for state transitions (card changes, panel enter/exit, filtered results). +- Avoid decorative over-animation that hurts readability in a dense dashboard. + +### Risks and mitigations +- Risk: “generic Tailwind look” + - Mitigation: strict token palette + component contracts + typography rules. +- Risk: visual inconsistency + - Mitigation: no direct color literals in component markup except token definitions. + +## 2. Baseline-First Sequencing + +This should happen **now**, not later. + +1. Foundation (tokens, layout, core components) +2. Motion and interaction polish +3. Accessibility and responsive hardening +4. Continue other tracer bullets + +## 3. Visual Language (v1) + +### Product feel +- High-signal operations UI. +- Calm neutral surfaces with sharp status accents. +- Dense information without visual clutter. + +### Typography +- Primary UI: `DM Sans` (or `Inter` fallback decision at implementation time) +- Metadata/IDs: `JetBrains Mono` + +### Color model +- Semantic tokens only: + - `background`, `foreground`, `surface`, `muted` + - `status-open`, `status-in-progress`, `status-blocked`, `status-deferred`, `status-closed` + - `priority-p0` ... `priority-p4` +- Contrast target: at least WCAG AA for normal text. + +## 4. Component Contract + +Required first-class components: +- `KanbanPageShell` +- `KanbanControls` +- `KanbanBoard` +- `KanbanColumn` +- `KanbanCard` +- `KanbanDetailPanel` +- shared: `Badge`, `Chip`, `StatPill` + +Rules: +- Component variants defined via class composition (CVA optional but preferred). +- No inline style objects for production components. +- All spacing/radius/shadow/color come from tokens/utilities. + +## 5. Layout Contract + +### Desktop +- Sticky top header with filters + stats. +- Main grid: board + detail panel. +- Columns scroll horizontally as needed. + +### Mobile +- Stacked controls. +- Board in horizontal swipe/scroll mode. +- Detail panel becomes full-screen drawer. + +## 6. Motion Contract (Framer Motion) + +Use motion for: +- Card appear/disappear on filtering. +- Detail panel slide-in/out. +- Subtle status count transitions. + +Do not animate: +- Global page container on every render. +- Constant hover effects that reduce legibility/performance. + +## 7. Tailwind v4 Implementation Plan + +### Phase A: Design System Foundation (P0) +- Add Tailwind v4 pipeline with `@import "tailwindcss"` in global stylesheet. +- Define `@theme` token set (colors, radius, spacing aliases, shadows, motion tokens). +- Add base layer typography/background defaults. +- Replace inline styles in tracer-1 components with tokenized Tailwind classes. + +Acceptance criteria: +- No inline style usage in `src/components/kanban/*` and shared primitives (except truly dynamic edge cases). +- UI at `localhost:3000` has coherent baseline styling. + +### Phase B: Motion + Interaction Polish (P1) +- Integrate Framer Motion transitions for board and panel. +- Improve visual hierarchy of card metadata. +- Add polished empty/loading/error states. + +Acceptance criteria: +- Motion communicates state changes without jitter. +- Filtering and detail interactions feel intentional. + +### Phase C: Accessibility + Responsive Hardening (P1) +- Keyboard focus and traversal for cards/panel. +- Verify color contrast and focus visibility. +- Tune mobile breakpoints and touch targets. + +Acceptance criteria: +- Keyboard-only flow works for core Kanban actions. +- Mobile experience is usable and visually consistent. + +## 8. Technical Boundaries + +- Read path remains `.beads/issues.jsonl` / `.beads/issues.jsonl.new`. +- No direct write path to JSONL. +- Styling changes must not alter read/write boundary behavior. + +## 9. Definition of Done (Tracer-1 Design Baseline) + +- Tailwind v4 configured and used as primary styling framework. +- Tokenized design system applied across tracer-1 Kanban components. +- Framer Motion integrated for key transitions. +- Tests/typecheck pass, app runs on `localhost:3000`. +- Visual result is clearly beyond prototype/demo quality. From 48009e2d4fcac1e9139fe3b696afe7178a2371f2 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 21:28:06 -0800 Subject: [PATCH 09/29] Fix scanner Dirent typing after branch consolidation --- src/lib/scanner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index aa24225..d611d84 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -155,7 +156,7 @@ async function scanRoot( } stats.scannedDirectories += 1; - let entries: fs.Dirent[]; + let entries: Dirent[]; try { entries = await fs.readdir(current.dir, { withFileTypes: true }); } catch (error) { From 7ab23448f0479e229c3d3b4aea422fbe1a8f3850 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 21:29:20 -0800 Subject: [PATCH 10/29] Archive stash backlogs into repo docs for traceable recovery --- docs/archive/postcss.config.mjs.experimental | 5 + .../stashes/stash-0-root-leftovers.patch | 166 +++ .../stashes/stash-1-agent-b-backlog.patch | 1087 +++++++++++++++++ 3 files changed, 1258 insertions(+) create mode 100644 docs/archive/postcss.config.mjs.experimental create mode 100644 docs/archive/stashes/stash-0-root-leftovers.patch create mode 100644 docs/archive/stashes/stash-1-agent-b-backlog.patch diff --git a/docs/archive/postcss.config.mjs.experimental b/docs/archive/postcss.config.mjs.experimental new file mode 100644 index 0000000..a34a3d5 --- /dev/null +++ b/docs/archive/postcss.config.mjs.experimental @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/docs/archive/stashes/stash-0-root-leftovers.patch b/docs/archive/stashes/stash-0-root-leftovers.patch new file mode 100644 index 0000000..fd7babd --- /dev/null +++ b/docs/archive/stashes/stash-0-root-leftovers.patch @@ -0,0 +1,166 @@ +diff --git a/.gitignore b/.gitignore +index eb35607..596f42a 100644 +--- a/.gitignore ++++ b/.gitignore +@@ -3,3 +3,6 @@ node_modules/ + *.tsbuildinfo + .worktrees/ + worktrees/ ++ ++# bv (beads viewer) local config and caches ++.bv/ +diff --git a/src/app/globals.css b/src/app/globals.css +index d17e938..a474080 100644 +--- a/src/app/globals.css ++++ b/src/app/globals.css +@@ -3,15 +3,15 @@ + @tailwind utilities; + + :root { +- --color-bg: #090c14; +- --color-surface: #101827; +- --color-surface-muted: #192336; +- --color-surface-raised: #22314a; +- --color-text-strong: #f6f8ff; +- --color-text-body: #d8e0f1; +- --color-text-muted: #9caccc; +- --color-border-soft: rgba(145, 166, 204, 0.3); +- --color-border-strong: rgba(187, 209, 246, 0.62); ++ --color-bg: #090909; ++ --color-surface: #161616; ++ --color-surface-muted: #212121; ++ --color-surface-raised: #2a2a2a; ++ --color-text-strong: #f5f5f5; ++ --color-text-body: #d0d0d0; ++ --color-text-muted: #9a9a9a; ++ --color-border-soft: rgba(255, 255, 255, 0.15); ++ --color-border-strong: rgba(255, 255, 255, 0.3); + + --status-open: #60a5fa; + --status-progress: #fbbf24; +@@ -38,10 +38,9 @@ body { + + body { + background: +- radial-gradient(circle at 10% 12%, rgba(12, 138, 215, 0.34), transparent 36%), +- radial-gradient(circle at 84% 20%, rgba(250, 122, 91, 0.18), transparent 30%), +- radial-gradient(circle at 68% 88%, rgba(57, 189, 154, 0.14), transparent 36%), +- linear-gradient(155deg, #05070d 0%, #0b1322 42%, #121e34 100%); ++ radial-gradient(circle at 14% 12%, rgba(255, 255, 255, 0.05), transparent 36%), ++ radial-gradient(circle at 84% 18%, rgba(255, 180, 80, 0.06), transparent 32%), ++ linear-gradient(160deg, #070707 0%, #101010 48%, #161616 100%); + color: var(--color-text-body); +- font-family: 'Segoe UI', 'Aptos', Inter, system-ui, sans-serif; ++ font-family: 'DM Sans', 'Segoe UI', Inter, system-ui, sans-serif; + } +diff --git a/src/app/layout.tsx b/src/app/layout.tsx +index ff1ad90..1417e77 100644 +--- a/src/app/layout.tsx ++++ b/src/app/layout.tsx +@@ -1,7 +1,18 @@ + import type { Metadata } from 'next'; ++import { DM_Sans, JetBrains_Mono } from 'next/font/google'; + import type { ReactNode } from 'react'; + import './globals.css'; + ++const dmSans = DM_Sans({ ++ subsets: ['latin'], ++ variable: '--font-ui', ++}); ++ ++const jetbrainsMono = JetBrains_Mono({ ++ subsets: ['latin'], ++ variable: '--font-mono', ++}); ++ + export const metadata: Metadata = { + title: 'BeadBoard', + description: 'Windows-native Beads dashboard', +@@ -10,7 +21,7 @@ export const metadata: Metadata = { + export default function RootLayout({ children }: { children: ReactNode }) { + return ( + +- {children} ++ {children} + + ); + } +diff --git a/src/components/kanban/kanban-controls.tsx b/src/components/kanban/kanban-controls.tsx +index 78b09f4..e1e04f9 100644 +--- a/src/components/kanban/kanban-controls.tsx ++++ b/src/components/kanban/kanban-controls.tsx +@@ -14,7 +14,7 @@ interface KanbanControlsProps { + + export function KanbanControls({ filters, stats, onFiltersChange }: KanbanControlsProps) { + const inputClass = +- 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-cyan-300/70 focus:ring-2 focus:ring-cyan-300/20'; ++ 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-border-strong focus:ring-2 focus:ring-white/10'; + + return ( +
+@@ -57,7 +57,7 @@ export function KanbanControls({ filters, stats, onFiltersChange }: KanbanContro + type="checkbox" + checked={filters.showClosed ?? false} + onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })} +- className="h-4 w-4 accent-cyan-400" ++ className="h-4 w-4 accent-amber-400" + /> + Show closed + +diff --git a/src/components/shared/chip.tsx b/src/components/shared/chip.tsx +index c1637e6..e29d49d 100644 +--- a/src/components/shared/chip.tsx ++++ b/src/components/shared/chip.tsx +@@ -7,7 +7,7 @@ interface ChipProps { + + const CHIP_TONE_CLASS: Record, string> = { + default: 'border-border-soft bg-surface-muted/75 text-text-body', +- status: 'border-cyan-300/30 bg-cyan-500/20 text-cyan-50', ++ status: 'border-zinc-300/30 bg-zinc-500/20 text-zinc-100', + priority: 'border-amber-300/30 bg-amber-500/20 text-amber-50', + }; + +diff --git a/tailwind.config.ts b/tailwind.config.ts +index 5ad9067..953965c 100644 +--- a/tailwind.config.ts ++++ b/tailwind.config.ts +@@ -5,8 +5,8 @@ const config: Config = { + theme: { + extend: { + fontFamily: { +- ui: ['Segoe UI', 'Inter', 'system-ui', 'sans-serif'], +- mono: ['JetBrains Mono', 'Consolas', 'monospace'], ++ ui: ['var(--font-ui)', 'Segoe UI', 'Inter', 'system-ui', 'sans-serif'], ++ mono: ['var(--font-mono)', 'Consolas', 'monospace'], + }, + colors: { + bg: 'var(--color-bg)', +diff --git a/tests/guards/kanban-responsive-contract.test.mjs b/tests/guards/kanban-responsive-contract.test.mjs +index 4e02f28..3efabf4 100644 +--- a/tests/guards/kanban-responsive-contract.test.mjs ++++ b/tests/guards/kanban-responsive-contract.test.mjs +@@ -9,11 +9,12 @@ async function read(relativePath) { + return fs.readFile(path.join(ROOT, relativePath), 'utf8'); + } + +-test('kanban board uses intentional horizontal scroll affordances', async () => { ++test('kanban board uses expandable vertical swimlanes', async () => { + const board = await read('src/components/kanban/kanban-board.tsx'); + +- assert.match(board, /snap-x/); +- assert.match(board, /overflow-x-auto/); ++ assert.match(board, /aria-expanded/); ++ assert.match(board, /onActivateStatus/); ++ assert.match(board, /max-h-\[50vh\]/); + }); + + test('kanban page defines mobile detail drawer behavior', async () => { +@@ -21,6 +22,8 @@ test('kanban page defines mobile detail drawer behavior', async () => { + + assert.match(page, /fixed inset-0/); + assert.match(page, /lg:hidden/); ++ assert.match(page, /lg:grid-cols-\[minmax\(0,1fr\)_minmax\(22rem,26rem\)\]/); ++ assert.match(page, /lg:border-l/); + }); + + test('kanban controls use fluid full-width sizing on small viewports', async () => { diff --git a/docs/archive/stashes/stash-1-agent-b-backlog.patch b/docs/archive/stashes/stash-1-agent-b-backlog.patch new file mode 100644 index 0000000..fbaedf1 --- /dev/null +++ b/docs/archive/stashes/stash-1-agent-b-backlog.patch @@ -0,0 +1,1087 @@ +diff --git a/package-lock.json b/package-lock.json +index 7a082ab..e0c00b5 100644 +--- a/package-lock.json ++++ b/package-lock.json +@@ -14,13 +14,29 @@ + "react-dom": "19.2.1" + }, + "devDependencies": { ++ "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", ++ "lightningcss-win32-x64-msvc": "^1.30.2", ++ "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", + "typescript": "^5.7.2" + } + }, ++ "node_modules/@alloc/quick-lru": { ++ "version": "5.2.0", ++ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", ++ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", ++ "dev": true, ++ "license": "MIT", ++ "engines": { ++ "node": ">=10" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/sindresorhus" ++ } ++ }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", +@@ -939,6 +955,56 @@ + "url": "https://opencollective.com/libvips" + } + }, ++ "node_modules/@jridgewell/gen-mapping": { ++ "version": "0.3.13", ++ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", ++ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", ++ "dev": true, ++ "license": "MIT", ++ "dependencies": { ++ "@jridgewell/sourcemap-codec": "^1.5.0", ++ "@jridgewell/trace-mapping": "^0.3.24" ++ } ++ }, ++ "node_modules/@jridgewell/remapping": { ++ "version": "2.3.5", ++ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", ++ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", ++ "dev": true, ++ "license": "MIT", ++ "dependencies": { ++ "@jridgewell/gen-mapping": "^0.3.5", ++ "@jridgewell/trace-mapping": "^0.3.24" ++ } ++ }, ++ "node_modules/@jridgewell/resolve-uri": { ++ "version": "3.1.2", ++ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", ++ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", ++ "dev": true, ++ "license": "MIT", ++ "engines": { ++ "node": ">=6.0.0" ++ } ++ }, ++ "node_modules/@jridgewell/sourcemap-codec": { ++ "version": "1.5.5", ++ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", ++ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", ++ "dev": true, ++ "license": "MIT" ++ }, ++ "node_modules/@jridgewell/trace-mapping": { ++ "version": "0.3.31", ++ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", ++ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", ++ "dev": true, ++ "license": "MIT", ++ "dependencies": { ++ "@jridgewell/resolve-uri": "^3.1.0", ++ "@jridgewell/sourcemap-codec": "^1.4.14" ++ } ++ }, + "node_modules/@next/env": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", +@@ -1082,6 +1148,546 @@ + "tslib": "^2.8.0" + } + }, ++ "node_modules/@tailwindcss/node": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", ++ "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", ++ "dev": true, ++ "license": "MIT", ++ "dependencies": { ++ "@jridgewell/remapping": "^2.3.4", ++ "enhanced-resolve": "^5.18.3", ++ "jiti": "^2.6.1", ++ "lightningcss": "1.30.2", ++ "magic-string": "^0.30.21", ++ "source-map-js": "^1.2.1", ++ "tailwindcss": "4.1.18" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", ++ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", ++ "dev": true, ++ "license": "MPL-2.0", ++ "dependencies": { ++ "detect-libc": "^2.0.3" ++ }, ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ }, ++ "optionalDependencies": { ++ "lightningcss-android-arm64": "1.30.2", ++ "lightningcss-darwin-arm64": "1.30.2", ++ "lightningcss-darwin-x64": "1.30.2", ++ "lightningcss-freebsd-x64": "1.30.2", ++ "lightningcss-linux-arm-gnueabihf": "1.30.2", ++ "lightningcss-linux-arm64-gnu": "1.30.2", ++ "lightningcss-linux-arm64-musl": "1.30.2", ++ "lightningcss-linux-x64-gnu": "1.30.2", ++ "lightningcss-linux-x64-musl": "1.30.2", ++ "lightningcss-win32-arm64-msvc": "1.30.2", ++ "lightningcss-win32-x64-msvc": "1.30.2" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", ++ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "android" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", ++ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "darwin" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", ++ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "darwin" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", ++ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "freebsd" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", ++ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", ++ "cpu": [ ++ "arm" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", ++ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", ++ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", ++ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", ++ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", ++ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "optional": true, ++ "os": [ ++ "win32" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", ++ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", ++ "dev": true, ++ "license": "MIT", ++ "engines": { ++ "node": ">= 10" ++ }, ++ "optionalDependencies": { ++ "@tailwindcss/oxide-android-arm64": "4.1.18", ++ "@tailwindcss/oxide-darwin-arm64": "4.1.18", ++ "@tailwindcss/oxide-darwin-x64": "4.1.18", ++ "@tailwindcss/oxide-freebsd-x64": "4.1.18", ++ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", ++ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", ++ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", ++ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", ++ "@tailwindcss/oxide-linux-x64-musl": "4.1.18", ++ "@tailwindcss/oxide-wasm32-wasi": "4.1.18", ++ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", ++ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-android-arm64": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", ++ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "android" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-darwin-arm64": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", ++ "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "darwin" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-darwin-x64": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", ++ "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "darwin" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-freebsd-x64": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", ++ "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "freebsd" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", ++ "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", ++ "cpu": [ ++ "arm" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", ++ "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-linux-arm64-musl": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", ++ "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-linux-x64-gnu": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", ++ "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-linux-x64-musl": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", ++ "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "linux" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-wasm32-wasi": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", ++ "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", ++ "bundleDependencies": [ ++ "@napi-rs/wasm-runtime", ++ "@emnapi/core", ++ "@emnapi/runtime", ++ "@tybys/wasm-util", ++ "@emnapi/wasi-threads", ++ "tslib" ++ ], ++ "cpu": [ ++ "wasm32" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "dependencies": { ++ "@emnapi/core": "^1.7.1", ++ "@emnapi/runtime": "^1.7.1", ++ "@emnapi/wasi-threads": "^1.1.0", ++ "@napi-rs/wasm-runtime": "^1.1.0", ++ "@tybys/wasm-util": "^0.10.1", ++ "tslib": "^2.4.0" ++ }, ++ "engines": { ++ "node": ">=14.0.0" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", ++ "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", ++ "cpu": [ ++ "arm64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "win32" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/oxide-win32-x64-msvc": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", ++ "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MIT", ++ "optional": true, ++ "os": [ ++ "win32" ++ ], ++ "engines": { ++ "node": ">= 10" ++ } ++ }, ++ "node_modules/@tailwindcss/postcss": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", ++ "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", ++ "dev": true, ++ "license": "MIT", ++ "dependencies": { ++ "@alloc/quick-lru": "^5.2.0", ++ "@tailwindcss/node": "4.1.18", ++ "@tailwindcss/oxide": "4.1.18", ++ "postcss": "^8.4.41", ++ "tailwindcss": "4.1.18" ++ } ++ }, ++ "node_modules/@tailwindcss/postcss/node_modules/postcss": { ++ "version": "8.5.6", ++ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", ++ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", ++ "dev": true, ++ "funding": [ ++ { ++ "type": "opencollective", ++ "url": "https://opencollective.com/postcss/" ++ }, ++ { ++ "type": "tidelift", ++ "url": "https://tidelift.com/funding/github/npm/postcss" ++ }, ++ { ++ "type": "github", ++ "url": "https://github.com/sponsors/ai" ++ } ++ ], ++ "license": "MIT", ++ "dependencies": { ++ "nanoid": "^3.3.11", ++ "picocolors": "^1.1.1", ++ "source-map-js": "^1.2.1" ++ }, ++ "engines": { ++ "node": "^10 || ^12 || >=14" ++ } ++ }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", +@@ -1149,12 +1755,26 @@ + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", ++ "devOptional": true, + "license": "Apache-2.0", +- "optional": true, + "engines": { + "node": ">=8" + } + }, ++ "node_modules/enhanced-resolve": { ++ "version": "5.19.0", ++ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", ++ "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", ++ "dev": true, ++ "license": "MIT", ++ "dependencies": { ++ "graceful-fs": "^4.2.4", ++ "tapable": "^2.3.0" ++ }, ++ "engines": { ++ "node": ">=10.13.0" ++ } ++ }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", +@@ -1225,6 +1845,53 @@ + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, ++ "node_modules/graceful-fs": { ++ "version": "4.2.11", ++ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", ++ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", ++ "dev": true, ++ "license": "ISC" ++ }, ++ "node_modules/jiti": { ++ "version": "2.6.1", ++ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", ++ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", ++ "dev": true, ++ "license": "MIT", ++ "bin": { ++ "jiti": "lib/jiti-cli.mjs" ++ } ++ }, ++ "node_modules/lightningcss-win32-x64-msvc": { ++ "version": "1.30.2", ++ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", ++ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", ++ "cpu": [ ++ "x64" ++ ], ++ "dev": true, ++ "license": "MPL-2.0", ++ "os": [ ++ "win32" ++ ], ++ "engines": { ++ "node": ">= 12.0.0" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/parcel" ++ } ++ }, ++ "node_modules/magic-string": { ++ "version": "0.30.21", ++ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", ++ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", ++ "dev": true, ++ "license": "MIT", ++ "dependencies": { ++ "@jridgewell/sourcemap-codec": "^1.5.5" ++ } ++ }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", +@@ -1457,6 +2124,27 @@ + } + } + }, ++ "node_modules/tailwindcss": { ++ "version": "4.1.18", ++ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", ++ "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", ++ "dev": true, ++ "license": "MIT" ++ }, ++ "node_modules/tapable": { ++ "version": "2.3.0", ++ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", ++ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", ++ "dev": true, ++ "license": "MIT", ++ "engines": { ++ "node": ">=6" ++ }, ++ "funding": { ++ "type": "opencollective", ++ "url": "https://opencollective.com/webpack" ++ } ++ }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", +diff --git a/package.json b/package.json +index d74369c..f4adadb 100644 +--- a/package.json ++++ b/package.json +@@ -9,7 +9,7 @@ + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit", +- "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs" ++ "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs" + }, + "dependencies": { + "next": "15.5.7", +@@ -17,9 +17,12 @@ + "react-dom": "19.2.1" + }, + "devDependencies": { ++ "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", ++ "lightningcss-win32-x64-msvc": "^1.30.2", ++ "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", + "typescript": "^5.7.2" + } +diff --git a/src/app/layout.tsx b/src/app/layout.tsx +index dd3008b..1d731f8 100644 +--- a/src/app/layout.tsx ++++ b/src/app/layout.tsx +@@ -1,5 +1,18 @@ + import type { Metadata } from 'next'; + import type { ReactNode } from 'react'; ++import { DM_Sans, JetBrains_Mono } from 'next/font/google'; ++ ++import './globals.css'; ++ ++const sans = DM_Sans({ ++ subsets: ['latin'], ++ variable: '--font-sans', ++}); ++ ++const mono = JetBrains_Mono({ ++ subsets: ['latin'], ++ variable: '--font-mono', ++}); + + export const metadata: Metadata = { + title: 'BeadBoard', +@@ -9,7 +22,7 @@ export const metadata: Metadata = { + export default function RootLayout({ children }: { children: ReactNode }) { + return ( + +- {children} ++ {children} + + ); + } +diff --git a/src/components/kanban/kanban-board.tsx b/src/components/kanban/kanban-board.tsx +index 34eea9d..8d61fa0 100644 +--- a/src/components/kanban/kanban-board.tsx ++++ b/src/components/kanban/kanban-board.tsx +@@ -12,22 +12,23 @@ interface KanbanBoardProps { + } + + export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) { ++ const statusTone: Record<(typeof KANBAN_STATUSES)[number], string> = { ++ open: 'text-status-open', ++ in_progress: 'text-status-in-progress', ++ blocked: 'text-status-blocked', ++ deferred: 'text-status-deferred', ++ closed: 'text-status-closed', ++ }; ++ + return ( +-
++
+ {KANBAN_STATUSES.map((status) => ( +-
+-
+- {status} +- {columns[status].length} ++
++
++ {status.replace('_', ' ')} ++ {columns[status].length} +
+-
++
+ {columns[status].map((issue) => ( + + ))} +diff --git a/src/components/kanban/kanban-card.tsx b/src/components/kanban/kanban-card.tsx +index 04df80b..51b5924 100644 +--- a/src/components/kanban/kanban-card.tsx ++++ b/src/components/kanban/kanban-card.tsx +@@ -11,32 +11,32 @@ interface KanbanCardProps { + } + + export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { ++ const priorityTone: Record = { ++ 0: 'text-priority-0', ++ 1: 'text-priority-1', ++ 2: 'text-priority-2', ++ 3: 'text-priority-3', ++ 4: 'text-priority-4', ++ }; ++ + return ( + + ); +} + +/** + * Renders a section header with a count badge. + */ +function SectionHeader({ label, count, color }: { label: string; count: number; color: string }) { + return ( +
+ {label} + {count} +
+ ); +} + +/** + * Renders the dependency flow as three responsive sections stacked vertically: + * Blocked By, Selected/Focus, and Blocks (Dependents). + * Each section uses a responsive wrapping grid so cards never overflow. + * On larger screens the three sections sit side-by-side; on smaller screens they stack. + */ +import { useState } from 'react'; + +// ... (FlowCardProps, DependencyFlowStripProps, statusDot, FlowCard, SectionHeader definitions remain unchanged) + +/** + * Renders the dependency flow as three responsive sections stacked vertically: + * Blocked By, Selected/Focus, and Blocks (Dependents). + * Each section uses a responsive wrapping grid so cards never overflow. + * On larger screens the three sections sit side-by-side; on smaller screens they stack. + */ +export function DependencyFlowStrip({ workspace, selectedId, signalById, onSelect }: DependencyFlowStripProps) { + const [minimized, setMinimized] = useState(false); + + // Flatten the multi-hop blocker/dependent arrays for display + const blockerNodes = workspace.blockers.flat(); + const dependentNodes = workspace.dependents.flat(); + + return ( +
+
+

+ Dependency Flow +

+ +
+ + {/* Responsive three-column layout: stacks on mobile, side-by-side on desktop */} + {!minimized && ( +
+ {/* Blocked By section */} +
+ + {blockerNodes.length > 0 ? ( +
+ {blockerNodes.map((node) => ( + + ))} +
+ ) : ( +
+

No blockers

+
+ )} +
+ + {/* Selected / Focused task section */} +
+ + {workspace.focus ? ( + + ) : ( +
+

Select a task

+
+ )} +
+ + {/* Blocks (Dependents) section */} +
+ + {dependentNodes.length > 0 ? ( +
+ {dependentNodes.map((node) => ( + + ))} +
+ ) : ( +
+

No dependents

+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/graph/dependency-graph-page.tsx b/src/components/graph/dependency-graph-page.tsx new file mode 100644 index 0000000..9e9aed8 --- /dev/null +++ b/src/components/graph/dependency-graph-page.tsx @@ -0,0 +1,921 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + MarkerType, + Position, + ReactFlowProvider, + type Edge, + type Node, + type NodeMouseHandler, + type NodeTypes, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import dagre from 'dagre'; + +import { EpicChipStrip } from './epic-chip-strip'; +import { WorkflowTabs, type WorkflowTab } from './workflow-tabs'; +import { TaskCardGrid, type BlockerDetail } from './task-card-grid'; +import { TaskDetailsDrawer } from './task-details-drawer'; +import { DependencyFlowStrip } from './dependency-flow-strip'; +import { GraphNodeCard, type GraphNodeData } from './graph-node-card'; +import { GraphSection } from './graph-section'; +import { ProjectScopeControls } from '../shared/project-scope-controls'; + +import { buildGraphModel, type GraphNode } from '../../lib/graph'; +import { + buildPathWorkspace, + type GraphHopDepth, + analyzeBlockedChain, + detectDependencyCycles, +} from '../../lib/graph-view'; +import { buildBlockedByTree, type BlockedTreeNode } from '../../lib/kanban'; +import { type BeadIssue } from '../../lib/types'; +import type { ProjectScopeOption } from '../../lib/project-scope'; + +/** Props for the DependencyGraphPage component. */ +interface DependencyGraphPageProps { + /** All issues in the project. */ + issues: BeadIssue[]; + /** The project root key for graph model construction. */ + projectRoot: string; + /** URL scope key (local or registry key). */ + projectScopeKey: string; + /** Available scope options for context rendering. */ + projectScopeOptions: ProjectScopeOption[]; + /** Scope mode selection from URL (single/aggregate). */ + projectScopeMode: 'single' | 'aggregate'; +} + +/** Available hop depth values for the depth selector. */ +const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full']; + + +/** + * Positions nodes using the Dagre graph layout engine. + * This respects dependency direction (Left-to-Right) and creates a true flowchart. + */ +function layoutDagre(nodes: Node[], edges: Edge[]): Node[] { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + + // Set layout direction: 'LR' = Left-to-Right (Blocker -> Blocked) + dagreGraph.setGraph({ rankdir: 'LR' }); + + // Node dimensions (must match Card dimensions + some padding?) + // Card is ~280x120? + // We can be precise or approximate. + const nodeWidth = 320; + const nodeHeight = 150; + + for (const node of nodes) { + dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); + } + + for (const edge of edges) { + dagreGraph.setEdge(edge.source, edge.target); + } + + dagre.layout(dagreGraph); + + // Apply positions back to nodes + // Dagre gives center coordinates (x, y). ReactFlow expects top-left? + // ReactFlow handles position as top-left by default. + // Wait, Dagre node `x,y` is the CENTER of the node? + // Let's check docs or common knowledge. Yes, Dagre usually returns center. + // ReactFlow nodes position is Top-Left. + return nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + return { + ...node, + position: { + x: nodeWithPosition.x - nodeWidth / 2, + y: nodeWithPosition.y - nodeHeight / 2, + }, + }; + }); +} + +/** + * Main Workflow Explorer page component. + * Provides a tabbed interface for browsing tasks and visualizing dependencies. + * + * Layout structure: + * - Header: title + navigation + * - Toolbar: hop depth, filters, epic chips, tab switcher + * - Tasks tab: responsive card grid + details drawer + * - Dependencies tab: flow strip + ReactFlow graph + */ +export function DependencyGraphPage({ + issues, + projectRoot, + projectScopeKey, + projectScopeOptions, + projectScopeMode, +}: DependencyGraphPageProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + // --- State --- + const [selectedEpicId, setSelectedEpicId] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [depth, setDepth] = useState(2); + const [hideClosed, setHideClosed] = useState(false); + const [showBlockingOnly, setShowBlockingOnly] = useState(false); + const [showFilters, setShowFilters] = useState(false); + const [activeTab, setActiveTab] = useState('tasks'); + const [drawerOpen, setDrawerOpen] = useState(false); + // Task-specific: sort ready (actionable) tasks to the top + const [sortReadyFirst, setSortReadyFirst] = useState(true); + // Mobile panel toggle (preserved for mobile responsiveness) + const [mobilePanel, setMobilePanel] = useState<'overview' | 'flow'>('overview'); + const requestedEpicId = searchParams.get('epic'); + const requestedTaskId = searchParams.get('task'); + const requestedTab = searchParams.get('tab'); + const kanbanHref = useMemo(() => { + const params = new URLSearchParams(); + if (projectScopeMode !== 'single') { + params.set('mode', projectScopeMode); + } + if (projectScopeKey !== 'local') { + params.set('project', projectScopeKey); + } + const query = params.toString(); + return query ? `/?${query}` : '/'; + }, [projectScopeKey, projectScopeMode]); + const activeScope = useMemo( + () => projectScopeOptions.find((option) => option.key === projectScopeKey) ?? projectScopeOptions[0] ?? null, + [projectScopeKey, projectScopeOptions], + ); + + // --- Derived data: epics --- + const epics = useMemo( + () => + issues + .filter((issue) => issue.issue_type === 'epic') + .sort((a, b) => { + // Push closed epics to the end + if (a.status === 'closed' && b.status !== 'closed') return 1; + if (b.status === 'closed' && a.status !== 'closed') return -1; + return a.id.localeCompare(b.id); + }), + [issues], + ); + + // --- Derived data: tasks grouped by parent epic --- + const tasksByEpic = useMemo(() => { + const map = new Map(); + // Initialize empty arrays for each epic + for (const epic of epics) { + map.set(epic.id, []); + } + + // Assign each non-epic issue to its parent epic + for (const issue of issues) { + if (issue.issue_type === 'epic') continue; + const parentDep = issue.dependencies.find((dep) => dep.type === 'parent'); + const candidateEpicId = parentDep?.target ?? (issue.id.includes('.') ? issue.id.split('.')[0] : null); + if (candidateEpicId && map.has(candidateEpicId)) { + map.get(candidateEpicId)?.push(issue); + } + } + + // Sort tasks within each epic: filter by closed status, then by priority + for (const [epicId, children] of map.entries()) { + map.set( + epicId, + children + .filter((x) => (!hideClosed ? true : x.status !== 'closed')) + .sort((a, b) => { + const priorityDiff = a.priority - b.priority; + if (priorityDiff !== 0) return priorityDiff; + return a.id.localeCompare(b.id); + }), + ); + } + return map; + }, [epics, hideClosed, issues]); + + const beadCounts = useMemo(() => { + const counts = new Map(); + for (const epic of epics) { + counts.set(epic.id, tasksByEpic.get(epic.id)?.length ?? 0); + } + return counts; + }, [epics, tasksByEpic]); + + // --- Derived: Map task ID to its Epic (for easy lookup) --- + const epicByTaskId = useMemo(() => { + const map = new Map(); + // Iterate tasksByEpic map + for (const [epicId, tasks] of tasksByEpic.entries()) { + const epic = epics.find((e) => e.id === epicId); + if (!epic) continue; + for (const t of tasks) { + map.set(t.id, epic); + } + } + return map; + }, [epics, tasksByEpic]); + + // --- Auto-select first epic if none selected --- + useEffect(() => { + if (epics.length === 0) { + if (selectedEpicId !== null) { + setSelectedEpicId(null); + } + return; + } + + const hasSelectedEpic = selectedEpicId ? epics.some((epic) => epic.id === selectedEpicId) : false; + if (!hasSelectedEpic) { + setSelectedEpicId(epics[0].id); + } + }, [epics, selectedEpicId]); + + useEffect(() => { + if (requestedTab === 'tasks' || requestedTab === 'dependencies') { + setActiveTab(requestedTab); + } + }, [requestedTab]); + + useEffect(() => { + if (!requestedEpicId) return; + if (!epics.some((epic) => epic.id === requestedEpicId)) return; + setSelectedEpicId(requestedEpicId); + }, [epics, requestedEpicId]); + + useEffect(() => { + if (!requestedTaskId) { + return; + } + if (!issues.some((issue) => issue.id === requestedTaskId)) { + return; + } + setSelectedId(requestedTaskId); + }, [issues, requestedTaskId]); + + // If project scope changes and the selected task no longer exists, reset selection. + useEffect(() => { + if (!selectedId) { + return; + } + if (!issues.some((issue) => issue.id === selectedId)) { + setSelectedId(null); + } + }, [issues, selectedId]); + + // --- Derived: selected epic and its tasks --- + const selectedEpic = useMemo(() => epics.find((epic) => epic.id === selectedEpicId) ?? null, [epics, selectedEpicId]); + const projectLevelTasks = useMemo( + () => + issues + .filter((issue) => issue.issue_type !== 'epic') + .filter((issue) => (!hideClosed ? true : issue.status !== 'closed')) + .sort((a, b) => { + const priorityDiff = a.priority - b.priority; + if (priorityDiff !== 0) { + return priorityDiff; + } + return a.id.localeCompare(b.id); + }), + [hideClosed, issues], + ); + + const selectedEpicTasks = useMemo(() => { + const epicChildren = selectedEpic ? tasksByEpic.get(selectedEpic.id) ?? [] : []; + if (epicChildren.length > 0) { + return epicChildren; + } + + // Fallback: some projects have tasks but weak/missing parent links. + // Keep the page usable by showing project-level tasks instead of a blank view. + if (projectLevelTasks.length > 0) { + return projectLevelTasks; + } + + // Last-resort fallback: if there are only epics, render epics as selectable items. + return epics.filter((epic) => (!hideClosed ? true : epic.status !== 'closed')); + }, [epics, hideClosed, projectLevelTasks, selectedEpic, tasksByEpic]); + + const selectedEpicHasChildren = useMemo(() => { + if (selectedEpic) { + return (tasksByEpic.get(selectedEpic.id) ?? []).length > 0; + } + return false; + }, [selectedEpic, tasksByEpic]); + + // --- Auto-select best task when epic changes --- + useEffect(() => { + // Keep current selection if it remains visible in the current scope. + if (selectedId && selectedEpicTasks.some((task) => task.id === selectedId)) { + return; + } + const best = selectedEpicTasks.find((task) => task.status !== 'closed') ?? selectedEpicTasks[0] ?? null; + if (best?.id !== selectedId) { + setSelectedId(best?.id ?? null); + } + }, [selectedEpic, selectedEpicTasks, selectedId]); + + // --- Graph model --- + const graphModel = useMemo(() => buildGraphModel(issues, { projectKey: projectRoot }), [issues, projectRoot]); + + // --- Signal map: blocker/blocks counts per issue --- + const signalById = useMemo(() => { + const map = new Map(); + for (const issue of issues) { + const adjacency = graphModel.adjacency[issue.id]; + map.set(issue.id, { + blockedBy: adjacency?.incoming.length ?? 0, + blocks: adjacency?.outgoing.length ?? 0, + }); + } + return map; + }, [graphModel.adjacency, issues]); + + + + // --- Blocker chain analysis for selected node --- + const blockerAnalysis = useMemo(() => { + if (!selectedId) return null; + return analyzeBlockedChain(graphModel, { focusId: selectedId }); + }, [graphModel, selectedId]); + + // --- Cycle detection across the entire graph --- + const cycleAnalysis = useMemo(() => { + return detectDependencyCycles(graphModel); + }, [graphModel]); + const cycleNodeIdSet = useMemo(() => new Set(cycleAnalysis.cycleNodeIds), [cycleAnalysis]); + + // --- Path workspace: blockers and dependents for the selected node --- + const workspace = useMemo( + () => + buildPathWorkspace(graphModel, { + focusId: selectedId, + depth, + hideClosed, + }), + [depth, graphModel, hideClosed, selectedId], + ); + + // --- Currently selected issue object --- + const selectedIssue = useMemo(() => issues.find((issue) => issue.id === selectedId) ?? null, [issues, selectedId]); + + // --- Compute which node IDs are in the selected dependency chain (for dimming) --- + const chainNodeIds = useMemo(() => { + if (!selectedId || !blockerAnalysis) return new Set(); + const ids = new Set([selectedId, ...blockerAnalysis.blockerNodeIds]); + // Also include dependents + for (const node of workspace.dependents.flat()) { + ids.add(node.id); + } + return ids; + }, [selectedId, blockerAnalysis, workspace.dependents]); + + // --- Compute actionable (unblocked) status for each node --- + const actionableNodeIds = useMemo(() => { + const ids = new Set(); + for (const issue of issues) { + if (issue.status === 'closed') continue; + const adjacency = graphModel.adjacency[issue.id]; + if (!adjacency) continue; + // A node is actionable if none of its incoming "blocks" edges come from non-closed nodes + const hasOpenBlocker = adjacency.incoming.some((edge) => { + if (edge.type !== 'blocks') return false; + const sourceNode = issues.find((i) => i.id === edge.source); + return sourceNode ? sourceNode.status !== 'closed' : false; + }); + if (!hasOpenBlocker) { + ids.add(issue.id); + } + } + return ids; + }, [graphModel.adjacency, issues]); + + // --- Sorted epic tasks: optionally sort ready/actionable tasks first --- + const sortedEpicTasks = useMemo(() => { + if (!sortReadyFirst) return selectedEpicTasks; + // Partition: ready (actionable + open) first, then in-progress, then blocked, then closed + return [...selectedEpicTasks].sort((a, b) => { + const aReady = actionableNodeIds.has(a.id) && a.status !== 'closed'; + const bReady = actionableNodeIds.has(b.id) && b.status !== 'closed'; + // Ready tasks bubble to the top + if (aReady && !bReady) return -1; + if (!aReady && bReady) return 1; + // Within same readiness group, keep original priority order + return 0; + }); + }, [selectedEpicTasks, actionableNodeIds, sortReadyFirst]); + + // --- Build blocker tooltip data per node --- + const blockerTooltipMap = useMemo(() => { + const map = new Map(); + for (const issue of issues) { + const adjacency = graphModel.adjacency[issue.id]; + if (!adjacency) continue; + const lines: string[] = []; + for (const edge of adjacency.incoming) { + if (edge.type !== 'blocks') continue; + const source = issues.find((i) => i.id === edge.source); + if (source && source.status !== 'closed') { + lines.push(`${source.id} (${source.status}) - "${source.title}"`); + } + } + map.set(issue.id, lines); + } + return map; + }, [graphModel.adjacency, issues]); + + // --- Detailed blocker info for task cards --- + const blockerDetailsMap = useMemo(() => { + const map = new Map(); + for (const task of selectedEpicTasks) { + const adjacency = graphModel.adjacency[task.id]; + if (!adjacency) continue; + const details: BlockerDetail[] = []; + for (const edge of adjacency.incoming) { + if (edge.type !== 'blocks') continue; + const source = issues.find((i) => i.id === edge.source); + if (source && source.status !== 'closed') { + const sourceEpic = epicByTaskId.get(source.id); + details.push({ + id: source.id, + title: source.title, + status: source.status, + priority: source.priority, + epicTitle: sourceEpic?.title, + }); + } + } + if (details.length > 0) map.set(task.id, details); + } + return map; + }, [graphModel.adjacency, issues, selectedEpicTasks, epicByTaskId]); + + // --- External blocker names for each task (shown inline on nodes) --- + const externalBlockerNames = useMemo(() => { + const epicTaskIds = new Set(selectedEpicTasks.map((t) => t.id)); + const map = new Map(); + + for (const task of selectedEpicTasks) { + const adjacency = graphModel.adjacency[task.id]; + if (!adjacency) continue; + const externalNames: string[] = []; + for (const edge of adjacency.incoming) { + if (edge.type !== 'blocks') continue; + // Only include blockers from OUTSIDE this epic + if (!epicTaskIds.has(edge.source) && edge.source !== selectedEpicId) { + const source = issues.find((i) => i.id === edge.source); + if (source && source.status !== 'closed') { + externalNames.push(`${source.id}: ${source.title}`); + } + } + } + if (externalNames.length > 0) map.set(task.id, externalNames); + } + return map; + }, [graphModel.adjacency, issues, selectedEpicId, selectedEpicTasks]); + + // --- Detailed downstream blocking info for task cards --- + const blocksDetailsMap = useMemo(() => { + const map = new Map(); + for (const task of selectedEpicTasks) { + const adjacency = graphModel.adjacency[task.id]; + if (!adjacency) continue; + const details: BlockerDetail[] = []; + for (const edge of adjacency.outgoing) { + if (edge.type !== 'blocks') continue; + const target = issues.find((i) => i.id === edge.target); + if (target && target.status !== 'closed') { + const targetEpic = epicByTaskId.get(target.id); + details.push({ + id: target.id, + title: target.title, + status: target.status, + priority: target.priority, + epicTitle: targetEpic?.title, + }); + } + } + if (details.length > 0) map.set(task.id, details); + } + return map; + }, [graphModel.adjacency, issues, selectedEpicTasks, epicByTaskId]); + + // --- ReactFlow model: ONLY this epic's tasks in status lanes --- + const flowModel = useMemo(() => { + if (selectedEpicTasks.length === 0) { + return { nodes: [] as Node[], edges: [] as Edge[] }; + } + + // SCOPED: Only the epic's own child tasks (no cross-epic workspace nodes) + const issueById = new Map(issues.map((issue) => [issue.id, issue])); + const visibleTasks = selectedEpicTasks + .filter((issue) => (!hideClosed ? true : issue.status !== 'closed')); + + // Build ReactFlow nodes with our custom GraphNodeData + const baseNodes: Node[] = visibleTasks + .map((issue) => ({ + id: issue.id, + data: { + title: issue.title, + kind: 'issue' as const, + status: issue.status, + priority: issue.priority, + blockedBy: signalById.get(issue.id)?.blockedBy ?? 0, + blocks: signalById.get(issue.id)?.blocks ?? 0, + isActionable: actionableNodeIds.has(issue.id), + isCycleNode: cycleNodeIdSet.has(issue.id), + isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false, + blockerTooltipLines: externalBlockerNames.get(issue.id) ?? blockerTooltipMap.get(issue.id) ?? [], + }, + position: { x: 0, y: 0 }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + type: 'flowNode', + })); + + const visibleIds = new Set(baseNodes.map((node) => node.id)); + const graphEdges: Edge[] = []; + + // Search ALL issues for blocking edges between visible nodes. + // Dependencies may be stored on issues outside visibleTasks but + // still connect two nodes that are both visible in the graph. + for (const issue of issues) { + for (const dep of issue.dependencies) { + // Both endpoints must be visible in the graph + if (!visibleIds.has(issue.id) && !visibleIds.has(dep.target)) continue; + if (!visibleIds.has(issue.id) || !visibleIds.has(dep.target)) continue; + // Only show blocking edges (skip parent, relates_to, etc.) + if (dep.type !== 'blocks') continue; + // Avoid self-loops + if (issue.id === dep.target) continue; + const edgeId = `${issue.id}:blocks:${dep.target}`; + + const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false; + + graphEdges.push({ + id: edgeId, + source: issue.id, + target: dep.target, + className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted', + animated: linkedToSelection, + style: { + stroke: linkedToSelection ? '#7dd3fc' : '#fbbf24', + strokeWidth: linkedToSelection ? 2.5 : 1.8, + opacity: linkedToSelection ? 1 : 0.55, + }, + markerEnd: { type: MarkerType.ArrowClosed, color: linkedToSelection ? '#7dd3fc' : '#fbbf24', width: 14, height: 14 }, + }); + } + } + + return { + nodes: layoutDagre(baseNodes, graphEdges), + edges: graphEdges, + }; + }, [ + hideClosed, issues, selectedEpicTasks, selectedId, + signalById, actionableNodeIds, cycleNodeIdSet, + chainNodeIds, blockerTooltipMap, externalBlockerNames, + ]); + + const nodeTypes: NodeTypes = useMemo( + () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + flowNode: GraphNodeCard as any, + }), + [], + ); + + // --- Handle node click in the graph (also opens detail drawer) --- + const handleFlowNodeClick: NodeMouseHandler = useCallback((_, node) => { + setSelectedId(node.id); + setDrawerOpen(true); + }, []); + + // --- Default edge rendering options --- + const defaultEdgeOptions = useMemo( + () => ({ + type: 'smoothstep' as const, + zIndex: 40, + interactionWidth: 24, + }), + [], + ); + + // --- Handle task selection (opens drawer on Tasks tab) --- + // If the target is in another epic or IS an epic, switch to that epic first. + const handleTaskSelect = useCallback((id: string, shouldOpenDrawer = true) => { + // 1. If task is already visible in current epic view, just select it + if (selectedEpicTasks.some((t) => t.id === id)) { + setSelectedId(id); + if (shouldOpenDrawer) setDrawerOpen(true); + return; + } + + // 2. If the target IS an epic itself, switch to that epic + const targetIsEpic = epics.some((e) => e.id === id); + if (targetIsEpic) { + setSelectedEpicId(id); + // Select the epic itself so the drawer shows its details + setSelectedId(id); + if (shouldOpenDrawer) setDrawerOpen(true); + return; + } + + // 3. Target is a task in another epic -- find which epic owns it + const targetIssue = issues.find((i) => i.id === id); + if (targetIssue) { + // Determine parent epic: explicit parent dependency, or convention (id prefix before first dot) + const parentDep = targetIssue.dependencies.find((dep) => dep.type === 'parent'); + const epicId = parentDep?.target ?? (targetIssue.id.includes('.') ? targetIssue.id.split('.')[0] : null); + + if (epicId && epicId !== selectedEpicId) { + const epicExists = epics.some((e) => e.id === epicId); + if (epicExists) { + // If the target is closed and we are hiding closed tasks, unhide so we can see it + if (targetIssue.status === 'closed' && hideClosed) { + setHideClosed(false); + } + + setSelectedEpicId(epicId); + setSelectedId(id); + if (shouldOpenDrawer) setDrawerOpen(true); + return; + } + } + } + + // 4. Fallback: select the id directly (might be orphan) + setSelectedId(id); + if (shouldOpenDrawer) setDrawerOpen(true); + }, [selectedEpicTasks, selectedEpicId, issues, epics, hideClosed]); + + // --- Handle drawer close --- + const handleDrawerClose = useCallback(() => { + setDrawerOpen(false); + }, []); + + return ( +
+ {/* Page header */} +
+

BeadBoard Workspace

+
+
+

Workflow Explorer

+ + ← Kanban + +
+

+ Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance. +

+
+ {activeScope ? ( +

+ Scope:{' '} + + {activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath} + +

+ ) : null} +
+ +
+
+ + {/* Main content area */} +
+ {/* Toolbar row: epic chips + tab switcher */} +
+ {/* Epic chip strip - shows titles, not just IDs */} +
+ +
+ + {/* Right side: filter toggle + stats + mobile toggle */} +
+ {/* Filters toggle - hides power-user controls behind a button */} + + + {/* Mobile panel toggle */} +
+ +
+
+
+ + {/* Collapsible filters row - tab-aware: different filters per tab */} + {showFilters ? ( +
+ {/* Shared filter: Hide closed */} + + + {/* Tasks-specific filters */} + {activeTab === 'tasks' ? ( + + ) : null} + + {/* Dependencies-specific filters */} + {activeTab === 'dependencies' ? ( + <> +
+ + +
+ + + + ) : null} +
+ ) : null} + + {/* Tab switcher row + selected epic context */} +
+ + {selectedEpic ? ( +
+ {selectedEpic.id} + {selectedEpic.title} + {!selectedEpicHasChildren && projectLevelTasks.length > 0 ? ( + + project tasks fallback + + ) : null} + + {selectedEpicTasks.length} tasks + +
+ ) : null} +
+ + {/* ====== MOBILE LAYOUT ====== */} + {/* Mobile: overview panel (epic selection + task cards + dep flow) */} +
+
+ {/* Epic selector as horizontal scroll */} +
+

1) Select Epic

+
+ +
+
+ + {/* Selected epic info */} +
+

{selectedEpic?.title ?? 'No epic selected'}

+

+ {selectedEpicTasks.length} tasks • {selectedEpic?.status ?? 'unknown'} +

+
+ + {/* Task cards */} +
+

2) Pick Task

+
+ +
+
+ +
+
+ + {/* ====== DESKTOP LAYOUT ====== */} + {/* Desktop: Tasks tab content - use conditional rendering, not Tailwind dynamic classes */} + {activeTab === 'tasks' ? ( +
+ +
+ ) : null} + + {/* Desktop: Dependencies tab content (graph only, no flow strip) */} + {activeTab === 'dependencies' ? ( +
+ {/* Dependency Flow Strip - above graph */} +
+ +
+ + + + +
+ ) : null} + + {/* Mobile: graph panel */} +
+ + + +
+
+ + {/* Task details drawer - slides in from right on task selection */} + router.refresh()} + blockedTree={selectedIssue ? buildBlockedByTree(issues, selectedIssue.id) : undefined} + outgoingBlocks={selectedId ? blocksDetailsMap.get(selectedId) ?? [] : []} + onSelectBlockedIssue={handleTaskSelect} + /> +
+ ); +} diff --git a/src/components/graph/epic-chip-strip.tsx b/src/components/graph/epic-chip-strip.tsx new file mode 100644 index 0000000..fd6da08 --- /dev/null +++ b/src/components/graph/epic-chip-strip.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useState } from 'react'; +import { Chip } from '../shared/chip'; +import type { BeadIssue } from '../../lib/types'; + +/** Props for the EpicChipStrip component. */ +interface EpicChipStripProps { + /** List of all epic issues to display as selectable chips. */ + epics: BeadIssue[]; + /** Currently selected epic ID, or null if none selected. */ + selectedEpicId: string | null; + /** Map of epic ID to total bead (task) count. */ + beadCounts: Map; + /** Callback fired when the user clicks an epic chip. */ + onSelect: (epicId: string) => void; +} + +/** + * Returns the label and color for an epic's status. + */ +function statusStyle(status: BeadIssue['status']): { label: string; dot: string } { + switch (status) { + case 'open': + return { label: 'Open', dot: 'bg-sky-400' }; + case 'in_progress': + return { label: 'In Progress', dot: 'bg-amber-400' }; + case 'blocked': + return { label: 'Blocked', dot: 'bg-rose-500' }; + case 'closed': + return { label: 'Done', dot: 'bg-emerald-400' }; + case 'deferred': + return { label: 'Deferred', dot: 'bg-slate-400' }; + default: + return { label: status, dot: 'bg-zinc-500' }; + } +} + +/** + * Renders an epic selector as a dropdown button that expands an inline selection panel. + * When collapsed: shows the selected epic's title as a button. + * When expanded: shows a horizontal strip of epic cards with ID, title, and status, + * pushing page content down naturally. + */ +export function EpicChipStrip({ epics, selectedEpicId, beadCounts, onSelect }: EpicChipStripProps) { + // Track whether the epic selector panel is expanded + const [expanded, setExpanded] = useState(false); + + // Find the currently selected epic for the button label + const selectedEpic = epics.find((epic) => epic.id === selectedEpicId); + + return ( +
+ {/* Collapsed state: button showing selected epic */} + + + {/* Expanded state: horizontal card strip */} + {expanded ? ( +
+
+ {epics.map((epic) => { + // Determine if this card is the currently selected epic + const isSelected = epic.id === selectedEpicId; + // Closed epics get a muted visual treatment + const isClosed = epic.status === 'closed'; + const style = statusStyle(epic.status); + const count = beadCounts.get(epic.id) ?? 0; + + return ( + + ); + })} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/graph/graph-node-card.tsx b/src/components/graph/graph-node-card.tsx new file mode 100644 index 0000000..cb96b58 --- /dev/null +++ b/src/components/graph/graph-node-card.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useState } from 'react'; +import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; + +import type { BeadIssue } from '../../lib/types'; + +/** Data payload for each custom ReactFlow node. */ +export interface GraphNodeData { + /** Index signature required by ReactFlow's Node> constraint. */ + [key: string]: unknown; + /** Display title of the task/epic. */ + title: string; + /** Whether this is an epic or a regular issue. */ + kind: 'epic' | 'issue'; + /** Current workflow status. */ + status: BeadIssue['status']; + /** Priority level (0 = highest). */ + priority: number; + /** Number of issues blocking this node. */ + blockedBy: number; + /** Number of issues this node blocks. */ + blocks: number; + /** Whether this node has zero open blockers and is actionable. */ + isActionable: boolean; + /** Whether this node is part of a dependency cycle. */ + isCycleNode: boolean; + /** Whether this node should appear dimmed (not in selected chain). */ + isDimmed: boolean; + /** Tooltip lines describing blocker details for hover display. */ + blockerTooltipLines: string[]; +} + +/** + * Returns the Tailwind background color class for a status dot indicator. + */ +function statusDot(status: BeadIssue['status']): string { + switch (status) { + case 'open': + return 'bg-sky-400'; + case 'in_progress': + return 'bg-amber-400'; + case 'blocked': + return 'bg-rose-500'; + case 'deferred': + return 'bg-slate-400'; + case 'closed': + return 'bg-emerald-400'; + case 'pinned': + return 'bg-violet-400'; + case 'hooked': + return 'bg-orange-400'; + default: + return 'bg-zinc-500'; + } +} + +/** + * Returns the base card style class based on the node kind (epic vs issue). + */ +function nodeStyle(kind: GraphNodeData['kind']): string { + return kind === 'epic' + ? 'bg-[linear-gradient(160deg,rgba(56,189,248,0.06),rgba(15,23,42,0.9))] border-sky-400/15' + : 'bg-[linear-gradient(160deg,rgba(255,255,255,0.03),rgba(15,23,42,0.85))] border-white/8'; +} + +/** + * Custom ReactFlow node component with: + * - Status-aware styling (green glow for actionable, red ring for cycles) + * - Hover tooltip showing blocker details or "Ready to work" + * - Pulse animation on selection + * - Dim effect when not in the selected dependency chain + */ +export function GraphNodeCard({ id, data, selected }: NodeProps>) { + // Track hover state for tooltip visibility + const [hovered, setHovered] = useState(false); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Target handle for incoming edges (from the left) */} + + + {/* Main card body */} +
+ {/* Header: ID + priority + status badges */} +
+ {id} +
+ {/* "READY" badge for actionable nodes */} + {data.isActionable ? ( + + Ready + + ) : null} + {/* Status badge: IN PROGRESS, BLOCKED, DONE */} + {data.status === 'in_progress' ? ( + + In Progress + + ) : data.status === 'blocked' ? ( + + Blocked + + ) : data.status === 'closed' ? ( + + Done + + ) : null} + p{data.priority} + +
+
+ + {/* Title - strikethrough for closed tasks */} +

+ {data.title} +

+ + {/* Footer: show blocker names for blocked tasks, click hint for others */} + {data.blockerTooltipLines.length > 0 ? ( +
+

Waiting on

+ {data.blockerTooltipLines.slice(0, 2).map((line) => ( +

+ {line} +

+ ))} + {data.blockerTooltipLines.length > 2 ? ( +

+ +{data.blockerTooltipLines.length - 2} more +

+ ) : null} +
+ ) : null} +
+ + {/* Tooltip: shown on hover with 300ms CSS delay */} + {hovered ? ( +
+
+ {data.isActionable ? ( + <> +

Ready to work

+

+ No open blockers. {data.blocks} task{data.blocks === 1 ? '' : 's'} depend{data.blocks === 1 ? 's' : ''} on this. +

+ + ) : ( + <> +

+ Blocked by {data.blockedBy} task{data.blockedBy === 1 ? '' : 's'} +

+ {data.blockerTooltipLines.length > 0 ? ( +
    + {data.blockerTooltipLines.map((line) => ( +
  • + • {line} +
  • + ))} +
+ ) : null} + + )} +
+
+ ) : null} + + {/* Source handle for outgoing edges (to the right) */} + +
+ ); +} diff --git a/src/components/graph/graph-section.tsx b/src/components/graph/graph-section.tsx new file mode 100644 index 0000000..cfca05c --- /dev/null +++ b/src/components/graph/graph-section.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { + Background, + ReactFlow, + type Edge, + type Node, + type NodeMouseHandler, + type NodeTypes, +} from '@xyflow/react'; + +import type { BlockedChainAnalysis } from '../../lib/graph-view'; +import type { GraphNodeData } from './graph-node-card'; + +/** Props for the GraphSection component. */ +interface GraphSectionProps { + /** ReactFlow nodes with layout positions applied. */ + nodes: Node[]; + /** ReactFlow edges connecting the nodes. */ + edges: Edge[]; + /** Map of custom node type names to their React components. */ + nodeTypes: NodeTypes; + /** Default edge rendering options. */ + defaultEdgeOptions: { + type: 'smoothstep'; + zIndex: number; + interactionWidth: number; + }; + /** Callback fired when a node is clicked in the graph. */ + onNodeClick: NodeMouseHandler; + /** Optional blocker summary for the currently selected task. */ + blockerAnalysis?: BlockedChainAnalysis | null; + /** Whether closed items are hidden from the graph workspace. */ + hideClosed?: boolean; +} + +/** + * Renders the ReactFlow graph with status-lane layout. + * Shows a compact legend and full graph viewport. + * Nodes are positioned in columns by status: Done | In Progress | Ready | Blocked. + */ +export function GraphSection({ + nodes, + edges, + nodeTypes, + defaultEdgeOptions, + onNodeClick, + blockerAnalysis, + hideClosed = false, +}: GraphSectionProps) { + return ( +
+ {/* Compact legend + tip */} +
+

+ Legend + {' '} + {!hideClosed ? ( + <> + Done + {' \u2192 '} + + ) : null} + In Progress + {' \u2192 '} + Ready + {' \u2192 '} + Blocked +

+

+ Click a task to see details •{' '} + = blocks +

+ {blockerAnalysis ? ( +

+ Open blockers: {blockerAnalysis.openBlockerCount} + {' | '} + In progress blockers: {blockerAnalysis.inProgressBlockerCount} +

+ ) : null} +
+ + {/* ReactFlow graph viewport */} +
+ + + +
+
+ ); +} diff --git a/src/components/graph/task-card-grid.tsx b/src/components/graph/task-card-grid.tsx new file mode 100644 index 0000000..7ce2398 --- /dev/null +++ b/src/components/graph/task-card-grid.tsx @@ -0,0 +1,366 @@ +'use client'; + +import type { BeadIssue } from '../../lib/types'; + +/** Props for an individual task card in the grid. */ +/** Details for a blocker task shown on the card. */ +export interface BlockerDetail { + id: string; + title: string; + status: BeadIssue['status']; + priority: BeadIssue['priority']; + epicTitle?: string; +} + +/** Props for an individual task card in the grid. */ +interface TaskCardProps { + /** The issue data for this card. */ + issue: BeadIssue; + /** Whether this card is the currently selected task. */ + selected: boolean; + /** Number of issues blocking this task. */ + blockedBy: number; + /** Number of issues this task blocks. */ + blocks: number; + /** List of issues blocking this task. */ + blockers: BlockerDetail[]; + /** List of issues this task blocks. */ + blocking: BlockerDetail[]; + /** Whether this task is actionable (unblocked). */ + isActionable: boolean; + /** Callback fired when the user clicks this card (or a blocker). */ + onSelect: (id: string, shouldOpenDrawer?: boolean) => void; +} + +/** Props for the TaskCardGrid component. */ +interface TaskCardGridProps { + /** List of tasks to display in the grid. */ + tasks: BeadIssue[]; + /** ID of the currently selected task, or null. */ + selectedId: string | null; + /** Map of issue ID to blocker/blocks counts. */ + signalById: Map; + /** Map of issue ID to detailed blocker info. */ + blockerDetailsMap: Map; + /** Map of issue ID to detailed downstream blocking info. */ + blocksDetailsMap: Map; + /** Set of actionable (unblocked) task IDs. */ + actionableIds: Set; + /** Callback fired when the user selects a task. */ + onSelect: (id: string, shouldOpenDrawer?: boolean) => void; +} + +/** + * Returns the Tailwind background color class for a status dot indicator. + * Mirrors the statusDot function from the original monolith. + */ +function statusDot(status: BeadIssue['status']): string { + switch (status) { + case 'open': + return 'bg-sky-400'; + case 'in_progress': + return 'bg-amber-400'; + case 'blocked': + return 'bg-rose-500'; + case 'deferred': + return 'bg-slate-400'; + case 'closed': + return 'bg-emerald-400'; + case 'pinned': + return 'bg-violet-400'; + case 'hooked': + return 'bg-orange-400'; + default: + return 'bg-zinc-500'; + } +} + +/** + * Returns a human-friendly label and text color class for a status. + */ +function statusBadge(status: BeadIssue['status'], isActionable: boolean, hasBlockers: boolean): { label: string; textColor: string; bgColor: string } { + // If effectively blocked (has open blockers), show Blocked (unless closed/done) + if (hasBlockers && status !== 'closed' && status !== 'in_progress') { + return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' }; + } + + // Special case: "Blocked Now Open" -> Ready + if (status === 'blocked' && isActionable) { + return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' }; + } + + switch (status) { + case 'in_progress': + return { label: 'In Progress', textColor: 'text-amber-400', bgColor: 'bg-amber-400/10' }; + case 'blocked': + return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' }; + case 'closed': + return { label: 'Done', textColor: 'text-emerald-400', bgColor: 'bg-emerald-400/10' }; + case 'deferred': + return { label: 'Deferred', textColor: 'text-slate-400', bgColor: 'bg-slate-400/10' }; + case 'open': + // Open with no blockers -> Ready + return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' }; + default: + return { label: status, textColor: 'text-zinc-400', bgColor: 'bg-zinc-400/10' }; + } +} + +/** + * Returns a card-level border class based on status for visual distinction. + */ +function statusBorder(status: BeadIssue['status'], isActionable: boolean, hasBlockers: boolean): string { + if (hasBlockers && status !== 'closed' && status !== 'in_progress') { + return 'border-l-2 border-l-rose-500/60'; + } + if (status === 'blocked' && isActionable) { + return 'border-l-2 border-l-cyan-400/60'; + } + if (status === 'open') { + return 'border-l-2 border-l-cyan-400/60'; + } + switch (status) { + case 'in_progress': + return 'border-l-2 border-l-amber-400/60'; + case 'blocked': + return 'border-l-2 border-l-rose-500/60'; + case 'closed': + return 'border-l-2 border-l-emerald-400/40 opacity-60'; + default: + return ''; + } +} + +/** + * A single task card displaying the issue ID, title, priority, type, assignee, + * and detailed blocker list (interactive). + */ +function TaskCard({ issue, selected, blockedBy, blocks, blockers, blocking, isActionable, onSelect }: TaskCardProps) { + const hasBlockers = blockers.length > 0; // Note: blockers list only contains OPEN blockers (computed in page) + const badge = statusBadge(issue.status, isActionable, hasBlockers); + const projectName = (issue as BeadIssue & { project?: { name?: string } }).project?.name ?? null; + + return ( +
onSelect(issue.id, false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(issue.id, false); + } + }} + className={`workflow-card group relative flex w-full flex-col rounded-xl px-4 py-4 text-left transition duration-200 ${statusBorder( + issue.status, + isActionable, + hasBlockers, + )} ${selected + ? 'workflow-card-selected' + : 'hover:border-sky-300/40 hover:bg-[linear-gradient(165deg,rgba(76,94,134,0.2),rgba(18,20,30,0.84))]' + }`} + > + {/* Expand / Open Drawer Button */} + + +
+
+
+ + {issue.id} + {/* Status Badge */} + + {badge.label} + +
+ {projectName ? ( +
+ project: {projectName} +
+ ) : null} +

+ {issue.title} +

+
+
+ + {/* Labels */} + {issue.labels?.length > 0 ? ( +
+ {issue.labels.map((label) => ( + + {label} + + ))} +
+ ) : null} + + {/* "Waiting On" section for blockers */} + {blockers.length > 0 ? ( +
+
+

Waiting On

+
+ {blockers.map((blocker) => ( +
{ + e.stopPropagation(); + onSelect(blocker.id, false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + onSelect(blocker.id, false); + } + }} + className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors" + > + {/* Expand Button */} + + +
+ + {blocker.id} + {blocker.title} +
+ {blocker.epicTitle ? ( +
+ ↳ {blocker.epicTitle} +
+ ) : null} +
+ ))} +
+
+
+ ) : null} + + {/* "Blocking" section (downstream) */} + {blocking.length > 0 ? ( +
0 ? 'mt-2' : 'mt-auto'} w-full`}> +
+

Blocking

+
+ {blocking.map((item) => ( +
{ + e.stopPropagation(); + onSelect(item.id, false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + onSelect(item.id, false); + } + }} + className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors" + > + {/* Expand Button */} + + +
+ + {item.id} + {item.title} +
+ {item.epicTitle ? ( +
+ ↳ {item.epicTitle} +
+ ) : null} +
+ ))} +
+
+
+ ) : null} + + {/* Footer Metadata: Assignee, Due Date */} +
+
+ {/* Assignee */} +
+ + {issue.assignee ?? 'Unassigned'} +
+ {/* Due Date (if exists) */} + {issue.due_at ? ( +
+ + {new Date(issue.due_at as string).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} +
+ ) : null} +
+
+
+ ); +} + +/** + * Renders a responsive grid of task cards. + * Uses auto-fill with minmax to prevent cards from being too narrow to read. + */ +export function TaskCardGrid({ tasks, selectedId, signalById, blockerDetailsMap, blocksDetailsMap, actionableIds, onSelect }: TaskCardGridProps) { + // Show an empty state when no tasks exist in the selected epic + if (tasks.length === 0) { + return ( +
+

No tasks in this epic

+
+ ); + } + + return ( +
+ {tasks.map((task) => ( + + ))} +
+ ); +} diff --git a/src/components/graph/task-details-drawer.tsx b/src/components/graph/task-details-drawer.tsx new file mode 100644 index 0000000..978fe94 --- /dev/null +++ b/src/components/graph/task-details-drawer.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +import type { BeadIssue } from '../../lib/types'; +import type { BlockedTreeNode } from '../../lib/kanban'; +import { KanbanDetail } from '../kanban/kanban-detail'; + +/** Props for the TaskDetailsDrawer component. */ +interface TaskDetailsDrawerProps { + /** The issue to display, or null if nothing is selected. */ + issue: BeadIssue | null; + /** Whether the drawer is open (visible). */ + open: boolean; + /** Callback fired when the user closes the drawer. */ + onClose: () => void; + /** Project root for mutation requests. */ + projectRoot?: string; + /** Whether editing is enabled for the drawer. */ + editable?: boolean; + /** Callback fired after successful save. */ + onIssueUpdated?: (issueId: string) => Promise | void; + + /** Tree of blocked issues (incoming). */ + blockedTree?: { total: number; nodes: BlockedTreeNode[] }; + /** List of issues blocked by this one (outgoing). */ + outgoingBlocks?: { id: string; title: string; status: string }[]; + /** Callback when a blocked/blocking issue is clicked. */ + onSelectBlockedIssue?: (issueId: string) => void; +} + +/** + * A slide-in drawer panel from the right side that shows full task details. + * Opens when a task is selected, closes via the X button or clicking the backdrop. + * Uses CSS translate for the slide animation. + */ +export function TaskDetailsDrawer({ + issue, + open, + onClose, + projectRoot, + editable = true, + onIssueUpdated, + blockedTree, + outgoingBlocks, + onSelectBlockedIssue +}: TaskDetailsDrawerProps) { + // Reference for the drawer panel to manage focus trapping + const drawerRef = useRef(null); + + // Close drawer on Escape key press + useEffect(() => { + if (!open) return; + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + onClose(); + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); + + return ( + <> + {/* Backdrop overlay - click to close */} +