things-api 0.12.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 (141) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/canonical-ref.d.ts +21 -0
  3. package/dist/cli/canonical-ref.js +23 -0
  4. package/dist/cli/canonical-ref.js.map +1 -0
  5. package/dist/cli/commands/area.d.ts +5 -5
  6. package/dist/cli/commands/area.js +39 -18
  7. package/dist/cli/commands/area.js.map +1 -1
  8. package/dist/cli/commands/install-skill.d.ts +2 -2
  9. package/dist/cli/commands/install-skill.js +29 -0
  10. package/dist/cli/commands/install-skill.js.map +1 -1
  11. package/dist/cli/commands/mcp.js +12 -1
  12. package/dist/cli/commands/mcp.js.map +1 -1
  13. package/dist/cli/commands/project.js +74 -22
  14. package/dist/cli/commands/project.js.map +1 -1
  15. package/dist/cli/commands/reads.d.ts +5 -1
  16. package/dist/cli/commands/reads.js +31 -15
  17. package/dist/cli/commands/reads.js.map +1 -1
  18. package/dist/cli/commands/setup.js +11 -4
  19. package/dist/cli/commands/setup.js.map +1 -1
  20. package/dist/cli/commands/show.js +51 -32
  21. package/dist/cli/commands/show.js.map +1 -1
  22. package/dist/cli/commands/todo.js +2 -2
  23. package/dist/cli/commands/todo.js.map +1 -1
  24. package/dist/cli/commands/writes.js +138 -67
  25. package/dist/cli/commands/writes.js.map +1 -1
  26. package/dist/cli/did-you-mean.js +19 -5
  27. package/dist/cli/did-you-mean.js.map +1 -1
  28. package/dist/cli/dry-run.d.ts +55 -0
  29. package/dist/cli/dry-run.js +92 -0
  30. package/dist/cli/dry-run.js.map +1 -0
  31. package/dist/cli/glyphs.d.ts +9 -2
  32. package/dist/cli/glyphs.js +11 -5
  33. package/dist/cli/glyphs.js.map +1 -1
  34. package/dist/cli/help.js +7 -6
  35. package/dist/cli/help.js.map +1 -1
  36. package/dist/cli/main.js +7 -0
  37. package/dist/cli/main.js.map +1 -1
  38. package/dist/cli/read-driver.d.ts +47 -7
  39. package/dist/cli/read-driver.js +43 -21
  40. package/dist/cli/read-driver.js.map +1 -1
  41. package/dist/cli/ref-render.d.ts +14 -0
  42. package/dist/cli/ref-render.js +9 -0
  43. package/dist/cli/ref-render.js.map +1 -0
  44. package/dist/cli/render.d.ts +39 -25
  45. package/dist/cli/render.js +102 -62
  46. package/dist/cli/render.js.map +1 -1
  47. package/dist/cli/resolve-invocation.d.ts +5 -3
  48. package/dist/cli/resolve-invocation.js +13 -4
  49. package/dist/cli/resolve-invocation.js.map +1 -1
  50. package/dist/client.d.ts +100 -32
  51. package/dist/client.js +63 -19
  52. package/dist/client.js.map +1 -1
  53. package/dist/contracts.d.ts +34 -33
  54. package/dist/contracts.js +1 -1
  55. package/dist/contracts.js.map +1 -1
  56. package/dist/db/baselines/db-v26.js +4 -1
  57. package/dist/db/baselines/db-v26.js.map +1 -1
  58. package/dist/index.d.ts +8 -5
  59. package/dist/index.js +9 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/mcp/server.js +192 -139
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/model/dates.d.ts +26 -0
  64. package/dist/model/dates.js +46 -4
  65. package/dist/model/dates.js.map +1 -1
  66. package/dist/model/entities.d.ts +11 -1
  67. package/dist/model/entities.js.map +1 -1
  68. package/dist/model/mappers.js +3 -0
  69. package/dist/model/mappers.js.map +1 -1
  70. package/dist/model/serialize.js +15 -3
  71. package/dist/model/serialize.js.map +1 -1
  72. package/dist/read/area-filter.d.ts +2 -2
  73. package/dist/read/area-filter.js +3 -3
  74. package/dist/read/area-filter.js.map +1 -1
  75. package/dist/read/project-view.d.ts +76 -1
  76. package/dist/read/project-view.js +80 -9
  77. package/dist/read/project-view.js.map +1 -1
  78. package/dist/read/queries.d.ts +71 -2
  79. package/dist/read/queries.js +199 -10
  80. package/dist/read/queries.js.map +1 -1
  81. package/dist/read/scope.d.ts +2 -2
  82. package/dist/read/scope.js +3 -3
  83. package/dist/read/scope.js.map +1 -1
  84. package/dist/read/shape.d.ts +87 -9
  85. package/dist/read/shape.js +483 -124
  86. package/dist/read/shape.js.map +1 -1
  87. package/dist/read/show-target.js +52 -33
  88. package/dist/read/show-target.js.map +1 -1
  89. package/dist/read/truncation.d.ts +73 -7
  90. package/dist/read/truncation.js +62 -29
  91. package/dist/read/truncation.js.map +1 -1
  92. package/dist/read/views.d.ts +20 -7
  93. package/dist/read/views.js +7 -7
  94. package/dist/read/views.js.map +1 -1
  95. package/dist/surface-copy.d.ts +13 -0
  96. package/dist/surface-copy.js +13 -0
  97. package/dist/surface-copy.js.map +1 -1
  98. package/dist/write/batch.js +0 -1
  99. package/dist/write/batch.js.map +1 -1
  100. package/dist/write/commands.d.ts +25 -1
  101. package/dist/write/commands.js +259 -122
  102. package/dist/write/commands.js.map +1 -1
  103. package/dist/write/guards.js +27 -11
  104. package/dist/write/guards.js.map +1 -1
  105. package/dist/write/move.d.ts +34 -9
  106. package/dist/write/move.js +730 -78
  107. package/dist/write/move.js.map +1 -1
  108. package/dist/write/operations.d.ts +50 -20
  109. package/dist/write/operations.js +2 -2
  110. package/dist/write/operations.js.map +1 -1
  111. package/dist/write/pipeline.js +25 -4
  112. package/dist/write/pipeline.js.map +1 -1
  113. package/dist/write/pre-state.d.ts +58 -28
  114. package/dist/write/pre-state.js +247 -49
  115. package/dist/write/pre-state.js.map +1 -1
  116. package/dist/write/reorder.js +266 -32
  117. package/dist/write/reorder.js.map +1 -1
  118. package/dist/write/resolution-timestamps.d.ts +34 -0
  119. package/dist/write/resolution-timestamps.js +286 -0
  120. package/dist/write/resolution-timestamps.js.map +1 -0
  121. package/dist/write/reversibility.js +5 -5
  122. package/dist/write/reversibility.js.map +1 -1
  123. package/dist/write/scope-guard.js +0 -5
  124. package/dist/write/scope-guard.js.map +1 -1
  125. package/dist/write/undo.d.ts +0 -6
  126. package/dist/write/undo.js +44 -4
  127. package/dist/write/undo.js.map +1 -1
  128. package/dist/write/vectors/applescript.js +14 -4
  129. package/dist/write/vectors/applescript.js.map +1 -1
  130. package/dist/write/vectors/url-scheme.js +0 -8
  131. package/dist/write/vectors/url-scheme.js.map +1 -1
  132. package/dist/write/verify/delta.d.ts +16 -2
  133. package/dist/write/verify/delta.js +21 -2
  134. package/dist/write/verify/delta.js.map +1 -1
  135. package/package.json +1 -1
  136. package/schema/envelope.schema.json +16 -78
  137. package/skills/things-cli/SKILL.md +10 -6
  138. package/skills/things-cli/references/contracts.md +3 -2
  139. package/skills/things-cli/references/errors.md +9 -1
  140. package/skills/things-cli/references/model.md +9 -7
  141. package/skills/things-cli/references/ordering.md +36 -10
