things-api 0.13.0 → 0.14.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 (104) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/commands/area.d.ts +5 -5
  3. package/dist/cli/commands/area.js +12 -7
  4. package/dist/cli/commands/area.js.map +1 -1
  5. package/dist/cli/commands/project.js +1 -1
  6. package/dist/cli/commands/project.js.map +1 -1
  7. package/dist/cli/commands/reads.js +21 -12
  8. package/dist/cli/commands/reads.js.map +1 -1
  9. package/dist/cli/commands/show.js +6 -2
  10. package/dist/cli/commands/show.js.map +1 -1
  11. package/dist/cli/commands/todo.js +1 -1
  12. package/dist/cli/commands/todo.js.map +1 -1
  13. package/dist/cli/commands/writes.js +116 -70
  14. package/dist/cli/commands/writes.js.map +1 -1
  15. package/dist/cli/glyphs.d.ts +9 -2
  16. package/dist/cli/glyphs.js +11 -5
  17. package/dist/cli/glyphs.js.map +1 -1
  18. package/dist/cli/read-driver.d.ts +39 -9
  19. package/dist/cli/read-driver.js +37 -16
  20. package/dist/cli/read-driver.js.map +1 -1
  21. package/dist/cli/render.d.ts +25 -26
  22. package/dist/cli/render.js +57 -56
  23. package/dist/cli/render.js.map +1 -1
  24. package/dist/client.d.ts +92 -35
  25. package/dist/client.js +61 -18
  26. package/dist/client.js.map +1 -1
  27. package/dist/contracts.d.ts +23 -33
  28. package/dist/contracts.js +1 -1
  29. package/dist/contracts.js.map +1 -1
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/mcp/server.js +158 -144
  34. package/dist/mcp/server.js.map +1 -1
  35. package/dist/model/dates.d.ts +26 -0
  36. package/dist/model/dates.js +46 -4
  37. package/dist/model/dates.js.map +1 -1
  38. package/dist/read/area-filter.d.ts +1 -1
  39. package/dist/read/area-filter.js +5 -4
  40. package/dist/read/area-filter.js.map +1 -1
  41. package/dist/read/area-view.d.ts +0 -11
  42. package/dist/read/area-view.js +1 -5
  43. package/dist/read/area-view.js.map +1 -1
  44. package/dist/read/project-view.d.ts +34 -19
  45. package/dist/read/project-view.js +26 -20
  46. package/dist/read/project-view.js.map +1 -1
  47. package/dist/read/scope.d.ts +1 -1
  48. package/dist/read/scope.js +5 -4
  49. package/dist/read/scope.js.map +1 -1
  50. package/dist/read/shape.d.ts +73 -14
  51. package/dist/read/shape.js +459 -151
  52. package/dist/read/shape.js.map +1 -1
  53. package/dist/read/truncation.d.ts +75 -2
  54. package/dist/read/truncation.js +69 -46
  55. package/dist/read/truncation.js.map +1 -1
  56. package/dist/read/views.d.ts +19 -18
  57. package/dist/read/views.js +5 -6
  58. package/dist/read/views.js.map +1 -1
  59. package/dist/surface-copy.d.ts +6 -0
  60. package/dist/surface-copy.js +6 -0
  61. package/dist/surface-copy.js.map +1 -1
  62. package/dist/write/batch.js +0 -1
  63. package/dist/write/batch.js.map +1 -1
  64. package/dist/write/commands.d.ts +25 -1
  65. package/dist/write/commands.js +256 -121
  66. package/dist/write/commands.js.map +1 -1
  67. package/dist/write/guards.js +27 -11
  68. package/dist/write/guards.js.map +1 -1
  69. package/dist/write/move.d.ts +26 -5
  70. package/dist/write/move.js +313 -2
  71. package/dist/write/move.js.map +1 -1
  72. package/dist/write/operations.d.ts +49 -19
  73. package/dist/write/operations.js +2 -2
  74. package/dist/write/operations.js.map +1 -1
  75. package/dist/write/pipeline.js +25 -4
  76. package/dist/write/pipeline.js.map +1 -1
  77. package/dist/write/pre-state.d.ts +48 -28
  78. package/dist/write/pre-state.js +134 -13
  79. package/dist/write/pre-state.js.map +1 -1
  80. package/dist/write/reorder.js +3 -3
  81. package/dist/write/reorder.js.map +1 -1
  82. package/dist/write/resolution-timestamps.d.ts +34 -0
  83. package/dist/write/resolution-timestamps.js +286 -0
  84. package/dist/write/resolution-timestamps.js.map +1 -0
  85. package/dist/write/reversibility.js +5 -5
  86. package/dist/write/reversibility.js.map +1 -1
  87. package/dist/write/scope-guard.js +0 -5
  88. package/dist/write/scope-guard.js.map +1 -1
  89. package/dist/write/undo.d.ts +0 -6
  90. package/dist/write/undo.js +44 -4
  91. package/dist/write/undo.js.map +1 -1
  92. package/dist/write/vectors/applescript.js +14 -4
  93. package/dist/write/vectors/applescript.js.map +1 -1
  94. package/dist/write/vectors/url-scheme.js +0 -8
  95. package/dist/write/vectors/url-scheme.js.map +1 -1
  96. package/dist/write/verify/delta.d.ts +16 -2
  97. package/dist/write/verify/delta.js +21 -2
  98. package/dist/write/verify/delta.js.map +1 -1
  99. package/package.json +1 -1
  100. package/schema/envelope.schema.json +2 -78
  101. package/skills/things-cli/SKILL.md +7 -5
  102. package/skills/things-cli/references/contracts.md +3 -4
  103. package/skills/things-cli/references/model.md +8 -8
  104. package/skills/things-cli/references/ordering.md +16 -7
