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.
package/lib/tools.js CHANGED
@@ -1,22 +1,23 @@
1
1
  /**
2
- * 六个 `tree_task_*` 工具。
2
+ * 十个 `tree_task_*` 工具。
3
3
  *
4
- * **写树分成两个工具,不是可以合并的重复。** `tree_task_create` 与 `tree_task_done`
5
- * 是折叠区间的两个边界(见 `lib/region.js`):`create` 是"开工"那一下,`done` 是
6
- * "收工"那一下。`create` 只在新建目标时调用一次,`plan` 却在任务中途反复调用——
7
- * 把两者并成一个工具,折叠起点就只能靠实参里有没有 `parentId` 去猜,
8
- * 模型一次写漏或多写就会把起点挪走,那段过程就再也收不走了。
9
- * 代价是两个工具的实参形状有点像(都是一串 `{title, detail}`),
10
- * 所以 `plan` 的说明里专门写了一句"建新目标不归它管"。
4
+ * 语义分三层:
11
5
  *
12
- * 工具本身**只做状态读写**——不注入消息、不动 surface,也不拦任何别的工具。
13
- * 「完成时把这个节点的执行过程从上下文里折起来、换成一条携带它结果的汇总」
14
- * 那件事完全由 `agent/pre-step` 监听器负责(它比对游标与 `activePath` 就知道
15
- * 哪一层刚结束)。
6
+ * - **需求**(`tree_task_create` / `tree_task_requirement` / `tree_task_start`)——用户要什么;
7
+ * - **任务与步骤**(`tree_task_plan` / `tree_task_update` / `tree_task_drop` / `tree_task_delete`)——
8
+ * AI 怎么把它做出来;
9
+ * - **查询与笔记**(`tree_task_status` / `tree_task_note`)。
16
10
  *
17
- * 工具能决定的只有一件事:**这个节点算不算做完了**。而它做完了就必须交出
18
- * `result`——插件正是拿这段文字去顶替它整段执行过程。没有结果的完成会让那段
19
- * 上下文白白消失,所以 `result` 是必填的。
11
+ * ## 为什么 `create` / `start` / `done` 必须各占一个名字
12
+ *
13
+ * 它们划出过程折叠的区间(见 `lib/region.js` 与 `docs/06`):`create` 是需求的起点标记,
14
+ * `start` 是开工并折叠资料段,`done` 是完成并折叠一段。`plan` 在任务中途反复调用,一旦让它兼任
15
+ * 起点,每次补步骤都会把起点往后推,`done` 与它之间那段过程就永远收不走。所以边界
16
+ * **按工具名认,不看实参**。`tree_task_requirement` 是中途动作(确认变更、归档、设前置),
17
+ * **不是边界**。
18
+ *
19
+ * 工具本身**只做状态读写**——不注入消息、不动 surface。「完成时把那一段过程折起来」
20
+ * 由 `agent/pre-step` 监听器负责。
20
21
  *
21
22
  * 所有工具共用同一套契约:
22
23
  * - `parameters` 用**原始 JSON Schema**(不能 import 宿主的 schemastery)
@@ -25,19 +26,39 @@
25
26
  * @module dsh-tree-task-flow/tools
26
27
  */
27
28
 
29
+ import { findNotes, renderNoteLines, upsertNote } from "./notes.js";
30
+ import { renderBriefList, renderDetail, renderList, renderNotes, statusLabel } from "./render.js";
28
31
  import {
29
32
  addChildren,
30
- addGoal,
31
33
  complete,
32
- createPlan,
33
- doneReply,
34
+ continueNote,
35
+ deleteNode,
34
36
  dropNode,
35
37
  findNode,
36
- renderContinue,
37
- renderGoal,
38
- renderTree,
38
+ goalsOf,
39
39
  updateNode,
40
40
  } from "./plan.js";
41
+ import {
42
+ REQUIREMENT_STATUS,
43
+ addDraft,
44
+ confirmPending,
45
+ confirmRequirement,
46
+ completeRequirement,
47
+ deleteRequirement,
48
+ dropRequirement,
49
+ listActive,
50
+ listArchived,
51
+ listDrafts,
52
+ isDraft,
53
+ questionPending,
54
+ requirementOf,
55
+ setArchived,
56
+ setPrerequisites,
57
+ taskCompletionHints,
58
+ updateByAi,
59
+ activeRequirement,
60
+ } from "./requirement.js";
61
+ import { previewFold, tailSeq } from "./region.js";
41
62
 
42
63
  /** 所有工具统一的返回形状与渲染。 */
