@dennisrongo/dsh-todo 0.3.0 → 0.4.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/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # @dennisrongo/dsh-todo
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@dennisrongo/dsh-todo)](https://www.npmjs.com/package/@dennisrongo/dsh-todo)
4
+
5
+ **npm:** [`@dennisrongo/dsh-todo`](https://www.npmjs.com/package/@dennisrongo/dsh-todo) ·
6
+ **source:** [dennisrongo/dsh-plugins](https://github.com/dennisrongo/dsh-plugins/tree/main/plugins/dsh-todo)
7
+
3
8
  A todo list for the [DeepSeek Harness](https://github.com/deepseek-ai) (dsh) web UI.
4
9
 
5
10
  Registers into the additive `conversation.view` slot — the conversation view
@@ -26,15 +31,23 @@ next to the code it describes.
26
31
  standup actually asks about: what is moving, and what is stuck.
27
32
  - **Priority** — `P0`–`P3`, shown as a chip. Only P0/P1 are coloured, so the
28
33
  list flags what is urgent instead of turning into a rainbow.
29
- - **Release and sprint** — two independent labels: what ships together
30
- (`v1.2.0`) and when it is worked (`Sprint 24`). A task can be in both.
34
+ - **Release and sprint** — two independent numeric labels: what ships
35
+ together (`1.5`, `0.5.1`) and when it is worked (`24`). A task can be in
36
+ both. A **release** takes up to three numbers, so a patch release has a label
37
+ of its own; a **sprint** is a single decimal. Never alpha (`v1.5` is
38
+ refused), so labels sort by version — `1.10` above `1.9`, `0.5.1` above
39
+ `0.5`. The inputs accept only digits and a dot as you type; a value that is
40
+ still not a valid label is flagged with an inline error and never saved.
31
41
  - **Group by** — None · Status · Release · Sprint · Priority, with collapsible
32
42
  section headers carrying their own `done/total` and progress bar. Grouping by
33
43
  status gives you a kanban board without drag-and-drop.
34
44
  - **Task detail modal** — click a task's title to open the full dialog: a
35
45
  roomy description box plus status, priority, release, sprint and due date.
36
- Esc or the backdrop closes it, focus is trapped inside and returned to the row
37
- you came from, and edits commit on close so a stray click never loses them.
46
+ Focus is trapped inside and returned to the row you came from, and text edits
47
+ commit on close so a stray click never loses them. **Done is the save**: it is
48
+ the only control that refuses to proceed while a release or sprint is invalid.
49
+ Esc, the backdrop and the X always let you out, discarding an unsaved bad
50
+ label rather than trapping you in the dialog.
38
51
  - **Expandable rows** — the chevron still gives a quick in-row peek without
39
52
  leaving the list. Double-click a title to rename it inline. One scannable line
40
53
  collapsed is what lets a task carry nine fields without becoming a wall of text.
@@ -50,7 +63,7 @@ next to the code it describes.
50
63
  restored (↩) or permanently deleted from the Archive view.
51
64
  - **Full editing** — add, check off, click-to-edit the title, edit the
52
65
  description and labels, reorder (▲/▼), archive, and delete. Release and sprint
53
- inputs suggest labels already in use, so free text converges on a shared
66
+ inputs suggest labels already in use, so values converge on a shared
54
67
  vocabulary without a releases table to administer.
55
68
  - **Themed** — colors come only from the shell's `--dsw-*` tokens, so it follows
56
69
  light/dark automatically. Respects `prefers-reduced-motion`.
@@ -96,12 +109,27 @@ dsh profiles set `autoInstallPeers: false`, so this resolves correctly.
96
109
  pnpm install
97
110
  pnpm run build # node build/build.mjs — emits lib/index.js + lib/client.js
98
111
  pnpm run typecheck # tsc --noEmit
99
- pnpm test # node test/smoke.mjs offline, exercises the BUILT lib/ output
100
- pnpm run test:icons # headless Chrome: icon sizes and the 40px row budget
101
- pnpm run test:modal # headless Chrome: the dialog escapes the list's scroll container
112
+ pnpm test # offline: client + CLI, exercising the BUILT lib/ output
113
+ pnpm run test:cli # just the in-process CLI suite
114
+ pnpm run test:integration # spawns the real bin one full agent workflow
115
+ pnpm run test:icons # headless Chrome: icon sizes and the 40px row budget
116
+ pnpm run test:modal # headless Chrome: the dialog escapes the list's scroll container
117
+ pnpm run test:agent # OPT-IN: a real model drives the CLI (needs a key; costs tokens)
102
118
  ```
103
119
 
104
- `pnpm test` asserts against `lib/`, so **build before testing**.
120
+ `pnpm test` asserts against `lib/`, so **build before testing** (the script rebuilds first).
121
+
122
+ The CLI has two layers on purpose. `test/cli.test.mjs` calls into `lib/cli.js` in-process;
123
+ `test/cli-integration.mjs` **spawns the real binary** for one realistic agent workflow — plan a
124
+ release, inspect it, hit a refusal, recover from the payload alone, finish and archive — because
125
+ the in-process suite cannot see the shebang'd entry point, `process.exitCode`, the stdout/stderr
126
+ split, or argv as a shell delivers it.
127
+
128
+ `pnpm run test:agent` is the odd one out and is **not** part of `pnpm test`: it hands a real
129
+ model nothing but the binary path and a goal, then asserts on the resulting database rather than
130
+ on anything the model said. It answers a question no offline test can — is `help` enough to
131
+ drive this thing, and is a refusal message enough to recover from? It needs `DEEPSEEK_API_KEY`
132
+ exported (it runs the harness in a throwaway `DSH_HOME`, which has no stored credentials).
105
133
 
106
134
  ## Install into a dsh profile
107
135
 
@@ -176,14 +204,14 @@ experimental-feature warning on stderr; it is harmless, and `2>/dev/null` silenc
176
204
  ```bash
177
205
  cd ~/projects/my-app # the workspace IS the current directory
178
206
 
179
- dsh-todo add "Fix token refresh" --priority p0 --release v1.2.0 --due 2026-03-14
180
- dsh-todo add "Write migration guide" --sprint "Sprint 24"
207
+ dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
208
+ dsh-todo add "Write migration guide" --sprint 24
181
209
  dsh-todo list
182
210
  ```
183
211
 
184
212
  ```text
185
- [ ] tmtcfbutukp4j todo p0 Fix token refresh (release=v1.2.0 due=2026-03-14)
186
- [ ] tmtcfbuxr5y9x todo p2 Write migration guide (sprint=Sprint 24)
213
+ [ ] tmtcfbutukp4j todo p0 Fix token refresh (release=1.5 due=2026-03-14)
214
+ [ ] tmtcfbuxr5y9x todo p2 Write migration guide (sprint=24)
187
215
  [ ] tmtcfbv071e6w in-progress p2 Ship it
188
216
  ```
189
217
 
@@ -193,7 +221,7 @@ That is the whole display contract — it stays greppable and diffable.
193
221
  Move work along, then file it away:
194
222
 
195
223
  ```bash
196
- dsh-todo update tmtcfbut --status in-progress --sprint "Sprint 24"
224
+ dsh-todo update tmtcfbut --status in-progress --sprint 24
197
225
  dsh-todo show tmtcfbut # everything about one task
198
226
  dsh-todo done tmtcfbut
199
227
  dsh-todo archive # archive EVERY completed task
@@ -226,15 +254,15 @@ dsh-todo --workspace ~/projects/other list --open
226
254
  | `--json` | all | Machine-readable output, **including on errors** |
227
255
  | `--status <s>` | add, update, list | `backlog` · `todo` · `in-progress` · `blocked` · `done` |
228
256
  | `--priority <p>` | add, update, list | `p0`–`p3` (default `p2`) |
229
- | `--release <label>` | add, update, list | e.g. `v1.2.0` |
230
- | `--sprint <label>` | add, update, list | e.g. `"Sprint 24"` |
257
+ | `--release <n[.n[.n]]>` | add, update, list | Numbers only, up to three: `1`, `1.5`, `0.5.1`; `v1.5` is refused |
258
+ | `--sprint <n[.n]>` | add, update, list | Numbers only, one dot at most: `24`, `1.5`; `0.5.1` is refused |
231
259
  | `--due <YYYY-MM-DD>` | add, update | A calendar day; impossible dates are refused |
232
260
  | `--description <text>` | add, update | Body text — acceptance criteria, repro steps |
233
261
  | `--title <text>` | update | Rename |
234
262
  | `--open` | list | Everything unfinished, whatever stage |
235
263
  | `--archived` | list | Show archived tasks *instead of* active ones |
236
264
 
237
- Filters combine, so `list --open --priority p0 --release v1.2.0` is an AND across all three.
265
+ Filters combine, so `list --open --priority p0 --release 1.5` is an AND across all three.
238
266
  `--key value` and `--key=value` are both accepted.
239
267
 
240
268
  ### Driving it from a script or an agent
@@ -248,6 +276,7 @@ dsh-todo list --open --json
248
276
 
249
277
  ```json
250
278
  {
279
+ "ok": true,
251
280
  "count": 3,
252
281
  "items": [
253
282
  {
@@ -255,7 +284,7 @@ dsh-todo list --open --json
255
284
  "title": "Fix token refresh",
256
285
  "status": "todo",
257
286
  "priority": "p0",
258
- "release": "v1.2.0",
287
+ "release": "1.5",
259
288
  "dueDate": "2026-03-14",
260
289
  "createdAt": 1787889639378
261
290
  }
@@ -263,19 +292,41 @@ dsh-todo list --open --json
263
292
  }
264
293
  ```
265
294
 
266
- `list` returns `{ count, items }`; `add` / `update` / `done` / `reopen` / `rm` return
267
- `{ item, revision }`; `archive` returns `{ archived, revision }`; `show` returns the task
268
- itself. A failure returns `{ error, code }` and exits with that code:
295
+ **Every payload leads with `ok`**, so a caller never has to infer the verdict from the
296
+ shape of what came back. On success `ok` is `true` and the command's own keys follow:
297
+ `list` adds `{ count, items }`; `add` / `update` / `done` / `reopen` / `rm` add
298
+ `{ item, revision }` — the stored task, so a write can be confirmed without a second
299
+ call; `archive` adds `{ archived, revision }`; `show` adds the task itself.
300
+
301
+ A failure returns `ok: false` with `error` and `code`, and exits with that code:
269
302
 
270
303
  ```bash
271
304
  $ dsh-todo update nope --status done --json; echo "exit=$?"
272
305
  {
306
+ "ok": false,
273
307
  "error": "no task matching \"nope\"",
274
308
  "code": 3
275
309
  }
276
310
  exit=3
277
311
  ```
278
312
 
313
+ A **refused value** says which field it refused, what that field accepts, and what it was
314
+ given — enough for an agent to correct itself without parsing the sentence. Nothing is
315
+ written when a value is refused:
316
+
317
+ ```bash
318
+ $ dsh-todo add "Ship it" --release v1.5 --json; echo "exit=$?"
319
+ {
320
+ "ok": false,
321
+ "error": "--release must be a version number like 1.5 or 0.5.1 (up to three numbers) (got \"v1.5\") — nothing was saved",
322
+ "code": 2,
323
+ "field": "release",
324
+ "expected": "a version number like 1.5 or 0.5.1 (up to three numbers)",
325
+ "got": "v1.5"
326
+ }
327
+ exit=2
328
+ ```
329
+
279
330
  Exit codes are distinct so a script can branch on **why** a command failed:
280
331
 
281
332
  | Code | Means |
@@ -333,15 +384,29 @@ list. That is the designed reconciliation, not lost data.
333
384
  | `description` | The body: acceptance criteria, repro steps, links. Its own 5000 cap, because reusing the title's 500 would silently truncate real notes. |
334
385
  | `status` | `backlog \| todo \| in-progress \| blocked \| done`. **The source of truth** — there is no separate `done` flag to fall out of sync. |
335
386
  | `priority` | `p0`–`p3`, default `p2` so an unranked task sits mid-pile rather than jumping the queue. |
336
- | `release` | What ships together, e.g. `v1.2.0`. |
337
- | `sprint` | When it is worked, e.g. `Sprint 24`. |
387
+ | `release` | What ships together, e.g. `1.5` or `0.5.1`. One to three numbers. |
388
+ | `sprint` | When it is worked, e.g. `24`. A single decimal — no patch segment. |
338
389
  | `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. |
339
390
 
340
- **Release and sprint are separate on purpose.** A task can be worked in Sprint 24
341
- and ship in v1.3.0; collapsing them into one field loses the ability to answer
342
- either question. Both are free text rather than entities grouping and
391
+ **Release and sprint are separate on purpose.** A task can be worked in sprint 24
392
+ and ship in 1.3; collapsing them into one field loses the ability to answer
393
+ either question. Both are **numeric labels** rather than entities, refused at
394
+ every write path — CLI and UI alike — if they are anything else, so grouping and
343
395
  filtering work with no releases table, no CRUD, and no migration to rename one.
344
396
 
397
+ The two shapes differ, because the fields mean different things:
398
+
399
+ | Field | Pattern | Passes | Refused |
400
+ | --- | --- | --- | --- |
401
+ | `release` | `^\d+(\.\d+){0,2}$` | `1`, `1.5`, `0.5.1` | `v1.5`, `1.2.3.4`, `1.` |
402
+ | `sprint` | `^\d+(\.\d+)?$` | `1`, `1.5`, `24` | `Sprint 24`, `0.5.1` |
403
+
404
+ A release carries a **patch segment** so a fix shipping on top of 0.5 gets its own
405
+ label; a sprint is a point on a calendar, not a shipped artefact, so it takes none.
406
+ Being numeric means labels sort by **version semantics** — compared segment by
407
+ segment, so `1.10` ranks above `1.9` and `0.5.1` sits between `0.5` and `0.6`.
408
+ Labels stored before the rule still load, group, and display unchanged.
409
+
345
410
  `completedAt` is written only by the status transition, so it can never claim a
346
411
  task is finished that isn't. Absent optional fields are absent *keys*, never
347
412
  `''`, so "no release" has exactly one representation.
package/lib/bin.js CHANGED
@@ -24,6 +24,14 @@ function normalizeLabel(raw) {
24
24
  const text = raw.replace(/\s+/g, " ").trim().slice(0, MAX_LABEL);
25
25
  return text.length > 0 ? text : void 0;
26
26
  }
27
+ var RELEASE_LABEL_RE = /^\d+(\.\d+){0,2}$/;
28
+ var SPRINT_LABEL_RE = /^\d+(\.\d+)?$/;
29
+ function normalizeVersionLabel(raw, field) {
30
+ const label = normalizeLabel(raw);
31
+ if (label === void 0) return void 0;
32
+ const pattern = field === "release" ? RELEASE_LABEL_RE : SPRINT_LABEL_RE;
33
+ return pattern.test(label) ? label : void 0;
34
+ }
27
35
  var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
28
36
  function normalizeDueDate(raw) {
29
37
  if (typeof raw !== "string") return void 0;
@@ -78,6 +86,7 @@ function migrateSchema(db) {
78
86
  add("release", "release TEXT");
79
87
  add("sprint", "sprint TEXT");
80
88
  add("due_date", "due_date TEXT");
89
+ add("session_id", "session_id TEXT");
81
90
  if (addedTitle && columns.has("text")) {
82
91
  db.exec("UPDATE todo SET title = text WHERE title IS NULL");
83
92
  }
@@ -93,7 +102,7 @@ function readList(db) {
93
102
  const updatedAt = Number(db.prepare("SELECT value FROM meta WHERE key = 'updatedAt'").get()?.value ?? 0);
94
103
  const rows = db.prepare(
95
104
  `SELECT id, title, description, status, priority, release, sprint, due_date,
96
- created_at, completed_at, archived_at
105
+ session_id, created_at, completed_at, archived_at
97
106
  FROM todo ORDER BY position ASC`
98
107
  ).all();
99
108
  const text = (v) => v === null || v === void 0 ? void 0 : String(v);
@@ -108,6 +117,7 @@ function readList(db) {
108
117
  ...normalizeLabel(row.release) !== void 0 ? { release: normalizeLabel(row.release) } : {},
109
118
  ...normalizeLabel(row.sprint) !== void 0 ? { sprint: normalizeLabel(row.sprint) } : {},
110
119
  ...normalizeDueDate(row.due_date) !== void 0 ? { dueDate: normalizeDueDate(row.due_date) } : {},
120
+ ...text(row.session_id) !== void 0 ? { sessionId: text(row.session_id) } : {},
111
121
  createdAt: Number(row.created_at),
112
122
  ...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
113
123
  ...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
@@ -121,8 +131,8 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
121
131
  db.prepare("DELETE FROM todo").run();
122
132
  const insert = db.prepare(
123
133
  `INSERT INTO todo (id, title, description, status, priority, release, sprint, due_date,
124
- text, done, created_at, completed_at, archived_at, position)
125
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
134
+ session_id, text, done, created_at, completed_at, archived_at, position)
135
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
126
136
  );
127
137
  items.forEach((item, index) => {
128
138
  insert.run(
@@ -134,6 +144,7 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
134
144
  item.release ?? null,
135
145
  item.sprint ?? null,
136
146
  item.dueDate ?? null,
147
+ item.sessionId ?? null,
137
148
  item.title,
138
149
  item.status === "done" ? 1 : 0,
139
150
  item.createdAt,
@@ -191,10 +202,13 @@ var CliError = class extends Error {
191
202
  /**
192
203
  * @param message - human-readable reason.
193
204
  * @param code - process exit code, from {@link EXIT}.
205
+ * @param details - extra machine-readable fields merged into the `--json`
206
+ * error payload, so an agent can correct itself without parsing the sentence.
194
207
  */
195
- constructor(message, code = EXIT.usage) {
208
+ constructor(message, code = EXIT.usage, details = {}) {
196
209
  super(message);
197
210
  this.code = code;
211
+ this.details = details;
198
212
  this.name = "CliError";
199
213
  }
200
214
  };
@@ -212,6 +226,16 @@ function oneOf(options, key, allowed) {
212
226
  }
213
227
  return raw;
214
228
  }
229
+ function assertLabel(field, raw) {
230
+ if (raw === void 0 || raw === "") return;
231
+ if (normalizeVersionLabel(raw, field) !== void 0) return;
232
+ const shape = field === "release" ? "a version number like 1.5 or 0.5.1 (up to three numbers)" : "a decimal number like 1.5 (one dot at most)";
233
+ throw new CliError(
234
+ `--${field} must be ${shape} (got "${raw}") \u2014 nothing was saved`,
235
+ EXIT.usage,
236
+ { field, expected: shape, got: raw }
237
+ );
238
+ }
215
239
  function resolveWorkspace(options, cwd) {
216
240
  return resolve2(str(options, "workspace") ?? cwd);
217
241
  }
@@ -274,6 +298,7 @@ function formatItem(item) {
274
298
  if (item.release) meta.push(`release=${item.release}`);
275
299
  if (item.sprint) meta.push(`sprint=${item.sprint}`);
276
300
  if (item.dueDate) meta.push(`due=${item.dueDate}`);
301
+ if (item.sessionId) meta.push(`session=${item.sessionId}`);
277
302
  if (isArchived(item)) meta.push("archived");
278
303
  return bits.join(" ") + (meta.length ? ` (${meta.join(" ")})` : "");
279
304
  }
@@ -299,9 +324,11 @@ Options
299
324
 
300
325
  --status <s> ${STATUSES.join("|")}
301
326
  --priority <p> ${PRIORITIES.join("|")}
302
- --release <label> e.g. v1.2.0 (empty string clears)
303
- --sprint <label> e.g. "Sprint 24" (empty string clears)
327
+ --release <n[.n[.n]]> e.g. 1.5 or 0.5.1 (empty string clears)
328
+ --sprint <n[.n]> e.g. 24 (empty string clears)
304
329
  --due <YYYY-MM-DD> Calendar day (empty string clears)
330
+ --session <id> Harness session working the task (update only;
331
+ empty string clears)
305
332
  --description <text> Body text (empty string clears)
306
333
  --title <text> Rename (update only)
307
334
 
@@ -311,8 +338,8 @@ Ids may be given as any unambiguous prefix.
311
338
 
312
339
  Examples
313
340
  dsh-todo list --open --json
314
- dsh-todo add "Fix token refresh" --priority p0 --release v1.2.0 --due 2026-03-14
315
- dsh-todo update t1a2 --status in-progress --sprint "Sprint 24"
341
+ dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
342
+ dsh-todo update t1a2 --status in-progress --sprint 24
316
343
  dsh-todo done t1a2
317
344
  `;
318
345
  function run(parsed, cwd, now = Date.now, rand = Math.random) {
@@ -349,6 +376,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
349
376
  `release ${item.release ?? "-"}`,
350
377
  `sprint ${item.sprint ?? "-"}`,
351
378
  `due ${item.dueDate ?? "-"}`,
379
+ `session ${item.sessionId ?? "-"}`,
352
380
  `created ${new Date(item.createdAt).toISOString()}`,
353
381
  ...item.completedAt ? [`completed ${new Date(item.completedAt).toISOString()}`] : [],
354
382
  ...item.archivedAt ? [`archived ${new Date(item.archivedAt).toISOString()}`] : [],
@@ -360,8 +388,12 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
360
388
  const title = positional.join(" ").trim();
361
389
  if (!title) throw new CliError("add needs a title");
362
390
  const description = str(options, "description");
363
- const release = normalizeLabel(str(options, "release"));
364
- const sprint = normalizeLabel(str(options, "sprint"));
391
+ const releaseRaw = str(options, "release");
392
+ const sprintRaw = str(options, "sprint");
393
+ assertLabel("release", releaseRaw);
394
+ assertLabel("sprint", sprintRaw);
395
+ const release = normalizeVersionLabel(releaseRaw, "release");
396
+ const sprint = normalizeVersionLabel(sprintRaw, "sprint");
365
397
  const dueRaw = str(options, "due");
366
398
  if (dueRaw !== void 0 && dueRaw !== "" && normalizeDueDate(dueRaw) === void 0) {
367
399
  throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${dueRaw}")`);
@@ -390,10 +422,13 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
390
422
  const release = str(options, "release");
391
423
  const sprint = str(options, "sprint");
392
424
  const due = str(options, "due");
425
+ const session = str(options, "session");
393
426
  if (due !== void 0 && due !== "" && normalizeDueDate(due) === void 0) {
394
427
  throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${due}")`);
395
428
  }
396
- if (status === void 0 && priority === void 0 && title === void 0 && description === void 0 && release === void 0 && sprint === void 0 && due === void 0) {
429
+ assertLabel("release", release);
430
+ assertLabel("sprint", sprint);
431
+ if (status === void 0 && priority === void 0 && title === void 0 && description === void 0 && release === void 0 && sprint === void 0 && due === void 0 && session === void 0) {
397
432
  throw new CliError("update needs at least one field to change");
398
433
  }
399
434
  let updated;
@@ -415,7 +450,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
415
450
  }
416
451
  for (const [key, raw] of [["release", release], ["sprint", sprint]]) {
417
452
  if (raw === void 0) continue;
418
- const label = normalizeLabel(raw);
453
+ const label = normalizeVersionLabel(raw, key);
419
454
  if (label !== void 0) next[key] = label;
420
455
  else delete next[key];
421
456
  }
@@ -424,6 +459,10 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
424
459
  if (value !== void 0) next.dueDate = value;
425
460
  else delete next.dueDate;
426
461
  }
462
+ if (session !== void 0) {
463
+ if (session) next.sessionId = session.slice(0, MAX_LABEL);
464
+ else delete next.sessionId;
465
+ }
427
466
  updated = next;
428
467
  return next;
429
468
  });
@@ -489,12 +528,14 @@ function main(argv, cwd = process.cwd()) {
489
528
  const wantsJson = parsed.options.json === true;
490
529
  try {
491
530
  const outcome = run(parsed, cwd);
492
- console.log(wantsJson ? JSON.stringify(outcome.json, null, 2) : outcome.text);
531
+ const json = outcome.json !== null && typeof outcome.json === "object" && !Array.isArray(outcome.json) ? { ok: true, ...outcome.json } : { ok: true, result: outcome.json };
532
+ console.log(wantsJson ? JSON.stringify(json, null, 2) : outcome.text);
493
533
  return EXIT.ok;
494
534
  } catch (error) {
495
535
  const message = error instanceof Error ? error.message : String(error);
496
536
  const code = error instanceof CliError ? error.code : 1;
497
- if (wantsJson) console.log(JSON.stringify({ error: message, code }, null, 2));
537
+ const details = error instanceof CliError ? error.details : {};
538
+ if (wantsJson) console.log(JSON.stringify({ ok: false, error: message, code, ...details }, null, 2));
498
539
  else console.error(`dsh-todo: ${message}`);
499
540
  return code;
500
541
  }
package/lib/cli.js CHANGED
@@ -22,6 +22,14 @@ function normalizeLabel(raw) {
22
22
  const text = raw.replace(/\s+/g, " ").trim().slice(0, MAX_LABEL);
23
23
  return text.length > 0 ? text : void 0;
24
24
  }
25
+ var RELEASE_LABEL_RE = /^\d+(\.\d+){0,2}$/;
26
+ var SPRINT_LABEL_RE = /^\d+(\.\d+)?$/;
27
+ function normalizeVersionLabel(raw, field) {
28
+ const label = normalizeLabel(raw);
29
+ if (label === void 0) return void 0;
30
+ const pattern = field === "release" ? RELEASE_LABEL_RE : SPRINT_LABEL_RE;
31
+ return pattern.test(label) ? label : void 0;
32
+ }
25
33
  var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
26
34
  function normalizeDueDate(raw) {
27
35
  if (typeof raw !== "string") return void 0;
@@ -76,6 +84,7 @@ function migrateSchema(db) {
76
84
  add("release", "release TEXT");
77
85
  add("sprint", "sprint TEXT");
78
86
  add("due_date", "due_date TEXT");
87
+ add("session_id", "session_id TEXT");
79
88
  if (addedTitle && columns.has("text")) {
80
89
  db.exec("UPDATE todo SET title = text WHERE title IS NULL");
81
90
  }
@@ -91,7 +100,7 @@ function readList(db) {
91
100
  const updatedAt = Number(db.prepare("SELECT value FROM meta WHERE key = 'updatedAt'").get()?.value ?? 0);
92
101
  const rows = db.prepare(
93
102
  `SELECT id, title, description, status, priority, release, sprint, due_date,
94
- created_at, completed_at, archived_at
103
+ session_id, created_at, completed_at, archived_at
95
104
  FROM todo ORDER BY position ASC`
96
105
  ).all();
97
106
  const text = (v) => v === null || v === void 0 ? void 0 : String(v);
@@ -106,6 +115,7 @@ function readList(db) {
106
115
  ...normalizeLabel(row.release) !== void 0 ? { release: normalizeLabel(row.release) } : {},
107
116
  ...normalizeLabel(row.sprint) !== void 0 ? { sprint: normalizeLabel(row.sprint) } : {},
108
117
  ...normalizeDueDate(row.due_date) !== void 0 ? { dueDate: normalizeDueDate(row.due_date) } : {},
118
+ ...text(row.session_id) !== void 0 ? { sessionId: text(row.session_id) } : {},
109
119
  createdAt: Number(row.created_at),
110
120
  ...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
111
121
  ...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
@@ -119,8 +129,8 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
119
129
  db.prepare("DELETE FROM todo").run();
120
130
  const insert = db.prepare(
121
131
  `INSERT INTO todo (id, title, description, status, priority, release, sprint, due_date,
122
- text, done, created_at, completed_at, archived_at, position)
123
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
132
+ session_id, text, done, created_at, completed_at, archived_at, position)
133
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
124
134
  );
125
135
  items.forEach((item, index) => {
126
136
  insert.run(
@@ -132,6 +142,7 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
132
142
  item.release ?? null,
133
143
  item.sprint ?? null,
134
144
  item.dueDate ?? null,
145
+ item.sessionId ?? null,
135
146
  item.title,
136
147
  item.status === "done" ? 1 : 0,
137
148
  item.createdAt,
@@ -189,10 +200,13 @@ var CliError = class extends Error {
189
200
  /**
190
201
  * @param message - human-readable reason.
191
202
  * @param code - process exit code, from {@link EXIT}.
203
+ * @param details - extra machine-readable fields merged into the `--json`
204
+ * error payload, so an agent can correct itself without parsing the sentence.
192
205
  */
193
- constructor(message, code = EXIT.usage) {
206
+ constructor(message, code = EXIT.usage, details = {}) {
194
207
  super(message);
195
208
  this.code = code;
209
+ this.details = details;
196
210
  this.name = "CliError";
197
211
  }
198
212
  };
@@ -210,6 +224,16 @@ function oneOf(options, key, allowed) {
210
224
  }
211
225
  return raw;
212
226
  }
227
+ function assertLabel(field, raw) {
228
+ if (raw === void 0 || raw === "") return;
229
+ if (normalizeVersionLabel(raw, field) !== void 0) return;
230
+ const shape = field === "release" ? "a version number like 1.5 or 0.5.1 (up to three numbers)" : "a decimal number like 1.5 (one dot at most)";
231
+ throw new CliError(
232
+ `--${field} must be ${shape} (got "${raw}") \u2014 nothing was saved`,
233
+ EXIT.usage,
234
+ { field, expected: shape, got: raw }
235
+ );
236
+ }
213
237
  function resolveWorkspace(options, cwd) {
214
238
  return resolve2(str(options, "workspace") ?? cwd);
215
239
  }
@@ -272,6 +296,7 @@ function formatItem(item) {
272
296
  if (item.release) meta.push(`release=${item.release}`);
273
297
  if (item.sprint) meta.push(`sprint=${item.sprint}`);
274
298
  if (item.dueDate) meta.push(`due=${item.dueDate}`);
299
+ if (item.sessionId) meta.push(`session=${item.sessionId}`);
275
300
  if (isArchived(item)) meta.push("archived");
276
301
  return bits.join(" ") + (meta.length ? ` (${meta.join(" ")})` : "");
277
302
  }
@@ -297,9 +322,11 @@ Options
297
322
 
298
323
  --status <s> ${STATUSES.join("|")}
299
324
  --priority <p> ${PRIORITIES.join("|")}
300
- --release <label> e.g. v1.2.0 (empty string clears)
301
- --sprint <label> e.g. "Sprint 24" (empty string clears)
325
+ --release <n[.n[.n]]> e.g. 1.5 or 0.5.1 (empty string clears)
326
+ --sprint <n[.n]> e.g. 24 (empty string clears)
302
327
  --due <YYYY-MM-DD> Calendar day (empty string clears)
328
+ --session <id> Harness session working the task (update only;
329
+ empty string clears)
303
330
  --description <text> Body text (empty string clears)
304
331
  --title <text> Rename (update only)
305
332
 
@@ -309,8 +336,8 @@ Ids may be given as any unambiguous prefix.
309
336
 
310
337
  Examples
311
338
  dsh-todo list --open --json
312
- dsh-todo add "Fix token refresh" --priority p0 --release v1.2.0 --due 2026-03-14
313
- dsh-todo update t1a2 --status in-progress --sprint "Sprint 24"
339
+ dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
340
+ dsh-todo update t1a2 --status in-progress --sprint 24
314
341
  dsh-todo done t1a2
315
342
  `;
316
343
  function run(parsed, cwd, now = Date.now, rand = Math.random) {
@@ -347,6 +374,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
347
374
  `release ${item.release ?? "-"}`,
348
375
  `sprint ${item.sprint ?? "-"}`,
349
376
  `due ${item.dueDate ?? "-"}`,
377
+ `session ${item.sessionId ?? "-"}`,
350
378
  `created ${new Date(item.createdAt).toISOString()}`,
351
379
  ...item.completedAt ? [`completed ${new Date(item.completedAt).toISOString()}`] : [],
352
380
  ...item.archivedAt ? [`archived ${new Date(item.archivedAt).toISOString()}`] : [],
@@ -358,8 +386,12 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
358
386
  const title = positional.join(" ").trim();
359
387
  if (!title) throw new CliError("add needs a title");
360
388
  const description = str(options, "description");
361
- const release = normalizeLabel(str(options, "release"));
362
- const sprint = normalizeLabel(str(options, "sprint"));
389
+ const releaseRaw = str(options, "release");
390
+ const sprintRaw = str(options, "sprint");
391
+ assertLabel("release", releaseRaw);
392
+ assertLabel("sprint", sprintRaw);
393
+ const release = normalizeVersionLabel(releaseRaw, "release");
394
+ const sprint = normalizeVersionLabel(sprintRaw, "sprint");
363
395
  const dueRaw = str(options, "due");
364
396
  if (dueRaw !== void 0 && dueRaw !== "" && normalizeDueDate(dueRaw) === void 0) {
365
397
  throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${dueRaw}")`);
@@ -388,10 +420,13 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
388
420
  const release = str(options, "release");
389
421
  const sprint = str(options, "sprint");
390
422
  const due = str(options, "due");
423
+ const session = str(options, "session");
391
424
  if (due !== void 0 && due !== "" && normalizeDueDate(due) === void 0) {
392
425
  throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${due}")`);
393
426
  }
394
- if (status === void 0 && priority === void 0 && title === void 0 && description === void 0 && release === void 0 && sprint === void 0 && due === void 0) {
427
+ assertLabel("release", release);
428
+ assertLabel("sprint", sprint);
429
+ if (status === void 0 && priority === void 0 && title === void 0 && description === void 0 && release === void 0 && sprint === void 0 && due === void 0 && session === void 0) {
395
430
  throw new CliError("update needs at least one field to change");
396
431
  }
397
432
  let updated;
@@ -413,7 +448,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
413
448
  }
414
449
  for (const [key, raw] of [["release", release], ["sprint", sprint]]) {
415
450
  if (raw === void 0) continue;
416
- const label = normalizeLabel(raw);
451
+ const label = normalizeVersionLabel(raw, key);
417
452
  if (label !== void 0) next[key] = label;
418
453
  else delete next[key];
419
454
  }
@@ -422,6 +457,10 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
422
457
  if (value !== void 0) next.dueDate = value;
423
458
  else delete next.dueDate;
424
459
  }
460
+ if (session !== void 0) {
461
+ if (session) next.sessionId = session.slice(0, MAX_LABEL);
462
+ else delete next.sessionId;
463
+ }
425
464
  updated = next;
426
465
  return next;
427
466
  });
@@ -487,12 +526,14 @@ function main(argv, cwd = process.cwd()) {
487
526
  const wantsJson = parsed.options.json === true;
488
527
  try {
489
528
  const outcome = run(parsed, cwd);
490
- console.log(wantsJson ? JSON.stringify(outcome.json, null, 2) : outcome.text);
529
+ const json = outcome.json !== null && typeof outcome.json === "object" && !Array.isArray(outcome.json) ? { ok: true, ...outcome.json } : { ok: true, result: outcome.json };
530
+ console.log(wantsJson ? JSON.stringify(json, null, 2) : outcome.text);
491
531
  return EXIT.ok;
492
532
  } catch (error) {
493
533
  const message = error instanceof Error ? error.message : String(error);
494
534
  const code = error instanceof CliError ? error.code : 1;
495
- if (wantsJson) console.log(JSON.stringify({ error: message, code }, null, 2));
535
+ const details = error instanceof CliError ? error.details : {};
536
+ if (wantsJson) console.log(JSON.stringify({ ok: false, error: message, code, ...details }, null, 2));
496
537
  else console.error(`dsh-todo: ${message}`);
497
538
  return code;
498
539
  }