@@ -54,16 +54,17 @@
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); 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).
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 two `children` bucket records (the
60
+ * bucket key `today`/`evening` states it) and inside any card/heading `upcoming`
61
+ * DATE-GROUP for a member whose `when` equals the group's date (the group states
62
+ * it). KEPT everywhere
63
+ * else it is present including the flat `upcoming`/`anytime`/`inbox`/`someday`
64
+ * catalogues, search, changes (a deadline-pulled row reads `when: "today"` in the
65
+ * mixed search/changes surfaces, informatively; note R13 re-files it to stage
66
+ * `anytime` and the flat inbox/someday views now EXCLUDE it — it appears in the
67
+ * `anytime` catalogue instead, `when: "today"` kept, stage dropped as pure).
67
68
  *
68
69
  * ## R13 — provisional Today members + GUI-faithful pulled-row membership
69
70
  * BANNER1 / BANNER1b (docs/lab/banner1-research.md). Two coupled facts:
@@ -77,10 +78,10 @@
77
78
  * side effect our read cannot perform (watchers beware).
78
79
  * - **stage `anytime` for a deadline pull** — a due-deadline pull re-files an undated
79
80
  * Inbox/Someday row into Anytime (deriveStage step 2½, L-A). So EVERY Today member
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.
81
+ * derives stage `anytime`, and the `today` view's two `children` buckets become
82
+ * stage-PURE → `stage` is DROPPED there (TODAY_SECTION_DROP), alongside the
83
+ * key-implied `when`. The flat someday/inbox views EXCLUDE pulled rows and the anytime view
84
+ * INCLUDES them (src/read/views.ts + predicates.ts DEADLINE_PULLED) — GUI fidelity.
84
85
  *
85
86
  * ## Universal item-DTO reshapes (R9 — EVERY tier, EVERY read kind incl. detail)
86
87
  * - **checklist nesting** — flat counts → presence-keyed `checklist: {open,total}`.
@@ -340,9 +341,9 @@ function shapeItem(src, drop, compact, promoter) {
340
341
  if (drop.stage !== true)
341
342
  o["stage"] = stage;
342
343
  // R12 — the today/evening marker KEYS are replaced by the derived `when` on
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).
344
+ // EVERY tier (they never appear on the wire); `when` is emitted unless the
345
+ // enclosing context provably states the position (the today view's sections;
346
+ // a card date-grouphandled in rebucketChildren).
346
347
  delete o["today"];
347
348
  delete o["evening"];
348
349
  // §9n — a reminder byte is presentation-dead once its `startDate` goes strictly
@@ -429,40 +430,109 @@ function withShapedItems(base, drop, compact, promoter) {
429
430
  out["items"] = shapeList(base["items"], drop, compact, promoter);
430
431
  return out;
431
432
  }
432
- /** Coerce an unknown value to an array (empty when absent). */
433
- const asArray = (v) => (Array.isArray(v) ? v : []);
434
433
  /**
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.
434
+ * Re-bucket a project's / area's / heading's live (non-logbook/trash) children
435
+ * into the R10 card shape by their derived {@link deriveStage} — so the bucket an
436
+ * item lands in ALWAYS equals its `stage`:
437
+ * - `anytime` stage anytime, in encounter order;
438
+ * - `upcoming`stage upcoming, date-grouped `[{date, items}]` (a dated row under
439
+ * its `startDate`, a template under its `nextOccurrence`), date ASC; date-LESS
440
+ * templates (after-completion / paused) form a trailing `{date: null, items}`
441
+ * group (explicit null per the `area: null` section precedent);
442
+ * - `someday` — stage someday.
443
+ * Items are already in view order (index / date+todayIndex) from the read layer,
444
+ * so encounter order within a date group preserves that ordering. Each item is
445
+ * then run through {@link shapeItem} with the section drop (ancestry + `stage`,
446
+ * since the bucket states it).
441
447
  */