@@ -56,9 +56,10 @@
56
56
  * - a template's `repeating.nextOccurrence` is GONE from the wire — `when` replaces
57
57
  * it (same fact, one word); the resting-templates `{date: null}` group is
58
58
  * unchanged (an unprojected template has no `when`).
59
- * - `when` is DROPPED inside the `today` view's own sections (the section key states
60
- * today/evening) and inside any card/heading `upcoming` DATE-GROUP for a member
61
- * whose `when` equals the group's date (the group states it). KEPT everywhere
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
62
63
  * else it is present — including the flat `upcoming`/`anytime`/`inbox`/`someday`
63
64
  * catalogues, search, changes (a deadline-pulled row reads `when: "today"` in the
64
65
  * mixed search/changes surfaces, informatively; note R13 re-files it to stage
@@ -77,9 +78,9 @@
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 own sections become stage-PURE →
81
- * `stage` is DROPPED there (TODAY_SECTION_DROP), alongside the section-implied
82
- * `when`. The flat someday/inbox views EXCLUDE pulled rows and the anytime view
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
83
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)
@@ -113,6 +114,46 @@
113
114
  * universal reshapes, and R10.
114
115
  */
115
116
  import { deriveStage, deriveWhen, whenIsProvisional } from "./stage.js";
117
+ /** The DB-less default: assume every title round-trips (bare title, no uuid sibling). */
118
+ const ALWAYS_ROUND_TRIPS = { roundTrips: () => true };
119
+ /** The uuid of a `{uuid,title}` container Ref, or undefined for a non-object / string. */
120
+ function refUuid(v) {
121
+ if (v !== null && typeof v === "object" && typeof v["uuid"] === "string") {
122
+ return v["uuid"];
123
+ }
124
+ return undefined;
125
+ }
126
+ /**
127
+ * Whether a still-unflattened container Ref carries the repeating-TEMPLATE mark.
128
+ * Only project/container refs ever set it (area/heading refs never do — see
129
+ * entities.Ref), so this reads true only for a template PROJECT container.
130
+ */
131
+ function refIsTemplate(v) {
132
+ return v !== null && typeof v === "object" && v["isRepeatingTemplate"] === true;
133
+ }
134
+ /**
135
+ * Flatten ONE container ref `o[key]` from a `{uuid,title}` object to its bare
136
+ * TITLE string, adding a flat sibling `o[uuidKey]` = the full uuid ONLY when the
137
+ * round-trip law demands it: `forceUuid` (the FULL/detail tier — uuid siblings
138
+ * unconditional) OR the bare title does not resolve back to this exact entity
139
+ * (`!promoter.roundTrips`). A null/absent ref, or one already flattened to a
140
+ * string, is left untouched. The container's `isRepeatingTemplate` marker (a
141
+ * TTY-render disambiguator on the internal entity) does not survive the flatten
142
+ * — the human render reads the unshaped entity. {@link shapeItem} re-emits that
143
+ * fact for the JSON container PROJECT as the flat presence-keyed sibling
144
+ * `projectIsTemplate: true` BEFORE this flatten runs.
145
+ */
146
+ function flattenRef(o, key, uuidKey, kind, forceUuid, promoter, projectUuid) {
147
+ const ref = o[key];
148
+ if (ref === null || typeof ref !== "object")
149
+ return; // absent, or already a bare string
150
+ const r = ref;
151
+ const uuid = typeof r["uuid"] === "string" ? r["uuid"] : "";
152
+ const title = typeof r["title"] === "string" ? r["title"] : "";
153
+ o[key] = title;
154
+ if (forceUuid || !promoter.roundTrips(kind, title, uuid, projectUuid))
155
+ o[uuidKey] = uuid;
156
+ }
116
157
  /**
117
158
  * Fold the flat `tags` / `inheritedTags` arrays of `{title}` objects into plain
118
159
  * arrays of tag NAMES (universal across tiers and kinds). Tag uuids were never
@@ -260,7 +301,7 @@ function whenOf(s, stage) {
260
301
  * (`changeKind` on a changes row, `match` on a search hit) pass through
261
302
  * untouched. Non-task values (areas, tags, refs, headings) are returned as-is.
262
303
  */
