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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-tree-task-flow —— 三级任务树:完成时提交结果,同时把执行过程折叠掉。
2
+ * dsh-tree-task-flow —— 三级任务树:完成[步骤/任务/需求]并折叠过程。
3
3
  *
4
4
  * 与 DSH 内置任务系统的差别在于**上下文的收放方式**:
5
5
  *
@@ -25,6 +25,7 @@ import { registerInterruptRecovery } from "./interrupt.js";
25
25
  import { registerPause } from "./pause.js";
26
26
  import { DEFAULT_ORDER, registerPromptSection } from "./prompt.js";
27
27
  import { registerRegion } from "./region.js";
28
+ import { pickSettings } from "./settings.js";
28
29
  import { createStore, defaultRoot } from "./store.js";
29
30
  import { registerTools } from "./tools.js";
30
31
 
@@ -55,17 +56,16 @@ export const DEFAULT_CONFIG = {
55
56
  /**
56
57
  * 是否允许在模型停下时自动续行。
57
58
  *
58
- * ★ **默认开启**。一条总规矩:**插件还没有配置界面,功能就不能默认关**——
59
- * 默认关等于这个功能不存在,没人会去翻 YAML 把它打开。等哪天真做了配置界面,
60
- * 再交给用户去配。
59
+ * ★ **默认关闭。** 它会趁你没说话的时候**往会话里推一条消息**、把模型再叫起来跑一轮,
60
+ * 是最需要人知情的一项。
61
61
  *
62
- * 曾经担心"开着就停不下来"(用户按下停止、agent 回到 idle,插件立刻又把它拉起来)。
63
- * **那个担心已经有闸门挡住了**:按停止会写 `held/<sessionId>.json` 的
64
- * `stopped: true`(见 `auto-continue.js` 的 `agent/turn-stopping`),
65
- * `agent/status` 一回 idle 就先看这个标记,是就一步都不动;真人再发一条消息才解除。
66
- * 再加上 `maxAutoRounds` 的轮次上限,兜底不会变成脱缰。
62
+ * 这里曾经写死过一条规矩:"插件还没有配置界面,功能就不能默认关"——默认关等于这个
63
+ * 功能不存在,没人会去翻 YAML 把它打开。**那条规矩随着设置界面一起作废**:现在面板上
64
+ * 就能点开,默认值该按"会不会打扰人"来定,而不是按"能不能被发现"。
65
+ *
66
+ * 想打开:面板上的设置里点一下,或在 profile 里写 `autoContinue: true`。
67
67
  */
68
- autoContinue: true,
68
+ autoContinue: false,
69
69
  /** 连续自动续行的轮次上限,达到就停。 */
70
70
  maxAutoRounds: 5,
71
71
  /**
@@ -102,6 +102,13 @@ export function apply(ctx, config) {
102
102
  const root = cfg.rootDir ?? defaultRoot();
103
103
  const store = createStore(root);
104
104
 
105
+ // 面板上改过的开关盖在 profile 的 config 之上:**人在界面上的选择优先**。
106
+ // 不这么做的话,界面改完、profile 一改又被顶回去,人会以为开关坏了。
107
+ //
108
+ // `cfg` 是**活的对象**:面板改开关时就地改它,各模块拿的是同一个引用,
109
+ // 所以立刻生效,不用重启。这也是为什么各模块要按事件现读 `cfg`,不是开机读一次。
110
+ Object.assign(cfg, pickSettings(store.readSettings()));
111
+
105
112
  // 被显式关掉时,一个字节都不碰会话:不注册工具、不加提示词段落、不折叠上下文、
106
113
  // 不续行——只留一条命令,让人能确认"装了但被关掉了"以及怎么打开。
107
114
  if (cfg.enabled !== true) {
@@ -109,7 +116,7 @@ export function apply(ctx, config) {
109
116
  // 条目接口照挂:客户端条目与 enabled 无关,照样会来读。读不到计划它就返回
110
117
  // null,一个像素都不占;而既有的计划文件仍然能看到。
111
118
  // 只读插件自己的状态目录,不碰任何会话。
112
- registerHttp(ctx, { store, autoContinue: null, pause: null });
119
+ registerHttp(ctx, { store, autoContinue: null, pause: null, cfg });
113
120
  ctx.logger?.warn?.(
114
121
  "dsh-tree-task-flow: 已按配置关闭(enabled=false)。要打开请在 profile 的 cordis.patch.yml 里把它设为 true。",
115
122
  );
@@ -142,15 +149,16 @@ export function apply(ctx, config) {
142
149
  // 意外中断的拉起:网络类按 1/5/10/30 分钟各一次,工具类(结果未知的合成错误)
143
150
  // 立刻一次,再失败就收走那一次尝试再来一次。它与 autoContinue 并存:
144
151
  // 那边管"模型正常收尾但计划没做完",这边管"模型没机会说完就断了"。
145
- if (cfg.interruptRecovery === true) {
146
- registerInterruptRecovery(ctx, { store, cfg, pause });
147
- }
152
+ //
153
+ // ★ **无条件注册**:开关(`interruptRecovery`)在面板上随时可改,注册与否取决于
154
+ // 它就没法在运行中打开。真正的闸门放在模块内部——事件来了先问一句 `cfg`。
155
+ registerInterruptRecovery(ctx, { store, cfg, pause });
148
156
 
149
157
  // /task-tree 命令:查看与管理任务树,也是"暂停 / 继续"的开关。
150
158
  registerCommands(ctx, { store, cfg, pause, enabled: true });
151
159
 
152
- // 任务树条目要用的 HTTP 接口。
153
- registerHttp(ctx, { store, autoContinue, pause });
160
+ // 任务树条目要用的 HTTP 接口。`cfg` 传进去是为了让面板能读到并改掉那几个开关。
161
+ registerHttp(ctx, { store, autoContinue, pause, cfg });
154
162
 
155
163
  ctx.logger?.info?.(`dsh-tree-task-flow: 已启用(状态目录 ${root})`);
156
164
  }
package/lib/interrupt.js CHANGED
@@ -198,6 +198,20 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
198
198
  return active !== null && active.step !== null;
199
199
  }
200
200
 
201
+ /**
202
+ * 开关关着时整个模块都不动。
203
+ *
204
+ * 它是**面板上随时可改**的,所以每次事件现读 `cfg`,不能开机读一次就定死;
205
+ * 注册与否也不该取决于它(见 `lib/index.js` 里那句"无条件注册")。
206
+ *
207
+ * 判据写成"**显式 false 才算关**":这一项的默认值是开,缺字段的意思是"没配,
208
+ * 按默认走",不该被当成关掉——直接构造这个模块的调用方(自检、以后别的入口)
209
+ * 往往只给一个 `{}`。
210
+ */
211
+ function off() {
212
+ return cfg?.interruptRecovery === false;
213
+ }
214
+
201
215
  /** 现在能不能拉:暂停 / 停止 / 没计划 / 没加载 / 不空闲,一条不满足就不拉。 */
202
216
  function blockedBy(sessionId) {
203
217
  if (pause?.isPaused?.(sessionId) === true) return "paused";
@@ -220,6 +234,7 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
220
234
  * @returns `{ ok, reason?, text? }`。
221
235
  */
222
236
  function revive(sessionId, kind, code) {
237
+ if (off()) return { ok: false, reason: "disabled" };
223
238
  const blocked = blockedBy(sessionId);
224
239
  if (blocked !== null) return { ok: false, reason: blocked };
225
240
 
@@ -253,6 +268,7 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
253
268
  * 一个不剩就把这条链标成用完。
254
269
  */
255
270
  function scheduleNext(sessionId) {
271
+ if (off()) return;
256
272
  clearTimer(sessionId);
257
273
  const state = store.readRevive(sessionId);
258
274
  if (state === null || state.class !== "network") return;
@@ -447,6 +463,7 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
447
463
  // ---------------------------------------------------------------- 会话事件
448
464
 
449
465
  ctx.on("session/event", (session, event) => {
466
+ if (off()) return;
450
467
  const sessionId = session?.id;
451
468
  if (sessionId === undefined || event === undefined) return;
452
469
 
@@ -495,6 +512,7 @@ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
495
512
  // ---------------------------------------------------------------- agent 状态
496
513
 
497
514
  ctx.on("agent/status", (payload) => {
515
+ if (off()) return;
498
516
  const sessionId = payload?.agent?.session?.id;
499
517
  if (sessionId === undefined || payload?.status !== "idle") return;
500
518
 
package/lib/notes.js ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * 笔记:跟工作有关、但不属于任何一条需求的事实与偏好。
3
+ *
4
+ * **跟着会话走**,不设全局层、不跨会话带——用户开新会话就不管旧会话了,这是设计不是缺陷。
5
+ * 偏好是"对产出的要求",不是"关于用户的知识",所以写进这里就够,不必另立体系。
6
+ *
7
+ * 纯逻辑模块:不碰 DSH、不碰磁盘。
8
+ *
9
+ * ## 为什么笔记要有 `key` 和 `rev`
10
+ *
11
+ * 折叠会把一段过程收成一条通告,通告里要带上"这段里动过的笔记"。于是同一条笔记可能:
12
+ * 先在较早的通告里出现(rev 1),后来又被改过(rev 2),两份通告都还在上下文里。
13
+ * 给每条笔记一个**短名字**和一个**修订号**,再在静态提示词里写死一句"同一名字以修订号大的
14
+ * 为准",模型就不会拿旧值当新值。
15
+ *
16
+ * `seq` 记的是写这条笔记那一刻会话表层的末尾节点,折叠时靠它判断"这条笔记是不是落在
17
+ * 这个区间里"。
18
+ *
19
+ * @module dsh-tree-task-flow/notes
20
+ */
21
+
22
+ /** 一条笔记的形状。 */
23
+ export function makeNote(input, now = Date.now()) {
24
+ return {
25
+ key: String(input?.key ?? "").trim(),
26
+ text: String(input?.text ?? "").trim(),
27
+ rev: 1,
28
+ at: now,
29
+ seq: typeof input?.seq === "number" ? input.seq : null,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * 读出来的笔记表补齐成当前形状。坏文件、缺字段一律当空,不让它拖垮会话。
35
+ *
36
+ * @param state - 磁盘上读到的对象,可能是 null。
37
+ * @returns `{ version, items, updatedAt }`。
38
+ */
39
+ export function normalizeNotes(state) {
40
+ if (state === null || typeof state !== "object") {
41
+ return { version: 1, items: [], updatedAt: null };
42
+ }
43
+ const items = Array.isArray(state.items)
44
+ ? state.items
45
+ .filter((item) => item !== null && typeof item === "object")
46
+ .map((item) => ({
47
+ key: String(item.key ?? "").trim(),
48
+ text: String(item.text ?? ""),
49
+ rev: Number.isFinite(item.rev) ? item.rev : 1,
50
+ at: Number.isFinite(item.at) ? item.at : null,
51
+ seq: Number.isFinite(item.seq) ? item.seq : null,
52
+ }))
53
+ .filter((item) => item.key !== "")
54
+ : [];
55
+ return {
56
+ version: 1,
57
+ items,
58
+ updatedAt: Number.isFinite(state.updatedAt) ? state.updatedAt : null,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * 写一条笔记:**同名就覆盖、修订号 +1**,不同名就新增。
64
+ *
65
+ * 当场返回结果——`tree_task_note` 的返回直接用它,不用等折叠。
66
+ *
67
+ * @param state - 笔记表(会被就地修改)。
68
+ * @param input - `{ key, text, seq? }`。
69
+ * @param now - 时间戳。
70
+ * @returns `{ ok, reason?, note?, created? }`。
71
+ */
72
+ export function upsertNote(state, input, now = Date.now()) {
73
+ const key = String(input?.key ?? "").trim();
74
+ const text = String(input?.text ?? "").trim();
75
+ if (key === "") return { ok: false, reason: "笔记要有一个短名字(key),以后按它找" };
76
+ if (text === "") return { ok: false, reason: "笔记正文不能为空" };
77
+
78
+ const existing = state.items.find((item) => item.key === key);
79
+ if (existing === undefined) {
80
+ const note = makeNote({ key, text, seq: input?.seq }, now);
81
+ state.items.push(note);
82
+ state.updatedAt = now;
83
+ return { ok: true, note, created: true };
84
+ }
85
+
86
+ existing.text = text;
87
+ existing.rev += 1;
88
+ existing.at = now;
89
+ if (typeof input?.seq === "number") existing.seq = input.seq;
90
+ state.updatedAt = now;
91
+ return { ok: true, note: existing, created: false };
92
+ }
93
+
94
+ /**
95
+ * 查笔记:不给条件就是全部,给了就在名字与正文里找。
96
+ *
97
+ * @param state - 笔记表。
98
+ * @param query - 关键词(不分大小写)。
99
+ * @returns 笔记数组,按最后修改时间倒序。
100
+ */
101
+ export function findNotes(state, query) {
102
+ const word = String(query ?? "").trim().toLowerCase();
103
+ const items = word === ""
104
+ ? [...state.items]
105
+ : state.items.filter(
106
+ (item) =>
107
+ item.key.toLowerCase().includes(word) || item.text.toLowerCase().includes(word),
108
+ );
109
+ return items.sort((a, b) => Number(b.at ?? 0) - Number(a.at ?? 0));
110
+ }
111
+
112
+ /**
113
+ * 某个表层区间里的笔记:`seq` 落在 `(from, to]` 里的那几条。
114
+ *
115
+ * 折叠时用它决定"这段通告要带哪些笔记"。没有 `seq` 的老笔记(迁移前写的)一律算进来——
116
+ * 宁可多带一条,也不能让笔记跟着被收走的过程一起消失。
117
+ *
118
+ * @param state - 笔记表。
119
+ * @param from - 区间起点 seq(不含)。
120
+ * @param to - 区间终点 seq(含)。
121
+ * @returns 笔记数组,按写入顺序。
122
+ */
123
+ export function notesInRange(state, from, to) {
124
+ return state.items
125
+ .filter((item) => item.seq === null || (item.seq > from && item.seq <= to))
126
+ .sort((a, b) => Number(a.seq ?? 0) - Number(b.seq ?? 0));
127
+ }
128
+
129
+ /**
130
+ * 把笔记渲染成通告里那一段。
131
+ *
132
+ * 形状是**固定且可再解析的**:一行 `· 名字 (r修订号):正文`。二次折叠时靠它把上一层通告里的
133
+ * 笔记捡回来,所以别随手改格式(`parseNoteLines` 认的就是它)。
134
+ *
135
+ * @param items - 笔记数组。
136
+ * @returns 多行文本;没有笔记时是空串。
137
+ */
138
+ export function renderNoteLines(items) {
139
+ if (!Array.isArray(items) || items.length === 0) return "";
140
+ return items.map((item) => `· ${item.key} (r${item.rev}):${oneLine(item.text)}`).join("\n");
141
+ }
142
+
143
+ /**
144
+ * 从一段通告正文里把笔记捡回来。
145
+ *
146
+ * @param text - 通告正文。
147
+ * @returns 笔记数组 `{ key, rev, text }`。
148
+ */
149
+ export function parseNoteLines(text) {
150
+ const rows = [];
151
+ const pattern = /^· (.+?) \(r(\d+)\):(.*)$/u;
152
+ for (const line of String(text ?? "").split("\n")) {
153
+ const match = pattern.exec(line.trim());
154
+ if (match === null) continue;
155
+ rows.push({ key: match[1], rev: Number(match[2]), text: match[3] });
156
+ }
157
+ return rows;
158
+ }
159
+
160
+ /**
161
+ * 正文压成一行:通告里每条笔记只占一行,换行会把它拆成两条,再解析就错位了。
162
+ *
163
+ * @param text - 原文。
164
+ * @returns 单行文本。
165
+ */
166
+ function oneLine(text) {
167
+ return String(text ?? "")
168
+ .replace(/\r\n?/gu, "\n")
169
+ .split("\n")
170
+ .map((line) => line.trim())
171
+ .filter((line) => line !== "")
172
+ .join(" / ");
173
+ }
package/lib/pause.js CHANGED
@@ -35,6 +35,20 @@
35
35
  * 唤醒要求该会话已被 DSH 加载成实时 agent(Web 界面打开这个会话就会触发按需
36
36
  * resume)。拿不到 agent 时不做任何事,只回一句"先打开它,或在其中发一条消息"。
37
37
  *
38
+ * ★ **点「继续」是无条件的。** "接着跑"是人的意思表示,不是"此刻有没有闸门挂着"
39
+ * 的一次查询。所以 `resume()` 的第一件事永远是**解除「被按住」**,然后才谈要不要
40
+ * 主动唤醒。
41
+ *
42
+ * 旧实现把这层意思押在了"此刻 `waiters` 里正好挂着一个"上:没有 waiter 就退到
43
+ * `wakeIdle()`,而它要求 agent 恰好 `idle`——会话正忙(工具还在跑、这一轮还没走到
44
+ * 岔口)时直接拒绝,并且**一个标记都不清**。于是那一次点击被静默吞掉:闸门在下一个
45
+ * 岔口照旧挂上,会话停住,人却以为自己已经点过了。实机就卡在这儿:
46
+ * session-c516e2cc 23:05:59 点了暂停,那个工具 23:07:03 才跑完,中间点的那次
47
+ * 「继续」白点了——等到闸门真挂上时,已经没人在等那一下点击。
48
+ *
49
+ * 唤醒失败也不再反过来把状态留下:留着只会让"我点过了它却还按着我"再来一次。
50
+ * 推不起来的原因照样如实回报(没加载 / 唤醒抛错 / 计划已收尾),人能据它决定下一步。
51
+ *
38
52
  * @module dsh-tree-task-flow/pause
39
53
  */
40
54
 
@@ -71,6 +85,18 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
71
85
  store?.patchHeld?.(sessionId, { paused: false });
72
86
  }
73
87
 
88
+ /**
89
+ * 真解除"被按住":暂停与停止两半一起清,内存、磁盘、自动续行压住的那一份都放开。
90
+ * 「继续」走的就是这一条,而且**无条件**(见模块头的 ★)。
91
+ *
92
+ * @param sessionId - 要放开的会话。
93
+ */
94
+ function clearHeld(sessionId) {
95
+ paused.delete(sessionId);
96
+ store?.patchHeld?.(sessionId, { paused: false, stopped: false });
97
+ autoContinue?.resume?.(sessionId);
98
+ }
99
+
74
100
  /**
75
101
  * 取这个会话的实时 agent。
76
102
  *
@@ -138,7 +164,13 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
138
164
  };
139
165
  }
140
166
  if (agent.status !== "idle") {
141
- return { woke: false, reason: "busy", text: "这个会话正在跑,不需要唤醒。" };
167
+ // 正忙不是失败:它自己会在下一个岔口接着跑,本来就不需要谁推它一把。
168
+ // 「被按住」已经由 `resume()` 无条件解除,所以这里什么都不用做。
169
+ return {
170
+ woke: false,
171
+ reason: "busy",
172
+ text: "这个会话正在跑,不用唤醒:被按住的状态已经解除,它会在下一个岔口接着往下跑。",
173
+ };
142
174
  }
143
175
  const plan = store?.readPlan?.(sessionId) ?? null;
144
176
  const active = activePath(plan);
@@ -215,28 +247,21 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
215
247
  return { released: false, woke: false, reason: "no-session", text: "拿不到当前会话。" };
216
248
  }
217
249
 
218
- /** 真解除"被按住":暂停与停止两半一起清,连自动续行的压住也放开。 */
219
- const clearHeld = () => {
220
- paused.delete(sessionId);
221
- store?.patchHeld?.(sessionId, { paused: false, stopped: false });
222
- autoContinue?.resume?.(sessionId);
223
- };
250
+ // ★ 先无条件解除"被按住",再谈唤醒。理由见模块头那段 ★:点「继续」是一个
251
+ // 意思表示,不该有条件;有条件就会制造"点过了却还按着"的静默卡死。
252
+ clearHeld(sessionId);
224
253
 
225
- // 本进程里真挂着:放行那一步——这是常规路径,**零新增消息**。
254
+ // 本进程里真挂着闸门:放行那一步——常规路径,**零新增消息**。
226
255
  const pending = waiters.get(sessionId);
227
256
  if (pending !== undefined && pending.size > 0) {
228
- clearHeld();
229
257
  for (const release of [...pending]) release();
230
258
  return { released: true, woke: false, reason: "released", text: "停在岔口上的那一步已经放行。" };
231
259
  }
232
260
 
233
- // 没有挂着的 waiter(进程重启过):退一步唤醒 idle 的会话。
234
- //
235
- // **只有真把它推起来、或计划确实已经收尾,才解除"被按住"。** 没成功时
236
- // (会话没加载 / 正在跑 / 唤醒抛错)必须把状态留着:否则用户既没有「继续」
237
- // 可再点一次,也看不到为什么没动,只剩一个停在原地的会话。
261
+ // 没有挂着的 waiter:本进程里没有东西可放行(进程重启过,或者这次暂停赶在
262
+ // 闸门挂上之前——那时候点的那一下,旧实现会被吞掉)。退一步,尽最大努力把
263
+ // idle 的会话推起来;推不起来也不改变"已经解除被按住"这个事实。
238
264
  const outcome = wakeIdle(sessionId);
239
- if (outcome.woke === true || outcome.reason === "finished") clearHeld();
240
265
  return { released: false, ...outcome };
241
266
  },
242
267
  /**
@@ -251,5 +276,49 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
251
276
  isPaused(sessionId) {
252
277
  return heldNow(sessionId);
253
278
  },
279
+
280
+ /**
281
+ * 往会话里排一句话,把它推起来。**面板上的「开始」按钮走这里。**
282
+ *
283
+ * 与 `resume()` 的区别:`resume` 是"把按住的东西放开",这里的语义是"从这条需求
284
+ * 开工"。会话正忙时不排——它自己会在下一个岔口继续;没被加载时也不排,回一句
285
+ * "先在界面上打开它"。
286
+ *
287
+ * @param sessionId - 目标会话。
288
+ * @param text - 要排进去的那句话。
289
+ * @param label - 界面上那行摘要的裸标签。
290
+ * @returns `{ woke, reason, text }`。
291
+ */
292
+ nudge(sessionId, text, label = "开始需求") {
293
+ if (sessionId === undefined) {
294
+ return { woke: false, reason: "no-session", text: "拿不到当前会话。" };
295
+ }
296
+ const agent = liveAgent(sessionId);
297
+ if (agent === null) {
298
+ return {
299
+ woke: false,
300
+ reason: "unloaded",
301
+ text: "这个会话还没被加载:先在界面上打开它,或在其中发一条消息。",
302
+ };
303
+ }
304
+ if (agent.status !== "idle") {
305
+ return {
306
+ woke: false,
307
+ reason: "busy",
308
+ text: "这个会话正在跑,不用再推它;它做完手上这一步就会接着往下走。",
309
+ };
310
+ }
311
+ try {
312
+ agent.followup({
313
+ id: `tt-nudge-${sessionId}-${Date.now().toString(36)}`,
314
+ role: "user",
315
+ content: [{ type: "text", text: String(text ?? "") }],
316
+ source: pluginSource(label),
317
+ });
318
+ } catch (error) {
319
+ return { woke: false, reason: "error", text: `唤醒失败:${String(error?.message ?? error)}` };
320
+ }
321
+ return { woke: true, reason: "woke", text: "已让它开始。" };
322
+ },
254
323
  };
255
324
  }
package/lib/plan.js CHANGED
@@ -355,6 +355,54 @@ export function dropNode(plan, id, now = Date.now()) {
355
355
  };
356
356
  }
357
357
 
358
+ /**
359
+ * 删掉一个节点。**删除和丢弃是两件事,别混着用:**
360
+ *
361
+ * - **丢弃**回答"这件事还要不要做":节点留在树里划掉,将来还查得到"曾经有这么一件事,
362
+ * 后来决定不做了"。它不改写历史。
363
+ * - **删除**是**清理**:内容本身就是错的(建重了、写歪了、结果记在了错的地方),
364
+ * 留着只会误导后面的人。所以它把这个节点连同它的全部子孙从树里凭空摘掉,
365
+ * **不看状态**——已完成的照样删得掉,那正是"这条记录不该留在这儿"的情形。
366
+ *
367
+ * 摘完树可能是空的(最后一个目标被删掉):那时 `plan.goals` 是**空数组**,而不是
368
+ * 没有计划——计划文件照旧在盘上,渲染会给出"还没有计划"的样子。
369
+ *
370
+ * 活动路径与检查点在删完**重新算**,调用方不要拿删除前的那一份去用。
371
+ *
372
+ * @param plan - 计划树。
373
+ * @param id - 节点 id。
374
+ * @param now - 时间戳。
375
+ * @returns `{ ok, reason?, removed?, active?, checkpoint? }`。
376
+ */
377
+ export function deleteNode(plan, id, now = Date.now()) {
378
+ const found = findNode(plan, id);
379
+ if (!found) return { ok: false, reason: `找不到节点 ${id}` };
380
+
381
+ // 先把要摘掉的那几个记下来——摘掉之后就找不着了。
382
+ const removed = [`${levelLabel(found.level)}「${found.node.title}」`];
383
+ if (found.level === "goal") {
384
+ for (const task of found.node.tasks ?? []) {
385
+ removed.push(`任务「${task.title}」`);
386
+ for (const step of task.steps ?? []) removed.push(`子任务「${step.title}」`);
387
+ }
388
+ plan.goals = goalsOf(plan).filter((goal) => goal.id !== id);
389
+ } else if (found.level === "task") {
390
+ for (const step of found.node.steps ?? []) removed.push(`子任务「${step.title}」`);
391
+ found.goal.tasks = (found.goal.tasks ?? []).filter((task) => task.id !== id);
392
+ } else {
393
+ found.task.steps = (found.task.steps ?? []).filter((step) => step.id !== id);
394
+ }
395
+ plan.updatedAt = now;
396
+
397
+ const checkpoint = checkpointOf(plan);
398
+ return {
399
+ ok: true,
400
+ removed,
401
+ active: activePath(plan),
402
+ checkpoint: checkpoint === null ? null : { id: checkpoint.node.id, level: checkpoint.level },
403
+ };
404
+ }
405
+
358
406
  /**
359
407
  * 改一个节点的标题或说明。
360
408
  *
package/lib/prompt.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 系统提示词段落:**只有一段**,讲的是"什么时候用这组工具、完成时守什么规矩"。
2
+ * 系统提示词段落:**只有一段**,讲的是"这组工具是什么、一次工作怎么开头、完成时守什么时机"。
3
3
  *
4
- * 工具清单**不在这里**——每个工具的用法由它自己的 `description` 承担。同一句话
4
+ * 工具清单与填法**不在这里**——每个工具怎么用由它自己的 `description` 承担。同一句话
5
5
  * 写在两处,只会让它在每一次请求里占两份位置,而模型真要调用时读的始终是
6
6
  * `description`。计划当前的状态同样不在这里,由 `tree_task_status` 现取。
7
7
  *
@@ -22,8 +22,24 @@ export const SECTION_NAME = "dsh-tree-task-flow";
22
22
  */
23
23
  export const DEFAULT_ORDER = 1800;
24
24
 
25
- /** 段落正文。 */
26
- export const SECTION_TEXT = "`tree_task_*` 工具适用长线任务,当目标和资料分析完成后,如果用户要求或判断任务较为复杂,可以将任务执行计划分为目标、任务、任务执行步骤三级写入任务树,借助工具来推进项目;将目标按照可独立交付的节点分为多个任务,任务节点需要准确表述清楚需求;将任务按照“需一次性处理并交付产物”分为多个步骤;完成步骤或任务时提交后续步骤需要的 result,提交内容可以是:产物/结论/问题/有哪些需要注意的坑;执行过程上下文将会在完成时被隐藏。";
25
+ /**
26
+ * 段落正文。
27
+ *
28
+ * 只讲这次工作按什么顺序走、什么时候该动哪个工具,不讲工具怎么填——填法归各自的
29
+ * `description`。三个入口工具(状态、建草稿、改需求)在这里点名,其余仍交给描述:
30
+ * 光说"用工具"换不到指代明确,十来个字不值得省。
31
+ *
32
+ * 流水线:需求草稿 → update 多次修改(仍是草稿)→ confirm 生成版本 → 读资料 → start。
33
+ * 任务与步骤的拆分口径归 `plan`,result 写法归 `done`,笔记覆盖归 `note`。
34
+ */
35
+ export const SECTION_TEXT =
36
+ "`tree_task_*` 工具记录这个会话的工作结构。需求记录用户提出的要求;任务与步骤记录实现路径;" +
37
+ "笔记记录不属于任何单条需求的工作事实与偏好。\n\n" +
38
+ "开工前先调用 tree_task_status 取回需求列表。用户提出要求后,先用 tree_task_create 记录需求草稿;" +
39
+ "草稿阶段用 tree_task_requirement 的 update 动作逐次修改正文,每次只给出要改的字段,修改后仍是草稿;" +
40
+ "标题与简介确定后,用同一工具的 confirm 动作确认并生成版本。确认之后,调用 tree_task_start 开始工作," +
41
+ "调用前先读取所需的文件与文档。\n\n" +
42
+ "每完成一个步骤、任务或需求,调用一次 tree_task_done。";
27
43
 
28
44
  /**
29
45
  * 注册段落。调用方必须包在 `ctx.effect()` 里,否则热更新后会重复注册而抛错。