442
- const PROJECT_ITEM_DROP = { project: true, area: true, keepHeading: true };
448
+ function rebucketChildren(children, drop, compact, promoter) {
449
+ const anytime = [];
450
+ const someday = [];
451
+ const datedByKey = new Map();
452
+ const datedOrder = [];
453
+ const restingTemplates = [];
454
+ const shape = (c) => shapeItem(c, drop, compact, promoter);
455
+ for (const raw of children) {
456
+ if (raw === null || typeof raw !== "object")
457
+ continue;
458
+ const c = raw;
459
+ const stage = stageOf(c);
460
+ if (stage === "anytime") {
461
+ anytime.push(shape(c));
462
+ }
463
+ else if (stage === "someday") {
464
+ someday.push(shape(c));
465
+ }
466
+ else if (stage === "upcoming") {
467
+ const repeating = c["repeating"];
468
+ const nextOcc = repeating != null && typeof repeating === "object"
469
+ ? (repeating["nextOccurrence"] ?? null)
470
+ : null;
471
+ const date = (c.startDate ?? null) !== null ? c.startDate : nextOcc;
472
+ if (date === null) {
473
+ restingTemplates.push(shape(c));
474
+ }
475
+ else {
476
+ if (!datedByKey.has(date)) {
477
+ datedByKey.set(date, []);
478
+ datedOrder.push(date);
479
+ }
480
+ // R12 — inside a date-group the group states the date, so a member whose
481
+ // `when` equals it drops it (every scheduled row and every projected
482
+ // template does — that IS the group key).
483
+ const shaped = shape(c);
484
+ if (shaped !== null && typeof shaped === "object" && shaped["when"] === date) {
485
+ delete shaped["when"];
486
+ }
487
+ datedByKey.get(date).push(shaped);
488
+ }
489
+ }
490
+ else {
491
+ // inbox / logbook / trash should not appear among a card's live children;
492
+ // route defensively to anytime rather than drop the row.
493
+ anytime.push(shape(c));
494
+ }
495
+ }
496
+ const upcoming = datedOrder
497
+ .toSorted((a, b) => a.localeCompare(b))
498
+ .map((date) => ({ date, items: datedByKey.get(date) }));
499
+ if (restingTemplates.length > 0)
500
+ upcoming.push({ date: null, items: restingTemplates });
501
+ return { anytime, upcoming, someday };
502
+ }
503
+ /** Flatten an internal IsoDateGroup[] (`[{date, items}]`) to its items, in order. */
504
+ function flattenGroups(groups) {
505
+ if (!Array.isArray(groups))
506
+ return [];
507
+ const out = [];
508
+ for (const g of groups) {
509
+ if (g !== null && typeof g === "object" && Array.isArray(g["items"])) {
510
+ out.push(...g["items"]);
511
+ }
512
+ }
513
+ return out;
514
+ }
515
+ /** Coerce an unknown value to an array (empty when absent). */
516
+ const asArray = (v) => (Array.isArray(v) ? v : []);
443
517
  /**
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`.
518
+ * The R6 ref drop for the un-headed BODY's four `children` bucket records (v2):
519
+ * every body child drops project/area (the card states them), the bucket-implied
520
+ * stage (each of `anytime`/`upcoming`/`someday`/`logbook` is stage-pure), and the
521
+ * heading ref a body child is by construction un-headed (its `heading` is null),
522
+ * and a project view surfaces no bare `heading: null` (drop it explicitly rather
523
+ * than leaning on omit-empty).
453
524
  */
454
- const PROJECT_LOGBOOK_DROP = {
455
- project: true,
456
- area: true,
457
- keepHeading: true,
458
- };
525
+ const PROJECT_CHILD_DROP = { project: true, area: true, heading: true, stage: true };
459
526
  /**
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.
527
+ * The R6 ref drop for a HEADING's four `children` bucket records (v2): a headed
528
+ * child drops project/area (the card states them), the heading ref (its position
529
+ * UNDER `headings[].children` states membership structural, #362 / task item 6),
530
+ * and the bucket-implied stage. Applied uniformly to the heading's live buckets
531
+ * AND its `logbook` — the logbook is stage-pure (all logged), so stage drops too.
464
532
  */
465
- const AREA_ITEM_DROP = { area: true };
533
+ const HEADING_MEMBER_DROP = { project: true, area: true, heading: true, stage: true };
534
+ /** Area-view child-item buckets drop their area (the card states it) + the bucket-implied stage. */
535
+ const AREA_CHILD_DROP = { area: true, stage: true };
466
536
  /** Area-view PROJECTS list: a mixed listing of the area's project rows — keep `stage`, drop area. */
467
537
  const AREA_PROJECTS_DROP = { area: true };
468
538
  /**
@@ -481,31 +551,28 @@ const SOMEDAY_SECTION_DROP = { area: true, stage: true };
481
551
  /** The card NODE / detail / mixed lists: keep every ref, `stage`, and `when`. */
482
552
  const NO_DROP = {};
