things-api 0.12.0 → 0.13.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.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/canonical-ref.d.ts +21 -0
  3. package/dist/cli/canonical-ref.js +23 -0
  4. package/dist/cli/canonical-ref.js.map +1 -0
  5. package/dist/cli/commands/area.js +27 -11
  6. package/dist/cli/commands/area.js.map +1 -1
  7. package/dist/cli/commands/install-skill.d.ts +2 -2
  8. package/dist/cli/commands/install-skill.js +29 -0
  9. package/dist/cli/commands/install-skill.js.map +1 -1
  10. package/dist/cli/commands/mcp.js +12 -1
  11. package/dist/cli/commands/mcp.js.map +1 -1
  12. package/dist/cli/commands/project.js +73 -21
  13. package/dist/cli/commands/project.js.map +1 -1
  14. package/dist/cli/commands/reads.d.ts +5 -1
  15. package/dist/cli/commands/reads.js +10 -3
  16. package/dist/cli/commands/reads.js.map +1 -1
  17. package/dist/cli/commands/setup.js +11 -4
  18. package/dist/cli/commands/setup.js.map +1 -1
  19. package/dist/cli/commands/show.js +45 -30
  20. package/dist/cli/commands/show.js.map +1 -1
  21. package/dist/cli/commands/todo.js +1 -1
  22. package/dist/cli/commands/todo.js.map +1 -1
  23. package/dist/cli/commands/writes.js +31 -6
  24. package/dist/cli/commands/writes.js.map +1 -1
  25. package/dist/cli/did-you-mean.js +19 -5
  26. package/dist/cli/did-you-mean.js.map +1 -1
  27. package/dist/cli/dry-run.d.ts +55 -0
  28. package/dist/cli/dry-run.js +92 -0
  29. package/dist/cli/dry-run.js.map +1 -0
  30. package/dist/cli/help.js +7 -6
  31. package/dist/cli/help.js.map +1 -1
  32. package/dist/cli/main.js +7 -0
  33. package/dist/cli/main.js.map +1 -1
  34. package/dist/cli/read-driver.d.ts +16 -6
  35. package/dist/cli/read-driver.js +19 -18
  36. package/dist/cli/read-driver.js.map +1 -1
  37. package/dist/cli/ref-render.d.ts +14 -0
  38. package/dist/cli/ref-render.js +9 -0
  39. package/dist/cli/ref-render.js.map +1 -0
  40. package/dist/cli/render.d.ts +21 -6
  41. package/dist/cli/render.js +73 -34
  42. package/dist/cli/render.js.map +1 -1
  43. package/dist/cli/resolve-invocation.d.ts +5 -3
  44. package/dist/cli/resolve-invocation.js +13 -4
  45. package/dist/cli/resolve-invocation.js.map +1 -1
  46. package/dist/client.d.ts +18 -7
  47. package/dist/client.js +2 -1
  48. package/dist/client.js.map +1 -1
  49. package/dist/contracts.d.ts +12 -1
  50. package/dist/contracts.js +1 -1
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db/baselines/db-v26.js +4 -1
  53. package/dist/db/baselines/db-v26.js.map +1 -1
  54. package/dist/index.d.ts +3 -1
  55. package/dist/index.js +7 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/mcp/server.js +81 -42
  58. package/dist/mcp/server.js.map +1 -1
  59. package/dist/model/entities.d.ts +11 -1
  60. package/dist/model/entities.js.map +1 -1
  61. package/dist/model/mappers.js +3 -0
  62. package/dist/model/mappers.js.map +1 -1
  63. package/dist/model/serialize.js +15 -3
  64. package/dist/model/serialize.js.map +1 -1
  65. package/dist/read/area-filter.d.ts +2 -2
  66. package/dist/read/area-filter.js +5 -6
  67. package/dist/read/area-filter.js.map +1 -1
  68. package/dist/read/area-view.d.ts +11 -0
  69. package/dist/read/area-view.js +5 -1
  70. package/dist/read/area-view.js.map +1 -1
  71. package/dist/read/project-view.d.ts +61 -1
  72. package/dist/read/project-view.js +74 -9
  73. package/dist/read/project-view.js.map +1 -1
  74. package/dist/read/queries.d.ts +71 -2
  75. package/dist/read/queries.js +199 -10
  76. package/dist/read/queries.js.map +1 -1
  77. package/dist/read/scope.d.ts +2 -2
  78. package/dist/read/scope.js +5 -6
  79. package/dist/read/scope.js.map +1 -1
  80. package/dist/read/shape.d.ts +36 -17
  81. package/dist/read/shape.js +251 -200
  82. package/dist/read/shape.js.map +1 -1
  83. package/dist/read/show-target.js +52 -33
  84. package/dist/read/show-target.js.map +1 -1
  85. package/dist/read/truncation.d.ts +0 -7
  86. package/dist/read/truncation.js +24 -14
  87. package/dist/read/truncation.js.map +1 -1
  88. package/dist/read/views.d.ts +19 -7
  89. package/dist/read/views.js +13 -12
  90. package/dist/read/views.js.map +1 -1
  91. package/dist/surface-copy.d.ts +7 -0
  92. package/dist/surface-copy.js +7 -0
  93. package/dist/surface-copy.js.map +1 -1
  94. package/dist/write/commands.js +3 -1
  95. package/dist/write/commands.js.map +1 -1
  96. package/dist/write/move.d.ts +10 -6
  97. package/dist/write/move.js +417 -76
  98. package/dist/write/move.js.map +1 -1
  99. package/dist/write/operations.d.ts +1 -1
  100. package/dist/write/operations.js.map +1 -1
  101. package/dist/write/pre-state.d.ts +10 -0
  102. package/dist/write/pre-state.js +118 -41
  103. package/dist/write/pre-state.js.map +1 -1
  104. package/dist/write/reorder.js +263 -29
  105. package/dist/write/reorder.js.map +1 -1
  106. package/package.json +1 -1
  107. package/schema/envelope.schema.json +14 -0
  108. package/skills/things-cli/SKILL.md +6 -4
  109. package/skills/things-cli/references/contracts.md +3 -1
  110. package/skills/things-cli/references/errors.md +9 -1
  111. package/skills/things-cli/references/model.md +6 -4
  112. package/skills/things-cli/references/ordering.md +21 -4
