things-api 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/commands/area.d.ts +5 -5
  3. package/dist/cli/commands/area.js +12 -7
  4. package/dist/cli/commands/area.js.map +1 -1
  5. package/dist/cli/commands/project.js +1 -1
  6. package/dist/cli/commands/project.js.map +1 -1
  7. package/dist/cli/commands/reads.js +21 -12
  8. package/dist/cli/commands/reads.js.map +1 -1
  9. package/dist/cli/commands/show.js +6 -2
  10. package/dist/cli/commands/show.js.map +1 -1
  11. package/dist/cli/commands/todo.js +1 -1
  12. package/dist/cli/commands/todo.js.map +1 -1
  13. package/dist/cli/commands/writes.js +116 -70
  14. package/dist/cli/commands/writes.js.map +1 -1
  15. package/dist/cli/glyphs.d.ts +9 -2
  16. package/dist/cli/glyphs.js +11 -5
  17. package/dist/cli/glyphs.js.map +1 -1
  18. package/dist/cli/read-driver.d.ts +39 -9
  19. package/dist/cli/read-driver.js +37 -16
  20. package/dist/cli/read-driver.js.map +1 -1
  21. package/dist/cli/render.d.ts +25 -26
  22. package/dist/cli/render.js +57 -56
  23. package/dist/cli/render.js.map +1 -1
  24. package/dist/client.d.ts +92 -35
  25. package/dist/client.js +61 -18
  26. package/dist/client.js.map +1 -1
  27. package/dist/contracts.d.ts +23 -33
  28. package/dist/contracts.js +1 -1
  29. package/dist/contracts.js.map +1 -1
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/mcp/server.js +158 -144
  34. package/dist/mcp/server.js.map +1 -1
  35. package/dist/model/dates.d.ts +26 -0
  36. package/dist/model/dates.js +46 -4
  37. package/dist/model/dates.js.map +1 -1
  38. package/dist/read/area-filter.d.ts +1 -1
  39. package/dist/read/area-filter.js +5 -4
  40. package/dist/read/area-filter.js.map +1 -1
  41. package/dist/read/area-view.d.ts +0 -11
  42. package/dist/read/area-view.js +1 -5
  43. package/dist/read/area-view.js.map +1 -1
  44. package/dist/read/project-view.d.ts +34 -19
  45. package/dist/read/project-view.js +26 -20
  46. package/dist/read/project-view.js.map +1 -1
  47. package/dist/read/scope.d.ts +1 -1
  48. package/dist/read/scope.js +5 -4
  49. package/dist/read/scope.js.map +1 -1
  50. package/dist/read/shape.d.ts +73 -14
  51. package/dist/read/shape.js +459 -151
  52. package/dist/read/shape.js.map +1 -1
  53. package/dist/read/truncation.d.ts +75 -2
  54. package/dist/read/truncation.js +69 -46
  55. package/dist/read/truncation.js.map +1 -1
  56. package/dist/read/views.d.ts +19 -18
  57. package/dist/read/views.js +5 -6
  58. package/dist/read/views.js.map +1 -1
  59. package/dist/surface-copy.d.ts +6 -0
  60. package/dist/surface-copy.js +6 -0
  61. package/dist/surface-copy.js.map +1 -1
  62. package/dist/write/batch.js +0 -1
  63. package/dist/write/batch.js.map +1 -1
  64. package/dist/write/commands.d.ts +25 -1
  65. package/dist/write/commands.js +256 -121
  66. package/dist/write/commands.js.map +1 -1
  67. package/dist/write/guards.js +27 -11
  68. package/dist/write/guards.js.map +1 -1
  69. package/dist/write/move.d.ts +26 -5
  70. package/dist/write/move.js +313 -2
  71. package/dist/write/move.js.map +1 -1
  72. package/dist/write/operations.d.ts +49 -19
  73. package/dist/write/operations.js +2 -2
  74. package/dist/write/operations.js.map +1 -1
  75. package/dist/write/pipeline.js +25 -4
  76. package/dist/write/pipeline.js.map +1 -1
  77. package/dist/write/pre-state.d.ts +48 -28
  78. package/dist/write/pre-state.js +134 -13
  79. package/dist/write/pre-state.js.map +1 -1
  80. package/dist/write/reorder.js +3 -3
  81. package/dist/write/reorder.js.map +1 -1
  82. package/dist/write/resolution-timestamps.d.ts +34 -0
  83. package/dist/write/resolution-timestamps.js +286 -0
  84. package/dist/write/resolution-timestamps.js.map +1 -0
  85. package/dist/write/reversibility.js +5 -5
  86. package/dist/write/reversibility.js.map +1 -1
  87. package/dist/write/scope-guard.js +0 -5
  88. package/dist/write/scope-guard.js.map +1 -1
  89. package/dist/write/undo.d.ts +0 -6
  90. package/dist/write/undo.js +44 -4
  91. package/dist/write/undo.js.map +1 -1
  92. package/dist/write/vectors/applescript.js +14 -4
  93. package/dist/write/vectors/applescript.js.map +1 -1
  94. package/dist/write/vectors/url-scheme.js +0 -8
  95. package/dist/write/vectors/url-scheme.js.map +1 -1
  96. package/dist/write/verify/delta.d.ts +16 -2
  97. package/dist/write/verify/delta.js +21 -2
  98. package/dist/write/verify/delta.js.map +1 -1
  99. package/package.json +1 -1
  100. package/schema/envelope.schema.json +2 -78
  101. package/skills/things-cli/SKILL.md +7 -5
  102. package/skills/things-cli/references/contracts.md +3 -4
  103. package/skills/things-cli/references/model.md +8 -8
  104. package/skills/things-cli/references/ordering.md +16 -7