483
553
  /**
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.
554
+ * The today view's two `children` bucket records: drop the key-implied `when`
555
+ * (R12 the `today`/`evening` bucket key states it) AND the bucket-implied
556
+ * `stage` (R13). Every Today member now derives stage `anytime` by construction —
557
+ * an ARRIVED `startDate` (step 5) or a DEADLINE PULL (step 2½) both derive
558
+ * `anytime`, and there are no future-dated or undated-someday Today members — so
559
+ * both buckets are provably stage-PURE `anytime` and the field is redundant there
560
+ * (verified strict by the today purity property test in test/unit/stage.test.ts).
561
+ * `provisional` is NOT a drop the banner is not a bucket, so nothing implies it.
494
562
  */
495
- const TODAY_ITEM_DROP = { stage: true };
563
+ const TODAY_SECTION_DROP = { when: true, stage: true };
496
564
  /**
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:
565
+ * Shape a heading GROUP node (the `headings[].heading` / `loggedHeadings[].heading`
566
+ * keyed sub-object). The type is triply implied by position, and a heading has no
567
+ * open/canceled/completed vocabulary the reader needs — so:
501
568
  * - 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);
569
+ * to-do" convention is scoped to ROWS/candidates, never this keyed sub-object);
503
570
  * - DROP `project` (the card states it);
504
571
  * - REPLACE `status` with the presence-keyed `archived` (the stopDate, an ISO
505
572
  * 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).
573
+ * the heading is archived (status "completed"), OMITTED when open. Region
574
+ * membership (live `headings` vs the logged region) expresses sweep state; the
575
+ * node carries only whether-and-when it was archived.
509
576
  */
510
577
  function shapeHeadingNode(src) {
511
578
  if (src === null || typeof src !== "object")
@@ -523,92 +590,288 @@ function shapeHeadingNode(src) {
523
590
  h["archived"] = stopped;
524
591
  return h;
525
592
  }
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;
593
+ /** A bucket record `{items, total?}` (v2 R1): `total` present IFF the bucket was capped. */
594
+ export function bucketRecord(items, total) {
595
+ return total !== undefined && items.length < total ? { items, total } : { items };
596
+ }
597
+ /** The `stopped` epoch of an internal entity (a Date pre-shaping), or 0 — for logbook DESC ordering. */
598
+ function stoppedMs(o) {
599
+ const s = o["stopped"];
600
+ return s instanceof Date ? s.getTime() : 0;
601
+ }
602
+ /**
603
+ * Build ONE container's four v2 `children` bucket records (PR 2) from its flat
604
+ * child set (live AND logged alike). Every child is routed by DERIVED STAGE, so
605
+ * one entity lands in exactly one place (R5/#V12):
606
+ * - `logbook: {items, total?}` — the swept/resolved children (`logged` flag),
607
+ * most-recently-completed first (`stopped` DESC — the certified HEADARC3/logbook
608
+ * ordering), stage-pure so `stage` drops;
609
+ * - `anytime` / `someday: {items, total?}` — stage-pure records;
610
+ * - `upcoming: [{when, items, total?} …]` — the day-block ARRAY (R3): dated blocks
611
+ * chronological, then a single trailing `{when: null, items}` resting block for
612
+ * date-less recurring templates (#V8). An open child stranded under an archived
613
+ * heading (HEADARC2-C anomaly) is NOT logged, so it rides `anytime` here — its
614
+ * presence in a live bucket under an `archived` heading node is self-evident.
615
+ * `drop` carries the container's ancestry drops (body vs heading); the day-block
616
+ * `when` renames the internal date group's `date` (rebucketChildren) — `null` for
617
+ * the resting block. No bucket is capped in the project view today, so every
618
+ * `total` is absent (R1: an untruncated bucket never restates its length); the
619
+ * `total?` argument keeps the record + day-block shape ready for PR 5's sweep and
620
+ * is exercised by the unit tests.
621
+ */
622
+ function shapeContainerChildren(children, drop, compact, promoter) {
623
+ const live = [];
624
+ const logged = [];
625
+ for (const c of asArray(children)) {
626
+ if (c !== null && typeof c === "object" && c["logged"] === true)
627
+ logged.push(c);
628
+ else
629
+ live.push(c);
630
+ }
631
+ const { anytime, upcoming, someday } = rebucketChildren(live, drop, compact, promoter);
632
+ const loggedSorted = logged.toSorted((a, b) => stoppedMs(b) - stoppedMs(a));
633
+ const logbook = shapeList(loggedSorted, drop, compact, promoter);
634
+ return {
635
+ anytime: bucketRecord(anytime),
636
+ // The day-block ARRAY: `date` → `when` (R3); `null` is the resting block (#V8).
637
+ upcoming: upcoming.map((g) => ({ when: g.date, items: g.items })),
638
+ someday: bucketRecord(someday),
639
+ logbook: bucketRecord(logbook),
531
640
  };
532
- return t(b) - t(a);
533
641
  }
534
642
  /**
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.
643
+ * Shape a project view into the read-shape v2 wire (PR 2):
644
+ * `{ project, children, headings[] }` NOTHING else at this level. `children` is
645
+ * the un-headed BODY's four stage-keyed bucket records; `headings[]` is EVERY
646
+ * heading (index order, all lifecycle classes R5) as `{uuid, title, archived?,
647
+ * children}` with the SAME recursive `children` shape. The per-container `logbook`
648
+ * lives inside each `children` (R6, no root logbook); the v1-era `logbookHeadings`
649
+ * and BOTH advisory keys (`openChildrenWhileResolved` /
650
+ * `openChildrenUnderArchivedHeading`) are DELETED (#V12) anomalous open children
651
+ * seat in the normal recursive buckets, the heading's `archived` mark making the
652
+ * anomaly self-evident. The card node keeps everything (children derive their
653
+ * container from it). `out` is built fresh, so no render-only field leaks.
545
654
  */
546
655
  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);
