dsh-global-todo 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.2] — 2026-10-19
11
+
12
+ Three things the leaders in this category do that we did not.
13
+
14
+ - **Deadlines now say something.** A 30-second host sweep finds active records whose `dueAt` has
15
+ passed and that were not announced yet; the panel shows a due bar, clicking an item opens it,
16
+ and acknowledging clears it. The announced set lives in memory, so a restart re-announces an
17
+ unacknowledged deadline once — the offline catch-up a reminder should have. A postponed
18
+ deadline is a new reminder; an announced one stays quiet.
19
+ - **Readable numbers: `#42`.** Records get a stable, human-facing sequence number assigned on
20
+ create and backfilled in creation order for legacy files; deleting #1 does not renumber #2. It
21
+ shows in the panel meta, in tool output and in the list line, so "把 #42 交给 Agent" means the
22
+ same thing to both sides.
23
+ - **Import previews before writing.** The panel runs a dry run first and shows 新增/更新 counts
24
+ with a confirm button; a wrong file can no longer overwrite by mis-click. The preview is also
25
+ available as `dryRun` on the route.
10
26
  ## [0.5.1] — 2026-10-19
11
27
 
12
28
  The last two loose ends from the D4 audit.
package/README.md CHANGED
@@ -244,7 +244,8 @@ fence when that service is present, and by a strict loopback check otherwise.
244
244
  | 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) |
245
245
  | GET | `/api/global-todo/get` | `id` |
246
246
  | GET | `/api/global-todo/export` | — (the store's own JSON document, for backup or migration) |
247
- | POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge }` — `replace` is an exact round trip, `merge` updates by id and appends (what the panel sends) |
247
+ | 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 |
248
+ | POST | `/api/global-todo/reminders/ack` | `{ ids }` — stop listing these passed deadlines as due |
248
249
  | POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
249
250
  | POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }` — one change across a selection; a stale id lands in `unmatched` instead of failing the batch |
250
251
  | POST | `/api/global-todo/ingest` | `{ items: [{ title, source: { kind }, confidence }], dryRun? }` with `Authorization: Bearer <settings.ingestToken>` — external items; below `ingestConfirmBelow` they wait in the inbox |
package/README.zh.md CHANGED
@@ -140,7 +140,8 @@ dsh plugin --profile web remove dsh-global-todo
140
140
  | GET | `/api/global-todo/dirs` | `path?`——列子目录,供面板内置的目录浏览器使用(宿主自己的选择器在时会优先) |
141
141
  | GET | `/api/global-todo/get` | `id` |
142
142
  | GET | `/api/global-todo/export` | ——(存储用的那份 JSON 文档,用于备份或迁移) |
143
- | POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge }`——`replace` 是无损往返,`merge` 同 id 覆盖、新 id 追加(面板用的是这个) |
143
+ | POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge, dryRun? }`——`replace` 是无损往返,`merge` 同 id 覆盖、新 id 追加(面板用的是这个);`dryRun` 只返回 `wouldCreate` / `wouldUpdate`,不落盘 |
144
+ | POST | `/api/global-todo/reminders/ack` | `{ ids }`——这些已到期的提醒不再列出来 |
144
145
  | POST | `/api/global-todo/note` | `{ id, text }`(追加进展记录) |
145
146
  | POST | `/api/global-todo/capture` | `{ sessionId? }`(请当前对话把要跟进的事抓进清单) |
