dsh-global-todo 0.12.8 → 0.12.10

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 CHANGED
@@ -1,3 +1,48 @@
1
+ ## [0.12.10] — 2026-10-21
2
+
3
+ Two follow-ups from #3, plus the notification channel this repository actually has.
4
+
5
+ - **`/health` names what waits for review.** `review.count` said something was waiting;
6
+ `review.items` says which (id, seq, title, doneAt, newest first) — checking the state no longer
7
+ means opening the panel and clicking through the list.
8
+ - **The panel says when the running version is older than the installed one.** `stale` was added to
9
+ `/health` in 0.12.9; the panel now shows a one-time notice naming both versions and saying to
10
+ restart `dsh web`. This is the trap where the client is new, the module graph is old, and the
11
+ panel calls routes that 404.
12
+ - **`tools/notify.mjs`** — pushes release / CI-failed / new-issue messages to WeChat through the
13
+ host's delivery library, which owns the freshness window and the outbox. `--dry-run` composes and
14
+ reports the window without sending; a closed window queues instead of failing.
15
+
16
+ ## [0.12.9] — 2026-10-21
17
+
18
+ Everything a Windows tester found in a 41-case sweep, answered (#3).
19
+
20
+ - **`P0`–`P3` are accepted as input.** The panel printed the labels and the API rejected them.
21
+ `priority: "P0"` now means `urgent` (likewise `P1`–`P3`), in create and in patch.
22
+ - **A paraphrase of something finished minutes ago is the same errand.** The 7-day cooldown only
23
+ compared exact titles, so 「T3 逾期项(再办一次)」 slipped past a row completed a minute earlier.
24
+ Similar wording now counts, with its own reason (`similar-recently-done`).
25
+ - **`POST /note` needs text.** An empty note appended a nameless entry to the activity trail; it is
26
+ now `400 invalid-body`.
27
+ - **`POST /diagnostics` needs a scope.** Without it every report collapsed into one nameless bucket
28
+ in `/health`.
29
+ - **A missing `id` is a bad body, not a missing record.** `POST /dispatch {}` answered 404; it is 400
30
+ now, matching the documented error contract.
31
+ - **One bad ingest row no longer aborts the batch.** Each item is isolated (like capture), and the
32
+ ingest title cap is the model's own 200 rather than a second opinion at 500.
33
+ - **Out-of-range settings are reported.** `{idleMinutes: -5}` answered 200 with 5; a provided value
34
+ outside its range is `400 invalid-body` instead of a silent clamp.
35
+ - **`POST /restore` refuses partial records.** `{id, title}` used to restore with an invented
36
+ `createdAt`, empty tags and a reassigned `seq`; a lossy success is worse than a refusal.
37
+ - **`/health` says when the installed package is newer than the running module**
38
+ (`packageVersion`, `stale`), which is the silent trap behind "I upgraded and the panel calls a
39
+ route that 404s".
40
+ - **Smaller ones**: `invalid-evidence` / `invalid-steps` / `invalid-decision` are now listed in
41
+ `ERROR_CODES`; an over-long step is rejected like an over-long list (both used to differ); the
42
+ Markdown section notes that `doing`/`cancelled` come back as `open`.
43
+ - **Reviewed, not changed**: an agent completion already sets `review.pending` in every path
44
+ (`model.js:545/641/685`), so the auto-complete case the report describes is covered since 0.7.
45
+
1
46
  ## [0.12.8] — 2026-10-21
2
47
 
3
48
  The Windows runner kept finding things after 0.12.7. Four more, all real.
package/README.md CHANGED
@@ -94,7 +94,9 @@ description, steps, attachments and progress notes stay.
94
94
 
95
95
  ### Markdown in and out
96
96
 
97
- The JSON export is the round-trip format. Markdown is the one you read, diff and edit by hand:
97
+ The JSON export is the round-trip format. Markdown is the one you read, diff and edit by hand
98
+ (note that `doing` and `cancelled` come back as `open`: a hand-edited list has no work-in-progress
99
+ state, and the JSON remains the format that round-trips exactly):
98
100
 
99
101
  ```text
100
102
  - [ ] 标题
@@ -288,7 +290,8 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
288
290
  wall of rows. A conversation with one item shows that row directly; items with no source
289
291
  conversation (added in the panel) sit under 手动添加. `time` is the original
290
292
  overdue / today / soon / later / no-due buckets, `level` groups by priority.
291
- - **Levels P0–P3** — `P0 urgent / P1 high / P2 normal (default) / P3 low`, with a badge on the
293
+ - **Levels P0–P3** — `P0 urgent / P1 high / P2 normal (default) / P3 low`; the API accepts
294
+ either the canonical names or the labels (`priority: "P0"` works), with a badge on the
292
295
  row. Records written before levels existed keep their `high/normal/low`, so nothing needed a
293
296
  migration. A conversation group shows the highest level it holds, and a green dot marks a
294
297
  group with a conversation still running.
@@ -378,7 +381,7 @@ fence when that service is present, and by a strict loopback check otherwise.
378
381
  | POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
379
382
  | POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }` — one change across a selection; a stale id lands in `unmatched` instead of failing the batch |
380
383
  | POST | `/api/global-todo/ingest` | `{ items: [{ title, source: { kind }, confidence }], dryRun? }` with `Authorization: Bearer <settings.ingestToken>` — external items; below `ingestConfirmBelow` they wait in the inbox |
381
- | GET | `/api/global-todo/health` | — (store location, counts, dispatch capabilities, plus **tool registration** and **whether the completion watcher attached** — both fail quietly, so both are reported — and `metrics`: dispatch success rate, agent tool calls, list growth; `autoCollect`: why each waiting conversation was or was not asked, counters, and how the settings file loaded) |
384
+ | GET | `/api/global-todo/health` | — (store location, counts, dispatch capabilities, plus **tool registration** and **whether the completion watcher attached** — both fail quietly, so both are reported — and `metrics`: dispatch success rate, agent tool calls, list growth; `autoCollect`: why each waiting conversation was or was not asked, counters, and how the settings file loaded; `version` is the running module and `packageVersion`/`stale` say whether the installed package is newer than the running one; `review.items` names the finished todos that still wait for a person, so a check does not have to drive the panel) |
382
385
  | POST | `/api/global-todo/create` | `{ title, description?, links?, steps?, tags?, priority?, dueAt? }` |
383
386
  | POST | `/api/global-todo/update` | `{ id, ...fields, by?: human\|agent }` |
384
387
  | POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
package/README.zh.md CHANGED
@@ -217,7 +217,7 @@ dsh plugin --profile web remove dsh-global-todo
217
217
  `auto` 优先复用这条待办已绑定的会话、其次是当前对话、最后才新开;`new` 总是新开对话;`bound` 只发给已绑定的对话,不在就明确报出原因。
218
218
  找不到任何可用的对话时**明确失败**,不会偷偷开一个新的。
219
219
  - **分组维度**:工具栏上有 `对话 / 时间 / 级别` 三个维度,**默认按对话**——同一个 agent 对话提交的待办会**合并成一条**(显示「N 条」,点开才展开),这样几次「抓一下待办」不会把清单糊成一片。单条的对话直接显示那一行;没有来源对话的(在面板里手加的)归到「手动添加」。`时间` 是原来的逾期 / 今天 / 接下来 / 以后 / 没有截止时间,`级别` 按高 / 一般 / 低分组。
220
- - **级别 P0–P3**:每条待办可设 `P0 紧急 / P1 高 / P2 常规 / P3 低`(旧记录里的 high/normal/low 原样有效,默认 P2)。行上有徽标,对话分组头显示组内**最高级别**,绿色小圆点表示该对话里还有在跑的会话。
220
+ - **级别 P0–P3**:每条待办可设 `P0 紧急 / P1 高 / P2 常规 / P3 低`;接口两种写法都收(`priority: "P0"` 或 `"urgent"`),旧记录里的 high/normal/low 原样有效,默认 P2。行上有徽标,对话分组头显示组内**最高级别**,绿色小圆点表示该对话里还有在跑的会话。
221
221
  - **从本对话抓取**:面板上一个按钮,把「请把这段对话里需要跟进的事用 `global_todo_capture` 抓一下」注入你正在说话的那个对话——聊着聊着把该跟进的事一次变成待办,不用自己一条条敲。提示词里同样写清了「不要记什么」(会话计划、闲聊、已完成的事),否则抓取只会制造噪音。
222
222
  - **自动完成**:派发出去的会话**干净结束**时,宿主自己把这条待办标成完成,并附上**它最后那段回复**作为完成说明、**这一轮改动的文件**作为产出——不用等模型记得调工具。面板上完成的行显示「完成于 …」加产出芯片,展开详情有「完成记录」段(说明 + 产出路径,可点开或复制)。
223
223
  两条护栏值得知道:**只对刚派发过的会话生效**(你在那个会话里随便聊两句不会把待办完成掉),**失败 / 中断的回合不标记完成**(宁可留着未完成,也不能让一份干砸的活显示成已完成)。
package/lib/capture.js CHANGED
@@ -91,6 +91,11 @@ export function findDuplicate(input) {
91
91
  if (now - created < windowMs && similarity(title, todo.title) >= threshold) {
92
92
  return { duplicate: true, reason: 'similar-active', matchId: todo.id }
93
93
  }
94
+ } else if (now - doneAt < cooldownMs && similarity(title, todo.title) >= threshold) {
95
+ // The cooldown was exact-title only, so 「T3 逾期项」 finished two minutes ago and
96
+ // 「T3 逾期项(再办一次)」 was accepted — the same errand, re-registered. Similar
97
+ // wording counts here for the same reason it counts among active rows.
98
+ return { duplicate: true, reason: 'similar-recently-done', matchId: todo.id }
94
99
  }
95
100
  }
