# DSH Kanban Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Rebuild `@local/dsh-kanban` as a native DSH plugin with Host persistence, a `/kanban-api` HTTP bridge, and the agreed four-column task board. **Architecture:** A Host plugin opens a `storageDomain` domain and owns all mutations through `KanbanStore`; a same-origin `/kanban-api` route exposes JSON endpoints. A React Client page registers in the `main` and `sidebar.panellist` slots. The Client uses a small fetch wrapper and an optimistic store. Generated Host/Client bundles are committed. **Tech Stack:** Node.js 22, DSH Cordis, `@deepseek-ai/dsh-storage-domain`, `zod`, React 18, esbuild, Node test runner. **Spec:** `docs/superpowers/specs/2026-10-21-dsh-kanban-design.md` ## Global Constraints - No iframe, no external Taskboard service, no SQLite. - Persistence uses `storageDomain`; localStorage is allowed only for collapsed-column UI state. - Stored statuses are `backlog`, `todo`, `in_progress`, `in_review`, `done`, `canceled`. - Blocking is `blockedReason` + `blockedSince`, not a stored status. - `Needs you` is derived; it cannot be a drop target. - Archived projects are read-only and restorable. - API base path is `/kanban-api`; all responses use `Cache-Control: no-store`. - Task mutations require the current `version`. - UI uses only DSH `--dsw-alias-*` theme tokens for color. - Generated `index.js` and `client.js` are committed; installation never builds. - Every code task follows red-green TDD. Run the failing test before implementation. - Commit after every task. --- ### Task 1: Scaffold the plugin and build pipeline **Files:** - Create: `package.json` - Create: `cordis.patch.yml` - Create: `scripts/build.mjs` - Create: `src/host/entry.js` - Create: `src/client/index.jsx` - Create: `src/client/styles.css` - Keep: `icon.svg`, `locale/en.json`, `locale/zh.json` **Interfaces:** - Produces: `pnpm build` → `index.js` (Host ESM) and `client.js` (module-loader bundle) - Produces: `pnpm test` → Node test runner - [ ] **Step 1: Confirm the isolated worktree baseline** ```bash git status --short --branch git log --oneline -2 ``` Expected: the implementation branch is checked out; only spec, plan and `.gitignore` are tracked. The old prototype remains in the main working tree and is not part of this branch. - [ ] **Step 2: Write `package.json`** ```json { "name": "@local/dsh-kanban", "version": "2.0.0", "private": true, "type": "module", "description": "Native task board for DeepSeek Harness.", "exports": { ".": "./index.js", "./client": "./client.js", "./locale/*.json": "./locale/*.json", "./icon": "./icon.svg" }, "files": [ "index.js", "client.js", "cordis.patch.yml", "locale/*.json", "icon.svg", "src/**/*.js", "src/**/*.jsx", "src/**/*.css" ], "scripts": { "build": "node scripts/build.mjs", "test": "node --test", "check": "pnpm build && pnpm test" }, "devDependencies": { "esbuild": "^0.25.0", "zod": "^4.4.3" }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "platform": "web", "immediately": true, "inject": ["@deepseek-ai/dsh-client-ui-layout"] } } } ``` - [ ] **Step 3: Write `cordis.patch.yml`** ```yaml - insert: - id: dsh-kanban name: '@local/dsh-kanban' ``` - [ ] **Step 4: Write `scripts/build.mjs`** ```js import { build } from 'esbuild' import { mkdir, readFile, writeFile } from 'node:fs/promises' await build({ entryPoints: ['src/host/entry.js'], outfile: 'index.js', bundle: true, platform: 'node', format: 'esm', target: 'node22', external: ['@deepseek-ai/*'], logLevel: 'info', }) const pkg = JSON.parse(await readFile('package.json', 'utf8')) const client = await build({ entryPoints: ['src/client/index.jsx'], bundle: true, write: false, platform: 'browser', format: 'cjs', target: ['chrome120'], external: ['react'], jsx: 'transform', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment', loader: { '.css': 'text' }, logLevel: 'info', }) const code = client.outputFiles[0].text const handoff = `window.__ModuleLoader__.load({ id: ${JSON.stringify(pkg.name)}, factory(require) { var module = { exports: {} }; var exports = module.exports; ${code} return module.exports; }, }); ` await writeFile('client.js', handoff) ``` - [ ] **Step 5: Write minimal Host entry** ```js export const inject = ['webServer', 'storageDomain', 'attachments'] export async function apply() {} ``` - [ ] **Step 6: Write minimal Client entry** ```jsx import React from 'react' export const inject = ['slots', 'locale'] export function apply() {} ``` - [ ] **Step 7: Write an empty stylesheet** ```css .dkb { box-sizing: border-box; } ``` - [ ] **Step 8: Install dependencies and build** Run: ```bash pnpm install pnpm build node --check index.js node --check client.js ``` Expected: both files are emitted, `node --check` exits 0, and `client.js` starts with `window.__ModuleLoader__.load`. - [ ] **Step 9: Commit** ```bash git add package.json cordis.patch.yml scripts/build.mjs src git commit -m "chore: scaffold native kanban plugin" ``` --- ### Task 2: Shared statuses and attention rules **Files:** - Create: `src/shared/statuses.js` - Create: `src/shared/rules.js` - Test: `src/shared/__tests__/rules.test.js` **Interfaces:** - Produces: `STATUSES`, `PRIORITIES`, `STATUS_LABELS`, `PRIORITY_LABELS`, `COLUMN_IDS` - Produces: `attentionReason(task)`, `columnForTask(task)`, `canDrop(task, columnId)`, `elapsedStart(task)`, `nextIdentifier(project, tasks)` - [ ] **Step 1: Write the failing tests** ```js import test from 'node:test' import assert from 'node:assert/strict' import { attentionReason, columnForTask, canDrop, elapsedStart, nextIdentifier, } from '../rules.js' const base = { id: 't1', projectId: 'dsh-kanban', identifier: 'DSH-1', title: 'task', status: 'in_progress', archivedAt: null, blockedReason: null, blockedSince: null, pendingQuestion: null, questionResolution: null, statusSince: '2026-10-21T00:00:00.000Z', } test('question outranks blocker and review', () => { const task = { ...base, status: 'in_review', blockedReason: 'waiting', pendingQuestion: { createdAt: '2026-10-21T01:00:00.000Z' }, } assert.equal(attentionReason(task), 'question') assert.equal(columnForTask(task), 'needs') assert.equal(elapsedStart(task), '2026-10-21T01:00:00.000Z') }) test('blocker outranks review and uses blockedSince', () => { const task = { ...base, status: 'in_review', blockedReason: 'waiting', blockedSince: '2026-10-21T02:00:00.000Z', } assert.equal(attentionReason(task), 'blocked') assert.equal(elapsedStart(task), '2026-10-21T02:00:00.000Z') }) test('review is attention and uses statusSince', () => { const task = { ...base, status: 'in_review' } assert.equal(attentionReason(task), 'review') assert.equal(elapsedStart(task), base.statusSince) }) test('normal columns map to backlog, running and done', () => { assert.equal(columnForTask({ ...base, status: 'backlog' }), 'backlog') assert.equal(columnForTask({ ...base, status: 'todo' }), 'backlog') assert.equal(columnForTask(base), 'running') assert.equal(columnForTask({ ...base, status: 'done' }), 'done') assert.equal(columnForTask({ ...base, status: 'canceled' }), null) }) test('attention tasks cannot leave Needs you and Needs you cannot be a target', () => { const attention = { ...base, status: 'in_review' } assert.equal(canDrop(attention, 'done').ok, false) assert.equal(canDrop(base, 'needs').ok, false) assert.equal(canDrop(base, 'done').ok, true) }) test('identifier uses the higher of nextTaskNumber and existing max', () => { const project = { id: 'dsh-kanban', name: 'DSH 看板', taskPrefix: 'DSH', nextTaskNumber: 2 } const tasks = [{ identifier: 'DSH-7' }, { identifier: 'DSH-3' }] assert.equal(nextIdentifier(project, tasks), 'DSH-8') }) ``` - [ ] **Step 2: Run the test and watch it fail** ```bash node --test src/shared/__tests__/rules.test.js ``` Expected: `ERR_MODULE_NOT_FOUND` for `../rules.js`. - [ ] **Step 3: Implement `src/shared/statuses.js`** ```js export const STATUSES = [ 'backlog', 'todo', 'in_progress', 'in_review', 'done', 'canceled', ] export const PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent'] export const COLUMN_IDS = ['backlog', 'running', 'needs', 'done'] export const STATUS_LABELS = { backlog: '待立项', todo: '等待认领', in_progress: '处理中', in_review: '等你确认', done: '完成', canceled: '取消', } export const PRIORITY_LABELS = { none: '无', low: '低', medium: '中', high: '高', urgent: '紧急', } ``` - [ ] **Step 4: Implement `src/shared/rules.js`** ```js export function attentionReason(task) { if (task.archivedAt) return null if (task.pendingQuestion && !task.questionResolution) return 'question' if (task.blockedReason) return 'blocked' if (task.status === 'in_review') return 'review' return null } export function columnForTask(task) { const attention = attentionReason(task) if (attention) return 'needs' if (task.status === 'backlog' || task.status === 'todo') return 'backlog' if (task.status === 'in_progress') return 'running' if (task.status === 'done') return 'done' return null } export function elapsedStart(task) { const attention = attentionReason(task) if (attention === 'question') return task.pendingQuestion.createdAt if (attention === 'blocked') return task.blockedSince || task.statusSince return task.statusSince } export function canDrop(task, columnId) { if (columnId === 'needs') return { ok: false, reason: 'Needs you is automatic' } if (attentionReason(task) && columnForTask(task) === 'needs') { return { ok: false, reason: 'Resolve attention before moving' } } if (columnId === 'done' && attentionReason(task)) { return { ok: false, reason: 'Resolve attention before Done' } } return { ok: true } } export function nextIdentifier(project, tasks) { const prefix = project.taskPrefix let max = 0 for (const task of tasks) { const match = new RegExp(`^${prefix}-(\\d+)$`).exec(task.identifier) if (match) max = Math.max(max, Number(match[1])) } return `${prefix}-${Math.max(project.nextTaskNumber, max + 1)}` } ``` - [ ] **Step 5: Run the tests and verify green** ```bash node --test src/shared/__tests__/rules.test.js ``` Expected: 6 tests pass. - [ ] **Step 6: Commit** ```bash git add src/shared git commit -m "feat: add shared status and attention rules" ``` --- ### Task 3: Domain schema **Files:** - Create: `src/host/schema.js` - Create: `src/host/domain.js` - Test: `src/host/__tests__/schema.test.js` **Interfaces:** - Produces: `projectSchema`, `taskSchema`, `commentSchema`, `attachmentSchema`, `activitySchema` - Produces: `kanbanDomainSpec` in `domain.js` - Tests import only `schema.js`, so the DSH loader package is not required in local Node tests. - [ ] **Step 1: Write the failing schema tests** ```js import test from 'node:test' import assert from 'node:assert/strict' import { projectSchema, taskSchema } from '../schema.js' const project = { id: 'dsh-kanban', name: 'DSH 看板', workspacePath: null, taskPrefix: 'DSH', labels: [], nextTaskNumber: 1, archivedAt: null, createdAt: '2026-10-21T00:00:00.000Z', updatedAt: '2026-10-21T00:00:00.000Z', } const task = { id: 't1', identifier: 'DSH-1', projectId: project.id, title: 'task', description: '', status: 'backlog', priority: 'none', labels: [], assignee: { type: 'user', id: 'local-user', name: '本地用户', avatarUrl: null }, sortOrder: 1000, statusSince: '2026-10-21T00:00:00.000Z', blockedReason: null, blockedSince: null, pendingQuestion: null, questionResolution: null, links: [], archivedAt: null, version: 1, createdAt: '2026-10-21T00:00:00.000Z', updatedAt: '2026-10-21T00:00:00.000Z', } test('valid project and task parse', () => { assert.equal(projectSchema.safeParse(project).success, true) assert.equal(taskSchema.safeParse(task).success, true) }) test('blocked is not a stored status', () => { assert.equal(taskSchema.safeParse({ ...task, status: 'blocked' }).success, false) }) test('invalid priority is rejected', () => { assert.equal(taskSchema.safeParse({ ...task, priority: 'P0' }).success, false) }) ``` - [ ] **Step 2: Run and watch fail** ```bash node --test src/host/__tests__/schema.test.js ``` Expected: cannot find `../schema.js`. - [ ] **Step 3: Implement `src/host/schema.js`** ```js import { z } from 'zod' import { PRIORITIES, STATUSES } from '../shared/statuses.js' const iso = z.string().min(1) const actor = z.object({ type: z.enum(['user', 'agent']), id: z.string().min(1), name: z.string().min(1), avatarUrl: z.string().nullable(), }) const link = z.object({ id: z.string().min(1), label: z.string().min(1).max(120), url: z.string().refine((value) => { try { const url = new URL(value) return url.protocol === 'http:' || url.protocol === 'https:' } catch { return false } }, 'URL must use http or https'), }) const questionOption = z.object({ id: z.string().min(1), label: z.string().min(1).max(200), detail: z.string().max(1000).default(''), }) const pendingQuestion = z.object({ prompt: z.string().min(1).max(2000), note: z.string().max(2000).default(''), options: z.array(questionOption).min(2).max(5), selected: z.string().nullable(), createdAt: iso, }) const questionResolution = z.object({ decision: z.enum(['approve', 'reject']), optionId: z.string().nullable(), optionLabel: z.string().nullable(), note: z.string().max(2000).default(''), at: iso, }) export const projectSchema = z.object({ id: z.string().min(1).max(64), name: z.string().min(1).max(120), workspacePath: z.string().nullable(), taskPrefix: z.string().regex(/^[A-Z0-9]{2,6}$/), labels: z.array(z.string().min(1).max(64)).max(50), nextTaskNumber: z.number().int().min(1), archivedAt: z.string().nullable(), createdAt: iso, updatedAt: iso, }) export const taskSchema = z.object({ id: z.string().min(1), identifier: z.string().min(1), projectId: z.string().min(1), title: z.string().min(1).max(240), description: z.string().max(100000), status: z.enum(STATUSES), priority: z.enum(PRIORITIES), labels: z.array(z.string().min(1).max(64)).max(20), assignee: actor, sortOrder: z.number().finite(), statusSince: iso, blockedReason: z.string().max(2000).nullable(), blockedSince: iso.nullable(), pendingQuestion: pendingQuestion.nullable(), questionResolution: questionResolution.nullable(), links: z.array(link).max(50), archivedAt: z.string().nullable(), version: z.number().int().min(1), createdAt: iso, updatedAt: iso, }) export const commentSchema = z.object({ id: z.string().min(1), taskId: z.string().min(1), body: z.string().min(1).max(10000), author: actor, createdAt: iso, updatedAt: iso, }) export const attachmentSchema = z.object({ id: z.string().min(1), taskId: z.string().min(1), filename: z.string().min(1).max(255), contentType: z.string().min(1).max(200), size: z.number().int().min(0), storageRef: z.object({ attachmentId: z.string().min(1), name: z.string().min(1), bytes: z.number().int().min(0), }), createdAt: iso, }) export const activitySchema = z.object({ id: z.string().min(1), taskId: z.string().min(1), actor, changes: z.array(z.object({ field: z.string().min(1), before: z.unknown(), after: z.unknown(), })), createdAt: iso, }) ``` - [ ] **Step 4: Implement `src/host/domain.js`** ```js import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' import { activitySchema, attachmentSchema, commentSchema, projectSchema, taskSchema, } from './schema.js' export const kanbanDomainSpec = defineDomain({ name: 'dsh-kanban', version: 1, tables: { projects: domainTable(projectSchema), tasks: domainTable(taskSchema), comments: domainTable(commentSchema), attachments: domainTable(attachmentSchema), task_activity: domainTable(activitySchema), }, }) ``` - [ ] **Step 5: Run the tests** ```bash node --test src/host/__tests__/schema.test.js ``` Expected: 3 tests pass. - [ ] **Step 6: Commit** ```bash git add src/host/schema.js src/host/domain.js src/host/__tests__/schema.test.js git commit -m "feat: add kanban domain schema" ``` ### Task 4: Project operations in `KanbanStore` **Files:** - Create: `src/host/store.js` - Create: `src/host/__tests__/memory-table.js` - Test: `src/host/__tests__/store-projects.test.js` **Interfaces:** - Produces: `KanbanStore({ tables, attachments, now, newId })` - Produces: `bootstrap()`, `createProject()`, `renameProject()`, `archiveProject()`, `restoreProject()`, `deleteProject()` - [ ] **Step 1: Write the in-memory table helper** ```js export class MemoryTable { constructor(initial = {}) { this.map = new Map(Object.entries(initial)) } get(key) { return this.map.get(key) } entries() { return this.map.entries() } keys() { return this.map.keys() } get size() { return this.map.size } async put(key, value) { this.map.set(key, value) } async delete(key) { return this.map.delete(key) } async update(key, fn) { const next = fn(this.map.get(key)) this.map.set(key, next) return next } } ``` - [ ] **Step 2: Write the failing project tests** ```js import test from 'node:test' import assert from 'node:assert/strict' import { KanbanStore } from '../store.js' import { MemoryTable } from './memory-table.js' function makeStore() { return new KanbanStore({ tables: { projects: new MemoryTable(), tasks: new MemoryTable(), comments: new MemoryTable(), attachments: new MemoryTable(), activity: new MemoryTable(), }, attachments: { admitEncodedFile: async () => ({ attachmentId: 'a1', name: 'f', bytes: 1 }) }, now: () => '2026-10-21T00:00:00.000Z', newId: (() => { let n = 0; return () => `id-${++n}` })(), }) } test('create, rename, archive, restore, delete empty project', async () => { const store = makeStore() const created = await store.createProject({ name: 'dsh-kanban' }) assert.equal(created.id, 'dsh-kanban') assert.equal(created.taskPrefix, 'DSH') const renamed = await store.renameProject(created.id, 'DSH Kanban') assert.equal(renamed.name, 'DSH Kanban') const archived = await store.archiveProject(created.id) assert.equal(archived.archivedAt, '2026-10-21T00:00:00.000Z') const restored = await store.restoreProject(created.id) assert.equal(restored.archivedAt, null) await store.deleteProject(created.id) assert.equal(store.bootstrap().projects.length, 0) }) test('delete rejects a non-empty project', async () => { const store = makeStore() const project = await store.createProject({ name: 'DSH 看板' }) await store.createTask({ projectId: project.id, title: 'task' }) await assert.rejects( () => store.deleteProject(project.id), (error) => error.code === 'PROJECT_NOT_EMPTY', ) }) ``` - [ ] **Step 3: Run and watch fail** ```bash node --test src/host/__tests__/store-projects.test.js ``` Expected: cannot find `../store.js`. - [ ] **Step 4: Implement the project part of `KanbanStore`** ```js import { attentionReason, columnForTask, nextIdentifier } from '../shared/rules.js' const LOCAL_USER = { type: 'user', id: 'local-user', name: '本地用户', avatarUrl: null } export class KanbanStore { #tables #attachments #now #newId #tail = Promise.resolve() constructor({ tables, attachments, now = () => new Date().toISOString(), newId = () => crypto.randomUUID() }) { this.#tables = tables this.#attachments = attachments this.#now = now this.#newId = newId } #run(operation) { const next = this.#tail.then(operation) this.#tail = next.catch(() => {}) return next } #project(id) { const project = this.#tables.projects.get(id) if (!project) throw Object.assign(new Error('Project not found'), { code: 'PROJECT_NOT_FOUND' }) return project } #task(id) { const task = this.#tables.tasks.get(id) if (!task) throw Object.assign(new Error('Task not found'), { code: 'TASK_NOT_FOUND' }) return task } bootstrap() { const projects = [...this.#tables.projects.entries()].map(([, project]) => ({ ...project, taskCount: [...this.#tables.tasks.entries()].filter(([, task]) => task.projectId === project.id && !task.archivedAt).length, })) const tasks = [...this.#tables.tasks.entries()] .map(([, task]) => task) .filter((task) => !task.archivedAt) .map((task) => this.#summary(task)) return { projects, tasks } } createProject({ name, workspacePath = null }) { return this.#run(async () => { const base = String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project' let id = base let suffix = 2 while (this.#tables.projects.get(id)) id = `${base}-${suffix++}` const prefixBase = String(name).toUpperCase().replace(/[^A-Z0-9]+/g, '').slice(0, 3) || 'TSK' let taskPrefix = prefixBase suffix = 2 while ([...this.#tables.projects.entries()].some(([, project]) => project.taskPrefix === taskPrefix)) { taskPrefix = `${prefixBase.slice(0, 5)}${suffix++}` } const timestamp = this.#now() const project = { id, name: String(name).trim(), workspacePath, taskPrefix, labels: [], nextTaskNumber: 1, archivedAt: null, createdAt: timestamp, updatedAt: timestamp, } await this.#tables.projects.put(id, project) return project }) } renameProject(id, name) { return this.#run(async () => { const project = this.#project(id) const next = { ...project, name: String(name).trim(), updatedAt: this.#now() } await this.#tables.projects.put(id, next) return next }) } archiveProject(id) { return this.#run(async () => { const project = this.#project(id) const next = { ...project, archivedAt: project.archivedAt ?? this.#now(), updatedAt: this.#now() } await this.#tables.projects.put(id, next) return next }) } restoreProject(id) { return this.#run(async () => { const project = this.#project(id) const next = { ...project, archivedAt: null, updatedAt: this.#now() } await this.#tables.projects.put(id, next) return next }) } deleteProject(id) { return this.#run(async () => { this.#project(id) const count = [...this.#tables.tasks.entries()].filter(([, task]) => task.projectId === id).length if (count > 0) { throw Object.assign(new Error('Project still contains tasks'), { code: 'PROJECT_NOT_EMPTY', details: { taskCount: count } }) } await this.#tables.projects.delete(id) }) } #summary(task) { return { ...task, attentionReason: attentionReason(task), column: columnForTask(task), commentCount: [...this.#tables.comments.entries()].filter(([, comment]) => comment.taskId === task.id).length, attachmentCount: [...this.#tables.attachments.entries()].filter(([, attachment]) => attachment.taskId === task.id).length, linkCount: task.links.length, } } } ``` - [ ] **Step 5: Run tests** The second test needs `createTask`; implement Task 5 before running both, or temporarily remove it. To keep this task self-contained, implement a minimal `createTask` now: ```js createTask({ projectId, title, status = 'backlog', priority = 'none' }) { return this.#run(async () => { const project = this.#project(projectId) if (project.archivedAt) throw Object.assign(new Error('Project is archived'), { code: 'PROJECT_ARCHIVED' }) const tasks = [...this.#tables.tasks.entries()].map(([, task]) => task) const identifier = nextIdentifier(project, tasks) const timestamp = this.#now() const task = { id: this.#newId(), identifier, projectId, title: String(title).trim(), description: '', status, priority, labels: [], assignee: LOCAL_USER, sortOrder: 1000, statusSince: timestamp, blockedReason: null, blockedSince: null, pendingQuestion: null, questionResolution: null, links: [], archivedAt: null, version: 1, createdAt: timestamp, updatedAt: timestamp, } await this.#tables.tasks.put(task.id, task) await this.#tables.projects.put(projectId, { ...project, nextTaskNumber: Number(identifier.split('-').at(-1)) + 1, updatedAt: timestamp }) return task }) } ``` Run: ```bash node --test src/host/__tests__/store-projects.test.js ``` Expected: 2 tests pass. - [ ] **Step 6: Commit** ```bash git add src/host/store.js src/host/__tests__/memory-table.js src/host/__tests__/store-projects.test.js git commit -m "feat: add project operations to kanban store" ``` --- ### Task 5: Task create, patch, detail, and archive **Files:** - Modify: `src/host/store.js` - Test: `src/host/__tests__/store-tasks.test.js` **Interfaces:** - Produces: `createTask()`, `updateTask()`, `getTaskDetail()`, `archiveTask()`, `restoreTask()`, `deleteTask()`, `listArchivedTasks()` - [ ] **Step 1: Write failing tests** ```js import test from 'node:test' import assert from 'node:assert/strict' import { KanbanStore } from '../store.js' import { MemoryTable } from './memory-table.js' function makeStore() { return new KanbanStore({ tables: { projects: new MemoryTable(), tasks: new MemoryTable(), comments: new MemoryTable(), attachments: new MemoryTable(), activity: new MemoryTable(), }, attachments: { admitEncodedFile: async () => ({ attachmentId: 'a1', name: 'f', bytes: 1 }) }, now: () => '2026-10-21T00:00:00.000Z', newId: (() => { let n = 0; return () => `id-${++n}` })(), }) } test('create task and increment nextTaskNumber', async () => { const store = makeStore() const project = await store.createProject({ name: 'DSH 看板' }) const task = await store.createTask({ projectId: project.id, title: 'first' }) assert.equal(task.identifier, 'DSH-1') assert.equal(task.status, 'backlog') assert.equal(store.bootstrap().projects[0].nextTaskNumber, 2) }) test('patch increments version and rejects stale versions', async () => { const store = makeStore() const project = await store.createProject({ name: 'DSH 看板' }) const task = await store.createTask({ projectId: project.id, title: 'first' }) const updated = await store.updateTask(task.id, { version: 1, priority: 'high' }) assert.equal(updated.priority, 'high') assert.equal(updated.version, 2) await assert.rejects( () => store.updateTask(task.id, { version: 1, priority: 'low' }), (error) => error.code === 'TASK_VERSION_CONFLICT', ) }) test('archive hides from bootstrap and restore returns it', async () => { const store = makeStore() const project = await store.createProject({ name: 'DSH 看板' }) const task = await store.createTask({ projectId: project.id, title: 'first' }) await store.archiveTask(task.id, { version: 1 }) assert.equal(store.bootstrap().tasks.length, 0) assert.equal(store.listArchivedTasks().length, 1) const restored = await store.restoreTask(task.id, { version: 2 }) assert.equal(restored.archivedAt, null) }) ``` - [ ] **Step 2: Run and watch fail** ```bash node --test src/host/__tests__/store-tasks.test.js ``` Expected: `updateTask is not a function`. - [ ] **Step 3: Implement task methods** Replace the minimal `createTask` from Task 4 with the full version, then add the methods below. ```js createTask({ projectId, title, description = '', status = 'backlog', priority = 'none', labels = [], assigneeTarget = 'current-user', sortOrder }) { return this.#run(async () => { const project = this.#project(projectId) if (project.archivedAt) throw Object.assign(new Error('Project is archived'), { code: 'PROJECT_ARCHIVED' }) const tasks = [...this.#tables.tasks.entries()].map(([, task]) => task) const identifier = nextIdentifier(project, tasks) const timestamp = this.#now() const siblings = tasks.filter((task) => task.projectId === projectId && task.status === status && !task.archivedAt) const order = sortOrder ?? (siblings.length ? Math.min(...siblings.map((task) => task.sortOrder)) - 1000 : 1000) const task = { id: this.#newId(), identifier, projectId, title: String(title).trim(), description: String(description), status, priority, labels: [...labels], assignee: LOCAL_USER, sortOrder: order, statusSince: timestamp, blockedReason: null, blockedSince: null, pendingQuestion: null, questionResolution: null, links: [], archivedAt: null, version: 1, createdAt: timestamp, updatedAt: timestamp, } await this.#tables.tasks.put(task.id, task) await this.#tables.projects.put(projectId, { ...project, nextTaskNumber: Number(identifier.split('-').at(-1)) + 1, updatedAt: timestamp, }) return task }) } updateTask(id, patch) { return this.#run(async () => { const task = this.#task(id) if (task.archivedAt) throw Object.assign(new Error('Task is archived'), { code: 'TASK_ARCHIVED' }) if (patch.version !== task.version) { throw Object.assign(new Error('Task changed elsewhere'), { code: 'TASK_VERSION_CONFLICT' }) } const project = this.#project(task.projectId) if (project.archivedAt) throw Object.assign(new Error('Project is archived'), { code: 'PROJECT_ARCHIVED' }) const now = this.#now() const next = { ...task, ...patch, version: task.version + 1, updatedAt: now } if (patch.status && patch.status !== task.status) next.statusSince = now if (patch.blockedReason && !task.blockedReason) next.blockedSince = now if (patch.blockedReason === null) next.blockedSince = null delete next.versionInput await this.#tables.tasks.put(id, next) return next }) } getTaskDetail(id) { const task = this.#task(id) return { task: this.#summary(task), comments: [...this.#tables.comments.entries()].map(([, comment]) => comment).filter((comment) => comment.taskId === id), attachments: [...this.#tables.attachments.entries()].map(([, attachment]) => attachment).filter((attachment) => attachment.taskId === id), activity: [...this.#tables.activity.entries()].map(([, event]) => event).filter((event) => event.taskId === id), } } archiveTask(id, { version }) { return this.#run(async () => { const task = this.#task(id) if (version !== task.version) throw Object.assign(new Error('Task changed elsewhere'), { code: 'TASK_VERSION_CONFLICT' }) const next = { ...task, archivedAt: this.#now(), updatedAt: this.#now(), version: task.version + 1 } await this.#tables.tasks.put(id, next) return next }) } restoreTask(id, { version }) { return this.#run(async () => { const task = this.#task(id) if (!task.archivedAt) throw Object.assign(new Error('Task is not archived'), { code: 'TASK_NOT_ARCHIVED' }) if (version !== task.version) throw Object.assign(new Error('Task changed elsewhere'), { code: 'TASK_VERSION_CONFLICT' }) const next = { ...task, archivedAt: null, updatedAt: this.#now(), version: task.version + 1 } await this.#tables.tasks.put(id, next) return next }) } deleteTask(id, { version }) { return this.#run(async () => { const task = this.#task(id) if (!task.archivedAt) throw Object.assign(new Error('Task is not archived'), { code: 'TASK_NOT_ARCHIVED' }) if (version !== task.version) throw Object.assign(new Error('Task changed elsewhere'), { code: 'TASK_VERSION_CONFLICT' }) await this.#tables.tasks.delete(id) for (const [key, comment] of this.#tables.comments.entries()) if (comment.taskId === id) await this.#tables.comments.delete(key) for (const [key, attachment] of this.#tables.attachments.entries()) if (attachment.taskId === id) await this.#tables.attachments.delete(key) for (const [key, event] of this.#tables.activity.entries()) if (event.taskId === id) await this.#tables.activity.delete(key) }) } listArchivedTasks(projectId) { return [...this.#tables.tasks.entries()] .map(([, task]) => task) .filter((task) => task.archivedAt && (!projectId || task.projectId === projectId)) .map((task) => this.#summary(task)) } ``` - [ ] **Step 4: Run tests** ```bash node --test src/host/__tests__/store-tasks.test.js ``` Expected: 3 tests pass. - [ ] **Step 5: Commit** ```bash git add src/host/store.js src/host/__tests__/store-tasks.test.js git commit -m "feat: add task operations to kanban store" ``` --- ### Task 6: Attention, blocking, questions, and approvals **Files:** - Modify: `src/host/store.js` - Test: `src/host/__tests__/store-attention.test.js` **Interfaces:** - Produces: `askQuestion()`, `answerQuestion()` - `updateTask` handles `blockedReason` timestamps - [ ] **Step 1: Write failing tests** ```js import test from 'node:test' import assert from 'node:assert/strict' import { KanbanStore } from '../store.js' import { MemoryTable } from './memory-table.js' function makeStore() { return new KanbanStore({ tables: { projects: new MemoryTable(), tasks: new MemoryTable(), comments: new MemoryTable(), attachments: new MemoryTable(), activity: new MemoryTable(), }, attachments: { admitEncodedFile: async () => ({ attachmentId: 'a1', name: 'f', bytes: 1 }) }, now: () => '2026-10-21T00:00:00.000Z', newId: (() => { let n = 0; return () => `id-${++n}` })(), }) } async function setup() { const store = makeStore() const project = await store.createProject({ name: 'DSH 看板' }) const task = await store.createTask({ projectId: project.id, title: 'task' }) return { store, task } } test('blocking sets blockedSince and clearing removes it', async () => { const { store, task } = await setup() const blocked = await store.updateTask(task.id, { version: 1, blockedReason: 'waiting' }) assert.equal(blocked.blockedSince, '2026-10-21T00:00:00.000Z') const cleared = await store.updateTask(task.id, { version: 2, blockedReason: null }) assert.equal(cleared.blockedSince, null) }) test('question enters Needs you; approve finishes and reject returns to backlog', async () => { const { store, task } = await setup() const asked = await store.askQuestion(task.id, { version: 1, prompt: 'Which name?', note: '', options: [ { id: 'a', label: 'A', detail: '' }, { id: 'b', label: 'B', detail: '' }, ], }) assert.equal(asked.attentionReason, 'question') const approved = await store.answerQuestion(task.id, { version: 2, decision: 'approve', selected: 'a', note: '', }) assert.equal(approved.status, 'done') assert.equal(approved.attentionReason, null) }) test('done is rejected while attention is unresolved', async () => { const { store, task } = await setup() await store.updateTask(task.id, { version: 1, blockedReason: 'waiting' }) await assert.rejects( () => store.updateTask(task.id, { version: 2, status: 'done' }), (error) => error.code === 'TASK_NEEDS_ATTENTION', ) }) ``` - [ ] **Step 2: Run and watch fail** ```bash node --test src/host/__tests__/store-attention.test.js ``` Expected: `askQuestion is not a function`. - [ ] **Step 3: Add the attention guard and methods** In `updateTask`, before writing: ```js const candidate = { ...task, ...patch, version: task.version + 1, updatedAt: now } if (candidate.status === 'done' && (candidate.pendingQuestion && !candidate.questionResolution || candidate.blockedReason)) { throw Object.assign(new Error('Resolve attention before Done'), { code: 'TASK_NEEDS_ATTENTION' }) } ``` Add methods: ```js askQuestion(id, { version, prompt, note = '', options }) { return this.#run(async () => { const task = this.#task(id) if (task.version !== version) throw Object.assign(new Error('Task changed elsewhere'), { code: 'TASK_VERSION_CONFLICT' }) if (task.pendingQuestion && !task.questionResolution) throw Object.assign(new Error('Question already pending'), { code: 'QUESTION_ALREADY_PENDING' }) const next = { ...task, pendingQuestion: { prompt, note, options, selected: null, createdAt: this.#now(), }, questionResolution: null, version: task.version + 1, updatedAt: this.#now(), } await this.#tables.tasks.put(id, next) return this.#summary(next) }) } answerQuestion(id, { version, decision, selected = null, note = '' }) { return this.#run(async () => { const task = this.#task(id) if (task.version !== version) throw Object.assign(new Error('Task changed elsewhere'), { code: 'TASK_VERSION_CONFLICT' }) if (!task.pendingQuestion && task.status !== 'in_review') { throw Object.assign(new Error('Task is not awaiting review'), { code: 'TASK_NOT_IN_REVIEW' }) } const option = task.pendingQuestion?.options.find((item) => item.id === selected) ?? null if (task.pendingQuestion && !option) { throw Object.assign(new Error('Selected option is required'), { code: 'INVALID_FIELD' }) } const now = this.#now() const next = { ...task, status: decision === 'approve' ? 'done' : 'backlog', statusSince: now, questionResolution: { decision, optionId: option?.id ?? null, optionLabel: option?.label ?? null, note, at: now, }, version: task.version + 1, updatedAt: now, } await this.#tables.tasks.put(id, next) return this.#summary(next) }) } ``` - [ ] **Step 4: Run tests** ```bash node --test src/host/__tests__/store-attention.test.js ``` Expected: 3 tests pass. - [ ] **Step 5: Commit** ```bash git add src/host/store.js src/host/__tests__/store-attention.test.js git commit -m "feat: add attention, blocking and approval rules" ``` --- ### Task 7: Comments and activity **Files:** - Modify: `src/host/store.js` - Test: `src/host/__tests__/store-comments.test.js` **Interfaces:** - Produces: `addComment()`, `deleteComment()` - Produces: internal `#activity()` - [ ] **Step 1: Write failing tests** ```js import test from 'node:test' import assert from 'node:assert/strict' import { KanbanStore } from '../store.js' import { MemoryTable } from './memory-table.js' function makeStore() { return new KanbanStore({ tables: { projects: new MemoryTable(), tasks: new MemoryTable(), comments: new MemoryTable(), attachments: new MemoryTable(), activity: new MemoryTable(), }, attachments: { admitEncodedFile: async () => ({ attachmentId: 'a1', name: 'f', bytes: 1 }) }, now: () => '2026-10-21T00:00:00.000Z', newId: (() => { let n = 0; return () => `id-${++n}` })(), }) } test('comments add and delete, and activity records task creation', async () => { const store = makeStore() const project = await store.createProject({ name: 'DSH 看板' }) const task = await store.createTask({ projectId: project.id, title: 'task' }) const comment = await store.addComment(task.id, 'hello') const detail = store.getTaskDetail(task.id) assert.equal(detail.comments.length, 1) assert.equal(detail.comments[0].body, 'hello') assert.ok(detail.activity.some((event) => event.changes.some((change) => change.field === 'status'))) await store.deleteComment(task.id, comment.id) assert.equal(store.getTaskDetail(task.id).comments.length, 0) }) ``` - [ ] **Step 2: Run and watch fail** ```bash node --test src/host/__tests__/store-comments.test.js ``` Expected: `addComment is not a function`. - [ ] **Step 3: Implement comments and activity** ```js async #activity(taskId, changes) { const event = { id: this.#newId(), taskId, actor: LOCAL_USER, changes, createdAt: this.#now(), } await this.#tables.activity.put(event.id, event) } addComment(taskId, body) { return this.#run(async () => { const task = this.#task(taskId) if (task.archivedAt) throw Object.assign(new Error('Task is archived'), { code: 'TASK_ARCHIVED' }) const project = this.#project(task.projectId) if (project.archivedAt) throw Object.assign(new Error('Project is archived'), { code: 'PROJECT_ARCHIVED' }) const timestamp = this.#now() const comment = { id: this.#newId(), taskId, body: String(body).trim(), author: LOCAL_USER, createdAt: timestamp, updatedAt: timestamp, } await this.#tables.comments.put(comment.id, comment) return comment }) } deleteComment(taskId, commentId) { return this.#run(async () => { const comment = this.#tables.comments.get(commentId) if (!comment || comment.taskId !== taskId) throw Object.assign(new Error('Comment not found'), { code: 'NOT_FOUND' }) await this.#tables.comments.delete(commentId) }) } ``` Call `await this.#activity(task.id, [{ field: 'status', before: null, after: task.status }])` after creating a task. Call activity from `updateTask` for each changed field, `askQuestion`, `answerQuestion`, `archiveTask`, `restoreTask`, and attachment methods. - [ ] **Step 4: Run tests** ```bash node --test src/host/__tests__/store-comments.test.js ``` Expected: 1 test passes. - [ ] **Step 5: Commit** ```bash git add src/host/store.js src/host/__tests__/store-comments.test.js git commit -m "feat: add comments and task activity" ``` --- ### Task 8: Attachments and HTTP route foundation **Files:** - Modify: `src/host/store.js` - Create: `src/host/http.js` - Test: `src/host/__tests__/store-attachments.test.js` - Test: `src/host/__tests__/http-bootstrap.test.js` **Interfaces:** - Produces: `addAttachment()`, `deleteAttachment()`, `readAttachment()` - Produces: `createHttpHandler(store)` and `handleRequest(store, request)` - [ ] **Step 1: Write failing attachment/store test** ```js import test from 'node:test' import assert from 'node:assert/strict' import { KanbanStore } from '../store.js' import { MemoryTable } from './memory-table.js' test('attachment metadata is stored after byte admission', async () => { const store = new KanbanStore({ tables: { projects: new MemoryTable(), tasks: new MemoryTable(), comments: new MemoryTable(), attachments: new MemoryTable(), activity: new MemoryTable(), }, attachments: { admitEncodedFile: async () => ({ attachmentId: 'a1', name: 'f.txt', bytes: 3 }) }, now: () => '2026-10-21T00:00:00.000Z', newId: () => 'att-1', }) const project = await store.createProject({ name: 'DSH 看板' }) const task = await store.createTask({ projectId: project.id, title: 'task' }) const attachment = await store.addAttachment(task.id, { filename: 'f.txt', contentType: 'text/plain', data: 'YWJj', }) assert.equal(attachment.filename, 'f.txt') assert.equal(store.getTaskDetail(task.id).attachments.length, 1) }) ``` - [ ] **Step 2: Run and watch fail** ```bash node --test src/host/__tests__/store-attachments.test.js ``` Expected: `addAttachment is not a function`. - [ ] **Step 3: Implement attachments** ```js addAttachment(taskId, { filename, contentType, data }) { return this.#run(async () => { const task = this.#task(taskId) const project = this.#project(task.projectId) if (task.archivedAt) throw Object.assign(new Error('Task is archived'), { code: 'TASK_ARCHIVED' }) if (project.archivedAt) throw Object.assign(new Error('Project is archived'), { code: 'PROJECT_ARCHIVED' }) const count = [...this.#tables.attachments.entries()].filter(([, attachment]) => attachment.taskId === taskId).length if (count >= 50) throw Object.assign(new Error('Attachment limit reached'), { code: 'ATTACHMENT_LIMIT_REACHED' }) const bytes = Buffer.from(data, 'base64') if (bytes.length > 20 * 1024 * 1024) { throw Object.assign(new Error('Attachment is too large'), { code: 'ATTACHMENT_TOO_LARGE' }) } const ref = await this.#attachments.admitEncodedFile({ data, name: filename }) const attachment = { id: this.#newId(), taskId, filename, contentType, size: ref.bytes, storageRef: { attachmentId: ref.attachmentId, name: ref.name, bytes: ref.bytes }, createdAt: this.#now(), } await this.#tables.attachments.put(attachment.id, attachment) await this.#activity(taskId, [{ field: 'attachment', before: null, after: filename }]) return attachment }) } deleteAttachment(taskId, attachmentId) { return this.#run(async () => { const attachment = this.#tables.attachments.get(attachmentId) if (!attachment || attachment.taskId !== taskId) throw Object.assign(new Error('Attachment not found'), { code: 'NOT_FOUND' }) await this.#tables.attachments.delete(attachmentId) }) } readAttachment(attachmentId) { const attachment = this.#tables.attachments.get(attachmentId) if (!attachment) throw Object.assign(new Error('Attachment not found'), { code: 'NOT_FOUND' }) return { attachment, stream: this.#attachments.readFileStream(attachment.storageRef), } } ``` - [ ] **Step 4: Write failing HTTP bootstrap test** ```js import test from 'node:test' import assert from 'node:assert/strict' import { KanbanStore } from '../store.js' import { MemoryTable } from './memory-table.js' import { handleRequest } from '../http.js' test('bootstrap returns projects and tasks', async () => { const store = new KanbanStore({ tables: { projects: new MemoryTable(), tasks: new MemoryTable(), comments: new MemoryTable(), attachments: new MemoryTable(), activity: new MemoryTable(), }, attachments: { admitEncodedFile: async () => ({ attachmentId: 'a1', name: 'f', bytes: 1 }) }, now: () => '2026-10-21T00:00:00.000Z', newId: () => 'id-1', }) const project = await store.createProject({ name: 'DSH 看板' }) await store.createTask({ projectId: project.id, title: 'task' }) const response = await handleRequest(store, { method: 'GET', pathname: '/kanban-api/bootstrap', searchParams: new URLSearchParams(), body: null, }) assert.equal(response.status, 200) assert.equal(response.body.projects.length, 1) assert.equal(response.body.tasks.length, 1) }) ``` - [ ] **Step 5: Run and watch fail** ```bash node --test src/host/__tests__/http-bootstrap.test.js ``` Expected: cannot find `../http.js`. - [ ] **Step 6: Implement the route dispatcher and Node adapter** ```js import { Readable } from 'node:stream' const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } function ok(body, status = 200) { return { status, headers: JSON_HEADERS, body } } function fail(status, code, message, details = {}) { return { status, headers: JSON_HEADERS, body: { error: { code, message, details } } } } export async function handleRequest(store, request) { const { method, pathname, searchParams, body } = request if (method === 'GET' && pathname === '/kanban-api/bootstrap') return ok(store.bootstrap()) if (method === 'POST' && pathname === '/kanban-api/projects') { try { return ok(await store.createProject(body), 201) } catch (error) { return toError(error) } } return fail(404, 'NOT_FOUND', 'Not found') } const STATUS_BY_CODE = { PROJECT_ARCHIVED: 403, TASK_NOT_FOUND: 404, PROJECT_NOT_FOUND: 404, NOT_FOUND: 404, PROJECT_EXISTS: 409, PROJECT_NOT_EMPTY: 409, TASK_VERSION_CONFLICT: 409, TASK_ARCHIVED: 409, TASK_NOT_ARCHIVED: 409, TASK_NOT_IN_REVIEW: 409, TASK_NEEDS_ATTENTION: 409, QUESTION_ALREADY_PENDING: 409, QUESTION_NOT_PENDING: 409, ATTACHMENT_TOO_LARGE: 413, ATTACHMENT_LIMIT_REACHED: 413, } function toError(error) { const status = STATUS_BY_CODE[error.code] ?? 400 return fail(status, error.code ?? 'INTERNAL_ERROR', error.message, error.details) } const MAX_BODY_BYTES = 30 * 1024 * 1024 export function createHttpHandler(store) { return async (request, response) => { const url = new URL(request.url, 'http://localhost') const download = /^\/kanban-api\/attachments\/([^/]+)$/.exec(url.pathname) if (download && request.method === 'GET') { try { const { attachment, stream } = store.readAttachment(decodeURIComponent(download[1])) response.writeHead(200, { 'content-type': attachment.contentType, 'content-disposition': `attachment; filename="${attachment.filename.replace(/"/g, '')}"`, 'content-length': String(attachment.size), 'cache-control': 'no-store', }) for await (const chunk of stream) response.write(chunk) response.end() } catch (error) { response.writeHead(404, JSON_HEADERS) response.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Attachment not found', details: {} } })) } return } let body = null if (request.method !== 'GET' && request.method !== 'DELETE') { const chunks = [] let total = 0 for await (const chunk of request) { total += chunk.length if (total > MAX_BODY_BYTES) { response.writeHead(413, JSON_HEADERS) response.end(JSON.stringify({ error: { code: 'ATTACHMENT_TOO_LARGE', message: 'Request body is too large', details: {} } })) return } chunks.push(chunk) } const text = Buffer.concat(chunks).toString('utf8') body = text ? JSON.parse(text) : {} } try { const result = await handleRequest(store, { method: request.method, pathname: url.pathname, searchParams: url.searchParams, body, }) response.writeHead(result.status, result.headers) response.end(result.body === null ? undefined : JSON.stringify(result.body)) } catch (error) { response.writeHead(500, JSON_HEADERS) response.end(JSON.stringify({ error: { code: 'INTERNAL_ERROR', message: String(error), details: {} } })) } } } ``` - [ ] **Step 7: Run tests** ```bash node --test src/host/__tests__/store-attachments.test.js src/host/__tests__/http-bootstrap.test.js ``` Expected: 2 tests pass. - [ ] **Step 8: Commit** ```bash git add src/host/store.js src/host/http.js src/host/__tests__/store-attachments.test.js src/host/__tests__/http-bootstrap.test.js git commit -m "feat: add attachments and HTTP bootstrap route" ``` --- ### Task 9: Complete HTTP routes and Host entry **Files:** - Modify: `src/host/http.js` - Modify: `src/host/entry.js` - Test: `src/host/__tests__/http-routes.test.js` **Interfaces:** - Completes project/task/question/comment/attachment/archive routes - `apply` opens the domain, provides `kanban`, registers the route - [ ] **Step 1: Write route tests** Cover each operation with a fake store: ```js test('project archive and task move routes', async () => { const calls = [] const store = { archiveProject: async (id) => { calls.push(['archiveProject', id]); return { id, archivedAt: 'now' } }, updateTask: async (id, body) => { calls.push(['updateTask', id, body]); return { id, version: body.version + 1 } }, bootstrap: () => ({ projects: [], tasks: [] }), } const a = await handleRequest(store, { method: 'POST', pathname: '/kanban-api/projects/p1/archive', searchParams: new URLSearchParams(), body: {} }) assert.equal(a.status, 200) const b = await handleRequest(store, { method: 'PATCH', pathname: '/kanban-api/tasks/t1', searchParams: new URLSearchParams(), body: { version: 1, status: 'in_progress' } }) assert.equal(b.status, 200) assert.equal(calls.length, 2) }) ``` - [ ] **Step 2: Run and watch fail** ```bash node --test src/host/__tests__/http-routes.test.js ``` Expected: archive route returns 404. - [ ] **Step 3: Implement routes** Use regex matching: ```js const projectMatch = /^\/kanban-api\/projects\/([^/]+)$/.exec(pathname) if (projectMatch) { const id = decodeURIComponent(projectMatch[1]) if (method === 'PATCH') return ok(await store.renameProject(id, body.name)) if (method === 'DELETE') { await store.deleteProject(id); return { status: 204, headers: {}, body: null } } } const projectAction = /^\/kanban-api\/projects\/([^/]+)\/(archive|restore)$/.exec(pathname) if (projectAction && method === 'POST') { return ok(await (projectAction[2] === 'archive' ? store.archiveProject(projectAction[1]) : store.restoreProject(projectAction[1]))) } const taskMatch = /^\/kanban-api\/tasks\/([^/]+)$/.exec(pathname) if (taskMatch) { const id = decodeURIComponent(taskMatch[1]) if (method === 'GET') return ok(store.getTaskDetail(id)) if (method === 'PATCH') return ok(await store.updateTask(id, body)) if (method === 'DELETE') { await store.deleteTask(id, body); return { status: 204, headers: {}, body: null } } } const taskAction = /^\/kanban-api\/tasks\/([^/]+)\/(archive|restore|question|answer)$/.exec(pathname) if (taskAction && method === 'POST') { const id = decodeURIComponent(taskAction[1]) if (taskAction[2] === 'archive') return ok(await store.archiveTask(id, body)) if (taskAction[2] === 'restore') return ok(await store.restoreTask(id, body)) if (taskAction[2] === 'question') return ok(await store.askQuestion(id, body)) if (taskAction[2] === 'answer') return ok(await store.answerQuestion(id, body)) } if (method === 'POST' && pathname === '/kanban-api/tasks') { return ok(await store.createTask(body), 201) } if (method === 'GET' && pathname === '/kanban-api/archived-tasks') { return ok({ tasks: store.listArchivedTasks(searchParams.get('projectId') ?? undefined) }) } const commentMatch = /^\/kanban-api\/tasks\/([^/]+)\/comments$/.exec(pathname) if (commentMatch) { const id = decodeURIComponent(commentMatch[1]) if (method === 'GET') return ok(store.getTaskDetail(id).comments) if (method === 'POST') return ok(await store.addComment(id, body.body), 201) } const commentDelete = /^\/kanban-api\/tasks\/([^/]+)\/comments\/([^/]+)$/.exec(pathname) if (commentDelete && method === 'DELETE') { await store.deleteComment(decodeURIComponent(commentDelete[1]), decodeURIComponent(commentDelete[2])) return { status: 204, headers: {}, body: null } } const attachmentMatch = /^\/kanban-api\/tasks\/([^/]+)\/attachments$/.exec(pathname) if (attachmentMatch) { const id = decodeURIComponent(attachmentMatch[1]) if (method === 'GET') return ok(store.getTaskDetail(id).attachments) if (method === 'POST') return ok(await store.addAttachment(id, body), 201) } const attachmentDelete = /^\/kanban-api\/tasks\/([^/]+)\/attachments\/([^/]+)$/.exec(pathname) if (attachmentDelete && method === 'DELETE') { await store.deleteAttachment(decodeURIComponent(attachmentDelete[1]), decodeURIComponent(attachmentDelete[2])) return { status: 204, headers: {}, body: null } } ``` Implement every branch with the same `try/catch → toError` pattern. `GET /kanban-api/attachments/:id` is handled by the Node adapter because it streams bytes rather than returning JSON. - [ ] **Step 4: Write `src/host/entry.js`** ```js import { kanbanDomainSpec } from './domain.js' import { KanbanStore } from './store.js' import { createHttpHandler } from './http.js' export const inject = ['webServer', 'storageDomain', 'attachments'] export async function apply(ctx) { const domain = await ctx.storageDomain.open(kanbanDomainSpec) ctx.effect(() => () => domain.close(), 'dsh-kanban: close domain') const store = new KanbanStore({ tables: { projects: domain.table('projects'), tasks: domain.table('tasks'), comments: domain.table('comments'), attachments: domain.table('attachments'), activity: domain.table('task_activity'), }, attachments: ctx.attachments, }) ctx.provide('kanban', store) ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/kanban-api', handler: createHttpHandler(store), }), 'dsh-kanban: HTTP route') } ``` - [ ] **Step 5: Run all Host tests and build** ```bash node --test src/host/__tests__ src/shared/__tests__ pnpm build node --check index.js ``` Expected: all tests pass, build exits 0. - [ ] **Step 6: Commit** ```bash git add src/host src/shared git commit -m "feat: add complete kanban HTTP API and host entry" ``` --- ### Task 10: Client API and store **Files:** - Create: `src/client/api.js` - Create: `src/client/store-state.js` - Test: `src/client/__tests__/store-state.test.js` **Interfaces:** - Produces: `createApi(base = '')` with `bootstrap`, project/task/comment/attachment methods - Produces: pure `applyTaskPatch(tasks, taskId, patch)` and `groupTasks(tasks)` - [ ] **Step 1: Write failing selector tests** ```js import test from 'node:test' import assert from 'node:assert/strict' import { groupTasks, applyTaskPatch } from '../store-state.js' test('groupTasks maps tasks into four columns', () => { const tasks = [ { id: 'a', column: 'backlog' }, { id: 'b', column: 'running' }, { id: 'c', column: 'needs' }, { id: 'd', column: 'done' }, ] const grouped = groupTasks(tasks) assert.deepEqual(grouped.backlog.map((task) => task.id), ['a']) assert.deepEqual(grouped.needs.map((task) => task.id), ['c']) }) test('applyTaskPatch replaces one task by id', () => { const next = applyTaskPatch([{ id: 'a', title: 'old' }], 'a', { title: 'new' }) assert.equal(next[0].title, 'new') }) ``` - [ ] **Step 2: Run and watch fail** ```bash node --test src/client/__tests__/store-state.test.js ``` Expected: cannot find `../store-state.js`. - [ ] **Step 3: Implement pure state helpers** ```js export function groupTasks(tasks) { return { backlog: tasks.filter((task) => task.column === 'backlog'), running: tasks.filter((task) => task.column === 'running'), needs: tasks.filter((task) => task.column === 'needs'), done: tasks.filter((task) => task.column === 'done'), } } export function applyTaskPatch(tasks, taskId, patch) { return tasks.map((task) => task.id === taskId ? { ...task, ...patch } : task) } ``` - [ ] **Step 4: Implement `src/client/api.js`** ```js async function request(path, options = {}) { const response = await fetch(`/kanban-api${path}`, { ...options, headers: { 'content-type': 'application/json', ...(options.headers ?? {}) }, }) const body = response.status === 204 ? null : await response.json() if (!response.ok) { const error = new Error(body?.error?.message ?? response.statusText) error.code = body?.error?.code error.details = body?.error?.details throw error } return body } export const api = { bootstrap: () => request('/bootstrap'), createProject: (body) => request('/projects', { method: 'POST', body: JSON.stringify(body) }), renameProject: (id, body) => request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), archiveProject: (id) => request(`/projects/${id}/archive`, { method: 'POST', body: '{}' }), restoreProject: (id) => request(`/projects/${id}/restore`, { method: 'POST', body: '{}' }), deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }), createTask: (body) => request('/tasks', { method: 'POST', body: JSON.stringify(body) }), updateTask: (id, body) => request(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), taskDetail: (id) => request(`/tasks/${id}`), archiveTask: (id, body) => request(`/tasks/${id}/archive`, { method: 'POST', body: JSON.stringify(body) }), restoreTask: (id, body) => request(`/tasks/${id}/restore`, { method: 'POST', body: JSON.stringify(body) }), deleteTask: (id, body) => request(`/tasks/${id}`, { method: 'DELETE', body: JSON.stringify(body) }), askQuestion: (id, body) => request(`/tasks/${id}/question`, { method: 'POST', body: JSON.stringify(body) }), answerQuestion: (id, body) => request(`/tasks/${id}/answer`, { method: 'POST', body: JSON.stringify(body) }), addComment: (id, body) => request(`/tasks/${id}/comments`, { method: 'POST', body: JSON.stringify(body) }), deleteComment: (taskId, commentId) => request(`/tasks/${taskId}/comments/${commentId}`, { method: 'DELETE' }), addAttachment: (id, body) => request(`/tasks/${id}/attachments`, { method: 'POST', body: JSON.stringify(body) }), deleteAttachment: (taskId, attachmentId) => request(`/tasks/${taskId}/attachments/${attachmentId}`, { method: 'DELETE' }), archivedTasks: (projectId) => request(`/archived-tasks${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ''}`), } ``` - [ ] **Step 5: Run tests** ```bash node --test src/client/__tests__/store-state.test.js ``` Expected: 2 tests pass. - [ ] **Step 6: Commit** ```bash git add src/client/api.js src/client/store-state.js src/client/__tests__/store-state.test.js git commit -m "feat: add client API and state helpers" ``` --- ### Task 11: App shell, slots, and project switcher **Files:** - Create: `src/client/components/App.js` - Create: `src/client/components/ProjectSwitcher.js` - Create: `src/client/components/dialogs.js` - Modify: `src/client/index.jsx` - Modify: `src/client/styles.css` **Interfaces:** - `apply(ctx)` registers `main` key `kanban` and `sidebar.panellist` id `kanban` - App receives `t` and owns bootstrap state - ProjectSwitcher receives projects, selectedId, callbacks - [ ] **Step 1: Register slots** ```jsx import React from 'react' import { App } from './components/App.js' const NS = 'kanban' const ZH = { nav: '看板', allProjects: '所有项目', createProject: '创建项目', manageProjects: '管理项目', archivedProjects: '已归档项目', archivedTasks: '已归档任务', search: '搜索标题 / 摘要 / 项目', filters: '筛选', runningCount: '{count} 个运行中', newCard: '新建卡片', backlog: 'Backlog', running: 'Running', needs: 'Needs you', done: 'Done', } const EN = { nav: 'Kanban', allProjects: 'All projects', createProject: 'Create project', manageProjects: 'Manage projects', archivedProjects: 'Archived projects', archivedTasks: 'Archived tasks', search: 'Search title / summary / project', filters: 'Filters', runningCount: '{count} running', newCard: 'New card', backlog: 'Backlog', running: 'Running', needs: 'Needs you', done: 'Done', } function SidebarIcon({ size = 18 }) { return } export function apply(ctx) { ctx.effect(() => { const offZh = ctx.locale.register(NS, 'zh', ZH) const offEn = ctx.locale.register(NS, 'en', EN) return () => { offZh(); offEn() } }, 'dsh-kanban: locale') const t = ctx.locale.bind(NS) ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: 'kanban', locale: NS, }, App)) ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: 'kanban', order: 20, label: () => t('nav'), }, SidebarIcon)) } ``` - [ ] **Step 2: Implement the App shell** Import the stylesheet as text in `src/client/components/App.js`: ```js import styles from '../styles.css' ``` Render it once at the top of the App tree: ```jsx ``` `App` must: - call `api.bootstrap()` on mount - hold `projects`, `tasks`, `selectedProjectId` (`'__all__'` by default) - render header with project switcher, search, filters, running count, refresh - render board via `groupTasks` - open task drawer and dialogs Use local state for now; Task 13 adds the drawer. The first version can render `null` board and an empty state until Task 12. - [ ] **Step 3: Implement the project switcher** Menu items: - all projects - active projects - create project - manage projects - archived projects - archived tasks Use `useEffect` + document pointerdown for outside-click close. Use `role="menu"`, `aria-expanded`. - [ ] **Step 4: Implement create/rename/archive dialogs** Dialogs use `position: fixed`, focus the first input, handle Escape, and return focus to the trigger. - [ ] **Step 5: Build and verify the slot registers** ```bash pnpm build node --check client.js ``` Expected: build succeeds. - [ ] **Step 6: Commit** ```bash git add src/client git commit -m "feat: add native kanban shell and project switcher" ``` --- ### Task 12: Board, columns, and task cards **Files:** - Create: `src/client/components/Board.js` - Create: `src/client/components/Column.js` - Create: `src/client/components/TaskCard.js` - Create: `src/client/components/icons.js` - Modify: `src/client/components/App.js` - Modify: `src/client/styles.css` **Interfaces:** - `Board({ tasks, collapsed, onToggleColumn, onOpenTask, onDropTask, onAddTask, t })` - `Column({ id, title, tone, tasks, collapsed, ... })` - `TaskCard({ task, t, onOpen, onFilterProject, onFilterPriority })` - [ ] **Step 1: Render the four columns** Use `groupTasks(tasks)` and a fixed metadata table: ```js const COLUMNS = [ { id: 'backlog', tone: 'backlog', labelKey: 'backlog' }, { id: 'running', tone: 'running', labelKey: 'running' }, { id: 'needs', tone: 'needs', labelKey: 'needs' }, { id: 'done', tone: 'done', labelKey: 'done' }, ] ``` - [ ] **Step 2: Render cards** Card content: - title - description - project chip (only when All projects) - priority chip - labels - activity or blocked reason - inline question block when `task.pendingQuestion` and no resolution - footer: attention label + elapsed, comment/attachment/link counts - archive/read-only indicator - [ ] **Step 3: Implement drag and drop** - `dataTransfer.setData('text/plain', task.id)` - Reject drops into `needs` with a toast. - Call `api.updateTask(task.id, { version, status, sortOrder })`. - On failure, reload bootstrap and show error. - [ ] **Step 4: Implement column collapse** Store `collapsed` in `localStorage` under `dsh-kanban.collapsed-columns`. Render a 50px rail with icon, count, and vertical label when collapsed. - [ ] **Step 5: Implement header search, filters, running count** - Search across title, description, project name, labels. - Filters: project, priority, label, assignee. Show active count. - Running count from `tasks.filter(task => task.status === 'in_progress').length`. - Refresh calls `api.bootstrap()`. - [ ] **Step 6: Build and verify** ```bash pnpm build node --check client.js ``` - [ ] **Step 7: Commit** ```bash git add src/client git commit -m "feat: add four-column board and task cards" ``` --- ### Task 13: Task drawer **Files:** - Create: `src/client/components/TaskDrawer.js` - Create: `src/client/components/QuestionBlock.js` - Modify: `src/client/components/App.js` - Modify: `src/client/styles.css` **Interfaces:** - `TaskDrawer({ taskId, projects, onClose, onChanged, onArchived, t })` - Loads `api.taskDetail(taskId)` on open - All mutations call the API and update parent state - [ ] **Step 1: Implement the drawer shell** - fixed right panel, width `min(560px, 100vw)` - overlay, focus trap, Escape close - sections: properties, description, blocker, links, question, comments, attachments, activity - archived mode disables inputs - [ ] **Step 2: Implement property updates** Status select, priority select, labels input, assignee select. Each change sends the current `version`, then updates local task. - [ ] **Step 3: Implement blocker** Input + "Set blocker" / "Clear blocker". Calls `updateTask` with `blockedReason`. - [ ] **Step 4: Implement question and approval** - "Request input" form with prompt, note, up to 5 options. - Calls `api.askQuestion`. - If `pendingQuestion`, render options, note, Reject/Approve. - Calls `api.answerQuestion`. - Generic `in_review` shows Confirm/Return. - [ ] **Step 5: Implement comments and links** - Comment list + input + delete. - Link list + label/url + add/remove. - Update the task via `updateTask` for links. - [ ] **Step 6: Implement attachments** - ``, read as base64, POST. - Show filename, size, download link, delete. - Download link points to `/kanban-api/attachments/:id`. - Show upload progress and errors. - [ ] **Step 7: Implement activity** Render `activity` in reverse chronological order with field changes. - [ ] **Step 8: Build and commit** ```bash pnpm build node --check client.js git add src/client git commit -m "feat: add task drawer with comments attachments and approvals" ``` --- ### Task 14: Archived tasks, read-only mode, and styling polish **Files:** - Modify: `src/client/components/App.js` - Modify: `src/client/components/ProjectSwitcher.js` - Modify: `src/client/components/Board.js` - Modify: `src/client/components/TaskDrawer.js` - Modify: `src/client/styles.css` - Modify: `locale/en.json` - Modify: `locale/zh.json` - Modify: `icon.svg` if needed **Interfaces:** - Archived tasks modal lists archived tasks grouped by project - Archived project board shows a read-only banner with Restore - [ ] **Step 1: Implement archived tasks modal** - entry from project switcher - fetch `api.archivedTasks(projectId)` for current project or all - restore/delete actions - empty state - [ ] **Step 2: Implement archived project read-only mode** - when selected project has `archivedAt`, show banner - disable create, drag, drawer mutations, comments, attachments - Restore button calls API and refreshes - [ ] **Step 3: Style against the screenshot** Use tokens only: - page background `--dsw-alias-bg-base` - columns with `color-mix` tints for backlog/running/needs/done - cards `--dsw-alias-bg-layer-1` - borders `--dsw-alias-border-l1/l2` - needs/warn `--dsw-alias-state-warn-primary` - running `--dsw-alias-brand-primary` - done `--dsw-alias-state-success-primary` - error `--dsw-alias-state-error-primary` - modal mask `--dsw-alias-bg-mask-1` + `--dsw-mask-blur` - shadows `--dsw-shadow-lv1/lv2/lv3` Match the screenshot's spacing, card radius, badges, question block, and footer metrics. - [ ] **Step 4: Update locale metadata** `locale/zh.json`: ```json { "meta": { "title": "DSH 看板", "description": "原生任务看板:项目、任务、审批、附件和活动记录。" } } ``` `locale/en.json`: ```json { "meta": { "title": "DSH Kanban", "description": "Native task board with projects, tasks, approvals, attachments and activity." } } ``` - [ ] **Step 5: Build and commit** ```bash pnpm build node --check client.js git add src/client locale icon.svg git commit -m "feat: add archive flows and screenshot-aligned styling" ``` --- ### Task 15: Build, install, and live verification **Files:** - Modify: `package.json` if build outputs need `files` adjustments - Verify: `index.js`, `client.js`, `cordis.patch.yml`, `locale/*.json`, `icon.svg` - [ ] **Step 1: Run all checks** ```bash pnpm check ``` Expected: build exits 0; all tests pass. - [ ] **Step 2: Install the bundle** Use the plugin manager with the absolute package directory: ```text plugin_manager install_bundle /Users/silas/Documents/deepseek-harness/default-workspace/dsh-kanban ``` Expected: `application: applied`, no warnings. - [ ] **Step 3: Confirm slots** Query the Client `Slots` provider for `main` and `sidebar.panellist`. Expected: - `main` has an active `kanban` occupant - `sidebar.panellist` has an active `kanban` entry - no slot crash - [ ] **Step 4: Verify the live operation path** In the DSH page: 1. Open Kanban from the sidebar. 2. Create a project. 3. Create a task. 4. Drag Backlog → Running → Done. 5. Set a blocker; confirm Needs you and the timer. 6. Ask a question; Approve it; confirm Done. 7. Add a comment and an attachment; download the attachment. 8. Archive and restore a task. 9. Archive and restore a project. 10. Refresh the page; confirm persistence. 11. Toggle light/dark theme. - [ ] **Step 5: Record evidence and limitations** Report: - exact test command output - build output - install result - slot status - each live operation result - any visual differences from the screenshot - the fact that browser screenshot control is unavailable, so the user performs final visual acceptance - [ ] **Step 6: Commit final artifacts** ```bash git add index.js client.js package.json cordis.patch.yml locale icon.svg git commit -m "chore: build and verify kanban plugin" ``` --- ## Execution Handoff After this plan is saved, choose one: 1. **Subagent-Driven (recommended)** — one fresh subagent per task, two-stage review between tasks. 2. **Inline Execution** — execute tasks in this session with checkpoints. Both paths use TDD and commit after each task.