@fonlan/dsh-task-kanban 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * User-facing plugin settings (global parallel worker count, default model),
3
+ * persisted through the settings service namespace `task-kanban`. The DSH
4
+ * settings service requires a lowercase kebab-case namespace
5
+ * (/^[a-z][a-z0-9-]*$/), so the scoped package name cannot be used here.
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import z from '@deepseek-ai/schemastery'
9
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
10
+ import type { KanbanSettingsShape } from '../shared/card.js'
11
+
12
+ export const KANBAN_SETTINGS_NS = settingsNamespace('task-kanban')
13
+
14
+ export const KanbanSettingsSchema = z.object({
15
+ maxParallelWorkers: z.natural().min(1).default(1),
16
+ defaultModel: z.string().default(''),
17
+ })
18
+
19
+ export interface KanbanSettingsFace {
20
+ get(): KanbanSettingsShape
21
+ update(patch: Partial<KanbanSettingsShape>): Promise<void>
22
+ /** Default provider/model route from the agent-default-model settings namespace. */
23
+ defaultModelRoute(): { provider?: string; model?: string }
24
+ }
25
+
26
+ /** Structural settings-service subset; the real service resolves the schema. */
27
+ interface SettingsServiceLike {
28
+ register(ns: unknown, schema: unknown): unknown
29
+ update(ns: unknown, patch: Record<string, unknown>, expectedRevision?: number): Promise<unknown>
30
+ }
31
+
32
+ export function registerSettings(ctx: Context): KanbanSettingsFace {
33
+ let current: KanbanSettingsShape = { maxParallelWorkers: 1, defaultModel: '' }
34
+ let service: SettingsServiceLike | undefined
35
+ ctx.inject(['settings'], (sctx) => {
36
+ service = sctx.settings as unknown as SettingsServiceLike
37
+ const scope = service.register(KANBAN_SETTINGS_NS, KanbanSettingsSchema) as {
38
+ get(): KanbanSettingsShape
39
+ watch(callback: (next: KanbanSettingsShape) => void): () => void
40
+ }
41
+ current = scope.get()
42
+ scope.watch((next) => { current = next })
43
+ })
44
+ return {
45
+ get: () => current,
46
+ defaultModelRoute: () => {
47
+ if (service === undefined) return {}
48
+ try {
49
+ const descriptors = (service as unknown as { describe(opts: { redactSecrets: boolean }): Array<{ ns: unknown; value: unknown }> })
50
+ .describe({ redactSecrets: true })
51
+ for (const d of descriptors) {
52
+ if (String(d.ns) === 'agent-default-model') {
53
+ const v = (d.value ?? {}) as { provider?: string; model?: string }
54
+ return { provider: v.provider, model: v.model }
55
+ }
56
+ }
57
+ } catch {
58
+ // fall through
59
+ }
60
+ return {}
61
+ },
62
+ update: async (patch) => {
63
+ if (service === undefined) {
64
+ throw new Error('@fonlan/dsh-task-kanban: settings service is not available in this profile')
65
+ }
66
+ await service.update(KANBAN_SETTINGS_NS, patch as Record<string, unknown>)
67
+ },
68
+ }
69
+ }
@@ -0,0 +1,128 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, readFile, writeFile, rename, readdir, rm } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { CARD_SCHEMA_VERSION, type KanbanCard } from '../shared/card.js'
5
+
6
+ const TASKS_REL = ['.dsh', 'task-kanban', 'tasks']
7
+
8
+ /**
9
+ * File-backed task store: one JSON file per card under
10
+ * `<workspace>/.dsh/task-kanban/tasks/<id>.json`. In-process cache plus
11
+ * atomic (tmp+rename) writes, with a per-card mutex so worker and RPC
12
+ * mutations never interleave.
13
+ */
14
+ export class TaskStore {
15
+ private cache = new Map<string, KanbanCard>()
16
+ private locks = new Map<string, Promise<unknown>>()
17
+
18
+ private taskDir(workspacePath: string): string {
19
+ return join(workspacePath, ...TASKS_REL)
20
+ }
21
+
22
+ private fileOf(workspacePath: string, id: string): string {
23
+ return join(this.taskDir(workspacePath), `${id}.json`)
24
+ }
25
+
26
+ private withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
27
+ const prev = this.locks.get(key) ?? Promise.resolve()
28
+ const next = prev.then(fn, fn)
29
+ this.locks.set(key, next.catch(() => undefined))
30
+ return next
31
+ }
32
+
33
+ async list(workspacePath: string): Promise<KanbanCard[]> {
34
+ const dir = this.taskDir(workspacePath)
35
+ let files: string[]
36
+ try {
37
+ files = await readdir(dir)
38
+ } catch {
39
+ return []
40
+ }
41
+ const out: KanbanCard[] = []
42
+ for (const f of files) {
43
+ if (!f.endsWith('.json')) continue
44
+ const id = f.slice(0, -5)
45
+ const card = await this.get(workspacePath, id)
46
+ if (card !== undefined) out.push(card)
47
+ }
48
+ out.sort((a, b) => a.createdAt - b.createdAt)
49
+ return out
50
+ }
51
+
52
+ async get(workspacePath: string, id: string): Promise<KanbanCard | undefined> {
53
+ const cached = this.cache.get(id)
54
+ if (cached !== undefined && cached.workspacePath === workspacePath) return cached
55
+ try {
56
+ const raw = await readFile(this.fileOf(workspacePath, id), 'utf8')
57
+ const card = JSON.parse(raw) as KanbanCard
58
+ this.cache.set(id, card)
59
+ return card
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }
64
+
65
+ async create(input: {
66
+ workspacePath: string
67
+ requirement: string
68
+ model: string
69
+ provider?: string
70
+ status?: KanbanCard['status']
71
+ }): Promise<KanbanCard> {
72
+ const id = randomUUID()
73
+ const card: KanbanCard = {
74
+ schemaVersion: CARD_SCHEMA_VERSION,
75
+ id,
76
+ workspacePath: input.workspacePath,
77
+ requirement: input.requirement,
78
+ model: input.model,
79
+ ...(input.provider !== undefined ? { provider: input.provider } : {}),
80
+ status: input.status ?? 'draft',
81
+ currentPhase: 0,
82
+ phaseCount: 0,
83
+ sessions: { refinement: [], phases: [], merge: [] },
84
+ createdAt: Date.now(),
85
+ }
86
+ await this.write(card)
87
+ return card
88
+ }
89
+
90
+ /** Run a mutation under the card's lock; returns the updated card. */
91
+ async mutate(
92
+ id: string,
93
+ fn: (card: KanbanCard) => void | Promise<void>,
94
+ workspacePath?: string,
95
+ ): Promise<KanbanCard | undefined> {
96
+ return this.withLock(id, async () => {
97
+ let card = this.cache.get(id)
98
+ if ((card === undefined || (workspacePath !== undefined && card.workspacePath !== workspacePath)) && workspacePath !== undefined) {
99
+ card = await this.get(workspacePath, id)
100
+ }
101
+ if (card === undefined) return undefined
102
+ await fn(card)
103
+ await this.write(card)
104
+ return card
105
+ })
106
+ }
107
+
108
+ private async write(card: KanbanCard): Promise<void> {
109
+ const dir = this.taskDir(card.workspacePath)
110
+ await mkdir(dir, { recursive: true })
111
+ const file = this.fileOf(card.workspacePath, card.id)
112
+ const tmp = file + '.tmp'
113
+ await writeFile(tmp, JSON.stringify(card, null, 2), 'utf8')
114
+ await rename(tmp, file)
115
+ this.cache.set(card.id, card)
116
+ }
117
+
118
+ async remove(workspacePath: string, id: string): Promise<void> {
119
+ await this.withLock(id, async () => {
120
+ this.cache.delete(id)
121
+ try {
122
+ await rm(this.fileOf(workspacePath, id), { force: true })
123
+ } catch {
124
+ // already gone
125
+ }
126
+ })
127
+ }
128
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The three kanban model-facing tools, registered SCOPED into the sessions
3
+ * that need them (only refinement/phase/merge agents see them):
4
+ * - kanban_write_plan → refinement session writes the plan back to the card
5
+ * - kanban_phase_complete → a phase implementation session declares its phase done
6
+ * - kanban_merge_resolved → a conflict merge session declares conflicts resolved
7
+ */
8
+ import { defineTool } from '@deepseek-ai/dsh-tools'
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import { validatePlan } from '../shared/plan.js'
11
+ import type { Plan } from '../shared/card.js'
12
+
13
+ export interface KanbanToolResolver {
14
+ /** Resolve the card id attached to a calling session id. */
15
+ cardOfSession(sessionId?: string): string | undefined
16
+ writePlan(cardId: string, plan: Plan): Promise<void>
17
+ /** Mark the phase done; returns the phase id for the tool output. */
18
+ phaseComplete(sessionId: string, summary: string): Promise<string>
19
+ mergeResolved(sessionId: string): Promise<void>
20
+ }
21
+
22
+ export function registerKanbanTools(agentCtx: Context, resolver: KanbanToolResolver): void {
23
+ agentCtx.tools.register(defineTool({
24
+ name: 'kanban_write_plan',
25
+ description: '把当前需求的完整分阶段实现计划写回任务卡片。调用一次,参数为完整计划(title、summary、按执行顺序排列的 phases)。成功写回后本细化会话即结束。',
26
+ parameters: {
27
+ title: { type: 'string', required: true, description: '计划标题' },
28
+ summary: { type: 'string', required: true, description: '计划摘要' },
29
+ phases: {
30
+ type: 'array',
31
+ required: true,
32
+ items: {
33
+ type: 'object',
34
+ additionalProperties: false,
35
+ properties: {
36
+ id: { type: 'string', required: true, description: '阶段 id,如 p1' },
37
+ title: { type: 'string', required: true, description: '阶段标题' },
38
+ goal: { type: 'string', required: true, description: '阶段目标:本阶段要交付什么' },
39
+ },
40
+ },
41
+ description: '按顺序串行执行的实现阶段;必须非空',
42
+ },
43
+ },
44
+ output: {
45
+ schema: {
46
+ type: 'object',
47
+ additionalProperties: false,
48
+ properties: {
49
+ ok: { type: 'boolean', required: true },
50
+ taskId: { type: 'string', required: true },
51
+ },
52
+ },
53
+ render: (_args, value) => [{ type: 'text', text: `实现计划已写回任务卡片 ${value.taskId}。` }],
54
+ },
55
+ async execute(args, exec) {
56
+ const sessionId = exec.agent?.id
57
+ const cardId = resolver.cardOfSession(sessionId)
58
+ if (cardId === undefined) {
59
+ throw new Error('当前会话没有关联的任务卡片,无法写回计划')
60
+ }
61
+ const result = validatePlan(args)
62
+ if (!result.ok) {
63
+ throw new Error(result.message)
64
+ }
65
+ const plan = result.plan
66
+ await resolver.writePlan(cardId, plan)
67
+ exec.concludeTurn()
68
+ return { ok: true, taskId: cardId }
69
+ },
70
+ }))
71
+
72
+ agentCtx.tools.register(defineTool({
73
+ name: 'kanban_phase_complete',
74
+ description: '声明当前 phase 实现完成。在工作完成后必须显式调用本工具(附完成摘要),否则该 phase 会被视为未完成。',
75
+ parameters: {
76
+ summary: { type: 'string', required: true, description: '本 phase 完成了什么的简要说明' },
77
+ },
78
+ output: {
79
+ schema: {
80
+ type: 'object',
81
+ additionalProperties: false,
82
+ properties: {
83
+ ok: { type: 'boolean', required: true },
84
+ phase: { type: 'string', required: true },
85
+ },
86
+ },
87
+ render: (_args, value) => [{ type: 'text', text: `phase ${value.phase} 已标记完成。` }],
88
+ },
89
+ async execute(args, exec) {
90
+ const sessionId = exec.agent?.id
91
+ const cardId = resolver.cardOfSession(sessionId)
92
+ if (cardId === undefined) {
93
+ throw new Error('当前会话没有关联的任务卡片')
94
+ }
95
+ if (typeof args.summary !== 'string' || args.summary.trim() === '') {
96
+ throw new Error('summary 不能为空')
97
+ }
98
+ const phase = await resolver.phaseComplete(sessionId!, args.summary)
99
+ exec.concludeTurn()
100
+ return { ok: true, phase }
101
+ },
102
+ }))
103
+
104
+ agentCtx.tools.register(defineTool({
105
+ name: 'kanban_merge_resolved',
106
+ description: '声明当前目录中的合并冲突已全部解决。只用于合并冲突解决会话;声明后本会话即将结束。',
107
+ parameters: {
108
+ summary: { type: 'string', description: '可选的解决方案说明' },
109
+ },
110
+ output: {
111
+ schema: {
112
+ type: 'object',
113
+ additionalProperties: false,
114
+ properties: {
115
+ ok: { type: 'boolean', required: true },
116
+ },
117
+ },
118
+ render: () => [{ type: 'text', text: '冲突已标记为解决。' }],
119
+ },
120
+ async execute(_args, exec) {
121
+ const sessionId = exec.agent?.id
122
+ const cardId = resolver.cardOfSession(sessionId)
123
+ if (cardId === undefined) {
124
+ throw new Error('当前会话没有关联的任务卡片')
125
+ }
126
+ await resolver.mergeResolved(sessionId!)
127
+ exec.concludeTurn()
128
+ return { ok: true }
129
+ },
130
+ }))
131
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Shared card/plan vocabulary used by both the server half (task store,
3
+ * runners) and the client half (board UI). Pure types: no runtime imports so
4
+ * it survives the client bundle's purity gate and gets inlined into both.
5
+ */
6
+
7
+ export const CARD_SCHEMA_VERSION = 1
8
+
9
+ /** Coarse card lifecycle status. */
10
+ export type CardStatus =
11
+ | 'draft'
12
+ | 'refining'
13
+ | 'planned'
14
+ | 'queued'
15
+ | 'running'
16
+ | 'completed'
17
+ | 'merging'
18
+ | 'merged'
19
+ | 'error'
20
+
21
+ /** Error kinds; each maps back to a resume action (retry/stop/delete). */
22
+ export type ErrorKind =
23
+ | 'refine_failed'
24
+ | 'phase_failed'
25
+ | 'interrupted'
26
+ | 'merge_failed'
27
+ | 'no_base_branch'
28
+ | 'worktree_failed'
29
+ | 'create_failed'
30
+
31
+ export interface CardError {
32
+ kind: ErrorKind
33
+ message: string
34
+ at: number
35
+ /** Lane the card was in when it failed (interrupted cards need this). */
36
+ stage?: Lane
37
+ }
38
+
39
+ /** One phase of a plan; phases execute strictly serially, each in its own session. */
40
+ export interface PlanPhase {
41
+ id: string
42
+ title: string
43
+ goal: string
44
+ }
45
+
46
+ export interface Plan {
47
+ schemaVersion: number
48
+ title: string
49
+ summary: string
50
+ phases: PlanPhase[]
51
+ }
52
+
53
+ /** One session attempt of a phase (retries append new attempts). */
54
+ export interface PhaseAttempt {
55
+ phaseIndex: number
56
+ sessionId: string
57
+ summary?: string
58
+ startedAt: number
59
+ completedAt?: number
60
+ }
61
+
62
+ /** Merge flow steps; retry resumes from the recorded step. */
63
+ export type MergeStep = 'prepare' | 'wt-merge' | 'ws-merge' | 'pop' | 'cleanup'
64
+
65
+ export interface MergeState {
66
+ step: MergeStep
67
+ stashApplied?: boolean
68
+ mergeCommit?: string
69
+ }
70
+
71
+ export interface KanbanCard {
72
+ schemaVersion: number
73
+ id: string
74
+ workspacePath: string
75
+ requirement: string
76
+ model: string
77
+ /** Provider route for the model (from the modal selection). */
78
+ provider?: string
79
+ status: CardStatus
80
+ error?: CardError
81
+ /** Git-mode: implementation happens in a worktree and merges back. */
82
+ gitMode?: boolean
83
+ baseRef?: string
84
+ baseSha?: string
85
+ branch?: string
86
+ worktreePath?: string
87
+ plan?: Plan
88
+ /** Index of the phase currently running / to resume from. */
89
+ currentPhase: number
90
+ phaseCount: number
91
+ sessions: {
92
+ refinement: string[]
93
+ phases: PhaseAttempt[]
94
+ merge: string[]
95
+ }
96
+ merge?: MergeState
97
+ queuedAt?: number
98
+ createdAt: number
99
+ stoppedAt?: number
100
+ }
101
+
102
+ /** The five board lanes. */
103
+ export type Lane = 'demand' | 'queue' | 'running' | 'completed' | 'merged'
104
+
105
+ /** Model entry for the new-task dropdown. */
106
+ export interface ModelOption {
107
+ provider: string
108
+ id: string
109
+ name?: string
110
+ }
111
+
112
+ export interface KanbanSettingsShape {
113
+ maxParallelWorkers: number
114
+ defaultModel: string
115
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Lane mapping and drag rules (shared by the board UI and tests).
3
+ */
4
+ import type { KanbanCard, Lane } from './card.js'
5
+
6
+ /** Which lane a card renders in, based on status + error stage. */
7
+ export function laneOf(card: KanbanCard): Lane {
8
+ if (card.status === 'error') {
9
+ if (card.error?.stage !== undefined) return card.error.stage
10
+ const kind = card.error?.kind
11
+ if (kind === 'phase_failed' || kind === 'no_base_branch' || kind === 'worktree_failed') return 'running'
12
+ if (kind === 'merge_failed') return 'completed'
13
+ return 'demand'
14
+ }
15
+ switch (card.status) {
16
+ case 'draft':
17
+ case 'refining':
18
+ case 'planned':
19
+ return 'demand'
20
+ case 'queued':
21
+ return 'queue'
22
+ case 'running':
23
+ return 'running'
24
+ case 'completed':
25
+ case 'merging':
26
+ return 'completed'
27
+ case 'merged':
28
+ return 'merged'
29
+ }
30
+ }
31
+
32
+ /** Lanes a drop onto `to` may come from. */
33
+ export const DROP_RULES: Record<Lane, Lane[]> = {
34
+ demand: ['queue'],
35
+ queue: ['demand', 'running'],
36
+ running: [],
37
+ completed: [],
38
+ merged: [],
39
+ }
40
+
41
+ export function canDrop(from: Lane, to: Lane): boolean {
42
+ return DROP_RULES[to].includes(from)
43
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Plan payload validation (shared by the write-back tool and tests).
3
+ */
4
+ import type { Plan } from './card.js'
5
+
6
+ export type ValidatePlanResult =
7
+ | { ok: true; plan: Plan }
8
+ | { ok: false; message: string }
9
+
10
+ export function validatePlan(input: unknown): ValidatePlanResult {
11
+ if (typeof input !== 'object' || input === null) {
12
+ return { ok: false, message: '计划必须是对象' }
13
+ }
14
+ const obj = input as Record<string, unknown>
15
+ if (typeof obj.title !== 'string' || obj.title.trim() === '') {
16
+ return { ok: false, message: 'title 不能为空' }
17
+ }
18
+ if (typeof obj.summary !== 'string' || obj.summary.trim() === '') {
19
+ return { ok: false, message: 'summary 不能为空' }
20
+ }
21
+ if (!Array.isArray(obj.phases) || obj.phases.length === 0) {
22
+ return { ok: false, message: 'phases 必须是非空数组' }
23
+ }
24
+ const seen = new Set<string>()
25
+ for (const phase of obj.phases) {
26
+ if (typeof phase !== 'object' || phase === null) {
27
+ return { ok: false, message: 'phase 必须是对象' }
28
+ }
29
+ const p = phase as Record<string, unknown>
30
+ if (typeof p.id !== 'string' || typeof p.title !== 'string' || typeof p.goal !== 'string') {
31
+ return { ok: false, message: '每个 phase 都必须包含 id/title/goal' }
32
+ }
33
+ if (p.id.trim() === '' || p.title.trim() === '' || p.goal.trim() === '') {
34
+ return { ok: false, message: '每个 phase 的 id/title/goal 都不能为空' }
35
+ }
36
+ if (seen.has(p.id)) {
37
+ return { ok: false, message: `phase id 重复: ${p.id}` }
38
+ }
39
+ seen.add(p.id)
40
+ }
41
+ return {
42
+ ok: true,
43
+ plan: {
44
+ schemaVersion: 1,
45
+ title: obj.title,
46
+ summary: obj.summary,
47
+ phases: (obj.phases as Array<{ id: string; title: string; goal: string }>).map((p) => ({
48
+ id: p.id,
49
+ title: p.title,
50
+ goal: p.goal,
51
+ })),
52
+ },
53
+ }
54
+ }