dsh-global-todo 0.4.0 → 0.4.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
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.2] — 2026-10-19
11
+
12
+ Noise is the failure mode that matters for a list other people are asked to trust.
13
+
14
+ - **Capture decides with a gate, not a title comparison.** The old check compared normalized
15
+ titles exactly against active rows, which missed the ordinary case — same errand, different
16
+ wording — and happily re-admitted something finished yesterday. `lib/capture.js` now refuses an
17
+ item when its normalized title matches a live row, when a close paraphrase (Dice over character
18
+ bigrams, 0.8) appears within 72 hours, or when the same errand was finished in the last 7 days.
19
+ Archived and cancelled rows are set-aside history and do not block a new one; every refusal
20
+ carries its reason in `skipped[]`.
21
+ ## [0.4.1] — 2026-10-19
22
+
23
+ The half-finished half: the selection could only be assigned, and the only way to get a record out
24
+ of the list was to delete it. Both are now actions of their own.
25
+
26
+ - **A selection can be completed, archived and tagged.** `POST /bulk` applies one change across the
27
+ picked rows, and the selection bar gained 完成 / 归档 / a tag field. Each record still goes through
28
+ the same validation and activity trail as a single edit; an id that no longer exists is reported
29
+ in `unmatched` rather than failing the whole batch, because a selection is a human gesture and one
30
+ stale row must not discard the other nine.
31
+
32
+ - **Archive instead of delete.** `archived` joined the statuses, so a finished-or-abandoned record can
33
+ leave the active view without losing its history. The panel offers 归档 / 取消归档 in the row menu,
34
+ the filter chip appears only once something is archived, and `global_todo_update` now accepts
35
+ `status` at all — before this, marking something in progress required the tool named
36
+ `global_todo_done`, which is exactly the kind of name/capability mismatch that makes a model misuse
37
+ a tool.
38
+
39
+ Still deliberately out of scope for this release: the wide two-column detail layout, and the
40
+ cross-machine conflict check — both deserve their own pass rather than riding along with a data-model
41
+ change.
10
42
  ## [0.4.0] — 2026-10-19
11
43
 
12
44
  The first release after the plugin was listed in a community catalog. It is a reliability
package/README.md CHANGED
@@ -194,7 +194,7 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
194
194
  | `global_todo_capture` | **Record a batch**: for 「抓一下待办」 or a turn that produced several follow-ups; each entry is validated on its own |
195
195
  | `global_todo_list` | Query by state, tag or text; every row carries its creation time |
196
196
  | `global_todo_get` | Read one item in full, including attachments and the last hand-off |
197
- | `global_todo_update` | Change any field of one item; `""` clears, `[]` empties |
197
+ | `global_todo_update` | Change any field of one item, including `status` (`archived` archives without deleting); `""` clears, `[]` empties |
198
198
  | `global_todo_link` | Attach or detach documents and addresses |
199
199
  | `global_todo_done` | Mark done, back to open, or in progress |
200
200
  | `global_todo_reopen` | Put a **wrongly finished** item back (`done` / `cancelled` → `open`); refuses anything else, and the trail records it as the agent |
@@ -227,6 +227,12 @@ produces noise, and a noisy list is worse than an empty one:
227
227
  The boundary: it **records, it never starts work**. The list belongs to the human, and
228
228
  starting a task stays a human decision (see the non-goals in [ROADMAP](docs/ROADMAP.md)).
229
229
 
230
+
231
+ Capture is not allowed to become noise. Before an item is written, the same normalized title
232
+ (full-width, case, spacing and punctuation folded away), a close paraphrase within the last 72
233
+ hours (Dice similarity over character bigrams, 0.8), and the same errand finished in the last 7
234
+ days are all refused with a reason on the record; archived and cancelled rows do not block a
235
+ new one. The rules live in `lib/capture.js` and are tested on their own.
230
236
  ### HTTP API
231
237
 
232
238
  The panel talks to the host over these routes. They are fenced by the host connection
