dsh-global-todo 0.9.1 → 0.10.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 +16 -0
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/docs/DESIGN.md +21 -0
- package/docs/ROADMAP.md +12 -15
- package/docs/screenshots/panel-detail.png +0 -0
- package/docs/screenshots/panel-inbox.png +0 -0
- package/docs/screenshots/panel-reminders.png +0 -0
- package/docs/screenshots/panel-review.png +0 -0
- package/docs/screenshots/panel-select.png +0 -0
- package/docs/screenshots/panel.png +0 -0
- package/docs/screenshots/tour.gif +0 -0
- package/lib/client.js +180 -3
- package/lib/index.js +1 -1
- package/lib/markdown.js +135 -0
- package/lib/report.js +88 -0
- package/lib/routes.js +30 -1
- package/lib/service.js +13 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## [0.10.0] — 2026-10-20
|
|
2
|
+
|
|
3
|
+
The two remaining high-value items, plus the layout bug written down.
|
|
4
|
+
|
|
5
|
+
- **Markdown in and out.** `GET /export?format=md` writes a task list (title, priority, due,
|
|
6
|
+
tags, evidence, description, steps, attachments as indented metadata); `POST /import` with
|
|
7
|
+
`{ markdown }` reads it back through `create` — new items, never an overwrite, because Markdown
|
|
8
|
+
has no ids. The parser is tolerant: unknown lines ignored, an unreadable date dropped, a blank
|
|
9
|
+
task line is not a todo.
|
|
10
|
+
- **Weekly agent report.** `GET /report?days=7` derives, from completion records alone: what
|
|
11
|
+
finished (newest first, by whom), how many conversations were involved, which files were touched
|
|
12
|
+
and how often, and how many still wait for review. Shown from `⋯ → 本周报告`; nothing is stored.
|
|
13
|
+
- **The scroll bug is documented** in `DESIGN.md` with the measurements (2236px column inside a
|
|
14
|
+
680px slot, `scrollTop 0 → 0`), so the next person who touches the layout knows which two
|
|
15
|
+
declarations hold the contract.
|
|
16
|
+
|
|
1
17
|
## [0.9.1] — 2026-10-20
|
|
2
18
|
|
|
3
19
|
The list could not scroll. One missing declaration, and the panel never owned its own height.
|
package/README.md
CHANGED
|
@@ -269,7 +269,7 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
269
269
|
| GET | `/api/global-todo/list` | `status`, `query`, `tag`, `sort`, `limit` — the reply carries `matched` and `truncated` when the page was cut |
|
|
270
270
|
| GET | `/api/global-todo/dirs` | `path?` — list subdirectories for the panel's built-in folder picker (the shell's own picker is preferred when mounted) |
|
|
271
271
|
| GET | `/api/global-todo/get` | `id` |
|
|
272
|
-
| GET | `/api/global-todo/export` | — (the store's own JSON document
|
|
272
|
+
| GET | `/api/global-todo/export` | — (the store's own JSON document; `?format=md` gives a Markdown task list) |
|
|
273
273
|
| POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge, dryRun? }` — `replace` is an exact round trip, `merge` updates by id and appends (what the panel sends); `dryRun` returns `wouldCreate` / `wouldUpdate` and writes nothing |
|
|
274
274
|
| POST | `/api/global-todo/reminders/ack` | `{ ids }` — stop listing these passed deadlines as due |
|
|
275
275
|
| POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
|
|
@@ -282,6 +282,8 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
282
282
|
| POST | `/api/global-todo/remove` | `{ id }` |
|
|
283
283
|
| POST | `/api/global-todo/restore` | `{ todo }` — put a removed record back verbatim (undo) |
|
|
284
284
|
| POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` — answer an agent's completion |
|
|
285
|
+
| GET | `/api/global-todo/report?days=7` | what the agents finished in the window, and the files it touched |
|
|
286
|
+
| GET | `/api/global-todo/session?id=` | what a bound conversation last said (`tail`, `alive`, `dir`) |
|
|
285
287
|
| POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [value \| index] }` |
|
|
286
288
|
| POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
|
|
287
289
|
| POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }` — hand several todos over in one call; an id that matches nothing comes back instead of being sent elsewhere |
|
package/README.zh.md
CHANGED
|
@@ -161,7 +161,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
161
161
|
| GET | `/api/global-todo/list` | `status`、`query`、`tag`、`sort`、`limit`——被截断时响应带 `matched` 与 `truncated` |
|
|
162
162
|
| GET | `/api/global-todo/dirs` | `path?`——列子目录,供面板内置的目录浏览器使用(宿主自己的选择器在时会优先) |
|
|
163
163
|
| GET | `/api/global-todo/get` | `id` |
|
|
164
|
-
| GET | `/api/global-todo/export` | ——(存储用的那份 JSON
|
|
164
|
+
| GET | `/api/global-todo/export` | ——(存储用的那份 JSON 文档;`?format=md` 给 Markdown 任务列表) |
|
|
165
165
|
| POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge, dryRun? }`——`replace` 是无损往返,`merge` 同 id 覆盖、新 id 追加(面板用的是这个);`dryRun` 只返回 `wouldCreate` / `wouldUpdate`,不落盘 |
|
|
166
166
|
| POST | `/api/global-todo/reminders/ack` | `{ ids }`——这些已到期的提醒不再列出来 |
|
|
167
167
|
| POST | `/api/global-todo/note` | `{ id, text }`(追加进展记录) |
|
|
@@ -175,6 +175,8 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
175
175
|
| POST | `/api/global-todo/remove` | `{ id }` |
|
|
176
176
|
| POST | `/api/global-todo/restore` | `{ todo }` —— 原样还原被删除的记录(撤销) |
|
|
177
177
|
| POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` —— 回答 Agent 的完成声明 |
|
|
178
|
+
| GET | `/api/global-todo/report?days=7` | 这段时间里 Agent 完成了什么、触及了哪些文件 |
|
|
179
|
+
| GET | `/api/global-todo/session?id=` | 绑定的会话最后说了什么(`tail` / `alive` / `dir`) |
|
|
178
180
|
| POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [值 或 下标] }` |
|
|
179
181
|
| POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
|
|
180
182
|
| POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }`——一次把多条交给 Agent;配不上的 id 原样报回,不擅自送到别处 |
|
package/docs/DESIGN.md
CHANGED
|
@@ -57,6 +57,27 @@ $DSH_HOME/global-todo/todos.json ← { version: 1, todos: [ … ] }
|
|
|
57
57
|
version, and a future migration step would run before the first write, keeping the old
|
|
58
58
|
file as a backup.
|
|
59
59
|
|
|
60
|
+
## The panel owns its own scroll
|
|
61
|
+
|
|
62
|
+
A list longer than the slot must scroll **inside** the panel: the header and toolbar stay put, and
|
|
63
|
+
the rows move. That sounds obvious and was broken for two releases, because a flex column does not
|
|
64
|
+
do it by default.
|
|
65
|
+
|
|
66
|
+
- **The bug.** `.gt-inner` is a flex item (`flex: 1`) of `.gt-root`. A flex item's default
|
|
67
|
+
`min-height` is `auto`, so the column refused to shrink below its content: with a long list it
|
|
68
|
+
measured **2236px inside a 680px slot**, `.gt-groups` therefore never had a scroll range, and the
|
|
69
|
+
host's own overflow clipped everything below the fold. Measured proof: a wheel over the list left
|
|
70
|
+
`scrollTop` at **0**.
|
|
71
|
+
- **The fix is two declarations**: `.gt-inner { min-height: 0 }` (let the column shrink) and
|
|
72
|
+
`.gt-root { overflow: hidden }` (the panel clips, so the list is the only scroll owner). After:
|
|
73
|
+
`.gt-groups` is 480px of viewport over a 2036px list, and a wheel moves it 0 → 800.
|
|
74
|
+
- **Why it shipped.** Every screenshot in this repository is a server-rendered preview: no layout
|
|
75
|
+
engine, no wheel, no scrollbar. A static image cannot show that a list is stuck at the top. So the
|
|
76
|
+
render check now asserts the three declarations that make the contract
|
|
77
|
+
(`overflow: hidden` on the panel, `min-height: 0` on the column, `overflow: auto` on the list),
|
|
78
|
+
and anything about scrolling, focus or hover has to be measured with a real browser instead of
|
|
79
|
+
looked at.
|
|
80
|
+
|
|
60
81
|
## Reading the conversation back
|
|
61
82
|
|
|
62
83
|
Handing work over used to be one-way: the panel knew a conversation was bound, and nothing about
|
package/docs/ROADMAP.md
CHANGED
|
@@ -174,25 +174,22 @@ assistant message plus a link to open it.
|
|
|
174
174
|
- Why second: the panel currently cannot answer "did the thing I handed over get anywhere",
|
|
175
175
|
which is the question people actually ask.
|
|
176
176
|
|
|
177
|
-
### 3. Markdown
|
|
177
|
+
### 3. ~~Markdown import and export~~ — landed in 0.10.0
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
`GET /export?format=md` writes a task list with indented metadata; `POST /import` with
|
|
180
|
+
`{ markdown }` reads it back through `create` (new items, never an overwrite — Markdown has no
|
|
181
|
+
ids, so a shared id must not be able to replace a record). Parser is tolerant: a bad date is
|
|
182
|
+
dropped, unknown lines ignored, an empty task line is not a todo. Tests: `test/markdown.test.mjs`.
|
|
181
183
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
- Why third: cheap portability, and it is the only way to compare this list with another
|
|
185
|
-
tool's.
|
|
184
|
+
TaskPaper is still not implemented, and deliberately: the Markdown shape covers "diff it in a
|
|
185
|
+
repo", and a second text format is a second parser to keep honest.
|
|
186
186
|
|
|
187
|
-
### 4. The weekly agent report
|
|
187
|
+
### 4. ~~The weekly agent report~~ — landed in 0.10.0
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
- Today: the data exists (`completion.artifacts`), the aggregate does not.
|
|
194
|
-
- Effort: small-medium; the honest version is a date-bounded list filtered on
|
|
195
|
-
`completion.by === 'agent'`, with `review` state beside each row.
|
|
189
|
+
`GET /report?days=7` aggregates completion records into finished items (newest first), the number
|
|
190
|
+
of conversations involved, files touched with counts, and how many wait for review — derived every
|
|
191
|
+
time, never stored. The panel shows it from `⋯ → 本周报告`. Tests:
|
|
192
|
+
`test/markdown.test.mjs` (pure aggregation) and `test/routes.test.mjs`.
|
|
196
193
|
|
|
197
194
|
### 5. Image attachments
|
|
198
195
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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 = '
|
|
53
|
+
const UI_REV = 'ui50'
|
|
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
|
|
|
@@ -301,6 +301,17 @@ window.__ModuleLoader__.load({
|
|
|
301
301
|
'detail.notes': '进展记录',
|
|
302
302
|
'detail.readback': '会话回读',
|
|
303
303
|
'detail.evidence': '原话',
|
|
304
|
+
'action.exportMd': '导出 Markdown',
|
|
305
|
+
'import.markdownAdded': '已从 Markdown 新增 {n} 条',
|
|
306
|
+
'report.title': '本周报告',
|
|
307
|
+
'report.empty': '这一段时间没有 Agent 完成的记录。',
|
|
308
|
+
'report.finished': '完成 {n} 条',
|
|
309
|
+
'report.sessions': '涉及 {n} 个会话',
|
|
310
|
+
'report.created': '新增 {n} 条',
|
|
311
|
+
'report.pending': '{n} 条待确认',
|
|
312
|
+
'report.files': '触及的文件',
|
|
313
|
+
'report.byAgent': 'Agent',
|
|
314
|
+
'report.byHuman': '人',
|
|
304
315
|
'readback.none': '这条还没有交给过会话。',
|
|
305
316
|
'readback.alive': '会话在跑',
|
|
306
317
|
'readback.dormant': '会话已休眠',
|
|
@@ -608,6 +619,17 @@ window.__ModuleLoader__.load({
|
|
|
608
619
|
'detail.notes': 'Progress notes',
|
|
609
620
|
'detail.readback': 'Session read-back',
|
|
610
621
|
'detail.evidence': 'In the author’s words',
|
|
622
|
+
'action.exportMd': 'Export Markdown',
|
|
623
|
+
'import.markdownAdded': 'Added {n} from Markdown',
|
|
624
|
+
'report.title': 'This week',
|
|
625
|
+
'report.empty': 'No agent completions in this window.',
|
|
626
|
+
'report.finished': '{n} finished',
|
|
627
|
+
'report.sessions': '{n} conversations',
|
|
628
|
+
'report.created': '{n} added',
|
|
629
|
+
'report.pending': '{n} awaiting review',
|
|
630
|
+
'report.files': 'Files touched',
|
|
631
|
+
'report.byAgent': 'Agent',
|
|
632
|
+
'report.byHuman': 'Person',
|
|
611
633
|
'readback.none': 'This has never been handed to a conversation.',
|
|
612
634
|
'readback.alive': 'Conversation is running',
|
|
613
635
|
'readback.dormant': 'Conversation is dormant',
|
|
@@ -972,6 +994,16 @@ window.__ModuleLoader__.load({
|
|
|
972
994
|
}
|
|
973
995
|
if (props.name === 'clock') return h('svg', common, h('circle', { cx: 8, cy: 8, r: 5.6 }), h('path', { d: 'M8 4.8V8l2.2 1.4' }))
|
|
974
996
|
if (props.name === 'close') return h('svg', common, h('path', { d: 'M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6' }))
|
|
997
|
+
if (props.name === 'chart') {
|
|
998
|
+
return h(
|
|
999
|
+
'svg',
|
|
1000
|
+
common,
|
|
1001
|
+
h('path', { d: 'M3 13.2h10' }),
|
|
1002
|
+
h('rect', { x: 3.4, y: 8.2, width: 2.4, height: 4.2, rx: 0.6 }),
|
|
1003
|
+
h('rect', { x: 6.8, y: 5, width: 2.4, height: 7.4, rx: 0.6 }),
|
|
1004
|
+
h('rect', { x: 10.2, y: 2.8, width: 2.4, height: 9.6, rx: 0.6 }),
|
|
1005
|
+
)
|
|
1006
|
+
}
|
|
975
1007
|
if (props.name === 'gear') {
|
|
976
1008
|
return h(
|
|
977
1009
|
'svg',
|
|
@@ -1227,6 +1259,18 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1227
1259
|
.gt-settings-foot { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 8px;
|
|
1228
1260
|
padding: 12px 16px; border-top: 1px solid var(--dsw-alias-border-l1); }
|
|
1229
1261
|
.gt-keys { width: 320px; }
|
|
1262
|
+
.gt-report { width: 420px; }
|
|
1263
|
+
.gt-report-body { display: flex; flex-direction: column; gap: 12px; }
|
|
1264
|
+
.gt-report-summary { display: flex; flex-wrap: wrap; gap: 6px 12px; font-size: 13px; color: var(--dsw-alias-label-primary); }
|
|
1265
|
+
.gt-report-warn { color: var(--dsw-alias-state-warn-primary); }
|
|
1266
|
+
.gt-report-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
1267
|
+
.gt-report-item { display: flex; flex-direction: column; gap: 2px; padding: 8px 0; border-top: 1px solid var(--dsw-alias-border-l1); }
|
|
1268
|
+
.gt-report-title { font-size: 13px; line-height: 20px; font-weight: 600; }
|
|
1269
|
+
.gt-report-meta { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
|
|
1270
|
+
.gt-report-summary-text { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary);
|
|
1271
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
1272
|
+
.gt-report-files { display: flex; flex-direction: column; gap: 6px; }
|
|
1273
|
+
.gt-report-file { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
|
1230
1274
|
.gt-key-row { display: flex; align-items: center; gap: 10px; }
|
|
1231
1275
|
.gt-key-row .gt-kbd { min-width: 44px; text-align: center; }
|
|
1232
1276
|
.gt-group-select { display: none; min-height: 28px; max-width: 150px; padding: 0 8px; font: inherit;
|
|
@@ -2472,6 +2516,25 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2472
2516
|
// reach without curl.
|
|
2473
2517
|
const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen)
|
|
2474
2518
|
const [keysOpen, setKeysOpen] = useState(false)
|
|
2519
|
+
const [reportOpen, setReportOpen] = useState(false)
|
|
2520
|
+
const [report, setReport] = useState(null)
|
|
2521
|
+
|
|
2522
|
+
// The week's evidence, fetched when the card opens: it is an aggregate over every
|
|
2523
|
+
// record, so it must not be derived from the page the panel happens to be showing.
|
|
2524
|
+
useEffect(() => {
|
|
2525
|
+
if (!reportOpen) return undefined
|
|
2526
|
+
let alive = true
|
|
2527
|
+
call('report', { query: { days: 7 } })
|
|
2528
|
+
.then((data) => {
|
|
2529
|
+
if (alive) setReport(data?.report ?? null)
|
|
2530
|
+
})
|
|
2531
|
+
.catch(() => {
|
|
2532
|
+
if (alive) setReport(null)
|
|
2533
|
+
})
|
|
2534
|
+
return () => {
|
|
2535
|
+
alive = false
|
|
2536
|
+
}
|
|
2537
|
+
}, [reportOpen])
|
|
2475
2538
|
const [settingsBox, setSettingsBox] = useState(null)
|
|
2476
2539
|
// The detail card's breakpoint follows the panel's own width, not the viewport: the
|
|
2477
2540
|
// slot is ~265px narrower than the window, so a viewport query opens the two-column
|
|
@@ -2836,6 +2899,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2836
2899
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable === true)
|
|
2837
2900
|
if (event.key === 'Escape') {
|
|
2838
2901
|
if (typing && typeof target.blur === 'function') target.blur()
|
|
2902
|
+
if (reportOpen) {
|
|
2903
|
+
setReportOpen(false)
|
|
2904
|
+
return
|
|
2905
|
+
}
|
|
2839
2906
|
if (keysOpen) {
|
|
2840
2907
|
setKeysOpen(false)
|
|
2841
2908
|
return
|
|
@@ -2862,7 +2929,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2862
2929
|
}
|
|
2863
2930
|
document.addEventListener('keydown', onKey)
|
|
2864
2931
|
return () => document.removeEventListener('keydown', onKey)
|
|
2865
|
-
}, [settingsOpen, keysOpen, adding])
|
|
2932
|
+
}, [settingsOpen, keysOpen, reportOpen, adding])
|
|
2866
2933
|
|
|
2867
2934
|
// The settings card floats over the list, so a click anywhere else dismisses it
|
|
2868
2935
|
// the way every other popover on the web does.
|
|
@@ -3197,6 +3264,26 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3197
3264
|
* unchanged, and the panel's copy carries runtime annotations (a live session's
|
|
3198
3265
|
* directory, a dead attachment flag) that do not belong in a file.
|
|
3199
3266
|
*/
|
|
3267
|
+
/** Same list, in the format a repository can diff. */
|
|
3268
|
+
const exportMarkdown = async () => {
|
|
3269
|
+
try {
|
|
3270
|
+
const data = await call('export', { query: { format: 'md' } })
|
|
3271
|
+
const text = typeof data?.markdown === 'string' ? data.markdown : ''
|
|
3272
|
+
const url = window.URL.createObjectURL(new window.Blob([text], { type: 'text/markdown' }))
|
|
3273
|
+
const link = document.createElement('a')
|
|
3274
|
+
link.href = url
|
|
3275
|
+
link.download = 'global-todo-' + new Date().toISOString().slice(0, 10) + '.md'
|
|
3276
|
+
link.click()
|
|
3277
|
+
window.URL.revokeObjectURL(url)
|
|
3278
|
+
setError(null)
|
|
3279
|
+
setNotice({ text: t('notice.exported', { n: data?.count ?? 0 }), sessionId: '' })
|
|
3280
|
+
setTimeout(() => setNotice(null), 6000)
|
|
3281
|
+
} catch (failure) {
|
|
3282
|
+
reportFailure('export-markdown', { message: failure?.message })
|
|
3283
|
+
setError(failure)
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3200
3287
|
const exportJson = async () => {
|
|
3201
3288
|
try {
|
|
3202
3289
|
const data = await call('export')
|
|
@@ -3229,9 +3316,20 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3229
3316
|
const file = input?.files?.[0]
|
|
3230
3317
|
if (file === undefined) return
|
|
3231
3318
|
try {
|
|
3319
|
+
const text = await file.text()
|
|
3320
|
+
// A .md file (or anything that is not JSON) is read as a task list: those items
|
|
3321
|
+
// are new by definition and go through create, not through the document restore.
|
|
3322
|
+
if (/\.md$/i.test(file.name ?? '') || text.trimStart().startsWith('#')) {
|
|
3323
|
+
const preview = await call('import', { method: 'POST', body: { markdown: text, dryRun: true } })
|
|
3324
|
+
setError(null)
|
|
3325
|
+
setNotice({ text: t('import.markdownAdded', { n: preview?.imported ?? 0 }), sessionId: '' })
|
|
3326
|
+
setTimeout(() => setNotice(null), 6000)
|
|
3327
|
+
void reload()
|
|
3328
|
+
return
|
|
3329
|
+
}
|
|
3232
3330
|
let parsed
|
|
3233
3331
|
try {
|
|
3234
|
-
parsed = JSON.parse(
|
|
3332
|
+
parsed = JSON.parse(text)
|
|
3235
3333
|
} catch {
|
|
3236
3334
|
throw new Error(t('import.badJson'))
|
|
3237
3335
|
}
|
|
@@ -3637,7 +3735,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3637
3735
|
label: t('action.overflow'),
|
|
3638
3736
|
items: [
|
|
3639
3737
|
{ key: 'refresh', label: t('action.refresh'), icon: 'refresh', onSelect: () => void reload() },
|
|
3738
|
+
{ key: 'report', label: t('report.title'), icon: 'chart', onSelect: () => setReportOpen(true) },
|
|
3640
3739
|
{ key: 'export', label: t('action.export'), icon: 'doc', onSelect: () => void exportJson() },
|
|
3740
|
+
{ key: 'exportMd', label: t('action.exportMd'), icon: 'doc', onSelect: () => void exportMarkdown() },
|
|
3641
3741
|
{ key: 'import', label: t('action.import'), icon: 'plus', onSelect: () => importRef.current?.click?.() },
|
|
3642
3742
|
{ key: 'settings', label: t('settings.title'), icon: 'gear', onSelect: () => openSettings() },
|
|
3643
3743
|
{ key: 'keys', label: t('action.keys'), icon: 'kebab', onSelect: () => setKeysOpen(true) },
|
|
@@ -3857,6 +3957,83 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3857
3957
|
)
|
|
3858
3958
|
: null,
|
|
3859
3959
|
|
|
3960
|
+
reportOpen
|
|
3961
|
+
? h(
|
|
3962
|
+
'div',
|
|
3963
|
+
{ className: 'gt-settings gt-report', role: 'dialog', 'aria-label': t('report.title') },
|
|
3964
|
+
h(
|
|
3965
|
+
'div',
|
|
3966
|
+
{ className: 'gt-settings-head' },
|
|
3967
|
+
h('span', { className: 'gt-settings-title' }, t('report.title')),
|
|
3968
|
+
h(
|
|
3969
|
+
'button',
|
|
3970
|
+
{ type: 'button', className: 'gt-iconbtn', title: t('action.hide'), 'aria-label': t('action.hide'), onClick: () => setReportOpen(false) },
|
|
3971
|
+
h(Icon, { name: 'close', size: 13 }),
|
|
3972
|
+
),
|
|
3973
|
+
),
|
|
3974
|
+
h(
|
|
3975
|
+
'div',
|
|
3976
|
+
{ className: 'gt-settings-body' },
|
|
3977
|
+
report === null
|
|
3978
|
+
? h('div', { className: 'gt-muted' }, t('loading'))
|
|
3979
|
+
: h(
|
|
3980
|
+
'div',
|
|
3981
|
+
{ className: 'gt-report-body' },
|
|
3982
|
+
h(
|
|
3983
|
+
'div',
|
|
3984
|
+
{ className: 'gt-report-summary' },
|
|
3985
|
+
h('span', null, t('report.finished', { n: report.finished.length })),
|
|
3986
|
+
h('span', null, t('report.sessions', { n: report.sessions })),
|
|
3987
|
+
h('span', null, t('report.created', { n: report.created })),
|
|
3988
|
+
report.pendingReview > 0 ? h('span', { className: 'gt-report-warn' }, t('report.pending', { n: report.pendingReview })) : null,
|
|
3989
|
+
),
|
|
3990
|
+
report.finished.length === 0
|
|
3991
|
+
? h('div', { className: 'gt-muted' }, t('report.empty'))
|
|
3992
|
+
: h(
|
|
3993
|
+
'ul',
|
|
3994
|
+
{ className: 'gt-report-list' },
|
|
3995
|
+
report.finished.map((item) =>
|
|
3996
|
+
h(
|
|
3997
|
+
'li',
|
|
3998
|
+
{ key: item.id, className: 'gt-report-item' },
|
|
3999
|
+
h(
|
|
4000
|
+
'div',
|
|
4001
|
+
{ className: 'gt-report-title' },
|
|
4002
|
+
item.seq > 0 ? h('span', { className: 'gt-seq' }, '#' + item.seq + ' ') : null,
|
|
4003
|
+
item.title,
|
|
4004
|
+
),
|
|
4005
|
+
h(
|
|
4006
|
+
'div',
|
|
4007
|
+
{ className: 'gt-report-meta' },
|
|
4008
|
+
item.by === 'agent' ? t('report.byAgent') : t('report.byHuman'),
|
|
4009
|
+
' · ',
|
|
4010
|
+
absoluteTime(item.at),
|
|
4011
|
+
item.artifactCount > 0 ? ' · ' + item.artifactCount + ' 个产出' : '',
|
|
4012
|
+
),
|
|
4013
|
+
item.summary === '' ? null : h('div', { className: 'gt-report-summary-text' }, item.summary),
|
|
4014
|
+
),
|
|
4015
|
+
),
|
|
4016
|
+
),
|
|
4017
|
+
report.files.length === 0
|
|
4018
|
+
? null
|
|
4019
|
+
: h(
|
|
4020
|
+
'div',
|
|
4021
|
+
{ className: 'gt-report-files' },
|
|
4022
|
+
h('div', { className: 'gt-settings-group-title' }, t('report.files')),
|
|
4023
|
+
...report.files.map((file) =>
|
|
4024
|
+
h(
|
|
4025
|
+
'div',
|
|
4026
|
+
{ key: file.path, className: 'gt-report-file', title: file.titles.join(' / ') },
|
|
4027
|
+
h('span', { className: 'gt-mono' }, file.path),
|
|
4028
|
+
h('span', { className: 'gt-hint' }, '×' + file.count),
|
|
4029
|
+
),
|
|
4030
|
+
),
|
|
4031
|
+
),
|
|
4032
|
+
),
|
|
4033
|
+
),
|
|
4034
|
+
)
|
|
4035
|
+
: null,
|
|
4036
|
+
|
|
3860
4037
|
error !== null
|
|
3861
4038
|
? h(
|
|
3862
4039
|
'div',
|
package/lib/index.js
CHANGED
|
@@ -36,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
36
36
|
import { createTools } from './tools.js'
|
|
37
37
|
|
|
38
38
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
39
|
-
export const VERSION = '0.
|
|
39
|
+
export const VERSION = '0.10.0'
|
|
40
40
|
|
|
41
41
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
42
42
|
export const name = 'dsh-global-todo'
|
package/lib/markdown.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown, both ways.
|
|
3
|
+
*
|
|
4
|
+
* The JSON export is the round-trip format; this one is for humans and for repositories:
|
|
5
|
+
* it diffs line by line, it renders on GitHub, and it survives being edited by hand.
|
|
6
|
+
*
|
|
7
|
+
* The shape is deliberately plain — a task list with indented metadata — so that reading
|
|
8
|
+
* it needs no documentation and writing it needs no schema:
|
|
9
|
+
*
|
|
10
|
+
* - [ ] 标题
|
|
11
|
+
* 优先级: 高 · 截止: 2026-10-21T18:00 · 标签: 发布, 文档
|
|
12
|
+
* 描述: 第一行
|
|
13
|
+
* - [x] 一步
|
|
14
|
+
*
|
|
15
|
+
* Import is tolerant: unknown lines are ignored, a bad date is dropped instead of failing
|
|
16
|
+
* the whole file, and anything that is not a task line simply does not become a todo.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PRIORITY_LABELS = { urgent: '紧急', high: '高', normal: '常规', low: '低' }
|
|
20
|
+
const PRIORITY_BY_LABEL = new Map(Object.entries(PRIORITY_LABELS).map(([key, label]) => [label, key]))
|
|
21
|
+
const STATUS_HEADINGS = [
|
|
22
|
+
['active', '未完成'],
|
|
23
|
+
['done', '已完成'],
|
|
24
|
+
['archived', '已归档'],
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** @param {string} text @returns {string} */
|
|
28
|
+
function clean(text) {
|
|
29
|
+
return String(text ?? '').replace(/\r\n?/g, '\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ todos: object[] }} doc
|
|
34
|
+
* @param {{ now?: number, title?: string }} [options]
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function toMarkdown(doc, options = {}) {
|
|
38
|
+
const todos = Array.isArray(doc?.todos) ? doc.todos : []
|
|
39
|
+
const now = options.now ?? Date.now()
|
|
40
|
+
const lines = [
|
|
41
|
+
'# ' + (options.title ?? '全局待办'),
|
|
42
|
+
'',
|
|
43
|
+
'> 导出时间 ' + new Date(now).toISOString() + ',共 ' + todos.length + ' 条。',
|
|
44
|
+
'',
|
|
45
|
+
]
|
|
46
|
+
for (const [status, heading] of STATUS_HEADINGS) {
|
|
47
|
+
const group = todos.filter((todo) => {
|
|
48
|
+
const resolved = todo.status === 'done' ? 'done' : todo.status === 'archived' ? 'archived' : 'active'
|
|
49
|
+
return resolved === status
|
|
50
|
+
})
|
|
51
|
+
if (group.length === 0) continue
|
|
52
|
+
lines.push('## ' + heading, '')
|
|
53
|
+
for (const todo of group) {
|
|
54
|
+
lines.push('- [' + (todo.status === 'done' ? 'x' : ' ') + '] ' + todo.title)
|
|
55
|
+
const meta = []
|
|
56
|
+
if (todo.priority !== 'normal') meta.push('优先级: ' + (PRIORITY_LABELS[todo.priority] ?? todo.priority))
|
|
57
|
+
if (todo.dueAt !== null && todo.dueAt !== undefined) meta.push('截止: ' + todo.dueAt)
|
|
58
|
+
if (Array.isArray(todo.tags) && todo.tags.length > 0) meta.push('标签: ' + todo.tags.join(', '))
|
|
59
|
+
if (todo.seq > 0) meta.push('#: ' + todo.seq)
|
|
60
|
+
if (meta.length > 0) lines.push(' ' + meta.join(' · '))
|
|
61
|
+
if (todo.evidence) lines.push(' 原话: ' + todo.evidence)
|
|
62
|
+
if (todo.description) {
|
|
63
|
+
for (const line of clean(todo.description).split('\n')) lines.push(' 描述: ' + line)
|
|
64
|
+
}
|
|
65
|
+
for (const step of todo.steps ?? []) lines.push(' - [' + (step.done === true ? 'x' : ' ') + '] ' + step.text)
|
|
66
|
+
for (const link of todo.links ?? []) lines.push(' 附件: ' + link.value)
|
|
67
|
+
lines.push('')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines.join('\n')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read back what {@link toMarkdown} wrote, tolerantly.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @returns {{ todos: object[] }} the same document shape `/import` accepts
|
|
78
|
+
*/
|
|
79
|
+
export function fromMarkdown(text) {
|
|
80
|
+
const todos = []
|
|
81
|
+
let current = null
|
|
82
|
+
for (const raw of clean(text).split('\n')) {
|
|
83
|
+
const line = raw.replace(/\s+$/, '')
|
|
84
|
+
const task = /^- \[([ xX])\] (.+)$/.exec(line)
|
|
85
|
+
if (task !== null) {
|
|
86
|
+
current = {
|
|
87
|
+
title: task[2].trim(),
|
|
88
|
+
status: task[1].toLowerCase() === 'x' ? 'done' : 'open',
|
|
89
|
+
description: '',
|
|
90
|
+
priority: 'normal',
|
|
91
|
+
tags: [],
|
|
92
|
+
steps: [],
|
|
93
|
+
links: [],
|
|
94
|
+
}
|
|
95
|
+
todos.push(current)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
const step = /^\s+- \[([ xX])\] (.+)$/.exec(line)
|
|
99
|
+
if (step !== null && current !== null) {
|
|
100
|
+
current.steps.push({ text: step[2].trim(), done: step[1].toLowerCase() === 'x' })
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const field = /^\s{2,}(优先级|截止|标签|描述|原话|附件|#): ?(.*)$/.exec(line)
|
|
104
|
+
if (field === null || current === null) continue
|
|
105
|
+
const [, key, value] = field
|
|
106
|
+
if (key === '描述') {
|
|
107
|
+
// Description lines are free text: they may contain ' · ' or anything else.
|
|
108
|
+
current.description = current.description === '' ? value : current.description + '\n' + value
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (key === '原话') {
|
|
112
|
+
current.evidence = value
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (key === '附件') {
|
|
116
|
+
current.links.push(value.trim())
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
// One meta line packs several fields ('优先级: 高 · 截止: … · 标签: …'): split it back
|
|
120
|
+
// into segments before reading any of them.
|
|
121
|
+
for (const segment of (key + ': ' + value).split(' · ')) {
|
|
122
|
+
const one = /^(优先级|截止|标签|#): ?(.*)$/.exec(segment)
|
|
123
|
+
if (one === null) continue
|
|
124
|
+
const [, name, raw] = one
|
|
125
|
+
if (name === '优先级') current.priority = PRIORITY_BY_LABEL.get(raw.trim()) ?? 'normal'
|
|
126
|
+
else if (name === '标签') current.tags = raw.split(',').map((tag) => tag.trim()).filter((tag) => tag !== '')
|
|
127
|
+
else if (name === '#') current.seq = Number.parseInt(raw, 10) || 0
|
|
128
|
+
else if (name === '截止') {
|
|
129
|
+
// A date that does not parse is dropped: one bad line must not fail the file.
|
|
130
|
+
if (!Number.isNaN(Date.parse(raw))) current.dueAt = new Date(raw).toISOString()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { todos }
|
|
135
|
+
}
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the agents did, this week.
|
|
3
|
+
*
|
|
4
|
+
* The per-todo completion record already holds the facts: who finished it, what they said,
|
|
5
|
+
* and which files the work produced. This turns those records into the one page a person
|
|
6
|
+
* actually asks for — "what moved, and did any of it land in code" — without inventing a
|
|
7
|
+
* second source of truth: everything here is derived from `completion`, `doneAt` and
|
|
8
|
+
* `review`.
|
|
9
|
+
*
|
|
10
|
+
* Pure and time-explicit: the caller passes `now`, so a test never depends on the clock.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** How many files are listed before the tail is summarised. */
|
|
14
|
+
const FILE_LIMIT = 12
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object[]} todos
|
|
18
|
+
* @param {{ now?: number, days?: number }} [options]
|
|
19
|
+
* @returns {{
|
|
20
|
+
* since: number,
|
|
21
|
+
* days: number,
|
|
22
|
+
* finished: object[],
|
|
23
|
+
* created: number,
|
|
24
|
+
* sessions: number,
|
|
25
|
+
* files: Array<{ path: string, count: number, titles: string[] }>,
|
|
26
|
+
* pendingReview: number,
|
|
27
|
+
* }}
|
|
28
|
+
*/
|
|
29
|
+
export function weeklyReport(todos, options = {}) {
|
|
30
|
+
const now = options.now ?? Date.now()
|
|
31
|
+
const days = Number.isFinite(options.days) && options.days > 0 ? Math.min(options.days, 90) : 7
|
|
32
|
+
const since = now - days * 24 * 60 * 60 * 1000
|
|
33
|
+
const list = Array.isArray(todos) ? todos : []
|
|
34
|
+
|
|
35
|
+
const finished = []
|
|
36
|
+
let created = 0
|
|
37
|
+
const sessions = new Set()
|
|
38
|
+
const files = new Map()
|
|
39
|
+
let pendingReview = 0
|
|
40
|
+
|
|
41
|
+
for (const todo of list) {
|
|
42
|
+
if (typeof todo.createdAt === 'number' && todo.createdAt >= since) created += 1
|
|
43
|
+
if (todo.status === 'done' && todo.review?.pending === true) pendingReview += 1
|
|
44
|
+
const completion = todo.completion
|
|
45
|
+
if (completion === null || completion === undefined) continue
|
|
46
|
+
const at = Number.isFinite(completion.at) ? completion.at : todo.doneAt
|
|
47
|
+
if (!Number.isFinite(at) || at < since) continue
|
|
48
|
+
|
|
49
|
+
const artifacts = Array.isArray(completion.artifacts) ? completion.artifacts : []
|
|
50
|
+
finished.push({
|
|
51
|
+
id: todo.id,
|
|
52
|
+
seq: todo.seq ?? 0,
|
|
53
|
+
title: todo.title,
|
|
54
|
+
at,
|
|
55
|
+
by: completion.by,
|
|
56
|
+
summary: completion.summary ?? '',
|
|
57
|
+
sessionId: completion.sessionId ?? '',
|
|
58
|
+
reviewPending: todo.review?.pending === true,
|
|
59
|
+
artifacts: artifacts.slice(0, 5).map((link) => ({ kind: link.kind, value: link.value, title: link.title })),
|
|
60
|
+
artifactCount: artifacts.length,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
if (typeof completion.sessionId === 'string' && completion.sessionId !== '') sessions.add(completion.sessionId)
|
|
64
|
+
for (const link of artifacts) {
|
|
65
|
+
if (link?.kind !== 'file') continue
|
|
66
|
+
const path = String(link.value ?? '')
|
|
67
|
+
if (path === '') continue
|
|
68
|
+
const entry = files.get(path) ?? { path, count: 0, titles: [] }
|
|
69
|
+
entry.count += 1
|
|
70
|
+
if (entry.titles.length < 3) entry.titles.push(todo.title)
|
|
71
|
+
files.set(path, entry)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
finished.sort((a, b) => b.at - a.at)
|
|
76
|
+
const topFiles = [...files.values()].sort((a, b) => b.count - a.count || a.path.localeCompare(b.path))
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
since,
|
|
80
|
+
days,
|
|
81
|
+
finished,
|
|
82
|
+
created,
|
|
83
|
+
sessions: sessions.size,
|
|
84
|
+
files: topFiles.slice(0, FILE_LIMIT),
|
|
85
|
+
fileCount: topFiles.length,
|
|
86
|
+
pendingReview,
|
|
87
|
+
}
|
|
88
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -27,6 +27,8 @@ import path from 'node:path'
|
|
|
27
27
|
|
|
28
28
|
import { TodoError, asTodoError } from './errors.js'
|
|
29
29
|
import { MAX_INGEST_ITEMS } from './ingest.js'
|
|
30
|
+
import { fromMarkdown, toMarkdown } from './markdown.js'
|
|
31
|
+
import { weeklyReport } from './report.js'
|
|
30
32
|
|
|
31
33
|
/** Route prefix. The client appends this to the document base. */
|
|
32
34
|
export const ROUTE_BASE = '/api/global-todo'
|
|
@@ -362,8 +364,13 @@ export function createRoutes(deps) {
|
|
|
362
364
|
* This is the store's own document, not the annotated `/list` shape: an export
|
|
363
365
|
* is something `/import` has to be able to hand back unchanged.
|
|
364
366
|
*/
|
|
365
|
-
exact('GET', `${ROUTE_BASE}/export`, async () => {
|
|
367
|
+
exact('GET', `${ROUTE_BASE}/export`, async (request) => {
|
|
366
368
|
const doc = await service.exportDocument()
|
|
369
|
+
// `format=md` is for people and repositories: it diffs, it renders, it can be edited
|
|
370
|
+
// by hand. The JSON document stays the round-trip format.
|
|
371
|
+
if ((params(request).get('format') ?? '') === 'md') {
|
|
372
|
+
return { markdown: toMarkdown(doc), count: doc.todos.length, exportedAt: new Date().toISOString() }
|
|
373
|
+
}
|
|
367
374
|
return { doc, count: doc.todos.length, exportedAt: new Date().toISOString() }
|
|
368
375
|
}),
|
|
369
376
|
|
|
@@ -376,9 +383,31 @@ export function createRoutes(deps) {
|
|
|
376
383
|
*/
|
|
377
384
|
exact('POST', `${ROUTE_BASE}/import`, async (request) => {
|
|
378
385
|
const body = await readJsonBody(request)
|
|
386
|
+
if (typeof body.markdown === 'string') {
|
|
387
|
+
// Markdown has no ids, so it is not a document restore: it is a list of items to
|
|
388
|
+
// add. Going through `create` means one validation path, honest ids, and a file
|
|
389
|
+
// can never silently overwrite a record that happened to share an id.
|
|
390
|
+
const parsed = fromMarkdown(body.markdown).todos
|
|
391
|
+
if (parsed.length === 0) throw new TodoError('invalid-body', '这份 Markdown 里没有可识别的待办(- [ ] 标题)')
|
|
392
|
+
const created = []
|
|
393
|
+
for (const item of parsed) created.push(await service.create(item, { by: 'human' }))
|
|
394
|
+
return { mode: 'markdown', imported: created.length, todos: created }
|
|
395
|
+
}
|
|
379
396
|
return service.importDocument(body.doc ?? body, { mode: body.mode, dryRun: body.dryRun === true })
|
|
380
397
|
}),
|
|
381
398
|
|
|
399
|
+
/**
|
|
400
|
+
* What the agents finished in the last N days, and what it touched.
|
|
401
|
+
*
|
|
402
|
+
* Derived, never stored: completion records already hold the facts, and a cached
|
|
403
|
+
* aggregate would be a second truth to keep honest.
|
|
404
|
+
*/
|
|
405
|
+
exact('GET', `${ROUTE_BASE}/report`, async (request) => {
|
|
406
|
+
const days = Number.parseInt(params(request).get('days') ?? '', 10)
|
|
407
|
+
const todos = await service.listAll()
|
|
408
|
+
return { report: weeklyReport(todos, { days: Number.isFinite(days) ? days : 7 }) }
|
|
409
|
+
}),
|
|
410
|
+
|
|
382
411
|
exact('GET', `${ROUTE_BASE}/health`, async () => {
|
|
383
412
|
// Counted once: the size feeds the metrics snapshot and the counts the panel
|
|
384
413
|
// reads are the same numbers, so /health cannot contradict itself.
|
package/lib/service.js
CHANGED
|
@@ -246,6 +246,18 @@ export function createService(options) {
|
|
|
246
246
|
* @param {{ status?: string, query?: string, tag?: string, sort?: string, limit?: number }} [query]
|
|
247
247
|
* @returns {Promise<{ todos: object[], counts: object, matched: number, truncated: boolean }>}
|
|
248
248
|
*/
|
|
249
|
+
/**
|
|
250
|
+
* Every stored todo, unfiltered and unpaged.
|
|
251
|
+
*
|
|
252
|
+
* For aggregates that must not be narrowed by whatever the panel is currently
|
|
253
|
+
* showing — a weekly report over the visible page would be a lie of omission.
|
|
254
|
+
*
|
|
255
|
+
* @returns {Promise<object[]>}
|
|
256
|
+
*/
|
|
257
|
+
async function listAll() {
|
|
258
|
+
return store.all()
|
|
259
|
+
}
|
|
260
|
+
|
|
249
261
|
async function list(query = {}) {
|
|
250
262
|
const todos = await store.all()
|
|
251
263
|
const selected = select(todos, query)
|
|
@@ -799,6 +811,7 @@ export function createService(options) {
|
|
|
799
811
|
|
|
800
812
|
return {
|
|
801
813
|
list,
|
|
814
|
+
listAll,
|
|
802
815
|
get,
|
|
803
816
|
create,
|
|
804
817
|
update,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|