feat(output)!: render type as a string in JSON - #214
Merged
Merged
Conversation
`type` was Things' raw TMTask code — 0, 1 or 2 — in every JSON payload that carried it. A bare integer reads badly for humans and agents alike, and `status` already renders as a string, so the two fields were in two different styles. `type` now renders as `todo`, `project` or `heading`, through a `model.TaskType` codec that mirrors `model.Status`: a name<->code map as the single source of truth, a `MarshalJSON` that falls back to the raw integer for an unrecognized code, and an `UnmarshalJSON` that accepts either form so values round-trip. Every JSON surface picks this up, because they all marshal `model.Task` through one encoder: list views, show, search and repeating. Plain text output is unchanged, including the `(project)` marker on a project row. BREAKING CHANGE: `type` in JSON output is now the string `todo`, `project` or `heading` instead of the integer 0, 1 or 2. A caller matching on `.type==1` has to become `.type=="project"`. Closes #208
This was referenced Sep 10, 2026
ryanlewis
added a commit
that referenced
this pull request
Sep 10, 2026
Closes #212 ## What changed `trash` now lists projects alongside to-dos, the same rule #205 and #209 applied to the other views. Trashing a project in Things puts the project row itself in the app's Trash, and `things projects` filters trashed rows, so a trashed project was visible nowhere in the CLI — excluded by construction, not by data. The view now selects `t.type IN (0, 1)` through the existing `todoOrProject` constant. Nothing else about it moved: headings (type 2) stay out, and `trash` keeps the literal reading of the database it shares with `logbook`, so the children of a trashed project and any repeating template that ends up in the bin still list as they are stored. `inbox` and `deadlines` stay to-do only. #213 covers `deadlines`. ## Telling a project row from a to-do No output change was needed. `printTasks` already appends a dim `(project)` for project rows, and since #214 the JSON carries `"type": "project"`. ## Verified against the real database Read the live database with `sqlite3 -readonly`, and compared against the app's own list membership via AppleScript (`id of to dos of list "Trash"`), by uuid rather than title. | | count | |---|---| | App's Trash list | 162 | | CLI `list trash` before | 148 | | CLI `list trash` after | 165 | | Trashed project rows gained | 17 | All 17 project rows the CLI now returns are in the app's Trash, and nothing the app shows is missing from the CLI. No false positives: the database holds no trashed headings and no trashed repeating templates, so neither could leak in. The 3 rows the CLI returns that the app does not are unchanged by this PR. All three are to-dos whose parent project is itself trashed; the app shows the trashed project as one row and folds its children into it. They were already returned before this change, under the `viewsIncludingTrashedProjects` decision from #155, and narrowing that is a separate question from this issue. ## Tests Three tests in `internal/db/tasks_test.go` over a new `seedTrashedProjects` helper: trashed projects of both statuses are listed and carry `model.TypeProject`; headings and live rows of either kind stay out; `--area` matches a trashed project through its own area while `--project` cannot. `TestTrashAndLogbookKeepTrashedProjectChildren` seeds a trashed project as the parent of its fixture, so its trash expectation gained the project row it was already seeding. Its subject, that the child is kept, is unchanged. Docs updated in `internal/skill/SKILL.md`, `docs/content/commands.md` and `docs/content/agents.md`: the sentence naming which views carry projects, and the repeating-template note that said a project template never reaches `trash`.
This was referenced Sep 10, 2026
ryanlewis
added a commit
that referenced
this pull request
Sep 10, 2026
Closes #218 ## What changed `type` on a to-do row in JSON output is now `"task"` instead of `"todo"`. `model.typeNames` is the single source of truth (`internal/model/model.go`), so every JSON surface picks this up without its own change. Mechanical rename, nothing else touched: - `internal/model/model.go`, `internal/model/model_test.go` — codec map and its round-trip/marshal/unmarshal/string tests - `cmd/things/run_test.go` — raw-JSON assertions from #214 - `internal/skill/SKILL.md`, `docs/content/commands.md`, `docs/content/agents.md` — every `"todo"` value in prose and `jq` examples The legacy integer `0` still decodes on input; no compatibility shim for the string `todo` is needed since #214 is unreleased. Plain text output, `model.Status`, and the codec's structure are unchanged. ## Why #214 introduced the string values `todo`/`project`/`heading`. Review on that PR flagged `todo` as a fourth spelling of one concept — the codebase already says `task`/`to-do` elsewhere — and the issue calls for `task` as the word the CLI should use generally, before 0.8.0 ships the contract. ## How tested - `make test` and `make lint` pass - `/code-review --fix` run against the diff; its two suggested fixes (a `db.go`/`tasks.go` cleanup) were out of scope for this issue and were not applied
This was referenced Sep 10, 2026
ryanlewis
added a commit
that referenced
this pull request
Sep 10, 2026
Closes #219 The `--json` error payload spelled one concept two ways. `kind` was `"task"` everywhere except the `not a project` failure, where it was `"to-do"`, while `type` on a task row has said `"task"` since #214. One word wins, and that word is `task`. ## What changed `ProjectEditCmd.Run` now builds its `wrongKindError` with `Kind: "task"`. That field feeds both the JSON `kind` and the rendered message, so the message moves with it: `"Post letter" is a task; use things edit`. Every other producer already said `task`, `project`, `area` or `tag`. The docs that print or explain the payload follow: the error-payload example in the bundled skill, the token paragraph on the agents page, and the README paragraph explaining what `kind` carries. The skill gains a one-line note in its `--json` section saying a to-do is a `task` in every JSON value the CLI emits, naming `import` payloads as the one exception. `commands.md` and `configuration.md` are untouched. Neither documents the error payload, and no command or flag changed. ## Not a breaking change `kind: "to-do"` was introduced in #193, which is not an ancestor of `v0.7.0`. The value has never appeared in a release, so no published contract moves and there is nothing for the 0.8.0 notes to warn about. That is the same reasoning #223 applied to the `type` rename. The `error` token is untouched either way: it stays `not a project`. ## Out of scope, deliberately `import` payloads still spell a to-do `"to-do"`. That is Things' own JSON URL scheme format and it stays documented as the exception. Three other `kind` variables hold `"to-do"` and were left alone, because none of them reaches JSON. They only compose English prose: the repeating-item refusal in `verify.go`, the per-item refusal line in `importcheck.go`, and the agent brief's opening sentence in `internal/output/agent.go`. The issue scopes itself to JSON values, and renaming these would rewrite prose and its tests well outside that. The new comment on the `Kind` field says so explicitly, so the next reader does not take the rule as wider than it is. ## How tested `make test` and `make lint` both green. `TestRunProjectEditRefusesTodoReference` asserts the new `kind` and message and carries a comment saying why. The built binary was run against the live Things database to confirm the payload it prints.
This was referenced Sep 10, 2026
ryanlewis
added a commit
that referenced
this pull request
Sep 10, 2026
Closes #241 Closes #215 ## What changed `Status` and `TaskType` each carried their own name map, `String`, `MarshalJSON` and `UnmarshalJSON`, written piece for piece the same way. They now share one generic `enumCodec[T ~int]` in `internal/model`. The name map stays the single source of truth and the decode direction is derived from it at construction, so the two directions cannot drift. Behaviour is unchanged for both types, down to the error text. `start` was the last enum the CLI shipped as a bare integer. It is now a named `model.Start` on `Task` and on `Project`, using the same codec, so it renders `inbox`, `anytime` or `someday` in JSON and still accepts the legacy integer on input. The ad hoc `switch` in `whenText` is gone: the agent brief and JSON now take their list names from one place. ## Breaking change `start` in every JSON output goes from an integer to a string. 0.8.0 already carries the same kind of change for `type`, so this belongs in the same release note. | Things code | v0.7.0 and earlier | 0.8.0 | Means | | --- | --- | --- | --- | | 0 | `"start": 0` | `"start": "inbox"` | Inbox | | 1 | `"start": 1` | `"start": "anytime"` | Anytime, or Today when it carries a date | | 2 | `"start": 2` | `"start": "someday"` | Someday, or Upcoming when it carries a date | A filter matching on the integer has to be updated: `jq '.[] | select(.start==2)'` becomes `jq '.[] | select(.start=="someday")'`. An unrecognised raw code is still emitted as its integer rather than collapsing to a lossy `"unknown"`, so a value the CLI does not know round-trips. `UnmarshalJSON` accepts the name or the legacy integer, and treats a JSON null as a no-op, matching `type` and `status`. The field appears on to-do rows and, since #204, on `things projects` rows, so both surfaces change together. ## The `startBucket` decision `startBucket` stays an integer, and the reason is in the data rather than in caution. Every row in the live database holds `0` or `1`, and `1` only ever appears on a dated `anytime` to-do: it is the Evening split within a scheduled day, the app's This Evening section. Only one of those two values has a name. Things' own vocabulary has `evening`, which the CLI already exposes as `--when evening`, and it has no word at all for the other side. Naming the pair would have meant inventing a token for `0` and then asserting it on every row that is not an evening row, in a public contract that would take another breaking change to undo. `0` says nothing, which is what it means. The field now carries a doc comment on `model.Task` saying exactly this. If it ever does want names, the shared codec makes it a five-line change. ## Verification Beyond the existing suites, which stay untouched and green: - Plain text output is byte-identical to `origin/main` across twenty-one surfaces, checked by diffing the two binaries against the live database: nine list views with and without `--include-completed`, plus `projects`, `areas` and `tags`. - The agent brief is byte-identical across thirty-nine real to-dos, covering the dated, `anytime` and `someday` paths through `whenText`. - The JSON diff between the two binaries touches exactly one field, `start`, on every list view and on `projects`. Nothing else moved. - `TestRunJSONRendersStartAsString` asserts against raw JSON rather than an unmarshalled `model.Task`, the way #214 did for `type`: `Start.UnmarshalJSON` still accepts the legacy integer, so decoding would keep passing even if the encoder regressed. A regex fails the test if a bare number reaches the `start` field. - The guards were mutation-tested. Marshalling `Start` as a raw int fails all six subtests of the new raw-JSON test; changing the codec's fallback name fails nine tests across three packages; wiring the `Start` codec with another type's name fails the new `TestEnumCodecErrorsNameTheirOwnType`, which exists because nothing else would notice that mistake. `TestStatusString` and the zero-value round-trip case that the #214 review noted were missing are both added, which is the rest of #215. ## One deliberate behaviour change beyond `start` `whenText` used to fall through to `"anytime"` for any `start` it did not recognise, and now renders `"unknown"`, matching how `type` and `status` render an unknown code. `internal/db` coalesces a NULL `start` to `0` and Things only ever writes `0`, `1` or `2`, so this is unreachable against a real database; `"unknown"` is the more honest answer if it ever were reached. ## Review `/code-review --fix` at high effort raised two points, both low. One was an ambiguous "both of these" in `commands.md` sitting two paragraphs after a sentence naming three fields, which could have led a reader to rewrite a working `.status=="open"` filter as part of the migration; that is fixed, and the sentence now names `type` and `start`. The other was a comment on `whenText` claiming the brief and the JSON field "cannot drift apart", which overstates it: for an unrecognised code JSON keeps the integer while the brief says `"unknown"`. The comment now says so. The review separately cleared the scan path (`database/sql` handles a named `int` through the same reflection path `Status` already relied on), confirmed no non-test code outside `internal/model` and `internal/output` reads either `Start` field, confirmed `"start"` is emitted from exactly one place so there is no second encoder to keep in step, and confirmed the on-disk cache stores UUIDs only, so there is no stale JSON to migrate. ## Not touched `README.md` shows no JSON `start` value anywhere, and documents neither `status` nor `type` as string enums, so there was nothing in it to update. It stays the short version and points at the site. The `t.start = 0/1/2` literals in `internal/db/tasks.go` are left alone: that file belongs to #240, and #238 is in it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #208
What changed
typein JSON output is now the stringtodo,projectorheadinginstead of Things' raw TMTask integer.statusalready rendered as a string, so the two fields were in two different styles on the same object; they now share one.The codec is
model.TaskType, a named int that mirrorsmodel.Status: atypeNamesmap as the single source of truth forString,MarshalJSONandUnmarshalJSON; an unrecognized raw code preserved as its integer rather than collapsing to a lossy"unknown";UnmarshalJSONaccepting the name or the legacy integer, and treating a JSON null as a no-op.Task.Typechanges frominttomodel.TaskType, so comparisons againstmodel.TypeTask/model.TypeProject/model.TypeHeadingcompile unchanged.Every JSON surface picks this up without its own change, because they all marshal
model.Taskthrough the single encoder ininternal/output.Plain text is unchanged, including the
(project)marker on a project row and theType: projectline in a project's detail block.model.Projectstill has notypefield.One non-obvious knock-on:
notHeadingininternal/db/tasks.gobuilds a SQL fragment withfmt.Sprintf("... != %d", model.TypeHeading). That constant is now afmt.Stringer, so%vor%swould splice the wordheadinginto the SQL where2belongs. The constant is wrapped inint()with a comment, so the fragment no longer depends on the format verb.Why
A bare integer is an internal Things code. It reads badly for a human and forces an agent or a jq filter to carry a lookup table that appears nowhere in the output.
How tested
make testandmake lintboth clean, on top of #206.TestTaskTypeMarshalJSON,TestTaskTypeUnmarshalJSON,TestTaskTypeRoundTripJSONandTestTaskTypeStringlock the three wire names, the integer fallback, the null no-op and the rejection of unknown names.TestRunJSONRendersTypeAsStringwalks the JSON surfaces end to end and asserts the raw JSON text, not an unmarshalledmodel.Task. That distinction matters:UnmarshalJSONstill accepts the legacy integer, so a decode-and-compare test would keep passing even if the encoder regressed to emitting ints. It also fails on anytypefield holding a bare number, matched by regex so it is independent of the encoder's indentation and catches an unmapped code rather than only 0, 1 and 2.MarshalJSONto emit the raw int fails them.list todayreturnsVf6GPsvdnJYqTdD5f41H3AastodoandX28bim63xxvGvWLncbfd7fasproject;showreturns the same two individually;searchreturnsBEFn6McBh2DFfU577KBaq7asproject;logbookreturnsL16wNJD3D84AEJwr14QWniasprojectacross 1190 rows.somedaycurrently holds no project, so onlytodoappears there.things projectscarries notypefield at all, across 23 rows.Breaking change
"type": 0"type": "todo""type": 1"type": "project""type": 2"type": "heading"Any caller matching on
.type==1has to become.type=="project".Docs updated in
internal/skill/SKILL.md,docs/content/commands.md,docs/content/agents.mdandREADME.md, including the workedjqexample inagents.mdthat filtered onselect(.type==0). Each page describing JSON output carries a note naming the old integers, anchored to v0.7.0 as the last release that emitted them.Two things the docs now say that they did not before, both found in review:
typerides on task rows only, and headings are never returned by any command, so"heading"never actually appears in output. It exists in the codec because the codec has to be total over the three codes the database uses.things importpayload takes. That format is Things' own JSON URL scheme, which spells a to-do"to-do". An agent that copies.typefrom a listing into an import item gets an item Things drops silently, sincevalidateImportJSONonly checks array shape.For whoever cuts the release
The release body is read from
.github/releases/<Tag>.mdand.github/release.ymlhas no breaking-change category, so afeat!commit files under "Features" with no compatibility warning. Thestatusbreak in 8656bd8 hit the same gap. The release note wants the table above, and a reminder to runthings skill installso agents pick up the new SKILL.md.Note for the open MCP branches
#98 and #99 serialise the same
model.Taskand will need a rebase onto this. They pick up the string automatically; what needs checking is any assertion or tool schema in those branches that describestypeas an integer.