96
101
  return { duplicate: false }
@@ -101,4 +106,5 @@ export const CAPTURE_REASONS = Object.freeze({
101
106
  'same-active': '清单里已有同名的未完成条目,未重复登记',
102
107
  'similar-active': '清单里已有一条很相似的未完成条目,未重复登记',
103
108
  'recently-done': '这条刚完成不久(7 天内),没有重复登记',
109
+ 'similar-recently-done': '这件事刚完成不久(7 天内),措辞不同也视为同一件,未重复登记',
104
110
  })
package/lib/client.js CHANGED
@@ -175,6 +175,9 @@ window.__ModuleLoader__.load({
175
175
  'notice.autoCollectOn': '已开启自动获取:一次人为发言最多自动问一次',
176
176
  'notice.autoCollectOff': '已关闭自动获取',
177
177
  'notice.externalChange': '清单在别处被改过,已载入最新内容',
178
+ // Installed newer than running: the panel would otherwise call routes the old module
179
+ // graph does not have, and look broken until someone happens to restart the Host.
180
+ 'notice.stale': '已安装 {installed},当前运行的是 {running};重启 dsh web 后生效',
178
181
  'reminder.title': '到点提醒({n})',
179
182
  'reminder.note': '这些是到了设定的提醒时间,和「逾期」不是同一批。',
180
183
  'reminder.dismissAll': '全部知道了',
@@ -472,6 +475,7 @@ window.__ModuleLoader__.load({
472
475
  'notice.autoCollectOn': 'Auto-collect on: at most one question per message from you',
473
476
  'notice.autoCollectOff': 'Auto-collect off',
474
477
  'notice.externalChange': 'The list changed somewhere else; the latest content is loaded',
478
+ 'notice.stale': 'Installed {installed}, running {running}; restart dsh web to finish the upgrade',
475
479
  'reminder.title': 'Due now ({n})',
476
480
  'reminder.note': 'These reached their reminder time — a different set from Overdue.',
477
481
  'reminder.dismissAll': 'Dismiss all',
@@ -2715,6 +2719,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2715
2719
  // The store counts file changes made outside this process (another machine, a sync
2716
2720
  // client). A new count means the panel's view was stale a moment ago; say so once.
2717
2721
  const externalRef = useRef(null)
2722
+ // Warn once per mount: a version mismatch is a fact about this page load, not an
2723
+ // event worth repeating on every refresh.
2724
+ const staleRef = useRef(false)
2718
2725
  // The wide-screen detail card floats over the panel, so its offsets are measured from
2719
2726
  // the panel's own column rather than derived from the viewport width.
2720
2727
  const innerRef = useRef(null)
@@ -2782,6 +2789,17 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2782
2789
  setTimeout(() => setNotice(null), 8000)
2783
2790
  }
2784
2791
  externalRef.current = external
2792
+ if (data?.stale === true && !staleRef.current) {
2793
+ staleRef.current = true
2794
+ setNotice({
2795
+ text: translate(lang, 'notice.stale', {
2796
+ installed: String(data.packageVersion ?? '?'),
2797
+ running: String(data.version ?? '?'),
2798
+ }),
2799
+ sessionId: '',
2800
+ })
2801
+ setTimeout(() => setNotice(null), 12000)
2802
+ }
2785
2803
  setState((current) => ({ ...current, health: data }))
2786
2804
  } catch {
2787
2805
  /* the health mirror is not worth an error banner */
package/lib/errors.js CHANGED
@@ -18,6 +18,9 @@ export const ERROR_CODES = Object.freeze([
18
18
  'invalid-due',
19
19
  'invalid-tags',
20
20
  'invalid-link',
21
+ 'invalid-evidence',
22
+ 'invalid-steps',
23
+ 'invalid-decision',
21
24
  'invalid-body',
22
25
  'invalid-json',
23
26
  'payload-too-large',
package/lib/index.js CHANGED
@@ -20,6 +20,7 @@
20
20
  * @module dsh-global-todo
21
21
  */
22
22
 
23
+ import { readFileSync } from 'node:fs'
23
24
  import { readFile, writeFile } from 'node:fs/promises'
24
25
  import path from 'node:path'
25
26
 
@@ -36,7 +37,7 @@ import { createStore, resolveStorePath } from './store.js'
36
37
  import { createTools } from './tools.js'
37
38
 
38
39
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
39
- export const VERSION = '0.12.8'
40
+ export const VERSION = '0.12.10'
40
41
 
41
42
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
42
43
  export const name = 'dsh-global-todo'
@@ -525,6 +526,15 @@ export function apply(ctx, config) {
525
526
 
526
527
  ctx.inject(['webServer'], (webCtx) => {
527
528
  webCtx.effect(() => {
529
+ // The running module is the truth for behaviour; package.json is the truth for what
530
+ // was installed. When they differ the host is still executing the old graph, and
531
+ // nothing else in the system says so.
532
+ let diskVersion
533
+ try {
534
+ diskVersion = JSON.parse(readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf8')).version
535
+ } catch {
536
+ diskVersion = undefined
537
+ }
528
538
  const routes = createRoutes({
529
539
  service,
530
540
  dispatcher,
@@ -545,6 +555,9 @@ export function apply(ctx, config) {
545
555
  autoCollect: autoCollectState,
546
556
  reminders: reminderState,
547
557
  version: VERSION,
558
+ // What is on disk, which after an upgrade is not what this process is running.
559
+ // The panel talks to a tree that changed under it; /health is where that is visible.
560
+ diskVersion,
548
561
  })
549
562
  const disposers = routes.map((definition) => webCtx.webServer.register(definition))
550
563
  webCtx.logger?.info?.(`dsh-global-todo: 注册了 ${disposers.length} 条路由(${ROUTE_BASE})`)
package/lib/ingest.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * @module dsh-global-todo/ingest
10
10
  */
11
11
 
12
+ import { LIMITS } from './model.js'
12
13
  import { INGEST_SOURCES } from './settings.js'
13
14
 
14
15
  /** Tag that marks a record as waiting for a person. */
@@ -28,7 +29,9 @@ export function normalizeIngestItem(raw, options = {}) {
28
29
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { error: '条目必须是对象' }
29
30
  const title = typeof raw.title === 'string' ? raw.title.trim() : ''
30
31
  if (title === '') return { error: '缺 title' }
31
- if (title.length > 500) return { error: 'title 超过 500 字' }
32
+ // The model's own limit, not a second opinion: a 500-character title passed here and was
33
+ // then rejected by create(), which aborted the whole batch instead of skipping one row.
34
+ if (title.length > LIMITS.title) return { error: `title 超过 ${LIMITS.title} 字` }
32
35
  const source = raw.source !== null && typeof raw.source === 'object' && !Array.isArray(raw.source) ? raw.source : {}
33
36
  const kind = typeof source.kind === 'string' ? source.kind.trim().toLowerCase() : ''
34
37
  if (!INGEST_SOURCES.includes(kind)) return { error: `source.kind 必须是 ${INGEST_SOURCES.join(' / ')}` }
package/lib/model.js CHANGED
@@ -40,6 +40,23 @@ export const PRIORITY_LABELS = Object.freeze({
40
40
  low: 'P3 低',
41
41
  })
42
42
 
43
+ /**
44
+ * The labels are also accepted on the way in.
45
+ *
46
+ * The panel and the tools show `P0`–`P3` everywhere, so a person (or a model reading the
47
+ * schema description) will eventually send `"P0"`; rejecting it with `invalid-priority`
48
+ * while the UI prints exactly that string is a trap, not a validation.
49
+ */
50
+ export const PRIORITY_ALIASES = Object.freeze({ p0: 'urgent', p1: 'high', p2: 'normal', p3: 'low' })
51
+
52
+ /** @param {unknown} value @returns {string} a canonical priority, or '' when unusable */
53
+ function canonicalPriority(value) {
54
+ if (typeof value !== 'string') return ''
55
+ const key = value.trim().toLowerCase()
56
+ if (PRIORITIES.includes(key)) return key
57
+ return PRIORITY_ALIASES[key] ?? ''
58
+ }
59
+
43
60
  /** Who performed a write. Recorded on every entry of the activity trail. */
44
61
  export const ACTORS = Object.freeze(['human', 'agent'])
45
62
 
@@ -139,6 +156,21 @@ export function normalizeTags(value) {
139
156
  * @param {string} fallback
140
157
  * @returns {string}
141
158
  */
159
+ /**
160
+ * Priority accepts the canonical names and the P0–P3 labels the panel prints.
161
+ *
162
+ * @param {unknown} value
163
+ * @param {string} fallback
164
+ * @returns {string}
165
+ */
166
+ function normalizePriorityField(value, fallback) {
167
+ if (value === undefined || value === null || value === '') return fallback
168
+ const canonical = canonicalPriority(value)
169
+ if (canonical === '') {
170
+ throw new TodoError('invalid-priority', `优先级只能是 ${PRIORITIES.join(' / ')}(或 P0–P3 标签),收到 ${String(value)}`)
171
+ }
172
+ return canonical
173
+ }
142
174
  function normalizeEnum(value, allowed, code, field, fallback) {
143
175
  if (value === undefined || value === null || value === '') return fallback
144
176
  if (typeof value !== 'string') throw new TodoError(code, `${field} 必须是字符串`)
@@ -204,7 +236,7 @@ export function createTodo(input, options = {}) {
204
236
  title,
205
237
  description: normalizeText(raw.description, '描述', LIMITS.description, 'invalid-description'),
206
238
  status: normalizeEnum(raw.status, STATUSES, 'invalid-status', '状态', 'open'),
207
- priority: normalizeEnum(raw.priority, PRIORITIES, 'invalid-priority', '优先级', 'normal'),
239
+ priority: normalizePriorityField(raw.priority, 'normal'),
208
240
  dueAt: normalizeDue(raw.dueAt ?? raw.due),
209
241
  tags: normalizeTags(raw.tags),
210
242
  links,
@@ -563,7 +595,7 @@ export function applyPatch(todo, patch, options = {}) {
563
595
  }
564
596
  }
565
597
  if (patch.priority !== undefined) {
566
- const priority = normalizeEnum(patch.priority, PRIORITIES, 'invalid-priority', '优先级', todo.priority)
598
+ const priority = normalizePriorityField(patch.priority, todo.priority)
567
599
  if (priority !== todo.priority) {
568
600
  todo.priority = priority
569
601
  changed.push('priority')
@@ -684,7 +716,12 @@ export function normalizeSteps(value) {
684
716
  const out = []
685
717
  for (const item of value) {
686
718
  if (typeof item === 'string') {
687
- const text = item.trim().slice(0, LIMITS.step)
719
+ const text = item.trim()
720
+ // Over-long text used to be truncated silently while an over-long list was rejected;
721
+ // two limits, two behaviours, and the quiet one loses words. Both refuse now.
722
+ if (text.length > LIMITS.step) {
723
+ throw new TodoError('invalid-steps', `每一步最多 ${LIMITS.step} 字,收到 ${text.length} 字`)
724
+ }
688
725
  // A blank line in a textarea is a typo, not a step.
689
726
  if (text !== '') out.push({ text, done: false })
690
727
  continue
@@ -692,7 +729,10 @@ export function normalizeSteps(value) {
692
729
  if (item === null || typeof item !== 'object' || Array.isArray(item)) {
693
730
  throw new TodoError('invalid-steps', '步骤必须是字符串或 { text, done }')
694
731
  }
695
- const text = typeof item.text === 'string' ? item.text.trim().slice(0, LIMITS.step) : ''
732
+ const text = typeof item.text === 'string' ? item.text.trim() : ''
733
+ if (text.length > LIMITS.step) {
734
+ throw new TodoError('invalid-steps', `每一步最多 ${LIMITS.step} 字,收到 ${text.length} 字`)
735
+ }
696
736
  if (text === '') continue
697
737
  out.push({ text, done: item.done === true })
698
738
  }
package/lib/routes.js CHANGED
@@ -419,8 +419,16 @@ export function createRoutes(deps) {
419
419
  return {
420
420
  plugin: 'dsh-global-todo',
421
421
  version,
422
+ // `version` is what this process runs; `packageVersion` is what is installed. After
423
+ // an upgrade the two differ until the host restarts, and the panel then calls routes
424
+ // the old graph does not have (a 404 that looks like a plugin bug).
425
+ packageVersion: deps.diskVersion ?? version,
426
+ stale: deps.diskVersion !== undefined && deps.diskVersion !== version,
422
427
  store: await service.describe(),
423
428
  counts,
429
+ // The count says something waits for a person; the items say what, so a check does not
430
+ // have to drive the UI to find out.
431
+ review: { count: counts.review, items: await service.awaitingReview() },
424
432
  // Purely additive, in-memory counters: hand-off success rate, agent tool
425
433
  // calls and how the list grew. `null` when they were never mounted — an
426
434
  // absent metric is not a zero.
@@ -510,6 +518,11 @@ export function createRoutes(deps) {
510
518
 
511
519
  exact('POST', `${ROUTE_BASE}/diagnostics`, async (request) => {
512
520
  const body = await readJsonBody(request)
521
+ // `scope` is the grouping key in /health; without it every entry collapses into one
522
+ // nameless bucket, so it is required rather than defaulted.
523
+ if (typeof body.scope !== 'string' || body.scope.trim() === '') {
524
+ throw new TodoError('invalid-body', 'diagnostics 需要 scope(如 health / list / dispatch)')
525
+ }
513
526
  // The panel's failures happen in someone else's browser, where neither the Host
514
527
  // nor the person wants to be reading consoles. Reporting them here is what makes
515
528
  // "click it and tell me what it said" unnecessary.
@@ -519,6 +532,10 @@ export function createRoutes(deps) {
519
532
 
520
533
  exact('POST', `${ROUTE_BASE}/settings`, async (request) => {
521
534
  const body = await readJsonBody(request)
535
+ // The settings store clamps silently, which is right for a hand-edited file and wrong
536
+ // for an API: `{idleMinutes: -5}` answering 200-but-5 hides the rejected intent. A
537
+ // provided value outside its range is reported instead.
538
+ assertSettingsRange(body ?? {})
522
539
  const next = await deps.settings.update(body ?? {})
523
540
  return {
524
541
  settings: {
@@ -605,6 +622,11 @@ export function createRoutes(deps) {
605
622
 
606
623
  exact('POST', `${ROUTE_BASE}/note`, async (request) => {
607
624
  const body = await readJsonBody(request)
625
+ // A note with no text records an empty entry in the activity trail: the README calls
626
+ // `text` required, so an empty one is a bad body, not a note.
627
+ if (typeof body.text !== 'string' || body.text.trim() === '') {
628
+ throw new TodoError('invalid-body', 'note 需要非空的 text')
629
+ }
608
630
  const { todo, note } = await service.note(body.id, body.text, { by: 'human' })
609
631
  return { todo, note }
610
632
  }),
@@ -644,7 +666,18 @@ export function createRoutes(deps) {
644
666
  // The panel's undo: put a removed record back verbatim, timestamps and
645
667
  // attachments included, instead of re-creating it as a new todo.
646
668
  const body = await readJsonBody(request)
647
- return { todo: await service.restore(body.todo, { by: 'human' }) }
669
+ // A partial object would be "restored" with invented defaults (a new createdAt, empty
670
+ // tags, a reassigned seq) — a lossy success is worse than a refusal.
671
+ const todo = body.todo
672
+ if (todo === null || typeof todo !== 'object' || Array.isArray(todo)) {
673
+ throw new TodoError('invalid-body', 'restore 需要完整的 todo 对象')
674
+ }
675
+ for (const field of ['id', 'title', 'createdAt', 'status']) {
676
+ if (todo[field] === undefined || todo[field] === null || todo[field] === '') {
677
+ throw new TodoError('invalid-body', `restore 的 todo 缺少 ${field};撤销要原样放回,不能补默认值`)
678
+ }
679
+ }
680
+ return { todo: await service.restore(todo, { by: 'human' }) }
648
681
  }),
649
682
 
650
683
  exact('POST', `${ROUTE_BASE}/link`, async (request) => {
@@ -681,6 +714,10 @@ export function createRoutes(deps) {
681
714
 
682
715
  exact('POST', `${ROUTE_BASE}/dispatch`, async (request) => {
683
716
  const body = await readJsonBody(request)
717
+ // A missing id is a malformed request (400), not a record that does not exist (404).
718
+ if (typeof body.id !== 'string' || body.id.trim() === '') {
719
+ throw new TodoError('invalid-body', 'dispatch 需要 id')
720
+ }
684
721
  return dispatcher.dispatch(body.id, {
685
722
  mode: body.mode,
686
723
  sessionId: body.sessionId,
@@ -698,4 +735,27 @@ export function createRoutes(deps) {
698
735
  ]
699
736
  }
700
737
 
738
+ /**
739
+ * Ranges the settings route refuses, mirroring the clamps in `normalizeSettings`.
740
+ *
741
+ * @param {Record<string, unknown>} body
742
+ */
743
+ function assertSettingsRange(body) {
744
+ const ranges = {
745
+ idleMinutes: [5, 24 * 60],
746
+ cooldownMinutes: [5, 24 * 60],
747
+ ingestConfirmBelow: [0, 1],
748
+ reminderLeadMinutes: [0, 24 * 60],
749
+ }
750
+ for (const [field, [low, high]] of Object.entries(ranges)) {
751
+ const value = body[field]
752
+ if (value === undefined || value === null) continue
753
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
754
+ throw new TodoError('invalid-body', `${field} 必须是数字(${low}–${high})`)
755
+ }
756
+ if (value < low || value > high) {
757
+ throw new TodoError('invalid-body', `${field} 必须在 ${low}–${high} 之间,收到 ${value}`)
758
+ }
759
+ }
760
+ }
701
761
  export { sendJson, readJsonBody }
package/lib/service.js CHANGED
@@ -775,7 +775,19 @@ export function createService(options) {
775
775
  preview.push({ title: verdict.item.title, tags: verdict.item.tags, inbox: verdict.inbox })
776
776
  continue
777
777
  }
778
- const todo = await create(verdict.item, { by: 'agent' })
778
+ // One bad row must not take the batch down with it: `global_todo_capture` already
779
+ // isolates each create, and the HTTP door should behave the same way.
780
+ let todo
781
+ try {
782
+ todo = await create(verdict.item, { by: 'agent' })
783
+ } catch (error) {
784
+ skipped.push({
785
+ title: verdict.item.title.slice(0, 80),
786
+ reason: 'invalid',
787
+ error: error?.message ?? String(error),
788
+ })
789
+ continue
790
+ }
779
791
  created.push(todo)
780
792
  existing.push(todo)
781
793
  }
@@ -809,10 +821,29 @@ export function createService(options) {
809
821
  return counts(todos, now())
810
822
  }
811
823
 
824
+ /**
825
+ * Which finished todos an agent marked done that a person has not confirmed.
826
+ *
827
+ * The count in `/health` says something is waiting; this says what, so a check does not
828
+ * have to open the panel and click through the list to find out.
829
+ *
830
+ * @param {number} [limit] newest first, capped
831
+ * @returns {Promise<{ id: string, seq: number, title: string, doneAt: number }[]>}
832
+ */
833
+ async function awaitingReview(limit = 20) {
834
+ const todos = await store.all()
835
+ return todos
836
+ .filter((todo) => todo.status === 'done' && todo.review?.pending === true)
837
+ .sort((left, right) => (right.doneAt ?? right.updatedAt ?? 0) - (left.doneAt ?? left.updatedAt ?? 0))
838
+ .slice(0, limit)
839
+ .map((todo) => ({ id: todo.id, seq: todo.seq, title: todo.title, doneAt: todo.doneAt ?? todo.updatedAt ?? 0 }))
840
+ }
841
+
812
842
  return {
813
843
  list,
814
844
  listAll,
815
845
  get,
846
+ awaitingReview,
816
847
  create,
817
848
  update,
818
849
  setStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Cross-session todo list for DeepSeek Harness: one JSON file a person and an agent both write to, with step plans that ship as a numbered hand-off, session read-back, and a human confirmation before an agent's \"done\" counts.",
6
6
  "type": "module",
@@ -73,6 +73,7 @@
73
73
  "check": "node scripts/check.mjs",
74
74
  "doctor": "node tools/doctor.mjs",
75
75
  "check:windows": "node tools/windows-check.mjs",
76
+ "notify": "node tools/notify.mjs",
76
77
  "test": "node --test \"test/**/*.test.mjs\"",
77
78
  "verify": "npm run check && npm run check:render && npm test",
78
79
  "preview": "node tools/preview.mjs",