dsh-global-todo 0.15.0 → 0.16.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
@@ -1,3 +1,30 @@
1
+ ## [0.16.1] — 2026-10-22
2
+
3
+ Same code as 0.16.0; the tag now points at a commit whose CI is green.
4
+
5
+ - **0.16.0's tag commit had all three CI jobs red.** The new `before:`/`after:` test asserted the
6
+ order of two todos created in the same test, and CI creates them in the same millisecond, so the
7
+ order it asserted was not the order the default sort produces. The behaviour under test was
8
+ correct; the assertion was wrong. Fixed in 8024a48 — the run after it is green on both platforms.
9
+ - **Why a release rather than a moved tag**: `v0.16.0` is published on npm, and a tag that no
10
+ longer matches what was published is worse than a version bump. 0.16.1 is 0.16.0 plus the
11
+ assertion, so its tag has a green check.
12
+
13
+ ## [0.16.0] — 2026-10-22
14
+
15
+ The three small promises the roadmap still carried.
16
+
17
+ - **`before:` / `after:` in the search box** bound the deadline (strict at the bound). Items
18
+ without a deadline cannot satisfy either bound, and a half-typed date such as `before:2026`
19
+ simply stops filtering instead of throwing on every keystroke.
20
+ - **「重新挂载」 next to a dead attachment** re-points it in place (`POST /relink`, same
21
+ `{ todo, changed }` shape as `/update`), keeping the kind and title. `annotateLinks()` finally
22
+ has its unit test — and it now flags a dead **image** too, which it used to skip, so a missing
23
+ screenshot shows the existing chip instead of a broken frame.
24
+ - **Accessibility pass**: the toast is a polite live region, errors are alerts, groups and rows
25
+ are list items, filter buttons report `aria-pressed`, and every icon-only button has a name.
26
+ `test/a11y.test.mjs` reads the client source so these cannot quietly regress without a browser.
27
+
1
28
  ## [0.15.0] — 2026-10-22
2
29
 
3
30
  Image attachments: a reference that renders, not a copy that goes stale.
package/README.md CHANGED
@@ -252,7 +252,7 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
252
252
  That is an attempt order: an unloadable target means the next one is tried. Anything with
253
253
  nowhere to go comes back with a picker rather than being sent somewhere unchosen, and one
254
254
  failure does not abort the batch.
255
- - **Search syntax** — `tag:release` (or `#ops`), `is:overdue`, `is:active`, `has:link`,
255
+ - **Search syntax** — `tag:release` (or `#ops`), `is:overdue`, `is:active`, `has:link`, `before:2026-11-01` / `after:2026-10-01` (deadline bounds; a half-typed date simply stops filtering),
256
256
  `has:note`, `level:p0`, plus free words combined with AND (which also match note text). A
257
257
  search without a colon behaves exactly as it always did.
