dsh-checklists 0.5.1 → 0.7.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/README.md +64 -1
- package/client/index.js +240 -4
- package/lib/index.js +72 -18
- package/lib/route.js +44 -0
- package/lib/store.js +16 -0
- package/lib/tools.js +64 -0
- package/lib/types/index.d.ts +16 -0
- package/lib/types/route.d.ts +14 -0
- package/lib/types/store.d.ts +7 -0
- package/lib/types/tools.d.ts +4 -0
- package/lib/types/workspace.d.ts +53 -0
- package/lib/workspace.js +134 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ If the agent forgets to mark something, you say so in chat and it fixes it.
|
|
|
30
30
|
| `checklist_delete` | Deletes a list (by id or exact title) |
|
|
31
31
|
| `checklist_remove_item` | Removes one item and everything nested under it |
|
|
32
32
|
| `checklist_rename` | Renames a list; titles stay unique within the session |
|
|
33
|
+
| `checklist_workspace` | Lists checklists from other sessions in the same workspace (read-only) |
|
|
33
34
|
| `checklist_template_save` | Saves a list as a reusable template for future sessions |
|
|
34
35
|
|
|
35
36
|
Items are plain strings. **Two leading spaces per level** nest them:
|
|
@@ -50,7 +51,7 @@ From a local build or tarball:
|
|
|
50
51
|
npm install
|
|
51
52
|
npm run build
|
|
52
53
|
npm pack
|
|
53
|
-
dsh plugin --profile dev add ./dsh-checklists-0.
|
|
54
|
+
dsh plugin --profile dev add ./dsh-checklists-0.7.0.tgz
|
|
54
55
|
dsh --profile dev --dump-config # the tool-checklist row should appear
|
|
55
56
|
```
|
|
56
57
|
|
|
@@ -68,6 +69,9 @@ Override the defaults on the plugin's entry in the profile's `cordis.patch.yml`:
|
|
|
68
69
|
maxDigestLines: 12 # lines of the injected digest
|
|
69
70
|
maxItemsPerList: 200 # per-list item cap
|
|
70
71
|
pruneDays: 14 # prune untouched sessions after N days (0 = never)
|
|
72
|
+
nudge: steer # steer | observe | off — see "Turn-end gate"
|
|
73
|
+
workspaceView: on-demand # off | on-demand | always — see "Workspace lens"
|
|
74
|
+
workspaceViewLimit: 10 # sibling checklists returned
|
|
71
75
|
# storageRoot: "D:/data/dsh-checklists"
|
|
72
76
|
# templates:
|
|
73
77
|
# my-flow: ["Step 1", " Sub-step", "Step 2"]
|
|
@@ -113,8 +117,67 @@ the agent's edit.
|
|
|
113
117
|
|---|---|---|
|
|
114
118
|
| `/dsh-checklist/state` | GET | One session's state (read-only, `no-store`) |
|
|
115
119
|
| `/dsh-checklist/item` | POST | Edit an item's text, with a conflict guard |
|
|
120
|
+
| `/dsh-checklist/nudge` | GET | Recent turn-end gate decisions |
|
|
121
|
+
| `/dsh-checklist/workspace` | GET | Checklists of sibling sessions in the same workspace |
|
|
116
122
|
| `/dsh-checklist/client-probe` | GET | Client load diagnostics |
|
|
117
123
|
|
|
124
|
+
## Turn-end gate
|
|
125
|
+
|
|
126
|
+
A checklist only helps while it is true. The gate closes the most common gap:
|
|
127
|
+
a turn does real work, forgets to mark it, and the list is stale by the time you
|
|
128
|
+
look at it.
|
|
129
|
+
|
|
130
|
+
When a turn is about to close, the plugin checks exactly three things:
|
|
131
|
+
|
|
132
|
+
1. Did the turn run a tool that changes the workspace (`bash`, `edit`, `write`, …)?
|
|
133
|
+
2. Did it skip every checklist write?
|
|
134
|
+
3. Is anything still marked `doing`?
|
|
135
|
+
|
|
136
|
+
If all three hold, the agent gets one short steering message before the turn ends:
|
|
137
|
+
|
|
138
|
+
> Before you finish: N checklist item(s) are still marked "doing". If you
|
|
139
|
+
> completed any of them this turn, update them with checklist_check and say what
|
|
140
|
+
> proves it. If not, leave them as they are — do not mark work you did not do.
|
|
141
|
+
|
|
142
|
+
It never guesses **what** was completed — only that the bookkeeping was skipped.
|
|
143
|
+
At most one nudge per turn, and none when the turn was pure conversation or the
|
|
144
|
+
checklist already changed.
|
|
145
|
+
|
|
146
|
+
Inspect recent decisions at `GET /dsh-checklist/nudge`:
|
|
147
|
+
|
|
148
|
+
```jsonc
|
|
149
|
+
{ "mode": "steer", "entries": [
|
|
150
|
+
{ "source": "turn-stopping", "nudge": true, "steered": true,
|
|
151
|
+
"reason": "1 item(s) still doing after workspace changes", "doing": 1 }
|
|
152
|
+
] }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`nudge: observe` records without interrupting; `nudge: off` disables the gate.
|
|
156
|
+
|
|
157
|
+
## Workspace lens
|
|
158
|
+
|
|
159
|
+
Checklists stay per session. The lens is a **read-only** way to see the
|
|
160
|
+
checklists of other sessions in the same project — available on request, and
|
|
161
|
+
never part of the prompt.
|
|
162
|
+
|
|
163
|
+
| Mode | Behaviour |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `on-demand` (default) | The tool works; the sidebar section starts collapsed |
|
|
166
|
+
| `always` | The sidebar section starts expanded |
|
|
167
|
+
| `off` | No tool, no route |
|
|
168
|
+
|
|
169
|
+
Clicking an entry expands it into a read-only tree, fetched once from that
|
|
170
|
+
session's own state route. Nothing there is editable — the lens can look, not
|
|
171
|
+
touch.
|
|
172
|
+
|
|
173
|
+
Membership comes from disk: a session belongs to the workspace directory that
|
|
174
|
+
contains it under `$DSH_HOME/sessions/<workspace>/<sessionId>/`. The host's
|
|
175
|
+
`workspace.json` is only used for a friendly title, because it is not always
|
|
176
|
+
complete — on the machine this was written on it was missing a workspace.
|
|
177
|
+
|
|
178
|
+
Nothing here is writable. To change another session's checklist, open that
|
|
179
|
+
session: two writers on one list would give the list two truths.
|
|
180
|
+
|
|
118
181
|
## Language
|
|
119
182
|
|
|
120
183
|
The panel follows the harness locale: **Spanish** when `locale` starts with `es`,
|
package/client/index.js
CHANGED
|
@@ -28,6 +28,8 @@ window.__ModuleLoader__.load({
|
|
|
28
28
|
const COLOR = { done: '#3fb950', doing: '#d29922', blocked: '#f85149', todo: 'inherit' }
|
|
29
29
|
|
|
30
30
|
const MUTED = { opacity: 0.72, fontSize: 13, padding: '10px 12px', lineHeight: 1.5 }
|
|
31
|
+
/** Stable empty map: read-only trees never collapse, so one shared object is enough. */
|
|
32
|
+
const EMPTY_COLLAPSED = {}
|
|
31
33
|
const BUTTON = {
|
|
32
34
|
background: 'transparent',
|
|
33
35
|
border: 'none',
|
|
@@ -61,6 +63,9 @@ window.__ModuleLoader__.load({
|
|
|
61
63
|
hideDoneHint: 'Hide or show completed items',
|
|
62
64
|
copy: 'Copy',
|
|
63
65
|
copyHint: 'Copy the checklist as markdown',
|
|
66
|
+
workspace: 'Workspace',
|
|
67
|
+
workspaceEmpty: 'No other session in this workspace has a checklist.',
|
|
68
|
+
loadingShort: 'Loading…',
|
|
64
69
|
copied: '✓ Copied',
|
|
65
70
|
copyFailed: 'Could not copy',
|
|
66
71
|
noClipboard: 'Clipboard unavailable',
|
|
@@ -86,6 +91,9 @@ window.__ModuleLoader__.load({
|
|
|
86
91
|
hideDoneHint: 'Ocultar o mostrar los ítems hechos',
|
|
87
92
|
copy: 'Copiar',
|
|
88
93
|
copyHint: 'Copiar el checklist como markdown',
|
|
94
|
+
workspace: 'Proyecto',
|
|
95
|
+
workspaceEmpty: 'Ninguna otra sesión de este proyecto tiene checklist.',
|
|
96
|
+
loadingShort: 'Cargando…',
|
|
89
97
|
copied: '✓ Copiado',
|
|
90
98
|
copyFailed: 'No se pudo copiar',
|
|
91
99
|
noClipboard: 'Portapapeles no disponible',
|
|
@@ -293,6 +301,7 @@ window.__ModuleLoader__.load({
|
|
|
293
301
|
const nodeKey = `${props.listId}/${item.id}`
|
|
294
302
|
const isCollapsed = Boolean(props.collapsed[nodeKey])
|
|
295
303
|
const counts = countTree(children)
|
|
304
|
+
const readOnly = Boolean(props.readOnly)
|
|
296
305
|
|
|
297
306
|
const label = isEditing
|
|
298
307
|
? h('input', {
|
|
@@ -324,19 +333,19 @@ window.__ModuleLoader__.load({
|
|
|
324
333
|
: h(
|
|
325
334
|
'span',
|
|
326
335
|
{
|
|
327
|
-
onDoubleClick: () => props.onStartEdit(item),
|
|
328
|
-
title: props.t('editHint'),
|
|
336
|
+
onDoubleClick: readOnly ? undefined : () => props.onStartEdit(item),
|
|
337
|
+
title: readOnly ? undefined : props.t('editHint'),
|
|
329
338
|
style: {
|
|
330
339
|
textDecoration: done ? 'line-through' : 'none',
|
|
331
340
|
wordBreak: 'break-word',
|
|
332
|
-
cursor: 'text',
|
|
341
|
+
cursor: readOnly ? 'default' : 'text',
|
|
333
342
|
},
|
|
334
343
|
},
|
|
335
344
|
item.text,
|
|
336
345
|
)
|
|
337
346
|
|
|
338
347
|
const chevron =
|
|
339
|
-
children.length > 0
|
|
348
|
+
children.length > 0 && !readOnly
|
|
340
349
|
? h(
|
|
341
350
|
'button',
|
|
342
351
|
{
|
|
@@ -480,6 +489,172 @@ window.__ModuleLoader__.load({
|
|
|
480
489
|
return h('section', { style: { marginBottom: 12 } }, header, body)
|
|
481
490
|
}
|
|
482
491
|
|
|
492
|
+
/**
|
|
493
|
+
* The workspace lens, below the session's own lists.
|
|
494
|
+
*
|
|
495
|
+
* Collapsed by default and lazy: nothing is fetched until the section is
|
|
496
|
+
* opened, so a session that never looks costs nothing. A 403 from the host
|
|
497
|
+
* means the profile disabled the lens, and the section disappears.
|
|
498
|
+
*/
|
|
499
|
+
/**
|
|
500
|
+
* One sibling session's tree, read-only.
|
|
501
|
+
*
|
|
502
|
+
* No editing, no collapse state, no handlers: that list belongs to another
|
|
503
|
+
* session, and two writers would give it two truths.
|
|
504
|
+
*/
|
|
505
|
+
function ReadOnlyTree(props) {
|
|
506
|
+
const state = props.state
|
|
507
|
+
|
|
508
|
+
if (!state || state.phase === 'loading') {
|
|
509
|
+
return h('div', { style: { opacity: 0.6, fontSize: 11, paddingLeft: 18 } }, props.t('loadingShort'))
|
|
510
|
+
}
|
|
511
|
+
if (state.phase === 'error') {
|
|
512
|
+
return h('div', { style: { opacity: 0.7, fontSize: 11, paddingLeft: 18 } }, state.message)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const lists = (state.data && state.data.lists) || []
|
|
516
|
+
if (lists.length === 0) {
|
|
517
|
+
return h('div', { style: { opacity: 0.55, fontSize: 11, paddingLeft: 18 } }, props.t('workspaceEmpty'))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const noop = () => {}
|
|
521
|
+
// One list: the entry header already carries its title, so repeating it
|
|
522
|
+
// inside the tree would say the same thing twice.
|
|
523
|
+
const showListTitles = lists.length > 1
|
|
524
|
+
|
|
525
|
+
return h(
|
|
526
|
+
'div',
|
|
527
|
+
{ style: { paddingLeft: 18, paddingBottom: 4 } },
|
|
528
|
+
lists.map((list) =>
|
|
529
|
+
h(
|
|
530
|
+
'div',
|
|
531
|
+
{ key: list.id },
|
|
532
|
+
showListTitles
|
|
533
|
+
? h('div', { style: { fontSize: 11, opacity: 0.6, marginTop: 4 } }, list.title)
|
|
534
|
+
: null,
|
|
535
|
+
h(
|
|
536
|
+
'ul',
|
|
537
|
+
{ style: { margin: 0, padding: 0 } },
|
|
538
|
+
(list.items || []).map((item) =>
|
|
539
|
+
h(Item, {
|
|
540
|
+
key: item.id,
|
|
541
|
+
item,
|
|
542
|
+
depth: 0,
|
|
543
|
+
t: props.t,
|
|
544
|
+
readOnly: true,
|
|
545
|
+
listId: `${props.sessionId}/${list.id}`,
|
|
546
|
+
collapsed: EMPTY_COLLAPSED,
|
|
547
|
+
onToggleNode: noop,
|
|
548
|
+
editing: null,
|
|
549
|
+
onStartEdit: noop,
|
|
550
|
+
onDraft: noop,
|
|
551
|
+
onCommit: noop,
|
|
552
|
+
onCancel: noop,
|
|
553
|
+
}),
|
|
554
|
+
),
|
|
555
|
+
),
|
|
556
|
+
),
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function WorkspaceSection(props) {
|
|
562
|
+
if (!props || props.state.phase === 'disabled') return null
|
|
563
|
+
|
|
564
|
+
const open = props.open
|
|
565
|
+
const state = props.state
|
|
566
|
+
const entries = state.phase === 'ready' ? state.data.checklists || [] : []
|
|
567
|
+
|
|
568
|
+
const header = h(
|
|
569
|
+
'button',
|
|
570
|
+
{
|
|
571
|
+
onClick: props.onToggle,
|
|
572
|
+
title: open ? props.t('collapse') : props.t('expand'),
|
|
573
|
+
style: {
|
|
574
|
+
...BUTTON,
|
|
575
|
+
opacity: 0.75,
|
|
576
|
+
fontSize: 12,
|
|
577
|
+
display: 'flex',
|
|
578
|
+
alignItems: 'baseline',
|
|
579
|
+
gap: 5,
|
|
580
|
+
width: '100%',
|
|
581
|
+
textAlign: 'left',
|
|
582
|
+
padding: '4px 0',
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
h('span', { style: { fontSize: 10, opacity: 0.55 } }, open ? '▾' : '▸'),
|
|
586
|
+
h('span', null, props.t('workspace')),
|
|
587
|
+
state.phase === 'ready'
|
|
588
|
+
? h('span', { style: { opacity: 0.55 } }, String(entries.length))
|
|
589
|
+
: null,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
let body = null
|
|
593
|
+
if (open) {
|
|
594
|
+
if (state.phase === 'loading') {
|
|
595
|
+
body = h('div', { style: { opacity: 0.6, fontSize: 12 } }, props.t('loadingShort'))
|
|
596
|
+
} else if (state.phase === 'error') {
|
|
597
|
+
body = h('div', { style: { opacity: 0.7, fontSize: 12 } }, state.message)
|
|
598
|
+
} else if (state.phase === 'ready') {
|
|
599
|
+
body = entries.length === 0
|
|
600
|
+
? h('div', { style: { opacity: 0.55, fontSize: 12 } }, props.t('workspaceEmpty'))
|
|
601
|
+
: h(
|
|
602
|
+
'ul',
|
|
603
|
+
{ style: { margin: 0, padding: 0 } },
|
|
604
|
+
entries.map((entry) => {
|
|
605
|
+
const treeOpen = Boolean(props.openTrees && props.openTrees[entry.sessionId])
|
|
606
|
+
return h(
|
|
607
|
+
'li',
|
|
608
|
+
{ key: entry.sessionId, style: { listStyle: 'none', margin: 0, padding: 0 } },
|
|
609
|
+
h(
|
|
610
|
+
'button',
|
|
611
|
+
{
|
|
612
|
+
onClick: () => props.onToggleTree(entry.sessionId),
|
|
613
|
+
title: treeOpen ? props.t('collapse') : props.t('expand'),
|
|
614
|
+
style: {
|
|
615
|
+
...BUTTON,
|
|
616
|
+
opacity: 0.8,
|
|
617
|
+
fontSize: 12,
|
|
618
|
+
display: 'flex',
|
|
619
|
+
gap: 6,
|
|
620
|
+
width: '100%',
|
|
621
|
+
textAlign: 'left',
|
|
622
|
+
padding: '1px 0',
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
h('span', { style: { fontSize: 9, opacity: 0.55 } }, treeOpen ? '▾' : '▸'),
|
|
626
|
+
h(
|
|
627
|
+
'span',
|
|
628
|
+
{ style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
629
|
+
entry.title,
|
|
630
|
+
),
|
|
631
|
+
h(
|
|
632
|
+
'span',
|
|
633
|
+
{ style: { opacity: 0.6, fontVariantNumeric: 'tabular-nums' } },
|
|
634
|
+
`${entry.done}/${entry.total}`,
|
|
635
|
+
),
|
|
636
|
+
),
|
|
637
|
+
treeOpen
|
|
638
|
+
? h(ReadOnlyTree, {
|
|
639
|
+
state: props.trees ? props.trees[entry.sessionId] : undefined,
|
|
640
|
+
t: props.t,
|
|
641
|
+
sessionId: entry.sessionId,
|
|
642
|
+
})
|
|
643
|
+
: null,
|
|
644
|
+
)
|
|
645
|
+
}),
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return h(
|
|
651
|
+
'section',
|
|
652
|
+
{ style: { marginTop: 10, borderTop: '1px solid rgba(127,127,127,0.2)', paddingTop: 6 } },
|
|
653
|
+
header,
|
|
654
|
+
body,
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
|
|
483
658
|
function ChecklistBody(props) {
|
|
484
659
|
const sessionId = props && props.sessionId
|
|
485
660
|
const snapshot = useChecklistState(sessionId)
|
|
@@ -525,6 +700,49 @@ window.__ModuleLoader__.load({
|
|
|
525
700
|
return () => clearTimeout(timer)
|
|
526
701
|
}, [notice])
|
|
527
702
|
|
|
703
|
+
const [workspaceOpen, setWorkspaceOpen] = React.useState(false)
|
|
704
|
+
const [workspace, setWorkspace] = React.useState({ phase: 'idle' })
|
|
705
|
+
const [openTrees, setOpenTrees] = React.useState({})
|
|
706
|
+
const [trees, setTrees] = React.useState({})
|
|
707
|
+
|
|
708
|
+
// Lazy on purpose: fetched the first time the section is opened, never on
|
|
709
|
+
// load, so the panel costs nothing until someone asks for the lens.
|
|
710
|
+
const toggleWorkspace = () => {
|
|
711
|
+
const next = !workspaceOpen
|
|
712
|
+
setWorkspaceOpen(next)
|
|
713
|
+
if (!next || workspace.phase !== 'idle' || !sessionId) return
|
|
714
|
+
|
|
715
|
+
setWorkspace({ phase: 'loading' })
|
|
716
|
+
fetch(`/dsh-checklist/workspace?session=${encodeURIComponent(sessionId)}`, { cache: 'no-store' })
|
|
717
|
+
.then((response) => {
|
|
718
|
+
if (response.status === 403) return { disabled: true }
|
|
719
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
720
|
+
return response.json()
|
|
721
|
+
})
|
|
722
|
+
.then((data) => setWorkspace(data && data.disabled ? { phase: 'disabled' } : { phase: 'ready', data }))
|
|
723
|
+
.catch((error) => setWorkspace({ phase: 'error', message: String((error && error.message) || error) }))
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Expanding one entry fetches that session's tree once. Nothing to
|
|
727
|
+
// invalidate: the lens never writes, so a cached tree stays true.
|
|
728
|
+
const toggleTree = (targetSessionId) => {
|
|
729
|
+
const next = !openTrees[targetSessionId]
|
|
730
|
+
setOpenTrees((current) => ({ ...current, [targetSessionId]: next }))
|
|
731
|
+
if (!next || trees[targetSessionId]) return
|
|
732
|
+
|
|
733
|
+
setTrees((current) => ({ ...current, [targetSessionId]: { phase: 'loading' } }))
|
|
734
|
+
fetch(`/dsh-checklist/state?session=${encodeURIComponent(targetSessionId)}`, { cache: 'no-store' })
|
|
735
|
+
.then((response) => {
|
|
736
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
737
|
+
return response.json()
|
|
738
|
+
})
|
|
739
|
+
.then((data) => setTrees((current) => ({ ...current, [targetSessionId]: { phase: 'ready', data } })))
|
|
740
|
+
.catch((error) => setTrees((current) => ({
|
|
741
|
+
...current,
|
|
742
|
+
[targetSessionId]: { phase: 'error', message: String((error && error.message) || error) },
|
|
743
|
+
})))
|
|
744
|
+
}
|
|
745
|
+
|
|
528
746
|
const startEdit = (item) => {
|
|
529
747
|
const next = { id: item.id, original: item.text, draft: item.text }
|
|
530
748
|
editingRef.current = next
|
|
@@ -594,6 +812,15 @@ window.__ModuleLoader__.load({
|
|
|
594
812
|
{ style: MUTED },
|
|
595
813
|
h('div', { style: { fontWeight: 600, marginBottom: 4 } }, t('emptyTitle')),
|
|
596
814
|
h('div', null, t('emptyHint')),
|
|
815
|
+
h(WorkspaceSection, {
|
|
816
|
+
t,
|
|
817
|
+
open: workspaceOpen,
|
|
818
|
+
state: workspace,
|
|
819
|
+
onToggle: toggleWorkspace,
|
|
820
|
+
trees,
|
|
821
|
+
openTrees,
|
|
822
|
+
onToggleTree: toggleTree,
|
|
823
|
+
}),
|
|
597
824
|
)
|
|
598
825
|
}
|
|
599
826
|
|
|
@@ -663,6 +890,15 @@ window.__ModuleLoader__.load({
|
|
|
663
890
|
onCancel: cancelEdit,
|
|
664
891
|
}),
|
|
665
892
|
),
|
|
893
|
+
h(WorkspaceSection, {
|
|
894
|
+
t,
|
|
895
|
+
open: workspaceOpen,
|
|
896
|
+
state: workspace,
|
|
897
|
+
onToggle: toggleWorkspace,
|
|
898
|
+
trees,
|
|
899
|
+
openTrees,
|
|
900
|
+
onToggleTree: toggleTree,
|
|
901
|
+
}),
|
|
666
902
|
)
|
|
667
903
|
}
|
|
668
904
|
|
package/lib/index.js
CHANGED
|
@@ -10,15 +10,36 @@
|
|
|
10
10
|
* The agent is the only writer. There is no client write path: the session
|
|
11
11
|
* document is the single source of truth.
|
|
12
12
|
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
13
14
|
import { renderDigest } from './digest.js';
|
|
14
15
|
import { decideNudge, isChecklistWrite, MUTATING_TOOLS, NUDGE_ROUTE } from './gate.js';
|
|
15
16
|
import { countState } from './model.js';
|
|
16
17
|
import { serveProbe, PROBE_ROUTE } from './probe.js';
|
|
17
|
-
import { serveItemUpdate, serveState, ITEM_ROUTE, STATE_ROUTE } from './route.js';
|
|
18
|
+
import { serveItemUpdate, serveState, serveWorkspace, ITEM_ROUTE, STATE_ROUTE, WORKSPACE_ROUTE } from './route.js';
|
|
18
19
|
import { ChecklistStore } from './store.js';
|
|
19
20
|
import { registerChecklistTools } from './tools.js';
|
|
20
21
|
export const name = 'dsh-checklist';
|
|
21
22
|
export const inject = ['tools'];
|
|
23
|
+
/**
|
|
24
|
+
* Build the steering message.
|
|
25
|
+
*
|
|
26
|
+
* Model-facing, so English. It states the observation, asks for evidence, and
|
|
27
|
+
* explicitly allows leaving an item untouched — a gate that demanded progress
|
|
28
|
+
* would push the model into marking work it never did.
|
|
29
|
+
*/
|
|
30
|
+
function buildSteerMessage(doing) {
|
|
31
|
+
return {
|
|
32
|
+
id: randomUUID(),
|
|
33
|
+
role: 'user',
|
|
34
|
+
content: [{
|
|
35
|
+
type: 'text',
|
|
36
|
+
text: `Before you finish: ${doing} checklist item(s) are still marked "doing". `
|
|
37
|
+
+ 'If you completed any of them this turn, update them with checklist_check and say what '
|
|
38
|
+
+ 'proves it. If not, leave them as they are — do not mark work you did not do.',
|
|
39
|
+
}],
|
|
40
|
+
source: { kind: 'user' },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
22
43
|
/**
|
|
23
44
|
* Resolve which session this prompt assembly belongs to.
|
|
24
45
|
*
|
|
@@ -41,16 +62,18 @@ export function apply(ctx, config = {}) {
|
|
|
41
62
|
registerChecklistTools(ctx, store, {
|
|
42
63
|
maxItemsPerList: config.maxItemsPerList,
|
|
43
64
|
templates: config.templates,
|
|
65
|
+
workspaceView: config.workspaceView,
|
|
66
|
+
workspaceViewLimit: config.workspaceViewLimit,
|
|
44
67
|
});
|
|
45
68
|
// Opportunistic housekeeping: never blocks startup, never fails the plugin.
|
|
46
69
|
if (config.pruneDays !== 0) {
|
|
47
70
|
void store.prune(config.pruneDays ?? 14).catch(() => undefined);
|
|
48
71
|
}
|
|
49
|
-
// --- Turn-end gate
|
|
50
|
-
// Watches one turn's tool activity and
|
|
51
|
-
// the checklist was left stale.
|
|
52
|
-
//
|
|
53
|
-
|
|
72
|
+
// --- Turn-end gate --------------------------------------------------------
|
|
73
|
+
// Watches one turn's tool activity and, as the turn closes, decides whether
|
|
74
|
+
// the checklist was left stale. In `steer` mode the agent gets a message
|
|
75
|
+
// before the turn is allowed to end; `observe` only records the decision.
|
|
76
|
+
const nudgeMode = config.nudge ?? 'steer';
|
|
54
77
|
const activity = new Map();
|
|
55
78
|
const gateLog = [];
|
|
56
79
|
const events = ctx;
|
|
@@ -69,7 +92,8 @@ export function apply(ctx, config = {}) {
|
|
|
69
92
|
current.checklistWrite = true;
|
|
70
93
|
activity.set(agentId, current);
|
|
71
94
|
}));
|
|
72
|
-
const evaluate = (
|
|
95
|
+
const evaluate = (agent, source) => {
|
|
96
|
+
const agentId = agent.id;
|
|
73
97
|
const current = activity.get(agentId) ?? { mutated: false, checklistWrite: false };
|
|
74
98
|
activity.set(agentId, { mutated: false, checklistWrite: false });
|
|
75
99
|
const doc = store.peek(agentId);
|
|
@@ -78,34 +102,55 @@ export function apply(ctx, config = {}) {
|
|
|
78
102
|
for (const list of doc.lists)
|
|
79
103
|
doing += countState(list.items, 'doing');
|
|
80
104
|
const decision = decideNudge(current, doing);
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
let steered = false;
|
|
106
|
+
let reason = decision.reason;
|
|
107
|
+
if (decision.nudge && nudgeMode === 'steer') {
|
|
108
|
+
try {
|
|
109
|
+
agent.steer?.(buildSteerMessage(doing));
|
|
110
|
+
steered = true;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
reason = `${reason}; steer failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Log every decision: an empty log must mean "the event never fired",
|
|
117
|
+
// never "the answer was no".
|
|
83
118
|
gateLog.push({
|
|
84
119
|
at: new Date().toISOString(),
|
|
85
120
|
sessionId: agentId,
|
|
86
121
|
source,
|
|
122
|
+
mode: nudgeMode,
|
|
87
123
|
nudge: decision.nudge,
|
|
88
|
-
|
|
124
|
+
steered,
|
|
125
|
+
reason,
|
|
89
126
|
doing,
|
|
90
127
|
});
|
|
91
128
|
if (gateLog.length > 20)
|
|
92
129
|
gateLog.shift();
|
|
93
130
|
};
|
|
94
131
|
ctx.effect(() => events.on('agent/turn-stopping', (payload) => {
|
|
132
|
+
if (nudgeMode === 'off')
|
|
133
|
+
return;
|
|
95
134
|
const event = payload;
|
|
96
135
|
if (typeof event.agent?.id === 'string')
|
|
97
|
-
evaluate(event.agent
|
|
136
|
+
evaluate(event.agent, 'turn-stopping');
|
|
98
137
|
}));
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// root listener.
|
|
138
|
+
// Fallback close signal. It evaluates only when `turn-stopping` left activity
|
|
139
|
+
// unconsumed, so it never duplicates a decision — but it still catches the
|
|
140
|
+
// turn if the primary event is ever missing.
|
|
103
141
|
ctx.effect(() => events.on('agent/status', (payload) => {
|
|
142
|
+
if (nudgeMode === 'off')
|
|
143
|
+
return;
|
|
104
144
|
const event = payload;
|
|
105
145
|
if (event.status !== 'idle')
|
|
106
146
|
return;
|
|
107
|
-
|
|
108
|
-
|
|
147
|
+
const agent = event.agent;
|
|
148
|
+
if (!agent || typeof agent.id !== 'string')
|
|
149
|
+
return;
|
|
150
|
+
const current = activity.get(agent.id);
|
|
151
|
+
if (!current || (!current.mutated && !current.checklistWrite))
|
|
152
|
+
return;
|
|
153
|
+
evaluate(agent, 'status');
|
|
109
154
|
}));
|
|
110
155
|
let loggedScope = false;
|
|
111
156
|
ctx.inject(['systemPrompt'], (promptCtx) => {
|
|
@@ -148,6 +193,15 @@ export function apply(ctx, config = {}) {
|
|
|
148
193
|
void serveItemUpdate(req, res, store);
|
|
149
194
|
},
|
|
150
195
|
}), 'dsh-checklist: item route');
|
|
196
|
+
// Workspace lens: other sessions' checklists. Read-only, and never part of
|
|
197
|
+
// the prompt unless the agent explicitly asks for it.
|
|
198
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
199
|
+
kind: 'exact',
|
|
200
|
+
path: WORKSPACE_ROUTE,
|
|
201
|
+
handler: (req, res) => {
|
|
202
|
+
void serveWorkspace(req, res, store, config.workspaceViewLimit ?? 10, (config.workspaceView ?? 'on-demand') !== 'off');
|
|
203
|
+
},
|
|
204
|
+
}), 'dsh-checklist: workspace route');
|
|
151
205
|
// Diagnostics channel for the browser half; see src/probe.ts.
|
|
152
206
|
webCtx.effect(() => webCtx.webServer.register({
|
|
153
207
|
kind: 'exact',
|
|
@@ -161,7 +215,7 @@ export function apply(ctx, config = {}) {
|
|
|
161
215
|
kind: 'exact',
|
|
162
216
|
path: NUDGE_ROUTE,
|
|
163
217
|
handler: (req, res) => {
|
|
164
|
-
const body = Buffer.from(`${JSON.stringify({ mode:
|
|
218
|
+
const body = Buffer.from(`${JSON.stringify({ mode: nudgeMode, entries: gateLog }, null, 2)}\n`, 'utf8');
|
|
165
219
|
res.writeHead(200, {
|
|
166
220
|
'content-type': 'application/json; charset=utf-8',
|
|
167
221
|
'content-length': String(body.byteLength),
|
package/lib/route.js
CHANGED
|
@@ -7,14 +7,58 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { findItemById } from './model.js';
|
|
9
9
|
import { toPublicState } from './state.js';
|
|
10
|
+
import { listWorkspaceChecklists } from './workspace.js';
|
|
10
11
|
export const STATE_ROUTE = '/dsh-checklist/state';
|
|
11
12
|
export const ITEM_ROUTE = '/dsh-checklist/item';
|
|
13
|
+
export const WORKSPACE_ROUTE = '/dsh-checklist/workspace';
|
|
12
14
|
/** Longest session id we will even look at; real ones are UUIDs. */
|
|
13
15
|
const MAX_SESSION_ID = 200;
|
|
14
16
|
/** Request body cap for the one write endpoint. */
|
|
15
17
|
const MAX_BODY_BYTES = 16 * 1024;
|
|
16
18
|
/** Item text cap, matching `checklist_edit`. */
|
|
17
19
|
const MAX_TEXT = 500;
|
|
20
|
+
/**
|
|
21
|
+
* Serve the workspace lens: checklists from other sessions in the same workspace.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately separate from the session state route: this data never enters the
|
|
24
|
+
* agent's context unless a tool explicitly asks for it.
|
|
25
|
+
*
|
|
26
|
+
* @param req - the incoming request; must be GET.
|
|
27
|
+
* @param res - the response to write.
|
|
28
|
+
* @param store - the checklist store.
|
|
29
|
+
* @param limit - maximum entries returned, newest first.
|
|
30
|
+
* @param enabled - false when the profile turned the lens off.
|
|
31
|
+
*/
|
|
32
|
+
export async function serveWorkspace(req, res, store, limit, enabled) {
|
|
33
|
+
if (req.method !== 'GET') {
|
|
34
|
+
res.writeHead(405, { allow: 'GET' });
|
|
35
|
+
res.end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!enabled) {
|
|
39
|
+
sendJson(res, 403, { error: 'workspace view is disabled in this profile' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
let session;
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
45
|
+
session = url.searchParams.get('session');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
sendJson(res, 400, { error: 'malformed request url' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!session || session.length === 0 || session.length > MAX_SESSION_ID) {
|
|
52
|
+
sendJson(res, 400, { error: 'the session query parameter is required' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
sendJson(res, 200, await listWorkspaceChecklists(store, session, limit));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
sendJson(res, 400, { error: 'invalid session id' });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
18
62
|
function sendJson(res, status, body) {
|
|
19
63
|
const payload = Buffer.from(`${JSON.stringify(body)}\n`, 'utf8');
|
|
20
64
|
res.writeHead(status, {
|
package/lib/store.js
CHANGED
|
@@ -38,6 +38,22 @@ export class ChecklistStore {
|
|
|
38
38
|
peek(sessionId) {
|
|
39
39
|
return this.cache.get(sessionId);
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Last modification time of one session's checklist file.
|
|
43
|
+
*
|
|
44
|
+
* @param sessionId - the session to inspect.
|
|
45
|
+
* @returns epoch milliseconds, or undefined when that session has no file.
|
|
46
|
+
*/
|
|
47
|
+
async modifiedAt(sessionId) {
|
|
48
|
+
try {
|
|
49
|
+
const info = await stat(this.fileFor(sessionId));
|
|
50
|
+
// Rounded: mtimeMs is a float, and the lens output declares it integer.
|
|
51
|
+
return Math.round(info.mtimeMs);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
41
57
|
/** Load one session (cache first, then disk). */
|
|
42
58
|
async prime(sessionId) {
|
|
43
59
|
const cached = this.cache.get(sessionId);
|
package/lib/tools.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
8
8
|
import { buildItems, checkRename, countItems, findList, flatten, ITEM_STATES, isItemState, mintId, nextItem, removeItemById, resolveRef, toLines, } from './model.js';
|
|
9
|
+
import { listWorkspaceChecklists } from './workspace.js';
|
|
9
10
|
/** Ships with the plugin so a first run has something to instantiate. */
|
|
10
11
|
const DEFAULT_TEMPLATES = {
|
|
11
12
|
release: [
|
|
@@ -614,6 +615,69 @@ export function registerChecklistTools(ctx, store, config = {}) {
|
|
|
614
615
|
});
|
|
615
616
|
},
|
|
616
617
|
})));
|
|
618
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
619
|
+
name: 'checklist_workspace',
|
|
620
|
+
description: 'List the checklists of OTHER sessions in the same workspace. Read-only; the current session is '
|
|
621
|
+
+ 'excluded, and these lists never enter the prompt on their own. Use it when the user asks what '
|
|
622
|
+
+ 'is pending in this project across sessions.',
|
|
623
|
+
parameters: {
|
|
624
|
+
limit: {
|
|
625
|
+
type: 'integer',
|
|
626
|
+
description: 'Maximum checklists to return, newest first (default 10).',
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
output: {
|
|
630
|
+
schema: {
|
|
631
|
+
type: 'object',
|
|
632
|
+
additionalProperties: false,
|
|
633
|
+
properties: {
|
|
634
|
+
workspaceTitle: { type: 'string' },
|
|
635
|
+
workspacePath: { type: 'string' },
|
|
636
|
+
checklists: {
|
|
637
|
+
type: 'array',
|
|
638
|
+
required: true,
|
|
639
|
+
items: {
|
|
640
|
+
type: 'object',
|
|
641
|
+
additionalProperties: false,
|
|
642
|
+
properties: {
|
|
643
|
+
sessionId: { type: 'string', required: true },
|
|
644
|
+
title: { type: 'string', required: true },
|
|
645
|
+
total: { type: 'integer', required: true },
|
|
646
|
+
done: { type: 'integer', required: true },
|
|
647
|
+
updatedAt: { type: 'integer', required: true },
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
render: (_args, value) => {
|
|
654
|
+
const view = value;
|
|
655
|
+
if (view.checklists.length === 0) {
|
|
656
|
+
return [{ type: 'text', text: 'No other session in this workspace has a checklist.' }];
|
|
657
|
+
}
|
|
658
|
+
const lines = view.checklists.map((entry) => `· ${entry.title} — ${entry.done}/${entry.total}`);
|
|
659
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
timeoutMs: 10_000,
|
|
663
|
+
async execute(args, exec) {
|
|
664
|
+
if ((config.workspaceView ?? 'on-demand') === 'off') {
|
|
665
|
+
throw new Error('checklist_workspace: the workspace lens is disabled in this profile');
|
|
666
|
+
}
|
|
667
|
+
const sessionId = sessionOf(exec);
|
|
668
|
+
const input = args;
|
|
669
|
+
const requested = typeof input.limit === 'number' && Number.isFinite(input.limit)
|
|
670
|
+
? Math.trunc(input.limit)
|
|
671
|
+
: undefined;
|
|
672
|
+
const limit = Math.min(Math.max(1, requested ?? config.workspaceViewLimit ?? 10), 50);
|
|
673
|
+
const view = await listWorkspaceChecklists(store, sessionId, limit);
|
|
674
|
+
return {
|
|
675
|
+
...(view.workspace?.title ? { workspaceTitle: view.workspace.title } : {}),
|
|
676
|
+
...(view.workspace?.path ? { workspacePath: view.workspace.path } : {}),
|
|
677
|
+
checklists: view.checklists,
|
|
678
|
+
};
|
|
679
|
+
},
|
|
680
|
+
})));
|
|
617
681
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
618
682
|
name: 'checklist_delete',
|
|
619
683
|
description: 'Delete one checklist from the CURRENT session. The list and its items are gone; there is no archive. '
|
package/lib/types/index.d.ts
CHANGED
|
@@ -24,5 +24,21 @@ export interface Config {
|
|
|
24
24
|
pruneDays?: number;
|
|
25
25
|
/** Extra templates merged over the built-in ones. */
|
|
26
26
|
templates?: Record<string, string[]>;
|
|
27
|
+
/**
|
|
28
|
+
* What the turn-end gate does when it finds a stale checklist.
|
|
29
|
+
* - `steer` (default): put a message in front of the agent before the turn ends.
|
|
30
|
+
* - `observe`: record the decision, never interrupt.
|
|
31
|
+
* - `off`: do not evaluate at all.
|
|
32
|
+
*/
|
|
33
|
+
nudge?: 'steer' | 'observe' | 'off';
|
|
34
|
+
/**
|
|
35
|
+
* Workspace lens: viewing checklists from other sessions of the same project.
|
|
36
|
+
* - `on-demand` (default): available, but nothing is shown unless asked.
|
|
37
|
+
* - `always`: the sidebar section starts expanded.
|
|
38
|
+
* - `off`: no route, no tool.
|
|
39
|
+
*/
|
|
40
|
+
workspaceView?: 'off' | 'on-demand' | 'always';
|
|
41
|
+
/** Maximum sibling checklists the lens returns (default 10). */
|
|
42
|
+
workspaceViewLimit?: number;
|
|
27
43
|
}
|
|
28
44
|
export declare function apply(ctx: Context, config?: Config): void;
|
package/lib/types/route.d.ts
CHANGED
|
@@ -9,6 +9,20 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
9
9
|
import type { ChecklistStore } from './store.js';
|
|
10
10
|
export declare const STATE_ROUTE = "/dsh-checklist/state";
|
|
11
11
|
export declare const ITEM_ROUTE = "/dsh-checklist/item";
|
|
12
|
+
export declare const WORKSPACE_ROUTE = "/dsh-checklist/workspace";
|
|
13
|
+
/**
|
|
14
|
+
* Serve the workspace lens: checklists from other sessions in the same workspace.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately separate from the session state route: this data never enters the
|
|
17
|
+
* agent's context unless a tool explicitly asks for it.
|
|
18
|
+
*
|
|
19
|
+
* @param req - the incoming request; must be GET.
|
|
20
|
+
* @param res - the response to write.
|
|
21
|
+
* @param store - the checklist store.
|
|
22
|
+
* @param limit - maximum entries returned, newest first.
|
|
23
|
+
* @param enabled - false when the profile turned the lens off.
|
|
24
|
+
*/
|
|
25
|
+
export declare function serveWorkspace(req: IncomingMessage, res: ServerResponse, store: ChecklistStore, limit: number, enabled: boolean): Promise<void>;
|
|
12
26
|
/**
|
|
13
27
|
* Serve one session's checklist state.
|
|
14
28
|
*
|
package/lib/types/store.d.ts
CHANGED
|
@@ -23,6 +23,13 @@ export declare class ChecklistStore {
|
|
|
23
23
|
constructor(root?: string);
|
|
24
24
|
/** The synchronous view used by the prompt digest; `undefined` until primed. */
|
|
25
25
|
peek(sessionId: string): SessionDoc | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Last modification time of one session's checklist file.
|
|
28
|
+
*
|
|
29
|
+
* @param sessionId - the session to inspect.
|
|
30
|
+
* @returns epoch milliseconds, or undefined when that session has no file.
|
|
31
|
+
*/
|
|
32
|
+
modifiedAt(sessionId: string): Promise<number | undefined>;
|
|
26
33
|
/** Load one session (cache first, then disk). */
|
|
27
34
|
prime(sessionId: string): Promise<SessionDoc>;
|
|
28
35
|
/**
|
package/lib/types/tools.d.ts
CHANGED
|
@@ -9,5 +9,9 @@ import type { ChecklistStore } from './store.js';
|
|
|
9
9
|
export interface ToolsConfig {
|
|
10
10
|
maxItemsPerList?: number;
|
|
11
11
|
templates?: Record<string, string[]>;
|
|
12
|
+
/** `off` removes the workspace lens entirely; the other modes expose the tool. */
|
|
13
|
+
workspaceView?: 'off' | 'on-demand' | 'always';
|
|
14
|
+
/** Maximum sibling checklists the lens returns. */
|
|
15
|
+
workspaceViewLimit?: number;
|
|
12
16
|
}
|
|
13
17
|
export declare function registerChecklistTools(ctx: Context, store: ChecklistStore, config?: ToolsConfig): void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace lens: a read-only view of checklists from sibling sessions.
|
|
3
|
+
*
|
|
4
|
+
* Two independent facts drive this module, and keeping them apart is what makes
|
|
5
|
+
* it survive host changes:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Membership comes from disk.** A session lives in
|
|
8
|
+
* `$DSH_HOME/sessions/<encoded-workspace>/<sessionId>/`. The host's
|
|
9
|
+
* `storages/workspace.json` was missing a workspace on the machine this was
|
|
10
|
+
* written on, so it is only consulted for a friendly title/path — never to
|
|
11
|
+
* decide which sessions belong together.
|
|
12
|
+
* 2. **Content comes from our own store.** Session logs are never decoded;
|
|
13
|
+
* titles and counts are already in the checklist document.
|
|
14
|
+
*/
|
|
15
|
+
import { type ChecklistStore } from './store.js';
|
|
16
|
+
export interface WorkspaceInfo {
|
|
17
|
+
/** Encoded directory name the host uses under `$DSH_HOME/sessions`. */
|
|
18
|
+
key: string;
|
|
19
|
+
/** Friendly workspace title, when the host metadata knows it. */
|
|
20
|
+
title?: string;
|
|
21
|
+
/** Absolute workspace path, when the host metadata knows it. */
|
|
22
|
+
path?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface WorkspaceListSummary {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
title: string;
|
|
27
|
+
total: number;
|
|
28
|
+
done: number;
|
|
29
|
+
/** Last write to that session's checklist file, in epoch milliseconds. */
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
}
|
|
32
|
+
export interface WorkspaceView {
|
|
33
|
+
workspace?: WorkspaceInfo;
|
|
34
|
+
checklists: WorkspaceListSummary[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Find the workspace directory that contains a session.
|
|
38
|
+
*
|
|
39
|
+
* @param sessionId - the session to locate.
|
|
40
|
+
* @returns the encoded workspace key, or undefined when the session has no dir.
|
|
41
|
+
*/
|
|
42
|
+
export declare function workspaceKeyOf(sessionId: string): Promise<string | undefined>;
|
|
43
|
+
/** Session ids that share one workspace directory. */
|
|
44
|
+
export declare function siblingSessionIds(key: string): Promise<string[]>;
|
|
45
|
+
/**
|
|
46
|
+
* List the checklists of every *other* session in the caller's workspace.
|
|
47
|
+
*
|
|
48
|
+
* @param store - the checklist store.
|
|
49
|
+
* @param sessionId - the calling session, excluded from the result.
|
|
50
|
+
* @param limit - maximum entries returned, newest first.
|
|
51
|
+
* @returns the workspace description plus one summary per sibling checklist.
|
|
52
|
+
*/
|
|
53
|
+
export declare function listWorkspaceChecklists(store: ChecklistStore, sessionId: string, limit: number): Promise<WorkspaceView>;
|
package/lib/workspace.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace lens: a read-only view of checklists from sibling sessions.
|
|
3
|
+
*
|
|
4
|
+
* Two independent facts drive this module, and keeping them apart is what makes
|
|
5
|
+
* it survive host changes:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Membership comes from disk.** A session lives in
|
|
8
|
+
* `$DSH_HOME/sessions/<encoded-workspace>/<sessionId>/`. The host's
|
|
9
|
+
* `storages/workspace.json` was missing a workspace on the machine this was
|
|
10
|
+
* written on, so it is only consulted for a friendly title/path — never to
|
|
11
|
+
* decide which sessions belong together.
|
|
12
|
+
* 2. **Content comes from our own store.** Session logs are never decoded;
|
|
13
|
+
* titles and counts are already in the checklist document.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { countItems } from './model.js';
|
|
18
|
+
import { resolveDshHome } from './store.js';
|
|
19
|
+
const SESSION_DIR = /^session-/;
|
|
20
|
+
function sessionsRoot() {
|
|
21
|
+
return join(resolveDshHome(), 'sessions');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Find the workspace directory that contains a session.
|
|
25
|
+
*
|
|
26
|
+
* @param sessionId - the session to locate.
|
|
27
|
+
* @returns the encoded workspace key, or undefined when the session has no dir.
|
|
28
|
+
*/
|
|
29
|
+
export async function workspaceKeyOf(sessionId) {
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = await readdir(sessionsRoot());
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (!entry.startsWith('--'))
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
const info = await stat(join(sessionsRoot(), entry, sessionId));
|
|
42
|
+
if (info.isDirectory())
|
|
43
|
+
return entry;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Not this workspace; keep looking.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/** Session ids that share one workspace directory. */
|
|
52
|
+
export async function siblingSessionIds(key) {
|
|
53
|
+
try {
|
|
54
|
+
const entries = await readdir(join(sessionsRoot(), key), { withFileTypes: true });
|
|
55
|
+
return entries
|
|
56
|
+
.filter((entry) => entry.isDirectory() && SESSION_DIR.test(entry.name))
|
|
57
|
+
.map((entry) => entry.name);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read the host's workspace table.
|
|
65
|
+
*
|
|
66
|
+
* Best effort: any parse problem yields no records, and the lens still works
|
|
67
|
+
* with the encoded directory name.
|
|
68
|
+
*/
|
|
69
|
+
async function hostWorkspaces() {
|
|
70
|
+
try {
|
|
71
|
+
const raw = await readFile(join(resolveDshHome(), 'storages', 'workspace.json'), 'utf8');
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
const table = parsed.tables?.workspaces;
|
|
74
|
+
if (table === null || typeof table !== 'object')
|
|
75
|
+
return [];
|
|
76
|
+
return Object.values(table)
|
|
77
|
+
.filter((value) => value !== null && typeof value === 'object');
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Describe a workspace by matching host records on session membership. */
|
|
84
|
+
async function describeWorkspace(key, siblings) {
|
|
85
|
+
const wanted = new Set(siblings);
|
|
86
|
+
const records = await hostWorkspaces();
|
|
87
|
+
const match = records.find((record) => {
|
|
88
|
+
const ids = record.sessionIds;
|
|
89
|
+
return Array.isArray(ids) && ids.some((id) => typeof id === 'string' && wanted.has(id));
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
key,
|
|
93
|
+
...(typeof match?.title === 'string' ? { title: match.title } : {}),
|
|
94
|
+
...(typeof match?.path === 'string' ? { path: match.path } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* List the checklists of every *other* session in the caller's workspace.
|
|
99
|
+
*
|
|
100
|
+
* @param store - the checklist store.
|
|
101
|
+
* @param sessionId - the calling session, excluded from the result.
|
|
102
|
+
* @param limit - maximum entries returned, newest first.
|
|
103
|
+
* @returns the workspace description plus one summary per sibling checklist.
|
|
104
|
+
*/
|
|
105
|
+
export async function listWorkspaceChecklists(store, sessionId, limit) {
|
|
106
|
+
const key = await workspaceKeyOf(sessionId);
|
|
107
|
+
if (!key)
|
|
108
|
+
return { checklists: [] };
|
|
109
|
+
const siblings = (await siblingSessionIds(key)).filter((id) => id !== sessionId);
|
|
110
|
+
const workspace = await describeWorkspace(key, siblings);
|
|
111
|
+
const checklists = [];
|
|
112
|
+
for (const id of siblings) {
|
|
113
|
+
const updatedAt = await store.modifiedAt(id);
|
|
114
|
+
if (updatedAt === undefined)
|
|
115
|
+
continue; // That session never used the plugin.
|
|
116
|
+
const doc = await store.prime(id);
|
|
117
|
+
const first = doc.lists[0];
|
|
118
|
+
if (!first)
|
|
119
|
+
continue;
|
|
120
|
+
const totals = doc.lists.reduce((acc, list) => {
|
|
121
|
+
const counts = countItems(list.items);
|
|
122
|
+
return { total: acc.total + counts.total, done: acc.done + counts.done };
|
|
123
|
+
}, { total: 0, done: 0 });
|
|
124
|
+
checklists.push({
|
|
125
|
+
sessionId: id,
|
|
126
|
+
title: doc.lists.length === 1 ? first.title : `${first.title} (+${doc.lists.length - 1})`,
|
|
127
|
+
total: totals.total,
|
|
128
|
+
done: totals.done,
|
|
129
|
+
updatedAt,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
checklists.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
133
|
+
return { workspace, checklists: checklists.slice(0, Math.max(1, limit)) };
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-checklists",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Session-scoped checklists for DeepSeek Harness: the agent tracks work as a tree, the model always sees a compact digest, and a read-only sidebar tab mirrors it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|