exponential-cli 1.8.0 → 1.9.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.
package/README.md CHANGED
@@ -70,9 +70,17 @@ exponential actions create -n "Task name" -d "Description" -p <project-id> --pri
70
70
  exponential actions update --id <action-id> --kanban DONE
71
71
  exponential actions update --id <action-id> -n "New name" --priority "2nd Priority" --due 2026-03-15
72
72
 
73
- # Get today's actions
73
+ # Set the do-date (when you plan to work on it). This is what `today` partitions on,
74
+ # and it takes precedence over --due.
75
+ exponential actions update --id <action-id> --scheduled-start 2026-08-05T09:00
76
+ exponential actions update --id <action-id> --scheduled-start null # clear it
77
+
78
+ # What's actually on your plate: overdue + today + inbox
74
79
  exponential actions today
75
80
 
81
+ # Just the actions whose dueDate is today (excludes overdue — see note below)
82
+ exponential actions today --due-only
83
+
76
84
  # Get actions in a date range
77
85
  exponential actions range --start 2024-01-01 --end 2024-01-31
78
86
 
@@ -80,6 +88,48 @@ exponential actions range --start 2024-01-01 --end 2024-01-31
80
88
  exponential actions kanban --project <project-id>
81
89
  ```
82
90
 
91
+ #### Triaging an overdue pile
92
+
93
+ A large overdue count is usually not a large number of missed commitments — it's
94
+ a few bulk writes (a generated project plan, an import) that stamped every row
95
+ with one identical timestamp. `actions overdue` separates the two:
96
+
97
+ ```bash
98
+ exponential actions overdue
99
+ ```
100
+
101
+ ```
102
+ Overdue: 43
103
+
104
+ 21 of these were bulk-created — stamped with one identical timestamp,
105
+ so almost certainly never individually due.
106
+
107
+ 17 actions stamped 2026-07-25T08:29:55.483Z (10d overdue)
108
+ Projects: Entity Money Map, Net Worth Baseline, Tax Reserve System, …
109
+ Amnesty: exponential actions defer --ids cmqcgvtlh…,cmqcgvtnn…,…
110
+
111
+ 19 individually dated — real debt, oldest first.
112
+ 12d Send out the job description for the data engineering role [Hiring]
113
+ ```
114
+
115
+ Then pick a disposition:
116
+
117
+ ```bash
118
+ # Amnesty: clear the dates, back to the project backlog untimed.
119
+ # For work that was never really due on the date it carries.
120
+ exponential actions defer --ids id1,id2,id3
121
+
122
+ # Reschedule: still due, just later.
123
+ exponential actions reschedule --ids id1,id2 --to today
124
+ exponential actions reschedule --ids id1,id2 --to 2026-08-10
125
+ ```
126
+
127
+ > **Note on `actions today`:** as of v1.9.0 this returns the same
128
+ > overdue/today/inbox partition the `/today` page renders, rather than a
129
+ > due-date-only list. The old behaviour silently omitted the overdue pile — it
130
+ > would report 6 actions while 43 sat overdue. Pass `--due-only` for the
131
+ > previous output shape.
132
+
83
133
  ### Projects
84
134
 
85
135
  ```bash