@@ -54,16 +54,16 @@
54
54
  * substrate) and KEPT in FULL/DETAIL beside `when` — different facts: `startDate`
55
55
  * = what is stored, `when` = where it sits.
56
56
  * - a template's `repeating.nextOccurrence` is GONE from the wire — `when` replaces
57
- * it (same fact, one word); the resting-templates `{date: null}` group is
58
- * unchanged (an unprojected template has no `when`).
59
- * - `when` is DROPPED inside the `today` view's own sections (the section key states
60
- * today/evening) and inside any card/heading `upcoming` DATE-GROUP for a member
61
- * whose `when` equals the group's date (the group states it). KEPT everywhere
62
- * else it is present — including the flat `upcoming`/`anytime`/`inbox`/`someday`
63
- * catalogues, search, changes (a deadline-pulled row reads `when: "today"` in the
64
- * mixed search/changes surfaces, informatively; note R13 re-files it to stage
65
- * `anytime` and the flat inbox/someday views now EXCLUDE it — it appears in the
66
- * `anytime` catalogue instead, `when: "today"` kept, stage dropped as pure).
57
+ * it (same fact, one word); an unprojected template has no `when`.
58
+ * - `when` is KEPT on every row it is present on. (The read-shape doctrine
59
+ * flattened the card `upcoming` date-groups into the project-/area-view flat
60
+ * `items[]`, so no enclosing node states the position anymore.) It rides the
61
+ * today view's flat `items[]` (Today-proper vs This-Evening), the project-/area-
62
+ * view `items[]`, and the flat `upcoming`/`anytime`/`inbox`/`someday` catalogues,
63
+ * search, changes (a deadline-pulled row reads `when: "today"` in the mixed
64
+ * search/changes surfaces, informatively; note R13 re-files it to stage `anytime`
65
+ * and the flat inbox/someday views now EXCLUDE it — it appears in the `anytime`
66
+ * catalogue instead, `when: "today"` kept, stage dropped as pure).
67
67
  *
68
68
  * ## R13 — provisional Today members + GUI-faithful pulled-row membership
69
69
  * BANNER1 / BANNER1b (docs/lab/banner1-research.md). Two coupled facts:
@@ -77,10 +77,10 @@
77
77
  * side effect our read cannot perform (watchers beware).
78
78
  * - **stage `anytime` for a deadline pull** — a due-deadline pull re-files an undated
79
79
  * Inbox/Someday row into Anytime (deriveStage step 2½, L-A). So EVERY Today member
80
- * derives stage `anytime`, and the `today` view's own sections become stage-PURE →
81
- * `stage` is DROPPED there (TODAY_SECTION_DROP), alongside the section-implied
82
- * `when`. The flat someday/inbox views EXCLUDE pulled rows and the anytime view
83
- * INCLUDES them (src/read/views.ts + predicates.ts DEADLINE_PULLED) — GUI fidelity.
80
+ * derives stage `anytime`, and the today view's flat `items[]` is stage-PURE →
81
+ * `stage` is DROPPED there (TODAY_ITEM_DROP) while `when` is KEPT. The flat
82
+ * someday/inbox views EXCLUDE pulled rows and the anytime view INCLUDES them
83
+ * (src/read/views.ts + predicates.ts DEADLINE_PULLED) — GUI fidelity.
84
84
  *
85
85
  * ## Universal item-DTO reshapes (R9 — EVERY tier, EVERY read kind incl. detail)
86
86
  * - **checklist nesting** — flat counts → presence-keyed `checklist: {open,total}`.
@@ -113,6 +113,46 @@
113
113
  * universal reshapes, and R10.
114
114
  */
115
115
  import { deriveStage, deriveWhen, whenIsProvisional } from "./stage.js";
