@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1128 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1514 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/server/git.d.ts +16 -0
- package/lib/types/server/models.d.ts +4 -0
- package/lib/types/server/prompts.d.ts +7 -0
- package/lib/types/server/rpc.d.ts +4 -0
- package/lib/types/server/runner.d.ts +84 -0
- package/lib/types/server/settings.d.ts +27 -0
- package/lib/types/server/task-store.d.ts +27 -0
- package/lib/types/server/tools.d.ts +11 -0
- package/lib/types/shared/card.d.ts +86 -0
- package/lib/types/shared/lanes.d.ts +9 -0
- package/lib/types/shared/plan.d.ts +12 -0
- package/package.json +105 -0
- package/src/client/api.ts +42 -0
- package/src/client/board.css +361 -0
- package/src/client/board.tsx +516 -0
- package/src/client/index.tsx +47 -0
- package/src/client/kanban-state.ts +104 -0
- package/src/client/locales.ts +127 -0
- package/src/client/sections.tsx +209 -0
- package/src/client/slot-augment.d.ts +15 -0
- package/src/index.ts +34 -0
- package/src/server/git.ts +68 -0
- package/src/server/models.ts +20 -0
- package/src/server/prompts.ts +54 -0
- package/src/server/rpc.ts +167 -0
- package/src/server/runner.ts +834 -0
- package/src/server/settings.ts +69 -0
- package/src/server/task-store.ts +128 -0
- package/src/server/tools.ts +131 -0
- package/src/shared/card.ts +115 -0
- package/src/shared/lanes.ts +43 -0
- package/src/shared/plan.ts +54 -0
|
@@ -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
|
+
}
|