dsh-global-todo 0.15.0 → 0.16.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 +15 -0
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/docs/ROADMAP.md +18 -12
- package/lib/client.js +58 -3
- package/lib/index.js +1 -1
- package/lib/routes.js +22 -0
- package/lib/service.js +37 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [0.16.0] — 2026-10-22
|
|
2
|
+
|
|
3
|
+
The three small promises the roadmap still carried.
|
|
4
|
+
|
|
5
|
+
- **`before:` / `after:` in the search box** bound the deadline (strict at the bound). Items
|
|
6
|
+
without a deadline cannot satisfy either bound, and a half-typed date such as `before:2026`
|
|
7
|
+
simply stops filtering instead of throwing on every keystroke.
|
|
8
|
+
- **「重新挂载」 next to a dead attachment** re-points it in place (`POST /relink`, same
|
|
9
|
+
`{ todo, changed }` shape as `/update`), keeping the kind and title. `annotateLinks()` finally
|
|
10
|
+
has its unit test — and it now flags a dead **image** too, which it used to skip, so a missing
|
|
11
|
+
screenshot shows the existing chip instead of a broken frame.
|
|
12
|
+
- **Accessibility pass**: the toast is a polite live region, errors are alerts, groups and rows
|
|
13
|
+
are list items, filter buttons report `aria-pressed`, and every icon-only button has a name.
|
|
14
|
+
`test/a11y.test.mjs` reads the client source so these cannot quietly regress without a browser.
|
|
15
|
+
|
|
1
16
|
## [0.15.0] — 2026-10-22
|
|
2
17
|
|
|
3
18
|
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
|
|
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
|
|
206
|
-
to
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
-
|
|
210
|
-
|
|
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
|
-
-
|
|
224
|
-
|
|
225
|
-
|
|
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.
|
|
40
|
+
export const VERSION = '0.16.0'
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.16.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",
|