things-api 0.13.0 → 0.15.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 (133) hide show
  1. package/README.md +3 -3
  2. package/dist/cli/commands/area.d.ts +5 -5
  3. package/dist/cli/commands/area.js +13 -8
  4. package/dist/cli/commands/area.js.map +1 -1
  5. package/dist/cli/commands/project.js +2 -2
  6. package/dist/cli/commands/project.js.map +1 -1
  7. package/dist/cli/commands/reads.js +94 -16
  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 +2 -2
  12. package/dist/cli/commands/todo.js.map +1 -1
  13. package/dist/cli/commands/writes.js +136 -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 +20 -15
  17. package/dist/cli/glyphs.js.map +1 -1
  18. package/dist/cli/help.js +4 -2
  19. package/dist/cli/help.js.map +1 -1
  20. package/dist/cli/read-driver.d.ts +45 -9
  21. package/dist/cli/read-driver.js +39 -16
  22. package/dist/cli/read-driver.js.map +1 -1
  23. package/dist/cli/render.d.ts +57 -27
  24. package/dist/cli/render.js +117 -65
  25. package/dist/cli/render.js.map +1 -1
  26. package/dist/client.d.ts +127 -38
  27. package/dist/client.js +86 -22
  28. package/dist/client.js.map +1 -1
  29. package/dist/contracts.d.ts +35 -34
  30. package/dist/contracts.js +1 -1
  31. package/dist/contracts.js.map +1 -1
  32. package/dist/index.d.ts +9 -7
  33. package/dist/index.js +3 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/server.js +212 -148
  36. package/dist/mcp/server.js.map +1 -1
  37. package/dist/model/dates.d.ts +26 -0
  38. package/dist/model/dates.js +46 -4
  39. package/dist/model/dates.js.map +1 -1
  40. package/dist/model/entities.d.ts +61 -39
  41. package/dist/model/entities.js +0 -4
  42. package/dist/model/entities.js.map +1 -1
  43. package/dist/model/mappers.js +24 -43
  44. package/dist/model/mappers.js.map +1 -1
  45. package/dist/read/area-filter.d.ts +1 -1
  46. package/dist/read/area-filter.js +5 -4
  47. package/dist/read/area-filter.js.map +1 -1
  48. package/dist/read/area-view.d.ts +0 -11
  49. package/dist/read/area-view.js +7 -11
  50. package/dist/read/area-view.js.map +1 -1
  51. package/dist/read/detail.js +2 -2
  52. package/dist/read/detail.js.map +1 -1
  53. package/dist/read/filter-contract.d.ts +1 -1
  54. package/dist/read/filter-contract.js +3 -0
  55. package/dist/read/filter-contract.js.map +1 -1
  56. package/dist/read/log-boundary.d.ts +46 -2
  57. package/dist/read/log-boundary.js +43 -2
  58. package/dist/read/log-boundary.js.map +1 -1
  59. package/dist/read/predicates.d.ts +13 -0
  60. package/dist/read/predicates.js +13 -0
  61. package/dist/read/predicates.js.map +1 -1
  62. package/dist/read/project-view.d.ts +34 -19
  63. package/dist/read/project-view.js +31 -25
  64. package/dist/read/project-view.js.map +1 -1
  65. package/dist/read/scope.d.ts +1 -1
  66. package/dist/read/scope.js +5 -4
  67. package/dist/read/scope.js.map +1 -1
  68. package/dist/read/search-rank.js +2 -2
  69. package/dist/read/search-rank.js.map +1 -1
  70. package/dist/read/shape.d.ts +78 -18
  71. package/dist/read/shape.js +496 -185
  72. package/dist/read/shape.js.map +1 -1
  73. package/dist/read/snapshot.js +2 -2
  74. package/dist/read/snapshot.js.map +1 -1
  75. package/dist/read/stage.d.ts +31 -13
  76. package/dist/read/stage.js +28 -11
  77. package/dist/read/stage.js.map +1 -1
  78. package/dist/read/truncation.d.ts +75 -2
  79. package/dist/read/truncation.js +69 -46
  80. package/dist/read/truncation.js.map +1 -1
  81. package/dist/read/views.d.ts +81 -18
  82. package/dist/read/views.js +136 -17
  83. package/dist/read/views.js.map +1 -1
  84. package/dist/surface-copy.d.ts +6 -0
  85. package/dist/surface-copy.js +6 -0
  86. package/dist/surface-copy.js.map +1 -1
  87. package/dist/write/batch.js +0 -1
  88. package/dist/write/batch.js.map +1 -1
  89. package/dist/write/clear-reminder.js +3 -1
  90. package/dist/write/clear-reminder.js.map +1 -1
  91. package/dist/write/commands.d.ts +25 -1
  92. package/dist/write/commands.js +350 -134
  93. package/dist/write/commands.js.map +1 -1
  94. package/dist/write/guards.js +36 -16
  95. package/dist/write/guards.js.map +1 -1
  96. package/dist/write/move.d.ts +26 -5
  97. package/dist/write/move.js +358 -2
  98. package/dist/write/move.js.map +1 -1
  99. package/dist/write/operations.d.ts +50 -19
  100. package/dist/write/operations.js +3 -2
  101. package/dist/write/operations.js.map +1 -1
  102. package/dist/write/pipeline.js +28 -7
  103. package/dist/write/pipeline.js.map +1 -1
  104. package/dist/write/pre-state.d.ts +89 -28
  105. package/dist/write/pre-state.js +197 -19
  106. package/dist/write/pre-state.js.map +1 -1
  107. package/dist/write/reorder.js +52 -5
  108. package/dist/write/reorder.js.map +1 -1
  109. package/dist/write/resolution-timestamps.d.ts +34 -0
  110. package/dist/write/resolution-timestamps.js +286 -0
  111. package/dist/write/resolution-timestamps.js.map +1 -0
  112. package/dist/write/reversibility.js +10 -5
  113. package/dist/write/reversibility.js.map +1 -1
  114. package/dist/write/scope-guard.js +2 -5
  115. package/dist/write/scope-guard.js.map +1 -1
  116. package/dist/write/undo.d.ts +1 -7
  117. package/dist/write/undo.js +63 -16
  118. package/dist/write/undo.js.map +1 -1
  119. package/dist/write/vectors/applescript.js +23 -4
  120. package/dist/write/vectors/applescript.js.map +1 -1
  121. package/dist/write/vectors/simulator.js +210 -9
  122. package/dist/write/vectors/simulator.js.map +1 -1
  123. package/dist/write/vectors/url-scheme.js +0 -8
  124. package/dist/write/vectors/url-scheme.js.map +1 -1
  125. package/dist/write/verify/delta.d.ts +71 -12
  126. package/dist/write/verify/delta.js +94 -12
  127. package/dist/write/verify/delta.js.map +1 -1
  128. package/package.json +1 -1
  129. package/schema/envelope.schema.json +19 -70
  130. package/skills/things-cli/SKILL.md +13 -9
  131. package/skills/things-cli/references/contracts.md +3 -4
  132. package/skills/things-cli/references/{model.md → data-model.md} +10 -8
  133. package/skills/things-cli/references/ordering.md +18 -7
