@fonlan/dsh-task-kanban 0.1.0 → 0.2.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/README.md +2 -1
- package/lib/client.js +330 -93
- package/lib/client.js.map +1 -1
- package/lib/index.js +271 -13
- package/lib/types/server/prompts.d.ts +7 -0
- package/lib/types/server/runner.d.ts +44 -0
- package/lib/types/server/skills.d.ts +71 -0
- package/lib/types/server/task-store.d.ts +1 -0
- package/lib/types/shared/card.d.ts +2 -0
- package/package.json +6 -1
- package/src/client/api.ts +43 -2
- package/src/client/board.css +126 -28
- package/src/client/board.tsx +210 -32
- package/src/client/kanban-state.ts +65 -23
- package/src/client/locales.ts +16 -2
- package/src/index.ts +1 -1
- package/src/server/prompts.ts +24 -0
- package/src/server/rpc.ts +2 -1
- package/src/server/runner.ts +214 -14
- package/src/server/skills.ts +105 -0
- package/src/server/task-store.ts +2 -0
- package/src/shared/card.ts +2 -0
|
@@ -1,32 +1,70 @@
|
|
|
1
1
|
import { useSyncExternalStore } from 'react'
|
|
2
2
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* HMR-safe shared state store keyed on the global symbol registry.
|
|
6
|
+
*
|
|
7
|
+
* The client-hmr hot swap re-evaluates this module (a fresh `lib/client.js`
|
|
8
|
+
* bundle) WITHOUT reloading the page. A module-level singleton would then be
|
|
9
|
+
* duplicated per evaluation: the session-navigation wrapper installed by the
|
|
10
|
+
* OLD module copy keeps calling the OLD copy's `exitBoard`, which reads the
|
|
11
|
+
* OLD copy's `boardOpen`/`boardDisposer` — while a board opened after the swap
|
|
12
|
+
* writes the NEW copy's state cell. The wrapper would then no-op and clicking
|
|
13
|
+
* a sidebar session would never leave the board.
|
|
14
|
+
*
|
|
15
|
+
* Keeping every field on one `globalThis`-backed record (via `Symbol.for`, so
|
|
16
|
+
* all bundle copies resolve the same key) makes every copy read/write the same
|
|
17
|
+
* state cell, so exit works regardless of which module copy performed the
|
|
18
|
+
* enter.
|
|
19
|
+
*/
|
|
20
|
+
const STATE_KEY = Symbol.for('@fonlan/dsh-task-kanban/state')
|
|
21
|
+
|
|
22
|
+
interface KanbanState {
|
|
23
|
+
client: ClientContext | null
|
|
24
|
+
boardRoot: ((props: Record<string, unknown>) => React.ReactNode) | null
|
|
25
|
+
boardDisposer: (() => void) | null
|
|
26
|
+
boardOpen: boolean
|
|
27
|
+
listeners: Set<() => void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getState(): KanbanState {
|
|
31
|
+
const g = globalThis as unknown as Record<symbol, KanbanState | undefined>
|
|
32
|
+
let state = g[STATE_KEY]
|
|
33
|
+
if (state === undefined) {
|
|
34
|
+
state = {
|
|
35
|
+
client: null,
|
|
36
|
+
boardRoot: null,
|
|
37
|
+
boardDisposer: null,
|
|
38
|
+
boardOpen: false,
|
|
39
|
+
listeners: new Set(),
|
|
40
|
+
}
|
|
41
|
+
g[STATE_KEY] = state
|
|
42
|
+
}
|
|
43
|
+
return state
|
|
44
|
+
}
|
|
45
|
+
|
|
5
46
|
export function setClient(ctx: ClientContext | null): void {
|
|
6
|
-
client = ctx
|
|
47
|
+
getState().client = ctx
|
|
7
48
|
}
|
|
8
49
|
export function getClient(): ClientContext | null {
|
|
9
|
-
return client
|
|
50
|
+
return getState().client
|
|
10
51
|
}
|
|
11
52
|
|
|
12
|
-
let boardRoot: ((props: Record<string, unknown>) => React.ReactNode) | null = null
|
|
13
53
|
export function setBoardRoot(component: (props: Record<string, unknown>) => React.ReactNode): void {
|
|
14
|
-
boardRoot = component
|
|
54
|
+
getState().boardRoot = component
|
|
15
55
|
}
|
|
16
56
|
export function getBoardRoot(): ((props: Record<string, unknown>) => React.ReactNode) | null {
|
|
17
|
-
return boardRoot
|
|
57
|
+
return getState().boardRoot
|
|
18
58
|
}
|
|
19
59
|
|
|
20
|
-
let boardDisposer: (() => void) | null = null
|
|
21
|
-
let boardOpen = false
|
|
22
|
-
const listeners = new Set<() => void>()
|
|
23
60
|
function notify(): void {
|
|
24
|
-
for (const
|
|
61
|
+
for (const listener of getState().listeners) listener()
|
|
25
62
|
}
|
|
26
63
|
export function isBoardOpen(): boolean {
|
|
27
|
-
return boardOpen
|
|
64
|
+
return getState().boardOpen
|
|
28
65
|
}
|
|
29
66
|
function subscribe(cb: () => void): () => void {
|
|
67
|
+
const listeners = getState().listeners
|
|
30
68
|
listeners.add(cb)
|
|
31
69
|
return () => { listeners.delete(cb) }
|
|
32
70
|
}
|
|
@@ -35,12 +73,13 @@ export function useBoardOpen(): boolean {
|
|
|
35
73
|
}
|
|
36
74
|
|
|
37
75
|
export function enterBoard(): void {
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
76
|
+
const state = getState()
|
|
77
|
+
const ctx = state.client
|
|
78
|
+
const root = state.boardRoot
|
|
79
|
+
if (ctx === null || root === null || state.boardDisposer !== null) return
|
|
41
80
|
try {
|
|
42
|
-
boardDisposer = ctx.slots.register({ name: 'conversation', priority: -1, locale: 'task-kanban' } as never, root as never)
|
|
43
|
-
boardOpen = true
|
|
81
|
+
state.boardDisposer = ctx.slots.register({ name: 'conversation', priority: -1, locale: 'task-kanban' } as never, root as never)
|
|
82
|
+
state.boardOpen = true
|
|
44
83
|
notify()
|
|
45
84
|
} catch (error) {
|
|
46
85
|
console.error('[@fonlan/dsh-task-kanban] cannot open the board:', error)
|
|
@@ -48,18 +87,19 @@ export function enterBoard(): void {
|
|
|
48
87
|
}
|
|
49
88
|
|
|
50
89
|
export function exitBoard(): void {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
boardDisposer
|
|
90
|
+
const state = getState()
|
|
91
|
+
if (state.boardDisposer !== null) {
|
|
92
|
+
state.boardDisposer()
|
|
93
|
+
state.boardDisposer = null
|
|
54
94
|
}
|
|
55
|
-
if (boardOpen) {
|
|
56
|
-
boardOpen = false
|
|
95
|
+
if (state.boardOpen) {
|
|
96
|
+
state.boardOpen = false
|
|
57
97
|
notify()
|
|
58
98
|
}
|
|
59
99
|
}
|
|
60
100
|
|
|
61
101
|
export function toggleBoard(): void {
|
|
62
|
-
if (
|
|
102
|
+
if (isBoardOpen()) exitBoard()
|
|
63
103
|
else enterBoard()
|
|
64
104
|
}
|
|
65
105
|
|
|
@@ -76,7 +116,9 @@ export function toggleBoard(): void {
|
|
|
76
116
|
* points covers all of them in one place.
|
|
77
117
|
*
|
|
78
118
|
* The wrapper is idempotent: a `kbBound` marker on the wrapper prevents a
|
|
79
|
-
* second bind (plugin re-apply / HMR) from stacking another layer.
|
|
119
|
+
* second bind (plugin re-apply / HMR) from stacking another layer. Because the
|
|
120
|
+
* wrapper's exit goes through the shared state store, a wrapper left over from
|
|
121
|
+
* a previous (HMR-replaced) module copy still closes the board correctly.
|
|
80
122
|
*/
|
|
81
123
|
export function bindSessionNavigation(ctx: ClientContext): void {
|
|
82
124
|
const sessions = ctx.sessions
|
package/src/client/locales.ts
CHANGED
|
@@ -38,13 +38,20 @@ export const zh = {
|
|
|
38
38
|
plan: '实现方案',
|
|
39
39
|
summary: '摘要',
|
|
40
40
|
phases: '阶段',
|
|
41
|
+
phaseGoal: '目标',
|
|
42
|
+
phaseSessions: '会话',
|
|
43
|
+
phaseConclusion: '结论',
|
|
41
44
|
error: '错误',
|
|
42
45
|
requirementLabel: '需求文本',
|
|
43
46
|
requirementPlaceholder: '描述你要实现的需求……',
|
|
47
|
+
skillGestureHint: '开头输入 /skill-name 可指定细化时使用的 Skill(如 /grill-me)',
|
|
48
|
+
skillHint: '细化时将使用 Skill「{skill}」,并可与该会话交流打磨需求',
|
|
49
|
+
skillMenuUserOnly: '仅用户可调',
|
|
50
|
+
skillBadge: '细化 Skill:/{skill}',
|
|
44
51
|
modelPlaceholder: '(使用默认模型)',
|
|
45
52
|
noWorkspace: '没有可用工作区,请先在侧边栏添加',
|
|
46
53
|
refresh: '刷新',
|
|
47
|
-
settingsTitle: '
|
|
54
|
+
settingsTitle: '任务看板',
|
|
48
55
|
maxParallelWorkers: '全局并行 worker 数',
|
|
49
56
|
defaultModel: '默认模型',
|
|
50
57
|
saved: '已保存',
|
|
@@ -99,13 +106,20 @@ export const en = {
|
|
|
99
106
|
plan: 'Plan',
|
|
100
107
|
summary: 'Summary',
|
|
101
108
|
phases: 'Phases',
|
|
109
|
+
phaseGoal: 'Goal',
|
|
110
|
+
phaseSessions: 'Sessions',
|
|
111
|
+
phaseConclusion: 'Conclusion',
|
|
102
112
|
error: 'Error',
|
|
103
113
|
requirementLabel: 'Requirement',
|
|
104
114
|
requirementPlaceholder: 'Describe what you want to build…',
|
|
115
|
+
skillGestureHint: 'Start with /skill-name to choose the refinement skill (e.g. /grill-me)',
|
|
116
|
+
skillHint: 'Refinement will use Skill “{skill}” — talk to this session to refine the requirement',
|
|
117
|
+
skillMenuUserOnly: 'User-only',
|
|
118
|
+
skillBadge: 'Refine skill: /{skill}',
|
|
105
119
|
modelPlaceholder: '(use default model)',
|
|
106
120
|
noWorkspace: 'No workspaces available — add one in the sidebar first',
|
|
107
121
|
refresh: 'Refresh',
|
|
108
|
-
settingsTitle: 'Task Kanban
|
|
122
|
+
settingsTitle: 'Task Kanban',
|
|
109
123
|
maxParallelWorkers: 'Global parallel workers',
|
|
110
124
|
defaultModel: 'Default model',
|
|
111
125
|
saved: 'Saved',
|
package/src/index.ts
CHANGED
package/src/server/prompts.ts
CHANGED
|
@@ -18,6 +18,30 @@ ${workspacePath}
|
|
|
18
18
|
5. 不要向用户提问,自主决策。`
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Interactive refinement prompt for a card created with a skill (e.g.
|
|
23
|
+
* `/grill-me`). The refinement session is explicit — the user can reply —
|
|
24
|
+
* so instead of deciding autonomously, the agent follows the skill's
|
|
25
|
+
* interview and asks whatever it needs before writing the plan back.
|
|
26
|
+
*/
|
|
27
|
+
export function interactiveRefinementPrompt(requirement: string, workspacePath: string): string {
|
|
28
|
+
return `你是一个需求分析师。请分析下面的需求,并为它在指定项目中的实现制定一个可执行的分阶段实现计划。
|
|
29
|
+
|
|
30
|
+
【需求】
|
|
31
|
+
${requirement}
|
|
32
|
+
|
|
33
|
+
【项目目录】
|
|
34
|
+
${workspacePath}
|
|
35
|
+
|
|
36
|
+
【要求】
|
|
37
|
+
1. 先浏览项目目录了解现状(阅读 README、关键源码与文档;本会话只做只读探索,不要修改任何文件)。
|
|
38
|
+
2. 这是一个显式会话,可以且应该与用户交流:按上方 Skill 的指引对需求进行充分打磨,通过提问澄清需求中模糊、缺失或相互矛盾的地方,直到需求足够明确。
|
|
39
|
+
3. 需求打磨完成后,把需求拆解为按顺序串行执行的实现阶段(phase)。简单需求 1 个 phase 即可;复杂需求拆多个 phase,每个 phase 必须是独立的、可串行执行的实现单元,后面的 phase 依赖前面 phase 的产出。
|
|
40
|
+
4. 每个 phase 必须包含:id(如 p1/p2/p3)、标题、目标(goal,说明该 phase 要交付什么)。
|
|
41
|
+
5. 完成后调用 kanban_write_plan 工具把完整计划写回(title、summary、phases)。这是唯一写回方式,不要把计划 JSON 直接输出到对话里。
|
|
42
|
+
6. 用户回复前不要擅自调用 kanban_write_plan;也不要因为用户暂时没有回复就放弃,等待用户回答即可。`
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
/** Phase implementation session prompt; identical on retries (the model reads git status/diff itself). */
|
|
22
46
|
export function phasePrompt(card: KanbanCard, phaseIndex: number, workdir: string): string {
|
|
23
47
|
const plan = card.plan!
|
package/src/server/rpc.ts
CHANGED
|
@@ -131,7 +131,8 @@ export function registerApiRoutes(
|
|
|
131
131
|
const requirement = requireString(p.requirement, 'requirement')
|
|
132
132
|
const model = typeof p.model === 'string' ? p.model : ''
|
|
133
133
|
const provider = typeof p.provider === 'string' ? p.provider : undefined
|
|
134
|
-
|
|
134
|
+
const skill = typeof p.skill === 'string' && p.skill !== '' ? p.skill : undefined
|
|
135
|
+
return runner.createTask({ workspacePath, requirement, model, provider, ...(skill !== undefined ? { skill } : {}) })
|
|
135
136
|
})
|
|
136
137
|
|
|
137
138
|
route('move', async (p) => {
|
package/src/server/runner.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from 'node:crypto'
|
|
9
9
|
import { appendFile, readFile } from 'node:fs/promises'
|
|
10
|
-
import type { SessionId } from '@deepseek-ai/dsh-session'
|
|
10
|
+
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import type { Context } from '@deepseek-ai/cordis'
|
|
13
13
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
@@ -16,7 +16,8 @@ import type { KanbanCard, ErrorKind, Lane, Plan } from '../shared/card.js'
|
|
|
16
16
|
import type { TaskStore } from './task-store.js'
|
|
17
17
|
import type { KanbanSettingsFace } from './settings.js'
|
|
18
18
|
import { currentBranch, detectBaseRef, hasStashMessage, isGitRepo, isTreeDirty, revParse, runGit, unmergedPaths } from './git.js'
|
|
19
|
-
import { mergePrompt, phasePrompt, refinementPrompt } from './prompts.js'
|
|
19
|
+
import { interactiveRefinementPrompt, mergePrompt, phasePrompt, refinementPrompt } from './prompts.js'
|
|
20
|
+
import { loadCardSkill, parseSkillGesture, skillInvocationMessage } from './skills.js'
|
|
20
21
|
import { registerKanbanTools, type KanbanToolResolver } from './tools.js'
|
|
21
22
|
import type {} from '@deepseek-ai/dsh-agent-presets'
|
|
22
23
|
|
|
@@ -48,6 +49,11 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
48
49
|
private merging = new Set<string>()
|
|
49
50
|
/** Retained agent handles (kept so ended sessions stay in the store/UI). */
|
|
50
51
|
private handles = new Map<string, AgentHandle[]>()
|
|
52
|
+
/** Whether startup recovery + session indexing ran (deferred until workspaces exist). */
|
|
53
|
+
private recovered = false
|
|
54
|
+
/** Agents that already got their scoped kanban tools (event idempotency). */
|
|
55
|
+
private toolScopedAgents = new WeakSet<object>()
|
|
56
|
+
private agentStartOff: (() => void) | undefined
|
|
51
57
|
|
|
52
58
|
constructor(ctx: Context, store: TaskStore, settings: KanbanSettingsFace) {
|
|
53
59
|
this.ctx = ctx
|
|
@@ -61,10 +67,22 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
61
67
|
|
|
62
68
|
start(): void {
|
|
63
69
|
this.pumpTimer = setInterval(() => { void this.pump() }, 2500)
|
|
70
|
+
// `agent/session-start` fires for BOTH agents.create (startup) and
|
|
71
|
+
// agents.resume (host re-open after a restart). Kanban tools are scoped
|
|
72
|
+
// per agent, so a resumed refinement/phase/merge session would otherwise
|
|
73
|
+
// lose them — register there instead of only in createAgent's setup.
|
|
74
|
+
this.agentStartOff = this.ctx.on('agent/session-start', (payload: { agent?: { id?: string; ctx?: Context } }) => {
|
|
75
|
+
void this.onAgentSessionStart(payload.agent)
|
|
76
|
+
})
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
stop(): void {
|
|
67
80
|
if (this.pumpTimer !== undefined) clearInterval(this.pumpTimer)
|
|
81
|
+
this.pumpTimer = undefined
|
|
82
|
+
if (this.agentStartOff !== undefined) {
|
|
83
|
+
this.agentStartOff()
|
|
84
|
+
this.agentStartOff = undefined
|
|
85
|
+
}
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
private newSessionId(): SessionId {
|
|
@@ -156,18 +174,83 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
156
174
|
}
|
|
157
175
|
}
|
|
158
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Rebuild sessionId → cardId from the task store. Runs once the workspace
|
|
179
|
+
* registry is available (see pump), so kanban sessions resumed after a
|
|
180
|
+
* restart still resolve their card.
|
|
181
|
+
*/
|
|
182
|
+
private async indexSessions(): Promise<void> {
|
|
183
|
+
for (const ws of await this.workspaceList()) {
|
|
184
|
+
const cards = await this.store.list(ws.path)
|
|
185
|
+
for (const card of cards) this.mapCardSessions(card.id, card)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private mapCardSessions(cardId: string, card: KanbanCard): void {
|
|
190
|
+
for (const sid of card.sessions.refinement) this.sessionCards.set(sid, cardId)
|
|
191
|
+
for (const attempt of card.sessions.phases) this.sessionCards.set(attempt.sessionId, cardId)
|
|
192
|
+
for (const sid of card.sessions.merge) this.sessionCards.set(sid, cardId)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Fallback lookup when a session starts before indexSessions ran. */
|
|
196
|
+
private async lookupCardOfSession(sessionId: string): Promise<string | undefined> {
|
|
197
|
+
for (const ws of await this.workspaceList()) {
|
|
198
|
+
const cards = await this.store.list(ws.path)
|
|
199
|
+
for (const card of cards) {
|
|
200
|
+
const hit =
|
|
201
|
+
card.sessions.refinement.includes(sessionId) ||
|
|
202
|
+
card.sessions.phases.some((a) => a.sessionId === sessionId) ||
|
|
203
|
+
card.sessions.merge.includes(sessionId)
|
|
204
|
+
if (hit) {
|
|
205
|
+
this.mapCardSessions(card.id, card)
|
|
206
|
+
return card.id
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The single place kanban tools get registered. `agent/session-start` fires
|
|
215
|
+
* for BOTH `agents.create` (startup) and `agents.resume` (host re-open after
|
|
216
|
+
* a restart), so a resumed refinement/phase/merge session keeps its scoped
|
|
217
|
+
* tools. Idempotent per agent object; non-kanban sessions are skipped.
|
|
218
|
+
*/
|
|
219
|
+
private async onAgentSessionStart(agent: { id?: string; ctx?: Context } | undefined): Promise<void> {
|
|
220
|
+
if (agent === undefined || agent.id === undefined || agent.ctx === undefined) return
|
|
221
|
+
if (this.toolScopedAgents.has(agent)) return
|
|
222
|
+
let cardId = this.sessionCards.get(agent.id)
|
|
223
|
+
if (cardId === undefined) cardId = await this.lookupCardOfSession(agent.id)
|
|
224
|
+
if (cardId === undefined) return
|
|
225
|
+
this.sessionCards.set(agent.id, cardId)
|
|
226
|
+
try {
|
|
227
|
+
registerKanbanTools(agent.ctx, this.toolResolver())
|
|
228
|
+
this.toolScopedAgents.add(agent)
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error('[task-kanban] register kanban tools for ' + agent.id + ' failed:', String(error))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
159
234
|
// ── Card operations (RPC entry points) ────────────────────────────────────
|
|
160
235
|
|
|
161
236
|
async listCards(workspacePath: string): Promise<KanbanCard[]> {
|
|
162
237
|
return this.store.list(workspacePath)
|
|
163
238
|
}
|
|
164
239
|
|
|
165
|
-
async createTask(input: { workspacePath: string; requirement: string; model: string; provider?: string }): Promise<KanbanCard> {
|
|
240
|
+
async createTask(input: { workspacePath: string; requirement: string; model: string; provider?: string; skill?: string }): Promise<KanbanCard> {
|
|
241
|
+
// A leading /skill-name gesture chooses the refinement skill; it is
|
|
242
|
+
// stripped from the stored requirement so the prose the model sees stays
|
|
243
|
+
// clean. The explicit `skill` field (client-parsed) wins when both are
|
|
244
|
+
// present.
|
|
245
|
+
const parsed = parseSkillGesture(input.requirement)
|
|
246
|
+
const skill = input.skill !== undefined && input.skill !== '' ? input.skill : parsed.skill
|
|
247
|
+
const requirement = skill !== undefined ? parsed.requirement : input.requirement.trim()
|
|
166
248
|
const card = await this.store.create({
|
|
167
249
|
workspacePath: input.workspacePath,
|
|
168
|
-
requirement
|
|
250
|
+
requirement,
|
|
169
251
|
model: input.model,
|
|
170
252
|
provider: input.provider,
|
|
253
|
+
...(skill !== undefined ? { skill } : {}),
|
|
171
254
|
status: 'refining',
|
|
172
255
|
})
|
|
173
256
|
await this.ensureGitignoreDsh(input.workspacePath)
|
|
@@ -278,6 +361,26 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
278
361
|
}
|
|
279
362
|
|
|
280
363
|
async pump(): Promise<void> {
|
|
364
|
+
// Recovery and session indexing need the workspace registry, which may
|
|
365
|
+
// not be populated when the plugin first applies (the old code silently
|
|
366
|
+
// no-opped and left interrupted cards stuck in refining). Retry on the
|
|
367
|
+
// pump tick until the first workspace appears.
|
|
368
|
+
if (!this.recovered) {
|
|
369
|
+
const ws = await this.workspaceList()
|
|
370
|
+
if (ws.length > 0) {
|
|
371
|
+
this.recovered = true
|
|
372
|
+
try {
|
|
373
|
+
await this.recoverInterrupted()
|
|
374
|
+
} catch (error) {
|
|
375
|
+
console.error('[task-kanban] recoverInterrupted failed:', String(error))
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
await this.indexSessions()
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.error('[task-kanban] indexSessions failed:', String(error))
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
281
384
|
if (this.slots >= this.maxParallel()) return
|
|
282
385
|
const workspaces = await this.workspaceList()
|
|
283
386
|
for (const ws of workspaces) {
|
|
@@ -331,13 +434,65 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
331
434
|
c.sessions.refinement.push(sessionId)
|
|
332
435
|
}, wsPath)
|
|
333
436
|
try {
|
|
334
|
-
const handle = await this.createAgent(sessionId, wsPath, route
|
|
437
|
+
const handle = await this.createAgent(sessionId, wsPath, route)
|
|
335
438
|
this.keepHandle(cardId, handle)
|
|
336
439
|
await this.attachRefinementSession(wsPath, sessionId)
|
|
337
|
-
|
|
440
|
+
// The agent scope is the layer where preset skill providers live
|
|
441
|
+
// (skill-filesystem + tool-skill); load the card's skill through it.
|
|
442
|
+
const skill = card.skill !== undefined
|
|
443
|
+
? await loadCardSkill(this.ctx, card.skill, wsPath, handle.agent)
|
|
444
|
+
: undefined
|
|
445
|
+
if (card.skill !== undefined && skill === undefined) {
|
|
446
|
+
await this.fail(cardId, 'refine_failed', `Skill "${card.skill}" 不存在或不可用于用户调用,请检查后重试`, 'demand')
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
if (skill !== undefined) {
|
|
450
|
+
// Queue the canonical <skill_content> block as injected context for
|
|
451
|
+
// the same pre-step that claims the refinement prompt (instructions
|
|
452
|
+
// form with the harness's skill-invocation source).
|
|
453
|
+
handle.agent.inject(skillInvocationMessage(skill))
|
|
454
|
+
}
|
|
455
|
+
const interactive = skill !== undefined
|
|
456
|
+
handle.agent.followup(this.asUserMessage(
|
|
457
|
+
interactive ? interactiveRefinementPrompt(card.requirement, wsPath) : refinementPrompt(card.requirement, wsPath),
|
|
458
|
+
))
|
|
459
|
+
await this.driveRefinement(cardId, wsPath, sessionId, handle, interactive)
|
|
460
|
+
} catch (error) {
|
|
461
|
+
console.error('[task-kanban] refinement create/drive failed:', String(error))
|
|
462
|
+
await this.fail(cardId, 'refine_failed', `细化会话失败: ${String(error)}`, 'demand')
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Drive the refinement session to a terminal card state. Non-interactive
|
|
468
|
+
* cards follow the original contract: the agent finishes one turn and must
|
|
469
|
+
* have written the plan. Interactive cards (created with a refinement
|
|
470
|
+
* skill) may ask the user clarifying questions — the session is explicit
|
|
471
|
+
* and visible in the workspace chat, so the runner waits for each reply
|
|
472
|
+
* instead of failing, and only settles when the plan is written or the
|
|
473
|
+
* session errors out.
|
|
474
|
+
*/
|
|
475
|
+
private async driveRefinement(
|
|
476
|
+
cardId: string,
|
|
477
|
+
wsPath: string,
|
|
478
|
+
sessionId: SessionId,
|
|
479
|
+
handle: AgentHandle,
|
|
480
|
+
interactive: boolean,
|
|
481
|
+
): Promise<void> {
|
|
482
|
+
while (true) {
|
|
483
|
+
// Arm the user-reply signal BEFORE waiting so no reply can slip past.
|
|
484
|
+
const reply = this.waitForUserReply(sessionId)
|
|
485
|
+
const settlement = this.waitForCardSettlement(cardId, wsPath)
|
|
338
486
|
await handle.agent.whenIdle()
|
|
339
487
|
const fresh = await this.store.get(wsPath, cardId)
|
|
340
|
-
if (fresh
|
|
488
|
+
if (fresh === undefined || fresh.status !== 'refining') {
|
|
489
|
+
reply.dispose()
|
|
490
|
+
settlement.dispose()
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
if (!interactive) {
|
|
494
|
+
reply.dispose()
|
|
495
|
+
settlement.dispose()
|
|
341
496
|
// debug: log the session tail regardless
|
|
342
497
|
try {
|
|
343
498
|
const events = handle.agent.session.events
|
|
@@ -350,18 +505,61 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
350
505
|
console.error('[task-kanban] could not read session events:', String(error))
|
|
351
506
|
}
|
|
352
507
|
await this.fail(cardId, 'refine_failed', '细化会话结束但未通过 kanban_write_plan 写回计划', 'demand')
|
|
508
|
+
return
|
|
353
509
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
510
|
+
// Interactive: the agent went idle without writing the plan — it asked
|
|
511
|
+
// the user something and is waiting for a reply. Wait for the next
|
|
512
|
+
// human message, then drive the agent through it and re-check. Also
|
|
513
|
+
// escape if the card is externally settled (writePlan/fail) without a
|
|
514
|
+
// message, so a stuck session cannot pin the card in 'refining'.
|
|
515
|
+
await Promise.race([reply.promise, settlement.promise])
|
|
516
|
+
reply.dispose()
|
|
517
|
+
settlement.dispose()
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* A promise resolving when the card stops being 'refining' (planned,
|
|
523
|
+
* failed, or gone), plus a disposer stopping the polling interval.
|
|
524
|
+
*/
|
|
525
|
+
private waitForCardSettlement(cardId: string, wsPath: string): { promise: Promise<void>; dispose(): void } {
|
|
526
|
+
let resolve!: () => void
|
|
527
|
+
const promise = new Promise<void>((res) => { resolve = res })
|
|
528
|
+
const timer = setInterval(async () => {
|
|
529
|
+
const fresh = await this.store.get(wsPath, cardId)
|
|
530
|
+
if (fresh === undefined || fresh.status !== 'refining') {
|
|
531
|
+
clearInterval(timer)
|
|
532
|
+
resolve()
|
|
533
|
+
}
|
|
534
|
+
}, 1000)
|
|
535
|
+
timer.unref?.()
|
|
536
|
+
return { promise, dispose: () => clearInterval(timer) }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* A promise resolving when a NEW human message enters the given session,
|
|
541
|
+
* plus a disposer detaching the listener. Messages injected by plugins with
|
|
542
|
+
* non-`user` sources (such as our skill-invocation instructions) never
|
|
543
|
+
* resolve it — only real human replies drive the interactive refinement.
|
|
544
|
+
*/
|
|
545
|
+
private waitForUserReply(sessionId: SessionId): { promise: Promise<void>; dispose(): void } {
|
|
546
|
+
let settle!: () => void
|
|
547
|
+
const promise = new Promise<void>((resolve) => { settle = resolve })
|
|
548
|
+
const listener = (session: Session, event: SessionEvent): void => {
|
|
549
|
+
if (session.id !== sessionId) return
|
|
550
|
+
if (event.type !== 'user/message') return
|
|
551
|
+
const source = ('source' in event.data ? (event.data as { source?: { kind?: string } }).source : undefined)
|
|
552
|
+
if (source?.kind === 'user') settle()
|
|
357
553
|
}
|
|
554
|
+
const detach = this.ctx.on('session/event', listener)
|
|
555
|
+
const dispose = (): void => { if (typeof detach === 'function') detach() }
|
|
556
|
+
return { promise, dispose }
|
|
358
557
|
}
|
|
359
558
|
|
|
360
559
|
private async createAgent(
|
|
361
560
|
sessionId: SessionId,
|
|
362
561
|
cwd: string,
|
|
363
562
|
route: { provider?: string; model?: string },
|
|
364
|
-
resolver: KanbanToolResolver,
|
|
365
563
|
) {
|
|
366
564
|
const agentOptions =
|
|
367
565
|
route.provider !== undefined && route.model !== undefined && route.model !== ''
|
|
@@ -371,6 +569,9 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
371
569
|
sessionId,
|
|
372
570
|
meta: { cwd },
|
|
373
571
|
...(agentOptions !== undefined ? { agentOptions } : {}),
|
|
572
|
+
// Kanban tools are NOT registered here: `agent/session-start` (see
|
|
573
|
+
// start()) fires for both create and resume, keeping them available
|
|
574
|
+
// even when the host re-opens a persisted kanban session.
|
|
374
575
|
setup: async (agentCtx) => {
|
|
375
576
|
const agentPresets = agentCtx.get('agentPresets') as
|
|
376
577
|
| { mount(agentCtx: unknown, id?: string): Promise<unknown> }
|
|
@@ -380,7 +581,6 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
380
581
|
} else {
|
|
381
582
|
throw new Error('@fonlan/dsh-task-kanban: agent-presets service is not mounted')
|
|
382
583
|
}
|
|
383
|
-
registerKanbanTools(agentCtx, resolver)
|
|
384
584
|
},
|
|
385
585
|
})
|
|
386
586
|
}
|
|
@@ -533,7 +733,7 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
533
733
|
}, wsPath)
|
|
534
734
|
const route = await this.modelRoute(cardId, wsPath)
|
|
535
735
|
try {
|
|
536
|
-
const handle = await this.createAgent(sessionId, workdir, route
|
|
736
|
+
const handle = await this.createAgent(sessionId, workdir, route)
|
|
537
737
|
this.keepHandle(cardId, handle)
|
|
538
738
|
entry.cancel = () => handle.agent.cancel({ kind: 'user' })
|
|
539
739
|
handle.agent.followup(this.asUserMessage(phasePrompt({ ...(await this.store.get(wsPath, cardId)) ?? { plan } as KanbanCard, plan } as KanbanCard, phaseIndex, workdir)))
|
|
@@ -786,7 +986,7 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
786
986
|
await this.store.mutate(cardId, (c) => { c.sessions.merge.push(sessionId) }, wsPath)
|
|
787
987
|
const route = await this.modelRoute(cardId, wsPath)
|
|
788
988
|
try {
|
|
789
|
-
const handle = await this.createAgent(sessionId, workdir, route
|
|
989
|
+
const handle = await this.createAgent(sessionId, workdir, route)
|
|
790
990
|
this.keepHandle(cardId, handle)
|
|
791
991
|
handle.agent.followup(this.asUserMessage(mergePrompt(workdir, conflicts)))
|
|
792
992
|
await handle.agent.whenIdle()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill wiring for requirement refinement.
|
|
3
|
+
*
|
|
4
|
+
* A task card may carry a skill name (e.g. `grill-me`) chosen in the
|
|
5
|
+
* create-task requirement input via a leading `/skill-name` gesture, matching
|
|
6
|
+
* the harness's user-invocation convention (the same whitespace-bounded token
|
|
7
|
+
* shape `tool-skill` scans for). The server strips the gesture from the stored
|
|
8
|
+
* requirement, records the skill on the card, and injects the canonical
|
|
9
|
+
* `renderSkillContent` block into the refinement session as an instructions-
|
|
10
|
+
* form user message with the harness's `skill-invocation` source, so the
|
|
11
|
+
* transcript UI renders it exactly like a user-invoked skill in chat.
|
|
12
|
+
*/
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
15
|
+
import { isSkillName, isUserInvocable, renderSkillContent, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The public skill-name gesture shape: a whitespace-bounded `/name` token
|
|
19
|
+
* matching the grammar `tool-skill` uses (kebab-case letters/digits), at the
|
|
20
|
+
* very start of the input (leading whitespace allowed). A second `/` or
|
|
21
|
+
* non-boundary character breaks the match, keeping file paths (`/usr/bin`)
|
|
22
|
+
* and fractions (`5/8`) out.
|
|
23
|
+
*/
|
|
24
|
+
const SKILL_GESTURE = /^\s*\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/
|
|
25
|
+
|
|
26
|
+
/** A parsed leading skill gesture: the skill name plus the cleaned requirement. */
|
|
27
|
+
export interface SkillGestureParse {
|
|
28
|
+
/** The skill name from the leading `/name` token, if the input led with one. */
|
|
29
|
+
skill?: string
|
|
30
|
+
/** The requirement text with the leading gesture removed. */
|
|
31
|
+
requirement: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract a leading `/skill-name` gesture from raw create-task input. Only a
|
|
36
|
+
* token at the very start selects a skill; tokens later in the text are
|
|
37
|
+
* ordinary requirement prose (the user might legitimately mention paths or
|
|
38
|
+
* fractions). The name is grammar-validated but not registry-checked here —
|
|
39
|
+
* unknown names surface as a refinement error with a clear message.
|
|
40
|
+
*/
|
|
41
|
+
export function parseSkillGesture(raw: string): SkillGestureParse {
|
|
42
|
+
const m = SKILL_GESTURE.exec(raw)
|
|
43
|
+
if (m === null) return { requirement: raw.trim() }
|
|
44
|
+
return {
|
|
45
|
+
skill: m[1],
|
|
46
|
+
requirement: raw.slice(m[0].length).trim(),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The minimal view of the hosted skill registry the plugin needs: list for a
|
|
52
|
+
* summary lookup and get for a full body, both honoring the calling scope.
|
|
53
|
+
*/
|
|
54
|
+
export interface SkillRegistryFace {
|
|
55
|
+
list(options?: { cwd?: string; scope?: unknown }): Promise<SkillSummary[]>
|
|
56
|
+
get(name: string, options?: { cwd?: string; scope?: unknown }): Promise<SkillDefinition | undefined>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve the host skill registry, or undefined when this profile lacks one. */
|
|
60
|
+
export function skillRegistry(ctx: Context): SkillRegistryFace | undefined {
|
|
61
|
+
try {
|
|
62
|
+
return ctx.get('skills') as SkillRegistryFace | undefined
|
|
63
|
+
} catch {
|
|
64
|
+
// service not injected/mounted in this context
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether the provided skill name is a syntactically valid skill name. */
|
|
70
|
+
export function validSkillName(name: string): boolean {
|
|
71
|
+
return isSkillName(name)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the instructions-form message carrying the rendered skill body, the
|
|
76
|
+
* same shape a user `/grill-me` invocation injects through `tool-skill`. The
|
|
77
|
+
* `skill-invocation` source is what the transcript UI decorates as a skill
|
|
78
|
+
* chip; the content is the canonical `<skill_content>` block.
|
|
79
|
+
*/
|
|
80
|
+
export function skillInvocationMessage(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>) {
|
|
81
|
+
return createUserMessage({
|
|
82
|
+
content: [{ type: 'text', text: renderSkillContent(skill) }],
|
|
83
|
+
source: { kind: 'skill-invocation', name: skill.name, form: 'instructions' },
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load the skill body the card names, scoped to the refinement agent so
|
|
89
|
+
* preset-layer providers (the filesystem skill roots) are visible. Returns
|
|
90
|
+
* undefined when the skill is unknown, not user-invocable, or the registry is
|
|
91
|
+
* absent — the caller decides how to surface that.
|
|
92
|
+
*/
|
|
93
|
+
export async function loadCardSkill(
|
|
94
|
+
ctx: Context,
|
|
95
|
+
name: string | undefined,
|
|
96
|
+
cwd: string,
|
|
97
|
+
scope: unknown,
|
|
98
|
+
): Promise<Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'> | undefined> {
|
|
99
|
+
if (name === undefined || !isSkillName(name)) return undefined
|
|
100
|
+
const registry = skillRegistry(ctx)
|
|
101
|
+
if (registry === undefined) return undefined
|
|
102
|
+
const skill = await registry.get(name, { cwd, scope })
|
|
103
|
+
if (skill === undefined || !isUserInvocable(skill)) return undefined
|
|
104
|
+
return skill
|
|
105
|
+
}
|