@harborppm/cli 0.86.0 → 0.88.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/dist/app.mjs CHANGED
@@ -1898,7 +1898,20 @@ function boardPositions(tasks) {
1898
1898
  return out;
1899
1899
  });
1900
1900
  }
1901
- var ids$1, projectTasks$1, EMPTY, waiting, positions;
1901
+ /** For each task id, the tasks that name it as their parent, in list order (src/lib/subtasks.ts reads it). */
1902
+ function childrenByParent(tasks) {
1903
+ return cached$1(children, tasks, () => {
1904
+ const out = /* @__PURE__ */ new Map();
1905
+ for (const t of tasks) {
1906
+ if (!t.parentTaskId) continue;
1907
+ const list = out.get(t.parentTaskId);
1908
+ if (list) list.push(t);
1909
+ else out.set(t.parentTaskId, [t]);
1910
+ }
1911
+ return out;
1912
+ });
1913
+ }
1914
+ var ids$1, projectTasks$1, EMPTY, waiting, positions, children;
1902
1915
  var init_indexes = __esmMin((() => {
1903
1916
  init_blockers();
1904
1917
  ids$1 = /* @__PURE__ */ new WeakMap();
@@ -1906,6 +1919,7 @@ var init_indexes = __esmMin((() => {
1906
1919
  EMPTY = Object.freeze([]);
1907
1920
  waiting = /* @__PURE__ */ new WeakMap();
1908
1921
  positions = /* @__PURE__ */ new WeakMap();
1922
+ children = /* @__PURE__ */ new WeakMap();
1909
1923
  }));
1910
1924
 
1911
1925
  // src/lib/premortem.ts
@@ -4765,6 +4779,54 @@ var init_task_people = __esmMin((() => {
4765
4779
  init_agents();
4766
4780
  }));
4767
4781
 
4782
+ // src/lib/subtasks.ts
4783
+ /** The parent `task` is a piece of, when that link holds; otherwise null. */
4784
+ function parentOf(task, tasks) {
4785
+ const id = task.parentTaskId;
4786
+ if (!id || id === task.id) return null;
4787
+ const parent = byId(tasks).get(id);
4788
+ if (!parent || parent.projectId !== task.projectId || parent.parentTaskId) return null;
4789
+ return parent;
4790
+ }
4791
+ /** Whether `task` shows inside its parent rather than on its own. */
4792
+ function isSubtask(task, tasks) {
4793
+ return parentOf(task, tasks) !== null;
4794
+ }
4795
+ /** The subtasks of `parent`, in list order. Empty for a task that is itself a subtask. */
4796
+ function subtasksOf(parent, tasks) {
4797
+ if (isSubtask(parent, tasks)) return NONE$2;
4798
+ const list = childrenByParent(tasks).get(parent.id);
4799
+ if (!list) return NONE$2;
4800
+ return list.filter((t) => t.projectId === parent.projectId && t.id !== parent.id);
4801
+ }
4802
+ /** The tasks that stand on their own: what a board or a list shows. */
4803
+ function topLevel(list, tasks) {
4804
+ return list.filter((t) => !isSubtask(t, tasks));
4805
+ }
4806
+ /**
4807
+ * Why `childId` can't become a subtask of `parentId`, or null when it can.
4808
+ * The parent must be another task in the same project that isn't a subtask,
4809
+ * and the child mustn't have subtasks of its own (one level only).
4810
+ */
4811
+ function subtaskProblem(childId, parentId, tasks) {
4812
+ const index = byId(tasks);
4813
+ const child = index.get(childId);
4814
+ const parent = index.get(parentId);
4815
+ if (!parent) return "That task isn't here any more.";
4816
+ if (childId === parentId) return "A task can't be a subtask of itself.";
4817
+ if (child && child.projectId !== parent.projectId) return "A subtask has to be in the same project as its parent.";
4818
+ if (isSubtask(parent, tasks)) return "That task is already a subtask; subtasks go one level deep.";
4819
+ if (child && subtasksOf(child, tasks).length > 0) return "This task has subtasks of its own, so it can't go inside another.";
4820
+ if (subtasksOf(parent, tasks).filter((t) => t.id !== childId).length >= 50) return `A task can hold up to ${50} subtasks.`;
4821
+ return null;
4822
+ }
4823
+ var SUBTASKS_MAX, NONE$2;
4824
+ var init_subtasks = __esmMin((() => {
4825
+ init_indexes();
4826
+ SUBTASKS_MAX = 50;
4827
+ NONE$2 = Object.freeze([]);
4828
+ }));
4829
+
4768
4830
  // src/lib/board.ts
4769
4831
  /** The column's cap after "No cap" (`uncapped`) or a number is picked. */
4770
4832
  function withWipLimit(col, limit) {
@@ -4943,11 +5005,12 @@ function wipFullMessage(overflow) {
4943
5005
  function columnWip(data, projectId, status, extra = 1) {
4944
5006
  const col = boardConfigOf(data, projectId).columns.find((c) => c.status === status);
4945
5007
  if (!col) return null;
4946
- const current = tasksOf(data.tasks, projectId).filter((t) => t.status === status).length;
5008
+ const current = tasksOf(data.tasks, projectId).filter((t) => t.status === status && !isSubtask(t, data.tasks)).length;
4947
5009
  return wipOverflow(col, current, extra);
4948
5010
  }
4949
5011
  var BOARD_PRESETS, ALL_FIELDS, DEFAULT_DOING_WIP, DEFAULT_REVIEW_WIP, WIP_CAP_CHOICES;
4950
5012
  var init_board = __esmMin((() => {
5013
+ init_subtasks();
4951
5014
  init_indexes();
4952
5015
  init_swatches();
4953
5016
  BOARD_PRESETS = [
@@ -5003,6 +5066,22 @@ function isVoyageProject(projectId) {
5003
5066
  function ownProjects(projects) {
5004
5067
  return projects.filter((p) => !isVoyageProject(p.id));
5005
5068
  }
5069
+ /**
5070
+ * The harbor as anyone outside it may see it: First voyage is practice, so
5071
+ * share links, their embeds and the Monday letter to stakeholders leave it
5072
+ * out, with its tasks, risks and spend, from what they show and count.
5073
+ */
5074
+ function withoutPractice(data) {
5075
+ if (!data.projects.some((p) => isVoyageProject(p.id))) return data;
5076
+ const out = {
5077
+ ...data,
5078
+ projects: ownProjects(data.projects),
5079
+ tasks: data.tasks.filter((t) => !isVoyageProject(t.projectId)),
5080
+ raidItems: data.raidItems.filter((r) => !r.projectId || !isVoyageProject(r.projectId))
5081
+ };
5082
+ if (data.spendEntries) out.spendEntries = data.spendEntries.filter((e) => !isVoyageProject(e.projectId));
5083
+ return out;
5084
+ }
5006
5085
  var VOYAGE_PROJECT_ID, STEPS, voyageTaskId, VOYAGE_NO_FORECAST, SEEDED;
5007
5086
  var init_first_voyage = __esmMin((() => {
5008
5087
  VOYAGE_PROJECT_ID = "voyage";
@@ -5979,6 +6058,7 @@ init_date_fns();
5979
6058
  init_date_text();
5980
6059
  init_first_voyage();
5981
6060
  init_indexes();
6061
+ init_subtasks();
5982
6062
  init_tide();
5983
6063
  init_format();
5984
6064
  init_raid();
@@ -6049,7 +6129,7 @@ function myRaid(data, personId) {
6049
6129
  /** A project's board: its visible columns, in order, with their tasks. */
6050
6130
  function boardOf(data, projectId) {
6051
6131
  const config = boardConfigOf(data, projectId);
6052
- const tasks = tasksOf(data.tasks, projectId);
6132
+ const tasks = topLevel(tasksOf(data.tasks, projectId), data.tasks);
6053
6133
  return visibleColumns(config).map((col) => ({
6054
6134
  status: col.status,
6055
6135
  label: columnLabel(col),
@@ -6065,7 +6145,7 @@ function neighbourStatus(data, projectId, status, step) {
6065
6145
  return cols[at + step] ?? null;
6066
6146
  }
6067
6147
  function projectTasks(data, projectId) {
6068
- return [...tasksOf(data.tasks, projectId)].sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || (a.number ?? 0) - (b.number ?? 0));
6148
+ return topLevel(tasksOf(data.tasks, projectId), data.tasks).sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || (a.number ?? 0) - (b.number ?? 0));
6069
6149
  }
6070
6150
  /**
6071
6151
  * The project a new task goes in when none was picked: the only open one,
@@ -8141,6 +8221,7 @@ function list$2(names) {
8141
8221
  * document only; nothing here changes it.
8142
8222
  */
8143
8223
  function shippingForecast(data, opts) {
8224
+ data = withoutPractice(data);
8144
8225
  const { now } = opts;
8145
8226
  const today = todayISO(now);
8146
8227
  const programs = data.programs.filter((p) => !opts.portfolioId || p.portfolioId === opts.portfolioId);
@@ -8227,6 +8308,7 @@ var init_shipping_forecast = __esmMin((() => {
8227
8308
  init_date_text();
8228
8309
  init_format();
8229
8310
  init_indexes();
8311
+ init_first_voyage();
8230
8312
  FORECAST_AREAS = 10;
8231
8313
  WORDS = [
8232
8314
  "no",
@@ -9958,7 +10040,9 @@ const KIND_RANK = {
9958
10040
  sounding: 3,
9959
10041
  assigned: 4,
9960
10042
  idle: 5,
9961
- mention: 6
10043
+ mention: 6,
10044
+ reply: 7,
10045
+ finished: 8
9962
10046
  };
9963
10047
  const PING_KIND_LABEL = {
9964
10048
  harbor: "Your harbor",
@@ -9968,7 +10052,9 @@ const PING_KIND_LABEL = {
9968
10052
  sounding: "Soundings",
9969
10053
  assigned: "Assigned to you",
9970
10054
  idle: "Quiet blockers",
9971
- mention: "Mentions"
10055
+ mention: "Mentions",
10056
+ reply: "Replies",
10057
+ finished: "Finished"
9972
10058
  };
9973
10059
  function firstName$3(name) {
9974
10060
  return name.split(/\s+/)[0] ?? name;
@@ -10176,6 +10262,63 @@ function inboxOf(data, personId, now = clockNow()) {
10176
10262
  href: `/projects/${task.projectId}`
10177
10263
  });
10178
10264
  }
10265
+ const recentByTask = /* @__PURE__ */ new Map();
10266
+ for (const c of data.comments) {
10267
+ const list = recentByTask.get(c.taskId);
10268
+ if (list) list.push(c);
10269
+ else recentByTask.set(c.taskId, [c]);
10270
+ }
10271
+ for (const [taskId, thread] of recentByTask) {
10272
+ const task = byId(data.tasks).get(taskId);
10273
+ if (!task) continue;
10274
+ const ordered = [...thread].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
10275
+ let joined = task.assignedBy === personId && task.assigneeId !== personId;
10276
+ const replies = [];
10277
+ for (const c of ordered) {
10278
+ if (c.authorId === personId) {
10279
+ joined = true;
10280
+ replies.length = 0;
10281
+ continue;
10282
+ }
10283
+ if (!joined || c.createdAt < since) continue;
10284
+ if ((c.mentionedIds ?? []).includes(personId)) continue;
10285
+ replies.push(c);
10286
+ }
10287
+ const latest = replies.at(-1);
10288
+ if (!latest) continue;
10289
+ const authors = [...new Set(replies.map((c) => {
10290
+ const who = data.people.find((p) => p.id === c.authorId);
10291
+ return who ? firstName$3(who.name) : "Someone";
10292
+ }))];
10293
+ const project = projectOf(data, task);
10294
+ const snippet = excerpt(latest.body);
10295
+ pings.push({
10296
+ id: `reply:${latest.id}`,
10297
+ kind: "reply",
10298
+ title: `${listNames(authors)} replied`,
10299
+ detail: snippet ? `on “${task.title}”${project ? ` · ${project.name}` : ""} — ${snippet}` : `on “${task.title}”${project ? ` · ${project.name}` : ""}`,
10300
+ at: latest.createdAt,
10301
+ taskId: task.id,
10302
+ personId: latest.authorId,
10303
+ href: `/projects/${task.projectId}`
10304
+ });
10305
+ }
10306
+ for (const task of data.tasks) {
10307
+ if (task.assignedBy !== personId || !task.assigneeId || task.assigneeId === personId) continue;
10308
+ if (task.status !== "done" || !task.completedAt || task.completedAt < since) continue;
10309
+ const who = data.people.find((p) => p.id === task.assigneeId);
10310
+ const project = projectOf(data, task);
10311
+ pings.push({
10312
+ id: `finished:${task.id}:${task.completedAt}`,
10313
+ kind: "finished",
10314
+ title: `${who ? firstName$3(who.name) : "Someone"} finished “${task.title}”`,
10315
+ detail: project ? `A task you gave them · ${project.name}` : "A task you gave them",
10316
+ at: task.completedAt,
10317
+ taskId: task.id,
10318
+ personId: task.assigneeId,
10319
+ href: `/projects/${task.projectId}`
10320
+ });
10321
+ }
10179
10322
  return pings.sort((a, b) => {
10180
10323
  const rank = KIND_RANK[a.kind] - KIND_RANK[b.kind];
10181
10324
  if (rank !== 0) return rank;
@@ -19822,11 +19965,7 @@ function fieldValueText(def, value, nameOf) {
19822
19965
  }
19823
19966
 
19824
19967
  // src/lib/exports.ts
19825
- /**
19826
- * The spreadsheets Harbor hands out: a project's tasks, the risk register and
19827
- * the review. The website downloads them, agents and the terminal get the
19828
- * same text (the export_csv tool, `harbor export`).
19829
- */
19968
+ init_subtasks();
19830
19969
  init_derived_health();
19831
19970
  init_priority();
19832
19971
  init_raid();
@@ -19861,6 +20000,7 @@ function tasksCsv(data, projectId) {
19861
20000
  "Done",
19862
20001
  "Blocked by",
19863
20002
  "Description",
20003
+ "Subtask of",
19864
20004
  ...fields.map((f) => f.name)
19865
20005
  ], tasks.map((t) => [
19866
20006
  taskKey(t, project) ?? "",
@@ -19872,6 +20012,7 @@ function tasksCsv(data, projectId) {
19872
20012
  t.status === "done" ? "yes" : "no",
19873
20013
  blockerIds(t).map((id) => here.get(id)).filter((b) => Boolean(b)).map(name).join("; "),
19874
20014
  t.description,
20015
+ ((p) => p ? name(p) : "")(parentOf(t, data.tasks)),
19875
20016
  ...fields.map((f) => f.type === "url" ? safeLink(t.customFieldValues?.[f.id]) ?? "" : fieldValueText(f, t.customFieldValues?.[f.id], nameOf))
19876
20017
  ]));
19877
20018
  }
@@ -20482,16 +20623,7 @@ function approvalsWaitingOn(tasks, personId) {
20482
20623
  }
20483
20624
 
20484
20625
  // src/lib/mcp/tools.ts
20485
- /**
20486
- * What an agent can read about a harbor, as MCP tools. Each tool is a pure
20487
- * function of one harbor's document (health already stamped) and who is
20488
- * asking, so it is tested against the demo without a server. The route in
20489
- * src/routes/api/mcp.ts signs the caller in, picks the harbor and loads it.
20490
- *
20491
- * Read only: nothing here changes a workspace. Answers carry ids so an agent
20492
- * can ask about one thing next, and every list is capped and pageable, since
20493
- * a harbor can hold thousands of tasks.
20494
- */
20626
+ init_subtasks();
20495
20627
  init_thanks();
20496
20628
  init_task_keys();
20497
20629
  init_brief();
@@ -21267,7 +21399,7 @@ const HARBOR_TOOLS = [
21267
21399
  tool({
21268
21400
  name: "get_task",
21269
21401
  title: "Task details",
21270
- description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), its values for the project's own fields by name (fields, when it has any; set them with set_field), and its url on the website." + DATA_NOTE,
21402
+ description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), its values for the project's own fields by name (fields, when it has any; set them with set_field), its subtasks or the task it's a subtask of (subtaskOf), and its url on the website." + DATA_NOTE,
21271
21403
  input: object({
21272
21404
  harbor: harborArg,
21273
21405
  task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
@@ -21319,6 +21451,12 @@ const HARBOR_TOOLS = [
21319
21451
  ...t.time?.length ? { timeLogged: timeOut(t, people) } : {},
21320
21452
  waitingOn: blockersOf(t, data.tasks).map(brief),
21321
21453
  waitedOnBy: waiters.map(brief),
21454
+ ...((parent) => parent ? { subtaskOf: brief(parent) } : {})(parentOf(t, data.tasks)),
21455
+ ...((subs) => subs.length ? { subtasks: subs.map((x) => ({
21456
+ ...brief(x),
21457
+ assignee: nameOf$2(people, x.assigneeId),
21458
+ due: dayOf(x.dueDate)
21459
+ })) } : {})(subtasksOf(t, data.tasks)),
21322
21460
  ...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
21323
21461
  ...t.approval ? { approval: approvalOut(data, t.approval) } : {},
21324
21462
  ...((v) => v ? { fields: v } : {})(fieldValuesOut(data, project, t)),
@@ -23888,7 +24026,9 @@ function tasksSlice({ set, get, write }) {
23888
24026
  get().log(`added ${who?.name ?? "someone"} as a second pair of hands on ${taskIds.length + raidIds.length} item${taskIds.length + raidIds.length === 1 ? "" : "s"}`, "/people");
23889
24027
  },
23890
24028
  addTask: (t, opts) => {
23891
- if (!opts?.allowOverCap) {
24029
+ const wanted = t.parentTaskId ? byId(get().tasks).get(t.parentTaskId) : void 0;
24030
+ const parentTaskId = wanted && wanted.projectId === t.projectId && !subtaskProblem("", wanted.id, get().tasks) ? wanted.id : null;
24031
+ if (!opts?.allowOverCap && !parentTaskId) {
23892
24032
  if (columnWip(get(), t.projectId, t.status, 1)) return "";
23893
24033
  }
23894
24034
  const id = uid("t");
@@ -23899,6 +24039,7 @@ function tasksSlice({ set, get, write }) {
23899
24039
  createdById: get().currentUserId || null,
23900
24040
  completedAt: t.status === "done" ? t.completedAt ?? stamp() : null,
23901
24041
  checklist: t.checklist ?? [],
24042
+ ..."parentTaskId" in t ? { parentTaskId } : {},
23902
24043
  ...blockersPatch(blockerIds({
23903
24044
  id,
23904
24045
  blockedById: t.blockedById,
@@ -23925,6 +24066,10 @@ function tasksSlice({ set, get, write }) {
23925
24066
  },
23926
24067
  updateTask: (id, patch, opts) => {
23927
24068
  const task = get().tasks.find((row) => row.id === id);
24069
+ if (task && patch.parentTaskId && subtaskProblem(id, patch.parentTaskId, get().tasks)) {
24070
+ patch = { ...patch };
24071
+ delete patch.parentTaskId;
24072
+ }
23928
24073
  if (task && patch.status && patch.status !== task.status) {
23929
24074
  if (patch.status === "done" && !opts?.allowBlocked && isBlocked$1(task, get().tasks)) {
23930
24075
  set({ blockPending: {
@@ -23936,13 +24081,24 @@ function tasksSlice({ set, get, write }) {
23936
24081
  } });
23937
24082
  return;
23938
24083
  }
23939
- if (!opts?.allowOverCap) {
24084
+ if (!opts?.allowOverCap && !isSubtask(task, get().tasks)) {
23940
24085
  if (columnWip(get(), task.projectId, patch.status, 1)) return;
23941
24086
  }
23942
24087
  }
24088
+ const moving = task && patch.projectId && patch.projectId !== task.projectId;
24089
+ const carried = moving ? new Set(subtasksOf(task, get().tasks).map((t) => t.id)) : null;
24090
+ if (moving && task.parentTaskId && !("parentTaskId" in patch)) patch = {
24091
+ ...patch,
24092
+ parentTaskId: null
24093
+ };
23943
24094
  const project = get().projects.find((p) => p.id === task?.projectId);
23944
24095
  const newComments = [];
23945
24096
  const tasks = get().tasks.map((row) => {
24097
+ if (carried?.has(row.id)) return {
24098
+ ...row,
24099
+ projectId: patch.projectId,
24100
+ touchedAt: stamp()
24101
+ };
23946
24102
  if (row.id !== id) return row;
23947
24103
  const next = {
23948
24104
  ...row,
@@ -23992,7 +24148,7 @@ function tasksSlice({ set, get, write }) {
23992
24148
  } });
23993
24149
  return;
23994
24150
  }
23995
- if (!opts?.allowOverCap) {
24151
+ if (!opts?.allowOverCap && !isSubtask(task, get().tasks)) {
23996
24152
  if (columnWip(get(), task.projectId, status, 1)) return;
23997
24153
  }
23998
24154
  const nextRow = withStatus(task, status);
@@ -24412,6 +24568,7 @@ var init_tasks = __esmMin((() => {
24412
24568
  init_assignment();
24413
24569
  init_bulk();
24414
24570
  init_task_people();
24571
+ init_subtasks();
24415
24572
  }));
24416
24573
 
24417
24574
  // src/lib/raid-history.ts
@@ -30471,6 +30628,7 @@ init_clock();
30471
30628
  init_date_text();
30472
30629
  init_work();
30473
30630
  init_blockers();
30631
+ init_subtasks();
30474
30632
  init_recurrence();
30475
30633
  init_task_people();
30476
30634
  init_search();
@@ -30589,6 +30747,8 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30589
30747
  const thread = comments.filter((c) => c.taskId === task.id).slice(-4);
30590
30748
  const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
30591
30749
  const blockers = blockersOf(task, tasks);
30750
+ const subtaskOf = parentOf(task, tasks);
30751
+ const subs = subtasksOf(task, tasks);
30592
30752
  const done = () => setEditing(null);
30593
30753
  const nameOf = (id) => people.find((p) => p.id === id)?.name;
30594
30754
  const setCustom = (fieldId, value) => store.getState().setTaskCustomFieldValue(task.id, fieldId, value);
@@ -30941,6 +31101,40 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30941
31101
  ]
30942
31102
  }, item.id);
30943
31103
  }),
31104
+ subtaskOf ? /* @__PURE__ */ jsxs(Text, {
31105
+ color: theme.muted,
31106
+ wrap: "truncate-end",
31107
+ children: [
31108
+ " ",
31109
+ "Subtask of ",
31110
+ keyOf$1({ projects }, subtaskOf),
31111
+ " ",
31112
+ subtaskOf.title
31113
+ ]
31114
+ }) : null,
31115
+ subs.length > 0 ? /* @__PURE__ */ jsxs(Text, {
31116
+ color: theme.muted,
31117
+ children: [
31118
+ " ",
31119
+ "Subtasks ",
31120
+ subs.filter((x) => x.status === "done").length,
31121
+ "/",
31122
+ subs.length
31123
+ ]
31124
+ }) : null,
31125
+ subs.map((x) => /* @__PURE__ */ jsxs(Text, {
31126
+ wrap: "truncate-end",
31127
+ children: [
31128
+ " ",
31129
+ x.status === "done" ? "[x] " : "[ ] ",
31130
+ keyOf$1({ projects }, x),
31131
+ " ",
31132
+ x.status === "done" ? /* @__PURE__ */ jsx(Text, {
31133
+ strikethrough: true,
31134
+ children: x.title
31135
+ }) : x.title
31136
+ ]
31137
+ }, x.id)),
30944
31138
  (task.github ?? []).slice(0, 3).map((ref) => /* @__PURE__ */ jsxs(Text, {
30945
31139
  color: theme.muted,
30946
31140
  wrap: "truncate-end",
package/lib/cli.mjs CHANGED
@@ -168,7 +168,7 @@ export const HELP = `harbor: Harbor from the terminal
168
168
  [--also "NAME, NAME"] [--due DATE|pilot]
169
169
  [--priority low|medium|high|urgent]
170
170
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
171
- [--literal] [--template NAME]
171
+ [--literal] [--template NAME] [--parent TASK]
172
172
  reads plain words in TITLE as the
173
173
  website does: @name, by friday,
174
174
  !high, #project (--literal: keep
@@ -183,7 +183,8 @@ export const HELP = `harbor: Harbor from the terminal
183
183
  [--also "NAME, NAME"|none] [--due DATE|pilot|none]
184
184
  [--priority P] [--status S] [--notes TEXT]
185
185
  [--repeat weekly|biweekly|monthly|never]
186
- [--waits-on TASK,TASK|none] [--confirm] [--over-limit]
186
+ [--waits-on TASK,TASK|none] [--parent TASK|none]
187
+ [--confirm] [--over-limit]
187
188
  harbor limit PROJECT [COLUMN N|none] how many tasks a board column
188
189
  takes at once; the owner sets it
189
190
  harbor week [--next|--last|--week DATE] [--review]
@@ -704,7 +705,7 @@ export function hint(message, project) {
704
705
  )
705
706
  .replace(
706
707
  /^Nothing to change: pass a field to update\.$/,
707
- "Nothing to change. Say what with --title, --assignee, --also, --due, --priority, --status, --notes, --repeat or --waits-on.",
708
+ "Nothing to change. Say what with --title, --assignee, --also, --due, --priority, --status, --notes, --repeat, --waits-on or --parent.",
708
709
  )
709
710
  .replace(
710
711
  /^This connection was allowed to read only\..*$/s,
@@ -785,6 +786,10 @@ const DETAIL = {
785
786
  " notes, priority, owner, others on it, checklist and due offset fill what",
786
787
  " the flags don't. Leave out TITLE to take the template's.",
787
788
  "",
789
+ " --parent TASK adds it inside that task as a subtask: a whole task with its",
790
+ " own owner and date, shown in the parent's details rather than on the",
791
+ " board. It goes in the parent's project; subtasks go one level deep.",
792
+ "",
788
793
  " --due pilot takes the earliest day the owner's queue suggests: their open",
789
794
  " work due first, their weekly pace, time away, what it waits on and how",
790
795
  " long their similar tasks took. It needs an owner (--assignee none is",
@@ -2288,6 +2293,7 @@ export async function run(argv, io = {}) {
2288
2293
  ),
2289
2294
  ];
2290
2295
  if (t.alsoOn?.length) lines.push(`Also on it: ${t.alsoOn.join(", ")}.`);
2296
+ if (t.subtaskOf) lines.push(`Subtask of ${t.subtaskOf.key ?? t.subtaskOf.id} ${t.subtaskOf.title}.`);
2291
2297
  if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
2292
2298
  for (const [name, v] of Object.entries(t.fields ?? {}))
2293
2299
  lines.push(`${name}: ${Array.isArray(v) ? v.join(", ") : v === true ? "yes" : v}`);
@@ -2296,6 +2302,15 @@ export async function run(argv, io = {}) {
2296
2302
  if (t.description) lines.push("", t.description);
2297
2303
  if (t.checklist.length)
2298
2304
  lines.push("", ...t.checklist.map((c) => ` [${c.done ? "x" : " "}] ${c.step}`));
2305
+ if (t.subtasks?.length)
2306
+ lines.push(
2307
+ "",
2308
+ `Subtasks (${t.subtasks.filter((x) => x.status === "done").length}/${t.subtasks.length} done):`,
2309
+ ...t.subtasks.map(
2310
+ (x) =>
2311
+ ` [${x.status === "done" ? "x" : " "}] ${x.key ?? x.id} ${x.title}${x.assignee ? `, ${x.assignee}` : ""}${x.due ? `, due ${x.due}` : ""}`,
2312
+ ),
2313
+ );
2299
2314
  if (t.waitingOn.length)
2300
2315
  lines.push(
2301
2316
  "",
@@ -2531,9 +2546,12 @@ export async function run(argv, io = {}) {
2531
2546
  const { positionals, values, harbor } = parse(
2532
2547
  spec(
2533
2548
  ["over-limit", "dry-run", "literal"],
2534
- ["project", "assignee", "also", "due", "priority", "status", "comment", "template"],
2549
+ ["project", "assignee", "also", "due", "priority", "status", "comment", "template", "parent"],
2535
2550
  ),
2536
2551
  );
2552
+ // A subtask goes in its parent's project.
2553
+ if (values.parent && !values.project)
2554
+ values.project = (await client.call("get_task", { harbor, task: values.parent })).projectId;
2537
2555
  // --template with no title: the template's own title.
2538
2556
  const fromTemplate = values.template !== undefined && positionals.length === 0;
2539
2557
  // Several at once: one title a line on stdin, with no title (or -) after add.
@@ -2636,6 +2654,7 @@ export async function run(argv, io = {}) {
2636
2654
  suggestedDue: duePilot(values.due),
2637
2655
  priority: values.priority ?? words.priority?.value,
2638
2656
  status: values.status === undefined ? undefined : statusWord(values.status),
2657
+ parent: values.parent,
2639
2658
  overLimit: values["over-limit"] || undefined,
2640
2659
  })
2641
2660
  .catch((err) => {
@@ -2761,6 +2780,7 @@ export async function run(argv, io = {}) {
2761
2780
  "notes",
2762
2781
  "repeat",
2763
2782
  "waits-on",
2783
+ "parent",
2764
2784
  ],
2765
2785
  ),
2766
2786
  );
@@ -2791,12 +2811,18 @@ export async function run(argv, io = {}) {
2791
2811
  .split(",")
2792
2812
  .map((x) => x.trim())
2793
2813
  .filter(Boolean),
2814
+ parent: values.parent,
2794
2815
  confirmed: values.confirm || undefined,
2795
2816
  overLimit: values["over-limit"] || undefined,
2796
2817
  },
2797
2818
  (r) =>
2798
2819
  [
2799
2820
  `Updated: ${taskLine(r.updated, opts)}`,
2821
+ values.parent !== undefined
2822
+ ? r.updated.subtaskOf
2823
+ ? `A subtask of ${r.updated.subtaskOf}.`
2824
+ : "A task of its own now."
2825
+ : null,
2800
2826
  values.also !== undefined
2801
2827
  ? r.updated.alsoOn?.length
2802
2828
  ? `Also on it: ${r.updated.alsoOn.join(", ")}.`
package/lib/format.mjs CHANGED
@@ -769,6 +769,12 @@ export function importText(r) {
769
769
  if (r.alreadyThere) lines.push(`${n(r.alreadyThere, "task")} already there, left alone.`);
770
770
  if (r.leftOut) lines.push(`${n(r.leftOut, "row")} left out (canceled, won't do or duplicate).`);
771
771
  if (r.waitsOnLinks) lines.push(`${n(r.waitsOnLinks, "link")} between tasks became "waits on".`);
772
+ if (r.subtasks)
773
+ lines.push(
774
+ r.subtasks === 1
775
+ ? "1 row came in as a subtask of its task."
776
+ : `${r.subtasks} rows came in as subtasks of their tasks.`,
777
+ );
772
778
  for (const p of r.problems ?? [])
773
779
  lines.push(`${p.card ? `Card “${p.card}”` : `Row ${p.row}`}: ${p.message}`);
774
780
  return lines.join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.86.0",
3
+ "version": "0.88.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",