116
+ /** The DB-less default: assume every title round-trips (bare title, no uuid sibling). */
117
+ const ALWAYS_ROUND_TRIPS = { roundTrips: () => true };
118
+ /** The uuid of a `{uuid,title}` container Ref, or undefined for a non-object / string. */
119
+ function refUuid(v) {
120
+ if (v !== null && typeof v === "object" && typeof v["uuid"] === "string") {
121
+ return v["uuid"];
122
+ }
123
+ return undefined;
124
+ }
125
+ /**
126
+ * Whether a still-unflattened container Ref carries the repeating-TEMPLATE mark.
127
+ * Only project/container refs ever set it (area/heading refs never do — see
128
+ * entities.Ref), so this reads true only for a template PROJECT container.
129
+ */
130
+ function refIsTemplate(v) {
131
+ return v !== null && typeof v === "object" && v["isRepeatingTemplate"] === true;
132
+ }
133
+ /**
134
+ * Flatten ONE container ref `o[key]` from a `{uuid,title}` object to its bare
135
+ * TITLE string, adding a flat sibling `o[uuidKey]` = the full uuid ONLY when the
136
+ * round-trip law demands it: `forceUuid` (the FULL/detail tier — uuid siblings
137
+ * unconditional) OR the bare title does not resolve back to this exact entity
138
+ * (`!promoter.roundTrips`). A null/absent ref, or one already flattened to a
139
+ * string, is left untouched. The container's `isRepeatingTemplate` marker (a
140
+ * TTY-render disambiguator on the internal entity) does not survive the flatten
141
+ * — the human render reads the unshaped entity. {@link shapeItem} re-emits that
142
+ * fact for the JSON container PROJECT as the flat presence-keyed sibling
143
+ * `projectIsTemplate: true` BEFORE this flatten runs.
144
+ */
145
+ function flattenRef(o, key, uuidKey, kind, forceUuid, promoter, projectUuid) {
146
+ const ref = o[key];
147
+ if (ref === null || typeof ref !== "object")
148
+ return; // absent, or already a bare string
149
+ const r = ref;
150
+ const uuid = typeof r["uuid"] === "string" ? r["uuid"] : "";
151
+ const title = typeof r["title"] === "string" ? r["title"] : "";
152
+ o[key] = title;
153
+ if (forceUuid || !promoter.roundTrips(kind, title, uuid, projectUuid))
154
+ o[uuidKey] = uuid;
155
+ }
116
156
  /**
117
157
  * Fold the flat `tags` / `inheritedTags` arrays of `{title}` objects into plain
118
158
  * arrays of tag NAMES (universal across tiers and kinds). Tag uuids were never
@@ -260,7 +300,7 @@ function whenOf(s, stage) {
260
300
  * (`changeKind` on a changes row, `match` on a search hit) pass through
261
301
  * untouched. Non-task values (areas, tags, refs, headings) are returned as-is.
262
302
  */
