dsh-global-todo 0.12.7 → 0.12.9

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,51 @@
1
+ ## [0.12.9] — 2026-10-21
2
+
3
+ Everything a Windows tester found in a 41-case sweep, answered (#3).
4
+
5
+ - **`P0`–`P3` are accepted as input.** The panel printed the labels and the API rejected them.
6
+ `priority: "P0"` now means `urgent` (likewise `P1`–`P3`), in create and in patch.
7
+ - **A paraphrase of something finished minutes ago is the same errand.** The 7-day cooldown only
8
+ compared exact titles, so 「T3 逾期项(再办一次)」 slipped past a row completed a minute earlier.
9
+ Similar wording now counts, with its own reason (`similar-recently-done`).
10
+ - **`POST /note` needs text.** An empty note appended a nameless entry to the activity trail; it is
11
+ now `400 invalid-body`.
12
+ - **`POST /diagnostics` needs a scope.** Without it every report collapsed into one nameless bucket
13
+ in `/health`.
14
+ - **A missing `id` is a bad body, not a missing record.** `POST /dispatch {}` answered 404; it is 400
15
+ now, matching the documented error contract.
16
+ - **One bad ingest row no longer aborts the batch.** Each item is isolated (like capture), and the
17
+ ingest title cap is the model's own 200 rather than a second opinion at 500.
18
+ - **Out-of-range settings are reported.** `{idleMinutes: -5}` answered 200 with 5; a provided value
19
+ outside its range is `400 invalid-body` instead of a silent clamp.
20
+ - **`POST /restore` refuses partial records.** `{id, title}` used to restore with an invented
21
+ `createdAt`, empty tags and a reassigned `seq`; a lossy success is worse than a refusal.
22
+ - **`/health` says when the installed package is newer than the running module**
23
+ (`packageVersion`, `stale`), which is the silent trap behind "I upgraded and the panel calls a
24
+ route that 404s".
25
+ - **Smaller ones**: `invalid-evidence` / `invalid-steps` / `invalid-decision` are now listed in
26
+ `ERROR_CODES`; an over-long step is rejected like an over-long list (both used to differ); the
27
+ Markdown section notes that `doing`/`cancelled` come back as `open`.
28
+ - **Reviewed, not changed**: an agent completion already sets `review.pending` in every path
29
+ (`model.js:545/641/685`), so the auto-complete case the report describes is covered since 0.7.
30
+
31
+ ## [0.12.8] — 2026-10-21
32
+
33
+ The Windows runner kept finding things after 0.12.7. Four more, all real.
34
+
35
+ - **`mkdir` over a file is `EEXIST` on Windows**, not `ENOTDIR`. The store translated only the
36
+ POSIX code, so an unwritable location could surface as a raw error; both now become
37
+ `store-unwritable`.
38
+ - **The diagnostics trim could lose its rename** while a reader held the log. The atomic write is
39
+ now shared (`lib/atomic.js`) by the store, settings and diagnostics, with a longer retry budget
40
+ for a file that is read constantly.
41
+ - **`tools/preview.mjs` created a directory named `panel.html`** on Windows: it stripped the file
42
+ name with a SQL-style regex on `/`, which matched nothing there. `path.dirname` instead — this is
43
+ what made `npm pack --dry-run` fail on the Windows runner.
44
+ - **Two test assertions were POSIX literals** (a default store path and a `path.resolve` result).
45
+ They now compare resolved paths, which is what the contract actually says.
46
+ - **CI is green on `windows-latest`** — tests, static checks, package shape and
47
+ `node tools/windows-check.mjs` all run there.
48
+
1
49
  ## [0.12.7] — 2026-10-21
2
50
 
3
51
  Windows, tested instead of assumed.
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) |
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/docs/WINDOWS.md CHANGED
@@ -21,6 +21,11 @@ Windows machine should run.
21
21
  CI runs the suite on **ubuntu-latest and windows-latest** (node 22.19 on both, node 24 on Linux).