package/dist/index.js CHANGED
@@ -329,6 +329,91 @@ ${statusBadge} ${chalk3.bold(action.name)}`);
329
329
  }
330
330
  console.log();
331
331
  }
332
+ function outputTodaysActionsJson(t, filters = {}) {
333
+ console.log(JSON.stringify({
334
+ overdue: { count: t.overdue.count, actions: t.overdue.actions },
335
+ today: { count: t.today.count, actions: t.today.actions },
336
+ inbox: { count: t.inbox.count, actions: t.inbox.actions },
337
+ total: t.overdue.count + t.today.count + t.inbox.count,
338
+ filters
339
+ }, null, 2));
340
+ }
341
+ function printTodayGroup(label, group, color) {
342
+ if (group.count === 0) return;
343
+ const truncated = group.count > group.actions.length ? chalk3.gray(` (showing ${group.actions.length})`) : "";
344
+ console.log(`
345
+ ${chalk3[color].bold(`${label} (${group.count})`)}${truncated}`);
346
+ for (const a of group.actions) {
347
+ const when = a.scheduledStart ?? a.dueDate;
348
+ const stamp = when ? chalk3.gray(formatDate(new Date(when))) : "";
349
+ const project = a.projectName ? chalk3.cyan(` [${a.projectName}]`) : "";
350
+ console.log(` ${a.name}${project} ${stamp}`);
351
+ console.log(chalk3.gray(` ID: ${a.id}`));
352
+ }
353
+ }
354
+ function outputTodaysActionsPretty(t) {
355
+ const total = t.overdue.count + t.today.count + t.inbox.count;
356
+ if (total === 0) {
357
+ console.log(chalk3.gray("Nothing on your plate."));
358
+ return;
359
+ }
360
+ console.log(chalk3.bold("\nOn your plate"));
361
+ console.log(chalk3.gray("\u2500".repeat(50)));
362
+ printTodayGroup("Overdue", t.overdue, "red");
363
+ printTodayGroup("Today", t.today, "green");
364
+ printTodayGroup("Inbox", t.inbox, "gray");
365
+ if (t.overdue.count > 0) {
366
+ console.log(chalk3.gray(`
367
+ Run "exponential actions overdue" to see why the overdue pile is that size.`));
368
+ }
369
+ console.log();
370
+ }
371
+ function outputOverdueTriageJson(t) {
372
+ console.log(JSON.stringify(t, null, 2));
373
+ }
374
+ function outputOverdueTriagePretty(t) {
375
+ if (t.totalOverdue === 0) {
376
+ console.log(chalk3.green("Nothing overdue."));
377
+ return;
378
+ }
379
+ console.log(chalk3.bold(`
380
+ Overdue: ${t.totalOverdue}`));
381
+ console.log(chalk3.gray("\u2500".repeat(50)));
382
+ if (t.cohorts.length > 0) {
383
+ console.log(
384
+ `
385
+ ${chalk3.yellow.bold(`${t.cohortCount} of these were bulk-created`)} ${chalk3.gray("\u2014 stamped with one identical timestamp, so almost certainly never individually due.")}`
386
+ );
387
+ for (const c of t.cohorts) {
388
+ console.log(
389
+ `
390
+ ${chalk3.bold(`${c.count} actions`)} stamped ${chalk3.gray(c.stampedAt.toISOString())} ${chalk3.red(`(${c.daysOverdue}d overdue)`)}`
391
+ );
392
+ if (c.projectNames.length > 0) {
393
+ console.log(` ${chalk3.cyan("Projects:")} ${c.projectNames.join(", ")}`);
394
+ }
395
+ for (const a of c.actions.slice(0, 4)) {
396
+ console.log(chalk3.gray(` \xB7 ${a.name.substring(0, 62)}`));
397
+ }
398
+ if (c.actions.length > 4) {
399
+ console.log(chalk3.gray(` \xB7 \u2026 and ${c.actions.length - 4} more`));
400
+ }
401
+ console.log(
402
+ chalk3.gray(` Amnesty: `) + `exponential actions defer --ids ${c.actionIds.slice(0, 2).join(",")}${c.actionIds.length > 2 ? ",\u2026" : ""}`
403
+ );
404
+ }
405
+ }
406
+ if (t.loose.length > 0) {
407
+ console.log(`
408
+ ${chalk3.bold(`${t.loose.length} individually dated`)} ${chalk3.gray("\u2014 real debt, oldest first.")}`);
409
+ for (const a of t.loose) {
410
+ const project = a.projectName ? chalk3.cyan(` [${a.projectName}]`) : "";
411
+ console.log(` ${chalk3.red(`${String(a.daysOverdue).padStart(3)}d`)} ${a.name.substring(0, 62)}${project}`);
412
+ console.log(chalk3.gray(` ID: ${a.id}`));
413
+ }
414
+ }
415
+ console.log();
416
+ }
332
417
  function outputProjectsJson(projects) {
333
418
  const output = {
334
419
  projects: projects.map(transformProject),
@@ -1312,6 +1397,22 @@ var VALID_PRIORITIES = [
1312
1397
  "Watch",
1313
1398
  "Someday Maybe"
1314
1399
  ];
1400
+ function parseNullableDate(raw, flag) {
1401
+ if (raw === void 0) return void 0;
1402
+ if (raw === "null") return null;
1403
+ const parsed = new Date(raw);
1404
+ if (isNaN(parsed.getTime())) {
1405
+ throw new Error(`Invalid --${flag} value "${raw}". Use YYYY-MM-DD, an ISO datetime, or "null".`);
1406
+ }
1407
+ return parsed;
1408
+ }
1409
+ function parseIds(raw) {
1410
+ const ids = raw.split(",").map((s) => s.trim()).filter(Boolean);
1411
+ if (ids.length === 0) {
1412
+ throw new Error("No action IDs supplied. Pass --ids id1,id2");
1413
+ }
1414
+ return ids;
1415
+ }
1315
1416
  function createActionsCommand() {
1316
1417
  const actions = new Command2("actions").description("Manage actions/tasks");
1317
1418
  actions.command("list").description("List all actions").option("--project <id>", "Filter by project ID").option("--status <status>", "Filter by kanban status (BACKLOG, TODO, IN_PROGRESS, IN_REVIEW, DONE)").option("--assignee <id>", "Filter by assignee ID").action(async (options, cmd) => {
@@ -1337,16 +1438,76 @@ function createActionsCommand() {
1337
1438
  handleError(error, useJson);
1338
1439
  }
1339
1440
  });
1340
- actions.command("today").description("Get actions due today").option("--workspace <id>", "Filter by workspace ID").action(async (options, cmd) => {
1441
+ actions.command("today").description("What's on your plate: overdue, today, and inbox").option("--workspace <id>", "Filter by workspace ID").option("--due-only", "Only actions whose dueDate is today (the pre-1.8 shape \u2014 excludes overdue)").action(async (options, cmd) => {
1341
1442
  const globalOpts = cmd.optsWithGlobals();
1342
1443
  const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
1343
1444
  try {
1344
1445
  const client = getClient();
1345
- const actions2 = await client.actions.getToday(options.workspace);
1446
+ if (options.dueOnly) {
1447
+ const actions2 = await client.actions.getToday(options.workspace);
1448
+ if (useJson) {
1449
+ outputActionsJson(actions2, { workspaceId: options.workspace });
1450
+ } else {
1451
+ outputActionsPretty(actions2);
1452
+ }
1453
+ return;
1454
+ }
1455
+ const todays = await client.actions.getTodaysActions(options.workspace);
1346
1456
  if (useJson) {
1347
- outputActionsJson(actions2, { workspaceId: options.workspace });
1457
+ outputTodaysActionsJson(todays, { workspaceId: options.workspace });
1348
1458
  } else {
1349
- outputActionsPretty(actions2);
1459
+ outputTodaysActionsPretty(todays);
1460
+ }
1461
+ } catch (error) {
1462
+ handleError(error, useJson);
1463
+ }
1464
+ });
1465
+ actions.command("overdue").description("Why the overdue pile is that size: bulk-created cohorts vs real debt").option("--workspace <id>", "Filter by workspace ID").action(async (options, cmd) => {
1466
+ const globalOpts = cmd.optsWithGlobals();
1467
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
1468
+ try {
1469
+ const client = getClient();
1470
+ const triage = await client.actions.getOverdueTriage(options.workspace);
1471
+ if (useJson) {
1472
+ outputOverdueTriageJson(triage);
1473
+ } else {
1474
+ outputOverdueTriagePretty(triage);
1475
+ }
1476
+ } catch (error) {
1477
+ handleError(error, useJson);
1478
+ }
1479
+ });
1480
+ actions.command("defer").description("Amnesty: clear dates so actions fall back to their project backlog untimed").requiredOption("--ids <ids>", "Comma-separated action IDs").action(async (options, cmd) => {
1481
+ const globalOpts = cmd.optsWithGlobals();
1482
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
1483
+ try {
1484
+ const ids = parseIds(options.ids);
1485
+ const client = getClient();
1486
+ const result = await client.actions.bulkDefer(ids);
1487
+ if (useJson) {
1488
+ console.log(JSON.stringify(result, null, 2));
1489
+ } else {
1490
+ console.log(`\u2713 ${result.message}`);
1491
+ }
1492
+ } catch (error) {
1493
+ handleError(error, useJson);
1494
+ }
1495
+ });
1496
+ actions.command("reschedule").description('Move actions to a new do-date (use "actions defer" if they were never really due)').requiredOption("--ids <ids>", "Comma-separated action IDs").requiredOption("--to <date>", 'New do-date (YYYY-MM-DD, or "today")').action(async (options, cmd) => {
1497
+ const globalOpts = cmd.optsWithGlobals();
1498
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
1499
+ try {
1500
+ const ids = parseIds(options.ids);
1501
+ const when = options.to === "today" ? /* @__PURE__ */ new Date() : new Date(options.to);
1502
+ if (isNaN(when.getTime())) {
1503
+ throw new Error('Invalid date format. Use YYYY-MM-DD or "today"');
1504
+ }
1505
+ const client = getClient();
1506
+ const result = await client.actions.bulkReschedule(ids, when);
1507
+ if (useJson) {
1508
+ console.log(JSON.stringify(result, null, 2));
1509
+ } else {
1510
+ console.log(`\u2713 Rescheduled ${result.count} action${result.count === 1 ? "" : "s"}`);
1350
1511
  }
1351
1512
  } catch (error) {
1352
1513
  handleError(error, useJson);
@@ -1395,7 +1556,7 @@ function createActionsCommand() {
1395
1556
  handleError(error, useJson);
1396
1557
  }
1397
1558
  });
1398
- actions.command("update").description("Update an existing action").requiredOption("--id <id>", "Action ID to update").option("-n, --name <name>", "New action name").option("-d, --description <text>", "New description").option("-p, --project <id>", "Move to project ID").option("--priority <priority>", "Priority (Quick, Scheduled, 1st Priority, etc.)").option("--status <status>", "Status (ACTIVE, COMPLETED, CANCELLED)").option("--kanban <status>", "Kanban status (BACKLOG, TODO, IN_PROGRESS, IN_REVIEW, DONE)").option("--due <date>", 'Due date (YYYY-MM-DD or "null" to clear)').action(async (options, cmd) => {
1559
+ actions.command("update").description("Update an existing action").requiredOption("--id <id>", "Action ID to update").option("-n, --name <name>", "New action name").option("-d, --description <text>", "New description").option("-p, --project <id>", "Move to project ID").option("--priority <priority>", "Priority (Quick, Scheduled, 1st Priority, etc.)").option("--status <status>", "Status (ACTIVE, COMPLETED, CANCELLED)").option("--kanban <status>", "Kanban status (BACKLOG, TODO, IN_PROGRESS, IN_REVIEW, DONE)").option("--due <date>", 'Due date (YYYY-MM-DD or "null" to clear)').option("--scheduled-start <datetime>", 'Do-date: when you plan to work on it (YYYY-MM-DD, ISO datetime, or "null" to clear). This is what /today partitions on.').option("--scheduled-end <datetime>", 'End of the time block (YYYY-MM-DD, ISO datetime, or "null" to clear)').action(async (options, cmd) => {
1399
1560
  const globalOpts = cmd.optsWithGlobals();
1400
1561
  const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
1401
1562
  try {
@@ -1411,6 +1572,8 @@ function createActionsCommand() {
1411
1572
  throw new Error("Invalid due date format. Use YYYY-MM-DD");
1412
1573
  }
1413
1574
  }
1575
+ const scheduledStart = parseNullableDate(options.scheduledStart, "scheduled-start");
1576
+ const scheduledEnd = parseNullableDate(options.scheduledEnd, "scheduled-end");
1414
1577
  const client = getClient();
1415
1578
  const action = await client.actions.update({
1416
1579
  id: options.id,
@@ -1420,7 +1583,9 @@ function createActionsCommand() {
1420
1583
  priority: options.priority,
1421
1584
  status: options.status,
1422
1585
  kanbanStatus: options.kanban,
1423
- dueDate
1586
+ dueDate,
1587
+ scheduledStart,
1588
+ scheduledEnd
1424
1589
  });
1425
1590
  if (useJson) {
1426
1591
  outputActionJson(action);
@@ -2034,7 +2199,7 @@ function createEpicsCommand() {
2034
2199
  const globalOpts = cmd.optsWithGlobals();
2035
2200
  const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
2036
2201
  try {
2037
- const parseNullableDate = (v) => {
2202
+ const parseNullableDate2 = (v) => {
2038
2203
  if (v === void 0) return void 0;
2039
2204
  if (v === "null") return null;
2040
2205
  const d = new Date(v);
@@ -2053,8 +2218,8 @@ function createEpicsCommand() {
2053
2218
  description,
2054
2219
  status,
2055
2220
  priority,
2056
- startDate: parseNullableDate(options.start),
2057
- targetDate: parseNullableDate(options.target)
2221
+ startDate: parseNullableDate2(options.start),
2222
+ targetDate: parseNullableDate2(options.target)
2058
2223
  });
2059
2224
  if (useJson) outputEpicJson(epic);
2060
2225
  else {
@@ -2500,6 +2665,17 @@ var FEATURE_STATUSES = [
2500
2665
  "DEPRECATED",
2501
2666
  "ARCHIVED"
2502
2667
  ];
2668
+ function summarizeTicket(ticket) {
2669
+ return {
2670
+ id: ticket.id,
2671
+ shortId: ticket.shortId ?? null,
2672
+ number: ticket.number ?? null,
2673
+ title: ticket.title
2674
+ };
2675
+ }
2676
+ function ticketLabel(ticket) {
2677
+ return ticket.shortId ?? (ticket.number != null ? `#${ticket.number}` : ticket.id);
2678
+ }
2503
2679
  function validateFeatureStatus(value) {
2504
2680
  if (!value) return void 0;
2505
2681
  if (!FEATURE_STATUSES.includes(value)) {
@@ -2606,6 +2782,119 @@ function createFeaturesCommand() {
2606
2782
  }
2607
2783
  }
2608
2784
  );