263
- function shapeItem(src, drop, compact) {
304
+ function shapeItem(src, drop, compact, promoter) {
264
305
  if (src === null || typeof src !== "object")
265
306
  return src;
266
307
  const s = src;
@@ -318,6 +359,12 @@ function shapeItem(src, drop, compact) {
318
359
  // R13 — the provisional banner marker (never dropped; presence-keyed).
319
360
  if (provisional)
320
361
  o["provisional"] = true;
362
+ // The owning project's uuid scopes the heading round-trip (headings resolve
363
+ // within their project). Captured BEFORE the R6 project-drop so a project-view
364
+ // LOGBOOK row — whose `project` is dropped as redundant, yet which KEEPS its
365
+ // heading ref (drop.keepHeading) — can still promote its `headingUuid` in the
366
+ // project's scope.
367
+ const projectUuid = refUuid(o["project"]);
321
368
  // R6 — drop redundant ancestry (both tiers).
322
369
  if (drop.project === true)
323
370
  delete o["project"];
@@ -325,6 +372,30 @@ function shapeItem(src, drop, compact) {
325
372
  delete o["area"];
326
373
  if (drop.heading === true)
327
374
  delete o["heading"];
375
+ // Absent `type` = to-do — omit it on to-do rows (project/heading keep theirs).
376
+ if (o["type"] === "to-do")
377
+ delete o["type"];
378
+ // Flatten the surviving container refs to bare TITLE strings, adding a flat
379
+ // `*Uuid` sibling per the round-trip law (FULL tier: unconditional; compact:
380
+ // only when the title would not resolve back to this entity). The `heading`
381
+ // ref is compact-dropped below (except drop.keepHeading), so it is flattened
382
+ // on the FULL tier OR when a logbook row explicitly keeps it.
383
+ const forceUuid = !compact;
384
+ // The container PROJECT's repeating-TEMPLATE fact — the JSON twin of the TTY ↻
385
+ // glyph (src/cli/render.ts). flattenRef discards the internal ref's
386
+ // `isRepeatingTemplate` marker, so re-emit it here as a flat presence-keyed
387
+ // sibling of the `project` ref (never `false`), riding wherever `project`
388
+ // rides. A heading-nested row already merged its owning project into `project`
389
+ // above, so direct AND headed template children mark; the R6 project-drop
390
+ // above already removed `project` where the view implies it, so a project-view
391
+ // child carries no orphaned marker. Both tiers — it is a correctness signal,
392
+ // not detail. Only project refs ever carry the flag (area/heading never do).
393
+ if (refIsTemplate(o["project"]))
394
+ o["projectIsTemplate"] = true;
395
+ flattenRef(o, "project", "projectUuid", "project", forceUuid, promoter);
396
+ flattenRef(o, "area", "areaUuid", "area", forceUuid, promoter);
397
+ if (!compact || drop.keepHeading === true)
398
+ flattenRef(o, "heading", "headingUuid", "heading", forceUuid, promoter, projectUuid);
328
399
  // R12 — FULL/DETAIL keep the raw `startDate` beside `when` as the SUBSTRATE
329
400
  // (`startDate` = what is stored, `when` = where it sits). COMPACT drops it below
330
401
  // (the position `when` carries is what a list needs).
@@ -341,20 +412,22 @@ function shapeItem(src, drop, compact) {
341
412
  if (notes !== "")
342
413
  o["hasNotes"] = true;
343
414
  // The heading ref is compact-dropped everywhere (the GUI shows the project,
344
- // never the heading, outside a project view). Full tier / detail keep it.
345
- delete o["heading"];
415
+ // never the heading, outside a project view). Full tier / detail keep it; a
416
+ // project-view logbook row (drop.keepHeading) keeps it too — flattened above.
417
+ if (drop.keepHeading !== true)
418
+ delete o["heading"];
346
419
  return o;
347
420
  }
348
421
  /** Map a plain array of items with the item shaper. */
349
- function shapeList(items, drop, compact) {
422
+ function shapeList(items, drop, compact, promoter) {
350
423
  if (!Array.isArray(items))
351
424
  return items;
352
- return items.map((i) => shapeItem(i, drop, compact));
425
+ return items.map((i) => shapeItem(i, drop, compact, promoter));
353
426
  }
354
427
  /** Copy `base` and overwrite `items` with the shaped list (avoids spread-in-map). */
355
- function withShapedItems(base, drop, compact) {
428
+ function withShapedItems(base, drop, compact, promoter) {
356
429
  const out = { ...base };
357
- out["items"] = shapeList(base["items"], drop, compact);
430
+ out["items"] = shapeList(base["items"], drop, compact, promoter);
358
431
  return out;
359
432
  }
360
433
  /**
@@ -372,13 +445,13 @@ function withShapedItems(base, drop, compact) {
372
445
  * then run through {@link shapeItem} with the section drop (ancestry + `stage`,
373
446
  * since the bucket states it).
374
447
  */
375
- function rebucketChildren(children, drop, compact) {
448
+ function rebucketChildren(children, drop, compact, promoter) {
376
449
  const anytime = [];
377
450
  const someday = [];
378
451
  const datedByKey = new Map();
379
452
  const datedOrder = [];
380
453
  const restingTemplates = [];
381
- const shape = (c) => shapeItem(c, drop, compact);
454
+ const shape = (c) => shapeItem(c, drop, compact, promoter);
382
455
  for (const raw of children) {
383
456
  if (raw === null || typeof raw !== "object")
384
457
  continue;
@@ -441,9 +514,22 @@ function flattenGroups(groups) {
441
514
  }
442
515
  /** Coerce an unknown value to an array (empty when absent). */
443
516
  const asArray = (v) => (Array.isArray(v) ? v : []);
444
- /** The R6 ref drop for every child bucket of a project view (unheaded members). */
445
- const PROJECT_CHILD_DROP = { project: true, area: true, stage: true };
446
- /** Heading-group members drop the heading ref too (the group states it). */
517
+ /**
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).
524
+ */
525
+ const PROJECT_CHILD_DROP = { project: true, area: true, heading: true, stage: true };
526
+ /**
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.
532
+ */
447
533
  const HEADING_MEMBER_DROP = { project: true, area: true, heading: true, stage: true };
448
534
  /** Area-view child-item buckets drop their area (the card states it) + the bucket-implied stage. */
449
535
  const AREA_CHILD_DROP = { area: true, stage: true };
@@ -465,101 +551,327 @@ const SOMEDAY_SECTION_DROP = { area: true, stage: true };
465
551
  /** The card NODE / detail / mixed lists: keep every ref, `stage`, and `when`. */
466
552
  const NO_DROP = {};
467
553
  /**
468
- * The today view's own sections: drop the section-implied `when` (R12) AND the
469
- * section-implied `stage` (R13). Every Today member now derives stage `anytime`
470
- * by construction an ARRIVED `startDate` (step 5) or a DEADLINE PULL (step 2½)
471
- * both derive `anytime`, and there are no future-dated or undated-someday Today
472
- * members so the Today sections are provably stage-PURE `anytime` and the field
473
- * is redundant there (verified strict by the today-section purity property test
474
- * in test/unit/stage.test.ts). `provisional` is NOT a drop — the banner is not a
475
- * section, so nothing implies it.
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.
476
562
  */
477
563
  const TODAY_SECTION_DROP = { when: true, stage: true };
478
- /** Shape every collection bucket of a project view; the card node is left full + ancestry-intact. */
479
- function shapeProjectView(view, compact) {
480
- const cd = PROJECT_CHILD_DROP;
481
- const hd = HEADING_MEMBER_DROP;
482
- const shapeHeadingGroup = (g) => {
483
- if (g === null || typeof g !== "object")
484
- return g;
485
- const grp = g;
486
- const out = {};
487
- // The heading NODE itself drops its `project` ref the card states it.
488
- if (grp["heading"] !== null && typeof grp["heading"] === "object") {
489
- const h = { ...grp["heading"] };
490
- delete h["project"];
491
- out["heading"] = h;
492
- }
493
- else {
494
- out["heading"] = grp["heading"];
495
- }
496
- const members = [
497
- ...asArray(grp["items"]),
498
- ...flattenGroups(grp["scheduled"]),
499
- ...asArray(grp["someday"]),
500
- ...asArray(grp["repeating"]),
501
- ];
502
- const { anytime, upcoming, someday } = rebucketChildren(members, hd, compact);
503
- out["anytime"] = anytime;
504
- out["upcoming"] = upcoming;
505
- out["someday"] = someday;
506
- return out;
564
+ /**
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:
568
+ * - DROP `type` (positional: this slot is always a heading; the "absent type =
569
+ * to-do" convention is scoped to ROWS/candidates, never this keyed sub-object);
570
+ * - DROP `project` (the card states it);
571
+ * - REPLACE `status` with the presence-keyed `archived` (the stopDate, an ISO
572
+ * date-time following the `stopped`/logged-row convention) — emitted ONLY when
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.
576
+ */
577
+ function shapeHeadingNode(src) {
578
+ if (src === null || typeof src !== "object")
579
+ return src;
580
+ const h = { ...src };
581
+ delete h["project"];
582
+ delete h["type"];
583
+ const isArchived = h["status"] !== undefined && h["status"] !== "open";
584
+ const stopped = h["stopped"];
585
+ delete h["status"];
586
+ delete h["stopped"];
587
+ // Presence-keyed `archived` — the ISO archive timestamp (past-participle twin of
588
+ // `stopped`/`created`/`modified`), full-datetime serialization like `stopped`.
589
+ if (isArchived && stopped != null)
590
+ h["archived"] = stopped;
591
+ return h;
592
+ }
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),
640
+ };
641
+ }
642
+ /**
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.
654
+ */
655
+ function shapeProjectView(view, compact, promoter) {
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),
507
691
  };
508
- const headings = Array.isArray(view["headings"])
509
- ? view["headings"].map(shapeHeadingGroup)
510
- : view["headings"];
511
- const looseMembers = [
512
- ...asArray(view["active"]),
513
- ...flattenGroups(view["scheduled"]),
514
- ...asArray(view["someday"]),
515
- ...asArray(view["repeating"]),
516
- ];
517
- const { anytime, upcoming, someday } = rebucketChildren(looseMembers, cd, compact);
518
- const out = { ...view };
519
- delete out["active"];
520
- delete out["scheduled"];
521
- delete out["repeating"];
522
- delete out["logged"];
523
- // Trashed children live only in `things trash` — never a project-view bucket.
524
- // Delete defensively in case an untyped source carries the old key.
525
- delete out["trashed"];
526
- // The project card NODE keeps everything (children derive their container from
527
- // it), but is still an item DTO, so the universal + R10 reshapes apply.
528
- out["project"] = shapeItem(view["project"], NO_DROP, false);
529
- out["anytime"] = anytime;
530
- out["upcoming"] = upcoming;
531
- out["someday"] = someday;
532
- out["headings"] = headings;
533
- // A project keeps its in-context `logbook` (a project is a bounded object with
534
- // a real done-state); trashed children live only in `things trash`.
535
- out["logbook"] = shapeList(view["logged"], cd, compact);
536
- return out;
537
692
  }
538
- /** Shape every collection bucket of an area view; the area node keeps its identity (tags folded). */
539
- function shapeAreaView(view, compact) {
693
+ /**
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.
705
+ */
706
+ function shapeAreaView(view, compact, promoter) {
540
707
  const looseMembers = [
541
708
  ...asArray(view["active"]),
542
709
  ...flattenGroups(view["scheduled"]),
543
710
  ...asArray(view["someday"]),
544
711
  ...asArray(view["repeating"]),
545
712
  ];
546
- const { anytime, upcoming, someday } = rebucketChildren(looseMembers, AREA_CHILD_DROP, compact);
547
- const out = { ...view };
548
- delete out["active"];
549
- delete out["scheduled"];
550
- delete out["repeating"];
551
- // No `logbook` or `trash` bucket: an area's logbook is the bounded query
552
- // `things logbook --area <ref>`, and trashed rows live only in `things trash`.
553
- // Delete defensively in case an untyped source carries the old keys.
554
- delete out["logged"];
555
- delete out["trashed"];
556
- out["area"] = shapeArea(view["area"]);
557
- out["anytime"] = anytime;
558
- // The projects list is a mixed listing of the area's project rows — keep stage.
559
- out["projects"] = shapeList(view["projects"], AREA_PROJECTS_DROP, compact);
560
- out["upcoming"] = upcoming;
561
- out["someday"] = someday;
562
- return out;
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
+ });
563
875
  }
564
876
  /** Fold an area entity's tags to string names in place (returns a shallow copy). */
565
877
  function shapeArea(src) {
@@ -569,32 +881,70 @@ function shapeArea(src) {
569
881
  flattenTags(o);
570
882
  return o;
571
883
  }
572
- /** Shape the today/evening split (mixed list — keep refs + stage; drop the section-implied `when`). */
573
- function shapeTodayView(view, compact) {
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;
574
911
  return {
575
- ...view,
576
- today: shapeList(view["today"], TODAY_SECTION_DROP, compact),
577
- evening: shapeList(view["evening"], TODAY_SECTION_DROP, compact),
912
+ today: withBucketTotal(c["today"], totals.today),
913
+ evening: withBucketTotal(c["evening"], totals.evening),
578
914
  };
579
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
+ }
580
931
  /** Shape sidebar sections (anytime/someday catalogues) with the section's drop spec. */
581
- function shapeSections(sections, drop, compact) {
932
+ function shapeSections(sections, drop, compact, promoter) {
582
933
  if (!Array.isArray(sections))
583
934
  return sections;
584
- return sections.map((s) => s === null || typeof s !== "object" ? s : withShapedItems(s, drop, compact));
935
+ return sections.map((s) => s === null || typeof s !== "object" ? s : withShapedItems(s, drop, compact, promoter));
585
936
  }
586
937
  /**
587
938
  * The flat, mixed-provenance list kinds mapped to their drop spec. Only the
588
939
  * stage-PURE catalogues (inbox/someday/logbook/trash; the section-based `anytime`
589
940
  * is pure too, handled via shapeSections below) drop the bucket-implied `stage`.
590
- * `upcoming` KEEPS it (R10.2): the Upcoming view is stage-mixed — it carries
591
- * deadline-forecast stage-`anytime`/`someday` rows alongside future-dated
592
- * stage-`upcoming` ones. The mixed/derived surfaces (search/changes/projects)
593
- * 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).
594
945
  */
595
946
  const FLAT_LIST_DROP = new Map([
596
947
  ["inbox", { stage: true }],
597
- ["upcoming", NO_DROP],
598
948
  ["logbook", { stage: true }],
599
949
  ["trash", { stage: true }],
600
950
  ["changes", NO_DROP],
@@ -608,28 +958,35 @@ const FLAT_LIST_DROP = new Map([
608
958
  * mutated (shallow copies throughout), so the human-render path keeps the full
609
959
  * entities.
610
960
  */
611
- export function shapeReadPayload(kind, data, full) {
961
+ export function shapeReadPayload(kind, data, full, promoter) {
962
+ // The ref-promotion oracle drives the round-trip law for flat container refs.
963
+ // Absent (a DB-less unit shaping): assume every title round-trips — bare
964
+ // titles, no uuid siblings. Production always passes the client's promoter.
965
+ const p = promoter ?? ALWAYS_ROUND_TRIPS;
612
966
  // `detail` is the FULL record and drops no ancestry / stage / `when`.
613
967
  if (kind === "detail")
614
- return shapeItem(data, NO_DROP, false);
968
+ return shapeItem(data, NO_DROP, false, p);
615
969
  const compact = !full;
616
970
  const flatDrop = FLAT_LIST_DROP.get(kind);
617
971
  if (flatDrop !== undefined)
618
- return shapeList(data, flatDrop, compact);
972
+ return shapeList(data, flatDrop, compact, p);
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);
619
976
  if (kind === "today" && data !== null && typeof data === "object") {
620
- return shapeTodayView(data, compact);
977
+ return shapeTodayView(data, compact, p);
621
978
  }
622
979
  if (kind === "anytime" && Array.isArray(data)) {
623
- return shapeSections(data, ANYTIME_SECTION_DROP, compact); // stage-pure → drop stage
980
+ return shapeSections(data, ANYTIME_SECTION_DROP, compact, p); // stage-pure → drop stage
624
981
  }
625
982
  if (kind === "someday" && Array.isArray(data)) {
626
- return shapeSections(data, SOMEDAY_SECTION_DROP, compact); // stage-pure → drop stage
983
+ return shapeSections(data, SOMEDAY_SECTION_DROP, compact, p); // stage-pure → drop stage
627
984
  }
628
985
  if (kind === "area-view" && data !== null && typeof data === "object") {
629
- return shapeAreaView(data, compact);
986
+ return shapeAreaView(data, compact, p);
630
987
  }
631
988
  if (kind === "project-view" && data !== null && typeof data === "object") {
632
- return shapeProjectView(data, compact);
989
+ return shapeProjectView(data, compact, p);
633
990
  }
634
991
  // The `areas` listing carries Area entities whose tags fold to names.
635
992
  if (kind === "areas" && Array.isArray(data))
@@ -663,8 +1020,10 @@ export function candidateRef(type, src) {
663
1020
  const out = {
664
1021
  uuid: typeof s["uuid"] === "string" ? s["uuid"] : "",
665
1022
  title: typeof s["title"] === "string" ? s["title"] : "",
666
- type,
667
1023
  };
1024
+ // Absent `type` = to-do — emit it only for the other kinds.
1025
+ if (type !== "to-do")
1026
+ out.type = type;
668
1027
  const area = candidateContainerTitle(s["area"]);
669
1028
  if (area !== null)
670
1029
  out.area = area;