dsh-tree-task-flow 0.3.13 → 0.3.15

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,6 +1,12 @@
1
1
  # 这个文件只是示例,插件不需要它——**安装即启用**,装完就能用。
2
2
  #
3
- # **默认值一律是"开"。** 插件还没有配置界面,功能默认关等于这个功能不存在。
3
+ # **两个开关平时不用改这里。** 打开侧边栏的「插件」页,在「已安装」里点
4
+ # `dsh-tree-task-flow` 那张卡,页面上就是这两个开关——那是活的,改完立刻生效,
5
+ # 而且**盖过这里写的值**。优先级是:
6
+ #
7
+ # 插件页里改过的 > profile 里的 config > DEFAULT_CONFIG
8
+ #
9
+ # 这里留给"连插件页也不想点"的场合(比如要一次性写进 profile 发给别人)。
4
10
  # 想改成默认以外的东西时,把下面这段抄进 profile 自己的 cordis.patch.yml
5
11
  # (通常是 $DSH_HOME/profiles/<名字>/cordis.patch.yml):
6
12
  #
@@ -16,14 +22,15 @@
16
22
  # 总开关。默认 true(装上即启用);写 false 就一句话关掉,
17
23
  # 关掉后插件不注册工具、不加提示词、不折叠上下文、不续行,一个字节都不碰会话。
18
24
  enabled: true
19
- # 模型停下时是否自动续行。默认 true —— 一条总规矩:插件还没有配置界面,
20
- # 功能就不能默认关(默认关等于这个功能不存在)。怕它停不下来不必靠这里:
21
- # 你按下停止会落盘 held/stopped,续行自己就被拦住,真人再发一条消息才解除。
22
- autoContinue: true
25
+ # 模型停下时是否自动续行。**默认 false(不续)。**
26
+ # 它会在你没看着的时候自己往下跑,所以默认交给插件页里的人来点——
27
+ # 插件页里的那两个开关是活的,改完立刻生效,不用重启。
28
+ autoContinue: false
23
29
  # 连续自动续行的轮次上限,达到就停;只有真人发言才重置配额。
24
30
  maxAutoRounds: 5
25
31
  # 意外中断后是否自动拉起(见 README「意外中断自动拉起」)。默认 true:
26
- # 它只在「这一轮不是正常结束」时动手,正常停下不受影响。
32
+ # 它只在「这一轮不是正常结束」时动手,正常停下不受影响。同样能在插件页里开关,
33
+ # 而且改完立刻生效——这一项是"要不要救",与上一项"要不要往前推"是两件事。
27
34
  interruptRecovery: true
28
35
  # 网络/API 类中断的拉起时刻,单位毫秒,默认 [1分钟, 5分钟, 10分钟, 30分钟];
29
36
  # null 表示用内置默认值。工具执行中断不看这张表,它永远立即拉一次。
package/cordis.patch.yml CHANGED
@@ -12,14 +12,16 @@
12
12
  # 字段一次写全。可覆盖的字段见 DEFAULT_CONFIG:
13
13
  #
14
14
  # enabled 总开关 默认 true(写 false 关掉)
15
- # autoContinue 是否允许自动续行 默认 true
15
+ # autoContinue 是否允许自动续行 默认 false(插件页里能改)
16
16
  # maxAutoRounds 连续自动续行轮次上限 默认 5
17
- # interruptRecovery 意外中断是否自动拉起 默认 true
17
+ # interruptRecovery 意外中断是否自动拉起 默认 true(插件页里能改)
18
18
  # interruptDelaysMs 网络类中断拉起时刻 默认 null(= 1/5/10/30 分钟)
19
19
  # promptOrder 工具说明段顺序 默认 1800
20
20
  # rootDir 插件状态目录 默认 $DSH_HOME/dsh-task-tree
21
21
  #
22
- # 默认值一律是"开"。插件还没有配置界面,功能默认关等于这个功能不存在。
22
+ # 两个布尔开关(autoContinue / interruptRecovery)平时不用在这里改:
23
+ # **侧边栏「插件」页**里点开 `dsh-tree-task-flow` 这张卡,页面上就有这两个开关——
24
+ # **改完立刻生效、并且盖过这里写的值**(插件页 > profile config > DEFAULT_CONFIG)。
23
25
  - insert:
24
26
  - id: task-tree
25
27
  name: 'dsh-tree-task-flow'
