@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.
@@ -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
@@ -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
@@ -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
 
@@ -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: input.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
- handle.agent.followup(this.asUserMessage(refinementPrompt(card.requirement, wsPath)))
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 !== undefined && fresh.status === 'refining') {
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
- } catch (error) {
355
- console.error('[task-kanban] refinement create/drive failed:', String(error))
356
- await this.fail(cardId, 'refine_failed', `细化会话失败: ${String(error)}`, 'demand')
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
+ }
@@ -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,
@@ -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