146
147
  | POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }`——一次改一整批;失效的 id 进 `unmatched`,不会让整批失败 |
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 = 'ui32'
53
+ const UI_REV = 'ui33'
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
 
@@ -136,6 +136,11 @@ window.__ModuleLoader__.load({
136
136
  'notice.autoCollectOn': '已开启自动获取:一次人为发言最多自动问一次',
137
137
  'notice.autoCollectOff': '已关闭自动获取',
138
138
  'notice.externalChange': '清单在别处被改过,已载入最新内容',
139
+ 'reminder.title': '到点提醒({n})',
140
+ 'reminder.dismiss': '知道了',
141
+ 'import.preview': '将导入 {total} 条:新增 {create}、更新 {update}',
142
+ 'import.confirm': '导入',
143
+ 'meta.seqHint': '给人看的编号',
139
144
  'action.exempt': '豁免',
140
145
  'action.exemptOn': '已豁免',
141
146
  'action.exemptHint': '这个工作区里的对话永不自动获取(再点一下取消)',
@@ -352,6 +357,11 @@ window.__ModuleLoader__.load({
352
357
  'notice.autoCollectOn': 'Auto-collect on: at most one question per message from you',
353
358
  'notice.autoCollectOff': 'Auto-collect off',
354
359
  'notice.externalChange': 'The list changed somewhere else; the latest content is loaded',
360
+ 'reminder.title': 'Due now ({n})',
361
+ 'reminder.dismiss': 'Dismiss',
362
+ 'import.preview': 'Will import {total}: {create} new, {update} updated',
363
+ 'import.confirm': 'Import',
364
+ 'meta.seqHint': 'Human-facing number',
355
365
  'action.exempt': 'Exempt',
356
366
  'action.exemptOn': 'Exempted',
357
367
  'action.exemptHint': 'Never auto-collect in this workspace (click again to undo)',
@@ -995,6 +1005,15 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
995
1005
  .gt-item-body { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 0; }
996
1006
  .gt-item-title { font-weight: 600; font-size: 13.5px; word-break: break-word; line-height: 1.45; }
997
1007
  .gt-input-xs { width: 68px; padding: 3px 6px; font-size: 12px; }
1008
+ .gt-seq { font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-secondary); }
1009
+ .gt-reminders { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 10px;
1010
+ border: 1px solid var(--dsw-alias-state-warn-primary); border-radius: 10px;
1011
+ background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
1012
+ .gt-reminders-title { color: var(--dsw-alias-state-warn-primary); font-weight: 600; }
1013
+ .gt-reminder-item { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2);
1014
+ color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; padding: 3px 8px;
1015
+ border-radius: 999px; cursor: pointer; max-width: 260px; overflow: hidden; text-overflow: ellipsis;
1016
+ white-space: nowrap; }
998
1017
  .gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 10.5px; font-weight: 600;
999
1018
  color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
1000
1019
  vertical-align: 1px; }
@@ -1412,6 +1431,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1412
1431
  draft.due !== toLocalInput(todo.dueAt)
1413
1432
 
1414
1433
  const meta = []
1434
+ if (Number.isInteger(todo.seq) && todo.seq > 0) {
1435
+ meta.push(h('span', { key: 'seq', className: 'gt-seq', title: t('meta.seqHint') }, `#${todo.seq}`))
1436
+ }
1415
1437
  meta.push(
1416
1438
  h('span', { key: 'added', title: new Date(todo.createdAt).toLocaleString() }, `${t('meta.added')} ${absoluteTime(todo.createdAt)}(${ageText(lang, todo.createdAt, now)})`),
1417
1439
  )
@@ -2017,6 +2039,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2017
2039
  const [bulkTag, setBulkTag] = useState('')
2018
2040
  const [assignBusy, setAssignBusy] = useState(false)
2019
2041
  const [assignAsk, setAssignAsk] = useState(null)
2042
+ // A picked file is previewed first: a merge is not undoable, and "did I pick the right
2043
+ // export" is exactly the kind of question a dry run answers before anything changes.
2044
+ const [importAsk, setImportAsk] = useState(null)
2020
2045
  const [groupMode, setGroupMode] = useState(() => readStoredGroupMode())
2021
2046
  // A notice may carry an action: after a hand-off the useful next step is
2022
2047
  // to go and look at the conversation the work is actually in.
@@ -2680,17 +2705,31 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2680
2705
  } catch {
2681
2706
  throw new Error(t('import.badJson'))
2682
2707
  }