258
258
  - **Progress notes** — an append-only trail per todo (count on the row, full text and an add
@@ -393,6 +393,7 @@ fence when that service is present, and by a strict loopback check otherwise.
393
393
  | POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` — answer an agent's completion |
394
394
  | GET | `/api/global-todo/report?days=7` | what the agents finished in the window, and the files it touched |
395
395
  | GET | `/api/global-todo/session?id=` | what a bound conversation last said (`tail`, `alive`, `dir`) |
396
+ | POST | `/api/global-todo/relink` | `{ id, index, value }` — re-point a dead attachment (the value must still be a valid absolute path or URL of that kind) |
396
397
  | GET | `/api/global-todo/asset?id=&index=` | the bytes of an **image** attachment (path read from the store, never from the request; ≤8 MB, png/jpg/jpeg/webp/gif/avif) |
397
398
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [value \| index] }` |
398
399
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
package/README.zh.md CHANGED
@@ -207,7 +207,7 @@ dsh plugin --profile web remove dsh-global-todo
207
207
  - **「抓取」在每个对话的分组上**:让**那个对话**里的 Agent 把该跟进的事登记成待办,结果就归在它自己那组下面(分组名旁边那个「抓取」)。工具栏上的「从本对话抓取」只在**当前对话还没有分组**时出现——否则没地方点它。抓取对象没在运行会**先唤醒**再问(与派发同一条规则)。
208
208
  - **工作目录**:`选择` 会用宿主的目录选择器;如果这个部署没挂载它(远程命名空间缺失),面板会自动改为**手动填路径**(`手动填` → 粘绝对路径 → `使用`),并说明原因——不会只丢一条看不懂的错误。
209
209
  - **一键分配(多选)**:工具栏点「选择」→ 勾几条 → 「一键分配」。每条按**原对话 → 同工作区对话 → 你选的目标**的顺序尝试(是**尝试顺序**,不是一次定死:原对话加载不了就自动往下走)。都找不到的会**报回来并给你选择器**,绝不擅自送到别处;其中一条失败也不会中断整批。
210
- - **搜索语法**:`tag:发布`(或 `#发布`)、`is:overdue`、`is:active`、`has:link`、`has:note`、`level:p0`(也可写 `level:urgent`),自由词之间是 **AND**,并且会一并搜进展记录。不带冒号的普通搜索行为不变。
210
+ - **搜索语法**:`tag:发布`(或 `#发布`)、`is:overdue`、`is:active`、`has:link`、`has:note`、`level:p0`(也可写 `level:urgent`)、`before:2026-11-01` / `after:2026-10-01`(按截止时间卡边界;日期没打完时只是不生效,不会报错),自由词之间是 **AND**,并且会一并搜进展记录。不带冒号的普通搜索行为不变。
211
211
  - **进展记录**:每条待办可追加「谁在什么时候说了什么」的流水(行上显示条数,详情里看全文并可直接补一条)。Agent 报「做不完、卡在哪」应该写在这里,**而不是改写你的描述**。
212
212
  - **失效附件会标出来**:文件被移走/删除的附件在行上标红、详情里注明;这个标记是**读取时算的**,不写进记录——文件回来了就不该再标红。
213
213
  - **新对话开在哪**:默认**继承写下这条待办的对话所在的工作区**(新会话的工作目录也就是那个工作区),所以「接着这个活干」不会跑到不相干的工作区去;想指定就选 `+ 新对话 · 工作区名`(列在工作区一栏里),指定的优先。只有当两者都不知道时,才退回按工作目录判断。
@@ -294,6 +294,7 @@ dsh plugin --profile web remove dsh-global-todo
294
294
  | POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` —— 回答 Agent 的完成声明 |
295
295
  | GET | `/api/global-todo/report?days=7` | 这段时间里 Agent 完成了什么、触及了哪些文件 |
296
296
  | GET | `/api/global-todo/session?id=` | 绑定的会话最后说了什么(`tail` / `alive` / `dir`) |
297
+ | POST | `/api/global-todo/relink` | `{ id, index, value }`——把失效附件重新指向(新值仍须是该类型的合法绝对路径/网址) |
297
298
  | GET | `/api/global-todo/asset?id=&index=` | 读**图片附件**的字节(路径只从待办记录里取,不接受请求传路径;≤8MB,png/jpg/jpeg/webp/gif/avif) |
298
299
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [值 或 下标] }` |
299
300
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
package/docs/ROADMAP.md CHANGED
@@ -200,14 +200,14 @@ of conversations involved, files touched with counts, and how many wait for revi
200
200
  time, never stored. The panel shows it from `⋯ → 本周报告`. Tests:
201
201
  `test/markdown.test.mjs` (pure aggregation) and `test/routes.test.mjs`.
202
202
 
203
- ### 5. Image attachments
203
+ ### 5. ~~Image attachments~~ — landed in 0.15.0
204
204
 
205
- `kind: 'image'` beside `url` / `file`, so a screenshot can be attached instead of a path
206
- to one.
207
-
208
- - Today: `LINK_KINDS = ['url', 'file']` (`lib/links.js`); the link shape is already prepared.
209
- - Effort: medium — the store must not inline bytes, so this is a path with a preview and a
210
- clear "the file is gone" state, which #3-adjacent work already models.
205
+ `kind: 'image'` beside `url` / `file`: still an absolute reference (the store never holds
206
+ bytes), required to carry a renderable extension, with `GET /asset?id=&index=` reading the
207
+ path out of the record rather than from the request (8 MB cap, `nosniff`, image links only).
208
+ The row shows a thumbnail and the detail a preview; a dead image keeps the existing
209
+ missing-file chip instead of rendering a broken frame. `lib/links.js`, `lib/routes.js`,
210
+ `lib/client.js`; tests in `test/links.test.mjs` and `test/routes.test.mjs`.
211
211
 
212
212
  ### 6. Source adapters for the ingest door
213
213
 
@@ -218,11 +218,17 @@ to one.
218
218
  - Effort: large, and the mode of the work is different: every adapter is an external
219
219
  dependency with its own failure modes. Do it when a real source exists, not before.
220
220
 
221
- ### 7. Small, well-understood items
222
-
223
- - `before:` / `after:` date bounds in the search syntax (the parser already walks tokens).
224
- - 「重新挂载」 next to a dead attachment, plus the `annotateLinks()` unit test it owes.
225
- - A screen-reader pass over the panel and the group headers; the keyboard path is close.
221
+ ### 7. ~~Small, well-understood items~~ — landed in 0.16.0
222
+
223
+ - **`before:` / `after:` date bounds** in the search syntax, applied to the deadline and strict
224
+ at the bound (`lib/service.js`); a half-typed date stops filtering instead of erroring on
225
+ every keystroke.
226
+ - **「重新挂载」** beside a dead attachment (`POST /relink`, same shape as `/update`), plus the
227
+ `annotateLinks()` unit test the code owed — including the image kind, which the annotation
228
+ used to skip.
229
+ - **Accessibility pass**: the toast is a polite live region, errors are alerts, groups and rows
230
+ are list items, filter buttons report `aria-pressed`, and every icon-only button carries a
231
+ name (`test/a11y.test.mjs` guards all of it).
226
232
 
227
233
  ### Landed after this list was written
228
234
 
package/lib/client.js CHANGED
@@ -237,6 +237,7 @@ window.__ModuleLoader__.load({
237
237
  'dir.picked': '(已指定)',
238
238
  'action.save': '保存',
239
239
  'action.cancel': '取消',
240
+ 'action.relink': '重新挂载',
240
241
  'filter.all': '全部',
241
242
  'filter.active': '未完成',
242
243
  'filter.doing': '进行中',
@@ -539,6 +540,7 @@ window.__ModuleLoader__.load({
539
540
  'meta.origin': 'from {dir}',
540
541
  'meta.completed': 'Completed {when}',
541
542
  'action.save': 'Save',
543
+ 'action.relink': 'Re-point',
542
544
  'action.cancel': 'Cancel',
543
545
  'filter.all': 'All',
544
546
  'filter.active': 'Open',
@@ -1377,6 +1379,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1377
1379
  .gt-pre { white-space: pre-wrap; word-break: break-word; margin: 0; line-height: 1.6; }
1378
1380
  .gt-muted { color: var(--dsw-alias-label-secondary); }
1379
1381
  .gt-links { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
1382
+ .gt-relink { display: flex; flex: 1 1 100%; gap: 6px; align-items: center; }
1380
1383
  .gt-link { display: flex; gap: 8px; align-items: center; padding: 4px 0; min-width: 0; }
1381
1384
  .gt-link-main { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
1382
1385
  .gt-evidence { margin: 0; padding: 2px 0 2px 10px; border-left: 2px solid var(--dsw-alias-border-l2);
@@ -1732,6 +1735,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1732
1735
  // the only place the question "did it get anywhere" is asked.
1733
1736
  const [tail, setTail] = useState(null)
1734
1737
  const [linkValue, setLinkValue] = useState('')
1738
+ // Re-pointing a dead attachment: which row is editing, and what path it now holds.
1739
+ const [relinkIndex, setRelinkIndex] = useState(null)
1740
+ const [relinkValue, setRelinkValue] = useState('')
1735
1741
  const [linkTitle, setLinkTitle] = useState('')
1736
1742
  const [draft, setDraft] = useState({
1737
1743
  title: todo.title,
@@ -2429,11 +2435,53 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2429
2435
  { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => copy(link.value) },
2430
2436
  copied === link.value ? t('action.copied') : t('action.copy'),
2431
2437
  ),
2438
+ link.missing === true
2439
+ ? h(
2440
+ 'button',
2441
+ {
2442
+ type: 'button',
2443
+ className: 'gt-btn gt-btn-quiet',
2444
+ disabled: busy,
2445
+ onClick: () => {
2446
+ setRelinkIndex(index)
2447
+ setRelinkValue(link.value)
2448
+ },
2449
+ },
2450
+ t('action.relink'),
2451
+ )
2452
+ : null,
2432
2453
  h(
2433
2454
  'button',
2434
2455
  { type: 'button', className: 'gt-btn gt-btn-quiet', disabled: busy, onClick: () => onAction('unlink', todo, { index }) },
2435
2456
  t('action.unlink'),
2436
2457
  ),
2458
+ relinkIndex === index
2459
+ ? h(
2460
+ 'span',
2461
+ { className: 'gt-relink' },
2462
+ h('input', {
2463
+ className: 'gt-input',
2464
+ value: relinkValue,
2465
+ placeholder: t('field.linkValue'),
2466
+ onChange: (event) => setRelinkValue(event.target.value),
2467
+ }),
2468
+ h(
2469
+ 'button',
2470
+ {
2471
+ type: 'button',
2472
+ className: 'gt-btn',
2473
+ disabled: busy || relinkValue.trim() === '',
2474
+ onClick: () => onAction('relink', todo, { index, value: relinkValue }),
2475
+ },
2476
+ t('action.save'),
2477
+ ),
2478
+ h(
2479
+ 'button',
2480
+ { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setRelinkIndex(null) },
2481
+ t('action.cancel'),
2482
+ ),
2483
+ )
2484
+ : null,
2437
2485
  ),
2438
2486
  ),
2439
2487
  ),
@@ -3524,6 +3572,13 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3524
3572
  if (kind === 'steps') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, steps: payload.steps } }))
3525
3573
  if (kind === 'note') return run(todo.id, () => call('note', { method: 'POST', body: { id: todo.id, ...payload } }))
3526
3574
  if (kind === 'link') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, ...payload } }))
