@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/lib/index.js CHANGED
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
7
7
  import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
+ import { isSkillName, isUserInvocable, renderSkillContent } from "@deepseek-ai/dsh-skill";
9
10
  import { defineTool } from "@deepseek-ai/dsh-tools";
10
11
  //#region src/server/settings.ts
11
12
  const KANBAN_SETTINGS_NS = settingsNamespace("task-kanban");
@@ -114,6 +115,7 @@ var TaskStore = class {
114
115
  requirement: input.requirement,
115
116
  model: input.model,
116
117
  ...input.provider !== void 0 ? { provider: input.provider } : {},
118
+ ...input.skill !== void 0 && input.skill !== "" ? { skill: input.skill } : {},
117
119
  status: input.status ?? "draft",
118
120
  currentPhase: 0,
119
121
  phaseCount: 0,
@@ -249,6 +251,29 @@ ${workspacePath}
249
251
  4. 完成后调用 kanban_write_plan 工具把完整计划写回(title、summary、phases)。这是唯一写回方式,不要把计划 JSON 直接输出到对话里。
250
252
  5. 不要向用户提问,自主决策。`;
251
253
  }
254
+ /**
255
+ * Interactive refinement prompt for a card created with a skill (e.g.
256
+ * `/grill-me`). The refinement session is explicit — the user can reply —
257
+ * so instead of deciding autonomously, the agent follows the skill's
258
+ * interview and asks whatever it needs before writing the plan back.
259
+ */
260
+ function interactiveRefinementPrompt(requirement, workspacePath) {
261
+ return `你是一个需求分析师。请分析下面的需求,并为它在指定项目中的实现制定一个可执行的分阶段实现计划。
262
+
263
+ 【需求】
264
+ ${requirement}
265
+
266
+ 【项目目录】
267
+ ${workspacePath}
268
+
269
+ 【要求】
270
+ 1. 先浏览项目目录了解现状(阅读 README、关键源码与文档;本会话只做只读探索,不要修改任何文件)。
271
+ 2. 这是一个显式会话,可以且应该与用户交流:按上方 Skill 的指引对需求进行充分打磨,通过提问澄清需求中模糊、缺失或相互矛盾的地方,直到需求足够明确。
272
+ 3. 需求打磨完成后,把需求拆解为按顺序串行执行的实现阶段(phase)。简单需求 1 个 phase 即可;复杂需求拆多个 phase,每个 phase 必须是独立的、可串行执行的实现单元,后面的 phase 依赖前面 phase 的产出。
273
+ 4. 每个 phase 必须包含:id(如 p1/p2/p3)、标题、目标(goal,说明该 phase 要交付什么)。
274
+ 5. 完成后调用 kanban_write_plan 工具把完整计划写回(title、summary、phases)。这是唯一写回方式,不要把计划 JSON 直接输出到对话里。
275
+ 6. 用户回复前不要擅自调用 kanban_write_plan;也不要因为用户暂时没有回复就放弃,等待用户回答即可。`;
276
+ }
252
277
  /** Phase implementation session prompt; identical on retries (the model reads git status/diff itself). */
253
278
  function phasePrompt(card, phaseIndex, workdir) {
254
279
  const plan = card.plan;
@@ -283,6 +308,75 @@ ${conflicts}
283
308
  3. 解决完成后调用 kanban_merge_resolved 工具声明完成(可附简短说明)。`;
284
309
  }
285
310
  //#endregion
