@dennisrongo/dsh-todo 0.4.0 → 0.5.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 CHANGED
@@ -31,15 +31,23 @@ next to the code it describes.
31
31
  standup actually asks about: what is moving, and what is stuck.
32
32
  - **Priority** — `P0`–`P3`, shown as a chip. Only P0/P1 are coloured, so the
33
33
  list flags what is urgent instead of turning into a rainbow.
34
- - **Release and sprint** — two independent labels: what ships together
35
- (`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.
36
41
  - **Group by** — None · Status · Release · Sprint · Priority, with collapsible
37
42
  section headers carrying their own `done/total` and progress bar. Grouping by
38
43
  status gives you a kanban board without drag-and-drop.
39
44
  - **Task detail modal** — click a task's title to open the full dialog: a
40
45
  roomy description box plus status, priority, release, sprint and due date.
41
- Esc or the backdrop closes it, focus is trapped inside and returned to the row
42
- 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.
43
51
  - **Expandable rows** — the chevron still gives a quick in-row peek without
44
52
  leaving the list. Double-click a title to rename it inline. One scannable line
45
53
  collapsed is what lets a task carry nine fields without becoming a wall of text.
@@ -55,8 +63,14 @@ next to the code it describes.
55
63
  restored (↩) or permanently deleted from the Archive view.
56
64
  - **Full editing** — add, check off, click-to-edit the title, edit the
57
65
  description and labels, reorder (▲/▼), archive, and delete. Release and sprint
58
- 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
59
67
  vocabulary without a releases table to administer.
68
+ - **Suggest** — a button in the tab header scans the workspace and proposes
69
+ concrete next tasks: unresolved `TODO`/`FIXME`/`HACK` comments, features the
70
+ docs promise but the code does not implement, and modules with no tests. Each
71
+ proposal comes with a one-line rationale, a priority, and a `file:line`
72
+ pointer where one exists. Tick the ones you want and **Add selected** files
73
+ them into the backlog. Never automatic — it runs only when you click it.
60
74
  - **Themed** — colors come only from the shell's `--dsw-*` tokens, so it follows
61
75
  light/dark automatically. Respects `prefers-reduced-motion`.
62
76
 
@@ -101,12 +115,27 @@ dsh profiles set `autoInstallPeers: false`, so this resolves correctly.
101
115
  pnpm install
102
116
  pnpm run build # node build/build.mjs — emits lib/index.js + lib/client.js
103
117
  pnpm run typecheck # tsc --noEmit
104
- pnpm test # node test/smoke.mjs offline, exercises the BUILT lib/ output
105
- pnpm run test:icons # headless Chrome: icon sizes and the 40px row budget
106
- pnpm run test:modal # headless Chrome: the dialog escapes the list's scroll container
118
+ pnpm test # offline: client + CLI, exercising the BUILT lib/ output
119
+ pnpm run test:cli # just the in-process CLI suite
120
+ pnpm run test:integration # spawns the real bin one full agent workflow
121
+ pnpm run test:icons # headless Chrome: icon sizes and the 40px row budget
122
+ pnpm run test:modal # headless Chrome: the dialog escapes the list's scroll container
123
+ pnpm run test:agent # OPT-IN: a real model drives the CLI (needs a key; costs tokens)
107
124
  ```
108
125
 
109
- `pnpm test` asserts against `lib/`, so **build before testing**.
126
+ `pnpm test` asserts against `lib/`, so **build before testing** (the script rebuilds first).
127
+
128
+ The CLI has two layers on purpose. `test/cli.test.mjs` calls into `lib/cli.js` in-process;
129
+ `test/cli-integration.mjs` **spawns the real binary** for one realistic agent workflow — plan a
130
+ release, inspect it, hit a refusal, recover from the payload alone, finish and archive — because
131
+ the in-process suite cannot see the shebang'd entry point, `process.exitCode`, the stdout/stderr
132
+ split, or argv as a shell delivers it.
133
+
134
+ `pnpm run test:agent` is the odd one out and is **not** part of `pnpm test`: it hands a real
135
+ model nothing but the binary path and a goal, then asserts on the resulting database rather than
136
+ on anything the model said. It answers a question no offline test can — is `help` enough to
137
+ drive this thing, and is a refusal message enough to recover from? It needs `DEEPSEEK_API_KEY`
138
+ exported (it runs the harness in a throwaway `DSH_HOME`, which has no stored credentials).
110
139
 
111
140
  ## Install into a dsh profile
112
141
 
@@ -156,6 +185,46 @@ swallow a move.
156
185
  `clearCompleted` (hard delete of done items) is still exported for callers that
157
186
  want it, but it is no longer wired to a button.
158
187
 
188
+ ## Suggest — what to work on next
189
+
190
+ The list holds work someone already thought of. Deciding what to do *next*
191
+ usually happens somewhere else — reading the code and noticing what is missing.
192
+ **Suggest**, in the tab header, moves that into the tab.
193
+
194
+ Clicking it reads the workspace and proposes concrete tasks from three kinds of
195
+ evidence:
196
+
197
+ - **Unresolved comments** — `TODO`, `FIXME` and `HACK`, with the file and line.
198
+ - **Docs-vs-implementation gaps** — the README and the file tree together, so
199
+ what is promised but absent has somewhere to show up.
200
+ - **Untested modules** — source files with no matching test file. A name-based
201
+ hint rather than a coverage run, so it is offered as a hint.
202
+
203
+ Each suggestion arrives as a checkbox row: a title, a one-line rationale, a
204
+ priority, and a `file:line` pointer where there is one to give. Nothing is
205
+ ticked by default — you opted into scanning, not into the results. **Add
206
+ selected** writes the ticked rows into the backlog as real tasks, with the
207
+ rationale as the description. Until then they are **proposals**: nothing is
208
+ stored, and closing the dialog discards them.
209
+
210
+ **Refresh returns genuinely new ideas, not a reshuffle.** Every title already
211
+ shown joins the exclusion set, alongside every unfinished task already in the
212
+ backlog, so the scan is told what not to repeat. Rows you have already ticked
213
+ survive a refresh — the selection is yours, not the model's.
214
+
215
+ > **A scan spends tokens.** It runs a real model session in the background —
216
+ > created, prompted, and archived when the scan finishes or you close the
217
+ > dialog; it never appears in the sidebar and is never navigated to. Nothing is
218
+ > scheduled and nothing is automatic: a scan happens when you click **Suggest**
219
+ > or **Refresh**, and only then.
220
+
221
+ What is sent is a **bounded digest**, not the repository: a capped file tree, a
222
+ capped list of comment matches one line each, and a leading slice of the README
223
+ and `package.json`. Vendored and generated directories (`node_modules`, `lib`,
224
+ `dist`, `vendor`, `target` and the rest) never enter it. Anything left out is
225
+ marked in the digest rather than dropped quietly, so a big repository yields a
226
+ smaller scan rather than a confident one about code it never read.
227
+
159
228
  ## CLI — for you and for AI agents
160
229
 
161
230
  The same list is reachable from a terminal, so an agent can shell out and manage your tasks
@@ -181,14 +250,14 @@ experimental-feature warning on stderr; it is harmless, and `2>/dev/null` silenc
181
250
  ```bash
182
251
  cd ~/projects/my-app # the workspace IS the current directory
183
252
 
184
- dsh-todo add "Fix token refresh" --priority p0 --release v1.2.0 --due 2026-03-14
185
- dsh-todo add "Write migration guide" --sprint "Sprint 24"
253
+ dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
254
+ dsh-todo add "Write migration guide" --sprint 24
186
255
  dsh-todo list
187
256
  ```
188
257
 
189
258
  ```text
190
- [ ] tmtcfbutukp4j todo p0 Fix token refresh (release=v1.2.0 due=2026-03-14)
191
- [ ] tmtcfbuxr5y9x todo p2 Write migration guide (sprint=Sprint 24)
259
+ [ ] tmtcfbutukp4j todo p0 Fix token refresh (release=1.5 due=2026-03-14)
260
+ [ ] tmtcfbuxr5y9x todo p2 Write migration guide (sprint=24)
192
261
  [ ] tmtcfbv071e6w in-progress p2 Ship it
193
262
  ```
194
263
 
@@ -198,7 +267,7 @@ That is the whole display contract — it stays greppable and diffable.
198
267
  Move work along, then file it away:
199
268
 
200
269
  ```bash
201
- dsh-todo update tmtcfbut --status in-progress --sprint "Sprint 24"
270
+ dsh-todo update tmtcfbut --status in-progress --sprint 24
202
271
  dsh-todo show tmtcfbut # everything about one task
203
272
  dsh-todo done tmtcfbut
204
273
  dsh-todo archive # archive EVERY completed task
@@ -231,15 +300,15 @@ dsh-todo --workspace ~/projects/other list --open
231
300
  | `--json` | all | Machine-readable output, **including on errors** |
232
301
  | `--status <s>` | add, update, list | `backlog` · `todo` · `in-progress` · `blocked` · `done` |
233
302
  | `--priority <p>` | add, update, list | `p0`–`p3` (default `p2`) |
234
- | `--release <label>` | add, update, list | e.g. `v1.2.0` |
235
- | `--sprint <label>` | add, update, list | e.g. `"Sprint 24"` |
303
+ | `--release <n[.n[.n]]>` | add, update, list | Numbers only, up to three: `1`, `1.5`, `0.5.1`; `v1.5` is refused |
304
+ | `--sprint <n[.n]>` | add, update, list | Numbers only, one dot at most: `24`, `1.5`; `0.5.1` is refused |
236
305
  | `--due <YYYY-MM-DD>` | add, update | A calendar day; impossible dates are refused |
237
306
  | `--description <text>` | add, update | Body text — acceptance criteria, repro steps |
238
307
  | `--title <text>` | update | Rename |
239
308
  | `--open` | list | Everything unfinished, whatever stage |
240
309
  | `--archived` | list | Show archived tasks *instead of* active ones |
241
310
 
242
- Filters combine, so `list --open --priority p0 --release v1.2.0` is an AND across all three.
311
+ Filters combine, so `list --open --priority p0 --release 1.5` is an AND across all three.
243
312
  `--key value` and `--key=value` are both accepted.
244
313
 
245
314
  ### Driving it from a script or an agent
@@ -253,6 +322,7 @@ dsh-todo list --open --json
253
322
 
254
323
  ```json
255
324
  {
325
+ "ok": true,
256
326
  "count": 3,
257
327
  "items": [
258
328
  {
@@ -260,7 +330,7 @@ dsh-todo list --open --json
260
330
  "title": "Fix token refresh",
261
331
  "status": "todo",
262
332
  "priority": "p0",
263
- "release": "v1.2.0",
333
+ "release": "1.5",
264
334
  "dueDate": "2026-03-14",
265
335
  "createdAt": 1787889639378
266
336
  }
@@ -268,19 +338,41 @@ dsh-todo list --open --json
268
338
  }
269
339
  ```
270
340
 
271
- `list` returns `{ count, items }`; `add` / `update` / `done` / `reopen` / `rm` return
272
- `{ item, revision }`; `archive` returns `{ archived, revision }`; `show` returns the task
273
- itself. A failure returns `{ error, code }` and exits with that code:
341
+ **Every payload leads with `ok`**, so a caller never has to infer the verdict from the
342
+ shape of what came back. On success `ok` is `true` and the command's own keys follow:
343
+ `list` adds `{ count, items }`; `add` / `update` / `done` / `reopen` / `rm` add
344
+ `{ item, revision }` — the stored task, so a write can be confirmed without a second
345
+ call; `archive` adds `{ archived, revision }`; `show` adds the task itself.
346
+
347
+ A failure returns `ok: false` with `error` and `code`, and exits with that code:
274
348
 
275
349
  ```bash
276
350
  $ dsh-todo update nope --status done --json; echo "exit=$?"
277
351
  {
352
+ "ok": false,
278
353
  "error": "no task matching \"nope\"",
279
354
  "code": 3
280
355
  }
281
356
  exit=3
282
357
  ```
283
358
 
359
+ A **refused value** says which field it refused, what that field accepts, and what it was
360
+ given — enough for an agent to correct itself without parsing the sentence. Nothing is
361
+ written when a value is refused:
362
+
363
+ ```bash
364
+ $ dsh-todo add "Ship it" --release v1.5 --json; echo "exit=$?"
365
+ {
366
+ "ok": false,
367
+ "error": "--release must be a version number like 1.5 or 0.5.1 (up to three numbers) (got \"v1.5\") — nothing was saved",
368
+ "code": 2,
369
+ "field": "release",
370
+ "expected": "a version number like 1.5 or 0.5.1 (up to three numbers)",
371
+ "got": "v1.5"
372
+ }
373
+ exit=2
374
+ ```
375
+
284
376
  Exit codes are distinct so a script can branch on **why** a command failed:
285
377
 
286
378
  | Code | Means |
@@ -338,15 +430,29 @@ list. That is the designed reconciliation, not lost data.
338
430
  | `description` | The body: acceptance criteria, repro steps, links. Its own 5000 cap, because reusing the title's 500 would silently truncate real notes. |
339
431
  | `status` | `backlog \| todo \| in-progress \| blocked \| done`. **The source of truth** — there is no separate `done` flag to fall out of sync. |
340
432
  | `priority` | `p0`–`p3`, default `p2` so an unranked task sits mid-pile rather than jumping the queue. |
341
- | `release` | What ships together, e.g. `v1.2.0`. |
342
- | `sprint` | When it is worked, e.g. `Sprint 24`. |
433
+ | `release` | What ships together, e.g. `1.5` or `0.5.1`. One to three numbers. |
434
+ | `sprint` | When it is worked, e.g. `24`. A single decimal — no patch segment. |
343
435
  | `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. |
344
436
 
345
- **Release and sprint are separate on purpose.** A task can be worked in Sprint 24
346
- and ship in v1.3.0; collapsing them into one field loses the ability to answer
347
- either question. Both are free text rather than entities grouping and
437
+ **Release and sprint are separate on purpose.** A task can be worked in sprint 24
438
+ and ship in 1.3; collapsing them into one field loses the ability to answer
439
+ either question. Both are **numeric labels** rather than entities, refused at
440
+ every write path — CLI and UI alike — if they are anything else, so grouping and
348
441
  filtering work with no releases table, no CRUD, and no migration to rename one.
349
442
 
443
+ The two shapes differ, because the fields mean different things:
444
+
445
+ | Field | Pattern | Passes | Refused |
446
+ | --- | --- | --- | --- |
447
+ | `release` | `^\d+(\.\d+){0,2}$` | `1`, `1.5`, `0.5.1` | `v1.5`, `1.2.3.4`, `1.` |
448
+ | `sprint` | `^\d+(\.\d+)?$` | `1`, `1.5`, `24` | `Sprint 24`, `0.5.1` |
449
+
450
+ A release carries a **patch segment** so a fix shipping on top of 0.5 gets its own
451
+ label; a sprint is a point on a calendar, not a shipped artefact, so it takes none.
452
+ Being numeric means labels sort by **version semantics** — compared segment by
453
+ segment, so `1.10` ranks above `1.9` and `0.5.1` sits between `0.5` and `0.6`.
454
+ Labels stored before the rule still load, group, and display unchanged.
455
+
350
456
  `completedAt` is written only by the status transition, so it can never claim a
351
457
  task is finished that isn't. Absent optional fields are absent *keys*, never
352
458
  `''`, 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;
@@ -36,6 +44,8 @@ function normalizeDueDate(raw) {
36
44
  var MAX_TEXT = 500;
37
45
  var MAX_DESC = 5e3;
38
46
  var MAX_LABEL = 60;
47
+ var SUGGESTIONS_DIR = ".dsh";
48
+ var SUGGESTIONS_FILE = `${SUGGESTIONS_DIR}/suggestions.json`;
39
49
 
40
50
  // src/db.ts
41
51
  var DOT_DSH = ".dsh";
@@ -78,6 +88,7 @@ function migrateSchema(db) {
78
88
  add("release", "release TEXT");
79
89
  add("sprint", "sprint TEXT");
80
90
  add("due_date", "due_date TEXT");
91
+ add("session_id", "session_id TEXT");
81
92
  if (addedTitle && columns.has("text")) {
82
93
  db.exec("UPDATE todo SET title = text WHERE title IS NULL");
83
94
  }
@@ -93,7 +104,7 @@ function readList(db) {
93
104
  const updatedAt = Number(db.prepare("SELECT value FROM meta WHERE key = 'updatedAt'").get()?.value ?? 0);
94
105
  const rows = db.prepare(
95
106
  `SELECT id, title, description, status, priority, release, sprint, due_date,
96
- created_at, completed_at, archived_at
107
+ session_id, created_at, completed_at, archived_at
97
108
  FROM todo ORDER BY position ASC`
98
109
  ).all();
99
110
  const text = (v) => v === null || v === void 0 ? void 0 : String(v);
@@ -108,6 +119,7 @@ function readList(db) {
108
119
  ...normalizeLabel(row.release) !== void 0 ? { release: normalizeLabel(row.release) } : {},
109
120
  ...normalizeLabel(row.sprint) !== void 0 ? { sprint: normalizeLabel(row.sprint) } : {},
110
121
  ...normalizeDueDate(row.due_date) !== void 0 ? { dueDate: normalizeDueDate(row.due_date) } : {},
122
+ ...text(row.session_id) !== void 0 ? { sessionId: text(row.session_id) } : {},
111
123
  createdAt: Number(row.created_at),
112
124
  ...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
113
125
  ...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
@@ -121,8 +133,8 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
121
133
  db.prepare("DELETE FROM todo").run();
122
134
  const insert = db.prepare(
123
135
  `INSERT INTO todo (id, title, description, status, priority, release, sprint, due_date,
124
- text, done, created_at, completed_at, archived_at, position)
125
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
136
+ session_id, text, done, created_at, completed_at, archived_at, position)
137
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
126
138
  );
127
139
  items.forEach((item, index) => {
128
140
  insert.run(
@@ -134,6 +146,7 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
134
146
  item.release ?? null,
135
147
  item.sprint ?? null,
136
148
  item.dueDate ?? null,
149
+ item.sessionId ?? null,
137
150
  item.title,
138
151
  item.status === "done" ? 1 : 0,
139
152
  item.createdAt,
@@ -191,10 +204,13 @@ var CliError = class extends Error {
191
204
  /**
192
205
  * @param message - human-readable reason.
193
206
  * @param code - process exit code, from {@link EXIT}.
207
+ * @param details - extra machine-readable fields merged into the `--json`
208
+ * error payload, so an agent can correct itself without parsing the sentence.
194
209
  */
195
- constructor(message, code = EXIT.usage) {
210
+ constructor(message, code = EXIT.usage, details = {}) {
196
211
  super(message);
197
212
  this.code = code;
213
+ this.details = details;
198
214
  this.name = "CliError";
199
215
  }
200
216
  };
@@ -212,6 +228,16 @@ function oneOf(options, key, allowed) {
212
228
  }
213
229
  return raw;
214
230
  }
231
+ function assertLabel(field, raw) {
232
+ if (raw === void 0 || raw === "") return;
233
+ if (normalizeVersionLabel(raw, field) !== void 0) return;
234
+ 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)";
235
+ throw new CliError(
236
+ `--${field} must be ${shape} (got "${raw}") \u2014 nothing was saved`,
237
+ EXIT.usage,
238
+ { field, expected: shape, got: raw }
239
+ );
240
+ }
215
241
  function resolveWorkspace(options, cwd) {
216
242
  return resolve2(str(options, "workspace") ?? cwd);
217
243
  }
@@ -274,6 +300,7 @@ function formatItem(item) {
274
300
  if (item.release) meta.push(`release=${item.release}`);
275
301
  if (item.sprint) meta.push(`sprint=${item.sprint}`);
276
302
  if (item.dueDate) meta.push(`due=${item.dueDate}`);
303
+ if (item.sessionId) meta.push(`session=${item.sessionId}`);
277
304
  if (isArchived(item)) meta.push("archived");
278
305
  return bits.join(" ") + (meta.length ? ` (${meta.join(" ")})` : "");
279
306
  }
@@ -299,9 +326,11 @@ Options
299
326
 
300
327
  --status <s> ${STATUSES.join("|")}
301
328
  --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)
329
+ --release <n[.n[.n]]> e.g. 1.5 or 0.5.1 (empty string clears)
330
+ --sprint <n[.n]> e.g. 24 (empty string clears)
304
331
  --due <YYYY-MM-DD> Calendar day (empty string clears)
332
+ --session <id> Harness session working the task (update only;
333
+ empty string clears)
305
334
  --description <text> Body text (empty string clears)
306
335
  --title <text> Rename (update only)
307
336
 
@@ -311,8 +340,8 @@ Ids may be given as any unambiguous prefix.
311
340
 
312
341
  Examples
313
342
  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"
343
+ dsh-todo add "Fix token refresh" --priority p0 --release 1.5 --due 2026-03-14
344
+ dsh-todo update t1a2 --status in-progress --sprint 24
316
345
  dsh-todo done t1a2
317
346
  `;
318
347
  function run(parsed, cwd, now = Date.now, rand = Math.random) {
@@ -349,6 +378,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
349
378
  `release ${item.release ?? "-"}`,
350
379
  `sprint ${item.sprint ?? "-"}`,
351
380
  `due ${item.dueDate ?? "-"}`,
381
+ `session ${item.sessionId ?? "-"}`,
352
382
  `created ${new Date(item.createdAt).toISOString()}`,
353
383
  ...item.completedAt ? [`completed ${new Date(item.completedAt).toISOString()}`] : [],
354
384
  ...item.archivedAt ? [`archived ${new Date(item.archivedAt).toISOString()}`] : [],
@@ -360,8 +390,12 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
360
390
  const title = positional.join(" ").trim();
361
391
  if (!title) throw new CliError("add needs a title");
362
392
  const description = str(options, "description");
363
- const release = normalizeLabel(str(options, "release"));
364
- const sprint = normalizeLabel(str(options, "sprint"));
393
+ const releaseRaw = str(options, "release");
394
+ const sprintRaw = str(options, "sprint");
395
+ assertLabel("release", releaseRaw);
396
+ assertLabel("sprint", sprintRaw);
397
+ const release = normalizeVersionLabel(releaseRaw, "release");
398
+ const sprint = normalizeVersionLabel(sprintRaw, "sprint");
365
399
  const dueRaw = str(options, "due");
366
400
  if (dueRaw !== void 0 && dueRaw !== "" && normalizeDueDate(dueRaw) === void 0) {
367
401
  throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${dueRaw}")`);
@@ -390,10 +424,13 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
390
424
  const release = str(options, "release");
391
425
  const sprint = str(options, "sprint");
392
426
  const due = str(options, "due");
427
+ const session = str(options, "session");
393
428
  if (due !== void 0 && due !== "" && normalizeDueDate(due) === void 0) {
394
429
  throw new CliError(`--due must be a real calendar date as YYYY-MM-DD (got "${due}")`);
395
430
  }
396
- if (status === void 0 && priority === void 0 && title === void 0 && description === void 0 && release === void 0 && sprint === void 0 && due === void 0) {
431
+ assertLabel("release", release);
432
+ assertLabel("sprint", sprint);
433
+ 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
434
  throw new CliError("update needs at least one field to change");
398
435
  }
399
436
  let updated;
@@ -415,7 +452,7 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
415
452
  }
416
453
  for (const [key, raw] of [["release", release], ["sprint", sprint]]) {
417
454
  if (raw === void 0) continue;
418
- const label = normalizeLabel(raw);
455
+ const label = normalizeVersionLabel(raw, key);
419
456
  if (label !== void 0) next[key] = label;
420
457
  else delete next[key];
421
458
  }
@@ -424,6 +461,10 @@ function run(parsed, cwd, now = Date.now, rand = Math.random) {
424
461
  if (value !== void 0) next.dueDate = value;
425
462
  else delete next.dueDate;
426
463
  }
464
+ if (session !== void 0) {
465
+ if (session) next.sessionId = session.slice(0, MAX_LABEL);
466
+ else delete next.sessionId;
467
+ }
427
468
  updated = next;
428
469
  return next;
429
470
  });
@@ -489,12 +530,14 @@ function main(argv, cwd = process.cwd()) {
489
530
  const wantsJson = parsed.options.json === true;
490
531
  try {
491
532
  const outcome = run(parsed, cwd);
492
- console.log(wantsJson ? JSON.stringify(outcome.json, null, 2) : outcome.text);
533
+ const json = outcome.json !== null && typeof outcome.json === "object" && !Array.isArray(outcome.json) ? { ok: true, ...outcome.json } : { ok: true, result: outcome.json };
534
+ console.log(wantsJson ? JSON.stringify(json, null, 2) : outcome.text);
493
535
  return EXIT.ok;
494
536
  } catch (error) {
495
537
  const message = error instanceof Error ? error.message : String(error);
496
538
  const code = error instanceof CliError ? error.code : 1;
497
- if (wantsJson) console.log(JSON.stringify({ error: message, code }, null, 2));
539
+ const details = error instanceof CliError ? error.details : {};
540
+ if (wantsJson) console.log(JSON.stringify({ ok: false, error: message, code, ...details }, null, 2));
498
541
  else console.error(`dsh-todo: ${message}`);
499
542
  return code;
500
543
  }