@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.
@@ -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
- let client: ClientContext | null = null
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 l of listeners) l()
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 ctx = client
39
- const root = boardRoot
40
- if (ctx === null || root === null || boardDisposer !== null) return
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
- if (boardDisposer !== null) {
52
- boardDisposer()
53
- boardDisposer = null
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 (boardOpen) exitBoard()
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
@@ -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 Settings',
122
+ settingsTitle: 'Task Kanban',
109
123
  maxParallelWorkers: 'Global parallel workers',
110
124
  defaultModel: 'Default model',
111
125
  saved: 'Saved',
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ import { registerApiRoutes } from './server/rpc.js'
15
15
 
16
16
  export const name = '@fonlan/dsh-task-kanban'
17
17
 
18
- export const inject = ['webServer', 'agents']
18
+ export const inject = ['webServer', 'agents', 'skills']
19
19
 
20
20
  export const Config = z.object({})
21
21
 
@@ -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
- return runner.createTask({ workspacePath, requirement, model, provider })
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) => {
@@ -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: input.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, this.toolResolver())
437
+ const handle = await this.createAgent(sessionId, wsPath, route)
335
438
  this.keepHandle(cardId, handle)
336
439
  await this.attachRefinementSession(wsPath, sessionId)
337
- handle.agent.followup(this.asUserMessage(refinementPrompt(card.requirement, wsPath)))
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 !== undefined && fresh.status === 'refining') {
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
- } catch (error) {
355
- console.error('[task-kanban] refinement create/drive failed:', String(error))
356
- await this.fail(cardId, 'refine_failed', `细化会话失败: ${String(error)}`, 'demand')
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, this.toolResolver())
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, this.toolResolver())
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
+ }