things-api 0.9.0 → 0.10.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 +30 -11
- package/dist/audit/log.js +56 -2
- package/dist/audit/log.js.map +1 -1
- package/dist/audit/schema.d.ts +26 -8
- package/dist/audit/schema.js +42 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/commands/area.d.ts +26 -3
- package/dist/cli/commands/area.js +125 -70
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +44 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/mcp.js +23 -4
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +18 -1
- package/dist/cli/commands/project.js +69 -31
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.js +423 -157
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/repeat-flags.d.ts +20 -0
- package/dist/cli/commands/repeat-flags.js +76 -0
- package/dist/cli/commands/repeat-flags.js.map +1 -0
- package/dist/cli/commands/setup.js +2 -3
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +47 -24
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.d.ts +1 -1
- package/dist/cli/commands/todo.js +7 -6
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +378 -148
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +1 -1
- package/dist/cli/excess-args.d.ts +15 -0
- package/dist/cli/excess-args.js +51 -0
- package/dist/cli/excess-args.js.map +1 -0
- package/dist/cli/glyphs.d.ts +39 -2
- package/dist/cli/glyphs.js +91 -21
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +54 -0
- package/dist/cli/help.js +355 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/main.js +32 -25
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/period.d.ts +7 -0
- package/dist/cli/period.js +12 -0
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.d.ts +26 -19
- package/dist/cli/read-driver.js +67 -33
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +92 -28
- package/dist/cli/render.js +318 -97
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +42 -4
- package/dist/cli/resolve-invocation.js +97 -14
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/tag-filters.d.ts +59 -0
- package/dist/cli/tag-filters.js +57 -0
- package/dist/cli/tag-filters.js.map +1 -0
- package/dist/cli/verb-hint.d.ts +25 -0
- package/dist/cli/verb-hint.js +138 -0
- package/dist/cli/verb-hint.js.map +1 -0
- package/dist/cli/width.d.ts +135 -0
- package/dist/cli/width.js +313 -0
- package/dist/cli/width.js.map +1 -0
- package/dist/client.d.ts +137 -24
- package/dist/client.js +105 -16
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +87 -17
- package/dist/contracts.js +44 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/fingerprint.d.ts +12 -0
- package/dist/db/fingerprint.js +15 -1
- package/dist/db/fingerprint.js.map +1 -1
- package/dist/db/locate.js +1 -1
- package/dist/db/locate.js.map +1 -1
- package/dist/diagnose.d.ts +63 -0
- package/dist/diagnose.js +38 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +30 -4
- package/dist/index.js +35 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -1
- package/dist/mcp/server.js +691 -133
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +31 -7
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +7 -0
- package/dist/model/mappers.js +7 -2
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/serialize.d.ts +28 -0
- package/dist/model/serialize.js +80 -0
- package/dist/model/serialize.js.map +1 -0
- package/dist/model/when-sugar.d.ts +47 -0
- package/dist/model/when-sugar.js +45 -0
- package/dist/model/when-sugar.js.map +1 -0
- package/dist/read/area-view.d.ts +8 -1
- package/dist/read/area-view.js +49 -13
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/filter-contract.d.ts +117 -0
- package/dist/read/filter-contract.js +78 -0
- package/dist/read/filter-contract.js.map +1 -0
- package/dist/read/predicates.d.ts +30 -0
- package/dist/read/predicates.js +30 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +12 -1
- package/dist/read/project-view.js +39 -8
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/queries.d.ts +149 -6
- package/dist/read/queries.js +250 -31
- package/dist/read/queries.js.map +1 -1
- package/dist/read/sections.d.ts +51 -0
- package/dist/read/sections.js +37 -0
- package/dist/read/sections.js.map +1 -0
- package/dist/read/sidebar-order.js +2 -1
- package/dist/read/sidebar-order.js.map +1 -1
- package/dist/read/tags.d.ts +27 -3
- package/dist/read/tags.js +83 -12
- package/dist/read/tags.js.map +1 -1
- package/dist/read/truncation.d.ts +71 -0
- package/dist/read/{pagination.js → truncation.js} +103 -94
- package/dist/read/truncation.js.map +1 -0
- package/dist/read/views.d.ts +140 -16
- package/dist/read/views.js +264 -67
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +19 -0
- package/dist/surface-copy.js +32 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/sync-health.d.ts +78 -0
- package/dist/sync-health.js +312 -0
- package/dist/sync-health.js.map +1 -0
- package/dist/write/accessibility-probe.d.ts +12 -0
- package/dist/write/accessibility-probe.js +63 -0
- package/dist/write/accessibility-probe.js.map +1 -0
- package/dist/write/automation-probe.d.ts +7 -0
- package/dist/write/automation-probe.js +8 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/batch.js +4 -2
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +8 -0
- package/dist/write/capabilities.js +13 -6
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/commands.d.ts +2 -0
- package/dist/write/commands.js +562 -48
- package/dist/write/commands.js.map +1 -1
- package/dist/write/edit-checklist.js +3 -2
- package/dist/write/edit-checklist.js.map +1 -1
- package/dist/write/guards.d.ts +2 -7
- package/dist/write/guards.js +64 -6
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.js +2 -0
- package/dist/write/heading.js.map +1 -1
- package/dist/write/lock.d.ts +40 -2
- package/dist/write/lock.js +91 -14
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +4 -0
- package/dist/write/make-repeating-project.js +253 -0
- package/dist/write/make-repeating-project.js.map +1 -0
- package/dist/write/operations.d.ts +145 -1
- package/dist/write/operations.js +48 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.d.ts +19 -0
- package/dist/write/pipeline.js +109 -26
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +68 -3
- package/dist/write/pre-state.js +102 -3
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/reopen.js +3 -2
- package/dist/write/reopen.js.map +1 -1
- package/dist/write/reorder.js +27 -17
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-rule.d.ts +22 -0
- package/dist/write/repeat-rule.js +258 -0
- package/dist/write/repeat-rule.js.map +1 -0
- package/dist/write/reversibility.js +49 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/tag-refs.d.ts +47 -0
- package/dist/write/tag-refs.js +126 -0
- package/dist/write/tag-refs.js.map +1 -0
- package/dist/write/undo.d.ts +74 -5
- package/dist/write/undo.js +493 -80
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +17 -1
- package/dist/write/vectors/registry.js +18 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/types.d.ts +119 -3
- package/dist/write/vectors/ui-certification.d.ts +48 -0
- package/dist/write/vectors/ui-certification.js +46 -0
- package/dist/write/vectors/ui-certification.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +188 -0
- package/dist/write/vectors/ui-drag.js +1095 -0
- package/dist/write/vectors/ui-drag.js.map +1 -0
- package/dist/write/vectors/ui-recipes.d.ts +70 -0
- package/dist/write/vectors/ui-recipes.js +558 -0
- package/dist/write/vectors/ui-recipes.js.map +1 -0
- package/dist/write/vectors/ui.d.ts +137 -0
- package/dist/write/vectors/ui.js +656 -0
- package/dist/write/vectors/ui.js.map +1 -0
- package/dist/write/verify/delta.d.ts +31 -3
- package/dist/write/verify/delta.js +22 -6
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.js +1 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +1 -1
- package/dist/cli/exit-codes.d.ts +0 -26
- package/dist/cli/exit-codes.js +0 -26
- package/dist/cli/exit-codes.js.map +0 -1
- package/dist/cli/output.d.ts +0 -42
- package/dist/cli/output.js +0 -16
- package/dist/cli/output.js.map +0 -1
- package/dist/read/pagination.d.ts +0 -104
- package/dist/read/pagination.js.map +0 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A typed TypeScript library + CLI (`things`) for programmatic interaction with [Things 3](https://culturedcode.com/things/) by Cultured Code.
|
|
4
4
|
|
|
5
|
-
**Status: read + write + MCP layers live and published to npm (v0.
|
|
5
|
+
**Status: read + write + MCP layers live and published to npm (v0.10.0 — see [CHANGELOG.md](CHANGELOG.md)).** Reads go straight to the local SQLite database (UI-exact Today ordering, sidebar-grouped Anytime/Someday with container-status cascade, decoded repeat rules, occurrence projections); writes run a verified pipeline over four write vectors — two lab-validated headless surfaces (the URL scheme + AppleScript) and two opt-in surfaces (Apple Shortcuts and an Accessibility-driven GUI vector) — with hazard guards, disruption-tier policy, a JSONL audit trail, batch mode, audit-replay undo (transactional across compound operations), full project lifecycle (complete/cancel/reopen/restore), heading rename/archive/unarchive with children policies, container detach, granular stateful checklists, tag hierarchy management incl. un-nesting, completion/creation backdating, Logbook imports, tiered fuzzy name resolution (uuid prefixes, `things:///show?id=` share links), and ordering across eight scopes (incl. a project's headings and the top-level sidebar projects). The Apple Shortcuts surface is wired for the two headless capabilities nothing else can do: creating a heading in an existing project (`things heading add`) and clearing a reminder from a date-scheduled item (`things todo clear-reminder`). Both run through bundled proxy shortcuts (`things setup shortcuts`) and are gated on their presence. The fourth vector — the Accessibility-driven GUI vector — is off by default and drives the local Things app to reach transforms that exist on no scriptable surface (repeat-rule editing on existing to-dos and projects, convert-to-project, sidebar area reorder); see [Accessibility GUI vector](#accessibility-gui-vector-optional-off-by-default) below. Single-item permanent delete stays interactive-only (its macOS consent has no always-allow) and is out of the headless pipeline. See [docs/design/](docs/design/) for the architecture and VM-lab design, [docs/lab/](docs/lab/harness.md) for the probe harness and campaign results the write layer is grounded in, and [docs/atlas/](docs/atlas/schema-v26.md) for the database↔UI map.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
things today --json # read: your Today list, Evening split, UI order
|
|
@@ -15,7 +15,7 @@ things capabilities --op todo.delete # what's possible, per vector, with evide
|
|
|
15
15
|
|
|
16
16
|
## Requirements & first-run setup
|
|
17
17
|
|
|
18
|
-
Things 3 installed and launched once, Node ≥ 24, and a handful of one-time macOS consents / Things settings depending on what you use (file-access consent for reads; "Enable Things URLs" + Automation consents for writes). **See [docs/setup.md](docs/setup.md)** — including the dedicated-automation-Mac checklist. `things doctor` validates your setup and prints remediation for anything missing.
|
|
18
|
+
Things 3 installed and launched once, Node ≥ 24, and a handful of one-time macOS consents / Things settings depending on what you use (file-access consent for reads; "Enable Things URLs" + Automation consents for writes; an Accessibility grant for the optional GUI vector). **See [docs/setup.md](docs/setup.md)** — including the dedicated-automation-Mac checklist. `things doctor` validates your setup and prints remediation for anything missing.
|
|
19
19
|
|
|
20
20
|
### Shortcuts setup (optional)
|
|
21
21
|
|
|
@@ -29,6 +29,14 @@ On each shortcut's first run macOS asks for permission — choose **Always Allow
|
|
|
29
29
|
|
|
30
30
|
Once installed, these Shortcuts-only operations become available as ordinary commands: `things heading add <project> <title>` (create a heading in an existing project) and `things todo clear-reminder <uuid>` (clear a date-scheduled to-do's reminder while keeping its date). Both are also exposed over MCP (`create_heading`, `clear_reminder`). If a required shortcut is missing, the command is blocked up front with a pointer back to `things setup shortcuts` — nothing is dispatched.
|
|
31
31
|
|
|
32
|
+
### Accessibility GUI vector (optional, off by default)
|
|
33
|
+
|
|
34
|
+
A handful of transforms exist on **no scriptable surface at all** — the URL scheme, AppleScript, and Shortcuts cannot express them. The fourth write vector (the "ui" vector) reaches them by driving the local Things app's real interface through macOS Accessibility. It unlocks: making an existing to-do or project repeat and rescheduling/pausing/resuming its rule; converting a to-do into a project; and moving an area to a new position in the global area order (the sidebar). See [docs/design/ui-vector.md](docs/design/ui-vector.md) for the full model.
|
|
35
|
+
|
|
36
|
+
Because it drives the live GUI it is **fail-closed and two-key gated**: enable it once with `things config set ui-enabled true`, then acknowledge each individual call with `--dangerously-drive-gui` (`dangerously_drive_gui` over MCP). It carries the `H-UI-DRIVE` hazard, sits at the top disruption tier (3), and is intended for a dedicated, always-on Mac pinned to the English app language (a non-English UI fails the vector closed). Its recipes are **fragile** — an app-layout change can break them — so each op carries a per-op certification status (`uncertified` → `lab-certified` → on-device `certified`) recorded in the manifest at [src/write/vectors/ui-certification.ts](src/write/vectors/ui-certification.ts) and surfaced by `things capabilities` and the `things doctor` ui-vector section; a successful drive of a not-yet-`certified` op returns a note saying so.
|
|
37
|
+
|
|
38
|
+
Setup — granting Accessibility to the driving process and verifying it with `things doctor --probe-accessibility` — is in [docs/setup.md](docs/setup.md).
|
|
39
|
+
|
|
32
40
|
### Development install
|
|
33
41
|
|
|
34
42
|
To get a global `things` command that runs the live TypeScript source (no build step — Node ≥ 24 strips types natively):
|
|
@@ -42,23 +50,23 @@ Edits under `src/` take effect immediately. The bin launcher ([bin/things.js](bi
|
|
|
42
50
|
|
|
43
51
|
## Core principles
|
|
44
52
|
|
|
45
|
-
- **Reads** go directly to Things' local SQLite database (read-only, WAL-aware). **Writes** go exclusively through official app surfaces — URL scheme, AppleScript, Shortcuts — never direct DB writes (sync corruption).
|
|
53
|
+
- **Reads** go directly to Things' local SQLite database (read-only, WAL-aware). **Writes** go exclusively through official app surfaces — URL scheme, AppleScript, Shortcuts, and (opt-in) the Accessibility-driven GUI — never direct DB writes (sync corruption).
|
|
46
54
|
- **Every mutation is verified**: pre-read → hazard guards → execute → poll re-read until the expected delta appears. Silent no-ops are failures.
|
|
47
55
|
- **Every mutation is audited**: JSONL trail (`~/.local/state/things-api/audit/`) with requested vs. observed deltas; auth tokens structurally redacted.
|
|
48
|
-
- **Schema drift is detected**: table/column fingerprints keyed by Things' database version; writes hard-block on mismatch ([drift runbook](docs/lab/drift-runbook.md)).
|
|
49
|
-
- **Disruption is explicit**: every operation×vector combination carries a disruption tier (0 = invisible → 3 =
|
|
56
|
+
- **Schema drift is detected**: table/column fingerprints keyed by Things' database version; writes hard-block on mismatch, and reads surface a non-blocking `meta.warnings` note rather than failing ([drift runbook](docs/lab/drift-runbook.md)).
|
|
57
|
+
- **Disruption is explicit**: every operation×vector combination carries a disruption tier (0 = invisible → 3 = drives the live UI); disruptive operations require explicit opt-in flags.
|
|
50
58
|
- **Nothing is developed against production data**: probing and integration tests run in disposable Tart macOS VMs.
|
|
51
59
|
|
|
52
60
|
## For agents
|
|
53
61
|
|
|
54
62
|
The CLI is designed to be driven by coding agents with no out-of-band knowledge. The contract:
|
|
55
63
|
|
|
56
|
-
1. **Discovery**: `things --help` (
|
|
57
|
-
2. **Structured output**: every command takes `--json` → versioned envelope `{ apiVersion, ok, kind, data|error, meta }` on stdout; human chatter goes to stderr only. List views are **bounded by default**: the flat/chronological views (`inbox`, `today`, `upcoming`, `logbook`, `trash`, `search`, `changes`) return at most 50 items — raise with `--limit <n>` or lift with `--all` — and carry exact truncation counts in `meta.
|
|
58
|
-
3. **Stable exit codes**: `0` ok · `2` usage · `3` verify-failed (mutation executed, expected delta never appeared) · `4` blocked (hazard guard or disruption policy; error carries `remediation`) · `5` drift-blocked · `6` unsupported · `7` environment.
|
|
64
|
+
1. **Discovery**: `things --help` (a grouped one-line-per-command index; orientation detail lives behind `things help <topic>` — `agent`, `filters`, `ids`, `output`, `writes`), per-command `things <command> --help` (behavior, side effects, and the exact acknowledgement flag names a write needs — regression-tested as API; by design it does **not** carry vector/tier/hazard vocabulary, which is banned from help text by [docs/design/surface-copy.md](docs/design/surface-copy.md) — that classification lives in `capabilities`), and `things capabilities [--op <op>] --json` (the lab-validated operation × vector support matrix with disruption tiers, hazards, per-op certification status, and probe-evidence ids). Command invocation follows one grammar (`things <view>` · `things <type> <verb> <subject>` · loose `things <verb> <subject>` · bare `things <subject>`) with a single precedence chain — registered command/alias → view keyword → reference resolution — specified in [docs/design/cli-grammar.md](docs/design/cli-grammar.md).
|
|
65
|
+
2. **Structured output**: every command takes `--json` → versioned envelope `{ apiVersion, ok, kind, data|error, meta }` on stdout; human chatter goes to stderr only. List views are **bounded by default**: the flat/chronological views (`inbox`, `today`, `upcoming`, `logbook`, `trash`, `search`, `changes`) return at most 50 items — raise with `--limit <n>` or lift with `--all` — and carry exact truncation counts in `meta.truncation { shown, total, limit, truncated }` (the split `today` view also breaks the counts down per render section under `sections`). The grouped catalogues (`anytime`, `someday`) always show every area and project row and cap per block instead (no `--limit`): `--area-limit <n>` (default 30) per area block on both, `--project-limit <n>` (default 3) per project block on anytime, `--show-active-project-items [n]` for someday's trailing active-projects section — reporting `meta.grouped { truncated, blocks[] }`, where each block is identity-carrying (`kind`, `ref`, `title`, `shown`, `total`) and project blocks nest inside their area block under `children`. A read whose database schema no longer matches this build's validated fingerprint carries a non-blocking `meta.warnings` note (the read still returns best-effort; the same drift hard-blocks writes). Same defaults and metadata apply over MCP.
|
|
66
|
+
3. **Stable exit codes**: `0` ok · `2` usage · `3` verify-failed (mutation executed, expected delta never appeared) · `4` blocked (hazard guard or disruption policy; error carries `remediation`) · `5` drift-blocked · `6` unsupported (op has no supported vector — `things batch` also aggregates to 6 when its only failures are unsupported ops) · `7` environment.
|
|
59
67
|
4. **Plan before executing**: every write supports `--dry-run` — compiled invocation (token-redacted), chosen vector, tier, hazards checked, expected delta. Nothing runs, nothing is audited.
|
|
60
|
-
5. **No prompts, ever**: risky semantics are explicit flags — `--children require-resolved|auto-complete` (project completion cascades), `--acknowledge-checklist-reset` (checklist replacement destroys per-item state), `--acknowledge-project-reopen` (open child reopens a resolved project), `--dangerously-permanent` (area/tag delete and empty-trash skip the Trash).
|
|
61
|
-
6. **Experimental surfaces are opt-in**: `things reorder` (ordering within Today, the Inbox, Someday, a project's to-dos, a project's headings, or an area) rides an undocumented AppleScript command that any Things update may remove. It requires `things config set allow-experimental true` and re-checks the app's sdef declaration before every dispatch; `things doctor` reports both gates. Two scopes never touch it: This Evening and the top-level sidebar projects use verified `when=` round-trips (the "bounce", ≤10 items) instead.
|
|
68
|
+
5. **No prompts, ever**: risky semantics are explicit flags — `--children require-resolved|auto-complete` (project completion cascades), `--acknowledge-checklist-reset` (checklist replacement destroys per-item state), `--acknowledge-project-reopen` (open child reopens a resolved project), `--dangerously-permanent` (area/tag delete and empty-trash skip the Trash), `--dangerously-drive-gui` (each Accessibility GUI-vector call).
|
|
69
|
+
6. **Experimental surfaces are opt-in**: `things reorder` (ordering within Today, the Inbox, Someday, a project's to-dos, a project's headings, or an area) rides an undocumented AppleScript command that any Things update may remove. It requires `things config set allow-experimental true` and re-checks the app's sdef declaration before every dispatch; `things doctor` reports both gates. Two scopes never touch it: This Evening and the top-level sidebar projects use verified `when=` round-trips (the "bounce", ≤10 items) instead. Reordering the areas themselves (the sidebar order) is a separate operation on the Accessibility GUI vector (`things area reorder`, MCP `reorder_area`).
|
|
62
70
|
|
|
63
71
|
A typical mutation flow:
|
|
64
72
|
|
|
@@ -87,4 +95,15 @@ The TypeScript library (`import { openThings } from "things-api"`) is the produc
|
|
|
87
95
|
{ "mcpServers": { "things": { "command": "things", "args": ["mcp"] } } }
|
|
88
96
|
```
|
|
89
97
|
|
|
90
|
-
The server instructions carry the user's live inventory (areas, tag hierarchy, open projects — read at server start) plus the reference/scheduling vocabulary, so models can name real destinations without a discovery round-trip. Tools mirror the client surface
|
|
98
|
+
The server instructions carry the user's live inventory (areas, tag hierarchy, open projects — read at server start) plus the reference/scheduling vocabulary, so models can name real destinations without a discovery round-trip. Tools mirror the client surface, grouped by area:
|
|
99
|
+
|
|
100
|
+
- **Reads**: `read_view` (today / inbox / anytime / upcoming with occurrence horizon / someday / logbook / trash), `search`, `changes_since`, `get_item`, `get_project`, `get_area`, `list_collections`.
|
|
101
|
+
- **To-do writes**: `add_todo`, `update_todo`, `set_todo_status`, `move_todo`, `set_tags`, `edit_checklist`, plus completion/creation backdating (`backdate_todo`) and Logbook import (`add_logged_todo`).
|
|
102
|
+
- **Project writes**: `add_project`, `update_project`, `set_project_status`, `move_project`.
|
|
103
|
+
- **To-dos and projects alike**: `delete_item`, `restore_item`, `duplicate_item`.
|
|
104
|
+
- **Areas & tags**: `add_area`, `update_area`, `delete_area`, `add_tag`, `update_tag`, `delete_tag` (tag CRUD incl. un-nesting and guarded subtree deletion).
|
|
105
|
+
- **Headings & reminders** (Shortcuts-backed where headless-impossible): `create_heading`, `rename_heading`, `archive_heading`, `unarchive_heading`, `clear_reminder`.
|
|
106
|
+
- **Accessibility GUI ("ui") vector** (two-key gated — `ui-enabled` config + `dangerously_drive_gui` per call): `make_repeating`, `reschedule_repeat`, `set_repeat_state`, `convert_to_project`, `make_project_repeating`, `create_repeating_project`, `reschedule_project_repeat`, `set_project_repeat_state`, `reorder_area`.
|
|
107
|
+
- **Generic & discovery**: `run_operation` (the full 49-op catalog), `batch`, `reorder`, `undo`, `capabilities`, `doctor`.
|
|
108
|
+
|
|
109
|
+
Every write tool takes `dry_run`; the ui-vector tools additionally require `dangerously_drive_gui`; tools carry read-only/destructive annotations; hazard blocks come back as structured tool errors carrying the same remediation text the CLI prints. Tool descriptions follow the consumer-voice contract in [docs/design/surface-copy.md](docs/design/surface-copy.md).
|
package/dist/audit/log.js
CHANGED
|
@@ -2,9 +2,63 @@
|
|
|
2
2
|
* JSONL audit writer: monthly files, never auto-deleted, structural token
|
|
3
3
|
* redaction — the serializer refuses to emit any string containing the
|
|
4
4
|
* loaded auth token.
|
|
5
|
+
*
|
|
6
|
+
* DURABILITY + TEAR-RESISTANCE (M5). Audit appends happen from processes that
|
|
7
|
+
* do NOT hold the mutation lock (e.g. a drift-gate or lock-contention block is
|
|
8
|
+
* recorded before/without ever acquiring it), so two things-api invocations can
|
|
9
|
+
* append to the same monthly file concurrently. The append path is therefore:
|
|
10
|
+
*
|
|
11
|
+
* open(path, O_APPEND) → ONE writeSync(fd, completeLineBuffer) → fsyncSync → close
|
|
12
|
+
*
|
|
13
|
+
* Two properties make this safe for our use in practice, on regular files:
|
|
14
|
+
* - ATOMIC APPEND. With O_APPEND the kernel positions the write at end-of-file
|
|
15
|
+
* and performs the append under the inode lock, so a SINGLE write() call
|
|
16
|
+
* cannot interleave with a concurrent write() from another fd — no torn or
|
|
17
|
+
* spliced line. We hand writeSync the COMPLETE `${line}\n` buffer so exactly
|
|
18
|
+
* one write() syscall carries the whole record. (The old appendFileSync path
|
|
19
|
+
* gave neither a single-write guarantee nor a flush.)
|
|
20
|
+
* - DURABILITY. fsyncSync flushes the record to disk before we return, so a
|
|
21
|
+
* crash immediately after the append cannot lose an already-acknowledged
|
|
22
|
+
* record — the pairing invariant the M3 intent record relies on holds.
|
|
23
|
+
*
|
|
24
|
+
* No lockfile is used, deliberately: for regular local files a single
|
|
25
|
+
* O_APPEND write() is already interleave-safe, our records are small and writes
|
|
26
|
+
* infrequent, and a lockfile would add a failure mode (stale locks, contention)
|
|
27
|
+
* into a path that must NEVER throw into the mutation result. A short write from
|
|
28
|
+
* write() only occurs on signals/full disks/pipes — not local regular-file
|
|
29
|
+
* appends of records this size — so the "one writeSync = one record" assumption
|
|
30
|
+
* holds; the >1MB round-trip test exercises the large-buffer case.
|
|
31
|
+
*
|
|
32
|
+
* NEVER THROWS INTO THE MUTATION PATH. If the durable path fails for any reason
|
|
33
|
+
* (fsync unsupported on an exotic FS, transient open error, …) we fall back to a
|
|
34
|
+
* best-effort plain append, and if THAT throws we swallow it: a mutation's
|
|
35
|
+
* result must never break because auditing hit an I/O error.
|
|
5
36
|
*/
|
|
6
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
37
|
+
import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, writeSync } from "node:fs";
|
|
7
38
|
import { join } from "node:path";
|
|
39
|
+
/** Durable single-write append (O_APPEND + fsync), best-effort fallback on failure. */
|
|
40
|
+
function durableAppend(path, buf) {
|
|
41
|
+
try {
|
|
42
|
+
const fd = openSync(path, "a"); // "a" → O_APPEND
|
|
43
|
+
try {
|
|
44
|
+
writeSync(fd, buf); // one write() with the complete line — atomic append
|
|
45
|
+
fsyncSync(fd); // flush to disk before returning
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
closeSync(fd);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Best-effort fallback: try a plain append, then give up silently. An audit
|
|
53
|
+
// write must never throw into the mutation path that called it.
|
|
54
|
+
try {
|
|
55
|
+
appendFileSync(path, buf);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* swallow — a mutation result must never break because auditing failed */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
8
62
|
export function createAuditWriter(options) {
|
|
9
63
|
const secrets = options.secrets.filter((s) => s.length > 0);
|
|
10
64
|
return {
|
|
@@ -24,7 +78,7 @@ export function createAuditWriter(options) {
|
|
|
24
78
|
}
|
|
25
79
|
return value;
|
|
26
80
|
});
|
|
27
|
-
|
|
81
|
+
durableAppend(join(options.dir, `${month}.jsonl`), Buffer.from(`${line}\n`, "utf8"));
|
|
28
82
|
},
|
|
29
83
|
};
|
|
30
84
|
}
|
package/dist/audit/log.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"log.js","sourceRoot":"","sources":["../../src/audit/log.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"log.js","sourceRoot":"","sources":["../../src/audit/log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAC/F,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAQjC,uFAAuF;AACvF,SAAS,aAAa,CAAC,IAAY,EAAE,GAAW;IAC9C,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,QAAQ,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,iBAAiB;QACjD,IAAI,CAAC;YACH,SAAS,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC,qDAAqD;YACzE,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,iCAAiC;QAClD,CAAC;gBAAS,CAAC;YACT,SAAS,CAAC,EAAE,CAAC,CAAC;QAChB,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,4EAA4E;QAC5E,gEAAgE;QAChE,IAAI,CAAC;YACH,cAAc,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAC5B,CAAC;QAAC,MAAM,CAAC;YACP,0EAA0E;QAC5E,CAAC;IACH,CAAC;AACH,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,OAKjC;IACC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC5D,OAAO;QACL,MAAM,CAAC,MAAmB;YACxB,IAAI,CAAC,OAAO,CAAC,OAAO;gBAAE,OAAO;YAC7B,SAAS,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YAC5C,MAAM,KAAK,GAAG,MAAM,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,UAAU;YAC/C,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;gBAClD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;oBAC9B,IAAI,GAAG,GAAG,KAAK,CAAC;oBAChB,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;wBAC7B,OAAO,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC;4BAAE,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;oBACrE,CAAC;oBACD,OAAO,GAAG,CAAC;gBACb,CAAC;gBACD,OAAO,KAAK,CAAC;YACf,CAAC,CAAC,CAAC;YACH,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,KAAK,QAAQ,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,IAAI,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;QACvF,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/dist/audit/schema.d.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Audit record v1 — one JSON object per line in
|
|
3
|
-
* ~/.local/state/things-api/audit/YYYY-MM.jsonl (see design §5).
|
|
4
|
-
*
|
|
5
|
-
* Every mutation ATTEMPT is recorded: successes, verification failures, and
|
|
6
|
-
* blocked decisions (with invocation null — the app was never touched).
|
|
7
|
-
*/
|
|
8
1
|
export interface AuditRecord {
|
|
9
2
|
v: 1;
|
|
10
3
|
ts: string;
|
|
@@ -26,13 +19,20 @@ export interface AuditRecord {
|
|
|
26
19
|
id: string;
|
|
27
20
|
role: "leg" | "summary";
|
|
28
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Undo back-reference (ADDITIVE): the undo token of the ORIGINAL mutation
|
|
24
|
+
* this record inverts. Set only on inverse mutations (`undo:<actor>` records)
|
|
25
|
+
* so a later `things undo --txn <token>` can tell an already-undone mutation
|
|
26
|
+
* apart from a nonexistent one. Absent on ordinary (non-undo) mutations.
|
|
27
|
+
*/
|
|
28
|
+
undoOf?: string;
|
|
29
29
|
/** Normalized requested delta (params as given, post-normalization). */
|
|
30
30
|
requested: Record<string, unknown>;
|
|
31
31
|
/** Asserted-field subset of the pre-state (null when target didn't exist). */
|
|
32
32
|
pre: Record<string, unknown> | null;
|
|
33
33
|
/** Post-verify observation (best-effort on failure). */
|
|
34
34
|
observed: Record<string, unknown> | null;
|
|
35
|
-
result: "ok" | "verify-failed:timeout" | "verify-failed:mismatch" | "verify-failed:silent-noop" | `blocked:${string}` | "unsupported";
|
|
35
|
+
result: "intent" | "ok" | "verify-failed:timeout" | "verify-failed:mismatch" | "verify-failed:silent-noop" | `blocked:${string}` | "unsupported";
|
|
36
36
|
verify: {
|
|
37
37
|
attempts: number;
|
|
38
38
|
elapsedMs: number;
|
|
@@ -44,3 +44,21 @@ export interface AuditRecord {
|
|
|
44
44
|
fingerprint: "ok" | "drift" | "user-accepted" | "unknown";
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The stable UNDO TOKEN for a mutation — the value a caller passes to
|
|
49
|
+
* `things undo --txn <token>` (MCP `txn`) to invert exactly THIS record,
|
|
50
|
+
* immune to interleaving. Two cases:
|
|
51
|
+
*
|
|
52
|
+
* - A compound operation's SUMMARY record already carries a real transaction
|
|
53
|
+
* id shared by its legs — that id IS the token (undoing it replays the
|
|
54
|
+
* whole sequence as one unit).
|
|
55
|
+
* - A single-op record has no such id, so we derive a content-addressed one
|
|
56
|
+
* from the fields that identify the record on disk (start timestamp + op +
|
|
57
|
+
* actor + host + target uuid). The mutation lock serializes writes, so those
|
|
58
|
+
* fields are unique per record in practice; the token is deterministic, so
|
|
59
|
+
* the write path (which returns it in the result) and the undo path (which
|
|
60
|
+
* recomputes it while scanning the trail) always agree.
|
|
61
|
+
*
|
|
62
|
+
* Purely additive: it is DERIVED from persisted fields, never stored.
|
|
63
|
+
*/
|
|
64
|
+
export declare function undoToken(record: Pick<AuditRecord, "ts" | "op" | "actor" | "host" | "uuid" | "txn">): string;
|
package/dist/audit/schema.js
CHANGED
|
@@ -4,6 +4,47 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Every mutation ATTEMPT is recorded: successes, verification failures, and
|
|
6
6
|
* blocked decisions (with invocation null — the app was never touched).
|
|
7
|
+
*
|
|
8
|
+
* A successful mutation writes TWO records: an `intent` marker immediately
|
|
9
|
+
* before the app is touched (M3 durability — so a crash between the app-side
|
|
10
|
+
* mutation and the final record leaves evidence the change may have landed),
|
|
11
|
+
* then the final `ok`/`verify-failed:*` record after read-after-write. The two
|
|
12
|
+
* share ts+op+actor+host (both derive from the same startedAt); an intent with
|
|
13
|
+
* no later final sibling is the signature of a crashed write. Intent records
|
|
14
|
+
* are NEVER undo targets — every undo reader filters `result === "ok"`, which
|
|
15
|
+
* an intent (result `"intent"`) is not, so it is excluded uniformly.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
/**
|
|
19
|
+
* The stable UNDO TOKEN for a mutation — the value a caller passes to
|
|
20
|
+
* `things undo --txn <token>` (MCP `txn`) to invert exactly THIS record,
|
|
21
|
+
* immune to interleaving. Two cases:
|
|
22
|
+
*
|
|
23
|
+
* - A compound operation's SUMMARY record already carries a real transaction
|
|
24
|
+
* id shared by its legs — that id IS the token (undoing it replays the
|
|
25
|
+
* whole sequence as one unit).
|
|
26
|
+
* - A single-op record has no such id, so we derive a content-addressed one
|
|
27
|
+
* from the fields that identify the record on disk (start timestamp + op +
|
|
28
|
+
* actor + host + target uuid). The mutation lock serializes writes, so those
|
|
29
|
+
* fields are unique per record in practice; the token is deterministic, so
|
|
30
|
+
* the write path (which returns it in the result) and the undo path (which
|
|
31
|
+
* recomputes it while scanning the trail) always agree.
|
|
32
|
+
*
|
|
33
|
+
* Purely additive: it is DERIVED from persisted fields, never stored.
|
|
7
34
|
*/
|
|
8
|
-
export {
|
|
35
|
+
export function undoToken(record) {
|
|
36
|
+
if (record.txn?.role === "summary")
|
|
37
|
+
return record.txn.id;
|
|
38
|
+
// JSON-encode the identity tuple so field boundaries are unambiguous (no
|
|
39
|
+
// separator an actor/host string could forge) while staying plain text.
|
|
40
|
+
const identity = JSON.stringify([
|
|
41
|
+
record.ts,
|
|
42
|
+
record.op,
|
|
43
|
+
record.actor,
|
|
44
|
+
record.host,
|
|
45
|
+
record.uuid ?? "",
|
|
46
|
+
]);
|
|
47
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
|
|
48
|
+
return `m-${digest}`;
|
|
49
|
+
}
|
|
9
50
|
//# sourceMappingURL=schema.js.map
|
package/dist/audit/schema.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/audit/schema.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/audit/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAkDzC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,SAAS,CACvB,MAA0E;IAE1E,IAAI,MAAM,CAAC,GAAG,EAAE,IAAI,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;IACzD,yEAAyE;IACzE,wEAAwE;IACxE,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,MAAM,CAAC,EAAE;QACT,MAAM,CAAC,EAAE;QACT,MAAM,CAAC,KAAK;QACZ,MAAM,CAAC,IAAI;QACX,MAAM,CAAC,IAAI,IAAI,EAAE;KAClB,CAAC,CAAC;IACH,MAAM,MAAM,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAChF,OAAO,KAAK,MAAM,EAAE,CAAC;AACvB,CAAC"}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* under `things area` are registered by writes.ts on the same group).
|
|
4
4
|
*/
|
|
5
5
|
import type { Command } from "commander";
|
|
6
|
-
import type
|
|
7
|
-
import { type
|
|
6
|
+
import { type AreaView, type GroupedLimits, type GroupedTruncation } from "../../index.ts";
|
|
7
|
+
import { type TagFlags } from "../tag-filters.ts";
|
|
8
8
|
export interface AreaShowOpts {
|
|
9
9
|
showLater?: boolean;
|
|
10
10
|
/** Commander optional-value flag: true when bare, the raw string when given a count. */
|
|
@@ -24,6 +24,29 @@ export interface AreaShowOpts {
|
|
|
24
24
|
* (future-scheduled projects, to-dos, and repeating templates intermixed in
|
|
25
25
|
* date order) and Someday (someday projects as a leading block, then
|
|
26
26
|
* someday to-dos). `--show-logged` reveals the full logbook.
|
|
27
|
+
*
|
|
28
|
+
* `view` is the already-bounded card (its ACTIVE project rows and direct to-dos
|
|
29
|
+
* capped; scheduled/someday project rows and the later/logged/trashed sections
|
|
30
|
+
* intact) and `grouped` the per-block metadata carrying each capped section's
|
|
31
|
+
* pre-cap total — so the "… N more" footers derive from metadata, never a
|
|
32
|
+
* pre-cap copy of the view.
|
|
27
33
|
*/
|
|
28
|
-
export declare function renderAreaView(view: AreaView, opts: AreaShowOpts): string[];
|
|
34
|
+
export declare function renderAreaView(view: AreaView, grouped: GroupedTruncation, opts: AreaShowOpts): string[];
|
|
35
|
+
/** Options accepted by the area-show code path (shared by `area show` and `areas <ref>`). */
|
|
36
|
+
export type AreaShowActionOpts = AreaShowOpts & TagFlags & {
|
|
37
|
+
json?: boolean;
|
|
38
|
+
db?: string;
|
|
39
|
+
limit?: string;
|
|
40
|
+
areaLimit?: string;
|
|
41
|
+
projectLimit?: string;
|
|
42
|
+
all?: boolean;
|
|
43
|
+
/** Content scope: keep only rows (loose to-dos + child projects) with an overdue own deadline. */
|
|
44
|
+
overdue?: boolean;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The `area show <ref>` action body, factored out so the pluralized
|
|
48
|
+
* `things areas <ref>` can delegate to the identical code path (a true synonym,
|
|
49
|
+
* not a reimplementation). Both echo the canonical `things area show …` hint.
|
|
50
|
+
*/
|
|
51
|
+
export declare function runAreaShow(ref: string, opts: AreaShowActionOpts): void;
|
|
29
52
|
export declare function registerAreaCommands(program: Command): void;
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { ExitCode } from "../../contracts.js";
|
|
2
|
-
import { localToday } from "../../model/dates.js";
|
|
3
1
|
import { bold, dim, green } from "../style.js";
|
|
4
2
|
import { areaMark, thingsLink } from "../glyphs.js";
|
|
5
3
|
import { Option } from "commander";
|
|
6
|
-
import { capAreaSections } from "../../read/pagination.js";
|
|
7
4
|
import { openInThings } from "./reads.js";
|
|
8
|
-
import { invocation, parseCap, runRead, shellQuote, withClient } from "../read-driver.js";
|
|
9
|
-
import { formatItem, uuidDisplayWidth } from "../render.js";
|
|
5
|
+
import { invocation, parseCap, runRead, shellQuote, usageError, withClient, } from "../read-driver.js";
|
|
6
|
+
import { disclosureHint, formatItem, quoteTitle, uuidDisplayWidth } from "../render.js";
|
|
10
7
|
import { DidYouMeanError } from "../did-you-mean.js";
|
|
11
8
|
import { showToggleFlags } from "./project.js";
|
|
12
|
-
import { AREA_PREVIEW_LIMIT, GROUPED_ALL_DESC } from "../../
|
|
9
|
+
import { AREA_PREVIEW_LIMIT, GROUPED_ALL_DESC, isActiveProjectRow, isScheduledProjectRow, isSomedayProjectRow, localToday, } from "../../index.js";
|
|
10
|
+
import { addTagFilterOptions, CONTAINER_TAG_HINT, tagFilterFields, tagFlagConflict, tagInvocationParts, } from "../tag-filters.js";
|
|
13
11
|
/** Bare `--show-logged` shows this many recent entries (areas accumulate thousands). */
|
|
14
12
|
const RECENT_LOGGED_DEFAULT = 15;
|
|
15
13
|
function loggedCount(showLogged) {
|
|
@@ -26,22 +24,28 @@ function loggedCount(showLogged) {
|
|
|
26
24
|
* (future-scheduled projects, to-dos, and repeating templates intermixed in
|
|
27
25
|
* date order) and Someday (someday projects as a leading block, then
|
|
28
26
|
* someday to-dos). `--show-logged` reveals the full logbook.
|
|
27
|
+
*
|
|
28
|
+
* `view` is the already-bounded card (its ACTIVE project rows and direct to-dos
|
|
29
|
+
* capped; scheduled/someday project rows and the later/logged/trashed sections
|
|
30
|
+
* intact) and `grouped` the per-block metadata carrying each capped section's
|
|
31
|
+
* pre-cap total — so the "… N more" footers derive from metadata, never a
|
|
32
|
+
* pre-cap copy of the view.
|
|
29
33
|
*/
|
|
30
|
-
export function renderAreaView(view, opts) {
|
|
34
|
+
export function renderAreaView(view, grouped, opts) {
|
|
31
35
|
const todayIso = localToday();
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const activeProjects = view.projects.filter((p) => !isSomedayProject(p) && !isScheduledProject(p));
|
|
37
|
-
const somedayProjects = view.projects.filter(isSomedayProject);
|
|
36
|
+
// The card's ACTIVE project rows are already capped in `view`; scheduled and
|
|
37
|
+
// someday rows always survive and route to the Upcoming/Someday sections.
|
|
38
|
+
const activeProjects = view.projects.filter((p) => isActiveProjectRow(p, todayIso));
|
|
39
|
+
const somedayProjects = view.projects.filter(isSomedayProjectRow);
|
|
38
40
|
// Upcoming intermixes scheduled projects, scheduled to-dos, and repeating
|
|
39
41
|
// templates in date order (templates sort by their next occurrence).
|
|
40
42
|
const upcoming = [
|
|
41
|
-
...view.projects
|
|
43
|
+
...view.projects
|
|
44
|
+
.filter((p) => isScheduledProjectRow(p, todayIso))
|
|
45
|
+
.map((p) => ({ date: p.startDate ?? "", item: p })),
|
|
42
46
|
...view.later.scheduled.flatMap((d) => d.items.map((t) => ({ date: d.date, item: t }))),
|
|
43
47
|
...view.later.repeating.map((t) => ({ date: t.repeating.nextOccurrence ?? "9999", item: t })),
|
|
44
|
-
].
|
|
48
|
+
].toSorted((a, b) => a.date.localeCompare(b.date));
|
|
45
49
|
const logged = view.logged.slice(0, loggedCount(opts.showLogged));
|
|
46
50
|
const shown = [
|
|
47
51
|
...activeProjects,
|
|
@@ -55,18 +59,32 @@ export function renderAreaView(view, opts) {
|
|
|
55
59
|
// Per-section caps (this view's sections are containers, so there is no
|
|
56
60
|
// strict total limit): the project-ROWS block and the direct-to-dos block
|
|
57
61
|
// truncate independently, each with its own exact-count footer. The card
|
|
58
|
-
// preamble and the toggled later/logged sections are never capped
|
|
62
|
+
// preamble and the toggled later/logged sections are never capped. The pre-cap
|
|
63
|
+
// totals come from the metadata; `limits` supplies the footer's doubling.
|
|
59
64
|
const limits = opts.limits ?? { area: null, project: null };
|
|
60
|
-
const
|
|
61
|
-
const
|
|
65
|
+
const projectsBlock = grouped.blocks.find((b) => b.kind === "projects");
|
|
66
|
+
const activeBlock = grouped.blocks.find((b) => b.kind === "area");
|
|
67
|
+
const hiddenProjects = projectsBlock ? projectsBlock.total - projectsBlock.shown : 0;
|
|
68
|
+
const hiddenActive = activeBlock ? activeBlock.total - activeBlock.shown : 0;
|
|
69
|
+
// The user's invocation, echoed by every disclosure hint (falls back to a
|
|
70
|
+
// canonical typed command when a caller omits it, e.g. a direct unit test).
|
|
71
|
+
const base = opts.hintBase ?? `things area show ${quoteTitle(view.area.title)}`;
|
|
72
|
+
// A per-block TRUNCATION FOOTER (indented two spaces under its partially-
|
|
73
|
+
// shown block): ` … N more <noun>s — `<base> <flag> <bigger>``.
|
|
62
74
|
const sectionMore = (hidden, noun, flag, cap) => {
|
|
63
75
|
if (hidden <= 0 || opts.hintBase === undefined || cap === null)
|
|
64
76
|
return;
|
|
65
|
-
lines.push(
|
|
77
|
+
lines.push(disclosureHint(hidden, `more ${noun}`, [{ command: `${opts.hintBase} ${flag} ${cap * 2}` }], {
|
|
78
|
+
indent: true,
|
|
79
|
+
}));
|
|
66
80
|
};
|
|
67
|
-
// Rows inside this view never repeat the area's own name.
|
|
81
|
+
// Rows inside this view never repeat the area's own name. The area's top
|
|
82
|
+
// projects are plain ROWS here (not group headings — they don't head a to-do
|
|
83
|
+
// group in this view), so they get the bold project title from delta 1 but NO
|
|
84
|
+
// underline; only ANYTIME treats projects as headings. So a project renders
|
|
85
|
+
// exactly like any other row here — no projectTitle opt.
|
|
68
86
|
const fmt = (i) => formatItem(i, w, { suppressArea: view.area.uuid });
|
|
69
|
-
const fmtProject =
|
|
87
|
+
const fmtProject = fmt;
|
|
70
88
|
// Card header: glyph + name, the GUI's share link (carries the uuid — it
|
|
71
89
|
// pastes back into any ref argument), then labeled meta lines. The opened
|
|
72
90
|
// resource shows its tags green (GUI: list pills are gray).
|
|
@@ -80,10 +98,10 @@ export function renderAreaView(view, opts) {
|
|
|
80
98
|
if (rows.length > 0)
|
|
81
99
|
lines.push("", ...rows);
|
|
82
100
|
};
|
|
83
|
-
block(
|
|
84
|
-
sectionMore(
|
|
85
|
-
block(
|
|
86
|
-
sectionMore(
|
|
101
|
+
block(activeProjects.map(fmtProject));
|
|
102
|
+
sectionMore(hiddenProjects, "project", "--project-limit", limits.project);
|
|
103
|
+
block(view.active.map(fmt));
|
|
104
|
+
sectionMore(hiddenActive, "to-do", "--area-limit", limits.area);
|
|
87
105
|
if (activeProjects.length === 0 && view.active.length === 0)
|
|
88
106
|
lines.push("", "(no active items)");
|
|
89
107
|
if (opts.showLater === true) {
|
|
@@ -99,77 +117,114 @@ export function renderAreaView(view, opts) {
|
|
|
99
117
|
}
|
|
100
118
|
}
|
|
101
119
|
}
|
|
102
|
-
// Default-hidden rows are never silent — a
|
|
120
|
+
// Default-hidden rows are never silent — a HIDDEN-SECTION placeholder (flush,
|
|
121
|
+
// full command) stands where the Upcoming/Someday sections would render.
|
|
103
122
|
if (opts.showLater !== true) {
|
|
104
123
|
const hiddenLater = upcoming.length + somedayProjects.length + view.later.someday.length;
|
|
105
124
|
if (hiddenLater > 0)
|
|
106
|
-
lines.push("",
|
|
125
|
+
lines.push("", disclosureHint(hiddenLater, "later item", [{ command: `${base} --show-later` }]));
|
|
107
126
|
}
|
|
127
|
+
// The full archive belongs to `things logbook --area <ref>` — echoed
|
|
128
|
+
// ready-to-paste with the real area name, like every other drill hint.
|
|
129
|
+
const logbookCmd = `things logbook --area ${quoteTitle(view.area.title)}`;
|
|
108
130
|
if (logged.length > 0) {
|
|
109
|
-
// Truncation is loud: areas accumulate years of history
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
131
|
+
// Truncation is loud: areas accumulate years of history. The header keeps
|
|
132
|
+
// the shown-of-total count for orientation; the actionable drill rides a
|
|
133
|
+
// section footer (matching the other truncated sections), never the header.
|
|
134
|
+
const more = view.logged.length - logged.length;
|
|
135
|
+
const header = more > 0
|
|
136
|
+
? `── Logged (${logged.length} of ${view.logged.length}) ──`
|
|
113
137
|
: `── Logged (${view.logged.length}) ──`;
|
|
114
138
|
lines.push("", bold(header), ...logged.map(fmt));
|
|
139
|
+
if (more > 0)
|
|
140
|
+
lines.push(dim(`… ${more} more — \`${logbookCmd}\``));
|
|
115
141
|
}
|
|
116
142
|
else if (view.logged.length > 0) {
|
|
117
|
-
|
|
143
|
+
// Hidden-section placeholder: `--show-logged` reveals only the RECENT 15
|
|
144
|
+
// (areas accumulate years), so it is labeled; the logbook drill reads its
|
|
145
|
+
// own effect and needs none.
|
|
146
|
+
lines.push("", disclosureHint(view.logged.length, "logged item", [
|
|
147
|
+
{ label: "recent", command: `${base} --show-logged` },
|
|
148
|
+
{ command: logbookCmd },
|
|
149
|
+
]));
|
|
118
150
|
}
|
|
119
151
|
if (view.trashed.length)
|
|
120
152
|
lines.push("", bold(`── Trashed (${view.trashed.length}) ──`));
|
|
121
153
|
return lines;
|
|
122
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* The `area show <ref>` action body, factored out so the pluralized
|
|
157
|
+
* `things areas <ref>` can delegate to the identical code path (a true synonym,
|
|
158
|
+
* not a reimplementation). Both echo the canonical `things area show …` hint.
|
|
159
|
+
*/
|
|
160
|
+
export function runAreaShow(ref, opts) {
|
|
161
|
+
if (tagFlagConflict(opts))
|
|
162
|
+
return;
|
|
163
|
+
if (opts.limit !== undefined) {
|
|
164
|
+
usageError(opts, "--limit is not available on area show — cap sections with --area-limit / --project-limit, or pass --all");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const areaCap = parseCap("--area-limit", opts.areaLimit, AREA_PREVIEW_LIMIT, opts.all === true, opts.json === true);
|
|
168
|
+
if (!areaCap.ok)
|
|
169
|
+
return;
|
|
170
|
+
const projectCap = parseCap("--project-limit", opts.projectLimit, AREA_PREVIEW_LIMIT, opts.all === true, opts.json === true);
|
|
171
|
+
if (!projectCap.ok)
|
|
172
|
+
return;
|
|
173
|
+
const overdue = opts.overdue === true;
|
|
174
|
+
const tagFilter = tagFilterFields(opts);
|
|
175
|
+
const limits = { area: areaCap.limit, project: projectCap.limit };
|
|
176
|
+
const hintBase = invocation("area show", [
|
|
177
|
+
shellQuote(ref),
|
|
178
|
+
...showToggleFlags(opts),
|
|
179
|
+
overdue && "--overdue",
|
|
180
|
+
...tagInvocationParts(opts),
|
|
181
|
+
]);
|
|
182
|
+
runRead(opts, "area-view", (c) => {
|
|
183
|
+
let bounded;
|
|
184
|
+
try {
|
|
185
|
+
bounded = c.read.areaView(ref, {
|
|
186
|
+
overdue,
|
|
187
|
+
...tagFilter,
|
|
188
|
+
areaLimit: areaCap.limit,
|
|
189
|
+
projectLimit: projectCap.limit,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
// Not-found gets a type-scoped did-you-mean; ambiguity is verbatim.
|
|
194
|
+
if (err instanceof RangeError && !err.message.includes("ambiguous")) {
|
|
195
|
+
throw new DidYouMeanError(err.message, ref, c.read.liteTitleSearch(ref, { type: "area" }));
|
|
196
|
+
}
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
data: bounded.view,
|
|
201
|
+
grouped: bounded.grouped,
|
|
202
|
+
lines: renderAreaView(bounded.view, bounded.grouped, { ...opts, limits, hintBase }),
|
|
203
|
+
};
|
|
204
|
+
}, () => []);
|
|
205
|
+
}
|
|
123
206
|
export function registerAreaCommands(program) {
|
|
124
207
|
const area = program.command("area").description("Area-scoped operations");
|
|
125
|
-
area
|
|
208
|
+
const areaShow = area
|
|
126
209
|
.command("show <ref>")
|
|
127
210
|
.description("Composite area view mirroring the native UI: active projects first, then the " +
|
|
128
211
|
"area's direct to-dos. --show-later adds the Upcoming (date-ordered) and " +
|
|
129
|
-
"Someday sections; --show-logged adds the full logbook.
|
|
130
|
-
"
|
|
212
|
+
"Someday sections; --show-logged adds the full logbook. --tag / --untagged filter " +
|
|
213
|
+
"the rows by a tag carried directly on the row — tags inherited from this area are " +
|
|
214
|
+
"ignored (every row inherits them); no descent into project contents. Target by " +
|
|
215
|
+
"uuid or unique name.")
|
|
131
216
|
.option("--show-later", "include Upcoming and Someday sections")
|
|
132
217
|
.option("--show-logged [n]", "include the n most recently logged items (bare flag = 15; full history via `things logbook --area`)")
|
|
133
218
|
.option("--project-limit <n>", `maximum project rows to show (default ${AREA_PREVIEW_LIMIT})`)
|
|
134
219
|
.option("--area-limit <n>", `maximum direct to-dos to show (default ${AREA_PREVIEW_LIMIT})`)
|
|
220
|
+
.option("--overdue", "only rows whose own deadline is past (due today is not overdue)")
|
|
135
221
|
.option("--all", GROUPED_ALL_DESC)
|
|
136
222
|
.addOption(new Option("--limit <n>").hideHelp())
|
|
137
223
|
.option("--json", "emit versioned JSON envelope on stdout")
|
|
138
|
-
.option("--db <path>", "explicit database path")
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
process.exitCode = ExitCode.Usage;
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
const areaCap = parseCap("--area-limit", opts.areaLimit, AREA_PREVIEW_LIMIT, opts.all === true);
|
|
146
|
-
if (!areaCap.ok)
|
|
147
|
-
return;
|
|
148
|
-
const projectCap = parseCap("--project-limit", opts.projectLimit, AREA_PREVIEW_LIMIT, opts.all === true);
|
|
149
|
-
if (!projectCap.ok)
|
|
150
|
-
return;
|
|
151
|
-
const limits = { area: areaCap.limit, project: projectCap.limit };
|
|
152
|
-
const hintBase = invocation("area show", [shellQuote(ref), ...showToggleFlags(opts)]);
|
|
153
|
-
runRead(opts, "area-view", (c) => {
|
|
154
|
-
let view;
|
|
155
|
-
try {
|
|
156
|
-
view = c.read.areaView(ref);
|
|
157
|
-
}
|
|
158
|
-
catch (err) {
|
|
159
|
-
// Not-found gets a type-scoped did-you-mean; ambiguity is verbatim.
|
|
160
|
-
if (err instanceof RangeError && !err.message.includes("ambiguous")) {
|
|
161
|
-
throw new DidYouMeanError(err.message, ref, c.read.liteTitleSearch(ref, { type: "area" }));
|
|
162
|
-
}
|
|
163
|
-
throw err;
|
|
164
|
-
}
|
|
165
|
-
const { data, grouped } = capAreaSections(view, limits);
|
|
166
|
-
return {
|
|
167
|
-
data,
|
|
168
|
-
grouped,
|
|
169
|
-
lines: renderAreaView(view, { ...opts, limits, hintBase }),
|
|
170
|
-
};
|
|
171
|
-
}, () => []);
|
|
172
|
-
});
|
|
224
|
+
.option("--db <path>", "explicit database path");
|
|
225
|
+
addTagFilterOptions(areaShow)
|
|
226
|
+
.addHelpText("after", CONTAINER_TAG_HINT)
|
|
227
|
+
.action((ref, opts) => runAreaShow(ref, opts));
|
|
173
228
|
area
|
|
174
229
|
.command("open <ref>")
|
|
175
230
|
.description("Open the area in the Things app — foregrounds the GUI on this Mac (NOT headless). Errors when the reference is not an area.")
|