dsh-global-todo 0.13.1 → 0.14.0

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,40 @@
1
+ ## [0.14.0] — 2026-10-22
2
+
3
+ Choose how much surface a deployment pays for.
4
+
5
+ - **`config.toolGroups`: `all` (10 tools, default), `core` (6 — the list itself) or `none`.**
6
+ The ten tool schemas cost about **2k tokens per turn**; a deployment that only wants the panel
7
+ should not pay for a collaboration surface it never asks an agent to use. An unrecognised value
8
+ is reported and treated as `all`, so a typo cannot silently remove tools.
9
+ - **The guidance section follows the tools.** With `core`/`none` the model is no longer told to
10
+ call `global_todo_capture`, which would be a call that cannot exist; `none` injects no guidance
11
+ at all.
12
+ - **`/health` reports `modules`** (`toolGroups`, `registered`, `capture`), and the panel hides the
13
+ "collect from this conversation" entries when the capture tool is off instead of offering a
14
+ button that 404s. An older host with no `modules` field is assumed fully on.
15
+ - **Deferred, on purpose**: splitting the package into several Loader rows (one row per module),
16
+ which the harness does support — `dsh.bundle.patch` may list files and a row may name a package
17
+ subpath. It buys Plugins-page cards for a user base of three stars; the config key buys the same
18
+ context saving today. `modules` in `/health` is the protocol that split would need, so it is in
19
+ place before anyone needs it.
20
+
21
+ ## [0.13.2] — 2026-10-22
22
+
23
+ Four gaps the Windows sweep found in the new weight/settings surface (#5).
24
+
25
+ - **`null` is a bad settings body, not zero.** `Number(null) === 0`, so `{captureMinWeight: null}`
26
+ answered 200, stored 0 and turned the whole capture gate off — while `{autoCollect: null}` was
27
+ correctly 400. Every numeric field now refuses `null` with a message saying to omit the field.
28
+ - **The weight threshold is two decimals or a bad request.** `{captureMinWeight: 0.345}` used to be
29
+ rounded to 0.35 and stored, the same silent adjustment a fractional minute is rejected for.
30
+ - **A deadline must be a real date.** `normalizeDue` was a bare `Date.parse`, so `"2026"` became
31
+ January 1st and `"October 2026"` an implementation-defined, timezone-dependent day. Input is now
32
+ a date, a date with time, or a date with time and zone — and `2026-02-31` is refused instead of
33
+ rolling over to March. The capture scorer reuses the same rule, so a garbage deadline can no
34
+ longer earn the +0.2 bonus that carried weak candidates past the gate.
35
+ - **A blank step is refused, not dropped.** `steps: ['']` was silently discarded while the caller
36
+ believed it was stored, and the scorer still gave it +0.15 for "already broken into steps".
37
+
1
38
  ## [0.13.1] — 2026-10-22
2
39
 
3
40
  The sort orders are total orders now, which is what CI caught.
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:
@@ -434,6 +434,7 @@ topic feeds we have not verified, is [docs/DISTRIBUTION.md](docs/DISTRIBUTION.md
434
434
  storePath: /home/me/notes/global-todos.json # a synced or versioned folder
435
435
  dispatchDir: /home/me/projects/thing # default cwd for a new session
436
436
  requireExistingFiles: true # refuse a document that is not there yet
437
+ toolGroups: all # all (10 tools) | core (6: the list itself) | none
437
438
  ```
438
439
 
439
440
  The file is the artifact: plain JSON, one record per todo, written through a temp file and
@@ -473,8 +474,11 @@ and [docs/ROADMAP.md](docs/ROADMAP.md) for what could come next.
473
474
  npm run check # parse every file, assert the shipping shape, and — when a DSH
474
475
  # profile is present — validate every tool schema against the
475
476
  # Host's own validator
476
- npm test # 257 unit, contract and bundle tests, zero dependencies
477
- npm run verify # both
477
+ npm test # unit, contract and bundle tests, zero dependencies
478
+ npm run verify # static checks + tests
479
+ npm run check:contracts # the cross-field matrix a reviewer re-tests by hand:
480
+ # one invalid value, one answer, whichever field it lands on
481
+ npm run check:windows # the same idea on a real filesystem (Windows in CI)
478
482
  npm run check:matrix # the running dsh against engines.dsh, then the manifest,
479
483
  # bundle and tool-schema contracts (no preview writes)
480
484
  ```
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
 
@@ -317,6 +317,7 @@ dsh plugin --profile web remove dsh-global-todo
317
317
  storePath: /home/me/notes/global-todos.json # 放进同步盘或版本库
318
318
  dispatchDir: /home/me/projects/thing # 新会话的默认工作目录
319
319
  requireExistingFiles: true # 文件还不存在就先不让挂
320
+ toolGroups: all # all(10 个工具)| core(6 个:只操作清单)| none
320
321
  ```
321
322
 
322
323
  **文件本身就是产物**:纯 JSON、一条记录一个待办,写入走「临时文件 + rename」,
package/cordis.patch.yml CHANGED
@@ -24,6 +24,13 @@
24
24
  # Inject the short usage guidance into every turn. `false` leaves the tools
25
25
  # available but stops asking the model to record things on its own.
26
26
  guidance: true
27
+ # Which Agent tools this deployment registers. The ten schemas cost roughly
28
+ # 2k tokens per turn, so a deployment that only wants the panel can shed them:
29
+ # all 10 tools (default)
30
+ # core 6 tools: add / list / get / update / note / done
31
+ # none no tools; the guidance section is skipped too
32
+ # An unrecognised value is reported and treated as `all`.
33
+ toolGroups: all
27
34
  # Starting point for the panel's built-in folder browser when the shell has
28
35
  # no native picker. Empty = home directory, then the Host's cwd.
29
36
  browseRoot: ''
@@ -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
@@ -133,14 +148,13 @@ reminder) photographed into `docs/screenshots/` by `npm run screenshots`, and
133
148
  `npm run check:render` asserting each state's marker.
134
149
  ### Partly landed
135
150
 
136
- - **Session read-back** — `sessionAlive` is on every listed todo (`sessionAlive` in
137
- `lib/dispatch.js`, surfaced by `GET /list`). Reading what that session last said, and
138
- linking to it, is not: `lib/completion.js` reads `assistant/message` only to close a todo
139
- this plugin dispatched, and stores one summary in its completion record.
140
- - **What the agents produced** — the per-todo record with a summary and the changed files landed
141
- (`completeTodo()` in `lib/model.js`, 「完成记录」 in `lib/client.js`, the 已完成 filter and
142
- group). The cross-todo "what did the sessions finish this week, and what did they touch" view
143
- did not.
151
+ - ~~**Session read-back**~~ — landed: `GET /session?id=` returns `alive`, `dir` and the last
152
+ assistant `tail` (`lib/routes.js`, `sessionTails` in `lib/index.js`), and the detail panel shows
153
+ that line (`lib/client.js`). It stays cheap and failure-tolerant: a conversation this process
154
+ has not seen speak answers `null`, which is the normal case for an old hand-off.
155
+ - ~~**What the agents produced**~~ — landed: `GET /report?days=7` aggregates completion records
156
+ into finished items, conversations, touched files and the review backlog (`lib/report.js`), and
157
+ the panel shows it from `⋯ → 本周报告`.
144
158
  - **Attachment health** — a dead file link is flagged on read, but there is no 「重新挂载」 next
145
159
  to it (`relink` does not exist anywhere in `lib/`), and `annotateLinks()` has no unit test:
146
160
  today the render assertion in `tools/preview.mjs` is the only coverage.
@@ -162,17 +176,12 @@ plan. `normalizeSteps()` in `lib/model.js`, `dispatchPrompt()` in `lib/service.j
162
176
  What is left of the original idea: making a step addressable on its own (a tool that ticks step 3
163
177
  without resending the array) — not worth a tenth tool today.
164
178
 
165
- ### 2. Session read-back — makes hand-off stop being fire-and-forget
166
-
167
- `sessionAlive` says a bound conversation still exists. Next: one line of its latest
168
- assistant message plus a link to open it.
179
+ ### 2. ~~Session read-back~~ — landed in 0.12
169
180
 
170
- - Today: `lib/completion.js` reads `assistant/message` only to close a todo this plugin
171
- dispatched; nothing exposes it to the panel.
172
- - Effort: medium, and it must stay cheap and failure-tolerant: a missing or huge message is
173
- a normal case, not an error.
174
- - Why second: the panel currently cannot answer "did the thing I handed over get anywhere",
175
- which is the question people actually ask.
181
+ `GET /session?id=` answers `alive`, `dir` and the last assistant `tail`, and the detail panel
182
+ shows that line instead of a bare "still alive" flag. `lib/completion.js` keeps the tail it
183
+ already reads for its own closing logic; a conversation this process has not seen speak answers
184
+ `null`, which is a normal case rather than an error.
176
185
 
177
186
  ### 3. ~~Markdown import and export~~ — landed in 0.10.0
178
187
 
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/client.js CHANGED
@@ -3089,6 +3089,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3089
3089
 
3090
3090
  // The two toggles belong with the other "how this panel behaves" controls, not in
3091
3091
  // the title row.
3092
+ // Absent modules (an older host) means everything is on; a switched-off capture tool
3093
+ // means the group action would ask for a tool that cannot exist.
3094
+ const captureOn = state.health?.modules?.capture !== false
3092
3095
  const settingsOn = state.health?.settings?.autoCollect === true
3093
3096
  // The Host already knows why it stayed quiet; the switch's tooltip is the cheapest
3094
3097
  // place to show it, and "nothing ever happens" stops being a mystery.
@@ -4789,7 +4792,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
4789
4792
  )
4790
4793
  : null,
4791
4794
  [
4792
- group.sessionId === undefined
4795
+ group.sessionId === undefined || !captureOn
4793
4796
  ? null
4794
4797
  : {
4795
4798
  key: 'capture',
@@ -4810,7 +4813,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
4810
4813
  : h(Menu, {
4811
4814
  label: t('group.actions'),
4812
4815
  items: [
4813
- group.sessionId === undefined
4816
+ group.sessionId === undefined || !captureOn
4814
4817
  ? null
4815
4818
  : {
4816
4819
  key: 'capture',
package/lib/index.js CHANGED
@@ -34,10 +34,10 @@ import { LIMITS } from './model.js'
34
34
  import { ROUTE_BASE, createRoutes } from './routes.js'
35
35
  import { createService } from './service.js'
36
36
  import { createStore, resolveStorePath } from './store.js'
37
- import { createTools } from './tools.js'
37
+ import { createTools, normalizeToolGroups, selectTools } from './tools.js'
38
38
 
39
39
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
40
- export const VERSION = '0.13.1'
40
+ export const VERSION = '0.14.0'
41
41
 
42
42
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
43
43
  export const name = 'dsh-global-todo'
@@ -49,6 +49,15 @@ export const DEFAULTS = Object.freeze({
49
49
  activityLimit: LIMITS.activity,
50
50
  requireExistingFiles: true,
51
51
  guidance: true,
52
+ /**
53
+ * Which agent tools this deployment registers: `all` (10), `core` (6, the list
54
+ * itself) or `none`.
55
+ *
56
+ * The schemas cost about 2k tokens per turn; a deployment that only wants the
57
+ * panel should not pay for the collaboration surface. An unrecognised value is
58
+ * reported and treated as `all`, because a typo must not silently remove tools.
59
+ */
60
+ toolGroups: 'all',
52
61
  // Starting point for the built-in folder browser when the shell has no picker.
53
62
  // Documented here because a patch override replaces `config` wholesale and has
54
63
  // to restate every key it wants to keep.
@@ -65,6 +74,24 @@ export const DEFAULTS = Object.freeze({
65
74
  *
66
75
  * Kept short on purpose: it is injected on every turn.
67
76
  */
77
+ /**
78
+ * The guidance section for a tool-group mode: no tools, no instructions.
79
+ *
80
+ * Telling the model to use `global_todo_capture` when that tool was switched off
81
+ * is worse than saying nothing — it produces a call that cannot exist.
82
+ *
83
+ * @param {string} mode
84
+ * @returns {string} an empty string when nothing is registered
85
+ */
86
+ export function guidanceFor(mode) {
87
+ const wanted = normalizeToolGroups(mode)
88
+ if (wanted === 'none') return ''
89
+ if (wanted === 'all') return GUIDANCE
90
+ return GUIDANCE.split('\n')
91
+ .filter((line) => !line.includes('global_todo_capture') && !line.includes('抓一下待办'))
92
+ .join('\n')
93
+ }
94
+
68
95
  const GUIDANCE = [
69
96
  '【全局待办】一份跨会话、跨工作区、人和你共用的待办清单(`global_todo_*` 工具)。它和内置 `todo_write` 不是一回事:那个是你这个会话的计划,这份属于用户、跨会话保留。',
70
97
  '',
@@ -503,8 +530,15 @@ export function apply(ctx, config) {
503
530
  toolCtx.effect(() => {
504
531
  try {
505
532
  const definitions = createTools(service, { metrics, settings })
506
- const disposers = definitions.map((definition) => toolCtx.tools.register(definition))
507
- toolState.registered = definitions.map((definition) => definition.name)
533
+ const mode = normalizeToolGroups(resolved.toolGroups)
534
+ if (resolved.toolGroups !== mode) {
535
+ // A typo in `config.toolGroups` must be visible, not silently `all`.
536
+ logger?.warn?.(`dsh-global-todo: 未知的 toolGroups=${String(resolved.toolGroups)},按 all 处理`)
537
+ }
538
+ const selected = selectTools(definitions, mode)
539
+ const disposers = selected.map((definition) => toolCtx.tools.register(definition))
540
+ toolState.registered = selected.map((definition) => definition.name)
541
+ toolState.mode = mode
508
542
  toolState.error = null
509
543
  toolCtx.logger?.info?.(`dsh-global-todo: 注册了 ${definitions.length} 个 Agent 工具`)
510
544
  return () => {
@@ -555,6 +589,13 @@ export function apply(ctx, config) {
555
589
  autoCollect: autoCollectState,
556
590
  reminders: reminderState,
557
591
  version: VERSION,
592
+ // What this deployment actually exposes, for the panel to hide absent entries and
593
+ // for a check to tell "switched off" apart from "failed to register".
594
+ modulesInfo: () => ({
595
+ toolGroups: normalizeToolGroups(resolved.toolGroups),
596
+ registered: toolState.registered.slice(),
597
+ capture: toolState.registered.includes('global_todo_capture'),
598
+ }),
558
599
  // What is on disk, which after an upgrade is not what this process is running.
559
600
  // The panel talks to a tree that changed under it; /health is where that is visible.
560
601
  diskVersion,
@@ -573,13 +614,14 @@ export function apply(ctx, config) {
573
614
  }, 'dsh-global-todo: web routes')
574
615
  })
575
616
 
576
- if (resolved.guidance !== false) {
617
+ const guidanceText = guidanceFor(resolved.toolGroups)
618
+ if (resolved.guidance !== false && guidanceText !== '') {
577
619
  ctx.inject(['systemPrompt'], (promptCtx) => {
578
620
  try {
579
621
  const dispose = promptCtx.systemPrompt.section({
580
622
  name: 'dsh-global-todo:guidance',
581
623
  order: 3040,
582
- text: GUIDANCE,
624
+ text: guidanceText,
583
625
  })
584
626
  return () => {
585
627
  try {
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
@@ -438,6 +438,10 @@ export function createRoutes(deps) {
438
438
  // Tool registration is the surface that fails quietly, so it is reported
439
439
  // rather than assumed.
440
440
  tools: deps.tools ?? { registered: [], error: null },
441
+ // Which optional surfaces this deployment has. The panel hides entries it cannot
442
+ // call, and a check can tell "switched off" (mode/registered) from "failed"
443
+ // (tools.error above). Absent on an older host → assume everything is on.
444
+ modules: deps.modulesInfo?.() ?? { toolGroups: 'all', registered: [], capture: true },
441
445
  // Automatic completion is the other surface that can fail quietly: if the
442
446
  // session watcher did not attach, the only symptom would be todos that
443
447
  // never finish themselves.
@@ -796,10 +800,21 @@ function assertSettingsRange(body) {
796
800
  }
797
801
  for (const [field, [low, high, whole]] of Object.entries(ranges)) {
798
802
  const value = body[field]
799
- if (value === undefined || value === null) continue
803
+ if (value === undefined) continue
804
+ // `null` used to fall through to the clamp: `Number(null) === 0`, so a null weight
805
+ // became 0 and turned the whole capture gate off while answering 200. A field that is
806
+ // present but null is a bad body, like every other non-number here.
807
+ if (value === null) {
808
+ throw new TodoError('invalid-body', `${field} 不能是 null;要恢复默认就从请求里省略它`)
809
+ }
800
810
  if (typeof value !== 'number' || !Number.isFinite(value)) {
801
811
  throw new TodoError('invalid-body', `${field} 必须是数字(${low}–${high})`)
802
812
  }
813
+ // The weight is a two-decimal number by contract; storing 0.345 as 0.35 is the same
814
+ // silent adjustment that a fractional minute is rejected for.
815
+ if (field === 'captureMinWeight' && Math.round(value * 100) / 100 !== value) {
816
+ throw new TodoError('invalid-body', 'captureMinWeight 最多两位小数,收到 ' + String(value))
817
+ }
803
818
  // normalizeSettings only accepts integers for the minute fields and otherwise falls back
804
819
  // to the default — a silently ignored 30.5 is the same trap as a silent clamp.
805
820
  if (whole === true && !Number.isInteger(value)) {
package/lib/tools.js CHANGED
@@ -325,6 +325,40 @@ function originOf(exec) {
325
325
  * @param {{ now?: () => number, metrics?: object }} [options]
326
326
  * @returns {object[]} definitions for `ctx.tools.register`
327
327
  */
328
+ /**
329
+ * Which tools a deployment gets. `core` is the list itself; `all` adds the
330
+ * collaboration surface (batch capture, attachments, delete, reopen).
331
+ *
332
+ * The ten tool schemas are about 2k tokens per turn, which is real money for a
333
+ * deployment that only wants a todo list and never asks an agent to touch it.
334
+ * A config value is enough to shed that; splitting the package into several
335
+ * Loader rows is the heavier option, deferred until adoption asks for it.
336
+ */
337
+ export const TOOL_GROUPS = Object.freeze({
338
+ core: Object.freeze(['global_todo_add', 'global_todo_list', 'global_todo_get', 'global_todo_update', 'global_todo_note', 'global_todo_done']),
339
+ advanced: Object.freeze(['global_todo_capture', 'global_todo_link', 'global_todo_remove', 'global_todo_reopen']),
340
+ })
341
+
342
+ /**
343
+ * @param {string} value
344
+ * @returns {'all' | 'core' | 'none'} one of the supported modes
345
+ */
346
+ export function normalizeToolGroups(value) {
347
+ return value === 'core' || value === 'none' || value === 'all' ? value : 'all'
348
+ }
349
+
350
+ /**
351
+ * @param {{ name: string }[]} definitions
352
+ * @param {'all' | 'core' | 'none'} mode
353
+ * @returns {{ name: string }[]} the subset this mode registers
354
+ */
355
+ export function selectTools(definitions, mode) {
356
+ const wanted = normalizeToolGroups(mode)
357
+ if (wanted === 'none') return []
358
+ if (wanted === 'all') return definitions
359
+ return definitions.filter((definition) => TOOL_GROUPS.core.includes(definition.name))
360
+ }
361
+
328
362
  export function createTools(service, options = {}) {
329
363
  const now = options.now ?? (() => Date.now())
330
364
  // Read lazily: the settings object is created before the tools and its value can change
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.1",
3
+ "version": "0.14.0",
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
+ "check:contracts": "node tools/contracts-check.mjs",
76
77
  "notify": "node tools/notify.mjs",
77
78
  "test": "node --test \"test/**/*.test.mjs\"",
78
79
  "verify": "npm run check && npm run check:render && npm test",