@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,127 @@
1
+ // Locale namespace = the plugin's cordis id (kebab). DSH flat namespace keys
2
+ // (settings + locale) use the internal id `task-kanban`; the scoped npm package
3
+ // name is reserved for package-bound identifiers (exported `name`, module-loader
4
+ // id, the /plugins/<pkg>/… route base).
5
+ export const LOCALE_NS = 'task-kanban'
6
+
7
+ export const zh = {
8
+ kanban: '任务看板',
9
+ openBoard: '打开任务看板',
10
+ closeBoard: '返回对话',
11
+ newTask: '新建任务',
12
+ requirement: '需求',
13
+ project: '所属项目',
14
+ model: '模型',
15
+ cancel: '取消',
16
+ close: '关闭',
17
+ addAndRefine: '添加并细化',
18
+ laneDemand: '需求',
19
+ laneQueue: '队列',
20
+ laneRunning: '运行中',
21
+ laneCompleted: '已完成',
22
+ laneMerged: '已合并',
23
+ statusDraft: '未细化',
24
+ statusRefining: '细化中',
25
+ statusPlanned: '已规划',
26
+ statusQueued: '排队中',
27
+ statusRunning: '实现中',
28
+ statusCompleted: '已完成',
29
+ statusMerging: '合并中',
30
+ statusMerged: '已合并',
31
+ statusError: '出错',
32
+ phaseProgress: '{current}/{total}',
33
+ retry: '重试',
34
+ stop: '停止',
35
+ delete: '删除',
36
+ deleteConfirm: '确定删除该任务?其 worktree 会被清理。',
37
+ openSession: '打开会话',
38
+ plan: '实现方案',
39
+ summary: '摘要',
40
+ phases: '阶段',
41
+ error: '错误',
42
+ requirementLabel: '需求文本',
43
+ requirementPlaceholder: '描述你要实现的需求……',
44
+ modelPlaceholder: '(使用默认模型)',
45
+ noWorkspace: '没有可用工作区,请先在侧边栏添加',
46
+ refresh: '刷新',
47
+ settingsTitle: '任务看板设置',
48
+ maxParallelWorkers: '全局并行 worker 数',
49
+ defaultModel: '默认模型',
50
+ saved: '已保存',
51
+ loadError: '加载任务失败',
52
+ noPlan: '该卡片还没有实现计划,请先点击"添加并细化"',
53
+ dragNoPlan: '还没有实现计划的卡片不能进入队列',
54
+ actionFailed: '操作失败:{message}',
55
+ sessionNotListed: '该会话不在当前列表,可能来自其他工作区',
56
+ interrupted: '已中断',
57
+ noBaseBranch: '项目没有 main/master 分支',
58
+ worktreeFailed: 'worktree 创建失败',
59
+ refineFailed: '细化失败',
60
+ phaseFailed: '阶段执行失败',
61
+ mergeFailed: '合并失败',
62
+ createFailed: '创建失败',
63
+ refinementLabel: '需求细化',
64
+ mergeLabel: '合并',
65
+ commitLabel: '提交',
66
+ }
67
+
68
+ export const en = {
69
+ kanban: 'Task Kanban',
70
+ openBoard: 'Open Task Kanban',
71
+ closeBoard: 'Back to chat',
72
+ newTask: 'New Task',
73
+ requirement: 'Requirement',
74
+ project: 'Project',
75
+ model: 'Model',
76
+ cancel: 'Cancel',
77
+ close: 'Close',
78
+ addAndRefine: 'Add & Refine',
79
+ laneDemand: 'Demands',
80
+ laneQueue: 'Queue',
81
+ laneRunning: 'Running',
82
+ laneCompleted: 'Completed',
83
+ laneMerged: 'Merged',
84
+ statusDraft: 'Unrefined',
85
+ statusRefining: 'Refining',
86
+ statusPlanned: 'Planned',
87
+ statusQueued: 'Queued',
88
+ statusRunning: 'Running',
89
+ statusCompleted: 'Completed',
90
+ statusMerging: 'Merging',
91
+ statusMerged: 'Merged',
92
+ statusError: 'Error',
93
+ phaseProgress: '{current}/{total}',
94
+ retry: 'Retry',
95
+ stop: 'Stop',
96
+ delete: 'Delete',
97
+ deleteConfirm: 'Delete this task? Its worktree will be cleaned up.',
98
+ openSession: 'Open session',
99
+ plan: 'Plan',
100
+ summary: 'Summary',
101
+ phases: 'Phases',
102
+ error: 'Error',
103
+ requirementLabel: 'Requirement',
104
+ requirementPlaceholder: 'Describe what you want to build…',
105
+ modelPlaceholder: '(use default model)',
106
+ noWorkspace: 'No workspaces available — add one in the sidebar first',
107
+ refresh: 'Refresh',
108
+ settingsTitle: 'Task Kanban Settings',
109
+ maxParallelWorkers: 'Global parallel workers',
110
+ defaultModel: 'Default model',
111
+ saved: 'Saved',
112
+ loadError: 'Failed to load tasks',
113
+ noPlan: 'This card has no plan yet — click "Add & Refine" first',
114
+ dragNoPlan: 'Cards without a plan cannot enter the queue',
115
+ actionFailed: 'Action failed: {message}',
116
+ sessionNotListed: 'Session is not listed here; it may belong to another workspace',
117
+ interrupted: 'Interrupted',
118
+ noBaseBranch: 'Project has no main/master branch',
119
+ worktreeFailed: 'Worktree creation failed',
120
+ refineFailed: 'Refinement failed',
121
+ phaseFailed: 'Phase failed',
122
+ mergeFailed: 'Merge failed',
123
+ createFailed: 'Creation failed',
124
+ refinementLabel: 'Refinement',
125
+ mergeLabel: 'Merge',
126
+ commitLabel: 'Commit',
127
+ }
@@ -0,0 +1,209 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import { api } from './api'
4
+ import { toggleBoard, useBoardOpen } from './kanban-state'
5
+ import type { KanbanSettingsShape } from '../shared/card'
6
+
7
+ interface FooterActionProps {
8
+ wide?: boolean
9
+ t?: (key: string) => string
10
+ }
11
+
12
+ /** Sidebar footer entry: toggles the kanban board main view.
13
+ * The shell renders footer actions on their own row ABOVE the settings row,
14
+ * so the button is reparented into the settings area to sit on the same
15
+ * line, right of the Settings trigger (see board.css .kb-settings-row). */
16
+ export function KanbanFooterButton({ wide, t }: FooterActionProps): JSX.Element {
17
+ const open = useBoardOpen()
18
+ const ref = useRef<HTMLButtonElement | null>(null)
19
+ const label = t !== undefined ? t(open ? 'closeBoard' : 'openBoard') : 'Task Kanban'
20
+
21
+ useEffect(() => {
22
+ const btn = ref.current
23
+ if (btn === null) return
24
+ let foot: Element | null = null
25
+ let disposed = false
26
+
27
+ // Locate the sidebar foot area WITHOUT moving any DOM: it is the ancestor
28
+ // of the button whose LAST child contains the settings trigger and which
29
+ // is itself the last child of its parent (root > footArea > settingsArea).
30
+ // The slot machinery wraps entries in an anonymous div, so the search
31
+ // tolerates extra wrapper levels. Styling is pure CSS (see board.css
32
+ // .kb-foot-row), so a failed lookup only leaves the button in its natural
33
+ // slot row — it can never vanish.
34
+ const findFoot = (): Element | null => {
35
+ let el: Element | null = btn.parentElement
36
+ while (el !== null && el !== document.body && el.parentElement !== null) {
37
+ const last = el.lastElementChild
38
+ const hasTrigger =
39
+ last !== null &&
40
+ (last.querySelector?.('button[aria-haspopup="dialog"]') !== null ||
41
+ last.querySelector?.('button[aria-haspopup="menu"]') !== null)
42
+ const isLastChild = el.parentElement.lastElementChild === el
43
+ if (hasTrigger && isLastChild) return el
44
+ el = el.parentElement
45
+ }
46
+ return null
47
+ }
48
+
49
+ const tryApply = (): boolean => {
50
+ if (foot === null) {
51
+ try {
52
+ foot = findFoot()
53
+ } catch {
54
+ foot = null
55
+ }
56
+ }
57
+ if (foot === null) return false
58
+ try {
59
+ foot.classList.add('kb-foot-row')
60
+ foot.classList.toggle('kb-foot-rail', wide !== true)
61
+ } catch {
62
+ // element already gone; retry will re-locate
63
+ }
64
+ return true
65
+ }
66
+
67
+ let applied = false
68
+ try {
69
+ applied = tryApply()
70
+ } catch (error) {
71
+ console.error('[@fonlan/dsh-task-kanban] foot placement error:', error)
72
+ }
73
+ if (!applied) {
74
+ // The settings entry may mount later than this entry (plugin load
75
+ // order); retry on DOM mutations AND on a short interval until the
76
+ // foot area is found (give up after 15s).
77
+ const observer = new MutationObserver(() => {
78
+ if (disposed) return
79
+ if (tryApply()) observer.disconnect()
80
+ })
81
+ observer.observe(document.body, { childList: true, subtree: true })
82
+ const timer = window.setInterval(() => {
83
+ if (disposed) {
84
+ window.clearInterval(timer)
85
+ return
86
+ }
87
+ if (tryApply()) {
88
+ observer.disconnect()
89
+ window.clearInterval(timer)
90
+ }
91
+ }, 1000)
92
+ window.setTimeout(() => {
93
+ observer.disconnect()
94
+ window.clearInterval(timer)
95
+ if (foot === null) {
96
+ // report the DOM chain so the next report carries evidence
97
+ const chain: string[] = []
98
+ let el: Element | null = btn.parentElement
99
+ while (el !== null && chain.length < 8) {
100
+ chain.push(el.tagName + '.' + String(el.className).slice(0, 60))
101
+ el = el.parentElement
102
+ }
103
+ console.warn('[@fonlan/dsh-task-kanban] could not locate the sidebar foot area:', chain)
104
+ }
105
+ }, 15000)
106
+ return () => {
107
+ disposed = true
108
+ observer.disconnect()
109
+ window.clearInterval(timer)
110
+ try {
111
+ foot?.classList.remove('kb-foot-row')
112
+ foot?.classList.remove('kb-foot-rail')
113
+ } catch {
114
+ // already gone
115
+ }
116
+ }
117
+ }
118
+ return () => {
119
+ try {
120
+ foot?.classList.remove('kb-foot-row')
121
+ foot?.classList.remove('kb-foot-rail')
122
+ } catch {
123
+ // already gone
124
+ }
125
+ }
126
+ }, [wide])
127
+
128
+ return (
129
+ <button
130
+ ref={ref}
131
+ type="button"
132
+ className={'kb-footer-action' + (wide === true ? ' kb-footer-action-wide' : '') + (open ? ' kb-footer-action-active' : '')}
133
+ aria-label={label}
134
+ title={label}
135
+ onClick={() => toggleBoard()}
136
+ >
137
+ <IconChecklistOutline14 size={wide === true ? 16 : 18} />
138
+ {wide === true && <span className="kb-footer-label">{t !== undefined ? t('kanban') : 'Kanban'}</span>}
139
+ </button>
140
+ )
141
+ }
142
+
143
+ interface SettingsSectionProps {
144
+ t?: (key: string) => string
145
+ }
146
+
147
+ /** Plugin settings section: global parallel workers + default model. */
148
+ export function KanbanSettingsSection({ t }: SettingsSectionProps): JSX.Element {
149
+ const tr = t ?? ((key: string) => key)
150
+ const [settings, setSettings] = useState<KanbanSettingsShape>({ maxParallelWorkers: 1, defaultModel: '' })
151
+ const [models, setModels] = useState<Array<{ provider: string; id: string; name?: string }>>([])
152
+ const [saved, setSaved] = useState(false)
153
+
154
+ useEffect(() => {
155
+ let alive = true
156
+ api.settingsGet()
157
+ .then((s) => { if (alive) setSettings(s) })
158
+ .catch(() => undefined)
159
+ api.models()
160
+ .then((list) => { if (alive) setModels(list) })
161
+ .catch(() => undefined)
162
+ return () => { alive = false }
163
+ }, [])
164
+
165
+ const commit = async (patch: Partial<KanbanSettingsShape>): Promise<void> => {
166
+ try {
167
+ const next = await api.settingsSet(patch)
168
+ setSettings(next)
169
+ setSaved(true)
170
+ window.setTimeout(() => setSaved(false), 1500)
171
+ } catch {
172
+ // keep the previous value
173
+ }
174
+ }
175
+
176
+ return (
177
+ <div className="kb-settings">
178
+ <label className="kb-field kb-field-row">
179
+ <span>{tr('maxParallelWorkers')}</span>
180
+ <input
181
+ className="kb-input kb-input-number"
182
+ type="number"
183
+ min={1}
184
+ step={1}
185
+ value={settings.maxParallelWorkers}
186
+ onChange={(e) => {
187
+ const value = Math.max(1, Math.floor(Number(e.target.value) || 1))
188
+ setSettings((s) => ({ ...s, maxParallelWorkers: value }))
189
+ void commit({ maxParallelWorkers: value })
190
+ }}
191
+ />
192
+ </label>
193
+ <label className="kb-field kb-field-row">
194
+ <span>{tr('defaultModel')}</span>
195
+ <select
196
+ className="kb-input"
197
+ value={settings.defaultModel}
198
+ onChange={(e) => { void commit({ defaultModel: e.target.value }) }}
199
+ >
200
+ <option value="">—</option>
201
+ {models.map((m) => (
202
+ <option key={m.provider + '/' + m.id} value={m.id}>{m.name ?? m.id}</option>
203
+ ))}
204
+ </select>
205
+ </label>
206
+ {saved && <div className="kb-settings-saved">{tr('saved')}</div>}
207
+ </div>
208
+ )
209
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Declare this plugin's locale namespace so slot registrations can type-check
3
+ * the `locale` seat and receive the typed `t` prop. zh is the key source
4
+ * of truth; en must carry the exact same key set (checked at runtime by the
5
+ * locale registry).
6
+ */
7
+ import type { zh } from './locales'
8
+
9
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
10
+ interface LocaleNamespaceMap {
11
+ 'task-kanban': keyof typeof zh
12
+ }
13
+ }
14
+
15
+ export {}
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @fonlan/dsh-task-kanban host half: the task kanban engine.
3
+ *
4
+ * Mounts a file-backed task store under each workspace's
5
+ * `.dsh/task-kanban/`, runs refinement/phase/merge sessions through
6
+ * `ctx.agents` (standard coding-agent preset + kanban-scoped tools), and
7
+ * serves the fenced JSON API the board UI calls.
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import z from '@deepseek-ai/schemastery'
11
+ import { registerSettings } from './server/settings.js'
12
+ import { TaskStore } from './server/task-store.js'
13
+ import { KanbanRunner } from './server/runner.js'
14
+ import { registerApiRoutes } from './server/rpc.js'
15
+
16
+ export const name = '@fonlan/dsh-task-kanban'
17
+
18
+ export const inject = ['webServer', 'agents']
19
+
20
+ export const Config = z.object({})
21
+
22
+ export function apply(ctx: Context, _config: unknown): void {
23
+ const settings = registerSettings(ctx)
24
+ const store = new TaskStore()
25
+ const runner = new KanbanRunner(ctx, store, settings)
26
+
27
+ ctx.effect(() => {
28
+ void runner.recoverInterrupted()
29
+ runner.start()
30
+ return () => runner.stop()
31
+ }, 'task-kanban: runner lifecycle')
32
+
33
+ ctx.effect(() => registerApiRoutes(ctx, runner, settings), 'task-kanban: api routes')
34
+ }
@@ -0,0 +1,68 @@
1
+ /** Thin typed wrapper over the git CLI (all merge/web-tree flow runs here). */
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+
5
+ const execFileAsync = promisify(execFile)
6
+
7
+ export interface GitResult {
8
+ code: number
9
+ stdout: string
10
+ stderr: string
11
+ }
12
+
13
+ export async function runGit(cwd: string, args: string[]): Promise<GitResult> {
14
+ try {
15
+ const { stdout, stderr } = await execFileAsync('git', args, { cwd, maxBuffer: 128 * 1024 * 1024, timeout: 600000 })
16
+ return { code: 0, stdout: String(stdout), stderr: String(stderr) }
17
+ } catch (error: unknown) {
18
+ const e = error as { code?: number | string; stdout?: string | Buffer; stderr?: string | Buffer }
19
+ return {
20
+ code: typeof e.code === 'number' ? e.code : 1,
21
+ stdout: e.stdout !== undefined ? String(e.stdout) : '',
22
+ stderr: e.stderr !== undefined ? String(e.stderr) : String(error),
23
+ }
24
+ }
25
+ }
26
+
27
+ export async function isGitRepo(dir: string): Promise<boolean> {
28
+ const r = await runGit(dir, ['rev-parse', '--is-inside-work-tree'])
29
+ return r.code === 0 && r.stdout.trim() === 'true'
30
+ }
31
+
32
+ export async function hasBranch(dir: string, name: string): Promise<boolean> {
33
+ const r = await runGit(dir, ['rev-parse', '--verify', `refs/heads/${name}`])
34
+ return r.code === 0
35
+ }
36
+
37
+ export async function detectBaseRef(dir: string): Promise<string | undefined> {
38
+ if (await hasBranch(dir, 'main')) return 'main'
39
+ if (await hasBranch(dir, 'master')) return 'master'
40
+ return undefined
41
+ }
42
+
43
+ export async function currentBranch(dir: string): Promise<string | undefined> {
44
+ const r = await runGit(dir, ['symbolic-ref', '--short', 'HEAD'])
45
+ return r.code === 0 && r.stdout.trim() !== '' ? r.stdout.trim() : undefined
46
+ }
47
+
48
+ export async function revParse(dir: string, ref: string): Promise<string | undefined> {
49
+ const r = await runGit(dir, ['rev-parse', ref])
50
+ return r.code === 0 ? r.stdout.trim() : undefined
51
+ }
52
+
53
+ /** True when the working tree has tracked changes (staged or unstaged). */
54
+ export async function isTreeDirty(dir: string): Promise<boolean> {
55
+ const r = await runGit(dir, ['status', '--porcelain', '--untracked-files=no'])
56
+ return r.code === 0 && r.stdout.trim() !== ''
57
+ }
58
+
59
+ export async function hasStashMessage(dir: string, message: string): Promise<boolean> {
60
+ const r = await runGit(dir, ['stash', 'list'])
61
+ return r.code === 0 && r.stdout.includes(message)
62
+ }
63
+
64
+ /** Unmerged path list after a conflicted merge (empty when clean). */
65
+ export async function unmergedPaths(dir: string): Promise<string> {
66
+ const r = await runGit(dir, ['diff', '--name-only', '--diff-filter=U'])
67
+ return r.code === 0 ? r.stdout.trim() : ''
68
+ }
@@ -0,0 +1,20 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type { ModelOption } from '../shared/card.js'
3
+
4
+ /** Available models from every registered LLM provider (advisory catalog). */
5
+ export async function listModels(ctx: Context): Promise<ModelOption[]> {
6
+ const llm = ctx.get('llm') as
7
+ | { listProviders(): Array<{ id: string; name?: string }>; listModels(provider: string): Promise<Array<{ id: string; name?: string }>> }
8
+ | undefined
9
+ if (llm === undefined) return []
10
+ const out: ModelOption[] = []
11
+ for (const provider of llm.listProviders()) {
12
+ try {
13
+ const models = await llm.listModels(provider.id)
14
+ for (const m of models) out.push({ provider: provider.id, id: m.id, name: m.name ?? m.id })
15
+ } catch {
16
+ // provider without a model catalog: skip
17
+ }
18
+ }
19
+ return out
20
+ }
@@ -0,0 +1,54 @@
1
+ import type { KanbanCard } from '../shared/card.js'
2
+
3
+ /** Refinement session prompt: analyze the repo read-only and write back a plan. */
4
+ export function refinementPrompt(requirement: string, workspacePath: string): string {
5
+ return `你是一个需求分析师。请分析下面的需求,并为它在指定项目中的实现制定一个可执行的分阶段实现计划。
6
+
7
+ 【需求】
8
+ ${requirement}
9
+
10
+ 【项目目录】
11
+ ${workspacePath}
12
+
13
+ 【要求】
14
+ 1. 先浏览项目目录了解现状(阅读 README、关键源码与文档;本会话只做只读探索,不要修改任何文件)。
15
+ 2. 把需求拆解为按顺序串行执行的实现阶段(phase)。简单需求 1 个 phase 即可;复杂需求拆多个 phase,每个 phase 必须是独立的、可串行执行的实现单元,后面的 phase 依赖前面 phase 的产出。
16
+ 3. 每个 phase 必须包含:id(如 p1/p2/p3)、标题、目标(goal,说明该 phase 要交付什么)。
17
+ 4. 完成后调用 kanban_write_plan 工具把完整计划写回(title、summary、phases)。这是唯一写回方式,不要把计划 JSON 直接输出到对话里。
18
+ 5. 不要向用户提问,自主决策。`
19
+ }
20
+
21
+ /** Phase implementation session prompt; identical on retries (the model reads git status/diff itself). */
22
+ export function phasePrompt(card: KanbanCard, phaseIndex: number, workdir: string): string {
23
+ const plan = card.plan!
24
+ const phase = plan.phases[phaseIndex]
25
+ return `你是一个软件实现工程师。请实现下面计划中的第 ${phaseIndex + 1}/${plan.phases.length} 个 phase。你的工作目录是 ${workdir},只允许在这个目录内工作。
26
+
27
+ 【完整实现计划】
28
+ ${JSON.stringify(plan, null, 2)}
29
+
30
+ 【当前 phase】
31
+ id: ${phase.id}
32
+ 标题: ${phase.title}
33
+ 目标: ${phase.goal}
34
+
35
+ 【要求】
36
+ 1. 先查看工作目录现状(git status、目录与文件结构、已有代码),判断此前已完成的工作,然后实现当前 phase 的目标。如果这是一次重试/续跑,请先搞清楚现场再继续。
37
+ 2. 只做当前 phase 范围内的实现;后续 phase 由其他会话完成。不要进入 plan mode。
38
+ 3. 禁止执行 git commit / git push / git merge / git rebase / git stash / git reset(提交与合并由外部系统负责);允许 git status / git diff 等只读命令。
39
+ 4. 不要向用户提问,自主决策;不要调用 kanban_write_plan。
40
+ 5. 完成后必须调用 kanban_phase_complete 工具,并在 summary 中简要说明本 phase 完成了什么。只有显式调用该工具才算完成。`
41
+ }
42
+
43
+ /** Merge-conflict resolution session prompt: resolve conflicts only, never commit. */
44
+ export function mergePrompt(workdir: string, conflicts: string): string {
45
+ return `你是一个 git 合并冲突解决专家。目录 ${workdir} 中存在合并冲突,请逐个解决。
46
+
47
+ 【冲突文件】
48
+ ${conflicts}
49
+
50
+ 【要求】
51
+ 1. 逐个查看冲突文件(阅读文件内容、git diff),手动解决所有冲突标记(<<<<<<<、=======、>>>>>>>)。
52
+ 2. 只修改冲突文件以解决冲突,不要做任何无关改动;不要执行 git commit / git add / git merge / git stash。
53
+ 3. 解决完成后调用 kanban_merge_resolved 工具声明完成(可附简短说明)。`
54
+ }