@@ -2,7 +2,7 @@ import { addDaysIso, decodePackedDate, encodePackedDate, localToday } from "../m
2
2
  import { ReferenceResolutionError, resolveTaskUuidPrefix } from "../read/queries.js";
3
3
  import { taskMembershipClause } from "../read/scope.js";
4
4
  import { isLooseRef, LOOSE_TO_AREA_REFUSAL } from "../read/pseudo-area.js";
5
- import { computeReorderPre, resolveArea, resolveHeading, resolveProject } from "./pre-state.js";
5
+ import { computeHeadingMovePre, computeReorderPre, resolveArea, resolveHeading, resolveProject, } from "./pre-state.js";
6
6
  import {} from "./pipeline.js";
7
7
  import { runMutation } from "./pipeline.js";
8
8
  import { runReorder } from "./reorder.js";
@@ -1239,6 +1239,315 @@ export async function runInPlaceReorder(deps, op, request, options = {}) {
1239
1239
  }
1240
1240
  return repositionInPlace(deps, op, rows, request.position, packedToday, options, "reorder", request.in);
1241
1241
  }
1242
+ /**
1243
+ * The ONE reorder verb (`things reorder <refs…>`) — kind-aware dispatch across
1244
+ * to-dos, projects, headings, and sidebar areas (spec §7). Every operand is
1245
+ * resolved (task first, then area) and classified; a homogeneous set routes to
1246
+ * its engine, and any illegal MIX gets one precise refusal:
1247
+ * - to-dos and/or projects → the in-place index/day/view engine (a to-do+project
1248
+ * set is legal only on the shared Today/Evening/day axes — that engine enforces
1249
+ * it); a pure-project set re-ranks its siblings;
1250
+ * - headings (one project) → the certified heading-block wire (#V11);
1251
+ * - areas → the sidebar-rank drag engine.
1252
+ * A heading or area may not share a call with any other kind.
1253
+ */
1254
+ export async function runUniversalReorder(deps, request, options = {}) {
1255
+ if (request.uuids.length === 0) {
1256
+ return refused("todo.move", "usage", "no items given — name at least one to reorder");
1257
+ }
1258
+ const classified = [];
1259
+ for (const ref of request.uuids) {
1260
+ const t = resolveMovee(deps, ref);
1261
+ if (!(t instanceof ReferenceResolutionError)) {
1262
+ const row = loadRow(deps.db, t.uuid);
1263
+ if (row !== undefined) {
1264
+ const kind = row.type === 1 ? "project" : row.type === 2 ? "heading" : "todo";
1265
+ classified.push({ kind, uuid: t.uuid, ref, row });
1266
+ continue;
1267
+ }
1268
+ }
1269
+ // Not a task ref — try a sidebar area (areas live in TMArea, not TMTask).
1270
+ const a = resolveArea(deps.db, { uuid: ref, title: ref });
1271
+ if (a.resolved !== null) {
1272
+ classified.push({ kind: "area", uuid: a.resolved.uuid, ref, title: a.resolved.title });
1273
+ continue;
1274
+ }
1275
+ // Neither a task nor an area — surface an AMBIGUOUS task match (its candidates
1276
+ // are the useful signal); a clean not-found names all four kinds.
1277
+ if (t instanceof ReferenceResolutionError &&
1278
+ t.candidates !== undefined &&
1279
+ t.candidates.length > 0) {
1280
+ return {
1281
+ kind: "move-refused",
1282
+ op: "todo.move",
1283
+ refusal: "usage",
1284
+ detail: t.message,
1285
+ candidates: t.candidates,
1286
+ };
1287
+ }
1288
+ return refused("todo.move", "usage", `no to-do, project, heading, or area matches "${ref}"`);
1289
+ }
1290
+ const kinds = new Set(classified.map((c) => c.kind));
1291
+ const hasHeading = kinds.has("heading");
1292
+ const hasArea = kinds.has("area");
1293
+ // Mixed-kind refusal (item 2): only a to-do+project set may share a call (and
1294
+ // only on a global axis, enforced downstream). A heading or an area never mixes.
1295
+ if ((hasHeading || hasArea) && kinds.size > 1) {
1296
+ const listed = classified.map((c) => `${c.ref} (${c.kind})`).join(", ");
1297
+ return refused("todo.move", "usage", "one kind at a time — `reorder` rearranges a set of to-dos, a set of projects, a set of " +
1298
+ `headings, OR a set of areas, but this set mixes kinds: ${listed}. Only to-dos and ` +
1299
+ "projects intermix, and only on the shared Today/Evening/day axes.", "reorder each kind in its own call");
1300
+ }
1301
+ if (hasArea) {
1302
+ if (request.in !== undefined) {
1303
+ return refused("area.reorder", "usage", "`--in` names a to-do/project reorder axis — areas have a single sidebar-rank order, so " +
1304
+ "it does not apply here", "drop --in; position the area(s) with --start / --end / --before / --after");
1305
+ }
1306
+ return runAreaReorderUniversal(deps, classified.filter((c) => c.kind === "area"), request.position, options);
1307
+ }
1308
+ if (hasHeading) {
1309
+ if (request.in !== undefined) {
1310
+ return refused("project.move-heading", "usage", "`--in` names a to-do/project reorder axis — a heading's order is the project's heading " +
1311
+ "axis, so it does not apply here", "drop --in; position the heading(s) with --start / --end / --before / --after");
1312
+ }
1313
+ return runHeadingReorder(deps, classified.filter((c) => c.kind === "heading"), request.position, options);
1314
+ }
1315
+ // to-dos and/or projects: the existing in-place engine. A pure-project set routes
1316
+ // through project.move; any to-do present routes through todo.move (which handles
1317
+ // the to-do+project global-axis intermix and refuses a non-global mixed set).
1318
+ const op = kinds.has("todo") ? "todo.move" : "project.move";
1319
+ return runInPlaceReorder(deps, op, request, options);
1320
+ }
1321
+ /** The project's heading uuids in current display (index) order. */
1322
+ function currentHeadingOrder(deps, projectUuid) {
1323
+ return deps.db
1324
+ .prepare(`SELECT uuid FROM TMTask WHERE type = 2 AND trashed = 0 AND project = ? ORDER BY "index"`)
1325
+ .all(projectUuid).map((r) => r.uuid);
1326
+ }
1327
+ /**
1328
+ * Same-project heading re-ranking via the universal verb — dispatches onto the
1329
+ * certified heading-block wire (`project.move-heading`), honoring #V11 (archived
1330
+ * headings reorderable unguarded, with reopens disclosed). `project move-heading`
1331
+ * remains the placement verb (cross-project / demotion); THIS is pure re-rank.
1332
+ */
1333
+ async function runHeadingReorder(deps, headings, position, options) {
1334
+ const op = "project.move-heading";
1335
+ // One shared project (cross-container refusal, item 2).
1336
+ const projectUuids = new Set(headings.map((h) => h.row.project));
1337
+ if (projectUuids.size !== 1 || projectUuids.has(null)) {
1338
+ const where = headings
1339
+ .map((h) => `${h.uuid} in ${h.row.project === null ? "no project" : `project ${h.row.project}`}`)
1340
+ .join("; ");
1341
+ return refused(op, "blocked", `these headings span projects (${where}) — a heading reorder rearranges the headings of ONE project`, "reorder the headings of one project at a time");
1342
+ }
1343
+ const projectUuid = [...projectUuids][0];
1344
+ const movees = headings.map((h) => h.uuid);
1345
+ // Map the anchor grammar onto a HeadingPlacement. --start/--end → first/last;
1346
+ // --before/--after resolve their ref to a heading of the SAME project (an anchor
1347
+ // positions, never migrates); bare → the block at the earliest movee's slot.
1348
+ let placement;
1349
+ if (position !== undefined && ("before" in position || "after" in position)) {
1350
+ const anchorRef = "before" in position ? position.before : position.after;
1351
+ const ar = resolveMovee(deps, anchorRef);
1352
+ if (ar instanceof ReferenceResolutionError) {
1353
+ return {
1354
+ kind: "move-refused",
1355
+ op,
1356
+ refusal: "usage",
1357
+ detail: ar.message,
1358
+ ...(ar.candidates !== undefined && { candidates: ar.candidates }),
1359
+ };
1360
+ }
1361
+ const arow = loadRow(deps.db, ar.uuid);
1362
+ if (arow === undefined || arow.type !== 2 || arow.project !== projectUuid) {
1363
+ return refused(op, "blocked", `the anchor ${ar.uuid} is not a heading of the movees' project — an anchor positions, it never migrates`, "pick a heading anchor in that project, or use --start / --end");
1364
+ }
1365
+ placement = "before" in position ? { before: ar.uuid } : { after: ar.uuid };
1366
+ }
1367
+ else if (position !== undefined && "at" in position) {
1368
+ placement = { position: position.at };
1369
+ }
1370
+ else {
1371
+ const current = currentHeadingOrder(deps, projectUuid);
1372
+ const moveeSet = new Set(movees);
1373
+ const earliest = current.findIndex((u) => moveeSet.has(u));
1374
+ const before = current.slice(0, Math.max(earliest, 0)).filter((u) => !moveeSet.has(u));
1375
+ placement =
1376
+ before.length > 0 ? { after: before[before.length - 1] } : { position: "first" };
1377
+ }
1378
+ // Compute the wire/reopened disclosure up front (both the dry-run plan and the ok
1379
+ // note surface it) — computeHeadingMovePre also validates membership/anchor.
1380
+ const pre = computeHeadingMovePre(deps.db, resolveProject(deps.db, { uuid: projectUuid }), movees, placement);
1381
+ if (pre.problems.length > 0) {
1382
+ return refused(op, "blocked", `heading reorder rejected: ${pre.problems.join("; ")}`, "reorder the headings of one project (read the project first)");
1383
+ }
1384
+ const touched = pre.wire.filter((u) => !movees.includes(u) && !pre.reopened.includes(u));
1385
+ const reopenNote = pre.reopened.length > 0
1386
+ ? `; re-ranking archived heading(s) ${pre.reopened.join(", ")} brought them back to open ` +
1387
+ "(their children stay resolved)"
1388
+ : "";
1389
+ const result = await runMutation(deps, op, { project: { uuid: projectUuid }, headings: movees, placement }, { ...legOptions(options), ...(options.dryRun === true && { dryRun: true }) });
1390
+ if (options.dryRun === true) {
1391
+ return {
1392
+ kind: "move-dry-run",
1393
+ op,
1394
+ plan: {
1395
+ movees,
1396
+ membership: "none (heading re-rank)",
1397
+ placement: `move-heading → ${describePosition(position)}`,
1398
+ placementClass: "guaranteed",
1399
+ note: dryRunNote(result, "heading re-rank via the native heading-block wire") + reopenNote,
1400
+ },
1401
+ };
1402
+ }
1403
+ if (result.kind === "blocked") {
1404
+ return {
1405
+ kind: "move-refused",
1406
+ op,
1407
+ refusal: "blocked",
1408
+ detail: result.detail,
1409
+ ...(result.remediation !== undefined && { remediation: result.remediation }),
1410
+ ...(result.hazard !== undefined && { hazard: result.hazard }),
1411
+ };
1412
+ }
1413
+ if (result.kind !== "ok") {
1414
+ return {
1415
+ kind: "move-leg-failed",
1416
+ op,
1417
+ detail: `the heading re-rank did not complete (${result.kind})`,
1418
+ failed: result,
1419
+ completed: [],
1420
+ };
1421
+ }
1422
+ return {
1423
+ kind: "move-ok",
1424
+ op,
1425
+ movees: headings.map((h) => ({ uuid: h.uuid, title: h.row.title })),
1426
+ membership: [],
1427
+ placement: result,
1428
+ placementClass: "guaranteed",
1429
+ note: `reordered ${movees.length} heading(s) within project ${projectUuid}` +
1430
+ reopenNote +
1431
+ (touched.length > 0
1432
+ ? `; also re-inserted ${touched.length} unnamed heading(s) to honor the order: ${touched.join(", ")}`
1433
+ : ""),
1434
+ };
1435
+ }
1436
+ /** Build the ordered single-area drag legs that realize a multi-area placement. */
1437
+ function planAreaLegs(areaUuids, position, anchorUuid) {
1438
+ if ("at" in position) {
1439
+ // first: drag last→first so the block ends up top-in-order; last: forward.
1440
+ return position.at === "first"
1441
+ ? areaUuids.toReversed().map((target) => ({ target, position: "first" }))
1442
+ : areaUuids.map((target) => ({ target, position: "last" }));
1443
+ }
1444
+ if ("before" in position) {
1445
+ // Each area dragged before the anchor, forward, lands them in order above it.
1446
+ return areaUuids.map((target) => ({ target, before: anchorUuid }));
1447
+ }
1448
+ // after: chain each area after the previous (anchor → a → b → c).
1449
+ const legs = [];
1450
+ let prev = anchorUuid;
1451
+ for (const target of areaUuids) {
1452
+ legs.push({ target, after: prev });
1453
+ prev = target;
1454
+ }
1455
+ return legs;
1456
+ }
1457
+ /**
1458
+ * Sidebar-area re-ranking via the universal verb — dispatches onto `area.reorder`
1459
+ * (the GUI sidebar-drag driver, the ONLY area-order surface, P6/O13). A single
1460
+ * area is one drag; a set composes sequential drags (non-atomic, disclosed). The
1461
+ * discriminating `things area reorder` alias reaches the same path.
1462
+ */
1463
+ async function runAreaReorderUniversal(deps, areas, position, options) {
1464
+ const op = "area.reorder";
1465
+ if (position === undefined) {
1466
+ return refused(op, "usage", "an area reorder needs a position — areas have no per-item bucket to assemble a block in", "use --start / --end, or --before / --after another area");
1467
+ }
1468
+ const movees = areas.map((a) => a.uuid);
1469
+ let anchorUuid = null;
1470
+ if ("before" in position || "after" in position) {
1471
+ const anchorRef = "before" in position ? position.before : position.after;
1472
+ const ar = resolveArea(deps.db, { uuid: anchorRef, title: anchorRef });
1473
+ if (ar.resolved === null) {
1474
+ return refused(op, "usage", `the anchor "${anchorRef}" did not resolve to an area`, "name an area (uuid or unique title) to anchor against");
1475
+ }
1476
+ anchorUuid = ar.resolved.uuid;
1477
+ if (movees.includes(anchorUuid)) {
1478
+ return refused(op, "blocked", "the anchor area cannot also be one of the moved areas — an anchor positions, it never migrates", "pick a different anchor area, or use --start / --end");
1479
+ }
1480
+ }
1481
+ const legs = planAreaLegs(movees, position, anchorUuid);
1482
+ const areaTitles = areas.map((a) => ({ uuid: a.uuid, title: a.title }));
1483
+ if (options.dryRun === true) {
1484
+ return {
1485
+ kind: "move-dry-run",
1486
+ op,
1487
+ plan: {
1488
+ movees,
1489
+ membership: "none (sidebar drag)",
1490
+ placement: `area re-rank → ${describePosition(position)}`,
1491
+ placementClass: "guaranteed",
1492
+ note: `${legs.length} sidebar-drag leg(s) — drives the local Things app, one drag per area`,
1493
+ },
1494
+ };
1495
+ }
1496
+ // Each leg is a full GUI-drive mutation; forward the drive acknowledgement.
1497
+ const legOpts = {
1498
+ ...legOptions(options),
1499
+ ...(options.dangerouslyDriveGui !== undefined && {
1500
+ dangerouslyDriveGui: options.dangerouslyDriveGui,
1501
+ }),
1502
+ };
1503
+ const placed = [];
1504
+ let last = null;
1505
+ for (const leg of legs) {
1506
+ const res = await runMutation(deps, op, leg, legOpts);
1507
+ last = res;
1508
+ if (res.kind === "blocked") {
1509
+ // A blocked FIRST leg (nothing moved yet) hoists to a clean refusal; a
1510
+ // blocked later leg is a partial-progress leg failure.
1511
+ if (placed.length === 0) {
1512
+ return {
1513
+ kind: "move-refused",
1514
+ op,
1515
+ refusal: "blocked",
1516
+ detail: res.detail,
1517
+ ...(res.remediation !== undefined && { remediation: res.remediation }),
1518
+ ...(res.hazard !== undefined && { hazard: res.hazard }),
1519
+ };
1520
+ }
1521
+ return {
1522
+ kind: "move-leg-failed",
1523
+ op,
1524
+ detail: `positioning ${leg.target} was blocked (${res.detail}) — ${placed.length} area(s) already moved`,
1525
+ failed: res,
1526
+ completed: [],
1527
+ };
1528
+ }
1529
+ if (res.kind !== "ok") {
1530
+ return {
1531
+ kind: "move-leg-failed",
1532
+ op,
1533
+ detail: `positioning ${leg.target} did not complete (${res.kind}) — ${placed.length} area(s) already moved`,
1534
+ failed: res,
1535
+ completed: [],
1536
+ };
1537
+ }
1538
+ placed.push(leg.target);
1539
+ }
1540
+ return {
1541
+ kind: "move-ok",
1542
+ op,
1543
+ movees: areaTitles,
1544
+ membership: [],
1545
+ placement: last,
1546
+ placementClass: "guaranteed",
1547
+ note: `re-ranked ${areas.length} area(s) via ${legs.length} sidebar drag(s)` +
1548
+ (legs.length > 1 ? " (sequential — non-atomic)" : ""),
1549
+ };
1550
+ }
1242
1551
  // ---------------------------------------------------------------- shared core
1243
1552
  /**
1244
1553
  * The packed day a single row contributes to a shared future day-group, or null.
@@ -1825,7 +2134,9 @@ function bucketMembers(deps, target, dayAnchor) {
1825
2134
  ...(target.container !== undefined && { container: { uuid: target.container } }),
1826
2135
  };
1827
2136
  const containerUuid = target.container ?? null;
1828
- const pre = computeReorderPre(deps.db, params, containerUuid, deps.now?.() ?? new Date());
2137
+ const pre = computeReorderPre(deps.db, params, containerUuid, deps.now?.() ?? new Date(), {
2138
+ zone: deps.zone,
2139
+ });
1829
2140
  return pre.members.map((m) => m.uuid);
1830
2141
  }
1831
2142
  function legOptions(options) {