311
+ //#region src/server/skills.ts
312
+ /**
313
+ * The public skill-name gesture shape: a whitespace-bounded `/name` token
314
+ * matching the grammar `tool-skill` uses (kebab-case letters/digits), at the
315
+ * very start of the input (leading whitespace allowed). A second `/` or
316
+ * non-boundary character breaks the match, keeping file paths (`/usr/bin`)
317
+ * and fractions (`5/8`) out.
318
+ */
319
+ const SKILL_GESTURE = /^\s*\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/;
320
+ /**
321
+ * Extract a leading `/skill-name` gesture from raw create-task input. Only a
322
+ * token at the very start selects a skill; tokens later in the text are
323
+ * ordinary requirement prose (the user might legitimately mention paths or
324
+ * fractions). The name is grammar-validated but not registry-checked here —
325
+ * unknown names surface as a refinement error with a clear message.
326
+ */
327
+ function parseSkillGesture(raw) {
328
+ const m = SKILL_GESTURE.exec(raw);
329
+ if (m === null) return { requirement: raw.trim() };
330
+ return {
331
+ skill: m[1],
332
+ requirement: raw.slice(m[0].length).trim()
333
+ };
334
+ }
335
+ /** Resolve the host skill registry, or undefined when this profile lacks one. */
336
+ function skillRegistry(ctx) {
337
+ try {
338
+ return ctx.get("skills");
339
+ } catch {
340
+ return;
341
+ }
342
+ }
343
+ /**
344
+ * Build the instructions-form message carrying the rendered skill body, the
345
+ * same shape a user `/grill-me` invocation injects through `tool-skill`. The
346
+ * `skill-invocation` source is what the transcript UI decorates as a skill
347
+ * chip; the content is the canonical `<skill_content>` block.
348
+ */
349
+ function skillInvocationMessage(skill) {
350
+ return createUserMessage({
351
+ content: [{
352
+ type: "text",
353
+ text: renderSkillContent(skill)
354
+ }],
355
+ source: {
356
+ kind: "skill-invocation",
357
+ name: skill.name,
358
+ form: "instructions"
359
+ }
360
+ });
361
+ }
362
+ /**
363
+ * Load the skill body the card names, scoped to the refinement agent so
364
+ * preset-layer providers (the filesystem skill roots) are visible. Returns
365
+ * undefined when the skill is unknown, not user-invocable, or the registry is
366
+ * absent — the caller decides how to surface that.
367
+ */
368
+ async function loadCardSkill(ctx, name, cwd, scope) {
369
+ if (name === void 0 || !isSkillName(name)) return void 0;
370
+ const registry = skillRegistry(ctx);
371
+ if (registry === void 0) return void 0;
372
+ const skill = await registry.get(name, {
373
+ cwd,
374
+ scope
375
+ });
376
+ if (skill === void 0 || !isUserInvocable(skill)) return void 0;
377
+ return skill;
378
+ }
379
+ //#endregion
286
380
  //#region src/shared/plan.ts
