Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

- **CHANGE (breaking, ALPHA) — the `today` view dissolves its Today/This-Evening `sections` into one flat `items[]`, and its count moves to `meta.counts`.** First of five PRs implementing the ratified read-shape doctrine (`docs/design/read-shape-doctrine.md`): JSON models semantic state, the TTY projects GUI placement. The Today-proper / This-Evening split is GUI placement recoverable per row from `when`, so it is no longer a wire bucket:
- **`data` shape (breaking).** `today`'s `data` is now `{ items }` — one flat list of Today members in comparator order (Today-proper and This-Evening interleaved by their SQL position), each row carrying its `when` (`"today"` / `"evening"`) and `provisional`; `stage` is still dropped (every Today member is stage-pure `anytime`, R13). The former `data.sections` (`[{key:"today"},{key:"evening"}]`) is gone — read `data.items` and split on `when === "evening"`. The `when` field, previously section-dropped, is KEPT on every today row.
- **Count → `meta.counts` (breaking).** The former `data.badge {dueOrOverdue, other}` moves to `meta.counts {dueOrOverdue, other}` (the app's sidebar count over OPEN members: due/overdue by deadline vs. the rest), so `data` stays pure domain rows. Over MCP the today count rides the result's metadata block alongside `truncation`.
- **"badge" vocabulary purged.** The word "badge" is eliminated from all consumer-facing surfaces (JSON keys, TTY, `--help`, MCP descriptions, contract) per the read-shape doctrine, and added to the banned-vocabulary regression lists (`docs/design/surface-copy.md` rule 6; `test/cli/help-contract.test.ts`, `test/mcp/server.test.ts`). Evidence docs describing the app's GUI badge stay as they are.
- **TTY redesign.** The Today header is now clean (`── ★ Today ──`); the counts render at the TOP as card-style metadata lines (` due/overdue: N` / ` other: M`, the `project show` / `area show` header-metadata convention). This Evening is still re-projected below (GUI-faithful), and its honesty hint under truncation is preserved — `meta.truncation.sections` survives as completeness metadata for the two TTY render sections (derived per row from `when`), not a `data` bucket.
- `--evening`, `--area`, `--overdue`, tag filters, and per-call `tz` are unchanged in behavior. (`src/read/views.ts`, `src/read/truncation.ts`, `src/read/shape.ts`, `src/read/area-filter.ts`, `src/read/scope.ts`, `src/cli/read-driver.ts`, `src/cli/render.ts`, `src/cli/commands/reads.ts`, `src/mcp/server.ts`, `src/contracts.ts`, `schema/envelope.schema.json`.) Docs: `docs/design/read-shape-doctrine.md` (finalized/ratified), `docs/contract.md`, `docs/design/contracts.md`, `docs/design/surface-copy.md`. No new `apiVersion`; no new error code.

- **CHANGE (breaking, ALPHA) — the `project show` heading model becomes GUI-faithful: a heading GROUP node drops `type`, replaces `status` with a presence-keyed `archived`, and swept archived headings group under the logged region.** Implements the HEADARC/HEADARC2 macOS logged-section fidelity spec (`docs/lab/headarc-archived-heading-moves.md`, `docs/lab/headarc2-residual-captures.md`). Five parts:
- **Heading node shape (breaking).** A project-view heading GROUP node (`headings[].heading`, `logbookHeadings[].heading`) no longer carries `type` (its slot always states heading — the "absent `type` = to-do" convention is scoped to ROWS/candidates, not this keyed sub-object) nor `status`. It emits a presence-keyed **`archived`** — the ISO archive timestamp (the `stopDate`, same emission convention as a row's `stopped`) — present iff the heading is archived, absent when open. A heading's lifecycle is archive/unarchive ONLY (never completed/canceled — oddity §169), so the node carries neither `status` nor `stage`; `archived` carries both the archive boolean and its timestamp.
- **Trinary live/logged split (the completion≠logged law applied to headings).** The live `headings` array now holds OPEN headings AND archived-but-UNSWEPT headings (archived, not yet past the logbook boundary — the same `log-boundary.ts` predicate the sibling to-do rows use); such a heading keeps its place among the live groups and carries `archived`. Only a SWEPT archived heading moves into the logged region as a grouped section. WHERE a heading renders expresses the sweep axis — no third field, mirroring how a to-do expresses logged-ness by bucket membership.
Expand Down
13 changes: 7 additions & 6 deletions docs/contract.md

Large diffs are not rendered by default.

21 changes: 13 additions & 8 deletions docs/design/contracts.md

Large diffs are not rendered by default.

252 changes: 252 additions & 0 deletions docs/design/read-shape-doctrine.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/design/surface-copy.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The **JSON envelope** carries `vector`, `tier`, and verification as **structured
- **Genuine behavioral trade-offs.** When two ways of expressing an intent produce different *results*, the difference is real API surface: "moving a to-do to the Inbox removes its schedule", "Evening reorders handle at most 10 items". Keep these; they are behavior, not implementation.
- **Onboarding and environment.** Permission prompts, one-time app settings ("Enable Things URLs"), and setup steps are real side effects of *adopting* the API. They belong in `doctor`, setup docs, and server instructions.
5. **Shared vocabulary is shared code.** The exact wording for recurring parameter formats (`when` values, date/reminder formats, "uuid or unique name") lives in `src/surface-copy.ts` and is used verbatim by the CLI option help and the MCP schemas, so vocabulary learned on one surface transfers to the others. Prose is NOT shared: each surface phrases its own descriptions in its own format for its own reader (a paragraph for an MCP model, a one-liner plus flags for a terminal, type-adjacent notes for JSDoc).
6. **No GUI-chrome vocabulary (read-shape doctrine).** Describe the DATA and its semantic state, never how the desktop/mobile app renders it. Banned from consumer copy: *badge* (the today counts are self-explanatory data on `meta.counts`, not "the sidebar badge"). This applies to JSON keys, TTY output, `--help`/topic copy, MCP tool and schema descriptions, the skill, and the contract. It does NOT apply to evidence docs (`docs/lab/`, `docs/things-app-oddities.md`) that describe the app's actual GUI banner/badge as probed behavior — that is evidence, not surface copy. Enforced by the `/\bbadge\b/i` entry in the banned-vocabulary regression lists (`test/cli/help-contract.test.ts`, `test/mcp/server.test.ts`). See [read-shape-doctrine.md](read-shape-doctrine.md).

## Enforcement

Expand Down
14 changes: 14 additions & 0 deletions schema/envelope.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,20 @@
"additionalProperties": false,
"description": "The active content filter this response was scoped to (ADDITIVE). Present ONLY when a scope was applied — currently `area` (the `--area` view filter), carrying the resolved area's uuid + title; ABSENT otherwise, so the wire shape is unchanged for unscoped reads (the `meta.clock` precedent)."
},
"counts": {
"type": "object",
"properties": {
"dueOrOverdue": {
"type": "number"
},
"other": {
"type": "number"
}
},
"required": ["dueOrOverdue", "other"],
"additionalProperties": false,
"description": "Whole-view aggregate counts (ADDITIVE). Present ONLY on the `today` view: the app's sidebar count split — `dueOrOverdue` (open members whose deadline is due or overdue) vs. `other` (the rest). A convenience aggregate an agent would otherwise recompute over the rows; it lives here so `data` stays pure domain rows. Both counts are OPEN members only, and a `0` is meaningful."
},
"scope": {
"type": "object",
"properties": {
Expand Down
4 changes: 2 additions & 2 deletions skills/things-cli/references/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ Entities, relationships, and how the sidebar views are computed over them, as ex

Reads decompose an item's position onto two derived, presence-keyed words (they REPLACED the old `start`/`startDate`/`logged`/`trashed`/`todaySection` wire fields, which no longer appear):

- **`stage`** — the sidebar BUCKET: `inbox | upcoming | anytime | someday | logbook | trash`. Read view membership off it directly. It is dropped inside a section/catalogue that already states it (the stage-pure `inbox`/`anytime`/`someday`/`logbook`/`trash` lists and the `today` view's sections) and kept everywhere it is not implied (the mixed `upcoming` catalogue, `search`, `changes`, the projects/areas listings, and `detail`).
- **`when`** — the TIME POSITION: `today | evening | a future ISO date`, or absent (unscheduled and not in Today). `evening` implies today. Someday is a bucket, never a `when`.
- **`stage`** — the sidebar BUCKET: `inbox | upcoming | anytime | someday | logbook | trash`. Read view membership off it directly. It is dropped inside a section/catalogue that already states it (the stage-pure `inbox`/`anytime`/`someday`/`logbook`/`trash` lists and the `today` view) and kept everywhere it is not implied (the mixed `upcoming` catalogue, `search`, `changes`, the projects/areas listings, and `detail`).
- **`when`** — the TIME POSITION: `today | evening | a future ISO date`, or absent (unscheduled and not in Today). `evening` implies today. Someday is a bucket, never a `when`. The `today` view is one flat `data.items` list (no `sections`); each row's `when` marks Today-proper vs This-Evening, and the whole-view count rides `meta.counts` (`{dueOrOverdue, other}`).
- The two are DIFFERENT facts. A due deadline pulls an UNDATED row into Today: it reads `when: "today"` and derives `stage: "anytime"` — the app re-files a deadline-pulled Inbox/Someday row into Anytime at pull time (R13/BANNER1b), so it drops out of the Inbox/Someday lists and joins Anytime while its `when` reads `today`. Completed/canceled → `stage: "logbook"`, trashed → `stage: "trash"`, regardless of any other hint.
- `provisional: true` marks a Today member the app has not yet materialized — see [banner.md](banner.md).

Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export function registerReadCommands(program: Command): void {
program
.command("today")
.description(
"The Today list, split into Today and This Evening (evening expires daily), with the sidebar badge split (red = deadline due/overdue)",
"The Today listToday and This Evening (evening expires daily). Counts (due/overdue vs. other) ride meta.counts",
)
.option("--tag <ref>", TAG_DESC, collectRef, [])
.option("--exact-tag", EXACT_TAG_DESC)
Expand Down Expand Up @@ -241,6 +241,7 @@ export function registerReadCommands(program: Command): void {
return {
data: view,
truncation,
counts: view.counts,
...(areaFilter !== undefined && { filter: areaFilter }),
...(warnings !== undefined && { warnings }),
lines: renderToday(view, truncation.sections, base, { eveningOnly }),
Expand Down
33 changes: 16 additions & 17 deletions src/cli/read-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
ThingsDbOpenError,
type EnvelopeMeta,
type ThingsClient,
type TodayView,
type Truncation,
type ViewFilterMeta,
} from "../index.ts";
Expand All @@ -41,6 +40,7 @@ import {
*/
const ITEMS_WRAPPER_KINDS: ReadonlySet<string> = new Set([
"inbox",
"today",
"upcoming",
"logbook",
"trash",
Expand All @@ -53,26 +53,17 @@ const ITEMS_WRAPPER_KINDS: ReadonlySet<string> = new Set([

/**
* Shape a read payload into its envelope `data` object (the 1.0 contract, R1/R2):
* `data` is always an object. Flat lists become `{ items }`; the sectioned
* catalogues (anytime/someday) become `{ sections }`; `today` becomes
* `{ sections: [{key,items}…], badge }`; the composite cards become `{ view }`;
* a single-entity detail becomes `{ item }`. Everything already object-shaped
* (open/snapshot/…) passes through. The human-render path keeps the raw inner
* payload — this transform is the JSON emit boundary only.
* `data` is always an object. Flat lists (incl. `today`, one flat `items[]` of
* Today members) become `{ items }`; the sectioned catalogues (anytime/someday)
* become `{ sections }`; the composite cards become `{ view }`; a single-entity
* detail becomes `{ item }`. Everything already object-shaped (open/snapshot/…)
* passes through. The human-render path keeps the raw inner payload — this
* transform is the JSON emit boundary only. `today`'s whole-view `counts`
* aggregate rides `meta.counts`, not `data` (runRead), so `data` stays pure rows.
*/
export function wrapEnvelopeData(kind: string, data: unknown): unknown {
if (ITEMS_WRAPPER_KINDS.has(kind)) return { items: data };
if (kind === "anytime" || kind === "someday") return { sections: data };
if (kind === "today") {
const view = data as TodayView;
return {
sections: [
{ key: "today", items: view.today },
{ key: "evening", items: view.evening },
],
badge: view.badge,
};
}
if (kind === "area-view" || kind === "project-view") return { view: data };
if (kind === "detail") return { item: data };
return data;
Expand Down Expand Up @@ -137,6 +128,12 @@ export interface PagedResult<T> {
kind?: string;
/** Active content filter (the `--area` scope) — carried into `meta.filter`. */
filter?: ViewFilterMeta;
/**
* Whole-view aggregate counts — the today view's due/overdue vs. other split
* (the app's sidebar count). Carried into `meta.counts`; absent for views that
* have no such aggregate.
*/
counts?: { dueOrOverdue: number; other: number };
/**
* Additional non-blocking advisories from the read itself (ADDITIVE), merged
* with the schema-drift warnings into `meta.warnings` (and echoed once on
Expand Down Expand Up @@ -191,6 +188,7 @@ export function runRead<T>(
truncation,
kind: kindOverride,
filter,
counts,
warnings: readWarnings,
lines: precomputed,
} = fn(client);
Expand All @@ -217,6 +215,7 @@ export function runRead<T>(
...(warnings.length > 0 && { warnings }),
...(clock !== undefined && { clock }),
...(filter !== undefined && { filter }),
...(counts !== undefined && { counts }),
...(scope !== undefined && { scope }),
};
// Human output gets the note once on STDERR (never mixed into the piped
Expand Down
64 changes: 42 additions & 22 deletions src/cli/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,13 +449,23 @@ export function renderList(items: ListItem[]): string[] {
const provisionalRowOpts = (i: ListItem): FormatOpts =>
entityProvisional(i) ? { mark: provisionalPip() } : {};

/** A today row's This-Evening membership — the SAME `when` axis the wire carries. */
const isEveningRow = (i: ListItem): boolean => entityWhen(i) === "evening";

/**
* The `things today` split. The membership glyph lives in the SECTION HEADER,
* not on every row — a yellow ★ in the Today header (which also carries the
* sidebar badge split) and a blue ⏾ in the This Evening header — so the rows
* drop the redundant per-item marker (the same convention that suppresses a
* `(project)` context inside that project's own view). Every OTHER view keeps
* the per-row ★/⏾, where the marker still carries information.
* The `things today` view. The wire is one flat `items[]` of Today members; the
* TTY re-projects the GUI's two render sections from each row's `when` — a
* Today-proper block (`when: "today"`) and a This-Evening block
* (`when: "evening"`). The whole-view `counts` aggregate (the app's sidebar
* count) renders at the TOP as card-style metadata lines (indented `key: value`,
* the same convention `things project show` / `things area show` use), so the
* section header itself stays clean (`── ★ Today ──`).
*
* The membership glyph lives in the SECTION HEADER, not on every row — a yellow ★
* in the Today header and a blue ⏾ in the This Evening header — so the rows drop
* the redundant per-item marker (the same convention that suppresses a
* `(project)` context inside that project's own view). Every OTHER view keeps the
* per-row ★/⏾, where the marker still carries information.
*
* A PROVISIONAL Today member (the wire's presence-keyed `provisional`, BANNER1
* law — a Today entrant the app has not yet materialized, banner-counted until
Expand Down Expand Up @@ -488,30 +498,40 @@ export function renderToday(
base: string,
options?: { eveningOnly?: boolean },
): string[] {
// Pre-cap totals from the truncation metadata; fall back to the shown view's
// The two render sections are re-projected from each row's `when` (the SAME
// axis the wire carries), so the TTY split can never disagree with the JSON.
const todayRows = view.items.filter((i) => !isEveningRow(i));
const eveningRows = view.items.filter(isEveningRow);
// Pre-cap totals from the truncation metadata; fall back to the shown rows'
// own lengths when a caller hands an unbounded view with no section counts.
const todayTotal = sections?.find((s) => s.key === "today")?.total ?? view.today.length;
const eveningTotal = sections?.find((s) => s.key === "evening")?.total ?? view.evening.length;
const w = uuidDisplayWidth([...view.today, ...view.evening]);
const todayTotal = sections?.find((s) => s.key === "today")?.total ?? todayRows.length;
const eveningTotal = sections?.find((s) => s.key === "evening")?.total ?? eveningRows.length;
const w = uuidDisplayWidth(view.items);
const eveningOnly = options?.eveningOnly === true;
const lines: string[] = eveningOnly
? []
: [
`${bold("──")} ${todayStar()} ${bold(`Today (badge: ${view.badge.dueOrOverdue} due/overdue · ${view.badge.other} other) ──`)}`,
...(view.today.length === 0
? ["(empty)"]
: view.today.map((i) => formatItem(i, w, provisionalRowOpts(i)))),
];
// The whole-view counts, at the TOP as card-style metadata lines (the
// project/area-show header convention). A `0` is meaningful, so both always show.
const lines: string[] = [
` ${dim("due/overdue:")} ${view.counts.dueOrOverdue}`,
` ${dim("other:")} ${view.counts.other}`,
];
if (!eveningOnly) {
lines.push(
`${bold("──")} ${todayStar()} ${bold("Today ──")}`,
...(todayRows.length === 0
? ["(empty)"]
: todayRows.map((i) => formatItem(i, w, provisionalRowOpts(i)))),
);
}
if (eveningTotal > 0) {
// A blank line before the header matches every other grouped renderer's
// section spacing — but only when the Today section rendered above it. In
// --evening mode the header is the first line, so no leading blank.
// --evening mode the header follows the count lines directly, no blank.
if (!eveningOnly) lines.push("");
lines.push(`${bold("──")} ${eveningMoon()} ${bold("This Evening ──")}`);
for (const i of view.evening) lines.push(formatItem(i, w, provisionalRowOpts(i)));
const hidden = eveningTotal - view.evening.length;
for (const i of eveningRows) lines.push(formatItem(i, w, provisionalRowOpts(i)));
const hidden = eveningTotal - eveningRows.length;
if (hidden > 0) {
const more = view.evening.length > 0 ? "more " : "";
const more = eveningRows.length > 0 ? "more " : "";
const count = `${hidden} ${more}evening item${hidden === 1 ? "" : "s"}`;
// Normal Today view: the global truncation footer already carries the
// quantity levers (a bigger --limit / --all), so this hint is a pure
Expand Down
Loading
Loading