3575
+ if (kind === 'relink') {
3576
+ return run(todo.id, async () => {
3577
+ await call('relink', { method: 'POST', body: { id: todo.id, ...payload } })
3578
+ setRelinkIndex(null)
3579
+ setRelinkValue('')
3580
+ })
3581
+ }
3527
3582
  if (kind === 'unlink') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, remove: [payload.index] } }))
3528
3583
  if (kind === 'remove') {
3529
3584
  return run(todo.id, async () => {
@@ -4782,7 +4837,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
4782
4837
  const exemptGroup = typeof group.dir === 'string' && group.dir !== '' && exemptWorkspaces.includes(group.dir)
4783
4838
  return h(
4784
4839
  'div',
4785
- { className: 'gt-group', key: group.key, 'data-collapsed': isCollapsed ? '1' : '0' },
4840
+ { className: 'gt-group', key: group.key, role: 'listitem', 'data-collapsed': isCollapsed ? '1' : '0' },
4786
4841
  h(
4787
4842
  'div',
4788
4843
  { className: 'gt-group-head' },
@@ -4873,7 +4928,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
4873
4928
  ? null
4874
4929
  : h(
4875
4930
  'div',
4876
- { className: 'gt-list' },
4931
+ { className: 'gt-list', role: 'list' },
4877
4932
  group.todos.map((todo) =>
4878
4933
  h(TodoItem, {
4879
4934
  selecting: selectMode,
@@ -4909,7 +4964,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
4909
4964
  notice !== null && notice !== undefined
4910
4965
  ? h(
4911
4966
  'div',
4912
- { className: 'gt-toast' },
4967
+ { className: 'gt-toast', role: 'status', 'aria-live': 'polite' },
4913
4968
  h('span', null, notice.text),
4914
4969
  // Only offer the jump when the capability is really there: a button
4915
4970
  // that does nothing is worse than no button.
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ import { createStore, resolveStorePath } from './store.js'
37
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.15.0'
40
+ export const VERSION = '0.16.1'
41
41
 
42
42
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
43
43
  export const name = 'dsh-global-todo'
package/lib/routes.js CHANGED
@@ -718,6 +718,28 @@ export function createRoutes(deps) {
718
718
  return { todo: await service.remove(body.id, { by: 'human' }) }
719
719
  }),
720
720
 
721
+ exact('POST', `${ROUTE_BASE}/relink`, async (request) => {
722
+ // Re-pointing a dead attachment: the byte-free equivalent of "upload it again".
723
+ const body = await readJsonBody(request)
724
+ if (typeof body.id !== 'string' || body.id.trim() === '') {
725
+ throw new TodoError('invalid-body', 'relink 需要 id')
726
+ }
727
+ const index = Number(body.index)
728
+ if (!Number.isInteger(index) || index < 0) {
729
+ throw new TodoError('invalid-body', 'relink 需要 index(附件在列表里的位置)')
730
+ }
731
+ const todo = await service.get(body.id)
732
+ const link = (todo.links ?? [])[index]
733
+ if (link === undefined) throw new TodoError('not-found', '这条待办没有这个附件')
734
+ if (typeof body.value !== 'string' || body.value.trim() === '') {
735
+ throw new TodoError('invalid-body', 'relink 需要新的 value(绝对路径或网址)')
736
+ }
737
+ const links = todo.links.slice()
738
+ links[index] = { kind: link.kind, value: body.value.trim(), ...(link.title === '' ? {} : { title: link.title }) }
739
+ // Same shape as /update: the service already answers `{ todo, changed }`.
740
+ return service.update(body.id, { links }, { by: 'human' })
741
+ }),
742
+
721
743
  exact('POST', `${ROUTE_BASE}/restore`, async (request) => {
722
744
  // The panel's undo: put a removed record back verbatim, timestamps and
723
745
  // attachments included, instead of re-creating it as a new todo.
package/lib/service.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  newId,
29
29
  normalizeActor,
30
30
  normalizeStored,
31
+ parseDueText,
31
32
  pushActivity,
32
33
  recordDispatch as recordDispatchOn,
33
34
  removeLink as removeLinkFrom,
@@ -132,8 +133,21 @@ export function createService(options) {
132
133
  * @param {string} raw
133
134
  * @returns {{ text: string[], tags: string[], levels: string[], flags: string[] }}
134
135
  */
136
+ /**
137
+ * A due-date bound from a `before:` / `after:` token.
138
+ *
139
+ * Only the explicit deadline shapes are accepted (date, date+time), because a search bound
140
+ * that silently became New Year's Day would filter the list without saying so.
141
+ *
142
+ * @param {string} text e.g. `2026-11-01` or `2026-11-01T18:00`
143
+ * @returns {number | null} epoch millis, or null when the token is not a date bound
144
+ */
145
+ function dueBound(text) {
146
+ const verdict = parseDueText(text)
147
+ return verdict.ok === true ? Date.parse(verdict.iso) : null
148
+ }
135
149
  function parseQuery(raw) {
136
- const parsed = { text: [], tags: [], levels: [], flags: [] }
150
+ const parsed = { text: [], tags: [], levels: [], flags: [], before: null, after: null }
137
151
  for (const token of String(raw).split(/\s+/)) {
138
152
  if (token === '') continue
139
153
  const lower = token.toLowerCase()
@@ -142,6 +156,17 @@ export function createService(options) {
142
156
  else if (lower.startsWith('level:') && lower.length > 6) {
143
157
  const value = lower.slice(6)
144
158
  parsed.levels.push(LEVEL_ALIAS[value] ?? value)
159
+ } else if (lower.startsWith('before:') && lower.length > 7) {
160
+ // A partial date is what a search box contains while it is being typed; turning that
161
+ // into an error would make the list flicker red on every keystroke. An unparseable
162
+ // bound is therefore ignored, and the token stays a plain search term.
163
+ const bound = dueBound(lower.slice(7))
164
+ if (bound === null) parsed.text.push(lower)
165
+ else parsed.before = bound
166
+ } else if (lower.startsWith('after:') && lower.length > 6) {
167
+ const bound = dueBound(lower.slice(6))
168
+ if (bound === null) parsed.text.push(lower)
169
+ else parsed.after = bound
145
170
  } else if (lower.startsWith('is:') && lower.length > 3) parsed.flags.push(lower)
146
171
  else if (lower.startsWith('has:') && lower.length > 4) parsed.flags.push(lower)
147
172
  else parsed.text.push(lower)
@@ -162,6 +187,14 @@ export function createService(options) {
162
187
  for (const level of parsed.levels) {
163
188
  if (todo.priority !== level) return false
164
189
  }
190
+ // A date bound is about the deadline. A todo without one cannot satisfy either bound:
191
+ // "what is due before Friday" has no meaningful answer for something with no deadline.
192
+ if (parsed.before !== null || parsed.after !== null) {
193
+ const due = typeof todo.dueAt === 'string' ? Date.parse(todo.dueAt) : Number.NaN
194
+ if (!Number.isFinite(due)) return false
195
+ if (parsed.before !== null && due >= parsed.before) return false
196
+ if (parsed.after !== null && due <= parsed.after) return false
197
+ }
165
198
  for (const flag of parsed.flags) {
166
199
  if (flag === 'is:overdue' && !isOverdue(todo, at)) return false
167
200
  if (flag === 'is:due' && (todo.dueAt === null || todo.dueAt === undefined)) return false
@@ -437,7 +470,9 @@ export function createService(options) {
437
470
  return {
438
471
  ...todo,
439
472
  links: todo.links.map((link) => {
440
- if (link.kind !== 'file') return { ...link }
473
+ // A dead image must be flagged exactly like a dead document: the panel would
474
+ // otherwise render a broken frame instead of the existing missing-file chip.
475
+ if (link.kind !== 'file' && link.kind !== 'image') return { ...link }
441
476
  let present = true
442
477
  try {
443
478
  present = check(link.value) !== false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
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",