22
22
  Windows runs `node tools/windows-check.mjs` as an extra step.
23
23
 
24
+ That matrix earned its keep on its first day: it found four things Linux could not see — a `mkdir`
25
+ over a file reporting `EEXIST` instead of `ENOTDIR`, the diagnostics trim losing a rename to a busy
26
+ reader, `tools/preview.mjs` creating a directory named `panel.html` (a POSIX-only regex), and two
27
+ test assertions that pinned POSIX literals. All fixed in 0.12.8.
28
+
24
29
  ## The real-filesystem check
25
30
 
26
31
  ```powershell
package/lib/atomic.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Small, shared file-system guarantees.
3
+ *
4
+ * Both the todo file and the diagnostics log are replaced by "write a sibling, then
5
+ * rename". That pattern has two platform traps this module exists to absorb:
6
+ *
7
+ * 1. `fsync` on Windows is `FlushFileBuffers`, which needs a **writable** handle. A
8
+ * read-only handle always throws `EPERM`, and a save that throws before its rename
9
+ * is a save that never happened (issue #2).
10
+ * 2. Antivirus, the search indexer and a concurrent reader can hold a handle for a few
11
+ * milliseconds, which makes the rename fail with `EPERM`/`EBUSY`/`EACCES`. Retrying
12
+ * those turns a spurious failure into a write; other errors still surface at once.
13
+ *
14
+ * @module dsh-global-todo/atomic
15
+ */
16
+
17
+ import { open, rename, unlink } from 'node:fs/promises'
18
+
19
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
20
+
21
+ /**
22
+ * Rename, retrying only the errors that mean "someone else has it for a moment".
23
+ *
24
+ * @param {{ rename: (from: string, to: string) => Promise<void> }} io
25
+ * @param {string} from temporary file
26
+ * @param {string} to target file
27
+ */
28
+ export async function renameWithRetry(io, from, to, attempts = 4) {
29
+ for (let attempt = 0; ; attempt += 1) {
30
+ try {
31
+ await io.rename(from, to)
32
+ return
33
+ } catch (error) {
34
+ if (attempt >= attempts || !TRANSIENT_RENAME_CODES.has(error?.code)) throw error
35
+ await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)))
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Replace a file atomically, and survive a platform that refuses `fsync`.
42
+ *
43
+ * The handle is opened for **writing** on purpose: on Windows `handle.sync()` needs a
44
+ * writable handle, and opening the temp file read-only made every write throw before the
45
+ * rename (the todo list looked empty while `*.tmp` debris accumulated).
46
+ *
47
+ * The sync itself is best effort: durability matters, but losing a save because a
48
+ * platform will not flush a handle is worse than a save that reaches the page cache.
49
+ *
50
+ * @param {string} file absolute path of the target
51
+ * @param {string} payload bytes to write
52
+ * @param {{ fs?: object, onSyncError?: (error: unknown) => void, renameAttempts?: number }} [options]
53
+ * injected for tests; `renameAttempts` is there for callers whose target is read constantly
54
+ */
55
+ export async function atomicWrite(file, payload, options = {}) {
56
+ const io = options.fs ?? { open, rename, unlink }
57
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`
58
+ let handle = null
59
+ let renamed = false
60
+ try {
61
+ handle = await io.open(temp, 'w', 0o600)
62
+ await handle.writeFile(payload, 'utf8')
63
+ try {
64
+ await handle.sync()
65
+ } catch (error) {
66
+ options.onSyncError?.(error)
67
+ }
68
+ await handle.close()
69
+ handle = null
70
+ await renameWithRetry(io, temp, file, options.renameAttempts)
71
+ renamed = true
72
+ } finally {
73
+ if (handle !== null) await handle.close().catch(() => {})
74
+ // Only debris is cleaned up: after a successful rename the temp file is gone.
75
+ if (!renamed) await io.unlink(temp).catch(() => {})
76
+ }
77
+ }
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
  })
