@harborppm/cli 0.85.0 → 0.87.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 (3) hide show
  1. package/dist/app.mjs +261 -62
  2. package/lib/cli.mjs +31 -5
  3. package/package.json +1 -1
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 = [
@@ -5266,6 +5329,49 @@ function cascadeSummary(counts, kind) {
5266
5329
  if (list.length === 1) return list[0];
5267
5330
  return `${list.slice(0, -1).join(", ")} and ${list.at(-1)}`;
5268
5331
  }
5332
+ /** `data` with the cut links put back, wherever both ends exist again. */
5333
+ function relink(data, links) {
5334
+ const out = {};
5335
+ const exists = (key) => new Set(data[key].map((r) => r.id));
5336
+ const targetOf = {
5337
+ programs: "programs",
5338
+ projects: "projects",
5339
+ tasks: "tasks",
5340
+ milestones: "projects"
5341
+ };
5342
+ for (const key of [
5343
+ "programs",
5344
+ "projects",
5345
+ "tasks",
5346
+ "milestones"
5347
+ ]) {
5348
+ const cut = links.blockers[key];
5349
+ if (!cut) continue;
5350
+ const there = exists(targetOf[key]);
5351
+ out[key] = data[key].map((row) => {
5352
+ const was = cut[row.id];
5353
+ if (!was) return row;
5354
+ const ids = [.../* @__PURE__ */ new Set([...was, ...blockerIds(row)])].filter((id) => there.has(id));
5355
+ return ids.length === blockerIds(row).length ? row : {
5356
+ ...row,
5357
+ ...blockersPatch(ids)
5358
+ };
5359
+ });
5360
+ }
5361
+ if (Object.keys(links.focus).length) {
5362
+ const tasks = exists("tasks");
5363
+ out.ropes = (data.ropes ?? []).map((r) => {
5364
+ const lost = links.focus[`${r.personId}|${r.date}`];
5365
+ if (!lost) return r;
5366
+ const back = lost.filter((id) => tasks.has(id) && !r.taskIds.includes(id));
5367
+ return back.length ? {
5368
+ ...r,
5369
+ taskIds: [...r.taskIds, ...back]
5370
+ } : r;
5371
+ });
5372
+ }
5373
+ return out;
5374
+ }
5269
5375
  var init_cascade = __esmMin((() => {
5270
5376
  init_blockers();
5271
5377
  }));
@@ -5731,15 +5837,19 @@ function roadmapDoneCount(data, portfolioId) {
5731
5837
  }
5732
5838
 
5733
5839
  // src/lib/search.ts
5840
+ /** Lower case without accents, so "cafe" finds "Café" and "Zoë" finds "Zoe". */
5841
+ function fold(text) {
5842
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
5843
+ }
5734
5844
  function tokensOf(query) {
5735
- return query.toLowerCase().split(/\s+/).filter(Boolean);
5845
+ return fold(query).split(/\s+/).filter(Boolean);
5736
5846
  }
5737
5847
  /** Score a thing by its own name first, then by everything else it carries. */
5738
5848
  function scoreMatch(query, name, ...secondary) {
5739
- const q = query.trim().toLowerCase();
5849
+ const q = fold(query.trim());
5740
5850
  if (!q) return SCORE.anywhere;
5741
5851
  const tokens = tokensOf(q);
5742
- const n = name.toLowerCase();
5852
+ const n = fold(name);
5743
5853
  if (n === q) return SCORE.exact;
5744
5854
  if (n.startsWith(q)) return SCORE.prefix;
5745
5855
  const words = n.split(/\s+/);
@@ -5747,7 +5857,7 @@ function scoreMatch(query, name, ...secondary) {
5747
5857
  const bonus = tokens.every((t) => words.some((w) => w.startsWith(t))) ? 5 : 0;
5748
5858
  return SCORE.name + bonus;
5749
5859
  }
5750
- const blob = [n, ...secondary.filter((s) => Boolean(s)).map((s) => s.toLowerCase())].join(" ");
5860
+ const blob = [n, ...secondary.filter((s) => Boolean(s)).map(fold)].join(" ");
5751
5861
  return tokens.every((t) => blob.includes(t)) ? SCORE.anywhere : 0;
5752
5862
  }
5753
5863
  /** Keep what matches, best first. Ties keep the caller's order. */
@@ -5932,6 +6042,7 @@ init_date_fns();
5932
6042
  init_date_text();
5933
6043
  init_first_voyage();
5934
6044
  init_indexes();
6045
+ init_subtasks();
5935
6046
  init_tide();
5936
6047
  init_format();
5937
6048
  init_raid();
@@ -6002,7 +6113,7 @@ function myRaid(data, personId) {
6002
6113
  /** A project's board: its visible columns, in order, with their tasks. */
6003
6114
  function boardOf(data, projectId) {
6004
6115
  const config = boardConfigOf(data, projectId);
6005
- const tasks = tasksOf(data.tasks, projectId);
6116
+ const tasks = topLevel(tasksOf(data.tasks, projectId), data.tasks);
6006
6117
  return visibleColumns(config).map((col) => ({
6007
6118
  status: col.status,
6008
6119
  label: columnLabel(col),
@@ -6018,7 +6129,7 @@ function neighbourStatus(data, projectId, status, step) {
6018
6129
  return cols[at + step] ?? null;
6019
6130
  }
6020
6131
  function projectTasks(data, projectId) {
6021
- return [...tasksOf(data.tasks, projectId)].sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || (a.number ?? 0) - (b.number ?? 0));
6132
+ 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));
6022
6133
  }
6023
6134
  /**
6024
6135
  * The project a new task goes in when none was picked: the only open one,
@@ -19775,11 +19886,7 @@ function fieldValueText(def, value, nameOf) {
19775
19886
  }
19776
19887
 
19777
19888
  // src/lib/exports.ts
19778
- /**
19779
- * The spreadsheets Harbor hands out: a project's tasks, the risk register and
19780
- * the review. The website downloads them, agents and the terminal get the
19781
- * same text (the export_csv tool, `harbor export`).
19782
- */
19889
+ init_subtasks();
19783
19890
  init_derived_health();
19784
19891
  init_priority();
19785
19892
  init_raid();
@@ -19814,6 +19921,7 @@ function tasksCsv(data, projectId) {
19814
19921
  "Done",
19815
19922
  "Blocked by",
19816
19923
  "Description",
19924
+ "Subtask of",
19817
19925
  ...fields.map((f) => f.name)
19818
19926
  ], tasks.map((t) => [
19819
19927
  taskKey(t, project) ?? "",
@@ -19825,6 +19933,7 @@ function tasksCsv(data, projectId) {
19825
19933
  t.status === "done" ? "yes" : "no",
19826
19934
  blockerIds(t).map((id) => here.get(id)).filter((b) => Boolean(b)).map(name).join("; "),
19827
19935
  t.description,
19936
+ ((p) => p ? name(p) : "")(parentOf(t, data.tasks)),
19828
19937
  ...fields.map((f) => f.type === "url" ? safeLink(t.customFieldValues?.[f.id]) ?? "" : fieldValueText(f, t.customFieldValues?.[f.id], nameOf))
19829
19938
  ]));
19830
19939
  }
@@ -20435,16 +20544,7 @@ function approvalsWaitingOn(tasks, personId) {
20435
20544
  }
20436
20545
 
20437
20546
  // src/lib/mcp/tools.ts
20438
- /**
20439
- * What an agent can read about a harbor, as MCP tools. Each tool is a pure
20440
- * function of one harbor's document (health already stamped) and who is
20441
- * asking, so it is tested against the demo without a server. The route in
20442
- * src/routes/api/mcp.ts signs the caller in, picks the harbor and loads it.
20443
- *
20444
- * Read only: nothing here changes a workspace. Answers carry ids so an agent
20445
- * can ask about one thing next, and every list is capped and pageable, since
20446
- * a harbor can hold thousands of tasks.
20447
- */
20547
+ init_subtasks();
20448
20548
  init_thanks();
20449
20549
  init_task_keys();
20450
20550
  init_brief();
@@ -21202,12 +21302,13 @@ const HARBOR_TOOLS = [
21202
21302
  if (input.query) {
21203
21303
  const q = input.query;
21204
21304
  const named = findByKey(data, q);
21305
+ const names = new Map(data.projects.map((p) => [p.id, p.name]));
21205
21306
  list = list.map((t) => ({
21206
21307
  t,
21207
- s: scoreMatch(q, t.title, t.description)
21308
+ s: scoreMatch(q, t.title, t.description, names.get(t.projectId))
21208
21309
  })).filter((h) => h.s > 0 && h.t !== named).sort((a, b) => b.s - a.s).map((h) => h.t);
21209
21310
  if (named) list.unshift(named);
21210
- if (!input.status?.length) doneMatches = data.tasks.filter((t) => t.status === "done" && t !== named && fits(t) && scoreMatch(q, t.title, t.description) > 0).length;
21311
+ if (!input.status?.length) doneMatches = data.tasks.filter((t) => t.status === "done" && t !== named && fits(t) && scoreMatch(q, t.title, t.description, names.get(t.projectId)) > 0).length;
21211
21312
  } else list = [...list].sort((a, b) => a.status === "done" && b.status === "done" ? (b.completedAt ?? "").localeCompare(a.completedAt ?? "") : Number(a.status === "done") - Number(b.status === "done") || (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999"));
21212
21313
  return {
21213
21314
  ...doneMatches ? { doneMatches } : {},
@@ -21219,7 +21320,7 @@ const HARBOR_TOOLS = [
21219
21320
  tool({
21220
21321
  name: "get_task",
21221
21322
  title: "Task details",
21222
- 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,
21323
+ 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,
21223
21324
  input: object({
21224
21325
  harbor: harborArg,
21225
21326
  task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
@@ -21271,6 +21372,12 @@ const HARBOR_TOOLS = [
21271
21372
  ...t.time?.length ? { timeLogged: timeOut(t, people) } : {},
21272
21373
  waitingOn: blockersOf(t, data.tasks).map(brief),
21273
21374
  waitedOnBy: waiters.map(brief),
21375
+ ...((parent) => parent ? { subtaskOf: brief(parent) } : {})(parentOf(t, data.tasks)),
21376
+ ...((subs) => subs.length ? { subtasks: subs.map((x) => ({
21377
+ ...brief(x),
21378
+ assignee: nameOf$2(people, x.assigneeId),
21379
+ due: dayOf(x.dueDate)
21380
+ })) } : {})(subtasksOf(t, data.tasks)),
21274
21381
  ...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
21275
21382
  ...t.approval ? { approval: approvalOut(data, t.approval) } : {},
21276
21383
  ...((v) => v ? { fields: v } : {})(fieldValuesOut(data, project, t)),
@@ -22878,7 +22985,10 @@ function withStatus(task, status) {
22878
22985
  touchedAt: stamp()
22879
22986
  };
22880
22987
  if (status === "done") next.completedAt = stamp();
22881
- else if (task.status === "done") next.completedAt = null;
22988
+ else if (task.status === "done") {
22989
+ next.completedAt = null;
22990
+ if (task.approval?.status === "approved") next.approval = null;
22991
+ }
22882
22992
  return next;
22883
22993
  }
22884
22994
  var init_shared = __esmMin((() => {}));
@@ -23051,6 +23161,26 @@ var init_session = __esmMin((() => {
23051
23161
  init_format();
23052
23162
  }));
23053
23163
 
23164
+ // src/lib/assignment.ts
23165
+ /**
23166
+ * Record who set a task's assignee and when. Only a change of assignee
23167
+ * stamps; clearing the assignee clears the stamp.
23168
+ */
23169
+ function stampAssignment(before, next, by, at) {
23170
+ if (before && before.assigneeId === next.assigneeId) return next;
23171
+ if (!next.assigneeId) return {
23172
+ ...next,
23173
+ assignedBy: null,
23174
+ assignedAt: null
23175
+ };
23176
+ return {
23177
+ ...next,
23178
+ assignedBy: by,
23179
+ assignedAt: at
23180
+ };
23181
+ }
23182
+ var init_assignment = __esmMin((() => {}));
23183
+
23054
23184
  // src/lib/first-week.ts
23055
23185
  /** The program launch named for you, when nobody has renamed it yet. */
23056
23186
  function starterProgram(data) {
@@ -23377,13 +23507,13 @@ function hierarchySlice({ get, write }) {
23377
23507
  const program = get().programs.find((p) => p.id === programId);
23378
23508
  get().log(`moved ${ids.length} project${ids.length === 1 ? "" : "s"} to ${program?.name ?? "a program"}`, `/programs/${programId}`);
23379
23509
  },
23380
- restoreRows: (rows) => {
23510
+ restoreRows: (rows, links) => {
23381
23511
  const state = get();
23382
23512
  const back = (current, add) => {
23383
23513
  const have = new Set((current ?? []).map((r) => r.id));
23384
23514
  return [...current ?? [], ...(add ?? []).filter((r) => !have.has(r.id))];
23385
23515
  };
23386
- write({
23516
+ const next = {
23387
23517
  portfolios: back(state.portfolios, rows.portfolios),
23388
23518
  programs: back(state.programs, rows.programs),
23389
23519
  projects: back(state.projects, rows.projects),
@@ -23399,7 +23529,14 @@ function hierarchySlice({ get, write }) {
23399
23529
  ...rows.boardConfigs ?? {},
23400
23530
  ...state.boardConfigs ?? {}
23401
23531
  }
23402
- });
23532
+ };
23533
+ write(links ? {
23534
+ ...next,
23535
+ ...relink({
23536
+ ...state,
23537
+ ...next
23538
+ }, links)
23539
+ } : next);
23403
23540
  const name = rows.portfolios?.[0]?.name ?? rows.programs?.[0]?.name ?? rows.projects?.[0]?.name;
23404
23541
  if (name) get().log(`put back “${name}”`);
23405
23542
  },
@@ -23410,10 +23547,13 @@ function hierarchySlice({ get, write }) {
23410
23547
  const ids = have(current);
23411
23548
  return [...current, ...(more ?? []).filter((r) => !ids.has(r.id))];
23412
23549
  };
23550
+ const small = (rows.tasks?.length ?? 0) <= 20;
23551
+ const at = stamp();
23552
+ const incoming = small ? rows.tasks?.map((t) => stampAssignment(void 0, t, state.currentUserId, at)) : rows.tasks;
23413
23553
  write({
23414
23554
  programs: add(state.programs, rows.programs),
23415
23555
  projects: add(state.projects, rows.projects),
23416
- tasks: add(state.tasks, rows.tasks),
23556
+ tasks: add(state.tasks, incoming),
23417
23557
  people: add(state.people, rows.people),
23418
23558
  raidItems: add(state.raidItems, rows.raidItems)
23419
23559
  });
@@ -23484,6 +23624,7 @@ function hierarchySlice({ get, write }) {
23484
23624
  }
23485
23625
  };
23486
23626
  }
23627
+ var SMALL_IMPORT;
23487
23628
  var init_hierarchy = __esmMin((() => {
23488
23629
  init_board();
23489
23630
  init_cascade();
@@ -23493,11 +23634,13 @@ var init_hierarchy = __esmMin((() => {
23493
23634
  init_health_history();
23494
23635
  init_derived_health();
23495
23636
  init_shared();
23637
+ init_assignment();
23496
23638
  init_hill();
23497
23639
  init_charter_lines();
23498
23640
  init_format();
23499
23641
  init_empty();
23500
23642
  init_first_week();
23643
+ SMALL_IMPORT = 20;
23501
23644
  }));
23502
23645
 
23503
23646
  // src/lib/github-refs.ts
@@ -23546,6 +23689,16 @@ function runActions(actions, start, ctx) {
23546
23689
  for (const action of actions) switch (action.type) {
23547
23690
  case "set_status": {
23548
23691
  if (!action.status) break;
23692
+ if (action.status === "done" && task.status !== "done" && ctx.waiting?.(task)) {
23693
+ comments.push({
23694
+ taskId: task.id,
23695
+ authorId: ctx.authorId,
23696
+ body: "Left open: this still waits on unfinished work, so the rule didn’t finish it.",
23697
+ createdAt: ctx.now,
23698
+ mentionedIds: []
23699
+ });
23700
+ break;
23701
+ }
23549
23702
  const wasDone = task.status === "done";
23550
23703
  const completedAt = action.status === "done" ? task.completedAt ?? ctx.now : wasDone ? null : task.completedAt;
23551
23704
  task = {
@@ -23611,7 +23764,8 @@ function applyAutomations(project, prev, next, opts) {
23611
23764
  const ctx = {
23612
23765
  authorId: opts.currentUserId,
23613
23766
  now,
23614
- today
23767
+ today,
23768
+ waiting: opts.waiting
23615
23769
  };
23616
23770
  let task = next;
23617
23771
  const comments = [];
@@ -23717,26 +23871,6 @@ var init_recurrence = __esmMin((() => {
23717
23871
  step = (d, repeat, n) => repeat === "weekly" ? addDays(d, 7 * n) : repeat === "biweekly" ? addDays(d, 14 * n) : addMonths$1(d, n);
23718
23872
  }));
23719
23873
 
23720
- // src/lib/assignment.ts
23721
- /**
23722
- * Record who set a task's assignee and when. Only a change of assignee
23723
- * stamps; clearing the assignee clears the stamp.
23724
- */
23725
- function stampAssignment(before, next, by, at) {
23726
- if (before && before.assigneeId === next.assigneeId) return next;
23727
- if (!next.assigneeId) return {
23728
- ...next,
23729
- assignedBy: null,
23730
- assignedAt: null
23731
- };
23732
- return {
23733
- ...next,
23734
- assignedBy: by,
23735
- assignedAt: at
23736
- };
23737
- }
23738
- var init_assignment = __esmMin((() => {}));
23739
-
23740
23874
  // src/lib/bulk.ts
23741
23875
  /** A task's due date moved by `days`, or null when it has none. */
23742
23876
  function shiftedDue(due, days) {
@@ -23813,7 +23947,9 @@ function tasksSlice({ set, get, write }) {
23813
23947
  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");
23814
23948
  },
23815
23949
  addTask: (t, opts) => {
23816
- if (!opts?.allowOverCap) {
23950
+ const wanted = t.parentTaskId ? byId(get().tasks).get(t.parentTaskId) : void 0;
23951
+ const parentTaskId = wanted && wanted.projectId === t.projectId && !subtaskProblem("", wanted.id, get().tasks) ? wanted.id : null;
23952
+ if (!opts?.allowOverCap && !parentTaskId) {
23817
23953
  if (columnWip(get(), t.projectId, t.status, 1)) return "";
23818
23954
  }
23819
23955
  const id = uid("t");
@@ -23824,6 +23960,7 @@ function tasksSlice({ set, get, write }) {
23824
23960
  createdById: get().currentUserId || null,
23825
23961
  completedAt: t.status === "done" ? t.completedAt ?? stamp() : null,
23826
23962
  checklist: t.checklist ?? [],
23963
+ ..."parentTaskId" in t ? { parentTaskId } : {},
23827
23964
  ...blockersPatch(blockerIds({
23828
23965
  id,
23829
23966
  blockedById: t.blockedById,
@@ -23833,7 +23970,8 @@ function tasksSlice({ set, get, write }) {
23833
23970
  const project = get().projects.find((p) => p.id === t.projectId);
23834
23971
  const { task: automated, comments: autoComments } = applyAutomations(project, void 0, row, {
23835
23972
  created: true,
23836
- currentUserId: get().currentUserId
23973
+ currentUserId: get().currentUserId,
23974
+ waiting: (t) => isBlocked$1(t, get().tasks)
23837
23975
  });
23838
23976
  const finalRow = stampAssignment(void 0, automated, get().currentUserId, stamp());
23839
23977
  write({
@@ -23849,6 +23987,10 @@ function tasksSlice({ set, get, write }) {
23849
23987
  },
23850
23988
  updateTask: (id, patch, opts) => {
23851
23989
  const task = get().tasks.find((row) => row.id === id);
23990
+ if (task && patch.parentTaskId && subtaskProblem(id, patch.parentTaskId, get().tasks)) {
23991
+ patch = { ...patch };
23992
+ delete patch.parentTaskId;
23993
+ }
23852
23994
  if (task && patch.status && patch.status !== task.status) {
23853
23995
  if (patch.status === "done" && !opts?.allowBlocked && isBlocked$1(task, get().tasks)) {
23854
23996
  set({ blockPending: {
@@ -23860,13 +24002,24 @@ function tasksSlice({ set, get, write }) {
23860
24002
  } });
23861
24003
  return;
23862
24004
  }
23863
- if (!opts?.allowOverCap) {
24005
+ if (!opts?.allowOverCap && !isSubtask(task, get().tasks)) {
23864
24006
  if (columnWip(get(), task.projectId, patch.status, 1)) return;
23865
24007
  }
23866
24008
  }
24009
+ const moving = task && patch.projectId && patch.projectId !== task.projectId;
24010
+ const carried = moving ? new Set(subtasksOf(task, get().tasks).map((t) => t.id)) : null;
24011
+ if (moving && task.parentTaskId && !("parentTaskId" in patch)) patch = {
24012
+ ...patch,
24013
+ parentTaskId: null
24014
+ };
23867
24015
  const project = get().projects.find((p) => p.id === task?.projectId);
23868
24016
  const newComments = [];
23869
24017
  const tasks = get().tasks.map((row) => {
24018
+ if (carried?.has(row.id)) return {
24019
+ ...row,
24020
+ projectId: patch.projectId,
24021
+ touchedAt: stamp()
24022
+ };
23870
24023
  if (row.id !== id) return row;
23871
24024
  const next = {
23872
24025
  ...row,
@@ -23876,12 +24029,16 @@ function tasksSlice({ set, get, write }) {
23876
24029
  if (next.assigneeId && next.followerIds?.includes(next.assigneeId)) next.followerIds = alsoOnIds(next);
23877
24030
  if (patch.status && patch.status !== row.status) {
23878
24031
  if (patch.status === "done" && !patch.completedAt) next.completedAt = stamp();
23879
- else if (row.status === "done" && patch.status !== "done") next.completedAt = null;
24032
+ else if (row.status === "done" && patch.status !== "done") {
24033
+ next.completedAt = null;
24034
+ if (row.approval?.status === "approved" && !("approval" in patch)) next.approval = null;
24035
+ }
23880
24036
  if (patch.status === "done" && isBlocked$1(row, get().tasks)) Object.assign(next, blockersPatch([]));
23881
24037
  }
23882
24038
  const { task: finalTask, comments: autoComments } = applyAutomations(project, row, next, {
23883
24039
  created: false,
23884
- currentUserId: get().currentUserId
24040
+ currentUserId: get().currentUserId,
24041
+ waiting: (t) => isBlocked$1(t, get().tasks)
23885
24042
  });
23886
24043
  newComments.push(...autoComments.map((c) => ({
23887
24044
  ...c,
@@ -23912,7 +24069,7 @@ function tasksSlice({ set, get, write }) {
23912
24069
  } });
23913
24070
  return;
23914
24071
  }
23915
- if (!opts?.allowOverCap) {
24072
+ if (!opts?.allowOverCap && !isSubtask(task, get().tasks)) {
23916
24073
  if (columnWip(get(), task.projectId, status, 1)) return;
23917
24074
  }
23918
24075
  const nextRow = withStatus(task, status);
@@ -23920,7 +24077,8 @@ function tasksSlice({ set, get, write }) {
23920
24077
  const project = get().projects.find((p) => p.id === task.projectId);
23921
24078
  const { task: finalRow, comments: autoComments } = applyAutomations(project, task, nextRow, {
23922
24079
  created: false,
23923
- currentUserId: get().currentUserId
24080
+ currentUserId: get().currentUserId,
24081
+ waiting: (t) => isBlocked$1(t, get().tasks)
23924
24082
  });
23925
24083
  const follow = status === "done" ? nextOccurrence(finalRow, uid("t"), todayISO(), stamp(), tasksOf(get().tasks, task.projectId)) : null;
23926
24084
  const moved = get().tasks.map((row) => row.id === id ? finalRow : row);
@@ -24002,7 +24160,8 @@ function tasksSlice({ set, get, write }) {
24002
24160
  },
24003
24161
  updateComment: (id, body) => {
24004
24162
  const comment = get().comments.find((c) => c.id === id);
24005
- if (!comment || comment.authorId !== get().currentUserId) return;
24163
+ if (!comment) return;
24164
+ if (comment.authorId !== get().currentUserId && get().myRole !== "owner") return;
24006
24165
  const trimmed = body.trim();
24007
24166
  if (!trimmed) return;
24008
24167
  const mentionedIds = parseMentions(trimmed, get().people).filter((pid) => pid !== get().currentUserId);
@@ -24015,7 +24174,8 @@ function tasksSlice({ set, get, write }) {
24015
24174
  },
24016
24175
  deleteComment: (id) => {
24017
24176
  const comment = get().comments.find((c) => c.id === id);
24018
- if (!comment || comment.authorId !== get().currentUserId) return;
24177
+ if (!comment) return;
24178
+ if (comment.authorId !== get().currentUserId && get().myRole !== "owner") return;
24019
24179
  set({ comments: get().comments.filter((c) => c.id !== id) });
24020
24180
  },
24021
24181
  addChecklistItem: (taskId, title) => {
@@ -24196,7 +24356,8 @@ function tasksSlice({ set, get, write }) {
24196
24356
  }
24197
24357
  const { task: automated, comments } = applyAutomations(projects.get(row.projectId), row, next, {
24198
24358
  created: false,
24199
- currentUserId: me
24359
+ currentUserId: me,
24360
+ waiting: (t) => isBlocked$1(t, s.tasks)
24200
24361
  });
24201
24362
  newComments.push(...comments.map((c) => ({
24202
24363
  ...c,
@@ -24328,6 +24489,7 @@ var init_tasks = __esmMin((() => {
24328
24489
  init_assignment();
24329
24490
  init_bulk();
24330
24491
  init_task_people();
24492
+ init_subtasks();
24331
24493
  }));
24332
24494
 
24333
24495
  // src/lib/raid-history.ts
@@ -30387,6 +30549,7 @@ init_clock();
30387
30549
  init_date_text();
30388
30550
  init_work();
30389
30551
  init_blockers();
30552
+ init_subtasks();
30390
30553
  init_recurrence();
30391
30554
  init_task_people();
30392
30555
  init_search();
@@ -30505,6 +30668,8 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30505
30668
  const thread = comments.filter((c) => c.taskId === task.id).slice(-4);
30506
30669
  const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
30507
30670
  const blockers = blockersOf(task, tasks);
30671
+ const subtaskOf = parentOf(task, tasks);
30672
+ const subs = subtasksOf(task, tasks);
30508
30673
  const done = () => setEditing(null);
30509
30674
  const nameOf = (id) => people.find((p) => p.id === id)?.name;
30510
30675
  const setCustom = (fieldId, value) => store.getState().setTaskCustomFieldValue(task.id, fieldId, value);
@@ -30857,6 +31022,40 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30857
31022
  ]
30858
31023
  }, item.id);
30859
31024
  }),
31025
+ subtaskOf ? /* @__PURE__ */ jsxs(Text, {
31026
+ color: theme.muted,
31027
+ wrap: "truncate-end",
31028
+ children: [
31029
+ " ",
31030
+ "Subtask of ",
31031
+ keyOf$1({ projects }, subtaskOf),
31032
+ " ",
31033
+ subtaskOf.title
31034
+ ]
31035
+ }) : null,
31036
+ subs.length > 0 ? /* @__PURE__ */ jsxs(Text, {
31037
+ color: theme.muted,
31038
+ children: [
31039
+ " ",
31040
+ "Subtasks ",
31041
+ subs.filter((x) => x.status === "done").length,
31042
+ "/",
31043
+ subs.length
31044
+ ]
31045
+ }) : null,
31046
+ subs.map((x) => /* @__PURE__ */ jsxs(Text, {
31047
+ wrap: "truncate-end",
31048
+ children: [
31049
+ " ",
31050
+ x.status === "done" ? "[x] " : "[ ] ",
31051
+ keyOf$1({ projects }, x),
31052
+ " ",
31053
+ x.status === "done" ? /* @__PURE__ */ jsx(Text, {
31054
+ strikethrough: true,
31055
+ children: x.title
31056
+ }) : x.title
31057
+ ]
31058
+ }, x.id)),
30860
31059
  (task.github ?? []).slice(0, 3).map((ref) => /* @__PURE__ */ jsxs(Text, {
30861
31060
  color: theme.muted,
30862
31061
  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) => {
@@ -2737,7 +2756,7 @@ export async function run(argv, io = {}) {
2737
2756
  },
2738
2757
  (r) =>
2739
2758
  [
2740
- `${r.updated.title}: ${{ doing: "started", review: "in review", done: "done" }[status]}.`,
2759
+ `${r.updated.title}: ${r.reopened ? "reopened, " : ""}${{ doing: "started", review: "in review", done: "done" }[status]}.`,
2741
2760
  r.queuedNext ? `Next one: ${taskLine(r.queuedNext, opts)}` : "",
2742
2761
  r.projectFinished
2743
2762
  ? `That was the last open task in ${r.projectFinished}. Mark the project Done in the app (harbor) or on the website.`
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.85.0",
3
+ "version": "0.87.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",