cs-bots 0.1.1 → 0.7.4
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/CHANGELOG.md +533 -0
- package/README.md +65 -10
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
- package/docs/ARCHITECTURE.md +277 -0
- package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
- package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
- package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
- package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
- package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
- package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
- package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
- package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
- package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
- package/package.json +4 -2
- package/src/admission.js +365 -0
- package/src/api.js +4097 -420
- package/src/board.js +883 -14
- package/src/botprofile.js +190 -3
- package/src/client.js +6596 -1082
- package/src/cycle.js +516 -0
- package/src/delivery-watch.js +255 -0
- package/src/dirbrowse.js +20 -0
- package/src/discipline.js +1188 -0
- package/src/fence.js +142 -0
- package/src/index.js +387 -4
- package/src/kernel.js +636 -24
- package/src/library.js +1224 -11
- package/src/memory.js +617 -37
- package/src/metrics.js +339 -0
- package/src/prompts.js +1318 -0
- package/src/reviewer.js +709 -0
- package/src/room.js +655 -14
- package/src/roster.js +346 -26
- package/src/routing/complexity.js +320 -0
- package/src/routing/hints.js +707 -0
- package/src/scratch.js +477 -0
- package/src/settings.js +97 -0
- package/src/stage.js +263 -0
- package/src/store.js +133 -1
- package/src/subscription.js +571 -0
- package/src/suggest.js +1203 -0
- package/src/teams.js +527 -0
- package/src/todo.js +1301 -0
- package/src/tools.js +734 -10
- package/src/verify.js +356 -0
package/src/todo.js
ADDED
|
@@ -0,0 +1,1301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots TodoService —— 四轨待办(对标 dsh-memory-evolve 的 dtodo,**完全自建、零依赖**)。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`ANALYSIS-memory-evolve-parity.md` 第 #10 项 + 第三节表格
|
|
5
|
+
* (四轨:life/work/project/daily + 四象限 + 截止 + 状态机 + 智能视图),
|
|
6
|
+
* 持久化与错误处理照抄本仓 `src/board.js` 的标准写法(jsonl 事件流 + snap 快照 + replay)。
|
|
7
|
+
*
|
|
8
|
+
* ── 四条不变量(与 board.js / discipline.js 同款,机械保证)──
|
|
9
|
+
* 1. **CAS 优先**:任何变更都要带 `expectedRev`;不匹配即抛错,绝不静默覆盖。
|
|
10
|
+
* 2. **事件流是真相源**:先 append `todo.jsonl`(失败即抛错,内存原封不动),
|
|
11
|
+
* 再更新内存,最后原子写 `todo.snap.json`(快照失败只降级 warn ——
|
|
12
|
+
* 事件流已落盘,`replay()` 能完整重建)。
|
|
13
|
+
* 3. **写失败绝不静默**:append 失败抛 `TODO_APPEND_FAILED`,不留下「内存有、磁盘无」的幽灵条目。
|
|
14
|
+
* 4. **project 轨按 cwd 隔离**:项目待办只对同一个工作目录可见,跨项目零泄露。
|
|
15
|
+
*
|
|
16
|
+
* ── 关键语义(都是实测踩坑点,勿"简化")──
|
|
17
|
+
* · **智能视图不是"全部未完成"**:只列「需要关注」的未完成项
|
|
18
|
+
* (逾期 → 今日到期 → 本项目未完成 → 重要紧急 q1/q2 → 今日每日待办),
|
|
19
|
+
* 其余 q3/q4 且无截止的条目要显式查(`all:true` 或按 track/status 查),
|
|
20
|
+
* 否则列表会被琐事淹没。硬上限 8 条(`SMART_VIEW_LIMIT`)。
|
|
21
|
+
* · **过往每日待办默认隐藏**:daily 的"截止"就是它所属的那一天(`date`),
|
|
22
|
+
* `date < 今天` 的条目属于历史。要查历史必须显式 `past:true`;
|
|
23
|
+
* 而历史里「未完成且已过期」的遗留默认仍被藏起来 —— 只带 `past:true`
|
|
24
|
+
* 会得到"昨天没有待办"的错觉,必须再带 `expired:true` 才能挖出遗留任务。
|
|
25
|
+
* 本模块在 `past:true && expired!==true` 时返回 `hint` 点明这一点。
|
|
26
|
+
* · **done 自动盖 doneAt**:`status` 变成 done 的那一刻写入 `doneAt`;
|
|
27
|
+
* 离开 done(改回 doing / cancelled 等)清空,避免"已完成时间"长期挂着。
|
|
28
|
+
* · **四象限与 important/urgent 必须自洽**:只给 quadrant 就反推两个布尔;
|
|
29
|
+
* 只给布尔就推出 quadrant;两边都给但互相矛盾则抛错(不允许歧义态)。
|
|
30
|
+
*
|
|
31
|
+
* ── 为什么 kernel.js 是「动态导入」而不是静态 import(与 board.js 相同,勿改)──
|
|
32
|
+
* `kernel.js` 静态 import 了宿主包(DSH 提供,只在 DSH profile 内可解析)。
|
|
33
|
+
* 本仓库是**裸检出**(无 node_modules),静态 import 会让 todo.js 连带
|
|
34
|
+
* kernel.js 一并加载失败 → `test/todo.test.mjs` 根本起不来。
|
|
35
|
+
* 故此处用顶层 `await import()` 并在失败时记下原因:
|
|
36
|
+
* - 拿得到 kernel → `config.stateDir` 缺省走 `kernel.stateDir()`
|
|
37
|
+
* - 拿不到 kernel 且调用方也没给 `config.stateDir` → **抛可读错误**(不静默兜底)
|
|
38
|
+
* 零依赖 CI(`test/ci-zero-dep.sh` 检查 1)依然成立:这里引用的只是 `./kernel.js`。
|
|
39
|
+
*
|
|
40
|
+
* 依赖面:node 内置(`node:crypto` / `node:path`)+ `./store.js` + `./kernel.js`。
|
|
41
|
+
* **不 import 任何宿主包或第三方包**(CI 机械校验)。
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { randomBytes } from 'node:crypto'
|
|
45
|
+
import { normalize as normalizePath } from 'node:path'
|
|
46
|
+
|
|
47
|
+
import {
|
|
48
|
+
appendJsonl,
|
|
49
|
+
readJson,
|
|
50
|
+
readJsonl,
|
|
51
|
+
statePath,
|
|
52
|
+
writeJsonAtomic,
|
|
53
|
+
} from './store.js'
|
|
54
|
+
|
|
55
|
+
// ── kernel 惰性桥(见文件头「为什么是动态导入」)──────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** @type {object | null} */
|
|
58
|
+
let kernel = null
|
|
59
|
+
/** @type {unknown} */
|
|
60
|
+
let kernelError = null
|
|
61
|
+
try {
|
|
62
|
+
kernel = await import('./kernel.js')
|
|
63
|
+
} catch (error) {
|
|
64
|
+
kernelError = error
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── store.appendAudit 软导入(P1-D §4)───────────────────────────────────────
|
|
68
|
+
// 软用:拿不到就只 warn 一次 + 计数,绝不阻断主流程,也绝不自己另造审计格式。
|
|
69
|
+
// 动态 `await import` 而非具名静态 import:store.js 正被并行修改,静态具名 import 在
|
|
70
|
+
// 对方重构掉该导出时是**加载期**失败(整个服务起不来);动态判定只少一条审计。
|
|
71
|
+
let storeAppendAudit = /** @type {null | ((dir: string, entry: object) => unknown)} */ (null)
|
|
72
|
+
let storeAuditMissing = false
|
|
73
|
+
try {
|
|
74
|
+
const storeMod = await import('./store.js')
|
|
75
|
+
if (typeof storeMod.appendAudit === 'function') storeAppendAudit = storeMod.appendAudit
|
|
76
|
+
else storeAuditMissing = true
|
|
77
|
+
} catch {
|
|
78
|
+
storeAuditMissing = true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── 常量(契约冻结)─────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/** 四轨:生活 / 工作 / 本项目 / 每日。 */
|
|
84
|
+
export const TRACKS = ['life', 'work', 'project', 'daily']
|
|
85
|
+
/** 状态机(缺省 pending)。 */
|
|
86
|
+
export const STATUSES = ['pending', 'doing', 'done', 'blocked', 'cancelled']
|
|
87
|
+
/** 四象限:q1 重要且紧急 / q2 重要不紧急 / q3 紧急不重要 / q4 都不。 */
|
|
88
|
+
export const QUADRANTS = ['q1', 'q2', 'q3', 'q4']
|
|
89
|
+
/** 字段上限。content 首行是标题、其余是详情,4000 与 discipline 的正文口径一致。 */
|
|
90
|
+
export const LIMITS = { content: 4000, cat: 64 }
|
|
91
|
+
/** 智能视图硬上限(对齐 dtodo 的 DEFAULT_VIEW_LIMIT)。 */
|
|
92
|
+
export const SMART_VIEW_LIMIT = 8
|
|
93
|
+
/** "需要关注"的状态集合:done/cancelled 已结束,不再进智能视图。 */
|
|
94
|
+
export const ACTIVE_STATUSES = ['pending', 'doing', 'blocked']
|
|
95
|
+
/** 严格日期形态(真正合法性还要过日历校验,见 `parseDateString`)。 */
|
|
96
|
+
export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
97
|
+
/** update 可补丁的字段(status 也走这里,但 doneAt 只能由 status 推导,不许外部直接写)。 */
|
|
98
|
+
const PATCH_FIELDS = ['track', 'cwd', 'date', 'content', 'quadrant', 'important', 'urgent', 'due', 'cat', 'status']
|
|
99
|
+
|
|
100
|
+
// ── 错误 ────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 待办模块结构化错误。所有失败都走它,**绝不静默**。
|
|
104
|
+
*
|
|
105
|
+
* `code` 一律 `TODO_` 前缀;`detail` 带机器可读定位信息(id / 字段 / 当前 rev)。
|
|
106
|
+
* 同时挂 `details` 别名:本仓 board.js 用的是 `details`,兼容两种读法,
|
|
107
|
+
* 免得调用方因为命名差异把一个真实错误读成 `undefined`。
|
|
108
|
+
*/
|
|
109
|
+
export class TodoError extends Error {
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} message 人类可读、含定位信息
|
|
112
|
+
* @param {string} [code]
|
|
113
|
+
* @param {object} [detail]
|
|
114
|
+
*/
|
|
115
|
+
constructor(message, code = 'TODO_ERROR', detail) {
|
|
116
|
+
super(message)
|
|
117
|
+
this.name = 'TodoError'
|
|
118
|
+
this.code = code
|
|
119
|
+
if (detail !== undefined) {
|
|
120
|
+
this.detail = detail
|
|
121
|
+
this.details = detail
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 深拷贝 + **JSON 归一**(去掉值为 undefined 的键)。
|
|
130
|
+
* 必须走 JSON 而不是 structuredClone:否则内存态会比磁盘态多出
|
|
131
|
+
* `{cat: undefined}` 这类键,`replay()` 重建后与内存值不等。
|
|
132
|
+
*/
|
|
133
|
+
function clone(value) {
|
|
134
|
+
if (value === undefined || value === null) return value
|
|
135
|
+
return JSON.parse(JSON.stringify(value))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 两位补零。 */
|
|
139
|
+
function pad2(n) {
|
|
140
|
+
return String(n).padStart(2, '0')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 毫秒时间戳 → 本地日历日 `YYYY-MM-DD`。
|
|
145
|
+
*
|
|
146
|
+
* 为什么不用 `toISOString().slice(0, 10)`:它按 **UTC** 算日期。
|
|
147
|
+
* 本机时区是 Asia/Shanghai(UTC+8),早上 8 点前 UTC 还停在前一天,
|
|
148
|
+
* 会把「今天」算成昨天,导致今日待办被当成过往、逾期待办少算一天。
|
|
149
|
+
*/
|
|
150
|
+
export function localDate(now = Date.now()) {
|
|
151
|
+
const d = new Date(now)
|
|
152
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 某年某月的天数(month 为 1–12)。用 Date 兜住闰年,不引日期库。 */
|
|
156
|
+
function daysInMonth(year, month) {
|
|
157
|
+
// Date.UTC(year, month, 0):month 作为「下个月」的 0 号 = 本月最后一天
|
|
158
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 非空字符串字段校验(返回原值,不 trim —— 由调用方决定是否 trim)。 */
|
|
162
|
+
function requireString(value, field, where) {
|
|
163
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
164
|
+
throw new TodoError(
|
|
165
|
+
`todo.${where}:字段 ${field} 缺失或类型错误(要求非空字符串,收到 ${JSON.stringify(value)})`,
|
|
166
|
+
'TODO_MISSING_FIELD',
|
|
167
|
+
{ field, where },
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
return value
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 时间参数归一:可注入(测试友好),缺省 Date.now()。 */
|
|
174
|
+
function normalizeNow(now, where) {
|
|
175
|
+
if (now === undefined || now === null) return Date.now()
|
|
176
|
+
if (typeof now !== 'number' || !Number.isFinite(now) || now < 0) {
|
|
177
|
+
throw new TodoError(
|
|
178
|
+
`todo.${where}:now 必须是有限的非负数毫秒时间戳(收到 ${JSON.stringify(now)})`,
|
|
179
|
+
'TODO_BAD_FIELD',
|
|
180
|
+
{ field: 'now', where },
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
return now
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 取操作者 id。契约参数名是 `by`;同时容忍 board 风格的 `byId`,
|
|
188
|
+
* 避免 api/tools 层混用时把一次正确调用误判成「缺 by」。
|
|
189
|
+
*/
|
|
190
|
+
function requireActor(input, where) {
|
|
191
|
+
const value = input?.by ?? input?.byId
|
|
192
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
193
|
+
throw new TodoError(
|
|
194
|
+
`todo.${where}:操作者缺失(字段 by,兼容别名 byId;收到 ${JSON.stringify(value)})`,
|
|
195
|
+
'TODO_MISSING_FIELD',
|
|
196
|
+
{ field: 'by', where },
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
return value.trim()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 取 id 参数(本模块 id 统一 `td-<8hex>`)。 */
|
|
203
|
+
function requireId(id, where) {
|
|
204
|
+
if (typeof id !== 'string' || id.trim() === '') {
|
|
205
|
+
throw new TodoError(
|
|
206
|
+
`todo.${where}:id 必须是非空字符串(形如 td-1a2b3c4d,收到 ${JSON.stringify(id)})`,
|
|
207
|
+
'TODO_MISSING_FIELD',
|
|
208
|
+
{ field: 'id', where },
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
return id.trim()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 严格日期校验:形态 + 日历合法性(2026-02-30 这类必须在入口拒绝)。 */
|
|
215
|
+
function parseDateString(value, field, where) {
|
|
216
|
+
if (typeof value !== 'string' || !DATE_RE.test(value)) {
|
|
217
|
+
throw new TodoError(
|
|
218
|
+
`todo.${where}:字段 ${field} 必须是 YYYY-MM-DD 字符串(收到 ${JSON.stringify(value)})`,
|
|
219
|
+
'TODO_BAD_DATE',
|
|
220
|
+
{ field, where, value },
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
const year = Number(value.slice(0, 4))
|
|
224
|
+
const month = Number(value.slice(5, 7))
|
|
225
|
+
const day = Number(value.slice(8, 10))
|
|
226
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
|
|
227
|
+
throw new TodoError(
|
|
228
|
+
`todo.${where}:字段 ${field} 不是合法日历日(${value},${year} 年 ${month} 月没有 ${day} 日)`,
|
|
229
|
+
'TODO_BAD_DATE',
|
|
230
|
+
{ field, where, value },
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
return value
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* cwd 归一(只用于**比较与存储**):
|
|
238
|
+
* 去首尾空白、按平台 normalize、去掉尾部分隔符;Windows 下统一小写。
|
|
239
|
+
*
|
|
240
|
+
* 为什么必须归一:project 轨的隔离键就是 cwd。`/a/b` 与 `/a/b/` 若被当成
|
|
241
|
+
* 两个项目,同一个项目的待办会分裂成两份,且互相看不见 —— 这是静默的数据丢失。
|
|
242
|
+
*/
|
|
243
|
+
function normalizeCwd(cwd, where) {
|
|
244
|
+
requireString(cwd, 'cwd', where)
|
|
245
|
+
let s = cwd.trim()
|
|
246
|
+
try {
|
|
247
|
+
s = normalizePath(s)
|
|
248
|
+
} catch {
|
|
249
|
+
/* 非法路径按原样比较,不抛 —— 比较不该比写还脆 */
|
|
250
|
+
}
|
|
251
|
+
if (s.length > 1) s = s.replace(/[\\/]+$/, '')
|
|
252
|
+
if (process.platform === 'win32') s = s.toLowerCase()
|
|
253
|
+
if (s === '') {
|
|
254
|
+
throw new TodoError(
|
|
255
|
+
`todo.${where}:cwd 解析后为空(收到 ${JSON.stringify(cwd)})`,
|
|
256
|
+
'TODO_BAD_FIELD',
|
|
257
|
+
{ field: 'cwd', where },
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
return s
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 判断"调用方到底给没给这个字段":undefined / null / '' 都算没给。 */
|
|
264
|
+
function given(value) {
|
|
265
|
+
return value !== undefined && value !== null && value !== ''
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 布尔字段归一。 */
|
|
269
|
+
function normalizeBoolean(value, field, where) {
|
|
270
|
+
if (typeof value !== 'boolean') {
|
|
271
|
+
throw new TodoError(
|
|
272
|
+
`todo.${where}:字段 ${field} 必须是布尔(收到 ${JSON.stringify(value)})`,
|
|
273
|
+
'TODO_BAD_FIELD',
|
|
274
|
+
{ field, where },
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
return value
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 四象限 → 两个布尔(q1/q2 重要;q1/q3 紧急)。 */
|
|
281
|
+
function flagsOfQuadrant(quadrant) {
|
|
282
|
+
return {
|
|
283
|
+
important: quadrant === 'q1' || quadrant === 'q2',
|
|
284
|
+
urgent: quadrant === 'q1' || quadrant === 'q3',
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 两个布尔 → 四象限。 */
|
|
289
|
+
function quadrantOfFlags(important, urgent) {
|
|
290
|
+
if (important) return urgent ? 'q1' : 'q2'
|
|
291
|
+
return urgent ? 'q3' : 'q4'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** quadrant 字面量校验。 */
|
|
295
|
+
function normalizeQuadrantValue(quadrant, where) {
|
|
296
|
+
if (typeof quadrant !== 'string' || !QUADRANTS.includes(quadrant)) {
|
|
297
|
+
throw new TodoError(
|
|
298
|
+
`todo.${where}:非法 quadrant=${JSON.stringify(quadrant)}(合法值:${QUADRANTS.join(' | ')})`,
|
|
299
|
+
'TODO_BAD_QUADRANT',
|
|
300
|
+
{ field: 'quadrant', where, quadrant },
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
return quadrant
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 四象限字段组合归一(create / update 共用)。
|
|
308
|
+
*
|
|
309
|
+
* 规则(为什么这么定):
|
|
310
|
+
* · 只给 `quadrant` → 由它反推 important/urgent,保证三字段永远自洽;
|
|
311
|
+
* · 只给布尔 → 由布尔推出 quadrant;
|
|
312
|
+
* · 两边都给且矛盾 → **抛错**。静默让某一方赢会让 UI 的色块与文字对不上,
|
|
313
|
+
* 而且调用方永远不知道自己的入参被改写了。
|
|
314
|
+
*
|
|
315
|
+
* @param {object} input 调用入参
|
|
316
|
+
* @param {object|null} current 更新时的当前记录(create 传 null)
|
|
317
|
+
* @param {string} where
|
|
318
|
+
* @returns {{ quadrant: string, important: boolean, urgent: boolean }}
|
|
319
|
+
*/
|
|
320
|
+
function normalizeQuadrantFields(input, current, where) {
|
|
321
|
+
const hasQuadrant = given(input?.quadrant)
|
|
322
|
+
if (hasQuadrant) {
|
|
323
|
+
const quadrant = normalizeQuadrantValue(input.quadrant, where)
|
|
324
|
+
const flags = flagsOfQuadrant(quadrant)
|
|
325
|
+
if (given(input?.important)) {
|
|
326
|
+
const important = normalizeBoolean(input.important, 'important', where)
|
|
327
|
+
if (important !== flags.important) {
|
|
328
|
+
throw new TodoError(
|
|
329
|
+
`todo.${where}:quadrant=${quadrant} 蕴含 important=${flags.important},但入参 important=${important} —— `
|
|
330
|
+
+ '两个字段互相矛盾,拒绝猜测哪边为准',
|
|
331
|
+
'TODO_QUADRANT_CONFLICT',
|
|
332
|
+
{ field: 'important', where, quadrant, important },
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (given(input?.urgent)) {
|
|
337
|
+
const urgent = normalizeBoolean(input.urgent, 'urgent', where)
|
|
338
|
+
if (urgent !== flags.urgent) {
|
|
339
|
+
throw new TodoError(
|
|
340
|
+
`todo.${where}:quadrant=${quadrant} 蕴含 urgent=${flags.urgent},但入参 urgent=${urgent} —— `
|
|
341
|
+
+ '两个字段互相矛盾,拒绝猜测哪边为准',
|
|
342
|
+
'TODO_QUADRANT_CONFLICT',
|
|
343
|
+
{ field: 'urgent', where, quadrant, urgent },
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { quadrant, important: flags.important, urgent: flags.urgent }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const important = given(input?.important)
|
|
351
|
+
? normalizeBoolean(input.important, 'important', where)
|
|
352
|
+
: Boolean(current?.important)
|
|
353
|
+
const urgent = given(input?.urgent)
|
|
354
|
+
? normalizeBoolean(input.urgent, 'urgent', where)
|
|
355
|
+
: Boolean(current?.urgent)
|
|
356
|
+
return { quadrant: quadrantOfFlags(important, urgent), important, urgent }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** track 字面量校验。 */
|
|
360
|
+
function normalizeTrack(track, where) {
|
|
361
|
+
if (typeof track !== 'string' || !TRACKS.includes(track)) {
|
|
362
|
+
throw new TodoError(
|
|
363
|
+
`todo.${where}:非法 track=${JSON.stringify(track)}(合法值:${TRACKS.join(' | ')})`,
|
|
364
|
+
'TODO_BAD_TRACK',
|
|
365
|
+
{ field: 'track', where, track },
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
return track
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** status 字面量校验。 */
|
|
372
|
+
function normalizeStatus(status, where) {
|
|
373
|
+
if (typeof status !== 'string' || !STATUSES.includes(status)) {
|
|
374
|
+
throw new TodoError(
|
|
375
|
+
`todo.${where}:非法 status=${JSON.stringify(status)}(合法值:${STATUSES.join(' | ')})`,
|
|
376
|
+
'TODO_BAD_STATUS',
|
|
377
|
+
{ field: 'status', where, status },
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
return status
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 轨 ↔ cwd/date 绑定归一(**track 决定必填项,不允许歧义态**)。
|
|
385
|
+
*
|
|
386
|
+
* · project:cwd 必填(隔离键);date 必须为空(项目待办的"日期"用 due 表达);
|
|
387
|
+
* · daily :date 必填(缺省=今天,本地日历日);cwd 必须为空;
|
|
388
|
+
* · life/work:cwd、date 都必须为空。
|
|
389
|
+
*
|
|
390
|
+
* 换轨时(例如 project → life)旧的 cwd/date 自动清空,避免带着上个轨的
|
|
391
|
+
* 隔离键"串味"。
|
|
392
|
+
*
|
|
393
|
+
* @param {object} input 调用入参
|
|
394
|
+
* @param {object|null} current 当前记录(create 为 null)
|
|
395
|
+
* @param {number} at 本次操作时间(daily 缺省日期由它推出)
|
|
396
|
+
* @param {string} where
|
|
397
|
+
* @returns {{ track: string, cwd: string|null, date: string|null }}
|
|
398
|
+
*/
|
|
399
|
+
function normalizeBinding(input, current, at, where) {
|
|
400
|
+
let track
|
|
401
|
+
if (given(input?.track)) track = normalizeTrack(input.track, where)
|
|
402
|
+
else if (current) track = current.track
|
|
403
|
+
else {
|
|
404
|
+
throw new TodoError(
|
|
405
|
+
`todo.${where}:字段 track 缺失(合法值:${TRACKS.join(' | ')})`,
|
|
406
|
+
'TODO_MISSING_FIELD',
|
|
407
|
+
{ field: 'track', where },
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 换轨后,旧轨的 cwd/date 一律不再沿用(否则会留下"life 却带 cwd"的歧义态)
|
|
412
|
+
const previous = current && current.track === track ? current : null
|
|
413
|
+
|
|
414
|
+
if (track === 'project') {
|
|
415
|
+
const cwd = given(input?.cwd)
|
|
416
|
+
? normalizeCwd(input.cwd, where)
|
|
417
|
+
: (previous?.cwd ?? null)
|
|
418
|
+
if (!cwd) {
|
|
419
|
+
throw new TodoError(
|
|
420
|
+
`todo.${where}:track=project 时必须给 cwd(项目待办按工作目录隔离,否则会串项目),`
|
|
421
|
+
+ `收到 ${JSON.stringify(input?.cwd)}`,
|
|
422
|
+
'TODO_CWD_REQUIRED',
|
|
423
|
+
{ field: 'cwd', where, track },
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
if (given(input?.date)) {
|
|
427
|
+
throw new TodoError(
|
|
428
|
+
`todo.${where}:track=project 不使用 date(收到 ${JSON.stringify(input.date)})——`
|
|
429
|
+
+ '项目待办的日期语义请用 due 表达',
|
|
430
|
+
'TODO_BAD_FIELD',
|
|
431
|
+
{ field: 'date', where, track },
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
return { track, cwd, date: null }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (track === 'daily') {
|
|
438
|
+
if (given(input?.cwd)) {
|
|
439
|
+
throw new TodoError(
|
|
440
|
+
`todo.${where}:track=daily 不使用 cwd(收到 ${JSON.stringify(input.cwd)})——`
|
|
441
|
+
+ '每日待办跨项目共享,它只按 date 归属',
|
|
442
|
+
'TODO_BAD_FIELD',
|
|
443
|
+
{ field: 'cwd', where, track },
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
const date = given(input?.date)
|
|
447
|
+
? parseDateString(input.date, 'date', where)
|
|
448
|
+
: (previous?.date ?? localDate(at))
|
|
449
|
+
return { track, cwd: null, date }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// life / work:不挂 cwd、不挂 date
|
|
453
|
+
if (given(input?.cwd)) {
|
|
454
|
+
throw new TodoError(
|
|
455
|
+
`todo.${where}:track=${track} 不使用 cwd(收到 ${JSON.stringify(input.cwd)})——cwd 只属于 project 轨`,
|
|
456
|
+
'TODO_BAD_FIELD',
|
|
457
|
+
{ field: 'cwd', where, track },
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
if (given(input?.date)) {
|
|
461
|
+
throw new TodoError(
|
|
462
|
+
`todo.${where}:track=${track} 不使用 date(收到 ${JSON.stringify(input.date)})——date 只属于 daily 轨`,
|
|
463
|
+
'TODO_BAD_FIELD',
|
|
464
|
+
{ field: 'date', where, track },
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
return { track, cwd: null, date: null }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 正文归一:非空、≤4000,去掉首尾空白(首行=标题,尾部空白只会污染展示)。
|
|
472
|
+
* 中间的换行与缩进**保留**(多行详情有意义)。
|
|
473
|
+
*/
|
|
474
|
+
function normalizeContent(content, where) {
|
|
475
|
+
requireString(content, 'content', where)
|
|
476
|
+
const text = content.trim()
|
|
477
|
+
if (text === '') {
|
|
478
|
+
throw new TodoError(
|
|
479
|
+
`todo.${where}:content 只有空白,无法作为待办标题`,
|
|
480
|
+
'TODO_MISSING_FIELD',
|
|
481
|
+
{ field: 'content', where },
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
if (text.length > LIMITS.content) {
|
|
485
|
+
throw new TodoError(
|
|
486
|
+
`todo.${where}:content 超长(${text.length} > ${LIMITS.content})——`
|
|
487
|
+
+ '待办是行动项不是文档,超长请拆条或写进便签',
|
|
488
|
+
'TODO_CONTENT_TOO_LONG',
|
|
489
|
+
{ field: 'content', where, length: text.length, max: LIMITS.content },
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
return text
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** due 归一:没给就沿用旧值;''/null 表示清空;否则严格日期。 */
|
|
496
|
+
function normalizeDue(value, current, where) {
|
|
497
|
+
if (value === undefined) return current?.due ?? null
|
|
498
|
+
if (value === null || value === '') return null
|
|
499
|
+
return parseDateString(value, 'due', where)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** cat 归一:没给沿用旧值;''/null 清空;否则 ≤64 的非空分类名。 */
|
|
503
|
+
function normalizeCat(value, current, where) {
|
|
504
|
+
if (value === undefined) return current?.cat ?? null
|
|
505
|
+
if (value === null || value === '') return null
|
|
506
|
+
const s = requireString(value, 'cat', where).trim()
|
|
507
|
+
if (s.length > LIMITS.cat) {
|
|
508
|
+
throw new TodoError(
|
|
509
|
+
`todo.${where}:cat 超长(${s.length} > ${LIMITS.cat})`,
|
|
510
|
+
'TODO_CAT_TOO_LONG',
|
|
511
|
+
{ field: 'cat', where, length: s.length, max: LIMITS.cat },
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
return s
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** CAS 前置校验:expectedRev 必须显式给、且与当前 rev 一致。 */
|
|
518
|
+
function assertExpectedRev(current, expectedRev, where) {
|
|
519
|
+
if (typeof expectedRev !== 'number' || !Number.isInteger(expectedRev)) {
|
|
520
|
+
throw new TodoError(
|
|
521
|
+
`todo.${where}:expectedRev 必须是整数(CAS 需要显式期望版本,条目 ${current.id} 当前 rev=${current.rev},`
|
|
522
|
+
+ `收到 ${JSON.stringify(expectedRev)})`,
|
|
523
|
+
'TODO_MISSING_EXPECTED_REV',
|
|
524
|
+
{ id: current.id, rev: current.rev, where },
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
if (current.rev !== expectedRev) {
|
|
528
|
+
throw new TodoError(
|
|
529
|
+
`todo.${where} CAS 冲突:条目 ${current.id} 期望 rev=${expectedRev},实际 rev=${current.rev} —— `
|
|
530
|
+
+ '拒绝静默覆盖,请重新 getTodo() 后按最新 rev 重试',
|
|
531
|
+
'TODO_REV_CONFLICT',
|
|
532
|
+
{ id: current.id, expectedRev, rev: current.rev, where },
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
|
|
539
|
+
* **不做静默兜底**:两条路都不通时必须让调用方看见。
|
|
540
|
+
*/
|
|
541
|
+
function resolveStateDir(config) {
|
|
542
|
+
if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
|
|
543
|
+
return config.stateDir.trim()
|
|
544
|
+
}
|
|
545
|
+
if (kernel && typeof kernel.stateDir === 'function') {
|
|
546
|
+
return kernel.stateDir()
|
|
547
|
+
}
|
|
548
|
+
const reason = kernelError?.message ?? (kernel ? 'kernel.stateDir 不存在' : 'kernel 未加载')
|
|
549
|
+
throw new TodoError(
|
|
550
|
+
`todo.createTodo:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`
|
|
551
|
+
+ '裸检出(无宿主包)下必须显式传 config.stateDir。',
|
|
552
|
+
'TODO_NO_STATE_DIR',
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* 把 `(ctx, config)` 归一。允许「只传一个 config 对象」的简写(`{ stateDir }`),
|
|
558
|
+
* 因为工具层/测试里手写实例时更顺手;两种写法语义完全一致(与 discipline.js 同款)。
|
|
559
|
+
*/
|
|
560
|
+
function splitArgs(ctx, config) {
|
|
561
|
+
const cfgGiven = config && typeof config === 'object' && Object.keys(config).length > 0
|
|
562
|
+
if (cfgGiven) return { context: ctx ?? {}, config }
|
|
563
|
+
if (ctx && typeof ctx === 'object' && typeof ctx.stateDir === 'string') {
|
|
564
|
+
return { context: {}, config: ctx }
|
|
565
|
+
}
|
|
566
|
+
return { context: ctx ?? {}, config: config ?? {} }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── 工厂 ────────────────────────────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 创建一个待办服务实例。
|
|
573
|
+
*
|
|
574
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [ctx] 宿主上下文(只用于日志,缺失即降级为 no-op)
|
|
575
|
+
* @param {{ stateDir?: string, name?: string }} [config]
|
|
576
|
+
* @returns {object} todo service
|
|
577
|
+
*/
|
|
578
|
+
export function createTodo(ctx, config = {}) {
|
|
579
|
+
const { context, config: cfg } = splitArgs(ctx, config)
|
|
580
|
+
const stateDir = resolveStateDir(cfg)
|
|
581
|
+
|
|
582
|
+
const jsonlPath = statePath(stateDir, 'todo.jsonl')
|
|
583
|
+
const snapPath = statePath(stateDir, 'todo.snap.json')
|
|
584
|
+
|
|
585
|
+
const info = (...args) => { try { context?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
|
|
586
|
+
const warn = (...args) => { try { context?.logger?.warn?.(...args) } catch { /* 同上 */ } }
|
|
587
|
+
|
|
588
|
+
/** @type {Map<string, object>} id → Todo(插入序 = 创建序) */
|
|
589
|
+
const todos = new Map()
|
|
590
|
+
/** @type {object[]} 全部事件(审计流,按落盘顺序) */
|
|
591
|
+
const events = []
|
|
592
|
+
/** @type {Map<string, number>} id → 已见到的最新 rev(回放幂等用) */
|
|
593
|
+
const lastRev = new Map()
|
|
594
|
+
/** 曾出现过的全部 id(含已删除)——随机 id 绝不能重复使用,否则历史事件会串号 */
|
|
595
|
+
const seenIds = new Set()
|
|
596
|
+
|
|
597
|
+
let disposed = false
|
|
598
|
+
|
|
599
|
+
const diagnostics = {
|
|
600
|
+
stateDir,
|
|
601
|
+
jsonlPath,
|
|
602
|
+
snapPath,
|
|
603
|
+
kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
|
|
604
|
+
hydratedFrom: /** @type {null|'jsonl'|'snapshot'|'empty'} */ (null),
|
|
605
|
+
replayedEvents: 0,
|
|
606
|
+
badLines: 0,
|
|
607
|
+
snapshotWriteFailures: 0,
|
|
608
|
+
auditWriteFailures: 0,
|
|
609
|
+
auditWarnedOnce: false,
|
|
610
|
+
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** 已销毁实例的调用一律抛错:静默返回空数据比崩掉更难查。 */
|
|
614
|
+
function assertAlive(where) {
|
|
615
|
+
if (disposed) {
|
|
616
|
+
throw new TodoError(
|
|
617
|
+
`todo.${where}:实例已 dispose(),请重新 createTodo()(拒绝在已销毁实例上静默返回空数据)`,
|
|
618
|
+
'TODO_DISPOSED',
|
|
619
|
+
{ where },
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ── 持久化 ────────────────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
/** 快照内容(落盘形态)。 */
|
|
627
|
+
const snapshot = () => ({
|
|
628
|
+
version: 1,
|
|
629
|
+
savedAt: Date.now(),
|
|
630
|
+
todos: [...todos.values()],
|
|
631
|
+
seenIds: [...seenIds],
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* 提交一次变更:**先落事件,再改内存,最后写快照**。
|
|
636
|
+
*
|
|
637
|
+
* 顺序不可颠倒:append 失败时内存必须原封不动(否则内存与磁盘分叉,
|
|
638
|
+
* 重启回放后状态凭空回退 —— 这是最恶劣的静默失败)。
|
|
639
|
+
*
|
|
640
|
+
* @param {string} todoId
|
|
641
|
+
* @param {'create'|'update'|'remove'} action
|
|
642
|
+
* @param {string|null} byId
|
|
643
|
+
* @param {number} at
|
|
644
|
+
* @param {object|null} before 变更前记录(create 为 null)
|
|
645
|
+
* @param {object|null} after 变更后记录(remove 为 null)
|
|
646
|
+
* @returns {object|null} 克隆后的 after
|
|
647
|
+
*/
|
|
648
|
+
function commit(todoId, action, byId, at, before, after) {
|
|
649
|
+
/** @type {Record<string, unknown>} */
|
|
650
|
+
const event = {
|
|
651
|
+
todoId,
|
|
652
|
+
rev: after ? after.rev : ((before?.rev ?? 0) + 1),
|
|
653
|
+
action,
|
|
654
|
+
byId: byId ?? null,
|
|
655
|
+
at,
|
|
656
|
+
before: before ? clone(before) : null,
|
|
657
|
+
after: after ? clone(after) : null,
|
|
658
|
+
}
|
|
659
|
+
if (!appendJsonl(jsonlPath, event)) {
|
|
660
|
+
throw new TodoError(
|
|
661
|
+
`todo.${action}:事件追加失败(${jsonlPath})——拒绝「只改内存不落盘」`,
|
|
662
|
+
'TODO_APPEND_FAILED',
|
|
663
|
+
{ id: todoId, action, path: jsonlPath },
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
seenIds.add(todoId)
|
|
667
|
+
if (after) {
|
|
668
|
+
todos.set(todoId, clone(after))
|
|
669
|
+
lastRev.set(todoId, after.rev)
|
|
670
|
+
} else {
|
|
671
|
+
todos.delete(todoId)
|
|
672
|
+
lastRev.set(todoId, event.rev)
|
|
673
|
+
}
|
|
674
|
+
events.push(event)
|
|
675
|
+
if (!writeJsonAtomic(snapPath, snapshot())) {
|
|
676
|
+
diagnostics.snapshotWriteFailures += 1
|
|
677
|
+
warn(`[cs-bots/todo] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
|
|
678
|
+
}
|
|
679
|
+
// ── 审计接线(P1-D §4)────────────────────────────────────────────────
|
|
680
|
+
// 软用 store.appendAudit:拿不到/写失败 → 只 warn 一次 + 计数,绝不阻断主流程。
|
|
681
|
+
if (storeAuditMissing) {
|
|
682
|
+
diagnostics.auditWriteFailures += 1
|
|
683
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
684
|
+
diagnostics.auditWarnedOnce = true
|
|
685
|
+
warn('[cs-bots/todo] store.appendAudit 尚不可用 → 审计接线跳过;主流程不受影响')
|
|
686
|
+
}
|
|
687
|
+
} else {
|
|
688
|
+
try {
|
|
689
|
+
const digest = (t) => (t
|
|
690
|
+
? { id: t.id, track: t.track ?? null, rev: t.rev, status: t.status ?? null }
|
|
691
|
+
: null)
|
|
692
|
+
const result = storeAppendAudit(stateDir, {
|
|
693
|
+
ts: at, actor: byId, action, target: todoId, before: digest(before), after: digest(after),
|
|
694
|
+
})
|
|
695
|
+
const failed = result === false || Boolean(result && typeof result === 'object' && result.ok === false)
|
|
696
|
+
if (failed) {
|
|
697
|
+
diagnostics.auditWriteFailures += 1
|
|
698
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
699
|
+
diagnostics.auditWarnedOnce = true
|
|
700
|
+
warn('[cs-bots/todo] store.appendAudit 返回失败——主流程不受影响')
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} catch (error) {
|
|
704
|
+
diagnostics.auditWriteFailures += 1
|
|
705
|
+
if (!diagnostics.auditWarnedOnce) {
|
|
706
|
+
diagnostics.auditWarnedOnce = true
|
|
707
|
+
warn(`[cs-bots/todo] store.appendAudit 抛错:${error?.message ?? error}——主流程不受影响`)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return after ? clone(after) : null
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ── 回放 ──────────────────────────────────────────────────────────────────
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* 旧版本/半损坏记录补齐字段(向后兼容,不改语义)。
|
|
718
|
+
* 快照可能来自更早的版本;缺字段时补默认值,而不是让消费方拿到 undefined。
|
|
719
|
+
* @param {object} raw
|
|
720
|
+
* @returns {object|null}
|
|
721
|
+
*/
|
|
722
|
+
function normalizeStoredRecord(raw) {
|
|
723
|
+
if (!raw || typeof raw.id !== 'string' || raw.id.trim() === '') return null
|
|
724
|
+
const record = clone(raw)
|
|
725
|
+
if (!TRACKS.includes(record.track)) return null
|
|
726
|
+
if (!STATUSES.includes(record.status)) record.status = 'pending'
|
|
727
|
+
if (typeof record.content !== 'string') return null
|
|
728
|
+
if (record.track === 'project') {
|
|
729
|
+
if (typeof record.cwd !== 'string' || record.cwd === '') return null
|
|
730
|
+
record.date = null
|
|
731
|
+
} else if (record.track === 'daily') {
|
|
732
|
+
if (typeof record.date !== 'string') return null
|
|
733
|
+
record.cwd = null
|
|
734
|
+
} else {
|
|
735
|
+
record.cwd = null
|
|
736
|
+
record.date = null
|
|
737
|
+
}
|
|
738
|
+
if (!QUADRANTS.includes(record.quadrant)) {
|
|
739
|
+
record.quadrant = quadrantOfFlags(Boolean(record.important), Boolean(record.urgent))
|
|
740
|
+
}
|
|
741
|
+
const flags = flagsOfQuadrant(record.quadrant)
|
|
742
|
+
record.important = flags.important
|
|
743
|
+
record.urgent = flags.urgent
|
|
744
|
+
record.due = typeof record.due === 'string' && DATE_RE.test(record.due) ? record.due : null
|
|
745
|
+
record.cat = typeof record.cat === 'string' && record.cat !== '' ? record.cat : null
|
|
746
|
+
record.doneAt = record.status === 'done' && Number.isFinite(record.doneAt) ? record.doneAt : null
|
|
747
|
+
record.rev = Number.isFinite(record.rev) ? record.rev : 1
|
|
748
|
+
record.createdAt = Number.isFinite(record.createdAt) ? record.createdAt : 0
|
|
749
|
+
record.updatedAt = Number.isFinite(record.updatedAt) ? record.updatedAt : record.createdAt
|
|
750
|
+
if (typeof record.createdBy !== 'string') record.createdBy = null
|
|
751
|
+
if (typeof record.updatedBy !== 'string') record.updatedBy = null
|
|
752
|
+
return record
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* 从 `todo.jsonl`(真相源)重建内存态;jsonl 为空才回落到快照。
|
|
757
|
+
* 坏行**不静默丢弃**:计入 diagnostics 并 warn。
|
|
758
|
+
*/
|
|
759
|
+
function hydrate() {
|
|
760
|
+
todos.clear()
|
|
761
|
+
events.length = 0
|
|
762
|
+
lastRev.clear()
|
|
763
|
+
seenIds.clear()
|
|
764
|
+
disposed = false
|
|
765
|
+
|
|
766
|
+
const { rows, bad } = readJsonl(jsonlPath)
|
|
767
|
+
diagnostics.badLines = bad
|
|
768
|
+
diagnostics.replayedEvents = rows.length
|
|
769
|
+
if (bad > 0) {
|
|
770
|
+
warn(`[cs-bots/todo] ${jsonlPath} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (rows.length === 0) {
|
|
774
|
+
const snap = readJson(snapPath, null)
|
|
775
|
+
const list = Array.isArray(snap?.todos) ? snap.todos : null
|
|
776
|
+
if (list) {
|
|
777
|
+
for (const raw of list) {
|
|
778
|
+
const record = normalizeStoredRecord(raw)
|
|
779
|
+
if (!record) continue
|
|
780
|
+
todos.set(record.id, record)
|
|
781
|
+
lastRev.set(record.id, record.rev)
|
|
782
|
+
seenIds.add(record.id)
|
|
783
|
+
}
|
|
784
|
+
if (Array.isArray(snap?.seenIds)) {
|
|
785
|
+
for (const id of snap.seenIds) if (typeof id === 'string' && id) seenIds.add(id)
|
|
786
|
+
}
|
|
787
|
+
diagnostics.hydratedFrom = 'snapshot'
|
|
788
|
+
info(`[cs-bots/todo] 回放完成:无事件流 → 由快照恢复 ${todos.size} 条待办`)
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
diagnostics.hydratedFrom = 'empty'
|
|
792
|
+
info('[cs-bots/todo] 回放完成:空库(无事件流、无快照)')
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// 事件流是真相源:只接受 rev 递增的事件(同 rev 重放幂等,容忍重复投递)
|
|
797
|
+
let skipped = 0
|
|
798
|
+
for (const ev of rows) {
|
|
799
|
+
const id = typeof ev?.todoId === 'string' ? ev.todoId : ev?.after?.id
|
|
800
|
+
if (typeof id !== 'string' || id === '') {
|
|
801
|
+
skipped += 1
|
|
802
|
+
continue
|
|
803
|
+
}
|
|
804
|
+
// 即便事件因非递增被跳过,也记下这个 id,防止随机 id 复用造成历史串号
|
|
805
|
+
seenIds.add(id)
|
|
806
|
+
const rev = Number.isFinite(ev?.rev) ? ev.rev : undefined
|
|
807
|
+
const current = lastRev.get(id) ?? 0
|
|
808
|
+
if (rev !== undefined && rev <= current) {
|
|
809
|
+
skipped += 1
|
|
810
|
+
continue
|
|
811
|
+
}
|
|
812
|
+
if (ev.after && ev.after.id === id) {
|
|
813
|
+
const record = normalizeStoredRecord(ev.after)
|
|
814
|
+
if (!record) {
|
|
815
|
+
skipped += 1
|
|
816
|
+
continue
|
|
817
|
+
}
|
|
818
|
+
todos.set(id, record)
|
|
819
|
+
lastRev.set(id, rev ?? record.rev)
|
|
820
|
+
} else if (ev.action === 'remove') {
|
|
821
|
+
todos.delete(id)
|
|
822
|
+
lastRev.set(id, rev ?? current + 1)
|
|
823
|
+
} else {
|
|
824
|
+
skipped += 1
|
|
825
|
+
continue
|
|
826
|
+
}
|
|
827
|
+
events.push(clone(ev))
|
|
828
|
+
}
|
|
829
|
+
if (skipped > 0) {
|
|
830
|
+
warn(`[cs-bots/todo] 回放跳过 ${skipped} 条非递增/残缺事件(已保留在文件中,未被静默丢弃)`)
|
|
831
|
+
}
|
|
832
|
+
diagnostics.hydratedFrom = 'jsonl'
|
|
833
|
+
info(`[cs-bots/todo] 回放完成:${rows.length} 条事件 → ${todos.size} 条待办`)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ── 内部查询工具 ──────────────────────────────────────────────────────────
|
|
837
|
+
|
|
838
|
+
/** 取记录或抛错。 */
|
|
839
|
+
function mustGet(id, where) {
|
|
840
|
+
const current = todos.get(id)
|
|
841
|
+
if (!current) {
|
|
842
|
+
throw new TodoError(
|
|
843
|
+
`todo.${where}:条目不存在 id=${id}`,
|
|
844
|
+
'TODO_NOT_FOUND',
|
|
845
|
+
{ id, where },
|
|
846
|
+
)
|
|
847
|
+
}
|
|
848
|
+
return current
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* 生成随机 id:`td-<8 hex>`。
|
|
853
|
+
*
|
|
854
|
+
* 为什么不像 board 那样用自增序号:待办是多端/多 Bot 高频写入的数据,
|
|
855
|
+
* 自增序号要求"读全局最大值再写",一旦两个实例并发就会撞号;
|
|
856
|
+
* 随机 8 hex(4 字节)碰撞概率可忽略,且撞上时这里会重试 8 次后明确抛错。
|
|
857
|
+
*/
|
|
858
|
+
function nextId() {
|
|
859
|
+
for (let i = 0; i < 8; i += 1) {
|
|
860
|
+
const candidate = `td-${randomBytes(4).toString('hex')}`
|
|
861
|
+
if (!seenIds.has(candidate)) return candidate
|
|
862
|
+
}
|
|
863
|
+
throw new TodoError(
|
|
864
|
+
'todo.create:连续 8 次生成随机 id 均与既有条目碰撞(状态库异常?)——拒绝写入以保证 id 唯一',
|
|
865
|
+
'TODO_ID_COLLISION',
|
|
866
|
+
)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** 是否已结束(done / cancelled)。 */
|
|
870
|
+
const isFinished = (t) => t.status === 'done' || t.status === 'cancelled'
|
|
871
|
+
/** 是否"需要关注"的未完成状态。 */
|
|
872
|
+
const isActive = (t) => ACTIVE_STATUSES.includes(t.status)
|
|
873
|
+
/** 逾期:未完成且 due 早于今天(字符串比较即可,YYYY-MM-DD 字典序=时间序)。 */
|
|
874
|
+
const isOverdue = (t, today) => isActive(t) && t.due !== null && t.due < today
|
|
875
|
+
/** 今日到期。 */
|
|
876
|
+
const isDueToday = (t, today) => isActive(t) && t.due === today
|
|
877
|
+
/** 过往每日待办(date < 今天)。 */
|
|
878
|
+
const isPastDaily = (t, today) => t.track === 'daily' && t.date !== null && t.date < today
|
|
879
|
+
/**
|
|
880
|
+
* 过往每日待办的"过期遗留":未结束,且没有 due 或 due 已过。
|
|
881
|
+
* 没有 due 的过往每日待办,其截止日就是所属那一天 → 必然过期。
|
|
882
|
+
*/
|
|
883
|
+
const isPastExpired = (t, today) => isPastDaily(t, today) && !isFinished(t)
|
|
884
|
+
&& (t.due === null || t.due < today)
|
|
885
|
+
|
|
886
|
+
// ── API:校验后写入 ────────────────────────────────────────────────────────
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* 新建待办。
|
|
890
|
+
*
|
|
891
|
+
* @param {{ track: string, cwd?: string, date?: string, content: string,
|
|
892
|
+
* quadrant?: string, important?: boolean, urgent?: boolean,
|
|
893
|
+
* due?: string|null, cat?: string|null, status?: string,
|
|
894
|
+
* by: string, now?: number }} input
|
|
895
|
+
* @returns {object} Todo
|
|
896
|
+
*/
|
|
897
|
+
function createTodo(input = {}) {
|
|
898
|
+
assertAlive('createTodo')
|
|
899
|
+
const byId = requireActor(input, 'create')
|
|
900
|
+
const at = normalizeNow(input?.now, 'create')
|
|
901
|
+
|
|
902
|
+
// 先做完所有校验再生成 id:校验失败绝不留下"幽灵 id"占用唯一性
|
|
903
|
+
const binding = normalizeBinding(input, null, at, 'create')
|
|
904
|
+
const content = normalizeContent(input?.content, 'create')
|
|
905
|
+
const quadrantFields = normalizeQuadrantFields(input, null, 'create')
|
|
906
|
+
const due = normalizeDue(input.due, null, 'create')
|
|
907
|
+
const cat = normalizeCat(input.cat, null, 'create')
|
|
908
|
+
// status 缺省 pending;但一旦给了就必须是合法值(给 ''/'all' 是调用方 bug,
|
|
909
|
+
// 不能悄悄当成缺省 —— 那会让「拼错的写入」看起来成功了)
|
|
910
|
+
const status = (input?.status === undefined || input?.status === null)
|
|
911
|
+
? 'pending'
|
|
912
|
+
: normalizeStatus(input.status, 'create')
|
|
913
|
+
|
|
914
|
+
const id = nextId()
|
|
915
|
+
/** @type {Record<string, unknown>} */
|
|
916
|
+
const record = {
|
|
917
|
+
id,
|
|
918
|
+
rev: 1,
|
|
919
|
+
track: binding.track,
|
|
920
|
+
cwd: binding.cwd,
|
|
921
|
+
date: binding.date,
|
|
922
|
+
content,
|
|
923
|
+
quadrant: quadrantFields.quadrant,
|
|
924
|
+
important: quadrantFields.important,
|
|
925
|
+
urgent: quadrantFields.urgent,
|
|
926
|
+
due,
|
|
927
|
+
cat,
|
|
928
|
+
status,
|
|
929
|
+
// 创建即已完成时也要盖 doneAt,否则"完成时间"缺失会让统计口径分叉
|
|
930
|
+
doneAt: status === 'done' ? at : null,
|
|
931
|
+
createdAt: at,
|
|
932
|
+
createdBy: byId,
|
|
933
|
+
updatedAt: at,
|
|
934
|
+
updatedBy: byId,
|
|
935
|
+
}
|
|
936
|
+
return commit(id, 'create', byId, at, null, record)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* 更新待办(字段补丁 + CAS)。
|
|
941
|
+
*
|
|
942
|
+
* @param {{ id: string, expectedRev: number, track?: string, cwd?: string, date?: string,
|
|
943
|
+
* content?: string, quadrant?: string, important?: boolean, urgent?: boolean,
|
|
944
|
+
* due?: string|null, cat?: string|null, status?: string,
|
|
945
|
+
* by: string, now?: number }} input
|
|
946
|
+
* @returns {object} 更新后的 Todo
|
|
947
|
+
*/
|
|
948
|
+
function updateTodo(input = {}) {
|
|
949
|
+
assertAlive('updateTodo')
|
|
950
|
+
const id = requireId(input?.id, 'update')
|
|
951
|
+
const current = mustGet(id, 'update')
|
|
952
|
+
const byId = requireActor(input, 'update')
|
|
953
|
+
const at = normalizeNow(input?.now, 'update')
|
|
954
|
+
// CAS 必须在任何字段归一/变更之前:conflict 时连"部分校验通过"的假象都不该出现
|
|
955
|
+
assertExpectedRev(current, input?.expectedRev, 'update')
|
|
956
|
+
|
|
957
|
+
const touched = PATCH_FIELDS.filter((field) => input?.[field] !== undefined)
|
|
958
|
+
if (touched.length === 0) {
|
|
959
|
+
throw new TodoError(
|
|
960
|
+
`todo.update:没有任何可更新字段(可更新:${PATCH_FIELDS.join(' / ')})——拒绝产生一条无意义的 rev 递增`,
|
|
961
|
+
'TODO_EMPTY_UPDATE',
|
|
962
|
+
{ id, where: 'update' },
|
|
963
|
+
)
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const binding = normalizeBinding(input, current, at, 'update')
|
|
967
|
+
const quadrantFields = normalizeQuadrantFields(input, current, 'update')
|
|
968
|
+
|
|
969
|
+
/** @type {Record<string, unknown>} */
|
|
970
|
+
const next = clone(current)
|
|
971
|
+
next.track = binding.track
|
|
972
|
+
next.cwd = binding.cwd
|
|
973
|
+
next.date = binding.date
|
|
974
|
+
if (input?.content !== undefined) next.content = normalizeContent(input.content, 'update')
|
|
975
|
+
next.quadrant = quadrantFields.quadrant
|
|
976
|
+
next.important = quadrantFields.important
|
|
977
|
+
next.urgent = quadrantFields.urgent
|
|
978
|
+
next.due = normalizeDue(input?.due, current, 'update')
|
|
979
|
+
next.cat = normalizeCat(input?.cat, current, 'update')
|
|
980
|
+
|
|
981
|
+
if (input?.status !== undefined && input?.status !== null) {
|
|
982
|
+
const status = normalizeStatus(input.status, 'update')
|
|
983
|
+
if (status !== current.status) {
|
|
984
|
+
next.status = status
|
|
985
|
+
// 进入 done 时盖章;离开 done(含 done→cancelled)清空,不留过期时间戳
|
|
986
|
+
next.doneAt = status === 'done' ? at : null
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
next.rev = current.rev + 1
|
|
991
|
+
next.updatedAt = at
|
|
992
|
+
next.updatedBy = byId
|
|
993
|
+
return commit(id, 'update', byId, at, current, next)
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* 删除待办(硬删,事件流里保留 before 快照可审计)。
|
|
998
|
+
*
|
|
999
|
+
* @param {{ id: string, expectedRev: number, by: string }} input
|
|
1000
|
+
* @returns {{ id: string, rev: number, removed: true }}
|
|
1001
|
+
*/
|
|
1002
|
+
function removeTodo(input = {}) {
|
|
1003
|
+
assertAlive('removeTodo')
|
|
1004
|
+
const id = requireId(input?.id, 'remove')
|
|
1005
|
+
const current = mustGet(id, 'remove')
|
|
1006
|
+
const byId = requireActor(input, 'remove')
|
|
1007
|
+
const at = normalizeNow(input?.now, 'remove')
|
|
1008
|
+
assertExpectedRev(current, input?.expectedRev, 'remove')
|
|
1009
|
+
commit(id, 'remove', byId, at, current, null)
|
|
1010
|
+
return { id, rev: current.rev + 1, removed: true }
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* 取单条(不存在返回 null,不抛 —— "查不到"是正常结果,不是错误)。
|
|
1015
|
+
* @param {string} id
|
|
1016
|
+
* @returns {object|null}
|
|
1017
|
+
*/
|
|
1018
|
+
function getTodo(id) {
|
|
1019
|
+
assertAlive('getTodo')
|
|
1020
|
+
if (typeof id !== 'string' || id.trim() === '') return null
|
|
1021
|
+
const current = todos.get(id.trim())
|
|
1022
|
+
return current ? clone(current) : null
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* 列出待办(支持智能视图)。
|
|
1027
|
+
*
|
|
1028
|
+
* 视图规则(**严格实现,勿简化**):
|
|
1029
|
+
* 1. 缺省 = 智能视图:只列需要关注的未完成项
|
|
1030
|
+
* (逾期 / 今日到期 / 本项目未完成 / life|work 的重要紧急 q1q2 / 今日每日待办),
|
|
1031
|
+
* 排序「逾期 → 今日到期 → 本项目 → q1 → q2 → q3 → q4」,最多 8 条。
|
|
1032
|
+
* 2. `status:'done'` 只看已完成;`all:true` 关掉智能过滤与 8 条上限(看全部状态)。
|
|
1033
|
+
* 3. 过往每日待办(date < 今天)默认隐藏;`past:true` 才纳入,
|
|
1034
|
+
* 且其中「未完成且过期」的遗留还要 `expired:true` 才出现(否则返回 hint 提醒)。
|
|
1035
|
+
* 4. project 轨按 cwd 隔离:带 cwd 只返回该目录的;不带 cwd 时 project 整体不出现
|
|
1036
|
+
* (跨项目零泄露),显式 `track:'project'` 却不说 cwd → 抛错。
|
|
1037
|
+
*
|
|
1038
|
+
* @param {{ track?: string, cwd?: string, date?: string, status?: string,
|
|
1039
|
+
* all?: boolean, past?: boolean, expired?: boolean, query?: string,
|
|
1040
|
+
* limit?: number, now?: number }} [options]
|
|
1041
|
+
* @returns {{ items: object[], total: number, truncated: boolean, defaultView: boolean,
|
|
1042
|
+
* today: string, hint: string|null }}
|
|
1043
|
+
*/
|
|
1044
|
+
function listTodos(options = {}) {
|
|
1045
|
+
assertAlive('listTodos')
|
|
1046
|
+
const now = normalizeNow(options?.now, 'listTodos')
|
|
1047
|
+
const today = localDate(now)
|
|
1048
|
+
|
|
1049
|
+
const track = given(options?.track) ? normalizeTrack(options.track, 'list') : undefined
|
|
1050
|
+
// `status:'all'` 是"我只想显式查询、不要智能视图"的开关(语义上不过滤状态);
|
|
1051
|
+
// 因此必须单独记住"调用方到底给没给 status",否则它会退化成缺省智能视图。
|
|
1052
|
+
const statusGiven = given(options?.status)
|
|
1053
|
+
const status = statusGiven && options.status !== 'all'
|
|
1054
|
+
? normalizeStatus(options.status, 'list')
|
|
1055
|
+
: undefined
|
|
1056
|
+
const cwd = given(options?.cwd) ? normalizeCwd(options.cwd, 'list') : undefined
|
|
1057
|
+
const date = given(options?.date) ? parseDateString(options.date, 'date', 'list') : undefined
|
|
1058
|
+
const query = typeof options?.query === 'string' && options.query.trim() !== ''
|
|
1059
|
+
? options.query.trim().toLowerCase()
|
|
1060
|
+
: undefined
|
|
1061
|
+
const all = options?.all === true
|
|
1062
|
+
const past = options?.past === true
|
|
1063
|
+
const expired = options?.expired === true
|
|
1064
|
+
|
|
1065
|
+
let limit
|
|
1066
|
+
if (options?.limit !== undefined && options?.limit !== null) {
|
|
1067
|
+
if (!Number.isInteger(options.limit) || options.limit <= 0) {
|
|
1068
|
+
throw new TodoError(
|
|
1069
|
+
`todo.list:limit 必须是正整数(收到 ${JSON.stringify(options.limit)})`,
|
|
1070
|
+
'TODO_BAD_FIELD',
|
|
1071
|
+
{ field: 'limit', where: 'list' },
|
|
1072
|
+
)
|
|
1073
|
+
}
|
|
1074
|
+
limit = options.limit
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// 显式 track:'project' 却不给 cwd:无法确定哪个项目,属于调用错误,不猜
|
|
1078
|
+
if (track === 'project' && cwd === undefined) {
|
|
1079
|
+
throw new TodoError(
|
|
1080
|
+
'todo.list:track=project 时必须给 cwd(项目待办按工作目录隔离,'
|
|
1081
|
+
+ '不指定工作目录就无法判断该看哪个项目 —— 拒绝跨项目汇总泄露)',
|
|
1082
|
+
'TODO_CWD_REQUIRED',
|
|
1083
|
+
{ field: 'cwd', where: 'list' },
|
|
1084
|
+
)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// 智能视图 = 没给任何"收窄意图"的参数;cwd 只是上下文(项目优先级),不算收窄
|
|
1088
|
+
const defaultView = !all && !past && track === undefined && !statusGiven
|
|
1089
|
+
&& date === undefined && query === undefined
|
|
1090
|
+
|
|
1091
|
+
/** 1) 范围过滤:轨绑定 + cwd 隔离 + 日期/历史窗口。 */
|
|
1092
|
+
const inScope = (t) => {
|
|
1093
|
+
if (track !== undefined && t.track !== track) return false
|
|
1094
|
+
if (t.track === 'project') {
|
|
1095
|
+
// 没有 cwd 的查询看不到项目待办(跨项目零泄露)
|
|
1096
|
+
if (cwd === undefined) return false
|
|
1097
|
+
if (t.cwd !== cwd) return false
|
|
1098
|
+
}
|
|
1099
|
+
if (t.track === 'daily') {
|
|
1100
|
+
if (date !== undefined) return t.date === date
|
|
1101
|
+
if (isPastDaily(t, today)) {
|
|
1102
|
+
// 过往每日待办:默认不看历史;past:true 才纳入,过期遗留还要 expired:true
|
|
1103
|
+
if (!past) return false
|
|
1104
|
+
if (!expired && isPastExpired(t, today)) return false
|
|
1105
|
+
}
|
|
1106
|
+
return true
|
|
1107
|
+
}
|
|
1108
|
+
// life / work 没有 date 字段;一旦按 date 查询,它们不匹配
|
|
1109
|
+
return date === undefined
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** 2) 状态 / 关键词过滤。 */
|
|
1113
|
+
const matches = (t) => {
|
|
1114
|
+
if (status !== undefined && t.status !== status) return false
|
|
1115
|
+
if (query !== undefined && !t.content.toLowerCase().includes(query)) return false
|
|
1116
|
+
return true
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** 3) 智能视图的"需要关注"过滤(只对缺省视图生效)。 */
|
|
1120
|
+
const needsAttention = (t) => {
|
|
1121
|
+
if (!isActive(t)) return false
|
|
1122
|
+
if (isOverdue(t, today) || isDueToday(t, today)) return true
|
|
1123
|
+
if (t.track === 'project') return true // cwd 已在 inScope 里匹配过
|
|
1124
|
+
if ((t.track === 'life' || t.track === 'work')
|
|
1125
|
+
&& (t.quadrant === 'q1' || t.quadrant === 'q2')) return true
|
|
1126
|
+
if (t.track === 'daily') return true // 今天(或未来)的每日待办
|
|
1127
|
+
return false
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
let selected = []
|
|
1131
|
+
for (const t of todos.values()) {
|
|
1132
|
+
if (!inScope(t) || !matches(t)) continue
|
|
1133
|
+
if (defaultView && !needsAttention(t)) continue
|
|
1134
|
+
selected.push(t)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** 4) 排序:逾期 → 今日到期 → 本项目 → q1 → q2 → q3 → q4 → 无象限 → 过往(垫底)。 */
|
|
1138
|
+
const rank = (t) => {
|
|
1139
|
+
if (isPastDaily(t, today)) return 9
|
|
1140
|
+
if (isOverdue(t, today)) return 0
|
|
1141
|
+
if (isDueToday(t, today)) return 1
|
|
1142
|
+
if (t.track === 'project') return 2
|
|
1143
|
+
if (t.quadrant === 'q1') return 3
|
|
1144
|
+
if (t.quadrant === 'q2') return 4
|
|
1145
|
+
if (t.quadrant === 'q3') return 5
|
|
1146
|
+
if (t.quadrant === 'q4') return 6
|
|
1147
|
+
return 7
|
|
1148
|
+
}
|
|
1149
|
+
selected.sort((a, b) => {
|
|
1150
|
+
const ra = rank(a)
|
|
1151
|
+
const rb = rank(b)
|
|
1152
|
+
if (ra !== rb) return ra - rb
|
|
1153
|
+
const pa = isPastDaily(a, today)
|
|
1154
|
+
const pb = isPastDaily(b, today)
|
|
1155
|
+
if (pa && pb) {
|
|
1156
|
+
// 历史:新的一天在前,同一天内新添加的在前
|
|
1157
|
+
if (a.date !== b.date) return a.date < b.date ? 1 : -1
|
|
1158
|
+
if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt
|
|
1159
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
1160
|
+
}
|
|
1161
|
+
// due:有截止的在前、早的在前;无截止的垫底('' 会让"没有截止"排最前,是反的)
|
|
1162
|
+
if (a.due !== b.due) {
|
|
1163
|
+
if (a.due === null) return 1
|
|
1164
|
+
if (b.due === null) return -1
|
|
1165
|
+
return a.due < b.due ? -1 : 1
|
|
1166
|
+
}
|
|
1167
|
+
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt
|
|
1168
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
const total = selected.length
|
|
1172
|
+
// 8 条上限只约束"智能视图";显式查询(track/status/past/all)默认全给,除非调用方自己传 limit
|
|
1173
|
+
let cap = defaultView ? SMART_VIEW_LIMIT : Infinity
|
|
1174
|
+
if (limit !== undefined) cap = Math.min(cap, limit)
|
|
1175
|
+
const items = selected.slice(0, cap).map((t) => clone(t))
|
|
1176
|
+
|
|
1177
|
+
// 真实踩坑点:只带 past 不带 expired 会以为"昨天没待办",这里显式点出来
|
|
1178
|
+
const hint = past && !expired
|
|
1179
|
+
? '提示:过往每日待办中「未完成且已过期」的遗留默认隐藏;要查遗留任务请再调一次 list 并加 expired=true'
|
|
1180
|
+
: null
|
|
1181
|
+
|
|
1182
|
+
return {
|
|
1183
|
+
items,
|
|
1184
|
+
total,
|
|
1185
|
+
truncated: total > items.length,
|
|
1186
|
+
defaultView,
|
|
1187
|
+
today,
|
|
1188
|
+
hint,
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// ── 统计 / 审计 / 生命周期 ─────────────────────────────────────────────────
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* 统计(给 /status 与 UI 概览用)。
|
|
1196
|
+
* @returns {{ total: number, active: number, done: number, overdue: number, dueToday: number,
|
|
1197
|
+
* byTrack: object, byStatus: object, byQuadrant: object, events: number }}
|
|
1198
|
+
*/
|
|
1199
|
+
function stats() {
|
|
1200
|
+
assertAlive('stats')
|
|
1201
|
+
const today = localDate(Date.now())
|
|
1202
|
+
const byTrack = { life: 0, work: 0, project: 0, daily: 0 }
|
|
1203
|
+
const byStatus = { pending: 0, doing: 0, done: 0, blocked: 0, cancelled: 0 }
|
|
1204
|
+
const byQuadrant = { q1: 0, q2: 0, q3: 0, q4: 0 }
|
|
1205
|
+
let active = 0
|
|
1206
|
+
let done = 0
|
|
1207
|
+
let overdue = 0
|
|
1208
|
+
let dueToday = 0
|
|
1209
|
+
for (const t of todos.values()) {
|
|
1210
|
+
byTrack[t.track] = (byTrack[t.track] ?? 0) + 1
|
|
1211
|
+
byStatus[t.status] = (byStatus[t.status] ?? 0) + 1
|
|
1212
|
+
byQuadrant[t.quadrant] = (byQuadrant[t.quadrant] ?? 0) + 1
|
|
1213
|
+
if (t.status === 'done') done += 1
|
|
1214
|
+
if (isFinished(t)) continue
|
|
1215
|
+
active += 1
|
|
1216
|
+
if (isOverdue(t, today)) overdue += 1
|
|
1217
|
+
else if (isDueToday(t, today)) dueToday += 1
|
|
1218
|
+
}
|
|
1219
|
+
return {
|
|
1220
|
+
total: todos.size,
|
|
1221
|
+
active,
|
|
1222
|
+
done,
|
|
1223
|
+
overdue,
|
|
1224
|
+
dueToday,
|
|
1225
|
+
byTrack,
|
|
1226
|
+
byStatus,
|
|
1227
|
+
byQuadrant,
|
|
1228
|
+
events: events.length,
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* 审计流查询。
|
|
1234
|
+
* @param {string} [id] 省略则返回全部事件
|
|
1235
|
+
* @returns {object[]}
|
|
1236
|
+
*/
|
|
1237
|
+
function history(id) {
|
|
1238
|
+
assertAlive('history')
|
|
1239
|
+
const rows = typeof id === 'string' && id !== ''
|
|
1240
|
+
? events.filter((e) => e.todoId === id)
|
|
1241
|
+
: events
|
|
1242
|
+
return clone(rows)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* 从 `todo.jsonl` 重建内存态(重启回放一致性)。
|
|
1247
|
+
* @returns {{ from: string, todos: number, events: number, bad: number }}
|
|
1248
|
+
*/
|
|
1249
|
+
function replay() {
|
|
1250
|
+
hydrate()
|
|
1251
|
+
diagnostics.lastReplayAt = Date.now()
|
|
1252
|
+
return {
|
|
1253
|
+
from: diagnostics.hydratedFrom ?? 'empty',
|
|
1254
|
+
todos: todos.size,
|
|
1255
|
+
events: events.length,
|
|
1256
|
+
bad: diagnostics.badLines,
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* 销毁实例(释放内存、拒绝后续调用)。
|
|
1262
|
+
* 为什么不写盘/不删文件:事件流是长期资产,dispose 只是「这个实例下班了」。
|
|
1263
|
+
* @returns {{ disposed: boolean, todos: number, events: number }}
|
|
1264
|
+
*/
|
|
1265
|
+
function dispose() {
|
|
1266
|
+
const out = { disposed: true, todos: todos.size, events: events.length }
|
|
1267
|
+
disposed = true
|
|
1268
|
+
todos.clear()
|
|
1269
|
+
events.length = 0
|
|
1270
|
+
lastRev.clear()
|
|
1271
|
+
return out
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** 首次构造即回放,保证 `listTodos()` 在重启后马上可用。 */
|
|
1275
|
+
hydrate()
|
|
1276
|
+
|
|
1277
|
+
const service = {
|
|
1278
|
+
name: cfg.name ?? 'todo',
|
|
1279
|
+
paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
|
|
1280
|
+
// 契约函数名(签名固定)
|
|
1281
|
+
createTodo,
|
|
1282
|
+
updateTodo,
|
|
1283
|
+
removeTodo,
|
|
1284
|
+
getTodo,
|
|
1285
|
+
listTodos,
|
|
1286
|
+
stats,
|
|
1287
|
+
replay,
|
|
1288
|
+
history,
|
|
1289
|
+
dispose,
|
|
1290
|
+
// 短别名:api/tools 层按 board 的 create/get/list 风格调用也能直接走
|
|
1291
|
+
create: createTodo,
|
|
1292
|
+
update: updateTodo,
|
|
1293
|
+
remove: removeTodo,
|
|
1294
|
+
get: getTodo,
|
|
1295
|
+
list: listTodos,
|
|
1296
|
+
diagnostics: () => clone(diagnostics),
|
|
1297
|
+
}
|
|
1298
|
+
return service
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
export default createTodo
|