@@ -32,10 +32,11 @@
32
32
  * non-redundant information — plus `today` (mixes upcoming + anytime-deadline
33
33
  * rows), search, changes, the projects/areas listings, the card NODE, and
34
34
  * detail.
35
- * - the former `todaySection` field is RETIRED from the wire entirely (R10.1):
36
- * `todaySection: "evening"` merely duplicated the `evening: true` marker. It
37
- * remains an internal entity field (the human render and the write-verify delta
38
- * still read it); shaping deletes it from the JSON copy.
35
+ * - the former `todaySection` field is DELETED from the entity model entirely:
36
+ * it merely duplicated the presence-keyed `today`/`evening` markers
37
+ * (`todaySection: "evening"` `evening: true`; `"today"` `today && !evening`),
38
+ * which every consumer now reads directly (the human render, the write-verify
39
+ * delta, the today-view evening split).
39
40
  *
40
41
  * ## R12 — `when`, the derived TIME-AXIS position (replaces startDate + markers)
41
42
  * Today/evening membership and the scheduled/projected date collapse onto ONE
@@ -54,16 +55,17 @@
54
55
  * substrate) and KEPT in FULL/DETAIL beside `when` — different facts: `startDate`
55
56
  * = what is stored, `when` = where it sits.
56
57
  * - 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).
