dsh-global-todo 0.13.0 → 0.13.2

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,33 @@
1
+ ## [0.13.2] — 2026-10-22
2
+
3
+ Four gaps the Windows sweep found in the new weight/settings surface (#5).
4
+
5
+ - **`null` is a bad settings body, not zero.** `Number(null) === 0`, so `{captureMinWeight: null}`
6
+ answered 200, stored 0 and turned the whole capture gate off — while `{autoCollect: null}` was
7
+ correctly 400. Every numeric field now refuses `null` with a message saying to omit the field.
8
+ - **The weight threshold is two decimals or a bad request.** `{captureMinWeight: 0.345}` used to be
9
+ rounded to 0.35 and stored, the same silent adjustment a fractional minute is rejected for.
10
+ - **A deadline must be a real date.** `normalizeDue` was a bare `Date.parse`, so `"2026"` became
11
+ January 1st and `"October 2026"` an implementation-defined, timezone-dependent day. Input is now
12
+ a date, a date with time, or a date with time and zone — and `2026-02-31` is refused instead of
13
+ rolling over to March. The capture scorer reuses the same rule, so a garbage deadline can no
14
+ longer earn the +0.2 bonus that carried weak candidates past the gate.
15
+ - **A blank step is refused, not dropped.** `steps: ['']` was silently discarded while the caller
16
+ believed it was stored, and the scorer still gave it +0.15 for "already broken into steps".
17
+
18
+ ## [0.13.1] — 2026-10-22
19
+
20
+ The sort orders are total orders now, which is what CI caught.
21
+
22
+ - **Two todos created in the same millisecond could sort in either order.** The comparators
23
+ returned 0 for equal timestamps and leaned on the sort's stability, so a fast machine (the
24
+ packed `npm pack --dry-run` step, where the whole suite runs again) produced a different order
25
+ than the same tests a minute earlier. `seq`, which the service assigns monotonically, is the
26
+ final tiebreaker for all four sorts.
27
+ - **The ordering test uses a stepping clock**, so it asserts the semantics it is named for instead
28
+ of depending on how fast the machine creates three todos; a second test pins the same-millisecond
29
+ case on purpose.
30
+
1
31
  ## [0.13.0] — 2026-10-22
2
32
 
3
33
  Batch capture now scores what it is given, and drops what is not worth a todo.