43
64
  const OUTPUT = {
@@ -58,7 +79,74 @@ const ok = (text) => ({ text });
58
79
  * @returns 中文称呼。
59
80
  */
60
81
  function childLabel(level) {
61
- return level === "goal" ? "任务" : "子任务";
82
+ return level === "goal" ? "任务" : "步骤";
83
+ }
84
+
85
+ /**
86
+ * 判断一个 id 指的是不是需求(需求住在 `plan.goals` 里,层级名仍是 `goal`)。
87
+ *
88
+ * @param found - `findNode()` 的结果。
89
+ * @returns 是需求为 true。
90
+ */
91
+ function isRequirementNode(found) {
92
+ return found !== null && found.level === "goal";
93
+ }
94
+
95
+ /**
96
+ * 把「一个 id」或「一批 ids」读成一个去重后的列表。
97
+ *
98
+ * 批量只给那些**入参就一个小东西、彼此不冲突**的动作用(放弃、删除、归档):
99
+ * 一次说清一批,省掉来回奔波的调用。带正文的动作(改标题、写 result)不批量——
100
+ * 那些的入参本来就是大头,合并没有意义。
101
+ *
102
+ * @param args - 工具实参。
103
+ * @returns id 列表,可能为空。
104
+ */
105
+ function idListOf(args) {
106
+ const list = [];
107
+ if (typeof args?.id === "string" && args.id.trim() !== "") list.push(args.id.trim());
108
+ if (Array.isArray(args?.ids)) {
109
+ for (const each of args.ids) {
110
+ if (typeof each === "string" && each.trim() !== "") list.push(each.trim());
111
+ }
112
+ }
113
+ return [...new Set(list)];
114
+ }
115
+
116
+ /**
117
+ * 下一步那句话:`continueNote` 给的文本(已经自带句号),这里只取文本。
118
+ *
119
+ * @param plan - 计划。
120
+ * @returns 一句话。
121
+ */
122
+ function nextStep(plan) {
123
+ return continueNote(plan).text;
124
+ }
125
+
126
+ /**
127
+ * 完成之后补的那一句:这一段过程这次能不能收走。
128
+ *
129
+ * 折叠发生在下一步之前(`agent/pre-step`),工具这一刻已经能算出结果,所以把「收不走、
130
+ * 先写需求」直接说在返回里——刚完成一个节点的时候,模型最容易顺手把用户的话落进需求。
131
+ *
132
+ * @param session - 当前会话。
133
+ * @param plan - 计划树。
134
+ * @param cursors - 游标(`foldedAt` 决定起点);可以不给。
135
+ * @returns 一行文本,或空数组。
136
+ */
137
+ function foldNote(session, plan, cursors = null) {
138
+ if (session === undefined || session === null) return [];
139
+ let preview;
140
+ try {
141
+ preview = previewFold(session, plan, cursors);
142
+ } catch {
143
+ return [];
144
+ }
145
+ if (preview.folds !== true || preview.blocked === null) return [];
146
+ return [
147
+ `本次折叠未执行:区间内有一条用户消息(seq ${preview.blocked.seq})尚未写入需求。` +
148
+ "先用 tree_task_requirement 的 update 动作写进需求,下一次调用就会折叠。",
149
+ ];
62
150
  }
63
151
 
64
152
  /**
@@ -70,111 +158,260 @@ function childLabel(level) {
70
158
  export function registerTools(ctx, { store, cfg }) {
71
159
  const sessionOf = (exec) => exec?.agent?.session?.id ?? null;
72
160
 
73
- // ---------------------------------------------------------------- 计划树
161
+ // 游标只在有状态后端时读得到(自检里那套假 store 没有这一层)。读不到就当没有:
162
+ // 折叠退回「上一个边界」,不影响正确性。
163
+ const cursorsOf = (sessionId) => {
164
+ try {
165
+ return typeof store.readSurfaces === "function" ? store.readSurfaces(sessionId) : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ };
170
+
171
+ // ---------------------------------------------------------------- 查询
74
172
 
75
173
  ctx.tools.register({
76
174
  name: "tree_task_status",
77
175
  description:
78
- "查看计划树:目标、任务、子任务各自的状态与已提交的结果,以及现在该推进哪个节点。" +
79
- "任务开头、上下文被压缩之后、或者不确定下一步做什么时,调用它。" +
80
- "默认只展开还没结束的目标——已经做完的目标只留目标那一行,要看它的完整树就把 id 填上。",
176
+ "取回这个会话的需求列表(活跃、已归档、草稿),并展开当前进行中需求的任务与步骤。" +
177
+ "给 ids 时按编号返回指定需求的完整信息。开工前、上下文被压缩之后、或不确定下一步时调用。",
81
178
  parameters: {
82
179
  type: "object",
83
180
  additionalProperties: false,
84
181
  properties: {
85
- id: {
86
- type: "string",
87
- description:
88
- "可选。填目标 id 就返回那个目标的完整树(已完成的目标默认不展开);" +
89
- "填任务或子任务 id,就按它所在的那个目标展开。",
182
+ ids: {
183
+ type: "array",
184
+ items: { type: "string" },
185
+ description: "要查的需求编号,例如 [\"R1\",\"R5\"]。填了就不返回列表,只返回这几条的全貌。",
186
+ },
187
+ match: { type: "string", description: "按关键词过滤需求,匹配标题、简介、详情。" },
188
+ include: {
189
+ type: "array",
190
+ items: { type: "string", enum: ["detail", "versions", "tasks"] },
191
+ description: "给 ids 时生效,默认 detail 和 tasks;versions 是版本历史,tasks 是任务与结果。",
90
192
  },
91
193
  },
92
194
  },
93
195
  output: OUTPUT,
94
196
  async execute(args, exec) {
95
197
  const plan = store.readPlan(sessionOf(exec));
96
- return ok(renderTree(plan, args?.id ?? null));
198
+ const include = Array.isArray(args?.include) ? args.include : ["detail", "tasks"];
199
+ const ids = Array.isArray(args?.ids) ? args.ids.map((id) => String(id)) : [];
200
+
201
+ if (ids.length > 0) {
202
+ const chunks = [];
203
+ for (const id of ids) {
204
+ const requirement = requirementOf(plan, id);
205
+ if (requirement === null) {
206
+ chunks.push(`找不到需求 ${id}。`);
207
+ continue;
208
+ }
209
+ chunks.push(renderDetail(plan, requirement, include));
210
+ }
211
+ return ok(chunks.join("\n\n---\n\n"));
212
+ }
213
+
214
+ if (plan === null) {
215
+ return ok("这个会话还没有需求。先用 tree_task_create 记录需求草稿。");
216
+ }
217
+
218
+ const keyword = String(args?.match ?? "").trim().toLowerCase();
219
+ const keep = (requirement) =>
220
+ keyword === "" ||
221
+ `${requirement.title} ${requirement.intro} ${requirement.detail}`.toLowerCase().includes(keyword);
222
+ // 列表接口给的是摘要行,展开要用原对象。
223
+ const pick = (rows) =>
224
+ rows.map((row) => requirementOf(plan, row.id)).filter((item) => item !== null).filter(keep);
225
+
226
+ const rows = {
227
+ active: pick(listActive(plan)),
228
+ archived: pick(listArchived(plan)),
229
+ drafts: pick(listDrafts(plan)),
230
+ };
231
+ return ok(renderList(plan, rows, activeRequirement(plan)));
97
232
  },
98
233
  });
99
234
 
235
+ // ---------------------------------------------------------------- 需求
236
+
100
237
  ctx.tools.register({
101
238
  name: "tree_task_create",
102
239
  description:
103
- "新建一个目标:这个目标 + 它下面的若干任务。**它是「开工」那一下**——" +
104
- "折叠上下文时,插件就认这次调用作为「这一轮从哪儿开始」。" +
105
- "一个会话可以有多个目标,按调用顺序排在计划里——已经建过的目标和它们的历史都留着," +
106
- "不会被这次调用替换掉。" +
107
- "这里的任务是交付节点——一件能独立验收的交付内容,可以是需要完成或验证的某件事," +
108
- "也可以是一个检查点,不是步骤本身。" +
109
- "建完记得用 tree_task_plan 给这个目标下的第一个任务拆子任务。",
240
+ "记录一条需求草稿。给出已知的标题、简介、详情,不确定的字段留空。" +
241
+ "草稿用 tree_task_requirement 的 update 动作逐次修改正文,再用 confirm 动作确认并生成版本。",
110
242
  parameters: {
111
243
  type: "object",
112
244
  additionalProperties: false,
113
245
  properties: {
114
- title: { type: "string", description: "目标:一句话说清最终要达成什么" },
115
- detail: { type: "string", description: "目标的补充说明" },
246
+ title: { type: "string", description: "标题,不超过 50 字。用户说得很清楚时直接填。" },
247
+ intro: { type: "string", description: "简介,不超过 200 字,说明用户要什么。" },
248
+ detail: {
249
+ type: "string",
250
+ description: "详情,不限长。用户原话、背景、约束都可以放;只有 AI 能写。",
251
+ },
116
252
  tasks: {
117
253
  type: "array",
118
- description: "目标下的任务列表(交付节点),按建议的执行顺序排列",
254
+ description: "用户已说明要做的任务时一并给出;未说明则留空,开工时再拆。",
119
255
  items: {
120
256
  type: "object",
121
257
  additionalProperties: false,
122
258
  properties: {
123
259
  title: { type: "string", description: "任务标题" },
124
- detail: { type: "string", description: "任务的补充说明" },
260
+ detail: { type: "string", description: "补充说明" },
125
261
  },
126
262
  required: ["title"],
127
263
  },
128
264
  },
129
265
  },
130
- required: ["title", "tasks"],
131
266
  },
132
267
  output: OUTPUT,
133
268
  async execute(args, exec) {
134
269
  const sessionId = sessionOf(exec);
135
- const existing = store.readPlan(sessionId);
136
- let plan;
137
- let goal;
138
- try {
139
- if (existing === null) {
140
- plan = createPlan(args);
141
- [goal] = plan.goals;
142
- } else {
143
- plan = existing;
144
- goal = addGoal(plan, args);
145
- }
146
- } catch (error) {
147
- return ok(`建立目标失败:${String(error?.message ?? error)}`);
270
+ let plan = store.readPlan(sessionId);
271
+ if (plan === null) {
272
+ plan = { version: 3, createdAt: Date.now(), updatedAt: Date.now(), goals: [] };
273
+ }
274
+ const requirement = addDraft(plan, args ?? {});
275
+
276
+ // 用户一口气写全的那种(标题、简介都有):仍然要确认一次,但不必重复提问。
277
+ if (requirement.tasks.length > 0) {
278
+ addChildrenTasks(plan, requirement);
148
279
  }
149
280
  store.writePlan(sessionId, plan);
281
+
150
282
  return ok(
151
283
  [
152
- existing === null ? `计划已建立,目标「${goal.title}」。` : `已新增目标「${goal.title}」。`,
284
+ `已记下需求草稿 ${requirement.id}。`,
153
285
  "",
154
- renderGoal(goal, plan, true),
155
- "",
156
- "下一步:给这个目标下的第一个任务拆子任务——用 tree_task_plan," +
157
- "parentId 填那个任务的 id。",
286
+ "确认之前仍是草稿。用 tree_task_requirement 的 update 动作补充正文(标题不超过 50 字,",
287
+ "简介不超过 200 字),再用 confirm 动作确认并生成版本。",
158
288
  ].join("\n"),
159
289
  );
160
290
  },
161
291
  });
162
292
 
293
+ ctx.tools.register({
294
+ name: "tree_task_requirement",
295
+ description:
296
+ "修改需求的状态或正文。每个动作都要给 id;archive / unarchive 可以只给 ids。\n" +
297
+ "- update:修改需求正文,只改给出的字段,其余字段保持不变;更新后需求回到草稿,可多次调用。\n" +
298
+ "- confirm:确认草稿为正式需求并生成版本(第一次为 v1,之后每次确认版本号加一)。\n" +
299
+ "- confirmChange:确认用户提交的暂定版本(版本号加一)。\n" +
300
+ "- question:暂定版本有歧义时,把疑问写进 question。\n" +
301
+ "- archive / unarchive:从活跃列表移出或移回,可批量。\n" +
302
+ "- prerequisites:设置前置需求;ids 是前置需求编号,整体替换,空数组清空。",
303
+ parameters: {
304
+ type: "object",
305
+ additionalProperties: false,
306
+ properties: {
307
+ id: { type: "string", description: "需求编号,例如 R3。archive / unarchive 可只给 ids。" },
308
+ action: {
309
+ type: "string",
310
+ enum: ["confirm", "update", "confirmChange", "question", "archive", "unarchive", "prerequisites"],
311
+ description: "要执行的动作",
312
+ },
313
+ title: { type: "string", description: "标题(不超过 50 字)。confirm 时可沿用草稿标题,update 时用于修改标题。" },
314
+ intro: { type: "string", description: "简介(不超过 200 字)" },
315
+ detail: { type: "string", description: "需求详情,不限长度" },
316
+ change: {
317
+ type: "string",
318
+ description: "本次修改的说明,写入版本历史。confirm 时默认「初始需求」。",
319
+ },
320
+ question: { type: "string", description: "action=question 时要问用户的话(必填)" },
321
+ ids: {
322
+ type: "array",
323
+ items: { type: "string" },
324
+ description: "prerequisites 时是前置需求编号;archive / unarchive 时是批量处理的需求编号。",
325
+ },
326
+ },
327
+ required: ["action"],
328
+ },
329
+ output: OUTPUT,
330
+ async execute(args, exec) {
331
+ const sessionId = sessionOf(exec);
332
+ const plan = store.readPlan(sessionId);
333
+ if (plan === null) return ok("这个会话还没有需求。");
334
+ const id = String(args?.id ?? "");
335
+ const action = String(args?.action ?? "");
336
+ const patch = { title: args?.title, intro: args?.intro, detail: args?.detail, change: args?.change };
337
+
338
+ let said = null;
339
+ if (action === "confirm") {
340
+ const result = confirmRequirement(plan, id, patch);
341
+ if (!result.ok) return ok(`没有确认:${result.reason}`);
342
+ said = `已确认 ${id} 为正式需求(v${result.version.n})。`;
343
+ } else if (action === "update") {
344
+ const result = updateByAi(plan, id, patch);
345
+ if (!result.ok) return ok(`没有修改:${result.reason}`);
346
+ said = result.noop === true
347
+ ? `${id} 的正文没有变化,只登记了这次说明。`
348
+ : `已改动 ${id} 的需求正文(仍是草稿,确认后生成版本)。`;
349
+ } else if (action === "confirmChange") {
350
+ const result = confirmPending(plan, id, args?.change);
351
+ if (!result.ok) return ok(`没有确认:${result.reason}`);
352
+ said = `已确认 ${id} 的改动(v${result.version.n})。`;
353
+ } else if (action === "question") {
354
+ const result = questionPending(plan, id, args?.question);
355
+ if (!result.ok) return ok(`没有退回:${result.reason}`);
356
+ said = `已把疑问记在 ${id} 上,暂定版本留着等用户改。`;
357
+ } else if (action === "archive" || action === "unarchive") {
358
+ const word = action === "archive" ? "归档" : "取消归档";
359
+ const targets = idListOf(args);
360
+ if (targets.length === 0) return ok(`没有${word}:要给 id 或 ids。`);
361
+ const changed = [];
362
+ const same = [];
363
+ const failed = [];
364
+ for (const each of targets) {
365
+ const result = setArchived(plan, each, action === "archive");
366
+ if (!result.ok) {
367
+ failed.push(`${each}(${result.reason})`);
368
+ continue;
369
+ }
370
+ (result.changed ? changed : same).push(each);
371
+ }
372
+ if (targets.length === 1) {
373
+ if (failed.length > 0) return ok(`没有${word}:${failed.join("、")}`);
374
+ said =
375
+ changed.length === 1
376
+ ? `${id} 已${word}。`
377
+ : `${id} 本来${action === "archive" ? "就是归档状态" : "没归档"},没动。`;
378
+ } else {
379
+ const parts = [];
380
+ if (changed.length > 0) parts.push(`已${word} ${changed.length} 条:${changed.join("、")}`);
381
+ if (same.length > 0) parts.push(`${same.length} 条本来就是这个状态:${same.join("、")}`);
382
+ if (failed.length > 0) parts.push(`${failed.length} 条没动:${failed.join("、")}`);
383
+ said = `${parts.join(";")}。`;
384
+ }
385
+ } else if (action === "prerequisites") {
386
+ const result = setPrerequisites(plan, id, args?.ids);
387
+ if (!result.ok) return ok(`没有设置:${result.reason}`);
388
+ said = `${id} 的前置已设为:${(result.requirement.prerequisites ?? []).join("、") || "无"}。`;
389
+ } else {
390
+ return ok(`不认识的动作:${action}`);
391
+ }
392
+
393
+ store.writePlan(sessionId, plan);
394
+ const requirement = requirementOf(plan, id);
395
+ if (requirement === null) return ok(said);
396
+ return ok(`${said}\n\n${renderDetail(plan, requirement, ["detail"])}`);
397
+ },
398
+ });
399
+
400
+ // ---------------------------------------------------------------- 任务
401
+
163
402
  ctx.tools.register({
164
403
  name: "tree_task_plan",
165
404
  description:
166
- "给一个节点追加子节点:给目标追加任务,给任务追加子任务。" +
167
- "给任务追加的是步骤(需要一次性做完的事情)。" +
168
- "子任务是最低一级,不能再往下拆。" +
169
- "**建新目标不归它管,那是 tree_task_create**——它只往已有的节点上接子节点。",
405
+ "向需求追加任务,或向任务追加步骤。任务是可独立验收的交付;步骤是一次可以完成的工作单元,不能再拆。" +
406
+ "调用前先读完所需资料。新建需求用 tree_task_create。",
170
407
  parameters: {
171
408
  type: "object",
172
409
  additionalProperties: false,
173
410
  properties: {
174
- parentId: { type: "string", description: "父节点 id(目标或任务),例如 t-abc123" },
411
+ parentId: { type: "string", description: "父节点 id:需求编号或任务 id。" },
175
412
  children: {
176
413
  type: "array",
177
- description: "要追加的子节点,按执行顺序;父节点是目标时是任务(交付节点),是任务时是子任务(步骤)",
414
+ description: "要追加的子节点,按执行顺序;父节点是需求时是任务,是任务时是步骤。至少给一个。",
178
415
  items: {
179
416
  type: "object",
180
417
  additionalProperties: false,
@@ -190,66 +427,136 @@ export function registerTools(ctx, { store, cfg }) {
190
427
  },
191
428
  output: OUTPUT,
192
429
  async execute(args, exec) {
193
- const plan = store.readPlan(sessionOf(exec));
194
- if (!plan) return ok("还没有计划。先用 tree_task_create 建立目标与任务。");
430
+ const sessionId = sessionOf(exec);
431
+ const plan = store.readPlan(sessionId);
432
+ if (!plan) return ok("这个会话还没有需求。先用 tree_task_create 记录需求草稿。");
195
433
  const result = addChildren(plan, args?.parentId, args?.children);
196
434
  if (!result.ok) return ok(`没有添加:${result.reason}`);
197
- store.writePlan(sessionOf(exec), plan);
435
+ store.writePlan(sessionId, plan);
198
436
  const found = findNode(plan, args?.parentId);
199
437
  return ok(
200
438
  [
201
439
  `已添加 ${result.added.length} 个${childLabel(result.level)}:`,
202
440
  ...result.added.map((node) => `- \`${node.id}\` ${node.title}`),
203
441
  "",
204
- renderGoal(found.goal, plan, true),
442
+ renderDetail(plan, found.goal, ["tasks"]),
205
443
  ].join("\n"),
206
444
  );
207
445
  },
208
446
  });
209
447
 
448
+ ctx.tools.register({
449
+ name: "tree_task_start",
450
+ description:
451
+ "开始一条已确认的需求:返回需求详情(简介、详情、前置、任务与步骤),并记录开工点。" +
452
+ "调用前先读取完成本需求所需的文件与文档。本次调用会折叠上一次折叠成功位置到本次 start 之前的过程;" +
453
+ "这次开工到需求完成之间的过程,在需求完成时整条折叠。草稿不能开始,先用 confirm 确认。",
454
+ parameters: {
455
+ type: "object",
456
+ additionalProperties: false,
457
+ properties: {
458
+ id: { type: "string", description: "要开始的需求编号。" },
459
+ },
460
+ required: ["id"],
461
+ },
462
+ output: OUTPUT,
463
+ async execute(args, exec) {
464
+ const sessionId = sessionOf(exec);
465
+ const plan = store.readPlan(sessionId);
466
+ if (!plan) return ok("这个会话还没有需求。");
467
+
468
+ const requirement = requirementOf(plan, String(args?.id ?? ""));
469
+ if (requirement === null) return ok(`没有开始:找不到需求 ${args?.id}`);
470
+ if (isDraft(requirement)) {
471
+ return ok(
472
+ `没有开始:${requirement.id} 仍是草稿。先用 tree_task_requirement 的 confirm 动作确认,再开始。`,
473
+ );
474
+ }
475
+ if (requirement.status !== REQUIREMENT_STATUS.OPEN) {
476
+ return ok(`没有开始:${requirement.id} 已经是${statusLabel(requirement)},不能再开工。`);
477
+ }
478
+
479
+ const now = Date.now();
480
+ requirement.startedAt = now;
481
+ requirement.updatedAt = now;
482
+ plan.updatedAt = now;
483
+ store.writePlan(sessionId, plan);
484
+
485
+ const lines = [renderDetail(plan, requirement, ["detail", "tasks"])];
486
+ lines.push(...foldNote(exec?.agent?.session, plan, cursorsOf(sessionId)));
487
+ return ok(lines.filter((line) => line !== "").join("\n"));
488
+ },
489
+ });
490
+
210
491
  ctx.tools.register({
211
492
  name: "tree_task_done",
212
493
  description:
213
- "完成一个节点并提交它的结果(目标 / 任务 / 子任务都给它的 id)。**必须写 result**:" +
214
- "插件会把这个节点从开始到现在的执行过程从上下文里折叠掉,换成一条只携带这段结果的汇总消息。" +
215
- "任务的 result 写交付物与验收方式(文件在哪、怎么跑、验出什么、已知限制);" +
216
- "子任务的 result 一句话说清这步做了什么、结果如何就够。" +
217
- "这次调用与它的返回都会留在上下文里,被收起的只是它前面的执行过程。",
494
+ "完成步骤、任务或需求,并记录结果。每完成一个步骤调用一次;多个节点完成时分别调用,不要合并成一次。" +
495
+ "完成后折叠这段执行过程:完成步骤或任务时,折叠上一个 done 或 start 到本次调用之前的过程;" +
496
+ "完成需求时,折叠这条需求从开工到本次调用之前的全部过程。折叠后只保留 result,result 必须能独立看懂:" +
497
+ "步骤写这一步做了什么;任务写交付物、验证方式、已知限制;需求写总结(做了什么、结论、遗留问题)。",
218
498
  parameters: {
219
499
  type: "object",
220
500
  additionalProperties: false,
221
501
  properties: {
222
- id: { type: "string", description: "要完成的节点 id" },
502
+ id: { type: "string", description: "要完成的节点:需求编号、任务 id 或步骤 id。" },
223
503
  result: {
224
504
  type: "string",
225
- description:
226
- "这个节点产出了什么。它整段执行过程会被这条结果顶替,所以要能独立看懂:" +
227
- "任务写交付物与验收方式,子任务写清这步做了什么。",
505
+ description: "这个节点产出了什么。折叠后只保留这段文本,所以必须能独立看懂。",
228
506
  },
229
507
  },
230
508
  required: ["id", "result"],
231
509
  },
232
510
  output: OUTPUT,
233
511
  async execute(args, exec) {
234
- const plan = store.readPlan(sessionOf(exec));
235
- if (!plan) return ok("还没有计划。");
236
- const result = complete(plan, args?.id, args?.result);
237
- if (!result.ok) return ok(`没有完成:${result.reason}`);
238
- store.writePlan(sessionOf(exec), plan);
239
- // 返回的就是"下一步该干什么":这条结果连同这次调用都留在表层上,
240
- // 折叠只收走它前面的执行过程。不重复节点状态,也不回整棵树。
241
- return ok(doneReply(plan));
512
+ const sessionId = sessionOf(exec);
513
+ const plan = store.readPlan(sessionId);
514
+ if (!plan) return ok("这个会话还没有需求。");
515
+
516
+ const found = findNode(plan, args?.id);
517
+ if (found === null) return ok(`没有完成:找不到节点 ${args?.id}`);
518
+
519
+ const lines = [];
520
+ if (isRequirementNode(found)) {
521
+ const result = completeRequirement(plan, args?.id, args?.result);
522
+ if (!result.ok) return ok(`没有完成:${result.reason}`);
523
+ store.writePlan(sessionId, plan);
524
+ // 需求完成时整条折掉,返回里给一眼能扫完的「简」列表:还剩哪几条。
525
+ lines.push(
526
+ renderBriefList(
527
+ {
528
+ active: listActive(plan),
529
+ archived: listArchived(plan),
530
+ drafts: listDrafts(plan),
531
+ },
532
+ activeRequirement(plan),
533
+ ),
534
+ nextStep(plan),
535
+ ...(result.hints ?? []),
536
+ );
537
+ } else {
538
+ const result = complete(plan, args?.id, args?.result);
539
+ if (!result.ok) return ok(`没有完成:${result.reason}`);
540
+ store.writePlan(sessionId, plan);
541
+ // 任务完成时的提示:还剩几个、是不是该写总结了。
542
+ lines.push(nextStep(plan), ...taskCompletionHints(found.goal));
543
+ }
544
+
545
+ lines.push(...foldNote(exec?.agent?.session, plan, cursorsOf(sessionId)));
546
+
547
+ return ok(lines.filter((line) => line !== "").join("\n"));
242
548
  },
243
549
  });
244
550
 
245
551
  ctx.tools.register({
246
552
  name: "tree_task_update",
247
- description: "修改一个节点的标题或说明。",
553
+ description:
554
+ "修改任务或步骤的标题与说明。修改需求正文用 tree_task_requirement 的 update 动作。",
248
555
  parameters: {
249
556
  type: "object",
250
557
  additionalProperties: false,
251
558
  properties: {
252
- id: { type: "string", description: "节点 id" },
559
+ id: { type: "string", description: "任务或步骤的 id" },
253
560
  title: { type: "string", description: "新的标题" },
254
561
  detail: { type: "string", description: "新的说明" },
255
562
  },
@@ -257,45 +564,217 @@ export function registerTools(ctx, { store, cfg }) {
257
564
  },
258
565
  output: OUTPUT,
259
566
  async execute(args, exec) {
260
- const plan = store.readPlan(sessionOf(exec));
261
- if (!plan) return ok("还没有计划。");
567
+ const sessionId = sessionOf(exec);
568
+ const plan = store.readPlan(sessionId);
569
+ if (!plan) return ok("这个会话还没有需求。");
570
+ const found = findNode(plan, args?.id);
571
+ if (isRequirementNode(found)) {
572
+ return ok(
573
+ `没有修改:${args?.id} 是需求,改它请用 tree_task_requirement action=update(要留版本历史)。`,
574
+ );
575
+ }
262
576
  const result = updateNode(plan, args?.id, args ?? {});
263
577
  if (!result.ok) return ok(`没有修改:${result.reason}`);
264
- store.writePlan(sessionOf(exec), plan);
265
- const found = findNode(plan, args?.id);
266
- return ok(`已更新 ${result.changed.join(" / ")}。\n\n${renderGoal(found.goal, plan, true)}`);
578
+ store.writePlan(sessionId, plan);
579
+ const after = findNode(plan, args?.id);
580
+ return ok(`已更新 ${result.changed.join(" / ")}。\n\n${renderDetail(plan, after.goal, ["tasks"])}`);
267
581
  },
268
582
  });
269
583
 
270
584
  ctx.tools.register({
271
585
  name: "tree_task_drop",
272
586
  description:
273
- "丢弃一个节点:目标、任务、子任务都能丢。丢弃目标会连同它下面的任务与子任务一起走," +
274
- "丢弃任务会带上它的子任务。改需求用这个,不要重建整棵树——" +
275
- "已丢弃的节点会留在树里可查。",
587
+ "放弃节点:标记为不再执行,保留在树中可查。放弃需求时,其下未完成的任务与步骤一并放弃,已完成节点不变。" +
588
+ "给 id 或 ids,至少给出一个。",
276
589
  parameters: {
277
590
  type: "object",
278
591
  additionalProperties: false,
279
592
  properties: {
280
- id: { type: "string", description: "要丢弃的节点 id" },
593
+ id: { type: "string", description: "要放弃的节点 id" },
594
+ ids: { type: "array", items: { type: "string" }, description: "要放弃的一批节点 id" },
281
595
  },
282
- required: ["id"],
283
596
  },
284
597
  output: OUTPUT,
285
598
  async execute(args, exec) {
286
- const plan = store.readPlan(sessionOf(exec));
287
- if (!plan) return ok("还没有计划。");
288
- const result = dropNode(plan, args?.id);
289
- if (!result.ok) return ok(`没有丢弃:${result.reason}`);
290
- store.writePlan(sessionOf(exec), plan);
291
- const found = findNode(plan, args?.id);
599
+ const sessionId = sessionOf(exec);
600
+ const plan = store.readPlan(sessionId);
601
+ if (!plan) return ok("这个会话还没有需求。");
602
+
603
+ const targets = idListOf(args);
604
+ if (targets.length === 0) return ok("没有放弃:要给 id 或 ids。");
605
+
606
+ if (targets.length === 1) {
607
+ const id = targets[0];
608
+ const found = findNode(plan, id);
609
+ if (found === null) return ok(`没有放弃:找不到节点 ${id}`);
610
+
611
+ if (isRequirementNode(found)) {
612
+ const result = dropRequirement(plan, id);
613
+ if (!result.ok) return ok(`没有放弃:${result.reason}`);
614
+ store.writePlan(sessionId, plan);
615
+ return ok([`已放弃需求 ${id}:${result.dropped.join("、")}`, nextStep(plan)].join("\n"));
616
+ }
617
+
618
+ const result = dropNode(plan, id);
619
+ if (!result.ok) return ok(`没有放弃:${result.reason}`);
620
+ store.writePlan(sessionId, plan);
621
+ return ok(
622
+ [`已放弃:${result.dropped.join("、")}`, "", renderDetail(plan, found.goal, ["tasks"]), "", nextStep(plan)].join(
623
+ "\n",
624
+ ),
625
+ );
626
+ }
627
+
628
+ // 批量:按给的顺序一个个来,前面被父节点带走的后面就找不到了——记成「已经不在了」。
629
+ const dropped = [];
630
+ const gone = [];
631
+ const failed = [];
632
+ for (const each of targets) {
633
+ const found = findNode(plan, each);
634
+ if (found === null) {
635
+ gone.push(each);
636
+ continue;
637
+ }
638
+ const result = isRequirementNode(found) ? dropRequirement(plan, each) : dropNode(plan, each);
639
+ if (!result.ok) {
640
+ failed.push(`${each}(${result.reason})`);
641
+ continue;
642
+ }
643
+ dropped.push(...result.dropped);
644
+ }
645
+ if (dropped.length === 0) {
646
+ return ok([`没有放弃:${[...gone, ...failed].join("、")}`, nextStep(plan)].join("\n"));
647
+ }
648
+ store.writePlan(sessionId, plan);
649
+ const lines = [`已放弃 ${dropped.length} 个节点:${dropped.join("、")}`];
650
+ if (gone.length > 0) lines.push(`(${gone.length} 个已经不在了:${gone.join("、")})`);
651
+ if (failed.length > 0) lines.push(`(${failed.length} 个没动:${failed.join("、")})`);
652
+ lines.push("", nextStep(plan));
653
+ return ok(lines.join("\n"));
654
+ },
655
+ });
656
+
657
+ ctx.tools.register({
658
+ name: "tree_task_delete",
659
+ description:
660
+ "删除节点:从树中物理移除,不可逆,不保留状态,其下子节点一并移除。" +
661
+ "仅在内容错误、重复创建、结果记错位置时使用;不再执行的节点用 tree_task_drop。" +
662
+ "给 id 或 ids,至少给出一个。",
663
+ parameters: {
664
+ type: "object",
665
+ additionalProperties: false,
666
+ properties: {
667
+ id: { type: "string", description: "要删除的节点 id" },
668
+ ids: { type: "array", items: { type: "string" }, description: "要删除的一批节点 id" },
669
+ },
670
+ },
671
+ output: OUTPUT,
672
+ async execute(args, exec) {
673
+ const sessionId = sessionOf(exec);
674
+ const plan = store.readPlan(sessionId);
675
+ if (!plan) return ok("这个会话还没有需求。");
676
+
677
+ const targets = idListOf(args);
678
+ if (targets.length === 0) return ok("没有删除:要给 id 或 ids。");
679
+
680
+ if (targets.length === 1) {
681
+ const id = targets[0];
682
+ const found = findNode(plan, id);
683
+ const result = isRequirementNode(found) ? deleteRequirement(plan, id) : deleteNode(plan, id);
684
+ if (!result.ok) return ok(`没有删除:${result.reason}`);
685
+ store.writePlan(sessionId, plan);
686
+
687
+ const lines = [`已删除:${result.removed.join("、")}`, ""];
688
+ if (found !== null && !isRequirementNode(found)) {
689
+ // 删的是任务或步骤:它所在的那条需求还在,把这条需求的进度给出来。
690
+ const owner = findNode(plan, found.goal.id);
691
+ lines.push(owner === null ? "" : renderDetail(plan, owner.node, ["tasks"]));
692
+ } else {
693
+ const rest = goalsOf(plan).length;
694
+ lines.push(
695
+ rest === 0
696
+ ? "这个会话现在没有需求了。用 tree_task_create 记录需求草稿。"
697
+ : `这个会话还剩 ${rest} 条需求,要看细节用 tree_task_status。`,
698
+ );
699
+ }
700
+ lines.push("", nextStep(plan));
701
+ return ok(lines.join("\n"));
702
+ }
703
+
704
+ // 批量:按给的顺序一个个来,前面被父节点带走的后面就找不到了——记成「已经不在了」。
705
+ const removed = [];
706
+ const gone = [];
707
+ const failed = [];
708
+ for (const each of targets) {
709
+ const found = findNode(plan, each);
710
+ if (found === null) {
711
+ gone.push(each);
712
+ continue;
713
+ }
714
+ const result = isRequirementNode(found) ? deleteRequirement(plan, each) : deleteNode(plan, each);
715
+ if (!result.ok) {
716
+ failed.push(`${each}(${result.reason})`);
717
+ continue;
718
+ }
719
+ removed.push(...result.removed);
720
+ }
721
+ if (removed.length === 0) {
722
+ return ok([`没有删除:${[...gone, ...failed].join("、")}`, nextStep(plan)].join("\n"));
723
+ }
724
+ store.writePlan(sessionId, plan);
725
+ const rest = goalsOf(plan).length;
726
+ const lines = [
727
+ `已删除 ${removed.length} 个节点:${removed.join("、")}`,
728
+ ...(gone.length > 0 ? [`(${gone.length} 个已经不在了:${gone.join("、")})`] : []),
729
+ ...(failed.length > 0 ? [`(${failed.length} 个没动:${failed.join("、")})`] : []),
730
+ "",
731
+ rest === 0
732
+ ? "这个会话现在没有需求了。用 tree_task_create 记录需求草稿。"
733
+ : `这个会话还剩 ${rest} 条需求,要看细节用 tree_task_status。`,
734
+ "",
735
+ nextStep(plan),
736
+ ];
737
+ return ok(lines.join("\n"));
738
+ },
739
+ });
740
+
741
+ // ---------------------------------------------------------------- 笔记
742
+
743
+ ctx.tools.register({
744
+ name: "tree_task_note",
745
+ description:
746
+ "记录与当前工作有关、但不属于任何单条需求的事实与偏好,例如构建入口、已遇到的问题、对产出的要求。" +
747
+ "同时给出 key 与 text 为写入;同一 key 再次写入会覆盖并递增修订号。" +
748
+ "查询时只给 query;不给任何参数返回全部。上下文中同名笔记以修订号最大的为准。",
749
+ parameters: {
750
+ type: "object",
751
+ additionalProperties: false,
752
+ properties: {
753
+ key: { type: "string", description: "笔记的短名称,例如「构建入口」" },
754
+ text: { type: "string", description: "笔记正文" },
755
+ query: { type: "string", description: "查询关键词" },
756
+ },
757
+ },
758
+ output: OUTPUT,
759
+ async execute(args, exec) {
760
+ const sessionId = sessionOf(exec);
761
+ const key = String(args?.key ?? "").trim();
762
+ const text = String(args?.text ?? "").trim();
763
+
764
+ if (key === "" && text === "") {
765
+ const state = store.readNotes(sessionId);
766
+ return ok(renderNotes(findNotes(state, args?.query), args?.query));
767
+ }
768
+
769
+ const state = store.readNotes(sessionId);
770
+ const note = upsertNote(state, { key, text, seq: tailSeq(exec?.agent?.session) });
771
+ if (!note.ok) return ok(`没有记下:${note.reason}`);
772
+ store.writeNotes(sessionId, state);
292
773
  return ok(
293
774
  [
294
- `已丢弃:${result.dropped.join("、")}`,
295
- "",
296
- renderGoal(found.goal, plan, true),
775
+ `${note.created ? "已记下" : "已更新"}笔记「${note.note.key}」(r${note.note.rev})。`,
297
776
  "",
298
- renderContinue(plan),
777
+ renderNoteLines([note.note]),
299
778
  ].join("\n"),
300
779
  );
301
780
  },
@@ -303,3 +782,15 @@ export function registerTools(ctx, { store, cfg }) {
303
782
 
304
783
  void cfg;
305
784
  }
785
+
786
+ /**
787
+ * 草稿自带任务时,把它们挂到这条需求下面。
788
+ *
789
+ * @param plan - 计划。
790
+ * @param requirement - 刚建的草稿(`tasks` 里是 `{title, detail}`)。
791
+ */
792
+ function addChildrenTasks(plan, requirement) {
793
+ const items = requirement.tasks;
794
+ requirement.tasks = [];
795
+ addChildren(plan, requirement.id, items);
796
+ }