58
+ * it (same fact, one word); the resting-templates `{date: null}` group is
59
+ * unchanged (an unprojected template has no `when`).
60
+ * - `when` is DROPPED inside the `today` view's two `children` bucket records (the
61
+ * bucket key `today`/`evening` states it) and inside any card/heading `upcoming`
62
+ * DATE-GROUP for a member whose `when` equals the group's date (the group states
63
+ * it). KEPT everywhere
64
+ * else it is present including the flat `upcoming`/`anytime`/`inbox`/`someday`
65
+ * catalogues, search, changes (a deadline-pulled row reads `when: "today"` in the
66
+ * mixed search/changes surfaces, informatively; note R13 re-files it to stage
67
+ * `anytime` and the flat inbox/someday views now EXCLUDE it — it appears in the
68
+ * `anytime` catalogue instead, `when: "today"` kept, stage dropped as pure).
67
69
  *
68
70
  * ## R13 — provisional Today members + GUI-faithful pulled-row membership
69
71
  * BANNER1 / BANNER1b (docs/lab/banner1-research.md). Two coupled facts:
@@ -77,10 +79,10 @@
77
79
  * side effect our read cannot perform (watchers beware).
78
80
  * - **stage `anytime` for a deadline pull** — a due-deadline pull re-files an undated
79
81
  * 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.
82
+ * derives stage `anytime`, and the `today` view's two `children` buckets become
83
+ * stage-PURE → `stage` is DROPPED there (TODAY_SECTION_DROP), alongside the
84
+ * key-implied `when`. The flat someday/inbox views EXCLUDE pulled rows and the anytime view
85
+ * INCLUDES them (src/read/views.ts + predicates.ts DEADLINE_PULLED) — GUI fidelity.
84
86
  *
85
87
  * ## Universal item-DTO reshapes (R9 — EVERY tier, EVERY read kind incl. detail)
86
88
  * - **checklist nesting** — flat counts → presence-keyed `checklist: {open,total}`.
@@ -256,22 +258,23 @@ function reshapeRepeatingWire(o) {
256
258
  o["instanceOf"] = r["templateUuid"];
257
259
  }
258
260
  }
259
- /** The R10 stage input read straight off a materialized task entity. */
261
+ /** The R10 stage input read straight off a materialized task entity's `derived` bag. */
260
262
  function stageOf(s) {
261
263
  const repeating = s["repeating"];
262
264
  const isTemplate = repeating !== null &&
263
265
  typeof repeating === "object" &&
264
266
  repeating["isTemplate"] === true;
267
+ const d = (s["derived"] ?? {});
265
268
  return deriveStage({
266
- trashed: s["trashed"] === true,
267
- logged: s["logged"] === true,
268
- start: s["start"],
269
+ trashed: d["trashed"] === true,
270
+ logged: d["logged"] === true,
271
+ start: d["start"],
269
272
  startDate: s["startDate"] ?? null,
270
273
  repeating: { isTemplate },
271
274
  // The presence-keyed Today marker (stamped at materialize with the response
272
275
  // clock) discriminates an ARRIVED dated row (→ anytime) from a strictly-
273
- // future one (→ upcoming). Read BEFORE the marker is stripped downstream.
274
- today: s["today"] === true,
276
+ // future one (→ upcoming). Read from the `derived` substrate bag.
277
+ today: d["today"] === true,
275
278
  });
276
279
  }
277
280
  /** The R12 `when` input read straight off a materialized task entity (given its stage). */