560
- const out = { ...view };
561
- delete out["active"];
562
- delete out["scheduled"];
563
- delete out["repeating"];
564
- delete out["someday"];
565
- delete out["logged"];
566
- delete out["loggedHeadings"];
567
- delete out["headingCatalog"];
568
- // Trashed children live only in `things trash` never a project-view bucket.
569
- // Delete defensively in case an untyped source carries the old key.
570
- delete out["trashed"];
571
- // The project card NODE keeps everything (children derive their container from
572
- // it), but is still an item DTO, so the universal + R10 reshapes apply.
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);
578
- out["headings"] = headings;
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);
583
- return out;
656
+ const headingContainers = asArray(view["headingContainers"]).map((c) => {
657
+ const grp = (c ?? {});
658
+ // The heading NODE (`{uuid, title, archived?}`) gains the recursive `children`
659
+ // (last key, so it reads after the identity). Object.assign mutates the fresh
660
+ // node copy shapeHeadingNode already returns — no spread-in-map.
661
+ const node = shapeHeadingNode(grp["heading"]);
662
+ return Object.assign(node, {
663
+ children: shapeContainerChildren(grp["children"], HEADING_MEMBER_DROP, compact, promoter),
664
+ });
665
+ });
666
+ return {
667
+ // The card NODE keeps everything but is still an item DTO (universal + R10 reshapes).
668
+ project: shapeItem(view["project"], NO_DROP, false, promoter),
669
+ children: shapeContainerChildren(view["bodyChildren"], PROJECT_CHILD_DROP, compact, promoter),
670
+ headings: headingContainers,
671
+ };
672
+ }
673
+ /**
674
+ * Build an area's THREE v2 `children` bucket records (PR 3) from its flat direct
675
+ * to-do set (live only — an area has NO logged-children region, so no `logbook`
676
+ * key; the area logbook is the bounded query `things logbook --area <ref>`, #346).
677
+ * The same stage-derived bucketing as {@link shapeContainerChildren} minus the
678
+ * logbook split: `anytime`/`someday` are `{items, total?}` records, `upcoming` is
679
+ * the day-block ARRAY (R3, `date` → `when`) with the trailing `{when: null, items}`
680
+ * resting block for date-less recurring templates (#V8). Inline `total` is stamped
681
+ * downstream by {@link withAreaBucketTotals} (only `anytime` can be capped the
682
+ * `--area-limit` scope; the scheduled/someday direct to-dos always survive).
683
+ */
684
+ function shapeAreaChildren(members, drop, compact, promoter) {
685
+ const { anytime, upcoming, someday } = rebucketChildren(members, drop, compact, promoter);
686
+ return {
687
+ anytime: bucketRecord(anytime),
688
+ // The day-block ARRAY: `date` `when` (R3); `null` is the resting block (#V8).
689
+ upcoming: upcoming.map((g) => ({ when: g.date, items: g.items })),
690
+ someday: bucketRecord(someday),
691
+ };
584
692
  }
585
693
  /**
586
- * Shape an area view (read-shape doctrine §3.13). The direct to-dos dissolve into
587
- * ONE flat `items[]` in index ordereach 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).
694
+ * Shape an area view into the read-shape v2 wire (PR 3):
695
+ * `{ area | null, children, projects }` NOTHING else at this level. `children`
696
+ * is the area's direct to-dos as three stage-keyed bucket records (`anytime`,
697
+ * `upcoming[]`, `someday` NO `logbook`, #346); `projects` is the child-project
698
+ * sidebar-rank scope as a bucket record `{items, total?}` a mixed-stage listing
699
+ * that KEEPS `stage`/`when` (the someday-projects / active split is TTY-only). The
700
+ * loose pseudo-area keeps `area: null`. The area node keeps its identity (tags
701
+ * folded to names); each direct-to-do row drops `area` (the node states it) + the
702
+ * bucket-implied `stage`. Inline `total` (present iff a scope was capped, R1) is
703
+ * injected downstream by {@link withAreaBucketTotals}, where the pre-cap sizes are
704
+ * known. `out` is built fresh, so no render-only field leaks.
593
705
  */
