dsh-global-todo 0.6.8 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ## [0.7.1] — 2026-10-20
2
+
3
+ Documentation for the one piece of layout that cannot be "cleaned up".
4
+
5
+ - **`docs/DESIGN.md` now records why the wide-screen detail card is `position: fixed`**, with the
6
+ measurements from the failed attempts (an absolute card came out 348×3051 at 1440; container
7
+ queries make the panel the containing block for fixed descendants), the real cause (the panel
8
+ scrolls with the host page, not `.gt-groups`), and the rules for changing it safely.
9
+ - `/api/global-todo/review` added to both README route tables (it shipped in 0.7.0 without its
10
+ row).
11
+
12
+ ## [0.7.0] — 2026-10-20
13
+
14
+ An agent's "done" becomes a claim a person confirms, instead of a fact.
15
+
16
+ - **Completing as an agent now leaves a review mark.** `todo.review = { pending, at, by }` is set
17
+ by the model whenever the actor is `agent` (both `setStatus … done` and the completion path the
18
+ tools use); a person finishing something sets none, and anything that leaves `done` clears it.
19
+ - **`POST /api/global-todo/review`** answers it: `confirm` keeps the todo finished and drops the
20
+ mark, `reject` puts it back on the list with the completion record removed — "not actually done"
21
+ is the only honest reading of a rejected claim. Answering twice is refused, not silently
22
+ accepted.
23
+ - **The panel shows it**: a `待确认` chip on the row, a `待确认` filter that is the done list
24
+ narrowed to pending marks, and `确认完成` / `退回清单` in the row menu. `counts.review` drives the
25
+ chip and the filter.
26
+ - **Agents see it too**: `presentTodo` carries `review`, so an agent can tell that its completion
27
+ is still waiting rather than assuming it landed.
28
+ - Two new model tests cover the rule (agent vs person, confirm vs reject, no double answers).
29
+
1
30
  ## [0.6.8] — 2026-10-20
2
31
 
3
32
  Every conditional state now has a baseline image, and the harness refuses to render a blank
package/README.md CHANGED
@@ -268,6 +268,7 @@ fence when that service is present, and by a strict loopback check otherwise.
268
268
  | POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
269
269
  | POST | `/api/global-todo/remove` | `{ id }` |
270
270
  | POST | `/api/global-todo/restore` | `{ todo }` — put a removed record back verbatim (undo) |
271
+ | POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` — answer an agent's completion |
271
272
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [value \| index] }` |
272
273
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
273
274
  | 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
@@ -164,6 +164,7 @@ dsh plugin --profile web remove dsh-global-todo
164
164
  | POST | `/api/global-todo/status` | `{ id, status, by?: human\|agent }` |
165
165
  | POST | `/api/global-todo/remove` | `{ id }` |
166
166
  | POST | `/api/global-todo/restore` | `{ todo }` —— 原样还原被删除的记录(撤销) |
167
+ | POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` —— 回答 Agent 的完成声明 |
167
168
  | POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [值 或 下标] }` |
168
169
  | POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