package/lib/commands.js CHANGED
@@ -8,16 +8,38 @@
8
8
  * @module dsh-tree-task-flow/commands
9
9
  */
10
10
 
11
- import { activePath, checkpointOf, complete, dropNode, levelLabel, renderTree } from "./plan.js";
11
+ import {
12
+ activePath,
13
+ checkpointOf,
14
+ complete,
15
+ deleteNode,
16
+ dropNode,
17
+ findNode,
18
+ goalsOf,
19
+ levelLabel,
20
+ renderGoal,
21
+ renderTree,
22
+ } from "./plan.js";
23
+ import { renderList } from "./render.js";
24
+ import {
25
+ activeRequirement,
26
+ deleteRequirement,
27
+ dropRequirement,
28
+ listActive,
29
+ listArchived,
30
+ listDrafts,
31
+ requirementOf,
32
+ } from "./requirement.js";
12
33
 
13
34
  /** 用法说明,status 和 help 都会带上。 */
14
35
  const USAGE = [
15
- "/task-tree 查看本会话的任务树与检查点状态",
16
- "/task-tree done <id> <结果> 提交某个节点的结果并完成它",
17
- "/task-tree drop <id> 丢弃某个节点",
36
+ "/task-tree 查看本会话的需求列表与当前进度",
37
+ "/task-tree done <id> <结果> 提交某个节点(需求 / 任务 / 步骤)的结果并完成它",
38
+ "/task-tree drop <id> 放弃:留着可查,标记为不做了",
39
+ "/task-tree delete <id> 删除:连下面的一起摘掉,不看状态",
18
40
  "/task-tree pause 暂停:当前工具跑完后停住,不再往下推进",
19
41
  "/task-tree resume 继续:从停住的地方接着跑,不插消息",
20
- "/task-tree reset 清掉本会话的计划树",
42
+ "/task-tree reset 清掉本会话的计划(需求、任务全没)",
21
43
  ].join("\n");
22
44
 
23
45
  /** 插件被配置关掉时唯一要说的话:现在是关的,以及怎么打开。 */
@@ -25,6 +47,25 @@ const DISABLED_NOTE =
25
47
  "dsh-tree-task-flow 已被配置**关闭**(config 里 enabled 不是 true):它没有注册工具," +
26
48
  "也不会折叠任何上下文。要打开请在 profile 的 cordis.patch.yml 里把 enabled 设为 true。";
27
49
 