2785
+ features.command("delete <id>").description(
2786
+ "Delete a feature. Refuses if it still has tickets \u2014 deleting a feature does not delete its tickets, it unlinks them (Ticket.featureId is SetNull), silently orphaning them in the product backlog."
2787
+ ).option(
2788
+ "--with-tickets",
2789
+ "Delete the feature's tickets first, then the feature (destructive)"
2790
+ ).option(
2791
+ "--orphan-tickets",
2792
+ "Delete the feature anyway, leaving its tickets in the backlog unlinked"
2793
+ ).action(
2794
+ async (id, options, cmd) => {
2795
+ const globalOpts = cmd.optsWithGlobals();
2796
+ const useJson = shouldUseJson(globalOpts.json, globalOpts.pretty);
2797
+ try {
2798
+ if (options.withTickets && options.orphanTickets) {
2799
+ throw new Error(
2800
+ "--with-tickets and --orphan-tickets are mutually exclusive"
2801
+ );
2802
+ }
2803
+ const client = getClient();
2804
+ const feature = await client.features.get(id);
2805
+ const tickets = feature._count?.tickets === 0 ? [] : await client.tickets.list({
2806
+ productId: feature.productId,
2807
+ featureId: feature.id
2808
+ });
2809
+ if (tickets.length > 0 && !options.withTickets && !options.orphanTickets) {
2810
+ const reason = `has ${tickets.length} ticket${tickets.length === 1 ? "" : "s"}`;
2811
+ if (useJson) {
2812
+ console.log(
2813
+ JSON.stringify(
2814
+ {
2815
+ deleted: false,
2816
+ id: feature.id,
2817
+ reason,
2818
+ tickets: tickets.map(summarizeTicket)
2819
+ },
2820
+ null,
2821
+ 2
2822
+ )
2823
+ );
2824
+ } else {
2825
+ console.error(
2826
+ `
2827
+ Refusing to delete "${feature.name}": it ${reason}.
2828
+ Deleting the feature would leave them unlinked in the product backlog.
2829
+ --with-tickets delete those tickets first, then the feature
2830
+ --orphan-tickets delete the feature anyway and unlink them`
2831
+ );
2832
+ for (const t of tickets) {
2833
+ console.error(` - ${ticketLabel(t)} ${t.title}`);
2834
+ }
2835
+ }
2836
+ process.exitCode = 1;
2837
+ return;
2838
+ }
2839
+ const ticketsDeleted = [];
2840
+ const ticketsFailed = [];
2841
+ if (options.withTickets) {
2842
+ for (const ticket of tickets) {
2843
+ try {
2844
+ await client.tickets.delete(ticket.id);
2845
+ ticketsDeleted.push(summarizeTicket(ticket));
2846
+ } catch (error) {
2847
+ ticketsFailed.push({
2848
+ ...summarizeTicket(ticket),
2849
+ error: error instanceof Error ? error.message : String(error)
2850
+ });
2851
+ }
2852
+ }
2853
+ }
2854
+ if (ticketsFailed.length > 0) {
2855
+ const reason = `failed to delete ${ticketsFailed.length} of ${tickets.length} tickets`;
2856
+ if (useJson) {
2857
+ console.log(
2858
+ JSON.stringify(
2859
+ { deleted: false, id: feature.id, reason, ticketsDeleted, ticketsFailed },
2860
+ null,
2861
+ 2
2862
+ )
2863
+ );
2864
+ } else {
2865
+ console.error(`
2866
+ Aborted: ${reason}. Feature "${feature.name}" was NOT deleted.`);
2867
+ for (const t of ticketsFailed) {
2868
+ console.error(` - ${t.shortId ?? t.id}: ${t.error}`);
2869
+ }
2870
+ }
2871
+ process.exitCode = 1;
2872
+ return;
2873
+ }
2874
+ await client.features.delete(feature.id);
2875
+ const ticketsOrphaned = options.orphanTickets ? tickets.map(summarizeTicket) : [];
2876
+ if (useJson) {
2877
+ console.log(
2878
+ JSON.stringify(
2879
+ { deleted: true, id: feature.id, ticketsDeleted, ticketsOrphaned },
2880
+ null,
2881
+ 2
2882
+ )
2883
+ );
2884
+ } else {
2885
+ console.log("\n\u2713 Feature deleted");
2886
+ if (ticketsDeleted.length > 0) {
2887
+ console.log(` ${ticketsDeleted.length} ticket(s) deleted with it`);
2888
+ }
2889
+ if (ticketsOrphaned.length > 0) {
2890
+ console.log(` ${ticketsOrphaned.length} ticket(s) left unlinked in the backlog`);
2891
+ }
2892
+ }
2893
+ } catch (error) {
2894
+ handleError(error, useJson);
2895
+ }
2896
+ }
2897
+ );
2609
2898
  features.command("link-page").description("Link a Knowledge page (PRD, spec, research) to a feature").requiredOption("--feature <id>", "Feature CUID").requiredOption("--page <id>", "Page CUID").option("--scope <id>", "Feature scope CUID to pin the page to").action(
2610
2899
  async (options, cmd) => {
2611
2900
  const globalOpts = cmd.optsWithGlobals();