2683
- const data = await call('import', { method: 'POST', body: { doc: parsed, mode: 'merge' } })
2708
+ const preview = await call('import', { method: 'POST', body: { doc: parsed, mode: 'merge', dryRun: true } })
2709
+ setError(null)
2710
+ setImportAsk({ doc: parsed, summary: preview })
2711
+ } catch (failure) {
2712
+ reportFailure('import', { message: failure?.message })
2713
+ setError(failure)
2714
+ } finally {
2715
+ if (input !== undefined && input !== null) input.value = ''
2716
+ }
2717
+ }
2718
+
2719
+ /** The bar from the dry run: this is the step that actually writes. */
2720
+ const confirmImport = async () => {
2721
+ if (importAsk === null) return
2722
+ try {
2723
+ const data = await call('import', { method: 'POST', body: { doc: importAsk.doc, mode: 'merge' } })
2684
2724
  setError(null)
2685
2725
  setNotice({ text: t('notice.imported', { n: data?.imported ?? 0, total: data?.total ?? 0 }), sessionId: '' })
2686
2726
  setTimeout(() => setNotice(null), 6000)
2727
+ setImportAsk(null)
2687
2728
  void reload()
2688
2729
  void refreshHealth()
2689
2730
  } catch (failure) {
2690
2731
  reportFailure('import', { message: failure?.message })
2691
2732
  setError(failure)
2692
- } finally {
2693
- if (input !== undefined && input !== null) input.value = ''
2694
2733
  }
2695
2734
  }
2696
2735
 
@@ -2824,6 +2863,17 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2824
2863
  return undefined
2825
2864
  }
2826
2865
 
2866
+ /** Acknowledge reminders so the host stops listing them. */
2867
+ const ackReminders = async (ids) => {
2868
+ try {
2869
+ await call('reminders/ack', { method: 'POST', body: { ids } })
2870
+ await refreshHealth()
2871
+ } catch (failure) {
2872
+ reportFailure('reminders', { message: failure?.message })
2873
+ setError(failure)
2874
+ }
2875
+ }
2876
+
2827
2877
  /** Apply one change to the whole selection; a stale id is reported, not fatal. */
2828
2878
  const runBulk = async (change) => {
2829
2879
  const ids = Object.keys(selected)
@@ -2850,6 +2900,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2850
2900
  }
2851
2901
 
2852
2902
  const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 0 }
2903
+ const remindersDue = Array.isArray(state.health?.reminders?.due) ? state.health.reminders.due : []
2853
2904
  const groups = useMemo(
2854
2905
  () => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
2855
2906
  [state.todos, groupMode, sessionTitles, lang],
@@ -3387,6 +3438,37 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3387
3438
  )
3388
3439
  : null,
3389
3440
 
3441
+ // Deadlines that passed. The host announces each one once; acknowledging here is
3442
+ // what stops it listing them on the next poll.
3443
+ remindersDue.length === 0
3444
+ ? null
3445
+ : h(
3446
+ 'div',
3447
+ { className: 'gt-reminders', role: 'status' },
3448
+ h('span', { className: 'gt-reminders-title' }, t('reminder.title', { n: remindersDue.length })),
3449
+ ...remindersDue.slice(0, 3).map((item) =>
3450
+ h(
3451
+ 'button',
3452
+ {
3453
+ key: item.id,
3454
+ type: 'button',
3455
+ className: 'gt-reminder-item',
3456
+ title: item.title,
3457
+ onClick: () => {
3458
+ void ackReminders([item.id])
3459
+ setExpandedId(item.id)
3460
+ },
3461
+ },
3462
+ `${item.seq > 0 ? `#${item.seq} ` : ''}${item.title}`,
3463
+ ),
3464
+ ),
3465
+ h(
3466
+ 'button',
3467
+ { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => void ackReminders(remindersDue.map((item) => item.id)) },
3468
+ t('reminder.dismiss'),
3469
+ ),
3470
+ ),
3471
+
3390
3472
  // Nothing could be worked out for these: the user picks one target for them
3391
3473
  // rather than having them sent somewhere nobody chose.
3392
3474
  assignAsk === null
@@ -3412,6 +3494,32 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3412
3494
  ),
3413
3495
  ),
3414
3496
 
3497
+ importAsk === null
3498
+ ? null
3499
+ : h(
3500
+ 'div',
3501
+ { className: 'gt-selectbar' },
3502
+ h(
3503
+ 'span',
3504
+ null,
3505
+ t('import.preview', {
3506
+ total: importAsk.summary?.wouldImport ?? 0,
3507
+ create: importAsk.summary?.wouldCreate ?? 0,
3508
+ update: importAsk.summary?.wouldUpdate ?? 0,
3509
+ }),
3510
+ ),
3511
+ h(
3512
+ 'button',
3513
+ { type: 'button', className: 'gt-btn gt-btn-brand', onClick: () => void confirmImport() },
3514
+ t('import.confirm'),
3515
+ ),
3516
+ h(
3517
+ 'button',
3518
+ { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setImportAsk(null) },
3519
+ t('select.clear'),
3520
+ ),
3521
+ ),
3522
+
3415
3523
  loading && state.todos.length === 0