169
170
  | POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }`——一次把多条交给 Agent;配不上的 id 原样报回,不擅自送到别处 |
package/docs/DESIGN.md CHANGED
@@ -60,15 +60,51 @@ $DSH_HOME/global-todo/todos.json ← { version: 1, todos: [ … ] }
60
60
  ## On a wide window, the detail stands beside the list
61
61
 
62
62
  Expanding a row inline is right on a narrow window and wrong on a wide one: the list jumps, the
63
- row you were reading slides away, and the rest of the screen sits empty. Above 1100px the open
64
- row's detail is lifted out of the flow into a card bracketed between the toolbar and the footer
65
- at the panel's right edge; only the list gives way (the toolbar keeps full width), and the list gets
66
- padding equal to that card. The card's offsets are measured from the panel's own column on mount
67
- and on resize — the slot does not always fill the window, so a viewport-derived offset put the card
68
- over the list inside the real GUI. This is a CSS-only override over unchanged markup
69
- (`.gt-root[data-wide-detail]` plus one media query) precisely so there is still exactly one
70
- detail implementation — a second render path for wide screens would drift from the inline one at
71
- the first field change.
63
+ row you were reading slides away, and the rest of the screen sits empty. When the panel itself is
64
+ at least 960px wide, the open row's detail is lifted out of the flow into a card at the panel's
65
+ right edge; only the list gives way (the header and toolbar keep full width), and the list gets
66
+ 360px of right padding equal to the card. The breakpoint follows the **panel's** measured width,
67
+ not the viewport: the slot is ~265px narrower than the window, so a viewport query opened the
68
+ two-column layout while the panel still had no room for it. This is a CSS-only override over
69
+ unchanged markup (`.gt-root[data-wide]` / `[data-wide-detail]`) precisely so there is still
70
+ exactly one detail implementation — a second render path for wide screens would drift from the
71
+ inline one at the first field change.
72
+
73
+ ### Why the card is `position: fixed` and not `sticky`
74
+
75
+ Three ways to do this were tried; only one survives the host's layout.
76
+
77
+ 1. **`position: sticky`** — the card is a child of the row, so sticky is bounded by the row's
78
+ box: it never leaves the 72–94px tall item. Moving the card out of the row would mean a second
79
+ detail render path.
80
+ 2. **`position: absolute` inside a positioned wrapper around the list** — measured on
81
+ 2026-10-20: at 1440 the card came out **348 × 3051px**, at 1280 **348 × 3203px**. The card
82
+ stretched to the full height of the list's *content* and scrolled away with the page. Reason:
83
+ **the scrolling ancestor of this panel is the host page, not `.gt-groups`** — the panel is a
84
+ column in someone else's scroller, so `.gt-groups` never scrolls and an absolutely positioned
85
+ box is bracketed by the whole content column instead of the visible area.
86
+ 3. **`container-type: inline-size` + container queries** — wanted, because the breakpoint would
87
+ then be CSS. But inline-size containment applies `contain: layout`, which makes the panel the
88
+ containing block for `position: fixed` descendants. The card (and the settings popover) would
89
+ silently switch from viewport-anchored to panel-anchored and land in the wrong place.
90
+
91
+ So: **`position: fixed` plus one measurement of the panel and list boxes**, taken when a row
92
+ opens and again on resize (and on a `ResizeObserver` of the column, because collapsing the host
93
+ sidebar changes the panel's width without a window resize). The measured values land in
94
+ `--gt-detail-top/right/bottom`; the CSS fallbacks assume the centred 1040px column, which is what
95
+ the static previews render.
96
+
97
+ ### Changing this safely
98
+
99
+ - Keep **one** detail implementation: if a change needs different markup for wide screens, it is
100
+ the wrong change.
101
+ - Keep the breakpoint on the **panel** width (960px). A viewport media query was already shipped
102
+ once and put a 360px card over a 416px list at 1100.
103
+ - Never let the card cover the list: it is `.gt-groups` padding that reserves the room, and the
104
+ two numbers (360 padding / 348 card + 12 gap) have to move together.
105
+ - Verify in the real GUI, not the preview, at **1440 / 1280 / 1100**: the card must be ≈348px wide,
106
+ the list must end before it, and the open row must stay visible next to it. The preview cannot
107
+ catch this class of bug — it is server-rendered, so no measurement effect ever runs there.
72
108
  ## A shared file changes without asking
73
109
 
74
110
  The list is deliberately portable — a notes repo, a synced folder, a NAS — so another machine
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 = 'ui44'
53
+ const UI_REV = 'ui45'
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
 
@@ -107,6 +107,12 @@ window.__ModuleLoader__.load({
107
107
  'settings.saved': '设置已保存',
108
108
  'settings.about': '关于',
109
109
  'action.keys': '快捷键',
110
+ 'review.confirm': '确认完成',
111
+ 'review.reject': '退回清单',
112
+ 'review.badge': '待确认',
113
+ 'review.hint': 'Agent 标记完成,等你确认;确认后才算真的完成。',
114
+ 'review.explain': '以下由 Agent 标记完成,确认后才算真的完成。',
115
+ 'filter.review': '待确认',
110
116
  'keys.search': '聚焦搜索',
111
117
  'keys.new': '开始记一条',
112
118
  'keys.save': '保存这条(在标题里)',
@@ -373,6 +379,12 @@ window.__ModuleLoader__.load({
373
379
  'settings.saved': 'Settings saved',
374
380
  'settings.about': 'About',
375
381
  'action.keys': 'Keyboard shortcuts',
382
+ 'review.confirm': 'Confirm done',
383
+ 'review.reject': 'Send back',
384
+ 'review.badge': 'Needs review',
385
+ 'review.hint': 'The agent says this is done; confirm it or send it back.',
386
+ 'review.explain': 'Finished by the agent. Confirm before it counts as done.',
387
+ 'filter.review': 'Review',
376
388
  'keys.search': 'Focus the search box',
377
389
  'keys.new': 'Start a new todo',
378
390
  'keys.save': 'Save it (from the title field)',
@@ -1210,6 +1222,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1210
1222
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1211
1223
  .gt-truncated { color: var(--dsw-alias-state-warn-primary); font-size: 12px; }
1212
1224
  .gt-bulk-tag { flex: 0 1 120px; min-width: 90px; }
1225
+ .gt-review { padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 600;
1226
+ color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary); }
1213
1227
  .gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 600;
1214
1228
  color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
1215
1229
  vertical-align: 1px; }
@@ -1674,6 +1688,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1674
1688
  if (todo.completion !== null && todo.completion !== undefined) {
1675
1689
  meta.push(h('span', { key: 'completed', title: todo.completion.summary }, t('meta.completed', { when: absoluteTime(todo.completion.at) })))
1676
1690
  }
1691
+ if (todo.review?.pending === true) {
1692
+ meta.push(h('span', { key: 'review', className: 'gt-review', title: t('review.hint') }, t('review.badge')))
1693
+ }
1677
1694
  meta.push(h('span', { key: 'by' }, t(`meta.by.${todo.createdBy === 'agent' ? 'agent' : 'human'}`)))
1678
1695
  if (todo.sessionId !== '') {
1679
1696
  const stranded = todo.sessionAlive === true && (todo.sessionDir ?? '') === ''
@@ -1884,6 +1901,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1884
1901
  h(Menu, {
1885
1902
  label: t('action.menu'),
1886
1903
  items: [
1904
+ ...(todo.review?.pending === true
1905
+ ? [
1906
+ { key: 'confirmReview', label: t('review.confirm'), icon: 'check', onSelect: () => onAction('review-confirm', todo) },
1907
+ { key: 'rejectReview', label: t('review.reject'), icon: 'refresh', danger: true, onSelect: () => onAction('review-reject', todo) },
1908
+ ]
1909
+ : []),
1887
1910
  ...(todo.tags.some((tag) => tag.toLowerCase() === 'inbox')
1888
1911
  ? [
1889
1912
  { key: 'confirm', label: t('action.confirm'), icon: 'check', onSelect: () => onAction('confirm', todo) },
@@ -2488,8 +2511,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2488
2511
  const reload = useCallback(
2489
2512
  async (next = {}) => {
2490
2513
  const selected = next.filter ?? filter
2491
- // The inbox is a tag view over active records, not a status of its own.
2492
- const status = selected === 'inbox' ? 'active' : selected
2514
+ // Two views sit on top of a status instead of being one: the inbox is a tag over
2515
+ // active records, and review is "done, but a person has not agreed yet".
2516
+ const status = selected === 'inbox' ? 'active' : selected === 'review' ? 'done' : selected
2493
2517
  const search = next.query ?? query
2494
2518
  try {
2495
2519
  const data = await call('list', {
@@ -2502,7 +2526,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2502
2526
  if (!mounted.current) return
2503
2527
  setState((current) => ({
2504
2528
  ...current,
2505
- todos: data.todos,
2529
+ // The review view is the done list narrowed to records whose agent
2530
+ // completion nobody has answered yet.
2531
+ todos:
2532
+ selected === 'review'
2533
+ ? data.todos.filter((todo) => todo.review?.pending === true)
2534
+ : data.todos,
2506
2535
  counts: data.counts,
2507
2536
  matched: Number.isInteger(data.matched) ? data.matched : data.todos.length,
2508
2537
  truncated: data.truncated === true,
@@ -3144,7 +3173,15 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3144
3173
  }
3145
3174
 
3146
3175
  const onAction = (kind, todo, payload) => {
3147
- if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done' } }))
3176
+ if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done', by: 'human' } }))
3177
+ // Answering an agent's completion. Confirming keeps it done; rejecting sends it back
3178
+ // to the list, because "not actually done" is the only honest reading.
3179
+ if (kind === 'review-confirm') {
3180
+ return run(todo.id, () => call('review', { method: 'POST', body: { id: todo.id, decision: 'confirm' } }))
3181
+ }
3182
+ if (kind === 'review-reject') {
3183
+ return run(todo.id, () => call('review', { method: 'POST', body: { id: todo.id, decision: 'reject' } }))
3184
+ }
3148
3185
  if (kind === 'reopen') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
3149
3186
  if (kind === 'doing') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'doing' } }))
3150
3187
  // Confirming keeps the record on the list; discarding archives it. Both only remove
@@ -3766,6 +3803,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3766
3803
  // day one is furniture, not a control.
3767
3804
  ...(counts.archived > 0 ? [['archived', counts.archived]] : []),
3768
3805
  ...(counts.inbox > 0 ? [['inbox', counts.inbox]] : []),
3806
+ ...(counts.review > 0 ? [['review', counts.review]] : []),
3769
3807
  ].map(([key, count]) => filterChip(key, key === 'all' ? undefined : count)),
3770
3808
  ),
3771
3809
  h('span', { className: 'gt-divider' }),
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.6.8'
39
+ export const VERSION = '0.7.1'
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/model.js CHANGED
@@ -217,6 +217,9 @@ export function createTodo(input, options = {}) {
217
217
  // beside the status rather than inside it, because "done" alone says
218
218
  // nothing about what was done.
219
219
  completion: null,
220
+ // Set when an agent finishes something: the person who owns the list confirms it or
221
+ // sends it back. An agent's "done" is a claim, not a decision.
222
+ review: null,
220
223
  // An append-only trail of what happened while the todo was open. Unlike the
221
224
  // description (the user's own text) this is where an agent reports progress or
222
225
  // says it is blocked, so reporting never has to rewrite someone's words.
@@ -312,6 +315,7 @@ export function normalizeStored(raw, options = {}) {
312
315
  originSessionId: typeof raw.originSessionId === 'string' ? raw.originSessionId : '',
313
316
  lastDispatch: raw.lastDispatch !== null && typeof raw.lastDispatch === 'object' ? raw.lastDispatch : null,
314
317
  completion: normalizeCompletion(raw.completion),
318
+ review: normalizeReview(raw.review),
315
319
  notes: normalizeNotes(raw.notes),
316
320
  activity: Array.isArray(raw.activity)
317
321
  ? raw.activity
@@ -328,6 +332,64 @@ export function normalizeStored(raw, options = {}) {
328
332
  return todo
329
333
  }
330
334
 
335
+ /**
336
+ * A stored review mark, repaired rather than rejected.
337
+ *
338
+ * Only one shape exists today — an agent finished this and the owner has not answered —
339
+ * so anything else is dropped instead of guessed at.
340
+ *
341
+ * @param {unknown} raw
342
+ * @returns {{ pending: true, at: number, by: string } | null}
343
+ */
344
+ function normalizeReview(raw) {
345
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
346
+ if (raw.pending !== true) return null
347
+ return {
348
+ pending: true,
349
+ at: Number.isFinite(raw.at) ? Number(raw.at) : Date.now(),
350
+ by: normalizeActor(raw.by),
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Answer the question an agent left behind.
356
+ *
357
+ * Confirm keeps the todo finished and drops the mark; reject puts it back on the list,
358
+ * because "not actually done" is the only honest reading of a rejected claim.
359
+ *
360
+ * @param {object} todo
361
+ * @param {'confirm' | 'reject'} decision
362
+ * @param {{ now?: number, by?: 'human' | 'agent', reason?: string }} [options]
363
+ * @returns {boolean} whether the record changed
364
+ */
365
+ export function reviewTodo(todo, decision, options = {}) {
366
+ const now = options.now ?? Date.now()
367
+ const by = normalizeActor(options.by ?? 'human')
368
+ const next = normalizeEnum(decision, ['confirm', 'reject'], 'invalid-decision', '决定', 'confirm')
369
+ if (todo.review === null || todo.review === undefined) return false
370
+ todo.review = null
371
+ todo.updatedAt = now
372
+ todo.updatedBy = by
373
+ const reason = typeof options.reason === 'string' ? options.reason.trim().slice(0, 120) : ''
374
+ if (next === 'reject') {
375
+ todo.status = 'open'
376
+ todo.doneAt = null
377
+ todo.completion = null
378
+ pushActivity(
379
+ todo,
380
+ { kind: 'reopened', by, at: now, summary: reason === '' ? '确认未完成,退回清单' : reason },
381
+ options.activityLimit ?? LIMITS.activity,
382
+ )
383
+ return true
384
+ }
385
+ pushActivity(
386
+ todo,
387
+ { kind: 'reviewed', by, at: now, summary: reason === '' ? '确认完成' : reason },
388
+ options.activityLimit ?? LIMITS.activity,
389
+ )
390
+ return true
391
+ }
392
+
331
393
  /**
332
394
  * A stored completion record, repaired rather than rejected.
333
395
  *
@@ -428,6 +490,7 @@ export function completeTodo(todo, evidence = {}, options = {}) {
428
490
  todo.doneAt = now
429
491
  todo.updatedAt = now
430
492
  todo.updatedBy = by
493
+ todo.review = by === 'agent' ? { pending: true, at: now, by } : null
431
494
  todo.completion = {
432
495
  at: now,
433
496
  by,
@@ -546,6 +609,9 @@ export function setStatus(todo, status, options = {}) {
546
609
  // A completion record describes a finished job, so reopening clears it: a
547
610
  // record that still claims the work is done is worse than no record.
548
611
  if (next !== 'done') todo.completion = null
612
+ // Who finished it decides whether a person still has to agree: an agent's "done" is a
613
+ // claim, the owner's is a decision. Leaving done drops the mark with the record.
614
+ todo.review = next === 'done' && by === 'agent' ? { pending: true, at: now, by } : null
549
615
  todo.updatedAt = now
550
616
  todo.updatedBy = by
551
617
  const kind = next === 'done' ? 'done' : next === 'open' ? 'reopened' : 'status'
@@ -666,7 +732,19 @@ export function isOverdue(todo, now = Date.now()) {
666
732
  * @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, archived: number, overdue: number }}
667
733
  */
668
734
  export function counts(todos, now = Date.now()) {
669
- const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 0 }
735
+ const out = {
736
+ total: todos.length,
737
+ open: 0,
738
+ doing: 0,
739
+ done: 0,
740
+ cancelled: 0,
741
+ archived: 0,
742
+ overdue: 0,
743
+ inbox: 0,
744
+ // Finished by an agent and not yet confirmed by a person. Counted separately from
745
+ // done because "the agent says so" is not the same fact as "a person agreed".
746
+ review: 0,
747
+ }
670
748
  for (const todo of todos) {
671
749
  if (todo.status in out) out[todo.status] += 1
672
750
  if (isOverdue(todo, now)) out.overdue += 1
@@ -675,6 +753,7 @@ export function counts(todos, now = Date.now()) {
675
753
  if ((todo.status === 'open' || todo.status === 'doing') && (todo.tags ?? []).some((tag) => tag.toLowerCase() === 'inbox')) {
676
754
  out.inbox += 1
677
755
  }
756
+ if (todo.status === 'done' && todo.review?.pending === true) out.review += 1
678
757
  }
679
758
  return out
680
759
  }
package/lib/routes.js CHANGED
@@ -578,6 +578,12 @@ export function createRoutes(deps) {
578
578
  return { todo: await service.setStatus(body.id, body.status ?? 'done', { by }) }
579
579
  }),
580
580
 
581
+ // Answering the mark an agent's completion leaves behind: confirm, or send it back.
582
+ exact('POST', `${ROUTE_BASE}/review`, async (request) => {
583
+ const body = await readJsonBody(request)
584
+ return { todo: await service.review(body.id, body.decision ?? 'confirm', { by: 'human', reason: body.reason }) }
585
+ }),
586
+
581
587
  exact('POST', `${ROUTE_BASE}/remove`, async (request) => {
582
588
  const body = await readJsonBody(request)
583
589
  return { todo: await service.remove(body.id, { by: 'human' }) }
package/lib/service.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  pushActivity,
32
32
  recordDispatch as recordDispatchOn,
33
33
  removeLink as removeLinkFrom,
34
+ reviewTodo,
34
35
  setStatus as setStatusOn,
35
36
  } from './model.js'
36
37
 
@@ -318,6 +319,33 @@ export function createService(options) {
318
319
  })
319
320
  }
320
321
 
322
+ /**
323
+ * Answer the review mark an agent's completion left behind.
324
+ *
325
+ * Kept separate from {@link setStatus} because it is a different decision: the record is
326
+ * already done, and the owner is either agreeing or sending it back — not changing a
327
+ * status that nobody asked about. A record without a pending mark is refused, so the
328
+ * panel cannot confirm something twice.
329
+ *
330
+ * @param {string} id
331
+ * @param {'confirm' | 'reject'} decision
332
+ * @param {{ by?: 'human' | 'agent', reason?: string }} [context]
333
+ * @returns {Promise<object>} the todo after the decision
334
+ */
335
+ async function review(id, decision, context = {}) {
336
+ return store.update((doc) => {
337
+ const todo = requireTodo(doc, id)
338
+ if (todo.review?.pending !== true) {
339
+ throw new TodoError('invalid-status', '这条待办没有等你确认的完成标记')
340
+ }
341
+ const changed = reviewTodo(todo, decision, {
342
+ ...modelOptions(context.by ?? 'human'),
343
+ reason: context.reason,
344
+ })
345
+ return changed ? { next: doc.todos, result: todo } : { result: todo }
346
+ })
347
+ }
348
+
321
349
  /**
322
350
  * Put a wrongly-finished todo back on the list.
323
351
  *
@@ -763,6 +791,7 @@ export function createService(options) {
763
791
  create,
764
792
  update,
765
793
  setStatus,
794
+ review,
766
795
  reopen,
767
796
  complete,
768
797
  note,
package/lib/tools.js CHANGED
@@ -162,6 +162,9 @@ export function presentTodo(todo, now = Date.now()) {
162
162
  originDir: todo.originDir ?? '',
163
163
  originSessionId: todo.originSessionId ?? '',
164
164
  notes: (todo.notes ?? []).map((entry) => ({ at: entry.at, by: entry.by, text: entry.text })),
165
+ // Present so an agent can see it left a claim waiting: a record an agent finished is
166
+ // not settled until a person confirms it, and saying so beats assuming it landed.
167
+ review: todo.review?.pending === true ? { pending: true, at: todo.review.at, by: todo.review.by } : null,
165
168
  completion:
166
169
  todo.completion === null || todo.completion === undefined
167
170
  ? null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.6.8",
3
+ "version": "0.7.1",
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",