@fonlan/dsh-task-kanban 0.1.0 → 0.1.1
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 +241 -40
- package/lib/client.js.map +1 -1
- package/lib/index.js +193 -8
- package/lib/types/server/prompts.d.ts +7 -0
- package/lib/types/server/runner.d.ts +23 -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 +80 -18
- package/src/client/board.tsx +145 -10
- package/src/client/kanban-state.ts +65 -23
- package/src/client/locales.ts +8 -0
- 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 +114 -9
- 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
|
@@ -41,6 +41,10 @@ export const zh = {
|
|
|
41
41
|
error: '错误',
|
|
42
42
|
requirementLabel: '需求文本',
|
|
43
43
|
requirementPlaceholder: '描述你要实现的需求……',
|
|
44
|
+
skillGestureHint: '开头输入 /skill-name 可指定细化时使用的 Skill(如 /grill-me)',
|
|
45
|
+
skillHint: '细化时将使用 Skill「{skill}」,并可与该会话交流打磨需求',
|
|
46
|
+
skillMenuUserOnly: '仅用户可调',
|
|
47
|
+
skillBadge: '细化 Skill:/{skill}',
|
|
44
48
|
modelPlaceholder: '(使用默认模型)',
|
|
45
49
|
noWorkspace: '没有可用工作区,请先在侧边栏添加',
|
|
46
50
|
refresh: '刷新',
|
|
@@ -102,6 +106,10 @@ export const en = {
|
|
|
102
106
|
error: 'Error',
|
|
103
107
|
requirementLabel: 'Requirement',
|
|
104
108
|
requirementPlaceholder: 'Describe what you want to build…',
|
|
109
|
+
skillGestureHint: 'Start with /skill-name to choose the refinement skill (e.g. /grill-me)',
|
|
110
|
+
skillHint: 'Refinement will use Skill “{skill}” — talk to this session to refine the requirement',
|
|
111
|
+
skillMenuUserOnly: 'User-only',
|
|
112
|
+
skillBadge: 'Refine skill: /{skill}',
|
|
105
113
|
modelPlaceholder: '(use default model)',
|
|
106
114
|
noWorkspace: 'No workspaces available — add one in the sidebar first',
|
|
107
115
|
refresh: 'Refresh',
|
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
|
|
|
@@ -162,12 +163,20 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
162
163
|
return this.store.list(workspacePath)
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
async createTask(input: { workspacePath: string; requirement: string; model: string; provider?: string }): Promise<KanbanCard> {
|
|
166
|
+
async createTask(input: { workspacePath: string; requirement: string; model: string; provider?: string; skill?: string }): Promise<KanbanCard> {
|
|
167
|
+
// A leading /skill-name gesture chooses the refinement skill; it is
|
|
168
|
+
// stripped from the stored requirement so the prose the model sees stays
|
|
169
|
+
// clean. The explicit `skill` field (client-parsed) wins when both are
|
|
170
|
+
// present.
|
|
171
|
+
const parsed = parseSkillGesture(input.requirement)
|
|
172
|
+
const skill = input.skill !== undefined && input.skill !== '' ? input.skill : parsed.skill
|
|
173
|
+
const requirement = skill !== undefined ? parsed.requirement : input.requirement.trim()
|
|
166
174
|
const card = await this.store.create({
|
|
167
175
|
workspacePath: input.workspacePath,
|
|
168
|
-
requirement
|
|
176
|
+
requirement,
|
|
169
177
|
model: input.model,
|
|
170
178
|
provider: input.provider,
|
|
179
|
+
...(skill !== undefined ? { skill } : {}),
|
|
171
180
|
status: 'refining',
|
|
172
181
|
})
|
|
173
182
|
await this.ensureGitignoreDsh(input.workspacePath)
|
|
@@ -334,10 +343,62 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
334
343
|
const handle = await this.createAgent(sessionId, wsPath, route, this.toolResolver())
|
|
335
344
|
this.keepHandle(cardId, handle)
|
|
336
345
|
await this.attachRefinementSession(wsPath, sessionId)
|
|
337
|
-
|
|
346
|
+
// The agent scope is the layer where preset skill providers live
|
|
347
|
+
// (skill-filesystem + tool-skill); load the card's skill through it.
|
|
348
|
+
const skill = card.skill !== undefined
|
|
349
|
+
? await loadCardSkill(this.ctx, card.skill, wsPath, handle.agent)
|
|
350
|
+
: undefined
|
|
351
|
+
if (card.skill !== undefined && skill === undefined) {
|
|
352
|
+
await this.fail(cardId, 'refine_failed', `Skill "${card.skill}" 不存在或不可用于用户调用,请检查后重试`, 'demand')
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (skill !== undefined) {
|
|
356
|
+
// Queue the canonical <skill_content> block as injected context for
|
|
357
|
+
// the same pre-step that claims the refinement prompt (instructions
|
|
358
|
+
// form with the harness's skill-invocation source).
|
|
359
|
+
handle.agent.inject(skillInvocationMessage(skill))
|
|
360
|
+
}
|
|
361
|
+
const interactive = skill !== undefined
|
|
362
|
+
handle.agent.followup(this.asUserMessage(
|
|
363
|
+
interactive ? interactiveRefinementPrompt(card.requirement, wsPath) : refinementPrompt(card.requirement, wsPath),
|
|
364
|
+
))
|
|
365
|
+
await this.driveRefinement(cardId, wsPath, sessionId, handle, interactive)
|
|
366
|
+
} catch (error) {
|
|
367
|
+
console.error('[task-kanban] refinement create/drive failed:', String(error))
|
|
368
|
+
await this.fail(cardId, 'refine_failed', `细化会话失败: ${String(error)}`, 'demand')
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Drive the refinement session to a terminal card state. Non-interactive
|
|
374
|
+
* cards follow the original contract: the agent finishes one turn and must
|
|
375
|
+
* have written the plan. Interactive cards (created with a refinement
|
|
376
|
+
* skill) may ask the user clarifying questions — the session is explicit
|
|
377
|
+
* and visible in the workspace chat, so the runner waits for each reply
|
|
378
|
+
* instead of failing, and only settles when the plan is written or the
|
|
379
|
+
* session errors out.
|
|
380
|
+
*/
|
|
381
|
+
private async driveRefinement(
|
|
382
|
+
cardId: string,
|
|
383
|
+
wsPath: string,
|
|
384
|
+
sessionId: SessionId,
|
|
385
|
+
handle: AgentHandle,
|
|
386
|
+
interactive: boolean,
|
|
387
|
+
): Promise<void> {
|
|
388
|
+
while (true) {
|
|
389
|
+
// Arm the user-reply signal BEFORE waiting so no reply can slip past.
|
|
390
|
+
const reply = this.waitForUserReply(sessionId)
|
|
391
|
+
const settlement = this.waitForCardSettlement(cardId, wsPath)
|
|
338
392
|
await handle.agent.whenIdle()
|
|
339
393
|
const fresh = await this.store.get(wsPath, cardId)
|
|
340
|
-
if (fresh
|
|
394
|
+
if (fresh === undefined || fresh.status !== 'refining') {
|
|
395
|
+
reply.dispose()
|
|
396
|
+
settlement.dispose()
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
if (!interactive) {
|
|
400
|
+
reply.dispose()
|
|
401
|
+
settlement.dispose()
|
|
341
402
|
// debug: log the session tail regardless
|
|
342
403
|
try {
|
|
343
404
|
const events = handle.agent.session.events
|
|
@@ -350,13 +411,57 @@ export class KanbanRunner implements KanbanToolResolver {
|
|
|
350
411
|
console.error('[task-kanban] could not read session events:', String(error))
|
|
351
412
|
}
|
|
352
413
|
await this.fail(cardId, 'refine_failed', '细化会话结束但未通过 kanban_write_plan 写回计划', 'demand')
|
|
414
|
+
return
|
|
353
415
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
416
|
+
// Interactive: the agent went idle without writing the plan — it asked
|
|
417
|
+
// the user something and is waiting for a reply. Wait for the next
|
|
418
|
+
// human message, then drive the agent through it and re-check. Also
|
|
419
|
+
// escape if the card is externally settled (writePlan/fail) without a
|
|
420
|
+
// message, so a stuck session cannot pin the card in 'refining'.
|
|
421
|
+
await Promise.race([reply.promise, settlement.promise])
|
|
422
|
+
reply.dispose()
|
|
423
|
+
settlement.dispose()
|
|
357
424
|
}
|
|
358
425
|
}
|
|
359
426
|
|
|
427
|
+
/**
|
|
428
|
+
* A promise resolving when the card stops being 'refining' (planned,
|
|
429
|
+
* failed, or gone), plus a disposer stopping the polling interval.
|
|
430
|
+
*/
|
|
431
|
+
private waitForCardSettlement(cardId: string, wsPath: string): { promise: Promise<void>; dispose(): void } {
|
|
432
|
+
let resolve!: () => void
|
|
433
|
+
const promise = new Promise<void>((res) => { resolve = res })
|
|
434
|
+
const timer = setInterval(async () => {
|
|
435
|
+
const fresh = await this.store.get(wsPath, cardId)
|
|
436
|
+
if (fresh === undefined || fresh.status !== 'refining') {
|
|
437
|
+
clearInterval(timer)
|
|
438
|
+
resolve()
|
|
439
|
+
}
|
|
440
|
+
}, 1000)
|
|
441
|
+
timer.unref?.()
|
|
442
|
+
return { promise, dispose: () => clearInterval(timer) }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A promise resolving when a NEW human message enters the given session,
|
|
447
|
+
* plus a disposer detaching the listener. Messages injected by plugins with
|
|
448
|
+
* non-`user` sources (such as our skill-invocation instructions) never
|
|
449
|
+
* resolve it — only real human replies drive the interactive refinement.
|
|
450
|
+
*/
|
|
451
|
+
private waitForUserReply(sessionId: SessionId): { promise: Promise<void>; dispose(): void } {
|
|
452
|
+
let settle!: () => void
|
|
453
|
+
const promise = new Promise<void>((resolve) => { settle = resolve })
|
|
454
|
+
const listener = (session: Session, event: SessionEvent): void => {
|
|
455
|
+
if (session.id !== sessionId) return
|
|
456
|
+
if (event.type !== 'user/message') return
|
|
457
|
+
const source = ('source' in event.data ? (event.data as { source?: { kind?: string } }).source : undefined)
|
|
458
|
+
if (source?.kind === 'user') settle()
|
|
459
|
+
}
|
|
460
|
+
const detach = this.ctx.on('session/event', listener)
|
|
461
|
+
const dispose = (): void => { if (typeof detach === 'function') detach() }
|
|
462
|
+
return { promise, dispose }
|
|
463
|
+
}
|
|
464
|
+
|
|
360
465
|
private async createAgent(
|
|
361
466
|
sessionId: SessionId,
|
|
362
467
|
cwd: string,
|
|
@@ -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
|
+
}
|
package/src/server/task-store.ts
CHANGED
|
@@ -67,6 +67,7 @@ export class TaskStore {
|
|
|
67
67
|
requirement: string
|
|
68
68
|
model: string
|
|
69
69
|
provider?: string
|
|
70
|
+
skill?: string
|
|
70
71
|
status?: KanbanCard['status']
|
|
71
72
|
}): Promise<KanbanCard> {
|
|
72
73
|
const id = randomUUID()
|
|
@@ -77,6 +78,7 @@ export class TaskStore {
|
|
|
77
78
|
requirement: input.requirement,
|
|
78
79
|
model: input.model,
|
|
79
80
|
...(input.provider !== undefined ? { provider: input.provider } : {}),
|
|
81
|
+
...(input.skill !== undefined && input.skill !== '' ? { skill: input.skill } : {}),
|
|
80
82
|
status: input.status ?? 'draft',
|
|
81
83
|
currentPhase: 0,
|
|
82
84
|
phaseCount: 0,
|
package/src/shared/card.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface KanbanCard {
|
|
|
73
73
|
id: string
|
|
74
74
|
workspacePath: string
|
|
75
75
|
requirement: string
|
|
76
|
+
/** Optional skill (e.g. `grill-me`) used to drive requirement refinement. */
|
|
77
|
+
skill?: string
|
|
76
78
|
model: string
|
|
77
79
|
/** Provider route for the model (from the modal selection). */
|
|
78
80
|
provider?: string
|