@@ -240,6 +246,7 @@ fence when that service is present, and by a strict loopback check otherwise.
240
246
  | GET | `/api/global-todo/export` | — (the store's own JSON document, for backup or migration) |
241
247
  | POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge }` — `replace` is an exact round trip, `merge` updates by id and appends (what the panel sends) |
242
248
  | POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
249
+ | 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 |
243
250
  | 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) |
244
251
  | POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
245
252
  | POST | `/api/global-todo/update` | `{ id, ...fields, by?: human\|agent }` |
package/README.zh.md CHANGED
@@ -120,7 +120,7 @@ dsh plugin --profile web remove dsh-global-todo
120
120
  | `global_todo_capture` | **一次登记多条**(批量抓取):用户说「抓一下待办」,或你一轮里发现好几件要跟进的;逐条校验,坏的那条只跳过它自己 |
121
121
  | `global_todo_list` | 按状态/标签/关键词查询,每条都带上加入时间 |
122
122
  | `global_todo_get` | 读一条的全文,含附件与最近一次派发 |
123
- | `global_todo_update` | 改任意字段;`""` 清空,`[]` 清空列表 |
123
+ | `global_todo_update` | 改任意字段,包括 `status`(`archived` 归档而不删除);`""` 清空,`[]` 清空列表 |
124
124
  | `global_todo_link` | 挂上或摘掉文档与地址 |
125
125
  | `global_todo_done` | 标完成 / 标回未完成 / 标进行中 |
126
126
  | `global_todo_reopen` | 把**被误标完成**的一条标回未完成(只做 `done` / `cancelled` → `open`);本来就未完成的会被拒绝,动作按 agent 记进活动轨迹 |
@@ -143,6 +143,7 @@ dsh plugin --profile web remove dsh-global-todo
143
143
  | POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge }`——`replace` 是无损往返,`merge` 同 id 覆盖、新 id 追加(面板用的是这个) |
144
144
  | POST | `/api/global-todo/note` | `{ id, text }`(追加进展记录) |
145
145
  | POST | `/api/global-todo/capture` | `{ sessionId? }`(请当前对话把要跟进的事抓进清单) |
