@fonlan/dsh-task-kanban 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/lib/client.js +330 -93
- package/lib/client.js.map +1 -1
- package/lib/index.js +271 -13
- package/lib/types/server/prompts.d.ts +7 -0
- package/lib/types/server/runner.d.ts +44 -0
- package/lib/types/server/skills.d.ts +71 -0
- package/lib/types/server/task-store.d.ts +1 -0
- package/lib/types/shared/card.d.ts +2 -0
- package/package.json +6 -1
- package/src/client/api.ts +43 -2
- package/src/client/board.css +126 -28
- package/src/client/board.tsx +210 -32
- package/src/client/kanban-state.ts +65 -23
- package/src/client/locales.ts +16 -2
- package/src/index.ts +1 -1
- package/src/server/prompts.ts +24 -0
- package/src/server/rpc.ts +2 -1
- package/src/server/runner.ts +214 -14
- package/src/server/skills.ts +105 -0
- package/src/server/task-store.ts +2 -0
- package/src/shared/card.ts +2 -0
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
|
|
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
|
|
988
|
+
const handle = await this.createAgent(sessionId, wsPath, route);
|
|
817
989
|
this.keepHandle(cardId, handle);
|
|
818
990
|
await this.attachRefinementSession(wsPath, sessionId);
|
|
819
|
-
|
|
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
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1040
|
+
await Promise.race([reply.promise, settlement.promise]);
|
|
1041
|
+
reply.dispose();
|
|
1042
|
+
settlement.dispose();
|
|
837
1043
|
}
|
|
838
1044
|
}
|
|
839
|
-
|
|
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
|
|
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
|
|
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 = [
|
|
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>;
|
|
@@ -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.
|
|
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 }),
|