594
706
  function shapeAreaView(view, compact, promoter) {
595
- const out = { ...view };
596
- delete out["active"];
597
- delete out["scheduled"];
598
- delete out["repeating"];
599
- delete out["someday"];
600
- // No `logbook` or `trash` bucket: an area's logbook is the bounded query
601
- // `things logbook --area <ref>`, and trashed rows live only in `things trash`.
602
- // Delete defensively in case an untyped source carries the old keys.
603
- delete out["logged"];
604
- delete out["trashed"];
605
- out["area"] = shapeArea(view["area"]);
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);
611
- return out;
707
+ const looseMembers = [
708
+ ...asArray(view["active"]),
709
+ ...flattenGroups(view["scheduled"]),
710
+ ...asArray(view["someday"]),
711
+ ...asArray(view["repeating"]),
712
+ ];
713
+ return {
714
+ // The area NODE, or `null` for the loose pseudo-area (shapeArea passes null).
715
+ area: shapeArea(view["area"]),
716
+ children: shapeAreaChildren(looseMembers, AREA_CHILD_DROP, compact, promoter),
717
+ // The projects list is a mixed listing of the area's project rows — keep stage.
718
+ projects: bucketRecord(shapeList(view["projects"], AREA_PROJECTS_DROP, compact, promoter)),
719
+ };
720
+ }
721
+ /**
722
+ * Inject the area view's inline scope `total`s (read-shape v2 R1, PR 3): present
723
+ * iff the scope was capped (`items.length < total`), absent otherwise — no
724
+ * `meta.truncation.blocks[]` sidecar. `children.anytime` carries the direct-to-dos
725
+ * (`--area-limit`) total; `projects` carries the project-rows (`--project-limit`)
726
+ * total. The scheduled/someday direct-to-do blocks and the scheduled/someday
727
+ * project rows are never capped, so they never gain a `total`. Both the CLI `view`
728
+ * wrapper and the MCP data block run the shaped view through this so completeness
729
+ * is answerable locally. Returns the view unchanged when it is not the expected
730
+ * shape.
731
+ */
732
+ export function withAreaBucketTotals(view, totals) {
733
+ if (view === null || typeof view !== "object")
734
+ return view;
735
+ const v = view;
736
+ const children = v["children"];
737
+ const withChildTotals = children !== null && typeof children === "object"
738
+ ? {
739
+ ...children,
740
+ anytime: withBucketTotal(children["anytime"], totals.anytime),
741
+ }
742
+ : children;
743
+ // Spread-then-override keeps the `area` / `children` / `projects` key order.
744
+ return {
745
+ ...v,
746
+ children: withChildTotals,
747
+ projects: withBucketTotal(v["projects"], totals.projects),
748
+ };
749
+ }
750
+ /**
751
+ * The global `upcoming` view's DAY-BLOCK key for one raw item (read-shape v2 PR 4):
752
+ * its `startDate` when scheduled; else, for a NON-template, its `deadline` (a
753
+ * deadline-forecast row appears at its due day — cohort 2, UPC1); else `null` —
754
+ * a date-LESS recurring template rides the trailing resting block (#V8). This is
755
+ * the emit-boundary twin of the renderer's `groupDate` (src/cli/render.ts) and of
756
+ * {@link upcomingBlockTotals} (the pre-cap sizer), so the wire's day blocks match
757
+ * the TTY grouping row-for-row and each block's inline `total` lines up with its
758
+ * scope. The library keeps its own day grouping; only the wire reshapes here.
759
+ */
760
+ function upcomingBlockKey(o) {
761
+ const startDate = o["startDate"] ?? null;
762
+ if (startDate !== null)
763
+ return startDate;
764
+ const repeating = o["repeating"];
765
+ const isTemplate = repeating !== null &&
766
+ typeof repeating === "object" &&
767
+ repeating["isTemplate"] === true;
768
+ if (isTemplate)
769
+ return null; // a date-less template → the resting block (#V8)
770
+ return o["deadline"] ?? null; // a forecast row appears at its deadline
771
+ }
772
+ /**
773
+ * Reshape the global `upcoming` view into the read-shape v2 day-block sections
774
+ * (PR 4): `[{ when, items, total? } …]` — chronological dated blocks keyed by
775
+ * {@link upcomingBlockKey} (each the COMPLETE global day scope, its `when` doubling
776
+ * as the `--in <when>` reorder token), then ONE trailing `{ when: null, items }`
777
+ * block holding the date-less resting recurring templates (#V8) when any exist.
778
+ * The incoming stream is already day-ordered (COALESCE(startDate, deadline) ASC,
779
+ * then the UI's within-day drag order), so encounter order preserves both the
780
+ * block chronology and the within-block order — no re-sort, matching the renderer.
781
+ * Rows KEEP `stage` (the view is projection-side stage-MIXED, R7: future-dated
782
+ * `upcoming` rows beside deadline-forecast `anytime`/`someday` ones) and drop
783
+ * `when` only when it equals the block's date (the block states it — the same rule
784
+ * {@link rebucketChildren} applies to a container day block); a forecast row's
785
+ * `when` is absent already, and a divergent projected `when` (horizon > 1) is kept.
786
+ * Every row keeps its container refs (a global mixed view — NO_DROP). Inline
787
+ * `total` is stamped downstream by {@link withUpcomingBlockTotals}.
788
+ */
789
+ function shapeUpcomingView(items, compact, promoter) {
790
+ const datedByKey = new Map();
791
+ const datedOrder = [];
792
+ const resting = [];
793
+ for (const raw of items) {
794
+ if (raw === null || typeof raw !== "object")
795
+ continue;
796
+ const key = upcomingBlockKey(raw);
797
+ const shaped = shapeItem(raw, NO_DROP, compact, promoter);
798
+ if (key === null) {
799
+ resting.push(shaped);
800
+ continue;
801
+ }
802
+ if (!datedByKey.has(key)) {
803
+ datedByKey.set(key, []);
804
+ datedOrder.push(key);
805
+ }
806
+ // R12 — inside a dated block the block states the date, so a member whose
807
+ // `when` equals it drops it (a scheduled row's when IS the key). A forecast
808
+ // row has no `when`; a horizon-projected row whose `when` diverges keeps it.
809
+ if (shaped !== null && typeof shaped === "object" && shaped["when"] === key) {
810
+ delete shaped["when"];
811
+ }
812
+ datedByKey.get(key).push(shaped);
813
+ }
814
+ const sections = datedOrder.map((when) => ({ when, items: datedByKey.get(when) }));
815
+ if (resting.length > 0)
816
+ sections.push({ when: null, items: resting });
817
+ return sections;
818
+ }
819
+ /**
820
+ * Inject each global-`upcoming` day block's inline `total` (read-shape v2 R1,
821
+ * PR 4): present iff that day's scope was capped by the flat row limit
822
+ * (`items.length < total`), absent otherwise — no `meta.truncation.blocks[]`
823
+ * sidecar (the whole-view `{shown,total,limit,truncated}` rollup still rides
824
+ * `meta.truncation` for the row hint). The flat cut across the day-ordered stream
825
+ * leaves at most ONE straddling block partial (its pre-cap size looked up by
826
+ * `when` from `totals`); blocks fully before the cut are complete (no `total`),
827
+ * and blocks fully past it never appear. The resting block keys on `null`. Both
828
+ * the CLI `sections` wrapper and the MCP data block run the shaped sections
829
+ * through this so completeness is answerable locally. Returns the input unchanged
830
+ * when it is not the expected sections array.
831
+ */
832
+ export function withUpcomingBlockTotals(sections, totals) {
833
+ if (!Array.isArray(sections))
834
+ return sections;
835
+ return sections.map((s) => {
836
+ if (s === null || typeof s !== "object")
837
+ return s;
838
+ const sec = s;
839
+ const when = (sec["when"] ?? null);
840
+ const total = totals.get(when);
841
+ const items = sec["items"];
842
+ const shown = Array.isArray(items) ? items.length : 0;
843
+ // Spread-then-add keeps the `when` / `items` / `total` key order.
844
+ return total !== undefined && shown < total ? { ...sec, total } : sec;
845
+ });
846
+ }
847
+ /**
848
+ * Inject each global anytime/someday section's inline `total` (read-shape v2 R1,
849
+ * PR 5): present iff that section's `items` were capped (`items.length < total`),
850
+ * absent otherwise — an untruncated section never restates its own length, and
851
+ * the pre-v2 `meta.truncation.blocks[]` descriptor-join sidecar is RETIRED. The
852
+ * pre-cap section sizes come from {@link previewSections}/{@link
853
+ * previewSomedaySections} keyed by area uuid (`null` for the loose section); the
854
+ * per-block "… N more" render detail is carried separately (internal
855
+ * {@link GroupBlock}[]), never on the wire. Both the CLI `sections` wrapper and
856
+ * the MCP data block run the shaped sections through this so completeness is
857
+ * answerable locally. Returns the input unchanged when it is not the expected
858
+ * sections array.
859
+ */
860
+ export function withSectionTotals(sections, totals) {
861
+ if (!Array.isArray(sections))
862
+ return sections;
863
+ return sections.map((s) => {
864
+ if (s === null || typeof s !== "object")
865
+ return s;
866
+ const sec = s;
867
+ const area = sec["area"];
868
+ const key = area !== null && typeof area === "object" ? area["uuid"] : null;
869
+ const total = totals.get(key);
870
+ const items = sec["items"];
871
+ const shown = Array.isArray(items) ? items.length : 0;
872
+ // Spread-then-add keeps the `area` / `items` / `total` key order.
873
+ return total !== undefined && shown < total ? { ...sec, total } : sec;
874
+ });
612
875
  }