3416
3524
  ? h('div', { className: 'gt-empty' }, t('loading'))
3417
3525
  : state.todos.length === 0
package/lib/index.js CHANGED
@@ -28,6 +28,7 @@ import { createDispatcher } from './dispatch.js'
28
28
  import { createSettings, explainAutoCollect, idleSessions, retryDelayMs } from './settings.js'
29
29
  import { createDiagnostics } from './diagnostics.js'
30
30
  import { createMetrics } from './metrics.js'
31
+ import { dueReminders } from './reminders.js'
31
32
  import { LIMITS } from './model.js'
32
33
  import { ROUTE_BASE, createRoutes } from './routes.js'
33
34
  import { createService } from './service.js'
@@ -35,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
35
36
  import { createTools } from './tools.js'
36
37
 
37
38
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
38
- export const VERSION = '0.5.1'
39
+ export const VERSION = '0.5.2'
39
40
 
40
41
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
41
42
  export const name = 'dsh-global-todo'
@@ -410,6 +411,29 @@ export function apply(ctx, config) {
410
411
  const diagnostics = createDiagnostics({ file: path.join(path.dirname(file), 'client-errors.log'), logger })
411
412
  const settingsFile = path.join(path.dirname(file), 'settings.json')
412
413
  const settings = createSettings({ file: settingsFile, logger })
414
+ // Deadlines that have passed and were not announced yet. In memory on purpose: a restart
415
+ // re-announces an unacknowledged deadline once, which is the offline catch-up a reminder
416
+ // is supposed to have.
417
+ const reminderState = { due: [], reminded: {} }
418
+ ctx.effect(() => {
419
+ const tick = async () => {
420
+ try {
421
+ const { todos } = await service.list({ status: 'active' })
422
+ const due = dueReminders(todos, { now: Date.now(), reminded: reminderState.reminded })
423
+ if (due.length === 0) return
424
+ for (const item of due) reminderState.reminded[item.id] = item.dueAt
425
+ const known = new Set(reminderState.due.map((item) => item.id))
426
+ reminderState.due = [...reminderState.due, ...due.filter((item) => !known.has(item.id))]
427
+ } catch (error) {
428
+ logger?.warn?.(`dsh-global-todo: reminder sweep failed: ${error?.message ?? error}`)
429
+ }
430
+ }
431
+ void tick()
432
+ if (typeof setInterval !== 'function') return () => {}
433
+ const timer = setInterval(() => void tick(), 30_000)
434
+ if (typeof timer.unref === 'function') timer.unref()
435
+ return () => clearInterval(timer)
436
+ }, 'dsh-global-todo: due reminders')
413
437
  const autoCollectState = {
414
438
  enabled: false,
415
439
  asking: false,
@@ -488,6 +512,7 @@ export function apply(ctx, config) {
488
512
  target: recentSession,
489
513
  settings,
490
514
  autoCollect: autoCollectState,
515
+ reminders: reminderState,
491
516
  version: VERSION,
492
517
  })
493
518
  const disposers = routes.map((definition) => webCtx.webServer.register(definition))
package/lib/model.js CHANGED
@@ -278,6 +278,9 @@ export function normalizeStored(raw, options = {}) {
278
278
 
279
279
  const todo = {
280
280
  id,
281
+ // Human-facing number ("把 #42 交给 Agent"). Assigned by the store from creation
282
+ // order; 0 means "not assigned yet" and is filled in on the next read.
283
+ seq: Number.isInteger(raw.seq) && raw.seq > 0 ? raw.seq : 0,
281
284
  title,
282
285
  description: typeof raw.description === 'string' ? raw.description : '',
283
286
  status: STATUSES.includes(raw.status) ? raw.status : 'open',
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Due reminders.
3
+ *
4
+ * The list already knows `dueAt`; what it never did was say anything when one passed. The rule
5
+ * is kept pure so the timer can be tested without waiting: a record is due when it is still
6
+ * active, its deadline is in the past, and this exact deadline has not been announced yet.
7
+ * Keying on `id → dueAt` means postponing a reminder makes it due again, while an announced one
8
+ * stays quiet across ticks and restarts (the map lives with the process).
9
+ *
10
+ * @module dsh-global-todo/reminders
11
+ */
12
+
13
+ /**
14
+ * @param {object[]} todos
15
+ * @param {{ now?: number, reminded?: Record<string, string|null> }} [options]
16
+ * @returns {{ id: string, seq: number, title: string, dueAt: string }[]}
17
+ */
18
+ export function dueReminders(todos, options = {}) {
19
+ const now = Number.isFinite(options.now) ? options.now : Date.now()
20
+ const reminded = options.reminded !== null && typeof options.reminded === 'object' ? options.reminded : {}
21
+ const due = []
22
+ for (const todo of Array.isArray(todos) ? todos : []) {
23
+ if (todo === null || typeof todo !== 'object') continue
24
+ if (todo.status !== 'open' && todo.status !== 'doing') continue
25
+ if (todo.dueAt === null || todo.dueAt === undefined || typeof todo.dueAt !== 'string') continue
26
+ const at = Date.parse(todo.dueAt)
27
+ if (!Number.isFinite(at) || at > now) continue
28
+ if (reminded[todo.id] === todo.dueAt) continue
29
+ due.push({
30
+ id: todo.id,
31
+ seq: Number.isInteger(todo.seq) ? todo.seq : 0,
32
+ title: typeof todo.title === 'string' ? todo.title : '',
33
+ dueAt: todo.dueAt,
34
+ })
35
+ }
36
+ return due
37
+ }
package/lib/routes.js CHANGED
@@ -354,7 +354,7 @@ export function createRoutes(deps) {
354
354
  */
355
355
  exact('POST', `${ROUTE_BASE}/import`, async (request) => {
356
356
  const body = await readJsonBody(request)
357
- return service.importDocument(body.doc ?? body, { mode: body.mode })
357
+ return service.importDocument(body.doc ?? body, { mode: body.mode, dryRun: body.dryRun === true })
358
358
  }),
359
359
 
360
360
  exact('GET', `${ROUTE_BASE}/health`, async () => {
@@ -443,6 +443,9 @@ export function createRoutes(deps) {
443
443
  lastRetry: deps.autoCollect?.lastRetry ?? null,
444
444
  lastDrop: deps.autoCollect?.lastDrop ?? null,
445
445
  },
446
+ // Deadlines that passed and have not been acknowledged. In memory: the host
447
+ // announces once, the panel acknowledges, a restart re-announces unacknowledged ones.
448
+ reminders: { due: deps.reminders?.due ?? [] },
446
449
  // 「交给 Agent」 resolves its target from the conversation the user last
447
450
  // wrote in, so whether that feed attached is worth reporting too.
448
451
  target: {
@@ -480,6 +483,18 @@ export function createRoutes(deps) {
480
483
  }
481
484
  }),
482
485
 
486
+ exact('POST', `${ROUTE_BASE}/reminders/ack`, async (request) => {
487
+ const body = await readJsonBody(request)
488
+ const ids = new Set(
489
+ Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === 'string' && id !== '') : [],
490
+ )
491
+ const state = deps.reminders
492
+ if (state !== undefined && state !== null && Array.isArray(state.due)) {
493
+ state.due = state.due.filter((item) => !ids.has(item.id))
494
+ }
495
+ return { cleared: ids.size }
496
+ }),
497
+
483
498
  exact('POST', `${ROUTE_BASE}/ingest`, async (request) => {
484
499
  const settings = deps.settings?.get?.()
485
500
  if (settings?.ingestEnabled !== true) {
package/lib/service.js CHANGED
@@ -277,6 +277,9 @@ export function createService(options) {
277
277
  usedIds: doc.todos.map((candidate) => candidate.id),
278
278
  ...(context.origin === undefined ? {} : { origin: context.origin }),
279
279
  })
280
+ // Stable, human-facing number: the store fills gaps on read, the service assigns the
281
+ // next one on create so "#42" keeps meaning #42.
282
+ todo.seq = doc.todos.reduce((max, candidate) => Math.max(max, candidate.seq ?? 0), 0) + 1
280
283
  doc.todos.push(todo)
281
284
  metrics?.recordList?.('created', doc.todos.length)
282
285
  logger?.debug?.(`dsh-global-todo: created ${todo.id}`)
@@ -610,6 +613,19 @@ export function createService(options) {
610
613
  seen.add(todo.id)
611
614
  records.push(todo)
612
615
  }
616
+ if (context.dryRun === true) {
617
+ const existing = await store.all()
618
+ const known = new Set(existing.map((todo) => todo.id))
619
+ const wouldCreate = records.filter((record) => !known.has(record.id)).length
620
+ return {
621
+ mode,
622
+ dryRun: true,
623
+ wouldImport: records.length,
624
+ wouldCreate,
625
+ wouldUpdate: records.length - wouldCreate,
626
+ total: mode === 'merge' ? existing.length + wouldCreate : records.length,
627
+ }
628
+ }
613
629
  return store.update((existing) => {
614
630
  const todos = mode === 'merge' ? mergeById(existing.todos, records) : records
615
631
  metrics?.recordList?.('imports', todos.length)
package/lib/store.js CHANGED
@@ -64,7 +64,26 @@ function salvage(value, activityLimit) {
64
64
  seen.add(todo.id)
65
65
  todos.push(todo)
66
66
  }
67
- return { version: Number.isInteger(doc.version) ? doc.version : SCHEMA_VERSION, todos }
67
+ return { version: Number.isInteger(doc.version) ? doc.version : SCHEMA_VERSION, todos: assignSequence(todos) }
68
+ }
69
+
70
+ /**
71
+ * Give every record its human-facing number.
72
+ *
73
+ * Existing numbers are kept so "把 #42 交给 Agent" stays true after other records are
74
+ * deleted; records without one are numbered in creation order, and the next write
75
+ * persists them. A file that never gets written again keeps deriving the same numbers.
76
+ *
77
+ * @param {object[]} todos
78
+ * @returns {object[]}
79
+ */
80
+ function assignSequence(todos) {
81
+ let max = 0
82
+ for (const todo of todos) if (Number.isInteger(todo.seq) && todo.seq > max) max = todo.seq
83
+ const missing = todos.filter((todo) => !Number.isInteger(todo.seq) || todo.seq <= 0)
84
+ missing.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id))
85
+ for (const todo of missing) todo.seq = ++max
86
+ return todos
68
87
  }
69
88
 
70
89
  /**
package/lib/tools.js CHANGED
@@ -85,6 +85,7 @@ const COMPLETION_SHAPE = {
85
85
  const TODO_SHAPE = { type: 'object',
86
86
  properties: {
87
87
  id: { type: 'string' },
88
+ seq: { type: 'integer', description: '给人看的编号(#42);0 表示尚未分配' },
88
89
  title: { type: 'string' },
89
90
  description: { type: 'string' },
90
91
  status: { type: 'string', enum: [...STATUSES] },
@@ -144,6 +145,7 @@ export function presentTodo(todo, now = Date.now()) {
144
145
  const due = todo.dueAt === null ? Number.POSITIVE_INFINITY : Date.parse(todo.dueAt)
145
146
  return {
146
147
  id: todo.id,
148
+ seq: todo.seq ?? 0,
147
149
  title: todo.title,
148
150
  description: todo.description,
149
151
  status: todo.status,
@@ -181,7 +183,8 @@ export function presentTodo(todo, now = Date.now()) {
181
183
  */
182
184
  export function todoLine(todo) {
183
185
  const mark = todo.status === 'done' ? 'x' : todo.status === 'doing' ? '~' : todo.status === 'cancelled' ? '-' : ' '
184
- const parts = [`- [${mark}] ${todo.title} id=${todo.id}`, `加入于 ${new Date(todo.createdAt).toISOString()}`]
186
+ const label = Number.isInteger(todo.seq) && todo.seq > 0 ? `#${todo.seq} ` : ''
187
+ const parts = [`- [${mark}] ${label}${todo.title} id=${todo.id}`, `加入于 ${new Date(todo.createdAt).toISOString()}`]
185
188
  if (todo.priority !== 'normal') parts.push(PRIORITY_LABELS[todo.priority] ?? todo.priority)
186
189
  if (todo.dueAt !== null) parts.push(`截止 ${todo.dueAt}`)
187
190
  if (todo.tags.length > 0) parts.push(todo.tags.map((tag) => `#${tag}`).join(' '))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",