263
- function shapeItem(src, drop, compact) {
303
+ function shapeItem(src, drop, compact, promoter) {
264
304
  if (src === null || typeof src !== "object")
265
305
  return src;
266
306
  const s = src;
@@ -300,9 +340,9 @@ function shapeItem(src, drop, compact) {
300
340
  if (drop.stage !== true)
301
341
  o["stage"] = stage;
302
342
  // R12 — the today/evening marker KEYS are replaced by the derived `when` on
303
- // EVERY tier (they never appear on the wire); `when` is emitted unless the
304
- // enclosing context provably states the position (the today view's sections;
305
- // a card date-grouphandled in rebucketChildren).
343
+ // EVERY tier (they never appear on the wire); `when` is emitted on every row it
344
+ // is present on (since the read-shape doctrine flattened the card date-groups,
345
+ // no enclosing node states the position anymore the flat lists carry it).
306
346
  delete o["today"];
307
347
  delete o["evening"];
308
348
  // §9n — a reminder byte is presentation-dead once its `startDate` goes strictly
@@ -318,6 +358,12 @@ function shapeItem(src, drop, compact) {
318
358
  // R13 — the provisional banner marker (never dropped; presence-keyed).
319
359
  if (provisional)
320
360
  o["provisional"] = true;
361
+ // The owning project's uuid scopes the heading round-trip (headings resolve
362
+ // within their project). Captured BEFORE the R6 project-drop so a project-view
363
+ // LOGBOOK row — whose `project` is dropped as redundant, yet which KEEPS its
364
+ // heading ref (drop.keepHeading) — can still promote its `headingUuid` in the
365
+ // project's scope.
366
+ const projectUuid = refUuid(o["project"]);
321
367
  // R6 — drop redundant ancestry (both tiers).
322
368
  if (drop.project === true)
323
369
  delete o["project"];
@@ -325,6 +371,30 @@ function shapeItem(src, drop, compact) {
325
371
  delete o["area"];
326
372
  if (drop.heading === true)
327
373
  delete o["heading"];
374
+ // Absent `type` = to-do — omit it on to-do rows (project/heading keep theirs).
375
+ if (o["type"] === "to-do")
376
+ delete o["type"];
377
+ // Flatten the surviving container refs to bare TITLE strings, adding a flat
378
+ // `*Uuid` sibling per the round-trip law (FULL tier: unconditional; compact:
379
+ // only when the title would not resolve back to this entity). The `heading`
380
+ // ref is compact-dropped below (except drop.keepHeading), so it is flattened
381
+ // on the FULL tier OR when a logbook row explicitly keeps it.
382
+ const forceUuid = !compact;
383
+ // The container PROJECT's repeating-TEMPLATE fact — the JSON twin of the TTY ↻
384
+ // glyph (src/cli/render.ts). flattenRef discards the internal ref's
385
+ // `isRepeatingTemplate` marker, so re-emit it here as a flat presence-keyed
386
+ // sibling of the `project` ref (never `false`), riding wherever `project`
387
+ // rides. A heading-nested row already merged its owning project into `project`
388
+ // above, so direct AND headed template children mark; the R6 project-drop
389
+ // above already removed `project` where the view implies it, so a project-view
390
+ // child carries no orphaned marker. Both tiers — it is a correctness signal,
391
+ // not detail. Only project refs ever carry the flag (area/heading never do).
392
+ if (refIsTemplate(o["project"]))
393
+ o["projectIsTemplate"] = true;
394
+ flattenRef(o, "project", "projectUuid", "project", forceUuid, promoter);
395
+ flattenRef(o, "area", "areaUuid", "area", forceUuid, promoter);
396
+ if (!compact || drop.keepHeading === true)
397
+ flattenRef(o, "heading", "headingUuid", "heading", forceUuid, promoter, projectUuid);
328
398
  // R12 — FULL/DETAIL keep the raw `startDate` beside `when` as the SUBSTRATE
329
399
  // (`startDate` = what is stored, `when` = where it sits). COMPACT drops it below
330
400
  // (the position `when` carries is what a list needs).
@@ -341,112 +411,58 @@ function shapeItem(src, drop, compact) {
341
411
  if (notes !== "")
342
412
  o["hasNotes"] = true;
343
413
  // The heading ref is compact-dropped everywhere (the GUI shows the project,
344
- // never the heading, outside a project view). Full tier / detail keep it.
345
- delete o["heading"];
414
+ // never the heading, outside a project view). Full tier / detail keep it; a
415
+ // project-view logbook row (drop.keepHeading) keeps it too — flattened above.
416
+ if (drop.keepHeading !== true)
417
+ delete o["heading"];
346
418
  return o;
347
419
  }
348
420
  /** Map a plain array of items with the item shaper. */
349
- function shapeList(items, drop, compact) {
421
+ function shapeList(items, drop, compact, promoter) {
350
422
  if (!Array.isArray(items))
351
423
  return items;
352
- return items.map((i) => shapeItem(i, drop, compact));
424
+ return items.map((i) => shapeItem(i, drop, compact, promoter));
353
425
  }
354
426
  /** Copy `base` and overwrite `items` with the shaped list (avoids spread-in-map). */
355
- function withShapedItems(base, drop, compact) {
427
+ function withShapedItems(base, drop, compact, promoter) {
356
428
  const out = { ...base };
357
- out["items"] = shapeList(base["items"], drop, compact);
358
- return out;
359
- }
360
- /**
361
- * Re-bucket a project's / area's / heading's live (non-logbook/trash) children
362
- * into the R10 card shape by their derived {@link deriveStage} — so the bucket an
363
- * item lands in ALWAYS equals its `stage`:
364
- * - `anytime` — stage anytime, in encounter order;
365
- * - `upcoming` — stage upcoming, date-grouped `[{date, items}]` (a dated row under
366
- * its `startDate`, a template under its `nextOccurrence`), date ASC; date-LESS
367
- * templates (after-completion / paused) form a trailing `{date: null, items}`
368
- * group (explicit null per the `area: null` section precedent);
369
- * - `someday` — stage someday.
370
- * Items are already in view order (index / date+todayIndex) from the read layer,
371
- * so encounter order within a date group preserves that ordering. Each item is
372
- * then run through {@link shapeItem} with the section drop (ancestry + `stage`,
373
- * since the bucket states it).
374
- */
375
- function rebucketChildren(children, drop, compact) {
376
- const anytime = [];
377
- const someday = [];
378
- const datedByKey = new Map();
379
- const datedOrder = [];
380
- const restingTemplates = [];
381
- const shape = (c) => shapeItem(c, drop, compact);
382
- for (const raw of children) {
383
- if (raw === null || typeof raw !== "object")
384
- continue;
385
- const c = raw;
386
- const stage = stageOf(c);
387
- if (stage === "anytime") {
388
- anytime.push(shape(c));
389
- }
390
- else if (stage === "someday") {
391
- someday.push(shape(c));
392
- }
393
- else if (stage === "upcoming") {
394
- const repeating = c["repeating"];
395
- const nextOcc = repeating != null && typeof repeating === "object"
396
- ? (repeating["nextOccurrence"] ?? null)
397
- : null;
398
- const date = (c.startDate ?? null) !== null ? c.startDate : nextOcc;
399
- if (date === null) {
400
- restingTemplates.push(shape(c));
401
- }
402
- else {
403
- if (!datedByKey.has(date)) {
404
- datedByKey.set(date, []);
405
- datedOrder.push(date);
406
- }
407
- // R12 — inside a date-group the group states the date, so a member whose
408
- // `when` equals it drops it (every scheduled row and every projected
409
- // template does — that IS the group key).
410
- const shaped = shape(c);
411
- if (shaped !== null && typeof shaped === "object" && shaped["when"] === date) {
412
- delete shaped["when"];
413
- }
414
- datedByKey.get(date).push(shaped);
415
- }
416
- }
417
- else {
418
- // inbox / logbook / trash should not appear among a card's live children;
419
- // route defensively to anytime rather than drop the row.
420
- anytime.push(shape(c));
421
- }
422
- }
423
- const upcoming = datedOrder
424
- .toSorted((a, b) => a.localeCompare(b))
425
- .map((date) => ({ date, items: datedByKey.get(date) }));
426
- if (restingTemplates.length > 0)
427
- upcoming.push({ date: null, items: restingTemplates });
428
- return { anytime, upcoming, someday };
429
- }
430
- /** Flatten an internal IsoDateGroup[] (`[{date, items}]`) to its items, in order. */
431
- function flattenGroups(groups) {
432
- if (!Array.isArray(groups))
433
- return [];
434
- const out = [];
435
- for (const g of groups) {
436
- if (g !== null && typeof g === "object" && Array.isArray(g["items"])) {
437
- out.push(...g["items"]);
438
- }
439
- }
429
+ out["items"] = shapeList(base["items"], drop, compact, promoter);
440
430
  return out;
441
431
  }
442
432
  /** Coerce an unknown value to an array (empty when absent). */
443
433
  const asArray = (v) => (Array.isArray(v) ? v : []);
444
- /** The R6 ref drop for every child bucket of a project view (unheaded members). */
445
- const PROJECT_CHILD_DROP = { project: true, area: true, stage: true };
446
- /** Heading-group members drop the heading ref too (the group states it). */
447
- const HEADING_MEMBER_DROP = { project: true, area: true, heading: true, stage: true };
448
- /** Area-view child-item buckets drop their area (the card states it) + the bucket-implied stage. */
449
- const AREA_CHILD_DROP = { area: true, stage: true };
434
+ /**
435
+ * The flat project-view `items[]` rows (read-shape doctrine §3.12): drop
436
+ * project/area (the card states them) but KEEP `stage` (the list is stage-MIXED:
437
+ * anytime + upcoming + someday + closed-unswept), `when`, and the `heading` ref.
438
+ * The heading ref is kept + flattened even in compact (keepHeading), scoped to
439
+ * the owning project flat title + `headingUuid` when the title would not
440
+ * round-trip — so a consumer reconstructs a heading's members from the rows.
441
+ */
442
+ const PROJECT_ITEM_DROP = { project: true, area: true, keepHeading: true };
443
+ /**
444
+ * Project-view LOGBOOK (flat logged) rows (read-shape doctrine §3.12 / #C4): drop
445
+ * project/area (the card states them) but KEEP `stage` and the `heading` ref
446
+ * (drop.keepHeading). The bucket is stage-MIXED since it absorbs the children of
447
+ * SWEPT ARCHIVED headings too — mostly `logbook`, but it can hold the odd OPEN
448
+ * child a Put-Back stranded under an archived heading (HEADARC2-C, stage
449
+ * `anytime`) — so `stage` is not provably implied and is kept. The heading ref is
450
+ * the GUI hint (the in-project logged toggle labels the HEADING; the global
451
+ * Logbook labels the PROJECT — the two-view asymmetry, HEADARC2-B), flat title +
452
+ * project-scoped `headingUuid`.
453
+ */
454
+ const PROJECT_LOGBOOK_DROP = {
455
+ project: true,
456
+ area: true,
457
+ keepHeading: true,
458
+ };
459
+ /**
460
+ * The flat area-view `items[]` rows (read-shape doctrine §3.13): drop `area` (the
461
+ * card states it) but KEEP `stage` (the list is stage-MIXED — anytime + upcoming
462
+ * + someday + closed-unswept) and `when`. Area direct to-dos are never headed and
463
+ * never project-nested, so nothing else to drop.
464
+ */
465
+ const AREA_ITEM_DROP = { area: true };
450
466
  /** Area-view PROJECTS list: a mixed listing of the area's project rows — keep `stage`, drop area. */
451
467
  const AREA_PROJECTS_DROP = { area: true };
452
468
  /**
@@ -465,100 +481,133 @@ const SOMEDAY_SECTION_DROP = { area: true, stage: true };
465
481
  /** The card NODE / detail / mixed lists: keep every ref, `stage`, and `when`. */
466
482
  const NO_DROP = {};
467
483
  /**
468
- * The today view's own sections: drop the section-implied `when` (R12) AND the
469
- * section-implied `stage` (R13). Every Today member now derives stage `anytime`
470
- * by construction — an ARRIVED `startDate` (step 5) or a DEADLINE PULL (step 2½)
471
- * both derive `anytime`, and there are no future-dated or undated-someday Today
472
- * members — so the Today sections are provably stage-PURE `anytime` and the field
473
- * is redundant there (verified strict by the today-section purity property test
474
- * in test/unit/stage.test.ts). `provisional` is NOT a drop — the banner is not a
475
- * section, so nothing implies it.
484
+ * The today view's flat `items[]`: drop the view-implied `stage` (R13) but KEEP
485
+ * `when`. Every Today member derives stage `anytime` by construction — an ARRIVED
486
+ * `startDate` (step 5) or a DEADLINE PULL (step 2½) both derive `anytime`, and
487
+ * there are no future-dated or undated-someday Today members — so the today view
488
+ * is provably stage-PURE `anytime` and the field is redundant (verified strict by
489
+ * the today purity property test in test/unit/stage.test.ts). `when` is KEPT: the
490
+ * flat list interleaves Today-proper (`when: "today"`) and This-Evening
491
+ * (`when: "evening"`) members, so each row must carry which render section it
492
+ * belongs to — the split is derived from `when`, not a wire bucket. `provisional`
493
+ * is NOT a drop — the banner pip is per-row, nothing implies it.
476
494
  */
477
- const TODAY_SECTION_DROP = { when: true, stage: true };
478
- /** Shape every collection bucket of a project view; the card node is left full + ancestry-intact. */
479
- function shapeProjectView(view, compact) {
480
- const cd = PROJECT_CHILD_DROP;
481
- const hd = HEADING_MEMBER_DROP;
482
- const shapeHeadingGroup = (g) => {
483
- if (g === null || typeof g !== "object")
484
- return g;
485
- const grp = g;
486
- const out = {};
487
- // The heading NODE itself drops its `project` refthe card states it.
488
- if (grp["heading"] !== null && typeof grp["heading"] === "object") {
489
- const h = { ...grp["heading"] };
490
- delete h["project"];
491
- out["heading"] = h;
492
- }
493
- else {
494
- out["heading"] = grp["heading"];
495
- }
496
- const members = [
497
- ...asArray(grp["items"]),
498
- ...flattenGroups(grp["scheduled"]),
499
- ...asArray(grp["someday"]),
500
- ...asArray(grp["repeating"]),
501
- ];
502
- const { anytime, upcoming, someday } = rebucketChildren(members, hd, compact);
503
- out["anytime"] = anytime;
504
- out["upcoming"] = upcoming;
505
- out["someday"] = someday;
506
- return out;
495
+ const TODAY_ITEM_DROP = { stage: true };
496
+ /**
497
+ * Shape a heading catalog entry (a `headings[]` node — the flat `{uuid,title,
498
+ * archived?}` catalog, read-shape doctrine §3.12 / #C3). The type is implied by
499
+ * position, and a heading has no open/canceled/completed vocabulary the reader
500
+ * needs so:
501
+ * - DROP `type` (positional: this slot is always a heading; the "absent type =
502
+ * to-do" convention is scoped to ROWS/candidates, never this catalog entry);
503
+ * - DROP `project` (the card states it);
504
+ * - REPLACE `status` with the presence-keyed `archived` (the stopDate, an ISO
505
+ * date-time following the `stopped`/logged-row convention)emitted ONLY when
506
+ * the heading is archived (status "completed"), OMITTED when open. The entry
507
+ * carries only whether-and-when it was archived; sweptness (past the logbook
508
+ * boundary) is not on the wire — the TTY derives it (#C3a).
509
+ */
510
+ function shapeHeadingNode(src) {
511
+ if (src === null || typeof src !== "object")
512
+ return src;
513
+ const h = { ...src };
514
+ delete h["project"];
515
+ delete h["type"];
516
+ const isArchived = h["status"] !== undefined && h["status"] !== "open";
517
+ const stopped = h["stopped"];
518
+ delete h["status"];
519
+ delete h["stopped"];
520
+ // Presence-keyed `archived` the ISO archive timestamp (past-participle twin of
521
+ // `stopped`/`created`/`modified`), full-datetime serialization like `stopped`.
522
+ if (isArchived && stopped != null)
523
+ h["archived"] = stopped;
524
+ return h;
525
+ }
526
+ /** Stopped-DESC comparator for the flat logbook (open odd children — no stopDate — sort last). */
527
+ function byStoppedDesc(a, b) {
528
+ const t = (x) => {
529
+ const s = x !== null && typeof x === "object" ? x["stopped"] : null;
530
+ return s instanceof Date ? s.getTime() : -Infinity;
507
531
  };
508
- const headings = Array.isArray(view["headings"])
509
- ? view["headings"].map(shapeHeadingGroup)
510
- : view["headings"];
511
- const looseMembers = [
512
- ...asArray(view["active"]),
513
- ...flattenGroups(view["scheduled"]),
514
- ...asArray(view["someday"]),
515
- ...asArray(view["repeating"]),
516
- ];
517
- const { anytime, upcoming, someday } = rebucketChildren(looseMembers, cd, compact);
532
+ return t(b) - t(a);
533
+ }
534
+ /**
535
+ * Shape a project view (read-shape doctrine §3.12 / #C3 / #C4). The live children
536
+ * are ONE flat `items[]` in project index order — each row carrying `stage`,
537
+ * `when`, and its `heading` ref (flat title + project-scoped `headingUuid`) — so a
538
+ * consumer reconstructs a heading's members by filtering `items` on `heading`.
539
+ * `headings[]` is the flat catalog `[{uuid,title,archived?}]` of EVERY heading
540
+ * (live + swept archived) in index order — the ORDER axis. `logbook` is one flat
541
+ * `stopDate DESC` list of ALL swept children — of open headings, un-headed, AND
542
+ * archived headings — each carrying its `heading` ref; `logbookHeadings` is gone
543
+ * (its rows folded in, the archived heading itself now a catalog entry with
544
+ * `archived`). The card node is left full + ancestry-intact.
545
+ */
546
+ function shapeProjectView(view, compact, promoter) {
547
+ // The heading catalog: every heading node (live + swept archived), index order,
548
+ // flattened to `{uuid,title,archived?}`. Membership rides the flat item/logbook
549
+ // row `heading` refs; archived-ness reads off `archived` here.
550
+ const headings = Array.isArray(view["headingCatalog"])
551
+ ? view["headingCatalog"].map(shapeHeadingNode)
552
+ : [];
553
+ // The flat logbook: swept children of open/un-headed headings (`logged`) PLUS
554
+ // the children of swept archived headings (`loggedHeadings`), merged into ONE
555
+ // stopDate-DESC list (open odd children last), each carrying its heading ref.
556
+ const loggedRows = [
557
+ ...asArray(view["logged"]),
558
+ ...asArray(view["loggedHeadings"]).flatMap((g) => g !== null && typeof g === "object" ? asArray(g["items"]) : []),
559
+ ].toSorted(byStoppedDesc);
518
560
  const out = { ...view };
519
561
  delete out["active"];
520
562
  delete out["scheduled"];
521
563
  delete out["repeating"];
564
+ delete out["someday"];
522
565
  delete out["logged"];
566
+ delete out["loggedHeadings"];
567
+ delete out["headingCatalog"];
523
568
  // Trashed children live only in `things trash` — never a project-view bucket.
524
569
  // Delete defensively in case an untyped source carries the old key.
525
570
  delete out["trashed"];
526
571
  // The project card NODE keeps everything (children derive their container from
527
572
  // it), but is still an item DTO, so the universal + R10 reshapes apply.
528
- out["project"] = shapeItem(view["project"], NO_DROP, false);
529
- out["anytime"] = anytime;
530
- out["upcoming"] = upcoming;
531
- out["someday"] = someday;
573
+ out["project"] = shapeItem(view["project"], NO_DROP, false, promoter);
574
+ // The flat live children — stage/when/heading kept, project/area dropped (the
575
+ // card states them). The heading ref is kept + flattened even in compact
576
+ // (keepHeading), project-scoped like the logbook rows.
577
+ out["items"] = shapeList(view["items"], PROJECT_ITEM_DROP, compact, promoter);
532
578
  out["headings"] = headings;
533
- // A project keeps its in-context `logbook` (a project is a bounded object with
534
- // a real done-state); trashed children live only in `things trash`.
535
- out["logbook"] = shapeList(view["logged"], cd, compact);
579
+ // A project keeps its in-context `logbook` (a project is a bounded object with a
580
+ // real done-state); trashed children live only in `things trash`. The flat
581
+ // rows KEEP stage (mixed) + their heading ref (PROJECT_LOGBOOK_DROP).
582
+ out["logbook"] = shapeList(loggedRows, PROJECT_LOGBOOK_DROP, compact, promoter);
536
583
  return out;
537
584
  }
538
- /** Shape every collection bucket of an area view; the area node keeps its identity (tags folded). */
539
- function shapeAreaView(view, compact) {
540
- const looseMembers = [
541
- ...asArray(view["active"]),
542
- ...flattenGroups(view["scheduled"]),
543
- ...asArray(view["someday"]),
544
- ...asArray(view["repeating"]),
545
- ];
546
- const { anytime, upcoming, someday } = rebucketChildren(looseMembers, AREA_CHILD_DROP, compact);
585
+ /**
586
+ * Shape an area view (read-shape doctrine §3.13). The direct to-dos dissolve into
587
+ * ONE flat `items[]` in index order — each row carrying `stage` + `when`, `area`
588
+ * dropped (the card states it). The stage/date sub-buckets (anytime/upcoming/
589
+ * someday) are gone. `projects[]` is KEPT — the area's child-project sidebar rank
590
+ * is a DISTINCT order axis from the direct-to-do index, so it earns its own list
591
+ * (mixed-stage, someday/scheduled projects included; the render split is TTY-only).
592
+ * The area node keeps its identity (tags folded).
593
+ */
594
+ function shapeAreaView(view, compact, promoter) {
547
595
  const out = { ...view };
548
596
  delete out["active"];
549
597
  delete out["scheduled"];
550
598
  delete out["repeating"];
599
+ delete out["someday"];
551
600
  // No `logbook` or `trash` bucket: an area's logbook is the bounded query
552
601
  // `things logbook --area <ref>`, and trashed rows live only in `things trash`.
553
602
  // Delete defensively in case an untyped source carries the old keys.
554
603
  delete out["logged"];
555
604
  delete out["trashed"];
556
605
  out["area"] = shapeArea(view["area"]);
557
- out["anytime"] = anytime;
558
- // The projects list is a mixed listing of the area's project rows — keep stage.
559
- out["projects"] = shapeList(view["projects"], AREA_PROJECTS_DROP, compact);
560
- out["upcoming"] = upcoming;
561
- out["someday"] = someday;
606
+ // The flat direct to-dos — stage/when kept, area dropped.
607
+ out["items"] = shapeList(view["items"], AREA_ITEM_DROP, compact, promoter);
608
+ // The projects list is a mixed listing of the area's project rows — keep stage,
609
+ // sidebar order (the distinct order axis).
610
+ out["projects"] = shapeList(view["projects"], AREA_PROJECTS_DROP, compact, promoter);
562
611
  return out;
563
612
  }
564
613
  /** Fold an area entity's tags to string names in place (returns a shallow copy). */
@@ -569,19 +618,11 @@ function shapeArea(src) {
569
618
  flattenTags(o);
570
619
  return o;
571
620
  }
572
- /** Shape the today/evening split (mixed list — keep refs + stage; drop the section-implied `when`). */
573
- function shapeTodayView(view, compact) {
574
- return {
575
- ...view,
576
- today: shapeList(view["today"], TODAY_SECTION_DROP, compact),
577
- evening: shapeList(view["evening"], TODAY_SECTION_DROP, compact),
578
- };
579
- }
580
621
  /** Shape sidebar sections (anytime/someday catalogues) with the section's drop spec. */
581
- function shapeSections(sections, drop, compact) {
622
+ function shapeSections(sections, drop, compact, promoter) {
582
623
  if (!Array.isArray(sections))
583
624
  return sections;
584
- return sections.map((s) => s === null || typeof s !== "object" ? s : withShapedItems(s, drop, compact));
625
+ return sections.map((s) => s === null || typeof s !== "object" ? s : withShapedItems(s, drop, compact, promoter));
585
626
  }
586
627
  /**
587
628
  * The flat, mixed-provenance list kinds mapped to their drop spec. Only the
@@ -608,28 +649,36 @@ const FLAT_LIST_DROP = new Map([
608
649
  * mutated (shallow copies throughout), so the human-render path keeps the full
609
650
  * entities.
610
651
  */
611
- export function shapeReadPayload(kind, data, full) {
652
+ export function shapeReadPayload(kind, data, full, promoter) {
653
+ // The ref-promotion oracle drives the round-trip law for flat container refs.
654
+ // Absent (a DB-less unit shaping): assume every title round-trips — bare
655
+ // titles, no uuid siblings. Production always passes the client's promoter.
656
+ const p = promoter ?? ALWAYS_ROUND_TRIPS;
612
657
  // `detail` is the FULL record and drops no ancestry / stage / `when`.
613
658
  if (kind === "detail")
614
- return shapeItem(data, NO_DROP, false);
659
+ return shapeItem(data, NO_DROP, false, p);
615
660
  const compact = !full;
616
661
  const flatDrop = FLAT_LIST_DROP.get(kind);
617
662
  if (flatDrop !== undefined)
618
- return shapeList(data, flatDrop, compact);
663
+ return shapeList(data, flatDrop, compact, p);
664
+ // The today view: one flat `items[]` of Today members (Today-proper + This
665
+ // Evening interleaved in comparator order); drop the view-implied `stage`,
666
+ // KEEP `when` (it carries the render section). The `counts` aggregate rides
667
+ // `meta.counts`, not `data`. The wire wrapper is `{ items }` (read-driver).
619
668
  if (kind === "today" && data !== null && typeof data === "object") {
620
- return shapeTodayView(data, compact);
669
+ return shapeList(data["items"], TODAY_ITEM_DROP, compact, p);
621
670
  }
622
671
  if (kind === "anytime" && Array.isArray(data)) {
623
- return shapeSections(data, ANYTIME_SECTION_DROP, compact); // stage-pure → drop stage
672
+ return shapeSections(data, ANYTIME_SECTION_DROP, compact, p); // stage-pure → drop stage
624
673
  }
625
674
  if (kind === "someday" && Array.isArray(data)) {
626
- return shapeSections(data, SOMEDAY_SECTION_DROP, compact); // stage-pure → drop stage
675
+ return shapeSections(data, SOMEDAY_SECTION_DROP, compact, p); // stage-pure → drop stage
627
676
  }
628
677
  if (kind === "area-view" && data !== null && typeof data === "object") {
629
- return shapeAreaView(data, compact);
678
+ return shapeAreaView(data, compact, p);
630
679
  }
631
680
  if (kind === "project-view" && data !== null && typeof data === "object") {
632
- return shapeProjectView(data, compact);
681
+ return shapeProjectView(data, compact, p);
633
682
  }
634
683
  // The `areas` listing carries Area entities whose tags fold to names.
635
684
  if (kind === "areas" && Array.isArray(data))
@@ -663,8 +712,10 @@ export function candidateRef(type, src) {
663
712
  const out = {
664
713
  uuid: typeof s["uuid"] === "string" ? s["uuid"] : "",
665
714
  title: typeof s["title"] === "string" ? s["title"] : "",
666
- type,
667
715
  };
716
+ // Absent `type` = to-do — emit it only for the other kinds.
717
+ if (type !== "to-do")
718
+ out.type = type;
668
719
  const area = candidateContainerTitle(s["area"]);
669
720
  if (area !== null)
670
721
  out.area = area;