@dennisrongo/dsh-todo
v0.5.2
Published
Todo list for DeepSeek Harness (dsh) — a per-workspace task list persisted on disk by the host
Readme
@dennisrongo/dsh-todo
npm: @dennisrongo/dsh-todo ·
source: dennisrongo/dsh-plugins
A todo list for the DeepSeek Harness (dsh) web UI.
Registers into the additive conversation.view slot — the conversation view
ring — so it appears as its own tab beside Chat and Trajectory (order: 20,
after chat at 0 and trajectory at 10) and fills the session pane when
selected.
A sprint/roadmap task list, not just a checklist: each task carries a status, a priority, and optional release and sprint labels, so a real backlog can live next to the code it describes.
Features
- Persisted on disk by the host — one SQLite database per project at
<workspace>/.dsh/todo.db, not in the browser. It survives a restart, a cleared browser cache, and a different browser entirely, and it travels with the project. - Per-workspace — each workspace has its own list, keyed by workspace id.
- Safe against races — every write carries the revision it observed; a losing write is refused and the view adopts the authoritative list, so two open tabs can never silently clobber each other.
- Real workflow states —
backlog · todo · in-progress · blocked · done, changed from a pill on the row. A boolean cannot express the two things a standup actually asks about: what is moving, and what is stuck. - Priority —
P0–P3, shown as a chip. Only P0/P1 are coloured, so the list flags what is urgent instead of turning into a rainbow. - Release and sprint — two independent numeric labels: what ships
together (
1.5,0.5.1) and when it is worked (24). A task can be in both. A release takes up to three numbers, so a patch release has a label of its own; a sprint is a single decimal. Never alpha (v1.5is refused), so labels sort by version —1.10above1.9,0.5.1above0.5. The inputs accept only digits and a dot as you type; a value that is still not a valid label is flagged with an inline error and never saved. - Group by — None · Status · Release · Sprint · Priority, with collapsible
section headers carrying their own
done/totaland progress bar. Grouping by status gives you a kanban board without drag-and-drop. - Task detail modal — click a task's title to open the full dialog: a roomy description box plus status, priority, release, sprint and due date. Focus is trapped inside and returned to the row you came from, and text edits commit on close so a stray click never loses them. Done is the save: it is the only control that refuses to proceed while a release or sprint is invalid. Esc, the backdrop and the X always let you out, discarding an unsaved bad label rather than trapping you in the dialog.
- Expandable rows — the chevron still gives a quick in-row peek without leaving the list. Double-click a title to rename it inline. One scannable line collapsed is what lets a task carry nine fields without becoming a wall of text.
- Due dates — stored as calendar days, so "due the 14th" reads as the 14th in every timezone. Overdue tasks are flagged red on the row, due-today amber, and a finished task is never overdue.
- Own tab — full-pane view with a progress header and
done/totalscore, plus live in-progress and blocked counts. - Filter ring — All · Open · In Progress · Blocked · Backlog · Done · Archive, with live counts; empty states are hidden rather than shown at zero.
- Archive, not delete — check items off, then "Archive completed" files them away. Archived items leave every active view but stay in the record, and can be restored (↩) or permanently deleted from the Archive view.
- Full editing — add, check off, click-to-edit the title, edit the description and labels, reorder (▲/▼), archive, and delete. Release and sprint inputs suggest labels already in use, so values converge on a shared vocabulary without a releases table to administer.
- Suggest — a button in the tab header scans the workspace and proposes
concrete next tasks: unresolved
TODO/FIXME/HACKcomments, features the docs promise but the code does not implement, and modules with no tests. Each proposal comes with a one-line rationale, a priority, and afile:linepointer where one exists. Tick the ones you want and Add selected files them into the backlog. Never automatic — it runs only when you click it. - Themed — colors come only from the shell's
--dsw-*tokens, so it follows light/dark automatically. Respectsprefers-reduced-motion.
Architecture
This is a dual-face plugin. Both halves ship from one package.
| Half | File | Role |
| --- | --- | --- |
| Host | src/index.ts | TodoService, a TypertRemoteService that owns the per-workspace SQLite database and exports list / replace as @Remote methods. |
| Client | src/client.tsx | The React tab. Mounts the host contract and calls it as ctx.remote.dshTodo.*. |
| Bridge | src/remote.ts | The Typert Remote descriptor the client mounts. |
| Shared | src/types.ts | Dependency-free vocabulary used by both halves. |
The browser holds no authority over the data: it renders an optimistic echo and the committed host revision always wins.
Two build constraints that are easy to break
Both are asserted by the smoke test, because both fail silently at runtime:
- The host half must not be minified. The Typert gateway discovers a
@Remotemethod's wire fields by reading its parameter names out ofFunction.prototype.toString(). Minifyingrequesttoechanges the wire contract. - The host half must target
es2021.@Remoteis a TC39 standard decorator, and Node 22 cannot yet parse native decorator syntax. esbuild only downlevels decorators when the target predates them; ates2022+it emits them verbatim and the host half fails to load.
Peer dependencies, not dependencies
@deepseek-ai/cordis, dsh-typert-protocol, and dsh-storage-domain are
declared as peer dependencies and marked external in the host build. They
must resolve to the running dsh install's copies — a second cordis instance
would register into a different registry and the service would never appear.
dsh profiles set autoInstallPeers: false, so this resolves correctly.
Commands
pnpm install
pnpm run build # node build/build.mjs — emits lib/index.js + lib/client.js
pnpm run typecheck # tsc --noEmit
pnpm test # offline: client + CLI, exercising the BUILT lib/ output
pnpm run test:cli # just the in-process CLI suite
pnpm run test:integration # spawns the real bin — one full agent workflow
pnpm run test:icons # headless Chrome: icon sizes and the 40px row budget
pnpm run test:modal # headless Chrome: the dialog escapes the list's scroll container
pnpm run test:agent # OPT-IN: a real model drives the CLI (needs a key; costs tokens)pnpm test asserts against lib/, so build before testing (the script rebuilds first).
The CLI has two layers on purpose. test/cli.test.mjs calls into lib/cli.js in-process;
test/cli-integration.mjs spawns the real binary for one realistic agent workflow — plan a
release, inspect it, hit a refusal, recover from the payload alone, finish and archive — because
the in-process suite cannot see the shebang'd entry point, process.exitCode, the stdout/stderr
split, or argv as a shell delivers it.
pnpm run test:agent is the odd one out and is not part of pnpm test: it hands a real
model nothing but the binary path and a goal, then asserts on the resulting database rather than
on anything the model said. It answers a question no offline test can — is help enough to
drive this thing, and is a refusal message enough to recover from? It needs DEEPSEEK_API_KEY
exported (it runs the harness in a throwaway DSH_HOME, which has no stored credentials).
Install into a dsh profile
The profile must already compose the workspace registry, which @deepseek-ai/dsh-web-app
does by default.
cd ~/.dsh/profiles/<profile>
pnpm add "file:/absolute/path/to/dsh-plugins/plugins/dsh-todo"then add the row to cordis.patch.yml:
- insert:
- id: dsh-todo
name: '@dennisrongo/dsh-todo'and restart the profile. The single row mounts both halves: the host service and the browser tab.
The profile installs
file:dependencies as a copy, not a symlink, so afterpnpm run buildyou must re-runpnpm installin the profile to pick up the new artifacts.
Archiving
Completed work is archived, not deleted. An item carries an optional
archivedAt epoch-ms stamp; its presence is the archived state, so there is
one source of truth and no way to store an archived item without a date.
| Action | Where | Effect | | --- | --- | --- | | Archive completed | footer, any active view | Stamps every done item. Recoverable. | | Archive (⌸) | row hover, completed items | Stamps one item. Recoverable. | | Restore (↩) | row hover, Archive view | Clears the stamp, returning it to the list. | | Delete (✕) | row hover | Removes one item outright, after a confirmation dialog naming the task. | | Delete archived | footer, Archive view | Permanently drops every archived item — the only destructive bulk action, and the only one that asks for confirmation. |
Archived items are excluded from the progress bar and the done/total score, so tidying up never makes progress appear to regress. They sort newest-archived first, so the Archive view reads as a log. Reordering is computed in active-list space, so a hidden archived entry between two visible rows cannot swallow a move.
clearCompleted (hard delete of done items) is still exported for callers that
want it, but it is no longer wired to a button.
Suggest — what to work on next
The list holds work someone already thought of. Deciding what to do next usually happens somewhere else — reading the code and noticing what is missing. Suggest, in the tab header, moves that into the tab.
Clicking it reads the workspace and proposes concrete tasks from three kinds of evidence:
- Unresolved comments —
TODO,FIXMEandHACK, with the file and line. - Docs-vs-implementation gaps — the README and the file tree together, so what is promised but absent has somewhere to show up.
- Untested modules — source files with no matching test file. A name-based hint rather than a coverage run, so it is offered as a hint.
Each suggestion arrives as a checkbox row: a title, a one-line rationale, a
priority, and a file:line pointer where there is one to give. Nothing is
ticked by default — you opted into scanning, not into the results. Add
selected writes the ticked rows into the backlog as real tasks, with the
rationale as the description. Until then they are proposals: nothing is
stored, and closing the dialog discards them.
Refresh returns genuinely new ideas, not a reshuffle. Every title already shown joins the exclusion set, alongside every unfinished task already in the backlog, so the scan is told what not to repeat. Rows you have already ticked survive a refresh — the selection is yours, not the model's.
A scan spends tokens. It runs a real model session in the background — created, prompted, and archived when the scan finishes or you close the dialog; it never appears in the sidebar and is never navigated to. Nothing is scheduled and nothing is automatic: a scan happens when you click Suggest or Refresh, and only then.
What is sent is a bounded digest, not the repository: a capped file tree, a
capped list of comment matches one line each, and a leading slice of the README
and package.json. Vendored and generated directories (node_modules, lib,
dist, vendor, target and the rest) never enter it. Anything left out is
marked in the digest rather than dropped quietly, so a big repository yields a
smaller scan rather than a confident one about code it never read.
CLI — for you and for AI agents
The same list is reachable from a terminal, so an agent can shell out and manage your tasks (and so can you). It targets a workspace directory and needs no profile, no session and no running server — it works offline and in CI.
Getting the command
The plugin ships a dsh-todo binary. If the package is already installed into a dsh
profile, that profile's node_modules/.bin has it; otherwise reach it directly:
npx @dennisrongo/dsh-todo list # no install
pnpm add -g @dennisrongo/dsh-todo # then just: dsh-todo list
node /path/to/plugins/dsh-todo/lib/bin.js list # from a checkoutRequires Node 22+ — storage is node:sqlite, which is built in (and still prints an
experimental-feature warning on stderr; it is harmless, and 2>/dev/null silences it).
Everyday use
cd ~/projects/my-app # the workspace IS the current directory
dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
dsh-todo add "Write migration guide" --sprint 24
dsh-todo list[ ] tmtcfbutukp4j todo p0 Fix token refresh (release=1.5 due=2026-03-14)
[ ] tmtcfbuxr5y9x todo p2 Write migration guide (sprint=24)
[ ] tmtcfbv071e6w in-progress p2 Ship itOne task per line: checkbox, id, status, priority, title, then any labels in parentheses. That is the whole display contract — it stays greppable and diffable.
Move work along, then file it away:
dsh-todo update tmtcfbut --status in-progress --sprint 24
dsh-todo show tmtcfbut # everything about one task
dsh-todo done tmtcfbut
dsh-todo archive # archive EVERY completed taskWork on a project you are not cd'd into with --workspace:
dsh-todo --workspace ~/projects/other list --openCommands
| Command | Does |
| --- | --- |
| list | Show tasks — active only unless --archived |
| add <title> | Create a task; every field flag is accepted |
| update <id> | Change one or more fields; needs at least one |
| done / reopen | Flip completion, stamping or clearing completedAt to match |
| rm <id> | Delete outright — no confirmation, no archive |
| archive [<id>] | Archive one task, or every completed one when no id is given |
| show <id> | Print one task in full, including description and timestamps |
| help | The same reference, in the terminal |
Options
| Option | Applies to | Notes |
| --- | --- | --- |
| --workspace <dir> | all | Workspace directory. Defaults to cwd, and is used as given — no upward search for a .dsh, so an agent in a subdirectory targets the project it was pointed at. |
| --json | all | Machine-readable output, including on errors |
| --status <s> | add, update, list | backlog · todo · in-progress · blocked · done |
| --priority <p> | add, update, list | p0–p3 (default p2) |
| --release <n[.n[.n]]> | add, update, list | Numbers only, up to three: 1, 1.5, 0.5.1; v1.5 is refused |
| --sprint <n[.n]> | add, update, list | Numbers only, one dot at most: 24, 1.5; 0.5.1 is refused |
| --due <YYYY-MM-DD> | add, update | A calendar day; impossible dates are refused |
| --description <text> | add, update | Body text — acceptance criteria, repro steps |
| --title <text> | update | Rename |
| --open | list | Everything unfinished, whatever stage |
| --archived | list | Show archived tasks instead of active ones |
Filters combine, so list --open --priority p0 --release 1.5 is an AND across all three.
--key value and --key=value are both accepted.
Driving it from a script or an agent
--json is the one to use from a script. It prints structured output on the error path
too, so a caller never has to parse a human sentence to find out what went wrong:
dsh-todo list --open --json{
"ok": true,
"count": 3,
"items": [
{
"id": "tmtcfbutukp4j",
"title": "Fix token refresh",
"status": "todo",
"priority": "p0",
"release": "1.5",
"dueDate": "2026-03-14",
"createdAt": 1787889639378
}
]
}Every payload leads with ok, so a caller never has to infer the verdict from the
shape of what came back. On success ok is true and the command's own keys follow:
list adds { count, items }; add / update / done / reopen / rm add
{ item, revision } — the stored task, so a write can be confirmed without a second
call; archive adds { archived, revision }; show adds the task itself.
A failure returns ok: false with error and code, and exits with that code:
$ dsh-todo update nope --status done --json; echo "exit=$?"
{
"ok": false,
"error": "no task matching \"nope\"",
"code": 3
}
exit=3A refused value says which field it refused, what that field accepts, and what it was given — enough for an agent to correct itself without parsing the sentence. Nothing is written when a value is refused:
$ dsh-todo add "Ship it" --release v1.5 --json; echo "exit=$?"
{
"ok": false,
"error": "--release must be a version number like 1.5 or 0.5.1 (up to three numbers) (got \"v1.5\") — nothing was saved",
"code": 2,
"field": "release",
"expected": "a version number like 1.5 or 0.5.1 (up to three numbers)",
"got": "v1.5"
}
exit=2Exit codes are distinct so a script can branch on why a command failed:
| Code | Means |
| --- | --- |
| 0 | Success |
| 2 | Usage — unknown command, bad flag, malformed value |
| 3 | Not found — a well-formed request that matched no task |
Piping into jq covers most agent work:
# ids of everything blocked
dsh-todo list --status blocked --json | jq -r '.items[].id'
# fail CI if any P0 is still open
test "$(dsh-todo list --open --priority p0 --json | jq '.count')" -eq 0Ids accept any unambiguous prefix — dsh-todo done tmtcfbut. Ids are time-ordered so
short prefixes collide; an ambiguous one is an error listing the candidates rather than a
guess at which task you meant.
An empty value clears a field: --release "" removes the release. From a shell there is
no other way to say "unset this" as opposed to "leave it alone".
PowerShell drops empty arguments.
--release ""arrives at Node as a bare--releaseflag, so the field is left untouched instead of cleared. Use the--release=form there, which survives intact on every shell.
Invalid values are refused, never dropped — --due 2026-02-31 exits 2 instead of
quietly storing nothing, because an agent would otherwise never learn its date was ignored.
Is it safe alongside the running app?
Yes, and it is tested. SQLite is a multi-process database: the file lock refuses a writer that
lands inside another process's transaction rather than letting it interleave, and the CLI sets
a busy_timeout so it waits for the harness to commit instead of failing. Verified live — the
CLI wrote while a running server held its handle, and the API returned the new task with no
restart.
The one visible effect is that an already-open browser tab may need a refresh: every write bumps a revision token, so the tab's next write is refused and it adopts the authoritative list. That is the designed reconciliation, not lost data.
The task model
{ id, title, description?, status, priority, release?, sprint?, dueDate?,
createdAt, completedAt?, archivedAt? }| Field | Notes |
| --- | --- |
| title | Short and scannable — the one line a collapsed row shows. Capped at 500. |
| description | The body: acceptance criteria, repro steps, links. Its own 5000 cap, because reusing the title's 500 would silently truncate real notes. |
| status | backlog \| todo \| in-progress \| blocked \| done. The source of truth — there is no separate done flag to fall out of sync. |
| priority | p0–p3, default p2 so an unranked task sits mid-pile rather than jumping the queue. |
| release | What ships together, e.g. 1.5 or 0.5.1. One to three numbers. |
| sprint | When it is worked, e.g. 24. A single decimal — no patch segment. |
| dueDate | YYYY-MM-DD. A calendar day, not an instant — an epoch would bind it to a timezone and let one task read as two different days. Impossible dates like 2025-02-31 are rejected rather than rolled forward. |
Release and sprint are separate on purpose. A task can be worked in sprint 24 and ship in 1.3; collapsing them into one field loses the ability to answer either question. Both are numeric labels rather than entities, refused at every write path — CLI and UI alike — if they are anything else, so grouping and filtering work with no releases table, no CRUD, and no migration to rename one.
The two shapes differ, because the fields mean different things:
| Field | Pattern | Passes | Refused |
| --- | --- | --- | --- |
| release | ^\d+(\.\d+){0,2}$ | 1, 1.5, 0.5.1 | v1.5, 1.2.3.4, 1. |
| sprint | ^\d+(\.\d+)?$ | 1, 1.5, 24 | Sprint 24, 0.5.1 |
A release carries a patch segment so a fix shipping on top of 0.5 gets its own
label; a sprint is a point on a calendar, not a shipped artefact, so it takes none.
Being numeric means labels sort by version semantics — compared segment by
segment, so 1.10 ranks above 1.9 and 0.5.1 sits between 0.5 and 0.6.
Labels stored before the rule still load, group, and display unchanged.
completedAt is written only by the status transition, so it can never claim a
task is finished that isn't. Absent optional fields are absent keys, never
'', so "no release" has exactly one representation.
Storage
| Location | Contents |
| --- | --- |
| <workspace>/.dsh/todo.db | SQLite: one todo row per task, plus a meta table holding revision and updatedAt. |
Archived items live in the same table — archiving never moves data between collections, so nothing can be lost in a partial write.
Upgrading from the checklist version
Existing databases are migrated in place on first open: the new columns are
added with ALTER TABLE, title is backfilled from the old text, and
done = 1 becomes status = 'done'. Nothing is dropped, and a list written by
the old version keeps its order, its completion stamps and its archive.
The old text/done columns are still written alongside their replacements, so
downgrading to the previous version still reads a sane list.
Migration from the old browser-only version
Earlier versions stored todos in localStorage under dsh-todo:items. On first
run, that list is imported once into the first workspace that opens with an
empty stored list, and the import is then marked with dsh-todo:migrated. The
original key is deliberately left in place rather than deleted.
Notes
- The host half publishes a service, so it belongs to the profile's host composition — not to an agent preset.