@@ -9,7 +9,9 @@
9
9
  * @module dsh-global-todo/diagnostics
10
10
  */
11
11
 
12
- import { appendFile, readFile, rename, rm, writeFile } from 'node:fs/promises'
12
+ import { appendFile, readFile, rm } from 'node:fs/promises'
13
+
14
+ import { atomicWrite } from './atomic.js'
13
15
 
14
16
  /** How many entries `/health` shows, how many are kept in memory, how many on disk. */
15
17
  const RECENT = 5
@@ -66,15 +68,13 @@ export function createDiagnostics(options) {
66
68
  * @param {string[]} lines
67
69
  */
68
70
  const rewrite = async (lines) => {
69
- const temp = `${options.file}.${process.pid}.${(rewrites += 1)}.tmp`
70
- try {
71
- await writeFile(temp, `${lines.slice(-ON_DISK).join('\n')}\n`, 'utf8')
72
- await rename(temp, options.file)
73
- } catch (error) {
74
- // A failed trim must not leave litter beside the log it failed to trim.
75
- await rm(temp, { force: true }).catch(() => {})
76
- throw error
77
- }
71
+ rewrites += 1
72
+ // Same helper as the todo file: opened for writing (Windows cannot fsync a read-only
73
+ // handle), rename retried while an antivirus or a reader holds the file for a moment,
74
+ // temp file removed if the replace fails.
75
+ // A debug reader (a tail, an editor, this test) may hold the file almost continuously,
76
+ // so the trim gets a longer retry budget than a normal save.
77
+ await atomicWrite(options.file, `${lines.slice(-ON_DISK).join('\n')}\n`, { renameAttempts: 12 })
78
78
  }
79
79
 
80
80
  return {
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.7'
40
+ export const VERSION = '0.12.9'
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,6 +419,11 @@ 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,
424
429
  // Purely additive, in-memory counters: hand-off success rate, agent tool
@@ -510,6 +515,11 @@ export function createRoutes(deps) {
510
515
 
511
516
  exact('POST', `${ROUTE_BASE}/diagnostics`, async (request) => {
512
517
  const body = await readJsonBody(request)
518
+ // `scope` is the grouping key in /health; without it every entry collapses into one
519
+ // nameless bucket, so it is required rather than defaulted.
520
+ if (typeof body.scope !== 'string' || body.scope.trim() === '') {
521
+ throw new TodoError('invalid-body', 'diagnostics 需要 scope(如 health / list / dispatch)')
522
+ }
513
523
  // The panel's failures happen in someone else's browser, where neither the Host
514
524
  // nor the person wants to be reading consoles. Reporting them here is what makes
515
525
  // "click it and tell me what it said" unnecessary.
@@ -519,6 +529,10 @@ export function createRoutes(deps) {
519
529
 
520
530
  exact('POST', `${ROUTE_BASE}/settings`, async (request) => {
521
531
  const body = await readJsonBody(request)
532
+ // The settings store clamps silently, which is right for a hand-edited file and wrong
533
+ // for an API: `{idleMinutes: -5}` answering 200-but-5 hides the rejected intent. A
534
+ // provided value outside its range is reported instead.
535
+ assertSettingsRange(body ?? {})
522
536
  const next = await deps.settings.update(body ?? {})
523
537
  return {
524
538
  settings: {
@@ -605,6 +619,11 @@ export function createRoutes(deps) {
605
619
 
606
620
  exact('POST', `${ROUTE_BASE}/note`, async (request) => {
607
621
  const body = await readJsonBody(request)
622
+ // A note with no text records an empty entry in the activity trail: the README calls
623
+ // `text` required, so an empty one is a bad body, not a note.
624
+ if (typeof body.text !== 'string' || body.text.trim() === '') {
625
+ throw new TodoError('invalid-body', 'note 需要非空的 text')
626
+ }
608
627
  const { todo, note } = await service.note(body.id, body.text, { by: 'human' })
609
628
  return { todo, note }
610
629
  }),
@@ -644,7 +663,18 @@ export function createRoutes(deps) {
644
663
  // The panel's undo: put a removed record back verbatim, timestamps and
645
664
  // attachments included, instead of re-creating it as a new todo.
646
665
  const body = await readJsonBody(request)
647
- return { todo: await service.restore(body.todo, { by: 'human' }) }
666
+ // A partial object would be "restored" with invented defaults (a new createdAt, empty
667
+ // tags, a reassigned seq) — a lossy success is worse than a refusal.
668
+ const todo = body.todo
669
+ if (todo === null || typeof todo !== 'object' || Array.isArray(todo)) {
670
+ throw new TodoError('invalid-body', 'restore 需要完整的 todo 对象')
671
+ }
672
+ for (const field of ['id', 'title', 'createdAt', 'status']) {
673
+ if (todo[field] === undefined || todo[field] === null || todo[field] === '') {
674
+ throw new TodoError('invalid-body', `restore 的 todo 缺少 ${field};撤销要原样放回,不能补默认值`)
675
+ }
676
+ }
677
+ return { todo: await service.restore(todo, { by: 'human' }) }
648
678
  }),
649
679
 
650
680
  exact('POST', `${ROUTE_BASE}/link`, async (request) => {
@@ -681,6 +711,10 @@ export function createRoutes(deps) {
681
711
 
682
712
  exact('POST', `${ROUTE_BASE}/dispatch`, async (request) => {
683
713
  const body = await readJsonBody(request)
714
+ // A missing id is a malformed request (400), not a record that does not exist (404).
715
+ if (typeof body.id !== 'string' || body.id.trim() === '') {
716
+ throw new TodoError('invalid-body', 'dispatch 需要 id')
717
+ }
684
718
  return dispatcher.dispatch(body.id, {
685
719
  mode: body.mode,
686
720
  sessionId: body.sessionId,
@@ -698,4 +732,27 @@ export function createRoutes(deps) {
698
732
  ]
699
733
  }
700
734
 
735
+ /**
736
+ * Ranges the settings route refuses, mirroring the clamps in `normalizeSettings`.
737
+ *
738
+ * @param {Record<string, unknown>} body
739
+ */
740
+ function assertSettingsRange(body) {
741
+ const ranges = {
742
+ idleMinutes: [5, 24 * 60],
743
+ cooldownMinutes: [5, 24 * 60],
744
+ ingestConfirmBelow: [0, 1],
745
+ reminderLeadMinutes: [0, 24 * 60],
746
+ }
747
+ for (const [field, [low, high]] of Object.entries(ranges)) {
748
+ const value = body[field]
749
+ if (value === undefined || value === null) continue
750
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
751
+ throw new TodoError('invalid-body', `${field} 必须是数字(${low}–${high})`)
752
+ }
753
+ if (value < low || value > high) {
754
+ throw new TodoError('invalid-body', `${field} 必须在 ${low}–${high} 之间,收到 ${value}`)
755
+ }
756
+ }
757
+ }
701
758
  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
  }
package/lib/store.js CHANGED
@@ -19,76 +19,18 @@
19
19
  * @module dsh-global-todo/store
20
20
  */
21
21
 
22
- import { open, mkdir, readFile, rename, stat, unlink } from 'node:fs/promises'
23
-
24
- /**
25
- * Rename, retrying only the errors that mean "someone else has it for a moment".
26
- *
27
- * @param {{ rename: (from: string, to: string) => Promise<void> }} io
28
- * @param {string} from temporary file
29
- * @param {string} to target file
30
- */
31
- async function renameWithRetry(io, from, to) {
32
- const transient = new Set(['EPERM', 'EBUSY', 'EACCES'])
33
- for (let attempt = 0; ; attempt += 1) {
34
- try {
35
- await io.rename(from, to)
36
- return
37
- } catch (error) {
38
- if (attempt >= 4 || !transient.has(error?.code)) throw error
39
- await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)))
40
- }
41
- }
42
- }
43
- /**
44
- * Replace a file atomically, and survive a platform that refuses `fsync`.
45
- *
46
- * Opened for **writing** on purpose: on Windows `handle.sync()` is `FlushFileBuffers`,
47
- * which needs a writable handle. Opening the temp file read-only and syncing it threw
48
- * `EPERM` on every write, and because that sync sat before `rename()`, the target file
49
- * was never replaced — the list looked empty while the data piled up as `*.tmp` debris.
50
- * That was issue #2.
51
- *
52
- * The sync itself is best effort: durability matters, but losing a save because a
53
- * platform will not flush a handle is strictly worse than a save that reaches the page
54
- * cache. A failing sync is reported and the rename still happens.
55
- *
56
- * @param {string} file absolute path of the target
57
- * @param {string} payload bytes to write
58
- * @param {{ fs?: object, onSyncError?: (error: unknown) => void }} [options] injected for tests
59
- */
60
- export async function atomicWrite(file, payload, options = {}) {
61
- const io = options.fs ?? { open, rename, unlink }
62
- const temp = `${file}.${process.pid}.${Date.now()}.tmp`
63
- let handle = null
64
- let renamed = false
65
- try {
66
- handle = await io.open(temp, 'w', 0o600)
67
- await handle.writeFile(payload, 'utf8')
68
- try {
69
- await handle.sync()
70
- } catch (error) {
71
- options.onSyncError?.(error)
72
- }
73
- await handle.close()
74
- handle = null
75
- // Windows antivirus and the search indexer take short-lived locks on files that were
76
- // just written, which makes the rename fail with EPERM/EBUSY for a few milliseconds.
77
- // A retry turns a spurious save failure into a save; other errors still surface at once.
78
- await renameWithRetry(io, temp, file)
79
- renamed = true
80
- } finally {
81
- if (handle !== null) await handle.close().catch(() => {})
82
- // Only debris is cleaned up: after a successful rename the temp file is gone.
83
- if (!renamed) await io.unlink(temp).catch(() => {})
84
- }
85
- }
22
+ import { mkdir, open, readFile, stat } from 'node:fs/promises'
86
23
  import { homedir } from 'node:os'
87
24
  import path from 'node:path'
88
25
 
26
+ import { atomicWrite } from './atomic.js'
89
27
  import { TodoError } from './errors.js'
90
28
  import { LIMITS, SCHEMA_VERSION, normalizeStored } from './model.js'
91
29
 
30
+ // Re-exported because the tests (and anyone poking at the store) reach for it here; the
31
+ // implementation lives in atomic.js so the diagnostics log can share it.
32
+ export { atomicWrite }
33
+
92
34
  /**
93
35
  * @param {Record<string, string | undefined>} [env]
94
36
  * @returns {string} the DSH home directory
@@ -248,7 +190,13 @@ export function createStore(options) {
248
190
  */
249
191
  async function write(doc) {
250
192
  const directory = path.dirname(file)
251
- await mkdir(directory, { recursive: true })
193
+ try {
194
+ await mkdir(directory, { recursive: true })
195
+ } catch (error) {
196
+ // A file where the directory should be reports ENOTDIR on POSIX and EEXIST on
197
+ // Windows; both mean the same thing to the caller, so both become store-unwritable.
198
+ throw new TodoError('store-unwritable', `建不了待办目录 ${directory}:${error?.message ?? error}`, { file })
199
+ }
252
200
  const payload = `${JSON.stringify({ version: SCHEMA_VERSION, todos: doc.todos }, null, 2)}\n`
253
201
  try {
254
202
  await atomicWrite(file, payload, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
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",