@@ -283,12 +286,13 @@ function whenOf(s, stage) {
283
286
  const nextOccurrence = isTemplate && typeof repeating["nextOccurrence"] === "string"
284
287
  ? repeating["nextOccurrence"]
285
288
  : null;
289
+ const d = (s["derived"] ?? {});
286
290
  return deriveWhen({
287
291
  stage,
288
292
  // The SAME presence-keyed markers stageOf reads — never re-derived, so a
289
293
  // `when` of today/evening can never disagree with Today-view membership.
290
- today: s["today"] === true,
291
- evening: s["evening"] === true,
294
+ today: d["today"] === true,
295
+ evening: d["evening"] === true,
292
296
  startDate: s["startDate"] ?? null,
293
297
  repeating: { isTemplate, nextOccurrence },
294
298
  });
@@ -320,7 +324,7 @@ function shapeItem(src, drop, compact, promoter) {
320
324
  // section, so no enclosing node implies it. The app rewrites start/startDate
321
325
  // when the user acknowledges the banner; that is a GUI-only side effect our
322
326
  // read cannot clear (watchers beware — see contract.md `provisional`).
323
- const provisional = whenIsProvisional(when, s["start"], s["startDate"] ?? null);
327
+ const provisional = whenIsProvisional(when, (s["derived"] ?? {})["start"], s["startDate"] ?? null);
324
328
  const o = { ...s };
325
329
  // R9 universal reshapes (every tier, every kind incl. detail).
326
330
  reshapeChecklist(o);
@@ -330,29 +334,20 @@ function shapeItem(src, drop, compact, promoter) {
330
334
  if (o["project"] == null && o["headingProject"] != null)
331
335
  o["project"] = o["headingProject"];
332
336
  delete o["headingProject"];
333
- // R10 the three lifecycle fields are replaced by the one derived `stage`.
334
- delete o["start"];
335
- delete o["logged"];
336
- delete o["trashed"];
337
- // R10.1 `todaySection` is retired from the wire (it duplicated `evening`);
338
- // the internal entity keeps it for the render / write-verify paths.
339
- delete o["todaySection"];
337
+ // The ENTIRE internal derivation substrate leaves the wire in ONE structural
338
+ // drop (one-vocabulary Batch 2, Option B): `start`/`logged`/`trashed` (R10 —
339
+ // replaced by `stage`), `today`/`evening` (R12 — replaced by `when`), and the
340
+ // raw `reminder` byte all live in the nested `o.derived` bag. §9n: the
341
+ // top-level consumer `reminder` is ALREADY the live-gated value (null once the
342
+ // byte is presentation-dead its `startDate` gone strictly past), gated at the
343
+ // mapper; a null key is pruned by omit-empty, so a stale reminder never reaches
344
+ // the wire without any drop here. `stage`/`when`/`provisional` are then stamped
345
+ // from the derivations above.
346
+ delete o["derived"];
340
347
  if (drop.stage !== true)
341
348
  o["stage"] = stage;
342
- // 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).
346
- delete o["today"];
347
- delete o["evening"];
348
- // §9n — a reminder byte is presentation-dead once its `startDate` goes strictly
349
- // past: the GUI hides the bell but never clears the byte. The materialize-time
350
- // `reminderLive` marker (mappers, via `reminderIsLive` under the response clock)
351
- // says whether the stored reminder still renders; drop the `reminder` key when
352
- // it does not, mirroring the GUI. The marker itself never rides the wire.
353
- if (o["reminderLive"] !== true)
354
- delete o["reminder"];
355
- delete o["reminderLive"];
349
+ // R12 — `when` is emitted unless the enclosing context provably states the
350
+ // position (the today view's sections; a card date-group in rebucketChildren).
356
351
  if (drop.when !== true && when !== undefined)
357
352
  o["when"] = when;
358
353
  // R13 — the provisional banner marker (never dropped; presence-keyed).
@@ -429,40 +424,109 @@ function withShapedItems(base, drop, compact, promoter) {
429
424
  out["items"] = shapeList(base["items"], drop, compact, promoter);
430
425
  return out;
431
426
  }
432
- /** Coerce an unknown value to an array (empty when absent). */
433
- const asArray = (v) => (Array.isArray(v) ? v : []);
434
427
  /**
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.
428
+ * Re-bucket a project's / area's / heading's live (non-logbook/trash) children
429
+ * into the R10 card shape by their derived {@link deriveStage} — so the bucket an
430
+ * item lands in ALWAYS equals its `stage`:
431
+ * - `anytime` stage anytime, in encounter order;
432
+ * - `upcoming`stage upcoming, day-grouped `[{when, items}]` (a dated row under
433
+ * its `startDate`, a template under its `nextOccurrence`), date ASC; date-LESS
434
+ * templates (after-completion / paused) form a trailing `{when: null, items}`
435
+ * group (explicit null per the `area: null` section precedent);
436
+ * - `someday` — stage someday.
437
+ * Items are already in view order (index / date+todayIndex) from the read layer,
438
+ * so encounter order within a date group preserves that ordering. Each item is
439
+ * then run through {@link shapeItem} with the section drop (ancestry + `stage`,
440
+ * since the bucket states it).
441
441
  */
442
- const PROJECT_ITEM_DROP = { project: true, area: true, keepHeading: true };
442
+ function rebucketChildren(children, drop, compact, promoter) {
443
+ const anytime = [];
444
+ const someday = [];
445
+ const datedByKey = new Map();
446
+ const datedOrder = [];
447
+ const restingTemplates = [];
448
+ const shape = (c) => shapeItem(c, drop, compact, promoter);
449
+ for (const raw of children) {
450
+ if (raw === null || typeof raw !== "object")
451
+ continue;
452
+ const c = raw;
453
+ const stage = stageOf(c);
454
+ if (stage === "anytime") {
455
+ anytime.push(shape(c));
456
+ }
457
+ else if (stage === "someday") {
458
+ someday.push(shape(c));
459
+ }
460
+ else if (stage === "upcoming") {
461
+ const repeating = c["repeating"];
462
+ const nextOcc = repeating != null && typeof repeating === "object"
463
+ ? (repeating["nextOccurrence"] ?? null)
464
+ : null;
465
+ const date = (c.startDate ?? null) !== null ? c.startDate : nextOcc;
466
+ if (date === null) {
467
+ restingTemplates.push(shape(c));
468
+ }
469
+ else {
470
+ if (!datedByKey.has(date)) {
471
+ datedByKey.set(date, []);
472
+ datedOrder.push(date);
473
+ }
474
+ // R12 — inside a date-group the group states the date, so a member whose
475
+ // `when` equals it drops it (every scheduled row and every projected
476
+ // template does — that IS the group key).
477
+ const shaped = shape(c);
478
+ if (shaped !== null && typeof shaped === "object" && shaped["when"] === date) {
479
+ delete shaped["when"];
480
+ }
481
+ datedByKey.get(date).push(shaped);
482
+ }
483
+ }
484
+ else {
485
+ // inbox / logbook / trash should not appear among a card's live children;
486
+ // route defensively to anytime rather than drop the row.
487
+ anytime.push(shape(c));
488
+ }
489
+ }
490
+ const upcoming = datedOrder
491
+ .toSorted((a, b) => a.localeCompare(b))
492
+ .map((date) => ({ when: date, items: datedByKey.get(date) }));
493
+ if (restingTemplates.length > 0)
494
+ upcoming.push({ when: null, items: restingTemplates });
495
+ return { anytime, upcoming, someday };
496
+ }
497
+ /** Flatten an internal IsoDateGroup[] (`[{when, items}]`) to its items, in order. */
498
+ function flattenGroups(groups) {
499
+ if (!Array.isArray(groups))
500
+ return [];
501
+ const out = [];
502
+ for (const g of groups) {
503
+ if (g !== null && typeof g === "object" && Array.isArray(g["items"])) {
504
+ out.push(...g["items"]);
505
+ }
506
+ }
507
+ return out;
508
+ }
509
+ /** Coerce an unknown value to an array (empty when absent). */
510
+ const asArray = (v) => (Array.isArray(v) ? v : []);
443
511
  /**
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`.
512
+ * The R6 ref drop for the un-headed BODY's four `children` bucket records (v2):
513
+ * every body child drops project/area (the card states them), the bucket-implied
514
+ * stage (each of `anytime`/`upcoming`/`someday`/`logbook` is stage-pure), and the
515
+ * heading ref a body child is by construction un-headed (its `heading` is null),
516
+ * and a project view surfaces no bare `heading: null` (drop it explicitly rather
517
+ * than leaning on omit-empty).
453
518
  */
454
- const PROJECT_LOGBOOK_DROP = {
455
- project: true,
456
- area: true,
457
- keepHeading: true,
458
- };
519
+ const PROJECT_CHILD_DROP = { project: true, area: true, heading: true, stage: true };
459
520
  /**
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.
521
+ * The R6 ref drop for a HEADING's four `children` bucket records (v2): a headed
522
+ * child drops project/area (the card states them), the heading ref (its position
523
+ * UNDER `headings[].children` states membership structural, #362 / task item 6),
524
+ * and the bucket-implied stage. Applied uniformly to the heading's live buckets
525
+ * AND its `logbook` — the logbook is stage-pure (all logged), so stage drops too.
464
526
  */
465
- const AREA_ITEM_DROP = { area: true };
527
+ const HEADING_MEMBER_DROP = { project: true, area: true, heading: true, stage: true };
528
+ /** Area-view child-item buckets drop their area (the card states it) + the bucket-implied stage. */
529
+ const AREA_CHILD_DROP = { area: true, stage: true };
466
530
  /** Area-view PROJECTS list: a mixed listing of the area's project rows — keep `stage`, drop area. */
467
531
  const AREA_PROJECTS_DROP = { area: true };
468
532
  /**
@@ -481,31 +545,28 @@ const SOMEDAY_SECTION_DROP = { area: true, stage: true };
481
545
  /** The card NODE / detail / mixed lists: keep every ref, `stage`, and `when`. */
482
546
  const NO_DROP = {};
483
547
  /**
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.
548
+ * The today view's two `children` bucket records: drop the key-implied `when`
549
+ * (R12 the `today`/`evening` bucket key states it) AND the bucket-implied
550
+ * `stage` (R13). Every Today member now derives stage `anytime` by construction —
551
+ * an ARRIVED `startDate` (step 5) or a DEADLINE PULL (step 2½) both derive
552
+ * `anytime`, and there are no future-dated or undated-someday Today members — so
553
+ * both buckets are provably stage-PURE `anytime` and the field is redundant there
554
+ * (verified strict by the today purity property test in test/unit/stage.test.ts).
555
+ * `provisional` is NOT a drop the banner is not a bucket, so nothing implies it.
494
556
  */
495
- const TODAY_ITEM_DROP = { stage: true };
557
+ const TODAY_SECTION_DROP = { when: true, stage: true };
496
558
  /**
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:
559
+ * Shape a heading GROUP node (the `headings[].heading` / `loggedHeadings[].heading`
560
+ * keyed sub-object). The type is triply implied by position, and a heading has no
561
+ * open/canceled/completed vocabulary the reader needs — so:
501
562
  * - 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);
563
+ * to-do" convention is scoped to ROWS/candidates, never this keyed sub-object);
503
564
  * - DROP `project` (the card states it);
504
565
  * - REPLACE `status` with the presence-keyed `archived` (the stopDate, an ISO
505
566
  * 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).
567
+ * the heading is archived (status "completed"), OMITTED when open. Region
568
+ * membership (live `headings` vs the logged region) expresses sweep state; the
569
+ * node carries only whether-and-when it was archived.
509
570
  */
510
571
  function shapeHeadingNode(src) {
511
572
  if (src === null || typeof src !== "object")
@@ -523,92 +584,294 @@ function shapeHeadingNode(src) {
523
584
  h["archived"] = stopped;
524
585
  return h;
525
586
  }
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;
587
+ /** A bucket record `{items, total?}` (v2 R1): `total` present IFF the bucket was capped. */
588
+ export function bucketRecord(items, total) {
589
+ return total !== undefined && items.length < total ? { items, total } : { items };
590
+ }
591
+ /** The `stopped` epoch of an internal entity (a Date pre-shaping), or 0 — for logbook DESC ordering. */
592
+ function stoppedMs(o) {
593
+ const s = o["stopped"];
594
+ return s instanceof Date ? s.getTime() : 0;
595
+ }
596
+ /**
597
+ * Build ONE container's four v2 `children` bucket records (PR 2) from its flat
598
+ * child set (live AND logged alike). Every child is routed by DERIVED STAGE, so
599
+ * one entity lands in exactly one place (R5/#V12):
600
+ * - `logbook: {items, total?}` — the swept/resolved children (`logged` flag),
601
+ * most-recently-completed first (`stopped` DESC — the certified HEADARC3/logbook
602
+ * ordering), stage-pure so `stage` drops;
603
+ * - `anytime` / `someday: {items, total?}` — stage-pure records;
604
+ * - `upcoming: [{when, items, total?} …]` — the day-block ARRAY (R3): dated blocks
605
+ * chronological, then a single trailing `{when: null, items}` resting block for
606
+ * date-less recurring templates (#V8). An open child stranded under an archived
607
+ * heading (HEADARC2-C anomaly) is NOT logged, so it rides `anytime` here — its
608
+ * presence in a live bucket under an `archived` heading node is self-evident.
609
+ * `drop` carries the container's ancestry drops (body vs heading); the day-block
610
+ * key is `when` end-to-end (`rebucketChildren` builds it — no rename) — `null` for
611
+ * the resting block. No bucket is capped in the project view today, so every
612
+ * `total` is absent (R1: an untruncated bucket never restates its length); the
613
+ * `total?` argument keeps the record + day-block shape ready for PR 5's sweep and
614
+ * is exercised by the unit tests.
615
+ */
616
+ function shapeContainerChildren(children, drop, compact, promoter) {
617
+ const live = [];
618
+ const logged = [];
619
+ for (const c of asArray(children)) {
620
+ // The logbook-boundary flag lives on the internal `derived` substrate bag.
621
+ const isLogged = c !== null &&
622
+ typeof c === "object" &&
623
+ c["derived"]?.["logged"] === true;
624
+ if (isLogged)
625
+ logged.push(c);
626
+ else
627
+ live.push(c);
628
+ }
629
+ const { anytime, upcoming, someday } = rebucketChildren(live, drop, compact, promoter);
630
+ const loggedSorted = logged.toSorted((a, b) => stoppedMs(b) - stoppedMs(a));
631
+ const logbook = shapeList(loggedSorted, drop, compact, promoter);
632
+ return {
633
+ anytime: bucketRecord(anytime),
634
+ // The day-block ARRAY (`{when, items}`, `when: null` the resting block #V8);
635
+ // `rebucketChildren` already keys each block by `when`.
636
+ upcoming,
637
+ someday: bucketRecord(someday),
638
+ logbook: bucketRecord(logbook),
531
639
  };
532
- return t(b) - t(a);
533
640
  }
534
641
  /**
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.
642
+ * Shape a project view into the read-shape v2 wire (PR 2):
643
+ * `{ project, children, headings[] }` NOTHING else at this level. `children` is
644
+ * the un-headed BODY's four stage-keyed bucket records; `headings[]` is EVERY
645
+ * heading (index order, all lifecycle classes R5) as `{uuid, title, archived?,
646
+ * children}` with the SAME recursive `children` shape. The per-container `logbook`
647
+ * lives inside each `children` (R6, no root logbook); the v1-era `logbookHeadings`
648
+ * and BOTH advisory keys (`openChildrenWhileResolved` /
649
+ * `openChildrenUnderArchivedHeading`) are DELETED (#V12) anomalous open children
650
+ * seat in the normal recursive buckets, the heading's `archived` mark making the
651
+ * anomaly self-evident. The card node keeps everything (children derive their
652
+ * container from it). `out` is built fresh, so no render-only field leaks.
545
653
  */
546
654
  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;
655
+ const headingContainers = asArray(view["headingContainers"]).map((c) => {
656
+ const grp = (c ?? {});
657
+ // The heading NODE (`{uuid, title, archived?}`) gains the recursive `children`
658
+ // (last key, so it reads after the identity). Object.assign mutates the fresh
659
+ // node copy shapeHeadingNode already returns — no spread-in-map.
660
+ const node = shapeHeadingNode(grp["heading"]);
661
+ return Object.assign(node, {
662
+ children: shapeContainerChildren(grp["children"], HEADING_MEMBER_DROP, compact, promoter),
663
+ });
664
+ });
665
+ return {
666
+ // The card NODE keeps everything but is still an item DTO (universal + R10 reshapes).
667
+ project: shapeItem(view["project"], NO_DROP, false, promoter),
668
+ children: shapeContainerChildren(view["bodyChildren"], PROJECT_CHILD_DROP, compact, promoter),
669
+ headings: headingContainers,
670
+ };
671
+ }
672
+ /**
673
+ * Build an area's THREE v2 `children` bucket records (PR 3) from its flat direct
674
+ * to-do set (live only — an area has NO logged-children region, so no `logbook`
675
+ * key; the area logbook is the bounded query `things logbook --area <ref>`, #346).
676
+ * The same stage-derived bucketing as {@link shapeContainerChildren} minus the
677
+ * logbook split: `anytime`/`someday` are `{items, total?}` records, `upcoming` is
678
+ * the day-block ARRAY (R3, keyed by `when`) with the trailing `{when: null, items}`
679
+ * resting block for date-less recurring templates (#V8). Inline `total` is stamped
680
+ * downstream by {@link withAreaBucketTotals} (only `anytime` can be capped the
681
+ * `--area-limit` scope; the scheduled/someday direct to-dos always survive).
682
+ */
683
+ function shapeAreaChildren(members, drop, compact, promoter) {
684
+ const { anytime, upcoming, someday } = rebucketChildren(members, drop, compact, promoter);
685
+ return {
686
+ anytime: bucketRecord(anytime),
687
+ // The day-block ARRAY (`{when, items}`, `when: null` the resting block #V8);
688
+ // `rebucketChildren` already keys each block by `when`.
689
+ upcoming,
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,18 +938,19 @@ 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/deadlines/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],
642
951
  ["search", NO_DROP],
952
+ // deadlines is stage-MIXED (to-dos + projects, deadline-ordered) — keep `stage`.
953
+ ["deadlines", NO_DROP],
643
954
  ["projects", NO_DROP],
644
955
  ]);
645
956
  /**
@@ -661,12 +972,11 @@ export function shapeReadPayload(kind, data, full, promoter) {
661
972
  const flatDrop = FLAT_LIST_DROP.get(kind);
662
973
  if (flatDrop !== undefined)
663
974
  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).
975
+ // The global `upcoming` view reshapes into `data.sections` day blocks (PR 4).
976
+ if (kind === "upcoming" && Array.isArray(data))
977
+ return shapeUpcomingView(data, compact, p);
668
978
  if (kind === "today" && data !== null && typeof data === "object") {
669
- return shapeList(data["items"], TODAY_ITEM_DROP, compact, p);
979
+ return shapeTodayView(data, compact, p);
670
980
  }
671
981
  if (kind === "anytime" && Array.isArray(data)) {
672
982
  return shapeSections(data, ANYTIME_SECTION_DROP, compact, p); // stage-pure → drop stage
@@ -723,9 +1033,10 @@ export function candidateRef(type, src) {
723
1033
  if (project !== null)
724
1034
  out.project = project;
725
1035
  // stage/when only for the task kinds, and only when the source carries the
726
- // materialized lifecycle substrate (`start`) — a thin uuid+title resolver row
727
- // does not, so the keys stay absent (presence-keyed, like the wire).
728
- if ((type === "to-do" || type === "project") && typeof s["start"] === "string") {
1036
+ // materialized lifecycle substrate (`derived.start`) — a thin uuid+title
1037
+ // resolver row does not, so the keys stay absent (presence-keyed, like the wire).
1038
+ if ((type === "to-do" || type === "project") &&
1039
+ typeof (s["derived"] ?? {})["start"] === "string") {
729
1040
  const stage = stageOf(s);
730
1041
  out.stage = stage;
731
1042
  const when = whenOf(s, stage);