@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 +219 -25
- package/lib/cli.mjs +30 -4
- package/lib/format.mjs +6 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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] [--
|
|
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
|
|
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");
|