package/README.md CHANGED
@@ -184,7 +184,7 @@ The plugin is a Loader row, not a hot-loaded script: **restart the host** (`dsh
184
184
  after installing, then refresh the page. A "待办 / Todos" entry appears in the sidebar.
185
185
 
186
186
  The npm package is the source of truth. A community catalog can lag behind it or point at an
187
- older release; pin the version you want (`dsh plugin --profile web add dsh-global-todo@0.12.4`)
187
+ older release; pin the version you want (`dsh plugin --profile web add dsh-global-todo@0.13.1`)
188
188
  when it matters.
189
189
 
190
190
  **Hand-copied a directory instead?** Check it before wondering why the plugins page looks wrong:
package/README.zh.md CHANGED
@@ -174,7 +174,7 @@ npx dsh-global-todo-doctor "$HOME/.dsh/plugins-local/全局待办插件"
174
174
  `Plugin metadata … ENOENT … \locale`。
175
175
 
176
176
  npm 上的包才是事实来源。社区目录可能落后于 npm、或指向某个旧 release;需要确定版本时
177
- 直接钉版本(`dsh plugin --profile web add dsh-global-todo@0.10.0`)。
177
+ 直接钉版本(`dsh plugin --profile web add dsh-global-todo@0.13.1`)。
178
178
 
179
179
  回滚:
180
180
 
@@ -15,10 +15,10 @@ of trusting this table.
15
15
 
16
16
  | Registry | Form | What it gives you | Verified state |
17
17
  |---|---|---|---|
18
- | **npm** | the package | the install path | `dsh-global-todo@0.12.10` |
18
+ | **npm** | the package | the install path | `dsh-global-todo@0.13.1` |
19
19
  | **1024 Store** ([deepseek1024.com](https://deepseek1024.com)) · [repo](https://github.com/imsai-sh/awesome-deepseek-harness-plugins) | catalog entry, merged [#654](https://github.com/imsai-sh/awesome-deepseek-harness-plugins/pull/654) | searchable, has a public API | 🟡 entry live, but its install line still resolves to `github:LUgABeEEE/dsh-global-todo` — their npm enrichment has not caught up. Install works either way. |
20
20
  | **dsh.pub** · [page](https://dsh.pub/en/plugins/dsh-global-todo/) | published page + `dshpub` CLI | `npx dshpub add LUgABeEEE/dsh-global-todo` | ✅ page live, pinned to a commit |
21
- | **DSH-Plugins-Marketplace** ([bradeGithub](https://github.com/bradeGithub/DSH-Plugins-Marketplace)) | `registry.json`, collected from the `dsh-plugin` topic | one-click install inside DSH | ✅ `v0.12.x`, risk tier `safe` |
21
+ | **DSH-Plugins-Marketplace** ([bradeGithub](https://github.com/bradeGithub/DSH-Plugins-Marketplace)) | `registry.json`, collected from the `dsh-plugin` topic | one-click install inside DSH | ✅ `v0.13.x`, risk tier `safe` |
22
22
  | **awesome-dsh-plugin** ([19.7k★](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)) | one YAML per plugin | the largest directory | ✅ merged [#7498](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/7498) |
23
23
  | **walkinglabs/awesome-deepseek-harness-plugins** | README entry (source-verified list) | review-backed listing | ✅ merged [#114](https://github.com/walkinglabs/awesome-deepseek-harness-plugins/pull/114) |
24
24
 
package/docs/ROADMAP.md CHANGED
@@ -15,14 +15,29 @@ is the durable part, and a stale number is worse than none.
15
15
  - Global list, one JSON file, atomic writes, one write path for both writers.
16
16
  - Creation time on every entry, immutable and shown everywhere.
17
17
  - Attachments as references: any number of documents and addresses per todo.
18
- - Nine `global_todo_*` tools — `add`, `capture`, `list`, `get`, `update`, `link`, `note`,
19
- `done`, `remove`; `reopen` joined them later (below). What `/health` reports as
18
+ - Ten `global_todo_*` tools — `add`, `capture`, `list`, `get`, `update`, `link`, `note`,
19
+ `done`, `remove`, and `reopen`, which followed in 0.3. What `/health` reports as
20
20
  `tools.registered` is the list as it stands, and panel and tools share the same validation.
21
21
  - Four hand-off modes, with success **and failure** recorded on the record.
22
22
  - Restore/undo for a delete, keyboard shortcuts, editable detail panel.
23
23
  - Proactive capture: `global_todo_capture` for batches, plus guidance that says when the
24
24
  agent should record something itself and when it must not.
25
25
 
26
+ ### Landed in 0.6
27
+
28
+ The panel became one column with one type scale, and every conditional state got a baseline.
29
+
30
+ - **The layout is a column, not a wrapping row** (`0.6.0`); the header wraps its actions, the
31
+ toolbar is two semantic rows, and the directory / handoff / browse controls sit behind a single
32
+ Context toggle. Five type sizes, one 28px control height, a 340px settings inspector instead of
33
+ an in-flow block.
34
+ - **The settings panel was rebuilt as an inspector** (`0.6.4`): title row, three labelled groups,
35
+ help text under every field, a pinned save row, and About showing the data file and version.
36
+ - **Every conditional state has an image** (`0.6.3`, `0.6.8`): empty list, filter with no match,
37
+ inbox, due reminder, selection, settings. `npm run screenshots` photographs them from the same
38
+ `SCENARIOS` table the static preview renders, and `npm run check:render` asserts each state's
39
+ marker, so a blank picture fails CI instead of shipping.
40
+
26
41
  ### Landed since 0.1
27
42
 
28
43
  Things that used to sit on the Next list (or in the Later table) and are facts in the
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ import { createStore, resolveStorePath } from './store.js'
37
37
  import { createTools } from './tools.js'
38
38
 
39
39
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
40
- export const VERSION = '0.13.0'
40
+ export const VERSION = '0.13.2'
41
41
 
42
42
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
43
43
  export const name = 'dsh-global-todo'
package/lib/model.js CHANGED
@@ -108,6 +108,52 @@ function normalizeText(value, field, max, code) {
108
108
  return text
109
109
  }
110
110
 
111
+ /**
112
+ * @param {unknown} value
113
+ * @returns {string | null} canonical ISO-8601 (UTC) or null
114
+ */
115
+ /**
116
+ * The shapes a deadline may take: a date, or a date with time, optionally zoned.
117
+ *
118
+ * The old implementation was a bare `Date.parse`, which accepts `"2026"` (January 1st,
119
+ * invented), `"October 2026"` (implementation-defined, timezone-dependent) and anything
120
+ * else the engine happens to recognise. A deadline that silently becomes New Year's Day
121
+ * is worse than a rejected one.
122
+ */
123
+ const DUE_SHAPE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2})?)?$/
124
+
125
+ /**
126
+ * @param {unknown} value
127
+ * @returns {{ ok: true, iso: string } | { ok: false, reason: string }}
128
+ */
129
+ export function parseDueText(value) {
130
+ if (typeof value !== 'string') return { ok: false, reason: '不是字符串' }
131
+ const text = value.trim()
132
+ const match = DUE_SHAPE.exec(text)
133
+ if (match === null) return { ok: false, reason: '只接受 2026-10-20 / 2026-10-20T10:00 / 带时区三种写法' }
134
+ const year = Number(match[1])
135
+ const month = Number(match[2])
136
+ const day = Number(match[3])
137
+ if (month < 1 || month > 12) return { ok: false, reason: '月份不合法' }
138
+ if (day < 1 || day > new Date(Date.UTC(year, month, 0)).getUTCDate()) return { ok: false, reason: '日期不合法' }
139
+ if (match[4] !== undefined && (Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6] ?? 0) > 59)) {
140
+ return { ok: false, reason: '时间不合法' }
141
+ }
142
+ // A zone-less value keeps its usual meaning (local time, as the panel's datetime picker
143
+ // produces); only the accepted shapes were narrowed.
144
+ const parsed = Date.parse(text)
145
+ if (Number.isNaN(parsed)) return { ok: false, reason: '无法解析' }
146
+ return { ok: true, iso: new Date(parsed).toISOString() }
147
+ }
148
+
149
+ /**
150
+ * @param {unknown} value
151
+ * @returns {boolean} whether this value would be accepted as a deadline
152
+ */
153
+ export function isValidDue(value) {
154
+ return typeof value === 'string' && value.trim() !== '' && parseDueText(value).ok === true
155
+ }
156
+
111
157
  /**
112
158
  * @param {unknown} value
113
159
  * @returns {string | null} canonical ISO-8601 (UTC) or null
@@ -115,13 +161,12 @@ function normalizeText(value, field, max, code) {
115
161
  export function normalizeDue(value) {
116
162
  if (value === undefined || value === null || value === '') return null
117
163
  if (typeof value !== 'string') throw new TodoError('invalid-due', '截止时间必须是 ISO-8601 字符串')
118
- const text = value.trim()
119
- if (text === '') return null
120
- const parsed = Date.parse(text)
121
- if (Number.isNaN(parsed)) {
122
- throw new TodoError('invalid-due', `无法解析截止时间:${value}(用 ISO-8601,例如 2026-10-20T10:00 或 2026-10-20)`)
164
+ if (value.trim() === '') return null
165
+ const verdict = parseDueText(value)
166
+ if (verdict.ok !== true) {
167
+ throw new TodoError('invalid-due', `无法解析截止时间:${value}(${verdict.reason};例如 2026-10-20 或 2026-10-20T10:00)`)
123
168
  }
124
- return new Date(parsed).toISOString()
169
+ return verdict.iso
125
170
  }
126
171
 
127
172
  /**
@@ -722,8 +767,11 @@ export function normalizeSteps(value) {
722
767
  if (text.length > LIMITS.step) {
723
768
  throw new TodoError('invalid-steps', `每一步最多 ${LIMITS.step} 字,收到 ${text.length} 字`)
724
769
  }
725
- // A blank line in a textarea is a typo, not a step.
726
- if (text !== '') out.push({ text, done: false })
770
+ // A blank step used to be dropped on the floor while the caller believed it was
771
+ // stored — and it earned the capture weight bonus for "already broken into steps".
772
+ // The panel already filters empty lines, so this only catches a caller that meant it.
773
+ if (text === '') throw new TodoError('invalid-steps', '步骤不能为空')
774
+ out.push({ text, done: false })
727
775
  continue
728
776
  }
729
777
  if (item === null || typeof item !== 'object' || Array.isArray(item)) {
@@ -733,7 +781,7 @@ export function normalizeSteps(value) {
733
781
  if (text.length > LIMITS.step) {
734
782
  throw new TodoError('invalid-steps', `每一步最多 ${LIMITS.step} 字,收到 ${text.length} 字`)
735
783
  }
736
- if (text === '') continue
784
+ if (text === '') throw new TodoError('invalid-steps', '步骤不能为空')
737
785
  out.push({ text, done: item.done === true })
738
786
  }
739
787
  return out
package/lib/routes.js CHANGED
@@ -796,10 +796,21 @@ function assertSettingsRange(body) {
796
796
  }
797
797
  for (const [field, [low, high, whole]] of Object.entries(ranges)) {
798
798
  const value = body[field]
799
- if (value === undefined || value === null) continue
799
+ if (value === undefined) continue
800
+ // `null` used to fall through to the clamp: `Number(null) === 0`, so a null weight
801
+ // became 0 and turned the whole capture gate off while answering 200. A field that is
802
+ // present but null is a bad body, like every other non-number here.
803
+ if (value === null) {
804
+ throw new TodoError('invalid-body', `${field} 不能是 null;要恢复默认就从请求里省略它`)
805
+ }
800
806
  if (typeof value !== 'number' || !Number.isFinite(value)) {
801
807
  throw new TodoError('invalid-body', `${field} 必须是数字(${low}–${high})`)
802
808
  }
809
+ // The weight is a two-decimal number by contract; storing 0.345 as 0.35 is the same
810
+ // silent adjustment that a fractional minute is rejected for.
811
+ if (field === 'captureMinWeight' && Math.round(value * 100) / 100 !== value) {
812
+ throw new TodoError('invalid-body', 'captureMinWeight 最多两位小数,收到 ' + String(value))
813
+ }
803
814
  // normalizeSettings only accepts integers for the minute fields and otherwise falls back
804
815
  // to the default — a silently ignored 30.5 is the same trap as a silent clamp.
805
816
  if (whole === true && !Number.isInteger(value)) {
package/lib/service.js CHANGED
@@ -219,20 +219,24 @@ export function createService(options) {
219
219
  if (!SORTS.includes(sort)) {
220
220
  throw new TodoError('invalid-body', `排序只能是 ${SORTS.join(' / ')},收到 ${sort}`)
221
221
  }
222
+ // Every comparator ends on `seq`, which the service assigns monotonically: two todos
223
+ // created in the same millisecond used to tie, and the order then depended on the sort's
224
+ // stability rather than on anything the list guarantees. A CI run caught that.
225
+ const newestFirst = (a, b) => b.createdAt - a.createdAt || (b.seq ?? 0) - (a.seq ?? 0)
222
226
  out = out.slice().sort((a, b) => {
223
- if (sort === 'updated') return b.updatedAt - a.updatedAt
227
+ if (sort === 'updated') return b.updatedAt - a.updatedAt || (b.seq ?? 0) - (a.seq ?? 0)
224
228
  if (sort === 'due') {
225
229
  const left = a.dueAt === null ? Number.POSITIVE_INFINITY : Date.parse(a.dueAt)
226
230
  const right = b.dueAt === null ? Number.POSITIVE_INFINITY : Date.parse(b.dueAt)
227
231
  if (left !== right) return left - right
228
- return b.createdAt - a.createdAt
232
+ return newestFirst(a, b)
229
233
  }
230
234
  if (sort === 'priority') {
231
235
  const rank = PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]
232
236
  if (rank !== 0) return rank
233
- return b.createdAt - a.createdAt
237
+ return newestFirst(a, b)
234
238
  }
235
- return b.createdAt - a.createdAt
239
+ return newestFirst(a, b)
236
240
  })
237
241
 
238
242
  const limit = Number.isInteger(query.limit) && query.limit > 0 ? Math.min(query.limit, 1000) : 200
package/lib/weight.js CHANGED
@@ -13,6 +13,8 @@
13
13
  * @module dsh-global-todo/weight
14
14
  */
15
15
 
16
+ import { isValidDue } from './model.js'
17
+
16
18
  /** A phrase that reads like a commitment rather than an observation. */
17
19
  const COMMITMENT = ['记得', '别忘', '回头', '下周', '明天', '后天', '截止', 'deadline', '要交', '待确认', '等回复', '跟进', '安排', '发我', '确认一下', '记得要', '别忘了']
18
20
 
@@ -46,10 +48,15 @@ export function scoreItem(item, options = {}) {
46
48
  else if (length >= 3) add('specific', 0.05, '标题偏短')
47
49
  else add('tiny', -0.15, '标题几乎没有信息')
48
50
 
49
- const due = typeof item.dueAt === 'string' && Number.isFinite(Date.parse(item.dueAt))
51
+ // The same validity rule create() applies: a date the store would reject must not earn
52
+ // the deadline bonus here, or a garbage dueAt could carry a weak candidate past the gate.
53
+ const due = isValidDue(item.dueAt)
50
54
  if (due) add('due', 0.2, '有可解析的截止时间')
51
55
 
52
- if (Array.isArray(item.steps) && item.steps.length > 0) add('steps', 0.15, '已经拆成步骤')
56
+ const stepTexts = Array.isArray(item.steps)
57
+ ? item.steps.map((step) => (typeof step === 'string' ? step : step !== null && typeof step === 'object' ? step.text : ''))
58
+ : []
59
+ if (stepTexts.some((text) => typeof text === 'string' && text.trim() !== '')) add('steps', 0.15, '已经拆成步骤')
53
60
  if (Array.isArray(item.links) && item.links.length > 0) add('links', 0.1, '挂了文档或地址')
54
61
  if (typeof item.evidence === 'string' && item.evidence.trim() !== '') add('evidence', 0.1, '引用了原话')
55
62
  if (item.priority === 'urgent') add('priority', 0.15, '标了 P0')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
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",