146
+ | POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }`——一次改一整批;失效的 id 进 `unmatched`,不会让整批失败 |
146
147
  | GET | `/api/global-todo/health` | —(数据位置、统计、派发能力,以及**工具注册状态**与**自动收尾观察者是否挂上**——这两种失败是静默的,所以必须能查;还有 `metrics`:派发成功率、工具调用次数、清单增长;`autoCollect`:每个候选对话**为什么被问/没被问**、计次,以及设置文件是怎么加载进来的) |
147
148
  | POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
148
149
  | POST | `/api/global-todo/update` | `{ id, ...字段, by?: human\|agent }` |
package/docs/DESIGN.md CHANGED
@@ -14,7 +14,7 @@ so the file stays readable, hand-editable and diffable.
14
14
  | `id` | string | `t-` + 8 hex; unique within the file |
15
15
  | `title` | string | 1–200 characters, trimmed |
16
16
  | `description` | string | up to 10 000 characters, Markdown-ish plain text |
17
- | `status` | `open` \| `doing` \| `done` \| `cancelled` | `doing` means someone is on it |
17
+ | `status` | `open` \| `doing` \| `done` \| `cancelled` \| `archived` | `doing` means someone is on it; `archived` keeps the record out of the active view without deleting it |
18
18
  | `priority` | `low` \| `normal` \| `high` \| `urgent` | `urgent` was added in front of the first three; `normal` stays the default |
19
19
  | `dueAt` | ISO-8601 string \| `null` | canonicalised to UTC on write |
20
20
  | `tags` | string[] | ≤ 20, each ≤ 32, de-duplicated case-insensitively |
@@ -157,6 +157,13 @@ rest of the batch — a partial success is reported as a partial success, never
157
157
  The ceiling: **capture ends at the list.** Nothing the plugin does starts work by itself;
158
158
  "hand it to an agent" is always a human click.
159
159
 
160
+
161
+ The gate has to be narrower than "remember things": a capture run that re-registers what the list
162
+ already holds is how a trusted list becomes noise. So an item is refused, with the reason on the
163
+ record, when its normalized title matches a live row, when a close paraphrase (Dice over character
164
+ bigrams, 0.8) appears in the last 72 hours, or when the same errand was finished within 7 days.
165
+ Archived and cancelled rows are history a person set aside and must not block a new one; the rules
166
+ are pure and live in `lib/capture.js` so they can be tested without a store.
160
167
  ## A fallback must never quietly outrank the real rule
161
168
 
162
169
  The routes are fenced: the Host's connection service decides (`isTrustedApiRequest` against
package/docs/ROADMAP.md CHANGED
@@ -97,6 +97,15 @@ asked / succeeded / failed / dropped (`explainAutoCollect` in `lib/settings.js`,
97
97
  in `lib/index.js`). A failed question releases its booking and retries after 1 / 5 / 25 minutes
98
98
  (`retryDelayMs`), and an ask that never settles is bounded by a ten-second timeout. An unknown
99
99
  working directory is never asked, because the exemption cannot be checked against it.
100
+ **Batch completion and tagging, and archive** — `/bulk` applies a status or tag change across a
101
+ selection in one request; a stale id is reported in `unmatched` instead of discarding the batch
102
+ (`bulk()` in `lib/service.js`, `runBulk` in `lib/client.js`). `archived` joined the statuses, so a
103
+ record can leave the active view without being deleted; the filter chip only appears once something
104
+ is archived, and `global_todo_update` finally accepts `status`.
105
+ **A capture gate, not a title comparison** — before `global_todo_capture` writes an item it asks
106
+ `findDuplicate` (`lib/capture.js`): normalized title against live rows, a close paraphrase inside
107
+ 72 hours, and the same errand finished in the last 7 days. The decision is pure, the reason lands
108
+ in `skipped[]`, and archived/cancelled rows deliberately do not block a new one.
100
109
  ### Partly landed
101
110
 
102
111
  - **Batch and multi-select** — the selection mode, its per-row markers and the batch
Binary file
Binary file
Binary file
package/lib/capture.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Capture quality gate.
3
+ *
4
+ * `global_todo_capture` is the one path that can add several records at once, and a capture run
5
+ * that keeps re-registering what the list already has is how the list turns into noise. The
6
+ * first version compared normalised titles exactly against the active rows; that misses the
7
+ * common case — same errand, different wording — and it re-admits something that was finished
8
+ * yesterday. Both are pure decisions, so they live here and are tested without a store.
9
+ *
10
+ * @module dsh-global-todo/capture
11
+ */
12
+
13
+ /** Titles closer than this are treated as the same errand. */
14
+ const SIMILARITY = 0.8
15
+ /** Only compare against rows from the last few days; older ones are history, not duplicates. */
16
+ const DEDUPE_WINDOW_HOURS = 72
17
+ /** A finished errand stays done this long before the same title may come back. */
18
+ const DONE_COOLDOWN_DAYS = 7
19
+
20
+ /**
21
+ * Fold a title to what it means: width, case, spacing and punctuation are noise.
22
+ *
23
+ * Numbers and dates are kept on purpose — "10 月 20 日交" and "10 月 21 日交" are different
24
+ * errands even though they differ by one character.
25
+ *
26
+ * @param {unknown} title
27
+ * @returns {string}
28
+ */
29
+ export function normalizeTitle(title) {
30
+ return String(title ?? '').normalize('NFKC').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
31
+ }
32
+
33
+ /** @param {string} text @returns {Set<string>} */
34
+ function bigrams(text) {
35
+ const chars = [...text]
36
+ if (chars.length < 2) return new Set(chars)
37
+ const out = new Set()
38
+ for (let index = 0; index < chars.length - 1; index += 1) out.add(chars[index] + chars[index + 1])
39
+ return out
40
+ }
41
+
42
+ /**
43
+ * Dice coefficient over character bigrams: 0 when unrelated, 1 when identical.
44
+ *
45
+ * @param {unknown} left
46
+ * @param {unknown} right
47
+ * @returns {number}
48
+ */
49
+ export function similarity(left, right) {
50
+ const a = bigrams(normalizeTitle(left))
51
+ const b = bigrams(normalizeTitle(right))
52
+ if (a.size === 0 || b.size === 0) return 0
53
+ let shared = 0
54
+ for (const gram of a) if (b.has(gram)) shared += 1
55
+ return (2 * shared) / (a.size + b.size)
56
+ }
57
+
58
+ /**
59
+ * Is this title already on the list, in a way a person would call a duplicate?
60
+ *
61
+ * @param {{ title?: unknown, existing?: object[], now?: number, similarity?: number, windowHours?: number, doneCooldownDays?: number }} input
62
+ * @returns {{ duplicate: boolean, reason?: string, matchId?: string }}
63
+ */
64
+ export function findDuplicate(input) {
65
+ const title = input.title
66
+ const key = normalizeTitle(title)
67
+ if (key === '') return { duplicate: false }
68
+ const now = Number.isFinite(input.now) ? input.now : Date.now()
69
+ const windowMs = (input.windowHours ?? DEDUPE_WINDOW_HOURS) * 60 * 60 * 1000
70
+ const cooldownMs = (input.doneCooldownDays ?? DONE_COOLDOWN_DAYS) * 24 * 60 * 60 * 1000
71
+ const threshold = Number.isFinite(input.similarity) ? input.similarity : SIMILARITY
72
+ for (const todo of Array.isArray(input.existing) ? input.existing : []) {
73
+ if (todo === null || typeof todo !== 'object' || typeof todo.title !== 'string') continue
74
+ // Archived is history a person chose to keep out of the way; it must not block the same
75
+ // errand from being written again. Cancelled is likewise not a live duplicate.
76
+ if (todo.status === 'cancelled' || todo.status === 'archived') continue
77
+ const doneAt = Number.isFinite(todo.doneAt)
78
+ ? todo.doneAt
79
+ : Number.isFinite(todo.updatedAt) ? todo.updatedAt : 0
80
+ if (normalizeTitle(todo.title) === key) {
81
+ if (todo.status === 'done') {
82
+ if (now - doneAt < cooldownMs) return { duplicate: true, reason: 'recently-done', matchId: todo.id }
83
+ } else {
84
+ return { duplicate: true, reason: 'same-active', matchId: todo.id }
85
+ }
86
+ }
87
+ if (todo.status !== 'done') {
88
+ const created = Number.isFinite(todo.createdAt) ? todo.createdAt : now
89
+ // A five-day-old item is not evidence that today's wording is a duplicate; only the
90
+ // recent window is compared for similarity.
91
+ if (now - created < windowMs && similarity(title, todo.title) >= threshold) {
92
+ return { duplicate: true, reason: 'similar-active', matchId: todo.id }
93
+ }
94
+ }
95
+ }
96
+ return { duplicate: false }
97
+ }
98
+
99
+ /** The sentence a person reads in `skipped[].error`. */
100
+ export const CAPTURE_REASONS = Object.freeze({
101
+ 'same-active': '清单里已有同名的未完成条目,未重复登记',
102
+ 'similar-active': '清单里已有一条很相似的未完成条目,未重复登记',
103
+ 'recently-done': '这条刚完成不久(7 天内),没有重复登记',
104
+ })
package/lib/client.js CHANGED
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
50
50
  * different mechanisms (restart vs. hot reload), so "which one am I looking
51
51
  * at" needs an answer that the bundle itself carries.
52
52
  */
53
- const UI_REV = 'ui24'
53
+ const UI_REV = 'ui25'
54
54
  /** Last language resolved for the shell; module-level so a fetch failure can be worded too. */
55
55
  let activeLang = 'zh'
56
56
 
@@ -75,6 +75,8 @@ window.__ModuleLoader__.load({
75
75
  'action.done': '完成',
76
76
  'action.reopen': '标回未完成',
77
77
  'action.doing': '标为进行中',
78
+ 'action.archive': '归档',
79
+ 'action.unarchive': '取消归档',
78
80
  'action.remove': '删除',
79
81
  'action.removeConfirm': '确认删除',
80
82
  'action.unlink': '摘掉',
@@ -102,6 +104,12 @@ window.__ModuleLoader__.load({
102
104
  'select.all': '全选',
103
105
  'select.allOn': '取消全选',
104
106
  'select.assign': '一键分配',
107
+ 'select.complete': '完成',
108
+ 'select.archive': '归档',
109
+ 'field.bulkTag': '标签…',
110
+ 'select.addTag': '加标签',
111
+ 'notice.bulk': '已更新 {n} 条',
112
+ 'notice.bulkPartial': '已更新 {n} 条,跳过 {m} 条',
105
113
  'select.clear': '清除',
106
114
  'select.done': '完成',
107
115
  'select.hint': '先勾几条待办,再点「一键分配」:优先回到写下它的那个对话,其次同工作区的对话,都找不到才让你选。',
@@ -173,6 +181,7 @@ window.__ModuleLoader__.load({
173
181
  'filter.doing': '进行中',
174
182
  'filter.done': '已完成',
175
183
  'filter.overdue': '逾期',
184
+ 'filter.archived': '已归档',
176
185
  'filter.label': '筛选',
177
186
  'field.title': '要做什么?回车即存',
178
187
  'field.description': '描述:验收标准、已知的坑…',
@@ -276,6 +285,8 @@ window.__ModuleLoader__.load({
276
285
  'action.done': 'Done',
277
286
  'action.reopen': 'Reopen',
278
287
  'action.doing': 'Start',
288
+ 'action.archive': 'Archive',
289
+ 'action.unarchive': 'Unarchive',
279
290
  'action.remove': 'Delete',
280
291
  'action.removeConfirm': 'Confirm delete',
281
292
  'action.unlink': 'Remove',
@@ -303,6 +314,12 @@ window.__ModuleLoader__.load({
303
314
  'select.all': 'Select all',
304
315
  'select.allOn': 'Clear selection',
305
316
  'select.assign': 'Assign',
317
+ 'select.complete': 'Complete',
318
+ 'select.archive': 'Archive',
319
+ 'field.bulkTag': 'Tag…',
320
+ 'select.addTag': 'Add tag',
321
+ 'notice.bulk': 'Updated {n}',
322
+ 'notice.bulkPartial': 'Updated {n}, skipped {m}',
306
323
  'select.clear': 'Clear',
307
324
  'select.done': 'Done',
308
325
  'select.hint': 'Pick a few todos, then Assign: back to the conversation that wrote each one, else a conversation in the same workspace, else you choose.',
@@ -376,6 +393,7 @@ window.__ModuleLoader__.load({
376
393
  'filter.doing': 'Doing',
377
394
  'filter.done': 'Done',
378
395
  'filter.overdue': 'Overdue',
396
+ 'filter.archived': 'Archived',
379
397
  'filter.label': 'Filters',
380
398
  'field.title': 'What needs doing? Enter to save',
381
399
  'field.description': 'Description: acceptance, known traps…',
@@ -1598,6 +1616,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1598
1616
  done
1599
1617
  ? { key: 'reopen', label: t('action.reopen'), icon: 'check', onSelect: () => onAction('reopen', todo) }
1600
1618
  : { key: 'doing', label: t('action.doing'), icon: 'clock', onSelect: () => onAction('doing', todo) },
1619
+ todo.status === 'archived'
1620
+ ? { key: 'unarchive', label: t('action.unarchive'), icon: 'check', onSelect: () => onAction('unarchive', todo) }
1621
+ : { key: 'archive', label: t('action.archive'), icon: 'doc', onSelect: () => onAction('archive', todo) },
1601
1622
  {
1602
1623
  key: 'remove',
1603
1624
  label: confirming ? t('action.removeConfirm') : t('action.remove'),
@@ -1939,6 +1960,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1939
1960
  // The hidden file input behind 导入: a page cannot open a file dialog by itself.
1940
1961
  const importRef = useRef(null)
1941
1962
  const [selected, setSelected] = useState({})
1963
+ // One tag field for the whole selection: typing a tag then pressing add applies it to
1964
+ // every picked row in one request, which is the batch half that was missing.
1965
+ const [bulkTag, setBulkTag] = useState('')
1942
1966
  const [assignBusy, setAssignBusy] = useState(false)
1943
1967
  const [assignAsk, setAssignAsk] = useState(null)
1944
1968
  const [groupMode, setGroupMode] = useState(() => readStoredGroupMode())
@@ -2619,6 +2643,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2619
2643
  if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done' } }))
2620
2644
  if (kind === 'reopen') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
2621
2645
  if (kind === 'doing') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'doing' } }))
2646
+ // Archive keeps the history without cluttering the list; delete stays a separate,
2647
+ // deliberate action reached only through the two-step menu item.
2648
+ if (kind === 'archive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'archived' } }))
2649
+ if (kind === 'unarchive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
2622
2650
  if (kind === 'save') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, ...payload } }))
2623
2651
  if (kind === 'note') return run(todo.id, () => call('note', { method: 'POST', body: { id: todo.id, ...payload } }))
2624
2652
  if (kind === 'link') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, ...payload } }))
@@ -2664,7 +2692,32 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2664
2692
  return undefined
2665
2693
  }
2666
2694
 
2667
- const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, overdue: 0 }
2695
+ /** Apply one change to the whole selection; a stale id is reported, not fatal. */
2696
+ const runBulk = async (change) => {
2697
+ const ids = Object.keys(selected)
2698
+ if (ids.length === 0) return
2699
+ try {
2700
+ const data = await call('bulk', { method: 'POST', body: { ids, ...change } })
2701
+ const skipped = (data?.unmatched?.length ?? 0) + (data?.errors?.length ?? 0)
2702
+ setNotice({
2703
+ text:
2704
+ skipped > 0
2705
+ ? t('notice.bulkPartial', { n: data?.updated ?? 0, m: skipped })
2706
+ : t('notice.bulk', { n: data?.updated ?? 0 }),
2707
+ sessionId: '',
2708
+ })
2709
+ setTimeout(() => setNotice(null), 6000)
2710
+ setSelected({})
2711
+ setError(null)
2712
+ void reload()
2713
+ void refreshHealth()
2714
+ } catch (failure) {
2715
+ reportFailure('bulk', { message: failure?.message })
2716
+ setError(failure)
2717
+ }
2718
+ }
2719
+
2720
+ const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0 }
2668
2721
  const groups = useMemo(
2669
2722
  () => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
2670
2723
  [state.todos, groupMode, sessionTitles, lang],
@@ -2875,6 +2928,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2875
2928
  ['doing', counts.doing],
2876
2929
  ['done', counts.done],
2877
2930
  ['overdue', counts.overdue],
2931
+ // Only shown once something is archived: an empty filter that exists from
2932
+ // day one is furniture, not a control.
2933
+ ...(counts.archived > 0 ? [['archived', counts.archived]] : []),
2878
2934
  ].map(([key, count]) => filterChip(key, key === 'all' ? undefined : count)),
2879
2935
  ),
2880
2936
  h('span', { className: 'gt-divider' }),
@@ -3097,6 +3153,48 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3097
3153
  },
3098
3154
  assignBusy ? t('assign.working') : t('select.assign'),
3099
3155
  ),
3156
+ h(
3157
+ 'button',
3158
+ {
3159
+ type: 'button',
3160
+ className: 'gt-btn gt-btn-quiet',
3161
+ disabled: Object.keys(selected).length === 0,
3162
+ onClick: () => void runBulk({ status: 'done' }),
3163
+ },
3164
+ t('select.complete'),
3165
+ ),
3166
+ h(
3167
+ 'button',
3168
+ {
3169
+ type: 'button',
3170
+ className: 'gt-btn gt-btn-quiet',
3171
+ disabled: Object.keys(selected).length === 0,
3172
+ onClick: () => void runBulk({ status: 'archived' }),
3173
+ },
3174
+ t('select.archive'),
3175
+ ),
3176
+ h('input', {
3177
+ className: 'gt-input gt-bulk-tag',
3178
+ value: bulkTag,
3179
+ placeholder: t('field.bulkTag'),
3180
+ 'aria-label': t('field.bulkTag'),
3181
+ onChange: (event) => setBulkTag(event.target.value),
3182
+ }),
3183
+ h(
3184
+ 'button',
3185
+ {
3186
+ type: 'button',
3187
+ className: 'gt-btn gt-btn-quiet',
3188
+ disabled: Object.keys(selected).length === 0 || bulkTag.trim() === '',
3189
+ onClick: () => {
3190
+ const tag = bulkTag.trim()
3191
+ if (tag === '') return
3192
+ void runBulk({ addTags: [tag] })
3193
+ setBulkTag('')
3194
+ },
3195
+ },
3196
+ t('select.addTag'),
3197
+ ),
3100
3198
  h(
3101
3199
  'button',
3102
3200
  { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setSelected({}) },
package/lib/index.js CHANGED
@@ -35,7 +35,7 @@ import { createStore, resolveStorePath } from './store.js'
35
35
  import { createTools } from './tools.js'
36
36
 
37
37
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
38
- export const VERSION = '0.4.0'
38
+ export const VERSION = '0.4.2'
39
39
 
40
40
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
41
41
  export const name = 'dsh-global-todo'
package/lib/model.js CHANGED
@@ -20,7 +20,7 @@ import { LINK_KINDS, linkKey, normalizeLink } from './links.js'
20
20
  export const SCHEMA_VERSION = 1
21
21
 
22
22
  /** Lifecycle states. `doing` is the explicit "an agent (or I) am on it" state. */
23
- export const STATUSES = Object.freeze(['open', 'doing', 'done', 'cancelled'])
23
+ export const STATUSES = Object.freeze(['open', 'doing', 'done', 'cancelled', 'archived'])
24
24
 
25
25
  /** Priorities are deliberately coarse: three levels people actually use. */
26
26
  /**
@@ -653,17 +653,17 @@ export function recordDispatch(todo, info, options = {}) {
653
653
  * @returns {boolean}
654
654
  */
655
655
  export function isOverdue(todo, now = Date.now()) {
656
- if (todo.dueAt === null || todo.status === 'done' || todo.status === 'cancelled') return false
656
+ if (todo.dueAt === null || todo.status === 'done' || todo.status === 'cancelled' || todo.status === 'archived') return false
657
657
  const due = Date.parse(todo.dueAt)
658
658
  return Number.isFinite(due) && due < now
659
659
  }
660
660
 
661
661
  /**
662
662
  * @param {object[]} todos
663
- * @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, overdue: number }}
663
+ * @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, archived: number, overdue: number }}
664
664
  */
665
665
  export function counts(todos, now = Date.now()) {
666
- const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, overdue: 0 }
666
+ const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0 }
667
667
  for (const todo of todos) {
668
668
  if (todo.status in out) out[todo.status] += 1
669
669
  if (isOverdue(todo, now)) out.overdue += 1
package/lib/routes.js CHANGED
@@ -468,6 +468,15 @@ export function createRoutes(deps) {
468
468
  return dispatcher.assignMany(body ?? {})
469
469
  }),
470
470
 
471
+ exact('POST', `${ROUTE_BASE}/bulk`, async (request) => {
472
+ const body = await readJsonBody(request)
473
+ return service.bulk(
474
+ body.ids,
475
+ { status: body.status, addTags: body.addTags, removeTags: body.removeTags },
476
+ { by: actorOf(body) },
477
+ )
478
+ }),
479
+
471
480
  exact('POST', `${ROUTE_BASE}/capture`, async (request) => {
472
481
  const body = await readJsonBody(request)
473
482
  return dispatcher.requestCapture(body ?? {})
package/lib/service.js CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  } from './model.js'
34
34
 
35
35
  /** Filters the panel and the tools may ask for. */
36
- export const FILTERS = Object.freeze(['all', 'active', 'open', 'doing', 'done', 'cancelled', 'overdue'])
36
+ export const FILTERS = Object.freeze(['all', 'active', 'open', 'doing', 'done', 'cancelled', 'archived', 'overdue'])
37
37
 
38
38
  /** Sort keys the panel and the tools may ask for. */
39
39
  export const SORTS = Object.freeze(['created', 'updated', 'due', 'priority'])
@@ -618,6 +618,54 @@ export function createService(options) {
618
618
  })
619
619
  }
620
620
 
621
+ /**
622
+ * Apply one change to several todos.
623
+ *
624
+ * Each record goes through the same validation and trail as a single edit. A stale id is
625
+ * reported in `unmatched` instead of failing the batch: a selection is a human gesture, and
626
+ * one row that moved must not discard the other nine.
627
+ *
628
+ * @param {string[]} ids
629
+ * @param {{ status?: string, addTags?: string[], removeTags?: string[] }} change
630
+ * @param {{ by?: 'human' | 'agent' }} [context]
631
+ * @returns {Promise<{ updated: number, ids: string[], unmatched: string[], errors: { id: string, error: string }[] }>}
632
+ */
633
+ async function bulk(ids, change = {}, context = {}) {
634
+ const wanted = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id.trim() !== '') : []
635
+ if (wanted.length === 0) throw new TodoError('invalid-body', '要改哪些待办?给一个非空的 ids')
636
+ const hasStatus = typeof change.status === 'string' && change.status !== ''
637
+ const addTags = Array.isArray(change.addTags) ? change.addTags : []
638
+ const removeTags = Array.isArray(change.removeTags) ? change.removeTags : []
639
+ if (!hasStatus && addTags.length === 0 && removeTags.length === 0) {
640
+ throw new TodoError('invalid-body', '批量修改需要 status / addTags / removeTags 之一')
641
+ }
642
+ const updated = []
643
+ const unmatched = []
644
+ const errors = []
645
+ for (const id of wanted) {
646
+ try {
647
+ const todo = await store.update((doc) => {
648
+ const target = doc.todos.find((candidate) => candidate.id === id)
649
+ if (target === undefined) return { result: null }
650
+ const patch = {}
651
+ if (hasStatus) patch.status = change.status
652
+ if (addTags.length > 0 || removeTags.length > 0) {
653
+ const merged = new Map(target.tags.map((tag) => [tag.toLowerCase(), tag]))
654
+ for (const tag of addTags) merged.set(String(tag).toLowerCase(), String(tag))
655
+ for (const tag of removeTags) merged.delete(String(tag).toLowerCase())
656
+ patch.tags = [...merged.values()]
657
+ }
658
+ const changed = applyPatch(target, patch, { ...modelOptions(context.by), ...linkOptions() })
659
+ return changed.length === 0 ? { result: target } : { next: doc.todos, result: target }
660
+ })
661
+ if (todo === null) unmatched.push(id)
662
+ else updated.push(id)
663
+ } catch (error) {
664
+ errors.push({ id, error: error instanceof TodoError ? error.message : String(error?.message ?? error) })
665
+ }
666
+ }
667
+ return { updated: updated.length, ids: updated, unmatched, errors }
668
+ }
621
669
  /** @returns {Promise<object>} counts for the panel header and tool summaries */
622
670
  async function stats() {
623
671
  const todos = await store.all()
@@ -637,6 +685,7 @@ export function createService(options) {
637
685
  findBySession,
638
686
  remove,
639
687
  restore,
688
+ bulk,
640
689
  addLink,
641
690
  removeLink,
642
691
  recordDispatch,
package/lib/tools.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * @module dsh-global-todo/tools
14
14
  */
15
15
 
16
+ import { CAPTURE_REASONS, findDuplicate } from './capture.js'
16
17
  import { asTodoError } from './errors.js'
17
18
  import { formatLink } from './links.js'
18
19
  import { LIMITS, PRIORITIES, PRIORITY_LABELS, STATUSES } from './model.js'
@@ -384,27 +385,30 @@ export function createTools(service, options = {}) {
384
385
  const origin = originOf(exec)
385
386
  const todos = []
386
387
  const skipped = []
387
- // A capture run that keeps re-registering what is already on the list is how the
388
- // list fills with near-duplicates. One lookup, then skip by normalised title.
388
+ // The quality gate decides per item: exact title, a close paraphrase within the
389
+ // recent window, or the same errand finished days ago. One lookup of everything —
390
+ // done and archived rows matter too, which the active-only lookup used to miss.
389
391
  let existing = []
390
392
  try {
391
- existing = (await service.list({ status: 'active' })).todos
393
+ existing = (await service.list({ status: 'all', limit: 1000 })).todos
392
394
  } catch {
393
395
  existing = []
394
396
  }
395
- const keyOf = (title) =>
396
- String(title ?? '').trim().toLowerCase().replace(/\s+/g, '')
397
- const seen = new Set(existing.map((todo) => keyOf(todo.title)))
397
+ const nowMs = now()
398
398
  for (const item of items) {
399
- const itemKey = keyOf(item?.title)
400
- if (itemKey !== '' && seen.has(itemKey)) {
401
- skipped.push({ title: String(item?.title ?? '(无标题)'), error: '清单里已有同名的未完成条目,未重复登记' })
399
+ const verdict = findDuplicate({ title: item?.title, existing, now: nowMs })
400
+ if (verdict.duplicate === true) {
401
+ skipped.push({
402
+ title: String(item?.title ?? '(无标题)'),
403
+ error: CAPTURE_REASONS[verdict.reason] ?? '清单里已有重复条目,未重复登记',
404
+ })
402
405
  continue
403
406
  }
404
407
  const outcome = await attempt(() => service.create(item ?? {}, { by: 'agent', origin }))
405
408
  if (outcome.ok === true) {
406
- if (itemKey !== '') seen.add(itemKey)
407
409
  todos.push(presentTodo(outcome.value, now()))
410
+ // Later items in the same batch must see this one as existing.
411
+ existing.push(outcome.value)
408
412
  }
409
413
  else skipped.push({ title: String(item?.title ?? '(无标题)'), error: outcome.error })
410
414
  }
@@ -423,7 +427,7 @@ export function createTools(service, options = {}) {
423
427
  properties: {
424
428
  status: {
425
429
  type: 'string',
426
- enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'overdue'],
430
+ enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'archived', 'overdue'],
427
431
  description: '缺省 active',
428
432
  },
429
433
  query: { type: 'string', description: '在标题、描述、标签、附件里模糊匹配' },
@@ -518,7 +522,7 @@ export function createTools(service, options = {}) {
518
522
  {
519
523
  name: 'global_todo_update',
520
524
  description:
521
- '改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link)。',
525
+ '改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link),`status` 可以设为 archived(归档,不删除)。',
522
526
  parameters: {
523
527
  type: 'object',
524
528
  properties: {
@@ -526,6 +530,9 @@ export function createTools(service, options = {}) {
526
530
  title: { type: 'string' },
527
531
  description: { type: 'string' },
528
532
  priority: { type: 'string', enum: [...PRIORITIES] },
533
+ // `status` belongs here: marking something in progress used to require the tool
534
+ // named `global_todo_done`, and archiving had no entry point at all.
535
+ status: { type: 'string', enum: [...STATUSES], description: 'open / doing / done / cancelled / archived' },
529
536
  dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }], description: '空串或 null 表示清空' },
530
537
  tags: { type: 'array', items: { type: 'string' } },
531
538
  links: LINKS_INPUT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Global, cross-session todos for DeepSeek Harness: one list owned by the human and the agent together, with creation time, attached documents and links, and one-click hand-off to an agent session. 全局待办:跨会话一份清单,记录加入时间,可挂文档与地址,可一键交给 Agent 继续干。",
6
6
  "type": "module",