50
+ /**
51
+ * 需求列表的文本:与面板、工具共用同一份渲染(`render.renderList`)。
52
+ *
53
+ * @param plan - 计划树。
54
+ * @returns 多行 Markdown。
55
+ */
56
+ function listText(plan) {
57
+ const pick = (rows) => rows.map((row) => requirementOf(plan, row.id)).filter((item) => item !== null);
58
+ return renderList(
59
+ plan,
60
+ {
61
+ active: pick(listActive(plan)),
62
+ archived: pick(listArchived(plan)),
63
+ drafts: listDrafts(plan),
64
+ },
65
+ activeRequirement(plan),
66
+ );
67
+ }
68
+
28
69
  /**
29
70
  * 把检查点那句话拼出来。措辞与 `plan.js` 的 `renderContinue` 一致。
30
71
  *
@@ -59,8 +100,9 @@ export function registerCommands(ctx, { store, cfg, pause = null, enabled = fals
59
100
 
60
101
  commands.register({
61
102
  name: "task-tree",
62
- description: "查看与管理任务树:status / done <id> <结果> / drop <id> / pause / resume / reset",
63
- input: { hint: "status | done <id> <结果> | drop <id> | pause | resume | reset" },
103
+ description:
104
+ "查看与管理任务树:status / done <id> <结果> / drop <id> / delete <id> / pause / resume / reset",
105
+ input: { hint: "status | done <id> <结果> | drop <id> | delete <id> | pause | resume | reset" },
64
106
  handler: (invocation) => {
65
107
  const sessionId = invocation?.agent?.session?.id;
66
108
  if (sessionId === undefined) {
@@ -127,12 +169,12 @@ export function registerCommands(ctx, { store, cfg, pause = null, enabled = fals
127
169
  };
128
170
  }
129
171
  const plan = store.readPlan(sessionId);
130
- if (plan === null) return { kind: "error", text: "本会话还没有计划。" };
172
+ if (plan === null) return { kind: "error", text: "本会话还没有需求。" };
131
173
  const outcome = complete(plan, id, result);
132
174
  if (!outcome.ok) return { kind: "error", text: `没有完成:${outcome.reason}` };
133
175
  store.writePlan(sessionId, plan);
134
- // `complete` 已经把检查点提示放进 events 了,这里只补上折叠后的树。
135
- return { kind: "success", text: [...outcome.events, "", renderTree(plan)].join("\n") };
176
+ // `complete` 已经把检查点提示放进 events 了,这里只补上最新的需求列表。
177
+ return { kind: "success", text: [...outcome.events, "", listText(plan)].join("\n") };
136
178
  }
137
179
 
138
180
  if (verb === "drop") {
@@ -141,29 +183,60 @@ export function registerCommands(ctx, { store, cfg, pause = null, enabled = fals
141
183
  return { kind: "error", text: `用法:/task-tree drop <节点 id>\n\n${USAGE}` };
142
184
  }
143
185
  const plan = store.readPlan(sessionId);
144
- if (plan === null) return { kind: "error", text: "本会话还没有计划。" };
145
- const result = dropNode(plan, id);
146
- if (!result.ok) return { kind: "error", text: `没有丢弃:${result.reason}` };
186
+ if (plan === null) return { kind: "error", text: "本会话还没有需求。" };
187
+ const isRequirement = requirementOf(plan, id) !== null;
188
+ const result = isRequirement ? dropRequirement(plan, id) : dropNode(plan, id);
189
+ if (!result.ok) return { kind: "error", text: `没有放弃:${result.reason}` };
147
190
  store.writePlan(sessionId, plan);
148
191
  return {
149
192
  kind: "success",
150
- text: [`已丢弃:${result.dropped.join("、")}`, "", renderTree(plan)].join("\n"),
193
+ text: [`已放弃:${result.dropped.join("、")}`, "", listText(plan)].join("\n"),
151
194
  };
152
195
  }
153
196
 
197
+ if (verb === "delete") {
198
+ const id = rest[0];
199
+ if (id === undefined) {
200
+ return { kind: "error", text: `用法:/task-tree delete <节点 id>\n\n${USAGE}` };
201
+ }
202
+ const plan = store.readPlan(sessionId);
203
+ if (plan === null) return { kind: "error", text: "本会话还没有需求。" };
204
+ // 摘掉之后就查不到"这次动的是哪条需求"了,先记一份。
205
+ const touched = findNode(plan, id);
206
+ const isRequirement = requirementOf(plan, id) !== null;
207
+ const result = isRequirement ? deleteRequirement(plan, id) : deleteNode(plan, id);
208
+ if (!result.ok) return { kind: "error", text: `没有删除:${result.reason}` };
209
+ store.writePlan(sessionId, plan);
210
+
211
+ const lines = [`已删除:${result.removed.join("、")}`, ""];
212
+ if (touched !== null && touched.level !== "goal") {
213
+ // 删的是任务或步骤:它所在的那条需求还在,只给它那条需求的列表。
214
+ lines.push(listText(plan));
215
+ } else {
216
+ const left = goalsOf(plan).length;
217
+ lines.push(
218
+ left === 0
219
+ ? "这个会话现在没有需求了。"
220
+ : `这个会话还剩 ${left} 条需求,要看全部用 /task-tree status。`,
221
+ );
222
+ }
223
+ return { kind: "success", text: lines.join("\n") };
224
+ }
225
+
154
226
  if (verb !== "status") {
155
227
  return { kind: "error", text: `不认识的子命令:${verb}\n\n${USAGE}` };
156
228
  }
157
229
 
158
230
  const plan = store.readPlan(sessionId);
159
- const lines = [renderTree(plan)];
231
+ const lines = [];
160
232
 
161
233
  if (enabled !== true) {
162
- lines.push("", DISABLED_NOTE);
234
+ lines.push(DISABLED_NOTE);
163
235
  } else if (plan === null) {
164
- lines.push("", "本会话还没有计划。用 tree_task_plan 建立目标与任务。");
236
+ lines.push("本会话还没有需求。用 tree_task_create 记一条需求草稿,跟用户确认之后才算真需求。");
165
237
  } else {
166
- // 检查点优先于"当前节点":那一刻要推进的不是某个子任务,
238
+ lines.push(listText(plan));
239
+ // 检查点优先于"当前节点":那一刻要推进的不是某个步骤,
167
240
  // 而是"这一层到底做完了没有"这个判断。
168
241
  const checkpoint = checkpointOf(plan);
169
242
  const active = activePath(plan);
package/lib/http.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * - **暂停状态**:条目上的暂停/继续按钮写的就是这个。暂停挂的是
15
15
  * `agent/pre-step`(见 `pause.js`),不插消息、不改计划,所以它不属于计划树,
16
16
  * 单独回传。
17
+ * - **设置**:面板上那两个开关(见 `settings.js`)。它跟会话无关,读的时候顺手带上,
18
+ * 改的时候写盘并**就地改活配置**,所以改完立刻生效,不用重启。
17
19
  *
18
20
  * 条目上的按钮都写计划树,所以写完要把**新的**活动路径、结果与检查点一并回传,
19
21
  * 免得条目还要再发一次请求才知道自己按出了什么效果。
@@ -21,7 +23,23 @@
21
23
  * @module dsh-tree-task-flow/http
22
24
  */
