dsh-global-todo 0.4.0 → 0.4.1

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,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] — 2026-10-19
11
+
12
+ The half-finished half: the selection could only be assigned, and the only way to get a record out
13
+ of the list was to delete it. Both are now actions of their own.
14
+
15
+ - **A selection can be completed, archived and tagged.** `POST /bulk` applies one change across the
16
+ picked rows, and the selection bar gained 完成 / 归档 / a tag field. Each record still goes through
17
+ the same validation and activity trail as a single edit; an id that no longer exists is reported
18
+ in `unmatched` rather than failing the whole batch, because a selection is a human gesture and one
19
+ stale row must not discard the other nine.
20
+
21
+ - **Archive instead of delete.** `archived` joined the statuses, so a finished-or-abandoned record can
22
+ leave the active view without losing its history. The panel offers 归档 / 取消归档 in the row menu,
23
+ the filter chip appears only once something is archived, and `global_todo_update` now accepts
24
+ `status` at all — before this, marking something in progress required the tool named
25
+ `global_todo_done`, which is exactly the kind of name/capability mismatch that makes a model misuse
26
+ a tool.
27
+
28
+ Still deliberately out of scope for this release: the wide two-column detail layout, and the
29
+ cross-machine conflict check — both deserve their own pass rather than riding along with a data-model
30
+ change.
10
31
  ## [0.4.0] — 2026-10-19
11
32
 
12
33
  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 |
@@ -240,6 +240,7 @@ fence when that service is present, and by a strict loopback check otherwise.
240
240
  | GET | `/api/global-todo/export` | — (the store's own JSON document, for backup or migration) |
241
241
  | 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
242
  | POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
243
+ | 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
244
  | 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
245
  | POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
245
246
  | 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 |
package/docs/ROADMAP.md CHANGED
@@ -97,6 +97,11 @@ 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`.
100
105
  ### Partly landed
101
106
 
102
107
  - **Batch and multi-select** — the selection mode, its per-row markers and the batch
Binary file
Binary file
Binary file
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.1'
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
@@ -423,7 +423,7 @@ export function createTools(service, options = {}) {
423
423
  properties: {
424
424
  status: {
425
425
  type: 'string',
426
- enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'overdue'],
426
+ enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'archived', 'overdue'],
427
427
  description: '缺省 active',
428
428
  },
429
429
  query: { type: 'string', description: '在标题、描述、标签、附件里模糊匹配' },
@@ -518,7 +518,7 @@ export function createTools(service, options = {}) {
518
518
  {
519
519
  name: 'global_todo_update',
520
520
  description:
521
- '改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link)。',
521
+ '改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link),`status` 可以设为 archived(归档,不删除)。',
522
522
  parameters: {
523
523
  type: 'object',
524
524
  properties: {
@@ -526,6 +526,9 @@ export function createTools(service, options = {}) {
526
526
  title: { type: 'string' },
527
527
  description: { type: 'string' },
528
528
  priority: { type: 'string', enum: [...PRIORITIES] },
529
+ // `status` belongs here: marking something in progress used to require the tool
530
+ // named `global_todo_done`, and archiving had no entry point at all.
531
+ status: { type: 'string', enum: [...STATUSES], description: 'open / doing / done / cancelled / archived' },
529
532
  dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }], description: '空串或 null 表示清空' },
530
533
  tags: { type: 'array', items: { type: 'string' } },
531
534
  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.1",
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",