287
381
  function validatePlan(input) {
288
382
  if (typeof input !== "object" || input === null) return {
@@ -517,6 +611,11 @@ var KanbanRunner = class {
517
611
  merging = /* @__PURE__ */ new Set();
518
612
  /** Retained agent handles (kept so ended sessions stay in the store/UI). */
519
613
  handles = /* @__PURE__ */ new Map();
614
+ /** Whether startup recovery + session indexing ran (deferred until workspaces exist). */
615
+ recovered = false;
616
+ /** Agents that already got their scoped kanban tools (event idempotency). */
617
+ toolScopedAgents = /* @__PURE__ */ new WeakSet();
618
+ agentStartOff;
520
619
  constructor(ctx, store, settings) {
521
620
  this.ctx = ctx;
522
621
  this.store = store;
@@ -529,9 +628,17 @@ var KanbanRunner = class {
529
628
  this.pumpTimer = setInterval(() => {
530
629
  this.pump();
531
630
  }, 2500);
631
+ this.agentStartOff = this.ctx.on("agent/session-start", (payload) => {
632
+ this.onAgentSessionStart(payload.agent);
633
+ });
532
634
  }
533
635
  stop() {
534
636
  if (this.pumpTimer !== void 0) clearInterval(this.pumpTimer);
637
+ this.pumpTimer = void 0;
638
+ if (this.agentStartOff !== void 0) {
639
+ this.agentStartOff();
640
+ this.agentStartOff = void 0;
641
+ }
535
642
  }
536
643
  newSessionId() {
537
644
  return `session-${randomUUID()}`;
@@ -609,15 +716,65 @@ var KanbanRunner = class {
609
716
  }
610
717
  }
611
718
  }
719
+ /**
720
+ * Rebuild sessionId → cardId from the task store. Runs once the workspace
721
+ * registry is available (see pump), so kanban sessions resumed after a
722
+ * restart still resolve their card.
723
+ */
724
+ async indexSessions() {
725
+ for (const ws of await this.workspaceList()) {
726
+ const cards = await this.store.list(ws.path);
727
+ for (const card of cards) this.mapCardSessions(card.id, card);
728
+ }
729
+ }
730
+ mapCardSessions(cardId, card) {
731
+ for (const sid of card.sessions.refinement) this.sessionCards.set(sid, cardId);
732
+ for (const attempt of card.sessions.phases) this.sessionCards.set(attempt.sessionId, cardId);
733
+ for (const sid of card.sessions.merge) this.sessionCards.set(sid, cardId);
734
+ }
735
+ /** Fallback lookup when a session starts before indexSessions ran. */
736
+ async lookupCardOfSession(sessionId) {
737
+ for (const ws of await this.workspaceList()) {
738
+ const cards = await this.store.list(ws.path);
739
+ for (const card of cards) if (card.sessions.refinement.includes(sessionId) || card.sessions.phases.some((a) => a.sessionId === sessionId) || card.sessions.merge.includes(sessionId)) {
740
+ this.mapCardSessions(card.id, card);
741
+ return card.id;
742
+ }
743
+ }
744
+ }
745
+ /**
746
+ * The single place kanban tools get registered. `agent/session-start` fires
747
+ * for BOTH `agents.create` (startup) and `agents.resume` (host re-open after
748
+ * a restart), so a resumed refinement/phase/merge session keeps its scoped
749
+ * tools. Idempotent per agent object; non-kanban sessions are skipped.
750
+ */
751
+ async onAgentSessionStart(agent) {
752
+ if (agent === void 0 || agent.id === void 0 || agent.ctx === void 0) return;
753
+ if (this.toolScopedAgents.has(agent)) return;
754
+ let cardId = this.sessionCards.get(agent.id);
755
+ if (cardId === void 0) cardId = await this.lookupCardOfSession(agent.id);
756
+ if (cardId === void 0) return;
757
+ this.sessionCards.set(agent.id, cardId);
758
+ try {
759
+ registerKanbanTools(agent.ctx, this.toolResolver());
760
+ this.toolScopedAgents.add(agent);
761
+ } catch (error) {
762
+ console.error("[task-kanban] register kanban tools for " + agent.id + " failed:", String(error));
763
+ }
764
+ }
612
765
  async listCards(workspacePath) {
613
766
  return this.store.list(workspacePath);
614
767
  }
615
768
  async createTask(input) {
769
+ const parsed = parseSkillGesture(input.requirement);
770
+ const skill = input.skill !== void 0 && input.skill !== "" ? input.skill : parsed.skill;
771
+ const requirement = skill !== void 0 ? parsed.requirement : input.requirement.trim();
616
772
  const card = await this.store.create({
617
773
  workspacePath: input.workspacePath,
618
- requirement: input.requirement,
774
+ requirement,
619
775
  model: input.model,
620
776
  provider: input.provider,
777
+ ...skill !== void 0 ? { skill } : {},
621
778
  status: "refining"
622
779
  });
623
780
  await this.ensureGitignoreDsh(input.workspacePath);
@@ -761,6 +918,21 @@ var KanbanRunner = class {
761
918
  return this.settings.get().maxParallelWorkers;
762
919
  }
763
920
  async pump() {
921
+ if (!this.recovered) {
922
+ if ((await this.workspaceList()).length > 0) {
923
+ this.recovered = true;
924
+ try {
925
+ await this.recoverInterrupted();
926
+ } catch (error) {
927
+ console.error("[task-kanban] recoverInterrupted failed:", String(error));
928
+ }
929
+ try {
930
+ await this.indexSessions();
931
+ } catch (error) {
932
+ console.error("[task-kanban] indexSessions failed:", String(error));
933
+ }
934
+ }
935
+ }
764
936
  if (this.slots >= this.maxParallel()) return;
765
937
  const workspaces = await this.workspaceList();
766
938
  for (const ws of workspaces) {
@@ -813,13 +985,46 @@ var KanbanRunner = class {
813
985
  c.sessions.refinement.push(sessionId);
814
986
  }, wsPath);
815
987
  try {
816
- const handle = await this.createAgent(sessionId, wsPath, route, this.toolResolver());
988
+ const handle = await this.createAgent(sessionId, wsPath, route);
817
989
  this.keepHandle(cardId, handle);
818
990
  await this.attachRefinementSession(wsPath, sessionId);
819
- handle.agent.followup(this.asUserMessage(refinementPrompt(card.requirement, wsPath)));
991
+ const skill = card.skill !== void 0 ? await loadCardSkill(this.ctx, card.skill, wsPath, handle.agent) : void 0;
992
+ if (card.skill !== void 0 && skill === void 0) {
993
+ await this.fail(cardId, "refine_failed", `Skill "${card.skill}" 不存在或不可用于用户调用,请检查后重试`, "demand");
994
+ return;
995
+ }
996
+ if (skill !== void 0) handle.agent.inject(skillInvocationMessage(skill));
997
+ const interactive = skill !== void 0;
998
+ handle.agent.followup(this.asUserMessage(interactive ? interactiveRefinementPrompt(card.requirement, wsPath) : refinementPrompt(card.requirement, wsPath)));
999
+ await this.driveRefinement(cardId, wsPath, sessionId, handle, interactive);
1000
+ } catch (error) {
1001
+ console.error("[task-kanban] refinement create/drive failed:", String(error));
1002
+ await this.fail(cardId, "refine_failed", `细化会话失败: ${String(error)}`, "demand");
1003
+ }
1004
+ }
1005
+ /**
1006
+ * Drive the refinement session to a terminal card state. Non-interactive
1007
+ * cards follow the original contract: the agent finishes one turn and must
1008
+ * have written the plan. Interactive cards (created with a refinement
1009
+ * skill) may ask the user clarifying questions — the session is explicit
1010
+ * and visible in the workspace chat, so the runner waits for each reply
1011
+ * instead of failing, and only settles when the plan is written or the
1012
+ * session errors out.
1013
+ */
1014
+ async driveRefinement(cardId, wsPath, sessionId, handle, interactive) {
1015
+ while (true) {
1016
+ const reply = this.waitForUserReply(sessionId);
1017
+ const settlement = this.waitForCardSettlement(cardId, wsPath);
820
1018
  await handle.agent.whenIdle();
821
1019
  const fresh = await this.store.get(wsPath, cardId);
822
- if (fresh !== void 0 && fresh.status === "refining") {
1020
+ if (fresh === void 0 || fresh.status !== "refining") {
1021
+ reply.dispose();
1022
+ settlement.dispose();
1023
+ return;
1024
+ }
1025
+ if (!interactive) {
1026
+ reply.dispose();
1027
+ settlement.dispose();
823
1028
  try {
824
1029
  const tail = handle.agent.session.events.slice(-10).map((e) => ({
825
1030
  type: e.type,
@@ -830,13 +1035,61 @@ var KanbanRunner = class {
830
1035
  console.error("[task-kanban] could not read session events:", String(error));
831
1036
  }
832
1037
  await this.fail(cardId, "refine_failed", "细化会话结束但未通过 kanban_write_plan 写回计划", "demand");
1038
+ return;
833
1039
  }
834
- } catch (error) {
835
- console.error("[task-kanban] refinement create/drive failed:", String(error));
836
- await this.fail(cardId, "refine_failed", `细化会话失败: ${String(error)}`, "demand");
1040
+ await Promise.race([reply.promise, settlement.promise]);
1041
+ reply.dispose();
1042
+ settlement.dispose();
837
1043
  }
838
1044
  }
839
- async createAgent(sessionId, cwd, route, resolver) {
1045
+ /**
1046
+ * A promise resolving when the card stops being 'refining' (planned,
1047
+ * failed, or gone), plus a disposer stopping the polling interval.
1048
+ */
1049
+ waitForCardSettlement(cardId, wsPath) {
1050
+ let resolve;
1051
+ const promise = new Promise((res) => {
1052
+ resolve = res;
1053
+ });
1054
+ const timer = setInterval(async () => {
1055
+ const fresh = await this.store.get(wsPath, cardId);
1056
+ if (fresh === void 0 || fresh.status !== "refining") {
1057
+ clearInterval(timer);
1058
+ resolve();
1059
+ }
1060
+ }, 1e3);
1061
+ timer.unref?.();
1062
+ return {
1063
+ promise,
1064
+ dispose: () => clearInterval(timer)
1065
+ };
1066
+ }
1067
+ /**
1068
+ * A promise resolving when a NEW human message enters the given session,
1069
+ * plus a disposer detaching the listener. Messages injected by plugins with
1070
+ * non-`user` sources (such as our skill-invocation instructions) never
1071
+ * resolve it — only real human replies drive the interactive refinement.
1072
+ */
1073
+ waitForUserReply(sessionId) {
1074
+ let settle;
1075
+ const promise = new Promise((resolve) => {
1076
+ settle = resolve;
1077
+ });
1078
+ const listener = (session, event) => {
1079
+ if (session.id !== sessionId) return;
1080
+ if (event.type !== "user/message") return;
1081
+ if (("source" in event.data ? event.data.source : void 0)?.kind === "user") settle();
1082
+ };
1083
+ const detach = this.ctx.on("session/event", listener);
1084
+ const dispose = () => {
1085
+ if (typeof detach === "function") detach();
1086
+ };
1087
+ return {
1088
+ promise,
1089
+ dispose
1090
+ };
1091
+ }
1092
+ async createAgent(sessionId, cwd, route) {
840
1093
  const agentOptions = route.provider !== void 0 && route.model !== void 0 && route.model !== "" ? {
841
1094
  provider: route.provider,
842
1095
  model: route.model
@@ -849,7 +1102,6 @@ var KanbanRunner = class {
849
1102
  const agentPresets = agentCtx.get("agentPresets");
850
1103
  if (agentPresets !== void 0) await agentPresets.mount(agentCtx, "standard");
851
1104
  else throw new Error("@fonlan/dsh-task-kanban: agent-presets service is not mounted");
852
- registerKanbanTools(agentCtx, resolver);
853
1105
  }
854
1106
  });
855
1107
  }
@@ -1002,7 +1254,7 @@ var KanbanRunner = class {
1002
1254
  }, wsPath);
1003
1255
  const route = await this.modelRoute(cardId, wsPath);
1004
1256
  try {
1005
- const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver());
1257
+ const handle = await this.createAgent(sessionId, workdir, route);
1006
1258
  this.keepHandle(cardId, handle);
1007
1259
  entry.cancel = () => handle.agent.cancel({ kind: "user" });
1008
1260
  handle.agent.followup(this.asUserMessage(phasePrompt({
@@ -1296,7 +1548,7 @@ var KanbanRunner = class {
1296
1548
  }, wsPath);
1297
1549
  const route = await this.modelRoute(cardId, wsPath);
1298
1550
  try {
1299
- const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver());
1551
+ const handle = await this.createAgent(sessionId, workdir, route);
1300
1552
  this.keepHandle(cardId, handle);
1301
1553
  handle.agent.followup(this.asUserMessage(mergePrompt(workdir, conflicts)));
1302
1554
  await handle.agent.whenIdle();
@@ -1460,11 +1712,13 @@ function registerApiRoutes(ctx, runner, settings) {
1460
1712
  const requirement = requireString(p.requirement, "requirement");
1461
1713
  const model = typeof p.model === "string" ? p.model : "";
1462
1714
  const provider = typeof p.provider === "string" ? p.provider : void 0;
1715
+ const skill = typeof p.skill === "string" && p.skill !== "" ? p.skill : void 0;
1463
1716
  return runner.createTask({
1464
1717
  workspacePath,
1465
1718
  requirement,
1466
1719
  model,
1467
- provider
1720
+ provider,
1721
+ ...skill !== void 0 ? { skill } : {}
1468
1722
  });
1469
1723
  });
1470
1724
  route("move", async (p) => {
@@ -1498,7 +1752,11 @@ function registerApiRoutes(ctx, runner, settings) {
1498
1752
  //#endregion
1499
1753
  //#region src/index.ts
1500
1754
  const name = "@fonlan/dsh-task-kanban";
1501
- const inject = ["webServer", "agents"];
1755
+ const inject = [
1756
+ "webServer",
1757
+ "agents",
1758
+ "skills"
1759
+ ];
1502
1760
  const Config = z.object({});
1503
1761
  function apply(ctx, _config) {
1504
1762
  const settings = registerSettings(ctx);
@@ -1,6 +1,13 @@
1
1
  import type { KanbanCard } from '../shared/card.js';
2
2
  /** Refinement session prompt: analyze the repo read-only and write back a plan. */
3
3
  export declare function refinementPrompt(requirement: string, workspacePath: string): string;
4
+ /**
5
+ * Interactive refinement prompt for a card created with a skill (e.g.
6
+ * `/grill-me`). The refinement session is explicit — the user can reply —
7
+ * so instead of deciding autonomously, the agent follows the skill's
8
+ * interview and asks whatever it needs before writing the plan back.
9
+ */
10
+ export declare function interactiveRefinementPrompt(requirement: string, workspacePath: string): string;
4
11
  /** Phase implementation session prompt; identical on retries (the model reads git status/diff itself). */
5
12
  export declare function phasePrompt(card: KanbanCard, phaseIndex: number, workdir: string): string;
6
13
  /** Merge-conflict resolution session prompt: resolve conflicts only, never commit. */
@@ -17,6 +17,11 @@ export declare class KanbanRunner implements KanbanToolResolver {
17
17
  private merging;
18
18
  /** Retained agent handles (kept so ended sessions stay in the store/UI). */
19
19
  private handles;
20
+ /** Whether startup recovery + session indexing ran (deferred until workspaces exist). */
21
+ private recovered;
22
+ /** Agents that already got their scoped kanban tools (event idempotency). */
23
+ private toolScopedAgents;
24
+ private agentStartOff;
20
25
  constructor(ctx: Context, store: TaskStore, settings: KanbanSettingsFace);
21
26
  register(ctx: Context): void;
22
27
  start(): void;
@@ -30,12 +35,29 @@ export declare class KanbanRunner implements KanbanToolResolver {
30
35
  mergeResolved(sessionId: string): Promise<void>;
31
36
  private toolResolver;
32
37
  recoverInterrupted(): Promise<void>;
38
+ /**
39
+ * Rebuild sessionId → cardId from the task store. Runs once the workspace
40
+ * registry is available (see pump), so kanban sessions resumed after a
41
+ * restart still resolve their card.
42
+ */
43
+ private indexSessions;
44
+ private mapCardSessions;
45
+ /** Fallback lookup when a session starts before indexSessions ran. */
46
+ private lookupCardOfSession;
47
+ /**
48
+ * The single place kanban tools get registered. `agent/session-start` fires
49
+ * for BOTH `agents.create` (startup) and `agents.resume` (host re-open after
50
+ * a restart), so a resumed refinement/phase/merge session keeps its scoped
51
+ * tools. Idempotent per agent object; non-kanban sessions are skipped.
52
+ */
53
+ private onAgentSessionStart;
33
54
  listCards(workspacePath: string): Promise<KanbanCard[]>;
34
55
  createTask(input: {
35
56
  workspacePath: string;
36
57
  requirement: string;
37
58
  model: string;
38
59
  provider?: string;
60
+ skill?: string;
39
61
  }): Promise<KanbanCard>;
40
62
  moveTask(cardId: string, toLane: Lane): Promise<{
41
63
  ok: boolean;
@@ -59,6 +81,28 @@ export declare class KanbanRunner implements KanbanToolResolver {
59
81
  private requestStop;
60
82
  private fail;
61
83
  private runRefinement;
84
+ /**
85
+ * Drive the refinement session to a terminal card state. Non-interactive
86
+ * cards follow the original contract: the agent finishes one turn and must
87
+ * have written the plan. Interactive cards (created with a refinement
88
+ * skill) may ask the user clarifying questions — the session is explicit
89
+ * and visible in the workspace chat, so the runner waits for each reply
90
+ * instead of failing, and only settles when the plan is written or the
91
+ * session errors out.
92
+ */
93
+ private driveRefinement;
94
+ /**
95
+ * A promise resolving when the card stops being 'refining' (planned,
96
+ * failed, or gone), plus a disposer stopping the polling interval.
97
+ */
98
+ private waitForCardSettlement;
99
+ /**
100
+ * A promise resolving when a NEW human message enters the given session,
101
+ * plus a disposer detaching the listener. Messages injected by plugins with
102
+ * non-`user` sources (such as our skill-invocation instructions) never
103
+ * resolve it — only real human replies drive the interactive refinement.
104
+ */
105
+ private waitForUserReply;
62
106
  private createAgent;
63
107
  private keepHandle;
64
108
  /**
@@ -0,0 +1,71 @@
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 { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill';
15
+ /** A parsed leading skill gesture: the skill name plus the cleaned requirement. */
16
+ export interface SkillGestureParse {
17
+ /** The skill name from the leading `/name` token, if the input led with one. */
18
+ skill?: string;
19
+ /** The requirement text with the leading gesture removed. */
20
+ requirement: string;
21
+ }
22
+ /**
23
+ * Extract a leading `/skill-name` gesture from raw create-task input. Only a
24
+ * token at the very start selects a skill; tokens later in the text are
25
+ * ordinary requirement prose (the user might legitimately mention paths or
26
+ * fractions). The name is grammar-validated but not registry-checked here —
27
+ * unknown names surface as a refinement error with a clear message.
28
+ */
29
+ export declare function parseSkillGesture(raw: string): SkillGestureParse;
30
+ /**
31
+ * The minimal view of the hosted skill registry the plugin needs: list for a
32
+ * summary lookup and get for a full body, both honoring the calling scope.
33
+ */
34
+ export interface SkillRegistryFace {
35
+ list(options?: {
36
+ cwd?: string;
37
+ scope?: unknown;
38
+ }): Promise<SkillSummary[]>;
39
+ get(name: string, options?: {
40
+ cwd?: string;
41
+ scope?: unknown;
42
+ }): Promise<SkillDefinition | undefined>;
43
+ }
44
+ /** Resolve the host skill registry, or undefined when this profile lacks one. */
45
+ export declare function skillRegistry(ctx: Context): SkillRegistryFace | undefined;
46
+ /** Whether the provided skill name is a syntactically valid skill name. */
47
+ export declare function validSkillName(name: string): boolean;
48
+ /**
49
+ * Build the instructions-form message carrying the rendered skill body, the
50
+ * same shape a user `/grill-me` invocation injects through `tool-skill`. The
51
+ * `skill-invocation` source is what the transcript UI decorates as a skill
52
+ * chip; the content is the canonical `<skill_content>` block.
53
+ */
54
+ export declare function skillInvocationMessage(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): {
55
+ content: {
56
+ type: "text";
57
+ text: string;
58
+ }[];
59
+ source: {
60
+ kind: "skill-invocation";
61
+ name: string;
62
+ form: "instructions";
63
+ };
64
+ } & Pick<import("@deepseek-ai/dsh-llm").UserMessage, "id" | "role">;
65
+ /**
66
+ * Load the skill body the card names, scoped to the refinement agent so
67
+ * preset-layer providers (the filesystem skill roots) are visible. Returns
68
+ * undefined when the skill is unknown, not user-invocable, or the registry is
69
+ * absent — the caller decides how to surface that.
70
+ */
71
+ export declare function loadCardSkill(ctx: Context, name: string | undefined, cwd: string, scope: unknown): Promise<Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'> | undefined>;
@@ -18,6 +18,7 @@ export declare class TaskStore {
18
18
  requirement: string;
19
19
  model: string;
20
20
  provider?: string;
21
+ skill?: string;
21
22
  status?: KanbanCard['status'];
22
23
  }): Promise<KanbanCard>;
23
24
  /** Run a mutation under the card's lock; returns the updated card. */
@@ -47,6 +47,8 @@ export interface KanbanCard {
47
47
  id: string;
48
48
  workspacePath: string;
49
49
  requirement: string;
50
+ /** Optional skill (e.g. `grill-me`) used to drive requirement refinement. */
51
+ skill?: string;
50
52
  model: string;
51
53
  /** Provider route for the model (from the modal selection). */
52
54
  provider?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-task-kanban",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "DSH web plugin: a task kanban that refines requirements into phased plans, auto-executes them in git worktrees (one session per phase), and auto-merges into the main branch on completion.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,6 +39,9 @@
39
39
  "README.md",
40
40
  "LICENSE"
41
41
  ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
42
45
  "scripts": {
43
46
  "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
44
47
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
@@ -73,6 +76,7 @@
73
76
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
74
77
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
75
78
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
79
+ "@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
76
80
  "@deepseek-ai/schemastery": "^3.18.1",
77
81
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
78
82
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
@@ -94,6 +98,7 @@
94
98
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
95
99
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
96
100
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
101
+ "@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
97
102
  "@deepseek-ai/schemastery": "^3.18.1",
98
103
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
99
104
  "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.6",
package/src/client/api.ts CHANGED
@@ -28,10 +28,51 @@ async function call<T>(method: string, payload: Record<string, unknown> = {}): P
28
28
  return parsed.value as T
29
29
  }
30
30
 
31
+ /** One row of the host `skill.list` catalog (same shape DSH's chat autocomplete uses). */
32
+ export interface SkillOption {
33
+ name: string
34
+ description: string
35
+ whenToUse?: string
36
+ modelInvocable: boolean
37
+ }
38
+
39
+ /**
40
+ * Call the host gateway's `skill.list` unary RPC over the same-origin HTTP
41
+ * bridge (the exact wire shape DSH's chat input uses for its /-autocomplete).
42
+ * The requested `sessionId` scopes the catalog to that session's agent
43
+ * preset, which is where the filesystem skill roots are registered.
44
+ */
45
+ export async function gatewaySkillList(sessionId: string): Promise<SkillOption[]> {
46
+ let response: Response
47
+ try {
48
+ response = await fetch('/api/skill.list', {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({
52
+ type: 'client-request',
53
+ rpcId: `task-kanban-${Math.random().toString(36).slice(2)}`,
54
+ method: 'skill.list',
55
+ payload: { sessionId },
56
+ }),
57
+ })
58
+ } catch (error) {
59
+ throw new KanbanApiError('network', error instanceof Error ? error.message : String(error))
60
+ }
61
+ const parsed: { type?: string; result?: { ok?: boolean; value?: { skills?: SkillOption[] }; error?: { code?: string; message?: string } } } | null
62
+ = await response.json().catch(() => null)
63
+ if (!response.ok || parsed === null || parsed.type !== 'server-response' || parsed.result?.ok !== true) {
64
+ throw new KanbanApiError(
65
+ parsed?.result?.error?.code ?? 'http',
66
+ parsed?.result?.error?.message ?? `HTTP ${response.status}`,
67
+ )
68
+ }
69
+ return parsed.result.value?.skills ?? []
70
+ }
71
+
31
72
  export const api = {
32
73
  list: (workspacePath: string) => call<KanbanCard[]>('list', { workspacePath }),
33
- create: (workspacePath: string, requirement: string, model: string, provider?: string) =>
34
- call<KanbanCard>('create', { workspacePath, requirement, model, provider }),
74
+ create: (workspacePath: string, requirement: string, model: string, provider?: string, skill?: string) =>
75
+ call<KanbanCard>('create', { workspacePath, requirement, model, provider, ...(skill !== undefined && skill !== '' ? { skill } : {}) }),
35
76
  move: (cardId: string, toLane: Lane) => call<{ ok: boolean; message?: string }>('move', { cardId, toLane }),
36
77
  stop: (cardId: string) => call<{ ok: boolean; message?: string }>('stop', { cardId }),
37
78
  retry: (cardId: string) => call<{ ok: boolean; message?: string }>('retry', { cardId }),