23
25
 
24
- import { activePath, checkpointOf, complete, dropNode, levelLabel } from "./plan.js";
26
+ import { activePath, complete, deleteNode, dropNode, levelLabel } from "./plan.js";
27
+ import { SETTING_KEYS, settingsSnapshot } from "./settings.js";
28
+ import {
29
+ addDraft,
30
+ currentVersion,
31
+ deleteRequirement,
32
+ dropRequirement,
33
+ listActive,
34
+ listArchived,
35
+ listDrafts,
36
+ missingPrerequisites,
37
+ nextTaskOf,
38
+ progressOf,
39
+ requirementOf,
40
+ setArchived,
41
+ updateByUser,
42
+ } from "./requirement.js";
25
43
 
26
44
  /** 统一的 JSON 响应。 */
27
45
  function sendJson(response, status, body) {
@@ -44,30 +62,98 @@ async function readJsonBody(request) {
44
62
  }
45
63
  }
46
64
 
47
- /** 活动路径压成面板要的形状:三层各自的 id,没到那层就是 null。 */
48
- function activeInfo(plan) {
49
- const active = activePath(plan);
50
- if (active === null) return null;
65
+ /**
66
+ * 一条需求压成面板要的形状。
67
+ *
68
+ * 列表上只显示标题、简介与状态;`detail` 与 `versions` 也一起给——面板是本机的,
69
+ * 展开时不用再发一次请求,而"要不要展示"是面板自己的事。
70
+ *
71
+ * @param plan - 计划树。
72
+ * @param requirement - 需求节点。
73
+ * @returns 面板用的行对象。
74
+ */
75
+ function rowOf(plan, requirement) {
76
+ const task = nextTaskOf(requirement);
51
77
  return {
52
- goalId: active.goal.id,
53
- taskId: active.task?.id ?? null,
54
- stepId: active.step?.id ?? null,
78
+ id: requirement.id,
79
+ title: requirement.title,
80
+ intro: requirement.intro,
81
+ detail: requirement.detail,
82
+ status: requirement.status,
83
+ archived: requirement.archived === true,
84
+ pending: requirement.pending
85
+ ? {
86
+ title: requirement.pending.title,
87
+ intro: requirement.pending.intro,
88
+ detail: requirement.pending.detail,
89
+ change: requirement.pending.change,
90
+ question: requirement.pending.question,
91
+ at: requirement.pending.at,
92
+ }
93
+ : null,
94
+ missing: missingPrerequisites(plan, requirement).map((item) => ({
95
+ id: item.id,
96
+ state: item.state,
97
+ })),
98
+ prerequisites: requirement.prerequisites ?? [],
99
+ nextTask: task === null ? null : { id: task.id, title: task.title },
100
+ version: currentVersion(requirement),
101
+ changes: (requirement.versions ?? []).length,
102
+ updatedAt: requirement.updatedAt ?? null,
103
+ result: requirement.result?.text ?? null,
104
+ tasks: (requirement.tasks ?? []).map((each) => ({
105
+ id: each.id,
106
+ title: each.title,
107
+ status: each.status,
108
+ result: each.result?.text ?? null,
109
+ steps: (each.steps ?? []).map((step) => ({
110
+ id: step.id,
111
+ title: step.title,
112
+ status: step.status,
113
+ result: step.result?.text ?? null,
114
+ })),
115
+ })),
55
116
  };
56
117
  }
57
118
 
58
- /** 检查点压成面板要的形状;没有检查点就是 null。 */
59
- function checkpointInfo(plan) {
60
- const found = checkpointOf(plan);
61
- if (found === null) return null;
119
+ /**
120
+ * 面板要的整份数据:两个列表 + 草稿区 + 当前进行中的展开。
121
+ *
122
+ * 「当前进行中」由**最后执行的那个任务**推导(`activeRequirement`),面板不另存状态。
123
+ *
124
+ * @param plan - 计划树。
125
+ * @returns 面板用的数据;没有计划时三个列表都是空的。
126
+ */
127
+ function panelData(plan) {
128
+ if (plan === null) {
129
+ return {
130
+ requirements: { active: [], archived: [], drafts: [] },
131
+ current: null,
132
+ progress: [],
133
+ };
134
+ }
135
+ const active = listActive(plan).map((row) => rowOf(plan, requirementOf(plan, row.id)));
136
+ const archived = listArchived(plan).map((row) => rowOf(plan, requirementOf(plan, row.id)));
137
+ const drafts = listDrafts(plan).map((requirement) => rowOf(plan, requirement));
138
+ // 当前进行中的那一条:它的任务与步骤要在列表里就地展开。
139
+ const live = active.find((row) => row.status === "pending") ?? null;
140
+ const current = live ?? null;
62
141
  return {
63
- id: found.node.id,
64
- level: found.level,
65
- label: levelLabel(found.level),
66
- title: found.node.title,
142
+ requirements: { active, archived, drafts },
143
+ current: current === null ? null : current.id,
144
+ progress:
145
+ current === null
146
+ ? []
147
+ : progressOf(requirementOf(plan, current.id)).map(({ task, steps }) => ({
148
+ id: task.id,
149
+ title: task.title,
150
+ status: task.status,
151
+ steps: steps.map((step) => ({ id: step.id, title: step.title, status: step.status })),
152
+ })),
67
153
  };
68
154
  }
69
155
 
70
- /** 一个节点提交过的结果;还没提交就是 null。 */
156
+ /** 一条节点提交过的结果;还没提交就是 null。 */
71
157
  function resultOf(node) {
72
158
  if (!node?.result) return null;
73
159
  return {
@@ -78,35 +164,23 @@ function resultOf(node) {
78
164
  };
79
165
  }
80
166
 
81
- /** 活动路径上每一层各自提交的结果。 */
82
- function levelResults(plan) {
83
- const active = activePath(plan);
84
- return {
85
- goal: resultOf(active?.goal),
86
- task: resultOf(active?.task),
87
- step: resultOf(active?.step),
88
- };
89
- }
90
-
91
167
  /** 一次写操作之后,面板需要的全部新状态。 */
92
168
  function afterWrite(plan) {
93
- return {
94
- active: activeInfo(plan),
95
- checkpoint: checkpointInfo(plan),
96
- results: levelResults(plan),
97
- };
169
+ return panelData(plan);
98
170
  }
99
171
 
100
172
  /**
101
173
  * 注册全部面板接口。
102
174
  *
103
175
  * @param ctx - 挂载本行的 Cordis 上下文。
104
- * @param options - `{ store, autoContinue, pause }`;插件被关闭时后两者是 null。
176
+ * @param options - `{ store, autoContinue, pause, cfg }`;插件被关闭时 `autoContinue`
177
+ * 与 `pause` 是 null。`cfg` 是那一份**活的对象**:读的时候用它报当前开关,
178
+ * 改的时候就地改它——各模块按事件现读,所以下一刻就生效。
105
179
  */
106
- export function registerHttp(ctx, { store, autoContinue, pause = null }) {
180
+ export function registerHttp(ctx, { store, autoContinue, pause = null, cfg = null }) {
107
181
  ctx.inject(["webServer"], (host) => {
108
182
  host.effect(() => {
109
- // 一棵树的全部信息:结构 + 每层结果 + 检查点 + 当前路径 + 三层折叠游标。
183
+ // 面板要的全部数据:需求两个列表 + 草稿区 + 当前进行中的展开 + 按住状态。
110
184
  host.webServer.register(
111
185
  {
112
186
  kind: "exact",
@@ -116,26 +190,38 @@ export function registerHttp(ctx, { store, autoContinue, pause = null }) {
116
190
  const url = new URL(request.url ?? "/", "http://localhost");
117
191
  const sessionId = url.searchParams.get("sessionId") ?? "";
118
192
  const plan = store.readPlan(sessionId);
119
- if (plan === null) return sendJson(response, 404, { ok: false, error: "这个会话没有计划" });
120
193
 
121
- const cursors = store.readSurfaces(sessionId);
122
194
  sendJson(response, 200, {
123
195
  ok: true,
124
- plan,
196
+ sessionId,
125
197
  ...afterWrite(plan),
126
- cursors: {
127
- goal: cursors.goal ?? null,
128
- task: cursors.task ?? null,
129
- step: cursors.step ?? null,
130
- },
131
198
  stopped: autoContinue?.isStopped?.(sessionId) === true,
132
199
  paused: pause?.isPaused?.(sessionId) === true,
200
+ settings: settingsSnapshot(cfg),
133
201
  });
134
202
  },
135
203
  },
136
204
  "dsh-tree-task-flow: tree",
137
205
  );
138
206
 
207
+ // 插件管理里本插件那一页用的接口:**跟会话无关**,只回两个开关的当前值与总开关
208
+ // 状态。那一页不是某个会话的界面,没有"当前会话"这个概念,所以它不能走 `/tree`。
209
+ host.webServer.register(
210
+ {
211
+ kind: "exact",
212
+ path: "/dsh-tree-task-flow/settings",
213
+ handler: async (request, response) => {
214
+ if (request.method !== "GET") return sendJson(response, 405, { ok: false, error: "只支持 GET" });
215
+ sendJson(response, 200, {
216
+ ok: true,
217
+ enabled: cfg?.enabled === true,
218
+ settings: settingsSnapshot(cfg),
219
+ });
220
+ },
221
+ },
222
+ "dsh-tree-task-flow: settings",
223
+ );
224
+
139
225
  // 面板上的按钮都走这一个端点。
140
226
  host.webServer.register(
141
227
  {
@@ -161,6 +247,92 @@ export function registerHttp(ctx, { store, autoContinue, pause = null }) {
161
247
  });
162
248
  }
163
249
 
250
+ // 面板上的开关。跟会话无关,所以不读 sessionId。
251
+ //
252
+ // 两件事一起做:**写盘**(重启后还在)与**就地改活配置**(这一刻就生效)。
253
+ // 各模块都是"事件来了现读 cfg",所以改完不用重启,下一次事件就是新值。
254
+ if (action === "setting") {
255
+ const key = String(body.key ?? "");
256
+ if (!SETTING_KEYS.includes(key)) {
257
+ return sendJson(response, 400, { ok: false, error: `不认识的开关:${key}` });
258
+ }
259
+ if (typeof body.value !== "boolean") {
260
+ return sendJson(response, 400, { ok: false, error: "开关的值只能是 true 或 false" });
261
+ }
262
+ store.patchSettings({ [key]: body.value });
263
+ if (cfg !== null && typeof cfg === "object") cfg[key] = body.value;
264
+ return sendJson(response, 200, { ok: true, settings: settingsSnapshot(cfg) });
265
+ }
266
+
267
+ // 面板上新建需求:**只产出草稿**,等 AI 沟通确认之后才算真需求。
268
+ if (action === "draft") {
269
+ const plan = store.readPlan(sessionId) ?? {
270
+ version: 3,
271
+ createdAt: Date.now(),
272
+ updatedAt: Date.now(),
273
+ goals: [],
274
+ };
275
+ const requirement = addDraft(plan, {
276
+ title: body.title,
277
+ intro: body.intro,
278
+ detail: body.detail,
279
+ });
280
+ store.writePlan(sessionId, plan);
281
+ return sendJson(response, 200, { ok: true, id: requirement.id, ...afterWrite(plan) });
282
+ }
283
+
284
+ // 用户改需求正文:**只落暂定版本**,不动正在生效的正文(`docs/09` §3.3)。
285
+ // 点这个按钮**不叫醒 AI**——它下次被唤起时会先看到这条待确认。
286
+ if (action === "pending") {
287
+ const plan = store.readPlan(sessionId);
288
+ if (plan === null) return sendJson(response, 404, { ok: false, error: "这个会话还没有需求" });
289
+ const outcome = updateByUser(plan, String(body.id ?? ""), {
290
+ title: body.title,
291
+ intro: body.intro,
292
+ detail: body.detail,
293
+ change: body.change,
294
+ });
295
+ if (!outcome.ok) return sendJson(response, 200, { ok: false, error: outcome.reason });
296
+ store.writePlan(sessionId, plan);
297
+ return sendJson(response, 200, {
298
+ ok: true,
299
+ cancelled: outcome.cancelled === true,
300
+ message: outcome.cancelled === true ? "改回和当前版本一样了,暂定版本已撤掉。" : "已提交这个版本的改动,等 AI 确认。",
301
+ ...afterWrite(plan),
302
+ });
303
+ }
304
+
305
+ if (action === "archive" || action === "unarchive") {
306
+ const plan = store.readPlan(sessionId);
307
+ if (plan === null) return sendJson(response, 404, { ok: false, error: "这个会话还没有需求" });
308
+ const outcome = setArchived(plan, String(body.id ?? ""), action === "archive");
309
+ if (!outcome.ok) return sendJson(response, 200, { ok: false, error: outcome.reason });
310
+ store.writePlan(sessionId, plan);
311
+ return sendJson(response, 200, { ok: true, ...afterWrite(plan) });
312
+ }
313
+
314
+ // 点某条需求的「开始」:这是**能叫醒 AI 的三种动作之一**。
315
+ if (action === "start") {
316
+ const plan = store.readPlan(sessionId);
317
+ const requirement = plan === null ? null : requirementOf(plan, String(body.id ?? ""));
318
+ if (requirement === null) {
319
+ return sendJson(response, 200, { ok: false, error: "找不到这条需求" });
320
+ }
321
+ const outcome = pause?.nudge?.(
322
+ sessionId,
323
+ `开始做需求 ${requirement.id}「${requirement.title}」。` +
324
+ "先用 tree_task_start 开始它(会折叠上一次折叠之后的过程,并给出这条需求的详情),再按计划往下推。",
325
+ "开始需求",
326
+ ) ?? { woke: false, reason: "unavailable", text: "插件没在运行,叫不醒会话。" };
327
+ return sendJson(response, 200, {
328
+ ok: true,
329
+ woke: outcome.woke === true,
330
+ message: outcome.text,
331
+ paused: pause?.isPaused?.(sessionId) === true,
332
+ stopped: autoContinue?.isStopped?.(sessionId) === true,
333
+ });
334
+ }
335
+
164
336
  if (action === "reset") {
165
337
  store.writePlan(sessionId, null);
166
338
  // 计划没了,游标也必须跟着清:游标里记的是"这一层总结到哪儿了",
@@ -175,17 +347,26 @@ export function registerHttp(ctx, { store, autoContinue, pause = null }) {
175
347
  return sendJson(response, 200, { ok: true, active: null, checkpoint: null, results: null });
176
348
  }
177
349
 
178
- if (action === "done" || action === "drop") {
350
+ if (action === "done" || action === "drop" || action === "delete") {
179
351
  const plan = store.readPlan(sessionId);
180
352
  if (plan === null) return sendJson(response, 404, { ok: false, error: "这个会话没有计划" });
181
353
  const id = String(body.id ?? "");
354
+ const found = requirementOf(plan, id) !== null;
182
355
  // 完成必须带结果:插件要用它替换掉这个节点的执行过程。
183
- const outcome = action === "done" ? complete(plan, id, body.result) : dropNode(plan, id);
356
+ const outcome = found
357
+ ? action === "drop"
358
+ ? dropRequirement(plan, id)
359
+ : deleteRequirement(plan, id)
360
+ : action === "done"
361
+ ? complete(plan, id, body.result)
362
+ : action === "drop"
363
+ ? dropNode(plan, id)
364
+ : deleteNode(plan, id);
184
365
  if (!outcome.ok) return sendJson(response, 200, { ok: false, error: outcome.reason });
185
366
  store.writePlan(sessionId, plan);
186
367
  return sendJson(response, 200, {
187
368
  ok: true,
188
- events: outcome.events ?? outcome.dropped ?? [],
369
+ events: outcome.events ?? outcome.dropped ?? outcome.removed ?? [],
189
370
  ...afterWrite(plan),
190
371
  });
191
372
  }
@@ -196,7 +377,7 @@ export function registerHttp(ctx, { store, autoContinue, pause = null }) {
196
377
  "dsh-tree-task-flow: action",
197
378
  );
198
379
 
199
- ctx.logger?.info?.("dsh-tree-task-flow: 设置面板接口已挂载");
380
+ ctx.logger?.info?.("dsh-tree-task-flow: 插件配置页接口已挂载");
200
381
  }, "dsh-tree-task-flow: http");
201
382
  });
202
383
  }