613
876
  /** Fold an area entity's tags to string names in place (returns a shallow copy). */
614
877
  function shapeArea(src) {
@@ -618,6 +881,53 @@ function shapeArea(src) {
618
881
  flattenTags(o);
619
882
  return o;
620
883
  }
884
+ /**
885
+ * Shape the today view into its two `children` bucket records (read-shape v2 R1):
886
+ * `{ today: { items }, evening: { items } }`, each a stage/`when`-pure list (the
887
+ * bucket key states both — TODAY_SECTION_DROP). The whole-view `counts` aggregate
888
+ * is NOT here — it rides `meta.counts` (runRead / the MCP metadata block). Inline
889
+ * per-bucket `total` (present iff capped) is injected downstream by
890
+ * {@link withTodayBucketTotals}, where the pre-cap sizes are known.
891
+ */
892
+ function shapeTodayView(view, compact, promoter) {
893
+ return {
894
+ today: { items: shapeList(view["today"], TODAY_SECTION_DROP, compact, promoter) },
895
+ evening: { items: shapeList(view["evening"], TODAY_SECTION_DROP, compact, promoter) },
896
+ };
897
+ }
898
+ /**
899
+ * Inject each today bucket's inline `total` (read-shape v2 R1): present iff the
900
+ * bucket was capped (`items.length < total`), absent otherwise — an untruncated
901
+ * bucket never restates its own length. `totals` are the pre-cap bucket sizes
902
+ * from `truncateToday`. Both the CLI `data.children` wrapper and the MCP data
903
+ * block run the shaped children through this so completeness is answerable
904
+ * locally, with no truncation sidecar. Returns the children object unchanged when
905
+ * it is not the expected shape.
906
+ */
907
+ export function withTodayBucketTotals(children, totals) {
908
+ if (children === null || typeof children !== "object")
909
+ return children;
910
+ const c = children;
911
+ return {
912
+ today: withBucketTotal(c["today"], totals.today),
913
+ evening: withBucketTotal(c["evening"], totals.evening),
914
+ };
915
+ }
916
+ /**
917
+ * Stamp a bucket record's inline `total` (read-shape v2 R1) iff it was capped
918
+ * (`items.length < total`) — an untruncated bucket never restates its own length.
919
+ * The pre-cap `total` comes from the bounding layer; returns the bucket unchanged
920
+ * when it is not a `{items}` record. Shared by the today and area inline-total
921
+ * injectors.
922
+ */
923
+ function withBucketTotal(bucket, total) {
924
+ if (bucket === null || typeof bucket !== "object")
925
+ return bucket;
926
+ const b = bucket;
927
+ const items = b["items"];
928
+ const shown = Array.isArray(items) ? items.length : 0;
929
+ return shown < total ? { ...b, total } : b;
930
+ }
621
931
  /** Shape sidebar sections (anytime/someday catalogues) with the section's drop spec. */
622
932
  function shapeSections(sections, drop, compact, promoter) {
623
933
  if (!Array.isArray(sections))
@@ -628,14 +938,13 @@ function shapeSections(sections, drop, compact, promoter) {
628
938
  * The flat, mixed-provenance list kinds mapped to their drop spec. Only the
629
939
  * stage-PURE catalogues (inbox/someday/logbook/trash; the section-based `anytime`
630
940
  * is pure too, handled via shapeSections below) drop the bucket-implied `stage`.
631
- * `upcoming` KEEPS it (R10.2): the Upcoming view is stage-mixed — it carries
632
- * deadline-forecast stage-`anytime`/`someday` rows alongside future-dated
633
- * stage-`upcoming` ones. The mixed/derived surfaces (search/changes/projects)
634
- * keep it too.
941
+ * The mixed/derived surfaces (search/changes/projects) keep it. The global
942
+ * `upcoming` view is NOT here — it reshapes into `data.sections` day blocks
943
+ * ({@link shapeUpcomingView}), keeping `stage` (R10.2: stage-mixed future-dated
944
+ * `upcoming` rows beside deadline-forecast `anytime`/`someday` ones).
635
945
  */
636
946
  const FLAT_LIST_DROP = new Map([
637
947
  ["inbox", { stage: true }],
638
- ["upcoming", NO_DROP],
639
948
  ["logbook", { stage: true }],
640
949
  ["trash", { stage: true }],
641
950
  ["changes", NO_DROP],
@@ -661,12 +970,11 @@ export function shapeReadPayload(kind, data, full, promoter) {
661
970
  const flatDrop = FLAT_LIST_DROP.get(kind);
662
971
  if (flatDrop !== undefined)
663
972
  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).
973
+ // The global `upcoming` view reshapes into `data.sections` day blocks (PR 4).
974
+ if (kind === "upcoming" && Array.isArray(data))
975
+ return shapeUpcomingView(data, compact, p);
668
976
  if (kind === "today" && data !== null && typeof data === "object") {
669
- return shapeList(data["items"], TODAY_ITEM_DROP, compact, p);
977
+ return shapeTodayView(data, compact, p);
670
978
  }
671
979
  if (kind === "anytime" && Array.isArray(data)) {
672
980
  return shapeSections(data, ANYTIME_SECTION_DROP, compact, p); // stage-pure → drop stage