dsh-tree-task-flow 0.3.14 → 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/README.md +119 -98
- package/client.js +590 -675
- package/config.example.yml +8 -7
- package/cordis.patch.yml +4 -4
- package/lib/commands.js +57 -25
- package/lib/http.js +181 -46
- package/lib/index.js +1 -1
- package/lib/notes.js +173 -0
- package/lib/pause.js +44 -0
- package/lib/prompt.js +20 -4
- package/lib/region.js +375 -58
- package/lib/render.js +311 -0
- package/lib/requirement.js +914 -0
- package/lib/store.js +50 -7
- package/lib/tools.js +588 -144
- package/package.json +2 -2
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 需求:用户在一个会话里对 AI 提出的要求。**不是一个项目管理对象**——
|
|
3
|
+
* 它记录的是"用户要什么",不记录"项目长什么样"(见 `docs/09-需求与任务模型.md`)。
|
|
4
|
+
*
|
|
5
|
+
* 纯逻辑模块:不碰 DSH、不碰磁盘。状态由调用方从 store 读出、改完写回。
|
|
6
|
+
*
|
|
7
|
+
* ## 为什么需求直接住在 `plan.goals` 里
|
|
8
|
+
*
|
|
9
|
+
* 需求与原来的「目标」是同一个位置上的东西:都是"一组任务的父节点"。所以需求**就是**
|
|
10
|
+
* 一个加了字段的目标节点,存在 `plan.goals` 里,字段名沿用旧键。这样任务 / 步骤那一层
|
|
11
|
+
* (`findNode`、`addChildren`、`complete`、`dropNode`、`deleteNode`、`activePath`)一个字都不用改,
|
|
12
|
+
* 新旧代码读同一个文件也互不打架。**`goals` 这个键名是历史遗留,指的就是需求。**
|
|
13
|
+
*
|
|
14
|
+
* ## 状态
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* draft ──确认──▶ pending ──完成──▶ done
|
|
18
|
+
* │ │
|
|
19
|
+
* └────放弃───────▶ dropped(划掉,留着可查)
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `pending` 是沿用下来的字面值(旧代码的 `isOpen` 认它),在文档与界面上叫「未完成」。
|
|
23
|
+
*
|
|
24
|
+
* 归档与删除跟上面这条状态线**正交**:归档只是"还占不占活跃列表",删除是物理摘除。
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-tree-task-flow/requirement
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { STATUS as NODE_STATUS, complete, deleteNode, dropNode, findNode } from "./plan.js";
|
|
30
|
+
|
|
31
|
+
/** 需求自己的状态取值。前三个与任务节点同名,`draft` 是需求独有的。 */
|
|
32
|
+
export const REQUIREMENT_STATUS = {
|
|
33
|
+
/** 草稿:用户建出来 / 用户说了一句,还没被 AI 沟通确认。不进活跃列表。 */
|
|
34
|
+
DRAFT: "draft",
|
|
35
|
+
/** 未完成(沿用旧的 `pending` 字面值)。 */
|
|
36
|
+
OPEN: "pending",
|
|
37
|
+
DONE: "done",
|
|
38
|
+
DROPPED: "dropped",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 标题与简介的长度上限,单位是**字符**(按码点算,中文一字一个)。超了直接拒绝,不截断。 */
|
|
42
|
+
export const LIMITS = { title: 50, intro: 200 };
|
|
43
|
+
|
|
44
|
+
/** 按码点数字符数。`String#length` 数的是 UTF-16 单元,emoji 会算成两个。 */
|
|
45
|
+
export function charCount(text) {
|
|
46
|
+
return Array.from(String(text ?? "")).length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 一个需求是不是还开着(未完成、未放弃、已确认)。 */
|
|
50
|
+
export function isOpenRequirement(requirement) {
|
|
51
|
+
return requirement?.status === REQUIREMENT_STATUS.OPEN;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 是不是草稿。 */
|
|
55
|
+
export function isDraft(requirement) {
|
|
56
|
+
return requirement?.status === REQUIREMENT_STATUS.DRAFT;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 已结束(完成或放弃)。 */
|
|
60
|
+
export function isClosedRequirement(requirement) {
|
|
61
|
+
return (
|
|
62
|
+
requirement?.status === REQUIREMENT_STATUS.DONE ||
|
|
63
|
+
requirement?.status === REQUIREMENT_STATUS.DROPPED
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把一条(可能是旧版、或字段不全的)需求补齐成当前形状。
|
|
69
|
+
*
|
|
70
|
+
* **读的时候补,不迁移磁盘上的老文件**——老会话的计划文件保持原样,字段缺什么就在内存里
|
|
71
|
+
* 补什么,写回时自然带上。旧版的目标没有版本历史,这里给它补一条「初始需求」,
|
|
72
|
+
* 版本号 1,`by` 记 `migrated`(界面上区分"这是从老结构来的")。
|
|
73
|
+
*
|
|
74
|
+
* @param requirement - 需求节点(可以是旧的 goal)。
|
|
75
|
+
* @returns 补齐后的同一个对象(就地修改)。
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeRequirement(requirement) {
|
|
78
|
+
if (requirement === null || typeof requirement !== "object") return requirement;
|
|
79
|
+
|
|
80
|
+
if (typeof requirement.intro !== "string") requirement.intro = "";
|
|
81
|
+
if (requirement.archived !== true) requirement.archived = false;
|
|
82
|
+
if (!Array.isArray(requirement.prerequisites)) requirement.prerequisites = [];
|
|
83
|
+
if (requirement.pending === undefined) requirement.pending = null;
|
|
84
|
+
|
|
85
|
+
// 只有**没有** `versions` 字段的记录才是旧版目标。新草稿的 `versions: []` 是有意义的
|
|
86
|
+
// ——它还没被确认过,本来就不该有版本历史。判空数组会凭空给草稿造出一条 v1,
|
|
87
|
+
// 于是第一次确认就变成"v2"。
|
|
88
|
+
if (!Array.isArray(requirement.versions)) {
|
|
89
|
+
requirement.versions = [
|
|
90
|
+
{
|
|
91
|
+
n: 1,
|
|
92
|
+
at: requirement.createdAt ?? null,
|
|
93
|
+
title: requirement.title ?? "",
|
|
94
|
+
intro: requirement.intro,
|
|
95
|
+
detail: requirement.detail ?? "",
|
|
96
|
+
change: "(从旧版目标迁移)",
|
|
97
|
+
by: "migrated",
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return requirement;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 校验用户 / AI 交来的需求字段。
|
|
107
|
+
*
|
|
108
|
+
* 超长**不截断**:截断等于悄悄改了用户的需求,宁可让调用方重写一次。返回的是问题清单,
|
|
109
|
+
* 由调用方拼成一句拒绝理由。
|
|
110
|
+
*
|
|
111
|
+
* @param patch - `{ title?, intro?, detail? }` 的任意子集。
|
|
112
|
+
* @returns 问题描述数组;空数组表示没问题。
|
|
113
|
+
*/
|
|
114
|
+
export function validateFields(patch) {
|
|
115
|
+
const problems = [];
|
|
116
|
+
|
|
117
|
+
if (patch?.title !== undefined) {
|
|
118
|
+
if (typeof patch.title !== "string") problems.push("标题必须是字符串");
|
|
119
|
+
else {
|
|
120
|
+
const n = charCount(patch.title.trim());
|
|
121
|
+
if (n === 0) problems.push("标题不能为空");
|
|
122
|
+
else if (n > LIMITS.title) {
|
|
123
|
+
problems.push(`标题 ${n} 字,超过 ${LIMITS.title} 字上限,请精简`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (patch?.intro !== undefined) {
|
|
129
|
+
if (typeof patch.intro !== "string") problems.push("简介必须是字符串");
|
|
130
|
+
else {
|
|
131
|
+
const n = charCount(patch.intro.trim());
|
|
132
|
+
if (n > LIMITS.intro) {
|
|
133
|
+
problems.push(`简介 ${n} 字,超过 ${LIMITS.intro} 字上限,请精简`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (patch?.detail !== undefined && typeof patch.detail !== "string") {
|
|
139
|
+
problems.push("详情必须是字符串");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return problems;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 这一版拿去存档的内容快照。 */
|
|
146
|
+
function snapshot(requirement) {
|
|
147
|
+
return {
|
|
148
|
+
title: requirement.title,
|
|
149
|
+
intro: requirement.intro,
|
|
150
|
+
detail: requirement.detail,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 追加一条版本历史,版本号 +1。
|
|
156
|
+
*
|
|
157
|
+
* @param requirement - 需求。
|
|
158
|
+
* @param change - 这次改了什么。
|
|
159
|
+
* @param by - `user` / `ai` / `migrated`。
|
|
160
|
+
* @param now - 时间戳。
|
|
161
|
+
* @returns 新版本对象。
|
|
162
|
+
*/
|
|
163
|
+
function pushVersion(requirement, change, by, now) {
|
|
164
|
+
const previous = requirement.versions ?? [];
|
|
165
|
+
const n = previous.length === 0 ? 1 : previous[previous.length - 1].n + 1;
|
|
166
|
+
const entry = {
|
|
167
|
+
n,
|
|
168
|
+
at: now,
|
|
169
|
+
...snapshot(requirement),
|
|
170
|
+
change: String(change ?? "").trim() || "(未说明改了什么)",
|
|
171
|
+
by,
|
|
172
|
+
};
|
|
173
|
+
requirement.versions = [...previous, entry];
|
|
174
|
+
return entry;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 当前版本号。 */
|
|
178
|
+
export function currentVersion(requirement) {
|
|
179
|
+
const versions = requirement?.versions ?? [];
|
|
180
|
+
return versions.length === 0 ? 0 : versions[versions.length - 1].n;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 分配下一个需求编号。
|
|
185
|
+
*
|
|
186
|
+
* 编号形如 `R1`、`R2`。**删掉的需求不回收它的号**:历史里可能还有别的地方写着 `R3`,
|
|
187
|
+
* 复用一个号会让旧记录指错。所以计数器只增不减,存在 `plan.nextNumber` 上。
|
|
188
|
+
*
|
|
189
|
+
* @param plan - 计划(需求都在 `plan.goals` 里)。
|
|
190
|
+
* @returns 下一个编号数字。
|
|
191
|
+
*/
|
|
192
|
+
export function nextRequirementNumber(plan) {
|
|
193
|
+
let max = typeof plan?.nextNumber === "number" ? plan.nextNumber - 1 : 0;
|
|
194
|
+
for (const requirement of plan?.goals ?? []) {
|
|
195
|
+
const match = /^R(\d+)$/u.exec(String(requirement?.id ?? ""));
|
|
196
|
+
if (match !== null) max = Math.max(max, Number(match[1]));
|
|
197
|
+
}
|
|
198
|
+
return max + 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 建一条**草稿**需求。
|
|
203
|
+
*
|
|
204
|
+
* 草稿不进活跃列表、不占上下文,只在面板上挂一个「待确认」小格子——等 AI 沟通确认之后
|
|
205
|
+
* 才成为真需求(见 `confirmRequirement`)。三种来源(对话里说、面板上建、用户一口气写全)
|
|
206
|
+
* 走的都是这条路。
|
|
207
|
+
*
|
|
208
|
+
* @param input - `{ title?, intro?, detail?, tasks? }`。只有 `detail` 有可能来自用户原话,
|
|
209
|
+
* 标题与简介通常还是空的,等 AI 沟通后填。
|
|
210
|
+
* @param number - 需求编号数字。
|
|
211
|
+
* @param now - 时间戳。
|
|
212
|
+
* @returns 草稿需求节点。
|
|
213
|
+
*/
|
|
214
|
+
export function makeDraft(input, number, now = Date.now()) {
|
|
215
|
+
return {
|
|
216
|
+
id: `R${number}`,
|
|
217
|
+
title: String(input?.title ?? "").trim(),
|
|
218
|
+
intro: String(input?.intro ?? "").trim(),
|
|
219
|
+
detail: String(input?.detail ?? ""),
|
|
220
|
+
status: REQUIREMENT_STATUS.DRAFT,
|
|
221
|
+
archived: false,
|
|
222
|
+
createdAt: now,
|
|
223
|
+
updatedAt: now,
|
|
224
|
+
completedAt: null,
|
|
225
|
+
droppedAt: null,
|
|
226
|
+
result: null,
|
|
227
|
+
versions: [],
|
|
228
|
+
pending: null,
|
|
229
|
+
prerequisites: [],
|
|
230
|
+
tasks: Array.isArray(input?.tasks) ? input.tasks : [],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 往计划里加一条草稿。
|
|
236
|
+
*
|
|
237
|
+
* @param plan - 计划。
|
|
238
|
+
* @param input - 同 `makeDraft`。
|
|
239
|
+
* @param now - 时间戳。
|
|
240
|
+
* @returns 新加的草稿需求。
|
|
241
|
+
*/
|
|
242
|
+
export function addDraft(plan, input, now = Date.now()) {
|
|
243
|
+
const number = nextRequirementNumber(plan);
|
|
244
|
+
const requirement = makeDraft(input, number, now);
|
|
245
|
+
if (!Array.isArray(plan.goals)) plan.goals = [];
|
|
246
|
+
plan.goals.push(requirement);
|
|
247
|
+
plan.nextNumber = number + 1;
|
|
248
|
+
plan.version = 3;
|
|
249
|
+
plan.updatedAt = now;
|
|
250
|
+
return requirement;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 按 id 找需求。找不到返回 null。
|
|
255
|
+
*
|
|
256
|
+
* @param plan - 计划。
|
|
257
|
+
* @param id - 需求编号,例如 `R3`。
|
|
258
|
+
* @returns 需求节点或 null。
|
|
259
|
+
*/
|
|
260
|
+
export function requirementOf(plan, id) {
|
|
261
|
+
const wanted = String(id ?? "").trim();
|
|
262
|
+
if (wanted === "") return null;
|
|
263
|
+
return (plan?.goals ?? []).find((item) => item.id === wanted) ?? null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 把草稿确认成正式需求(AI 沟通完之后的那一步)。
|
|
268
|
+
*
|
|
269
|
+
* 确认时把标题 / 简介 / 详情一次写清,并落下 v1 版本历史。用户一口气写全的那种,
|
|
270
|
+
* AI 确认一遍即可转正,不必重复提问。
|
|
271
|
+
*
|
|
272
|
+
* @param plan - 计划。
|
|
273
|
+
* @param id - 需求编号。
|
|
274
|
+
* @param patch - `{ title, intro?, detail?, change? }`;`title` 必填。
|
|
275
|
+
* @param now - 时间戳。
|
|
276
|
+
* @returns `{ ok, reason?, requirement?, version? }`。
|
|
277
|
+
*/
|
|
278
|
+
export function confirmRequirement(plan, id, patch, now = Date.now()) {
|
|
279
|
+
const requirement = requirementOf(plan, id);
|
|
280
|
+
if (requirement === null) return { ok: false, reason: `找不到需求 ${id}` };
|
|
281
|
+
if (!isDraft(requirement)) {
|
|
282
|
+
return { ok: false, reason: `${id} 已经是正式需求了,改它请走「变更」` };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const problems = validateFields(patch);
|
|
286
|
+
if (problems.length > 0) return { ok: false, reason: problems.join(";") };
|
|
287
|
+
// 草稿里已经有标题就沿用——用户在对话里说清、AI 建草稿时已经填过的那种,
|
|
288
|
+
// 确认时再逼着抄一遍标题只是白费一次工具调用(实测被自己绊到过)。
|
|
289
|
+
const title = String(patch?.title ?? "").trim() || String(requirement.title ?? "").trim();
|
|
290
|
+
if (title === "") return { ok: false, reason: "确认成正式需求要给它一个标题" };
|
|
291
|
+
|
|
292
|
+
requirement.title = title;
|
|
293
|
+
if (typeof patch?.intro === "string") requirement.intro = patch.intro.trim();
|
|
294
|
+
if (typeof patch?.detail === "string") requirement.detail = patch.detail;
|
|
295
|
+
requirement.status = REQUIREMENT_STATUS.OPEN;
|
|
296
|
+
requirement.updatedAt = now;
|
|
297
|
+
const version = pushVersion(requirement, patch?.change ?? "初始需求", "ai", now);
|
|
298
|
+
plan.updatedAt = now;
|
|
299
|
+
|
|
300
|
+
return { ok: true, requirement, version };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* AI 改需求正文:只改给出的字段,需求回到草稿,等确认时才落版本。
|
|
305
|
+
*
|
|
306
|
+
* 草稿也能改:草稿阶段的正文本身就是要反复补充的,确认之前不产生版本。
|
|
307
|
+
* 已确认的需求被改正文后同样回到草稿——正文是用户要什么的那份约定,
|
|
308
|
+
* 改完要再确认一次,版本只在确认时生成,于是版本历史里不存在「没被确认过的一版」。
|
|
309
|
+
*
|
|
310
|
+
* **有一条硬闸门**:需求上有用户提交的暂定版本时,AI 不能直接改正文——那份暂定版本是
|
|
311
|
+
* 基于旧正文写的,AI 先改了正文,用户确认时就会覆盖掉 AI 的改动,或者反过来。所以先
|
|
312
|
+
* `confirmPending` 或 `questionPending`,再改。
|
|
313
|
+
*
|
|
314
|
+
* @param plan - 计划。
|
|
315
|
+
* @param id - 需求编号。
|
|
316
|
+
* @param patch - `{ title?, intro?, detail?, change? }`。
|
|
317
|
+
* @param now - 时间戳。
|
|
318
|
+
* @returns `{ ok, reason?, requirement?, changed?, noop? }`。
|
|
319
|
+
*/
|
|
320
|
+
export function updateByAi(plan, id, patch, now = Date.now()) {
|
|
321
|
+
const requirement = requirementOf(plan, id);
|
|
322
|
+
if (requirement === null) return { ok: false, reason: `找不到需求 ${id}` };
|
|
323
|
+
if (isClosedRequirement(requirement)) {
|
|
324
|
+
return { ok: false, reason: `${id} 已经${requirement.status === REQUIREMENT_STATUS.DONE ? "完成" : "放弃"}了,不能改` };
|
|
325
|
+
}
|
|
326
|
+
if (requirement.pending !== null) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
reason: `${id} 有一条用户改动等着你确认(暂定版本),先确认或者退回,再改正文`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const problems = validateFields(patch);
|
|
334
|
+
if (problems.length > 0) return { ok: false, reason: problems.join(";") };
|
|
335
|
+
|
|
336
|
+
const changed = applyPatch(requirement, patch);
|
|
337
|
+
if (changed.length === 0) {
|
|
338
|
+
// 只给了 `change`:正文没变,但这仍然是一次「把用户的话写进需求」的登记。
|
|
339
|
+
// 没有这条路,"用户说了句继续"就永远满足不了折叠的前提,只能逼模型去改正文。
|
|
340
|
+
// **不升版**:正文没变就没有新版本,版本历史里不该多出一条空变动。
|
|
341
|
+
const note = String(patch?.change ?? "").trim();
|
|
342
|
+
if (note === "") {
|
|
343
|
+
return { ok: false, reason: "没有给出任何要改的字段(title / intro / detail)" };
|
|
344
|
+
}
|
|
345
|
+
requirement.updatedAt = now;
|
|
346
|
+
plan.updatedAt = now;
|
|
347
|
+
return { ok: true, requirement, changed, noop: true };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 正文变了就回草稿等确认;版本在 confirm 时落。
|
|
351
|
+
requirement.status = REQUIREMENT_STATUS.DRAFT;
|
|
352
|
+
requirement.updatedAt = now;
|
|
353
|
+
plan.updatedAt = now;
|
|
354
|
+
return { ok: true, requirement, changed };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 把 patch 里给出的字段写到需求上。
|
|
359
|
+
*
|
|
360
|
+
* @param requirement - 需求。
|
|
361
|
+
* @param patch - `{ title?, intro?, detail? }`。
|
|
362
|
+
* @returns 真正改到的字段名数组。
|
|
363
|
+
*/
|
|
364
|
+
function applyPatch(requirement, patch) {
|
|
365
|
+
const changed = [];
|
|
366
|
+
if (typeof patch?.title === "string" && patch.title.trim() !== "") {
|
|
367
|
+
requirement.title = patch.title.trim();
|
|
368
|
+
changed.push("title");
|
|
369
|
+
}
|
|
370
|
+
if (typeof patch?.intro === "string") {
|
|
371
|
+
requirement.intro = patch.intro.trim();
|
|
372
|
+
changed.push("intro");
|
|
373
|
+
}
|
|
374
|
+
if (typeof patch?.detail === "string") {
|
|
375
|
+
requirement.detail = patch.detail;
|
|
376
|
+
changed.push("detail");
|
|
377
|
+
}
|
|
378
|
+
return changed;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 用户改需求:**只生成暂定版本,不动正在生效的正文**。
|
|
383
|
+
*
|
|
384
|
+
* 改的是字,活儿不能跟着抖——所以工作仍按生效版本走,等 AI 确认之后才转正
|
|
385
|
+
* (`confirmPending`)。点「确认变更」这个动作**不唤醒 AI**,AI 下次被唤起时
|
|
386
|
+
* 先看这条待确认。
|
|
387
|
+
*
|
|
388
|
+
* @param plan - 计划。
|
|
389
|
+
* @param id - 需求编号。
|
|
390
|
+
* @param patch - `{ title?, intro?, detail?, change? }`。
|
|
391
|
+
* @param now - 时间戳。
|
|
392
|
+
* @returns `{ ok, reason?, requirement?, pending? }`。
|
|
393
|
+
*/
|
|
394
|
+
export function updateByUser(plan, id, patch, now = Date.now()) {
|
|
395
|
+
const requirement = requirementOf(plan, id);
|
|
396
|
+
if (requirement === null) return { ok: false, reason: `找不到需求 ${id}` };
|
|
397
|
+
if (isClosedRequirement(requirement)) {
|
|
398
|
+
return { ok: false, reason: `${id} 已经${requirement.status === REQUIREMENT_STATUS.DONE ? "完成" : "放弃"}了,不能改` };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const problems = validateFields(patch);
|
|
402
|
+
if (problems.length > 0) return { ok: false, reason: problems.join(";") };
|
|
403
|
+
|
|
404
|
+
// 草稿还没有生效版本,用户改了就直接写进草稿正文,不产生暂定版本。
|
|
405
|
+
if (isDraft(requirement)) {
|
|
406
|
+
const changed = applyPatch(requirement, patch);
|
|
407
|
+
if (changed.length === 0) {
|
|
408
|
+
return { ok: false, reason: "没有给出任何要改的字段(title / intro / detail)" };
|
|
409
|
+
}
|
|
410
|
+
requirement.updatedAt = now;
|
|
411
|
+
plan.updatedAt = now;
|
|
412
|
+
return { ok: true, requirement, changed };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 暂定版本是**整份快照**:用户在生效版本的基础上改了几处,其余字段保持生效版本的样子。
|
|
416
|
+
const base = requirement.pending ?? snapshot(requirement);
|
|
417
|
+
const merged = {
|
|
418
|
+
title: typeof patch?.title === "string" ? patch.title.trim() : base.title,
|
|
419
|
+
intro: typeof patch?.intro === "string" ? patch.intro.trim() : base.intro,
|
|
420
|
+
detail: typeof patch?.detail === "string" ? patch.detail : base.detail,
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
if (
|
|
424
|
+
merged.title === requirement.title &&
|
|
425
|
+
merged.intro === requirement.intro &&
|
|
426
|
+
merged.detail === requirement.detail
|
|
427
|
+
) {
|
|
428
|
+
// 改回了和生效版本一模一样的内容:撤掉暂定版本,不用等 AI 确认。
|
|
429
|
+
requirement.pending = null;
|
|
430
|
+
requirement.updatedAt = now;
|
|
431
|
+
plan.updatedAt = now;
|
|
432
|
+
return { ok: true, requirement, pending: null, cancelled: true };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
requirement.pending = {
|
|
436
|
+
...merged,
|
|
437
|
+
change: String(patch?.change ?? "").trim(),
|
|
438
|
+
at: now,
|
|
439
|
+
by: "user",
|
|
440
|
+
question: null,
|
|
441
|
+
};
|
|
442
|
+
requirement.updatedAt = now;
|
|
443
|
+
plan.updatedAt = now;
|
|
444
|
+
return { ok: true, requirement, pending: requirement.pending };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* AI 确认用户提交的暂定版本:正文改成暂定版本的内容,版本 +1。
|
|
449
|
+
*
|
|
450
|
+
* @param plan - 计划。
|
|
451
|
+
* @param id - 需求编号。
|
|
452
|
+
* @param change - 这次改了什么;不给就用用户填的那句,再没有就是「用户提出的变更」。
|
|
453
|
+
* @param now - 时间戳。
|
|
454
|
+
* @returns `{ ok, reason?, requirement?, version? }`。
|
|
455
|
+
*/
|
|
456
|
+
export function confirmPending(plan, id, change, now = Date.now()) {
|
|
457
|
+
const requirement = requirementOf(plan, id);
|
|
458
|
+
if (requirement === null) return { ok: false, reason: `找不到需求 ${id}` };
|
|
459
|
+
|
|
460
|
+
const pending = requirement.pending;
|
|
461
|
+
if (pending === null) return { ok: false, reason: `${id} 没有等确认的改动` };
|
|
462
|
+
if (isClosedRequirement(requirement)) {
|
|
463
|
+
return { ok: false, reason: `${id} 已经${requirement.status === REQUIREMENT_STATUS.DONE ? "完成" : "放弃"}了,不能再确认改动` };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
requirement.title = pending.title;
|
|
467
|
+
requirement.intro = pending.intro;
|
|
468
|
+
requirement.detail = pending.detail;
|
|
469
|
+
requirement.pending = null;
|
|
470
|
+
requirement.updatedAt = now;
|
|
471
|
+
|
|
472
|
+
const text = String(change ?? "").trim() || String(pending.change ?? "").trim() || "用户提出的变更";
|
|
473
|
+
const version = pushVersion(requirement, text, "user", now);
|
|
474
|
+
plan.updatedAt = now;
|
|
475
|
+
return { ok: true, requirement, version };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* AI 认为暂定版本有歧义:**不转正,把疑问记下来**,等用户再改。
|
|
480
|
+
*
|
|
481
|
+
* 这一步存在的理由就是歧义与错别字——用户维护的是"要什么",他写的那句话 AI 读完
|
|
482
|
+
* 不一定靠得住,所以需求偏移的最后一道闸门在 AI 这一侧。
|
|
483
|
+
*
|
|
484
|
+
* @param plan - 计划。
|
|
485
|
+
* @param id - 需求编号。
|
|
486
|
+
* @param question - 要问用户的话(必填,不能空问)。
|
|
487
|
+
* @param now - 时间戳。
|
|
488
|
+
* @returns `{ ok, reason?, requirement?, pending? }`。
|
|
489
|
+
*/
|
|
490
|
+
export function questionPending(plan, id, question, now = Date.now()) {
|
|
491
|
+
const requirement = requirementOf(plan, id);
|
|
492
|
+
if (requirement === null) return { ok: false, reason: `找不到需求 ${id}` };
|
|
493
|
+
if (requirement.pending === null) return { ok: false, reason: `${id} 没有等确认的改动` };
|
|
494
|
+
|
|
495
|
+
const text = String(question ?? "").trim();
|
|
496
|
+
if (text === "") return { ok: false, reason: "要问什么得写出来,不能空着退回" };
|
|
497
|
+
|
|
498
|
+
requirement.pending.question = text;
|
|
499
|
+
requirement.pending.at = now;
|
|
500
|
+
requirement.updatedAt = now;
|
|
501
|
+
plan.updatedAt = now;
|
|
502
|
+
return { ok: true, requirement, pending: requirement.pending };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 完成一条需求。
|
|
507
|
+
*
|
|
508
|
+
* 谁来拍板:**AI 觉得完成就完成**,不需要用户点头,工具不拦。完成时提交的那段文字就是
|
|
509
|
+
* 「总结」——它同时是别的需求快速查阅时看到的东西。存储上用的是旧的 `result` 字段
|
|
510
|
+
* (渲染、检查点、折叠都认它),文档里叫总结。
|
|
511
|
+
*
|
|
512
|
+
* @param plan - 计划。
|
|
513
|
+
* @param id - 需求编号。
|
|
514
|
+
* @param summary - 总结:做了什么、结论、坑、给后续需求的注意。
|
|
515
|
+
* @param now - 时间戳。
|
|
516
|
+
* @returns `{ ok, reason?, requirement?, events?, hints? }`。
|
|
517
|
+
*/
|
|
518
|
+
export function completeRequirement(plan, id, summary, now = Date.now()) {
|
|
519
|
+
const requirement = requirementOf(plan, id);
|
|
520
|
+
if (requirement === null) return { ok: false, reason: `找不到需求 ${id}` };
|
|
521
|
+
if (isDraft(requirement)) return { ok: false, reason: `${id} 还是草稿,不能完成` };
|
|
522
|
+
if (requirement.pending !== null) {
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
reason: `${id} 有一条用户改动等着确认,先确认或退回再完成`,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const done = complete(plan, id, summary, now);
|
|
530
|
+
if (!done.ok) return done;
|
|
531
|
+
requirement.completedAt = now;
|
|
532
|
+
plan.updatedAt = now;
|
|
533
|
+
return { ...done, requirement, hints: completionHints(plan, requirement) };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* 放弃一条需求:划掉、留着可查,它下面的未结束任务一并放弃。
|
|
538
|
+
*
|
|
539
|
+
* 已完成的需求不能放弃——那会毁掉它的总结。
|
|
540
|
+
*
|
|
541
|
+
* @param plan - 计划。
|
|
542
|
+
* @param id - 需求编号。
|
|
543
|
+
* @param now - 时间戳。
|
|
544
|
+
* @returns `{ ok, reason?, requirement?, dropped? }`。
|
|
545
|
+
*/
|
|
546
|
+
export function dropRequirement(plan, id, now = Date.now()) {
|
|
547
|
+
const requirement = requirementOf(plan, id);
|
|
548
|
+
if (requirement === null) return { ok: false, reason: `找不到需求 ${id}` };
|
|
549
|
+
if (isDraft(requirement)) {
|
|
550
|
+
// 草稿直接删,不必先放弃一次。
|
|
551
|
+
return { ok: false, reason: `${id} 还是草稿,直接删掉就好` };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const result = dropNode(plan, id, now);
|
|
555
|
+
if (!result.ok) return result;
|
|
556
|
+
requirement.droppedAt = now;
|
|
557
|
+
plan.updatedAt = now;
|
|
558
|
+
return { ...result, requirement };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* 删除一条需求:物理摘除,不可逆,不看状态——已完成的照样删得掉。
|
|
563
|
+
*
|
|
564
|
+
* 与放弃的区别见 `dropRequirement`:放弃回答"这件事还做不做",删除是"这条记录不该留在这儿"。
|
|
565
|
+
*
|
|
566
|
+
* @param plan - 计划。
|
|
567
|
+
* @param id - 需求编号。
|
|
568
|
+
* @param now - 时间戳。
|
|
569
|
+
* @returns `{ ok, reason?, removed? }`。
|
|
570
|
+
*/
|
|
571
|
+
export function deleteRequirement(plan, id, now = Date.now()) {
|
|
572
|
+
const requirement = requirementOf(plan, id);
|
|
573
|
+
if (requirement === null) {
|
|
574
|
+
// 草稿也走这里;`requirementOf` 认的就是 `plan.goals`,草稿同样在里面。
|
|
575
|
+
return { ok: false, reason: `找不到需求 ${id}` };
|
|
576
|
+
}
|
|
577
|
+
return deleteNode(plan, id, now);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* 归档 / 取消归档。
|
|
582
|
+
*
|
|
583
|
+
* 归档是**用户控制固定占用多少的阀门**:从活跃列表消失,查得到,可取消。它跟完成状态
|
|
584
|
+
* 正交——完成过但没归档的需求,仍然在活跃列表里。草稿不能归档(它本来就不在列表里)。
|
|
585
|
+
*
|
|
586
|
+
* @param plan - 计划。
|
|
587
|
+
* @param id - 需求编号。
|
|
588
|
+
* @param archived - `true` 归档,`false` 取消归档。
|
|
589
|
+
* @param now - 时间戳。
|
|
590
|
+
* @returns `{ ok, reason?, requirement?, changed? }`。
|
|
591
|
+
*/
|
|
592
|
+
export function setArchived(plan, id, archived, now = Date.now()) {
|
|
593
|
+
const requirement = requirementOf(plan, id);
|
|
594
|
+
if (requirement === null) return { ok: false, reason: `找不到需求 ${id}` };
|
|
595
|
+
if (isDraft(requirement)) {
|
|
596
|
+
return { ok: false, reason: `${id} 还是草稿,草稿本来就不在活跃列表里` };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const wanted = archived === true;
|
|
600
|
+
const changed = requirement.archived !== wanted;
|
|
601
|
+
requirement.archived = wanted;
|
|
602
|
+
requirement.updatedAt = now;
|
|
603
|
+
plan.updatedAt = now;
|
|
604
|
+
return { ok: true, requirement, changed };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* 设置前置需求。成环直接拒绝——环里的需求永远等不到"前置全部满足"。
|
|
609
|
+
*
|
|
610
|
+
* @param plan - 计划。
|
|
611
|
+
* @param id - 需求编号。
|
|
612
|
+
* @param ids - 前置需求编号数组。
|
|
613
|
+
* @param now - 时间戳。
|
|
614
|
+
* @returns `{ ok, reason?, requirement? }`。
|
|
615
|
+
*/
|
|
616
|
+
export function setPrerequisites(plan, id, ids, now = Date.now()) {
|
|
617
|
+
const requirement = requirementOf(plan, id);
|
|
618
|
+
if (requirement === null) return { ok: false, reason: `找不到需求 ${id}` };
|
|
619
|
+
if (!Array.isArray(ids)) return { ok: false, reason: "前置需求要给一个编号数组" };
|
|
620
|
+
|
|
621
|
+
const wanted = [];
|
|
622
|
+
for (const raw of ids) {
|
|
623
|
+
const target = String(raw ?? "").trim();
|
|
624
|
+
if (target === "") continue;
|
|
625
|
+
if (target === id) return { ok: false, reason: "需求不能把自己当前置" };
|
|
626
|
+
if (requirementOf(plan, target) === null) {
|
|
627
|
+
return { ok: false, reason: `找不到前置需求 ${target}` };
|
|
628
|
+
}
|
|
629
|
+
if (!wanted.includes(target)) wanted.push(target);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 探环:从每个要加的前置出发,能走回自己就是环。
|
|
633
|
+
for (const target of wanted) {
|
|
634
|
+
if (reaches(plan, target, id, new Set())) {
|
|
635
|
+
return { ok: false, reason: `前置成环:${target} 已经(直接或间接)依赖 ${id}` };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
requirement.prerequisites = wanted;
|
|
640
|
+
requirement.updatedAt = now;
|
|
641
|
+
plan.updatedAt = now;
|
|
642
|
+
return { ok: true, requirement };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* 从 `from` 沿前置关系往下走,能不能走到 `to`。
|
|
647
|
+
*
|
|
648
|
+
* @param plan - 计划。
|
|
649
|
+
* @param from - 起点需求编号。
|
|
650
|
+
* @param to - 目标需求编号。
|
|
651
|
+
* @param seen - 已访问集合,防死循环。
|
|
652
|
+
* @returns 能走到为 true。
|
|
653
|
+
*/
|
|
654
|
+
function reaches(plan, from, to, seen) {
|
|
655
|
+
if (from === to) return true;
|
|
656
|
+
if (seen.has(from)) return false;
|
|
657
|
+
seen.add(from);
|
|
658
|
+
const requirement = requirementOf(plan, from);
|
|
659
|
+
if (requirement === null) return false;
|
|
660
|
+
return (requirement.prerequisites ?? []).some((id) => reaches(plan, id, to, seen));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* 这条需求还缺哪些前置。
|
|
665
|
+
*
|
|
666
|
+
* `state` 为 `done` 的算满足;`open` 是还没做;`dropped` 是放弃;`missing` 是被删掉了
|
|
667
|
+
* (悬空——不静默当满足,要让调用方说一句)。
|
|
668
|
+
*
|
|
669
|
+
* @param plan - 计划。
|
|
670
|
+
* @param requirement - 需求节点。
|
|
671
|
+
* @returns `[{ id, title, state }]`。
|
|
672
|
+
*/
|
|
673
|
+
export function missingPrerequisites(plan, requirement) {
|
|
674
|
+
const rows = [];
|
|
675
|
+
for (const id of requirement?.prerequisites ?? []) {
|
|
676
|
+
const target = requirementOf(plan, id);
|
|
677
|
+
if (target === null) {
|
|
678
|
+
rows.push({ id, title: null, state: "missing" });
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (target.status === REQUIREMENT_STATUS.DONE) continue;
|
|
682
|
+
rows.push({
|
|
683
|
+
id,
|
|
684
|
+
title: target.title,
|
|
685
|
+
state: target.status === REQUIREMENT_STATUS.DROPPED ? "dropped" : "open",
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
return rows;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* 哪些需求以 `id` 为前置(下游)。
|
|
693
|
+
*
|
|
694
|
+
* @param plan - 计划。
|
|
695
|
+
* @param id - 需求编号。
|
|
696
|
+
* @returns 需求节点数组,按创建顺序。
|
|
697
|
+
*/
|
|
698
|
+
export function dependentsOf(plan, id) {
|
|
699
|
+
return (plan?.goals ?? []).filter((item) => (item.prerequisites ?? []).includes(id));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* 完成一条需求时给的那几行提示。**只出提示,不改任何状态。**
|
|
704
|
+
*
|
|
705
|
+
* 三件事:
|
|
706
|
+
* 1. 下游已经自报完成的 → 顺序颠倒,建议复核(它是在本需求没做完时完成的);
|
|
707
|
+
* 2. 下游的前置因为这次完成而全部满足 → 可以开始了;
|
|
708
|
+
* 3. 本需求自己是跳着做的(前置还缺)→ 记一句,不追问;
|
|
709
|
+
* 4. 整个会话的需求都结束了 → 收尾。
|
|
710
|
+
*
|
|
711
|
+
* @param plan - 计划。
|
|
712
|
+
* @param requirement - 刚完成的需求。
|
|
713
|
+
* @returns 提示行数组,可能为空。
|
|
714
|
+
*/
|
|
715
|
+
export function completionHints(plan, requirement) {
|
|
716
|
+
const hints = [];
|
|
717
|
+
|
|
718
|
+
for (const dependent of dependentsOf(plan, requirement.id)) {
|
|
719
|
+
if (dependent.status === REQUIREMENT_STATUS.DONE) {
|
|
720
|
+
hints.push(
|
|
721
|
+
`${dependent.id} 依赖本需求且已自报完成——它是在本需求没做完时完成的,建议回头复核。`,
|
|
722
|
+
);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (isOpenRequirement(dependent) && missingPrerequisites(plan, dependent).length === 0) {
|
|
726
|
+
hints.push(`${dependent.id} 的前置全部满足,可以开始了。`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const missing = missingPrerequisites(plan, requirement);
|
|
731
|
+
if (missing.length > 0) {
|
|
732
|
+
const names = missing.map((item) => `${item.id}(${stateLabel(item.state)})`).join("、");
|
|
733
|
+
hints.push(`${requirement.id} 的前置仍缺 ${names},本次是跳着做的。`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if ((plan?.goals ?? []).every((item) => isClosedRequirement(item) || isDraft(item))) {
|
|
737
|
+
hints.push("这个会话的需求全部完成。");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return hints;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** 前置状态的短说法。 */
|
|
744
|
+
function stateLabel(state) {
|
|
745
|
+
if (state === "open") return "未完成";
|
|
746
|
+
if (state === "dropped") return "已放弃";
|
|
747
|
+
if (state === "missing") return "已被删除";
|
|
748
|
+
return state;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 完成一个**任务**时给的那几行提示。
|
|
753
|
+
*
|
|
754
|
+
* @param requirement - 任务所属的需求。
|
|
755
|
+
* @returns 提示行数组,可能为空。
|
|
756
|
+
*/
|
|
757
|
+
export function taskCompletionHints(requirement) {
|
|
758
|
+
const tasks = requirement?.tasks ?? [];
|
|
759
|
+
if (tasks.length === 0) return [];
|
|
760
|
+
|
|
761
|
+
const open = tasks.filter((task) => task.status === NODE_STATUS.PENDING);
|
|
762
|
+
if (open.length === 1) return ["这条需求下还剩 1 个任务。"];
|
|
763
|
+
if (open.length > 1) return [`这条需求下还剩 ${open.length} 个任务。`];
|
|
764
|
+
if (tasks.every((task) => task.status === NODE_STATUS.DONE)) {
|
|
765
|
+
return ["这条需求的任务都完了,可以写总结并完成它。"];
|
|
766
|
+
}
|
|
767
|
+
return [];
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* 一条需求"最后动过"的时间。
|
|
772
|
+
*
|
|
773
|
+
* 「当前进行中」是由**最后执行的那个任务**推导出来的,不另存状态——少一个要同步的状态,
|
|
774
|
+
* 就少一类 bug。任务、子任务的完成时间与创建时间都算活动。
|
|
775
|
+
*
|
|
776
|
+
* @param requirement - 需求。
|
|
777
|
+
* @returns 时间戳;从没动过是 0。
|
|
778
|
+
*/
|
|
779
|
+
export function activityAt(requirement) {
|
|
780
|
+
let at = Number(requirement?.startedAt ?? 0);
|
|
781
|
+
for (const task of requirement?.tasks ?? []) {
|
|
782
|
+
at = Math.max(at, Number(task.completedAt ?? 0), Number(task.createdAt ?? 0));
|
|
783
|
+
for (const step of task.steps ?? []) {
|
|
784
|
+
at = Math.max(at, Number(step.completedAt ?? 0), Number(step.createdAt ?? 0));
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return at;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* 当前进行中的需求(列表里要展开的那一条)。
|
|
792
|
+
*
|
|
793
|
+
* 取**还开着、且没归档**的需求里活动时间最新的那一条;所有需求都没有任务动过时,
|
|
794
|
+
* 退回「最后更新的那一条」。找不到就是 null。
|
|
795
|
+
*
|
|
796
|
+
* 归档的必须排除:它已经不在活跃列表里,选了它就会出现「一条谁也没展开、当前标记
|
|
797
|
+
* 也不见了」的怪象——实测在重启后的真会话里踩到过。
|
|
798
|
+
*
|
|
799
|
+
* @param plan - 计划。
|
|
800
|
+
* @returns 需求节点或 null。
|
|
801
|
+
*/
|
|
802
|
+
export function activeRequirement(plan) {
|
|
803
|
+
const open = (plan?.goals ?? []).filter(
|
|
804
|
+
(requirement) => isOpenRequirement(requirement) && requirement.archived !== true,
|
|
805
|
+
);
|
|
806
|
+
if (open.length === 0) return null;
|
|
807
|
+
|
|
808
|
+
let best = null;
|
|
809
|
+
let bestAt = -1;
|
|
810
|
+
for (const requirement of open) {
|
|
811
|
+
const at = activityAt(requirement);
|
|
812
|
+
if (at > bestAt) {
|
|
813
|
+
best = requirement;
|
|
814
|
+
bestAt = at;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (bestAt > 0) return best;
|
|
818
|
+
|
|
819
|
+
return open.reduce((a, b) => (Number(b.updatedAt ?? 0) > Number(a.updatedAt ?? 0) ? b : a));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* 活跃列表:**未归档、未删除**的需求,一行一条(草稿不算——它还没被确认过)。
|
|
824
|
+
*
|
|
825
|
+
* 放弃的需求仍然在这里,带着"已放弃"的标记——它回答的是"这件事还做不做",
|
|
826
|
+
* 不是"还占不占地方"。
|
|
827
|
+
*
|
|
828
|
+
* @param plan - 计划。
|
|
829
|
+
* @returns 行数组 `{ id, title, intro, status, missing, nextTask, pending }`。
|
|
830
|
+
*/
|
|
831
|
+
export function listActive(plan) {
|
|
832
|
+
return (plan?.goals ?? [])
|
|
833
|
+
.filter((requirement) => !isDraft(requirement) && requirement.archived !== true)
|
|
834
|
+
.map((requirement) => indexRow(plan, requirement));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* 已归档列表。
|
|
839
|
+
*
|
|
840
|
+
* @param plan - 计划。
|
|
841
|
+
* @returns 行数组,同 `listActive`。
|
|
842
|
+
*/
|
|
843
|
+
export function listArchived(plan) {
|
|
844
|
+
return (plan?.goals ?? [])
|
|
845
|
+
.filter((requirement) => !isDraft(requirement) && requirement.archived === true)
|
|
846
|
+
.map((requirement) => indexRow(plan, requirement));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* 还没被确认的草稿(面板上那个「待确认」小格子)。
|
|
851
|
+
*
|
|
852
|
+
* @param plan - 计划。
|
|
853
|
+
* @returns 草稿需求数组。
|
|
854
|
+
*/
|
|
855
|
+
export function listDrafts(plan) {
|
|
856
|
+
return (plan?.goals ?? []).filter(isDraft);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* 列表里那一行。
|
|
861
|
+
*
|
|
862
|
+
* @param plan - 计划。
|
|
863
|
+
* @param requirement - 需求。
|
|
864
|
+
* @returns `{ id, title, intro, status, missing, nextTask, pending }`。
|
|
865
|
+
*/
|
|
866
|
+
function indexRow(plan, requirement) {
|
|
867
|
+
return {
|
|
868
|
+
id: requirement.id,
|
|
869
|
+
title: requirement.title,
|
|
870
|
+
intro: requirement.intro,
|
|
871
|
+
status: requirement.status,
|
|
872
|
+
missing: missingPrerequisites(plan, requirement).map((item) => item.id),
|
|
873
|
+
nextTask: nextTaskOf(requirement),
|
|
874
|
+
// 用真值判断而不是 `!== null`:读进来的对象可能根本没有这个字段(旧数据、
|
|
875
|
+
// 或者调用方手工造的节点),`undefined !== null` 会一路走到 `.change` 上炸掉。
|
|
876
|
+
pending: requirement.pending
|
|
877
|
+
? requirement.pending.change || "(未说明改了什么)"
|
|
878
|
+
: null,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* 这条需求下一个要做的任务。
|
|
884
|
+
*
|
|
885
|
+
* @param requirement - 需求。
|
|
886
|
+
* @returns 任务节点或 null。
|
|
887
|
+
*/
|
|
888
|
+
export function nextTaskOf(requirement) {
|
|
889
|
+
return (requirement?.tasks ?? []).find((task) => task.status === NODE_STATUS.PENDING) ?? null;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* 展开一条需求的进度:一级是任务目标,二级是步骤。
|
|
894
|
+
*
|
|
895
|
+
* 列表里只展开**当前进行中**的那一条,所以这个函数只给它用。
|
|
896
|
+
*
|
|
897
|
+
* @param requirement - 需求。
|
|
898
|
+
* @returns `[{ task, steps }]`,`steps` 是该任务下的子任务数组。
|
|
899
|
+
*/
|
|
900
|
+
export function progressOf(requirement) {
|
|
901
|
+
return (requirement?.tasks ?? []).map((task) => ({ task, steps: task.steps ?? [] }));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* 在计划里定位一个任务所属的需求。
|
|
906
|
+
*
|
|
907
|
+
* @param plan - 计划。
|
|
908
|
+
* @param taskId - 任务或子任务的 id。
|
|
909
|
+
* @returns 需求节点或 null。
|
|
910
|
+
*/
|
|
911
|
+
export function requirementOfTask(plan, taskId) {
|
|
912
|
+
const found = findNode(plan, taskId);
|
|
913
|
+
return found === null ? null : found.goal;
|
|
914
|
+
}
|