dsh-global-todo 0.9.0 → 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 CHANGED
@@ -1,3 +1,33 @@
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
+
17
+ ## [0.9.1] — 2026-10-20
18
+
19
+ The list could not scroll. One missing declaration, and the panel never owned its own height.
20
+
21
+ - **`.gt-inner` needed `min-height: 0`.** A flex item defaults to `min-height: auto`, so the
22
+ column refused to shrink below its content: with a long list it grew to 2236px inside a 680px
23
+ slot, `.gt-groups` never got a scroll range, and the host's own overflow clipped everything
24
+ below the fold — measured `scrollTop 0 → 0` after a wheel, i.e. nothing moved.
25
+ - **`.gt-root` now clips its own overflow**, so the header and toolbar stay put and the list is the
26
+ only scroll area (measured after the fix: `.gt-groups` 480px viewport over a 2036px list, wheel
27
+ moves 0 → 800).
28
+ - **`check:render` asserts the contract**: panel clips, column may shrink, list scrolls. This class
29
+ of bug is invisible to the server-rendered preview screenshot, which is why it shipped.
30
+
1
31
  ## [0.9.0] — 2026-10-20
2
32
 
3
33
  Competitive ground: the hand-off now has a return path, capture carries its source, and the
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, for backup or migration) |
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 / TaskPaper import and export
177
+ ### 3. ~~Markdown import and export~~ — landed in 0.10.0
178
178
 
179
- JSON round-trips byte for byte; the text formats are what let the list live in a repo and
180
- diff.
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
- - Today: only `GET /export` / `POST /import` over JSON.
183
- - Effort: medium — a serializer plus a tolerant parser, both with round-trip tests.
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
- Per-todo completion records already carry a summary and the changed files. A cross-todo
190
- view — "what did the sessions finish this week, and what did they touch" — turns that into
191
- something reviewable.
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 = 'ui48'
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',
@@ -999,8 +1031,14 @@ window.__ModuleLoader__.load({
999
1031
 
1000
1032
  const CSS = `
1001
1033
  .gt-root { position: relative; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0;
1034
+ /* The panel clips; the list inside it scrolls. Without this the column grew past the
1035
+ slot and the host's own overflow hid the rest of the list — the "cannot scroll" bug. */
1036
+ overflow: hidden;
1002
1037
  color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1.5; }
1003
1038
  .gt-inner { display: flex; flex-direction: column; gap: 14px; width: 100%; max-width: 1040px; min-width: 0;
1039
+ /* min-height is the load-bearing line: a flex item defaults to min-height:auto, so the
1040
+ column refused to shrink below its content and .gt-groups never got a scroll range. */
1041
+ min-height: 0;
1004
1042
  box-sizing: border-box; margin: 0 auto; padding: 20px 24px 32px; flex: 1; }
1005
1043
  .gt-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
1006
1044
  .gt-head-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; flex: 0 0 auto; }
@@ -1221,6 +1259,18 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1221
1259
  .gt-settings-foot { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 8px;
1222
1260
  padding: 12px 16px; border-top: 1px solid var(--dsw-alias-border-l1); }
1223
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; }
1224
1274
  .gt-key-row { display: flex; align-items: center; gap: 10px; }
1225
1275
  .gt-key-row .gt-kbd { min-width: 44px; text-align: center; }
1226
1276
  .gt-group-select { display: none; min-height: 28px; max-width: 150px; padding: 0 8px; font: inherit;
@@ -2466,6 +2516,25 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2466
2516
  // reach without curl.
2467
2517
  const [settingsOpen, setSettingsOpen] = useState(initialSettingsOpen)
2468
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])
2469
2538
  const [settingsBox, setSettingsBox] = useState(null)
2470
2539
  // The detail card's breakpoint follows the panel's own width, not the viewport: the
2471
2540
  // slot is ~265px narrower than the window, so a viewport query opens the two-column
@@ -2830,6 +2899,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2830
2899
  (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable === true)
2831
2900
  if (event.key === 'Escape') {
2832
2901
  if (typing && typeof target.blur === 'function') target.blur()
2902
+ if (reportOpen) {
2903
+ setReportOpen(false)
2904
+ return
2905
+ }
2833
2906
  if (keysOpen) {
2834
2907
  setKeysOpen(false)
2835
2908
  return
@@ -2856,7 +2929,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2856
2929
  }
2857
2930
  document.addEventListener('keydown', onKey)
2858
2931
  return () => document.removeEventListener('keydown', onKey)
2859
- }, [settingsOpen, keysOpen, adding])
2932
+ }, [settingsOpen, keysOpen, reportOpen, adding])
2860
2933
 
2861
2934
  // The settings card floats over the list, so a click anywhere else dismisses it
2862
2935
  // the way every other popover on the web does.
@@ -3191,6 +3264,26 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3191
3264
  * unchanged, and the panel's copy carries runtime annotations (a live session's
3192
3265
  * directory, a dead attachment flag) that do not belong in a file.
3193
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
+
3194
3287
  const exportJson = async () => {
3195
3288
  try {
3196
3289
  const data = await call('export')
@@ -3223,9 +3316,20 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3223
3316
  const file = input?.files?.[0]
3224
3317
  if (file === undefined) return
3225
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
+ }
3226
3330
  let parsed
3227
3331
  try {
3228
- parsed = JSON.parse(await file.text())
3332
+ parsed = JSON.parse(text)
3229
3333
  } catch {
3230
3334
  throw new Error(t('import.badJson'))
3231
3335
  }
@@ -3631,7 +3735,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3631
3735
  label: t('action.overflow'),
3632
3736
  items: [
3633
3737
  { key: 'refresh', label: t('action.refresh'), icon: 'refresh', onSelect: () => void reload() },
3738
+ { key: 'report', label: t('report.title'), icon: 'chart', onSelect: () => setReportOpen(true) },
3634
3739
  { key: 'export', label: t('action.export'), icon: 'doc', onSelect: () => void exportJson() },
3740
+ { key: 'exportMd', label: t('action.exportMd'), icon: 'doc', onSelect: () => void exportMarkdown() },
3635
3741
  { key: 'import', label: t('action.import'), icon: 'plus', onSelect: () => importRef.current?.click?.() },
3636
3742
  { key: 'settings', label: t('settings.title'), icon: 'gear', onSelect: () => openSettings() },
3637
3743
  { key: 'keys', label: t('action.keys'), icon: 'kebab', onSelect: () => setKeysOpen(true) },
@@ -3851,6 +3957,83 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3851
3957
  )
3852
3958
  : null,
3853
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
+
3854
4037
  error !== null
3855
4038
  ? h(
3856
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.9.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'
@@ -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.9.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",