@harborppm/cli 0.53.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/dist/app.mjs +25161 -23240
- package/lib/cli.mjs +286 -18
- package/lib/format.mjs +123 -1
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
notesText,
|
|
36
36
|
daysText,
|
|
37
37
|
soundingText,
|
|
38
|
+
lifeboatsText,
|
|
39
|
+
followedText,
|
|
38
40
|
soundingsText,
|
|
39
41
|
voyageText,
|
|
40
42
|
whyText,
|
|
@@ -49,6 +51,7 @@ import {
|
|
|
49
51
|
taskLine,
|
|
50
52
|
taskList,
|
|
51
53
|
todayLocal,
|
|
54
|
+
weekText,
|
|
52
55
|
} from "./format.mjs";
|
|
53
56
|
import { colourDepth, logoLines } from "./logo.mjs";
|
|
54
57
|
import { HarborError, createClient } from "./mcp.mjs";
|
|
@@ -112,6 +115,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
112
115
|
[--off]
|
|
113
116
|
a project's public roadmap: read,
|
|
114
117
|
publish or take it down
|
|
118
|
+
harbor shore PROGRAM [--url URL] [--on KINDS] [--off KINDS] [--test]
|
|
119
|
+
[--remove]
|
|
120
|
+
a program's chat channel (Slack,
|
|
121
|
+
Discord, Teams) and its posts
|
|
115
122
|
|
|
116
123
|
harbor init [PROJECT] [--hook] [--dry-run] keep a project's tasks as files in
|
|
117
124
|
this repository (.harbor/), one
|
|
@@ -125,10 +132,14 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
125
132
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
126
133
|
[--ends DATE]
|
|
127
134
|
harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
128
|
-
[--ends DATE] [--budget N]
|
|
135
|
+
[--ends DATE] [--budget N] [--from "DONE PROJECT"]
|
|
136
|
+
--from: plan it from a finished
|
|
137
|
+
project as that one really ran
|
|
129
138
|
harbor add "TITLE" --project P [--assignee me|none|NAME] [--due DATE]
|
|
130
139
|
[--priority low|medium|high|urgent] [--status S]
|
|
131
|
-
[--over-limit]
|
|
140
|
+
[--comment TEXT] [--over-limit]
|
|
141
|
+
with no TITLE: one title a line
|
|
142
|
+
from stdin
|
|
132
143
|
harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
|
|
133
144
|
move a task to In play, In review
|
|
134
145
|
or Done
|
|
@@ -138,6 +149,13 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
138
149
|
[--waits-on TASK,TASK|none] [--confirm] [--over-limit]
|
|
139
150
|
harbor limit PROJECT [COLUMN N|none] how many tasks a board column
|
|
140
151
|
takes at once; the owner sets it
|
|
152
|
+
harbor week [--next|--last|--week DATE] [--review]
|
|
153
|
+
your own week: tasks on days
|
|
154
|
+
against your hours, only for
|
|
155
|
+
you; --review: planned vs done
|
|
156
|
+
harbor week TASK DAY [--hours N] put a task on a day: mon to fri
|
|
157
|
+
harbor week TASK --off [DAY] take it off that day, or the week
|
|
158
|
+
harbor week --day-hours N the hours you work a day
|
|
141
159
|
harbor comment TASK "TEXT"
|
|
142
160
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
143
161
|
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
@@ -155,6 +173,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
155
173
|
[--title T] [--description T] [--mitigation T]
|
|
156
174
|
[--contingency T] [--resolution T] [--rationale T]
|
|
157
175
|
[--notes T] [--turned-down T] [--look-again DATE|none]
|
|
176
|
+
[--still-stands WHY]
|
|
158
177
|
one risk, issue or decision in
|
|
159
178
|
full, or change it
|
|
160
179
|
harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
|
|
@@ -201,6 +220,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
201
220
|
weeks, one at a time: still
|
|
202
221
|
needed, a new date, or drop it
|
|
203
222
|
harbor fog keep TASK it's still needed: out of the fog
|
|
223
|
+
harbor lifeboats [--program P] work only one person knows, each
|
|
224
|
+
with a second pair of hands
|
|
225
|
+
harbor lifeboats N [--person NAME] adds that second pair of hands
|
|
226
|
+
harbor follow TASK|RISK --person NAME someone follows it too
|
|
204
227
|
harbor agent [--agent NAME] the tasks given to an AI agent on
|
|
205
228
|
the crew, in the order to do them
|
|
206
229
|
harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
@@ -320,6 +343,13 @@ export function localMinute(now = new Date()) {
|
|
|
320
343
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
321
344
|
}
|
|
322
345
|
|
|
346
|
+
/** All of a stream's text: titles piped to harbor add. */
|
|
347
|
+
async function readAll(stream) {
|
|
348
|
+
let text = "";
|
|
349
|
+
for await (const chunk of stream) text += chunk;
|
|
350
|
+
return text;
|
|
351
|
+
}
|
|
352
|
+
|
|
323
353
|
const one = (positionals, what) => {
|
|
324
354
|
if (positionals.length !== 1) throw new HarborError(`Give one ${what}.`);
|
|
325
355
|
return positionals[0];
|
|
@@ -532,6 +562,23 @@ const DETAIL = {
|
|
|
532
562
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
533
563
|
" name, give the key.",
|
|
534
564
|
],
|
|
565
|
+
shore: [
|
|
566
|
+
" harbor shore PROGRAM reads the program's chat channel and",
|
|
567
|
+
" which posts go there",
|
|
568
|
+
" harbor shore PROGRAM --url URL writes: posts go to this Slack, Discord",
|
|
569
|
+
" or Teams incoming-webhook address",
|
|
570
|
+
" harbor shore PROGRAM --on KINDS writes: turns posts on; KINDS are",
|
|
571
|
+
" forecast, health and notes, with commas",
|
|
572
|
+
" harbor shore PROGRAM --off KINDS writes: turns them off",
|
|
573
|
+
" harbor shore PROGRAM --test writes: sends a test post",
|
|
574
|
+
" harbor shore PROGRAM --remove writes: stops posting and forgets the",
|
|
575
|
+
" address",
|
|
576
|
+
"",
|
|
577
|
+
" forecast is the morning harbor forecast, health a line when the program or",
|
|
578
|
+
" one of its projects changes health, notes a note for later when it opens.",
|
|
579
|
+
" Every post links back to Harbor. A new channel posts nothing until you turn",
|
|
580
|
+
" something on. The address is a secret: once saved, only a hint of it shows.",
|
|
581
|
+
],
|
|
535
582
|
roadmap: [
|
|
536
583
|
" harbor roadmap PROJECT reads whether it's public, and its link",
|
|
537
584
|
" harbor roadmap PROJECT --publish writes: publishes it, or keeps it live",
|
|
@@ -698,6 +745,20 @@ const DETAIL = {
|
|
|
698
745
|
" Piped, or with --json, it lists. The one-at-a-time sweep needs full access",
|
|
699
746
|
" (harbor login without --read-only), since dropping a task deletes it.",
|
|
700
747
|
],
|
|
748
|
+
lifeboats: [
|
|
749
|
+
" harbor lifeboats [--program P] reads: work only one person has",
|
|
750
|
+
" touched (a project, their corner of",
|
|
751
|
+
" one, a task others wait on, their",
|
|
752
|
+
" serious risks), numbered, each with",
|
|
753
|
+
" someone who has room to take a look",
|
|
754
|
+
" harbor lifeboats N [--person NAME] writes: puts that person (or NAME)",
|
|
755
|
+
" on line N as a second pair of hands",
|
|
756
|
+
],
|
|
757
|
+
follow: [
|
|
758
|
+
" harbor follow TASK --person NAME writes: NAME follows the task too,",
|
|
759
|
+
" without taking it over",
|
|
760
|
+
' harbor follow "RISK" --person NAME the same for a risk or issue',
|
|
761
|
+
],
|
|
701
762
|
handback: [
|
|
702
763
|
" harbor handback PERSON writes, straight away: gives back",
|
|
703
764
|
" what was handed over for their time",
|
|
@@ -762,6 +823,10 @@ const DETAIL = {
|
|
|
762
823
|
" today unless --decided-by or",
|
|
763
824
|
" --decided-on say otherwise)",
|
|
764
825
|
' harbor raid "TITLE" --update "TEXT" writes: adds a line to its updates',
|
|
826
|
+
' harbor raid "TITLE" --still-stands "WHY"',
|
|
827
|
+
" writes: a decision looked at again",
|
|
828
|
+
" still stands (\"\" for no reason);",
|
|
829
|
+
" clears its look again",
|
|
765
830
|
"",
|
|
766
831
|
" Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
|
|
767
832
|
" --due DATE|none (review by, or decide by), --title, --description,",
|
|
@@ -1320,6 +1385,12 @@ export async function run(argv, io = {}) {
|
|
|
1320
1385
|
`⚓ Saved on ${result.project.name}. It's on the Forecast tab under Open dry dock; --apply makes it real.`,
|
|
1321
1386
|
),
|
|
1322
1387
|
);
|
|
1388
|
+
else if (result.drops.length)
|
|
1389
|
+
out(
|
|
1390
|
+
plain(
|
|
1391
|
+
`Nothing was changed. --apply --confirm would delete ${result.drops.join(", ")}; --save keeps the changes to show someone.`,
|
|
1392
|
+
),
|
|
1393
|
+
);
|
|
1323
1394
|
else if (result.changes.length)
|
|
1324
1395
|
out(
|
|
1325
1396
|
"Nothing was changed. Add --apply to make these changes, or --save to keep them to show someone.",
|
|
@@ -1530,7 +1601,7 @@ export async function run(argv, io = {}) {
|
|
|
1530
1601
|
}
|
|
1531
1602
|
case "project": {
|
|
1532
1603
|
const { positionals, values, harbor } = parse(
|
|
1533
|
-
spec([], ["program", "lead", "starts", "ends", "budget"]),
|
|
1604
|
+
spec([], ["program", "lead", "starts", "ends", "budget", "from"]),
|
|
1534
1605
|
);
|
|
1535
1606
|
if (!values.program) throw new HarborError("Say which program: --program NAME.");
|
|
1536
1607
|
let budget;
|
|
@@ -1550,9 +1621,18 @@ export async function run(argv, io = {}) {
|
|
|
1550
1621
|
starts: dueDate(values.starts),
|
|
1551
1622
|
ends: dueDate(values.ends),
|
|
1552
1623
|
budget,
|
|
1624
|
+
planFrom: values.from,
|
|
1625
|
+
},
|
|
1626
|
+
(r) => {
|
|
1627
|
+
const opened = `Opened the project ${r.created.name} in ${r.created.program} (${r.created.starts} to ${r.created.ends}, lead ${r.created.lead ?? "nobody"}).`;
|
|
1628
|
+
const f = r.plannedFrom;
|
|
1629
|
+
if (!f)
|
|
1630
|
+
return `${opened} Add work with harbor add "TITLE" --project "${r.created.name}".`;
|
|
1631
|
+
const came = f.risks
|
|
1632
|
+
? `, and ${f.risks} risk${f.risks === 1 ? "" : "s"} that came true`
|
|
1633
|
+
: "";
|
|
1634
|
+
return `${opened}\nPlanned from ${f.project}: ${f.tasks} task${f.tasks === 1 ? "" : "s"}, each due when it really finished last time${came}. ${f.says}`;
|
|
1553
1635
|
},
|
|
1554
|
-
(r) =>
|
|
1555
|
-
`Opened the project ${r.created.name} in ${r.created.program} (${r.created.starts} to ${r.created.ends}, lead ${r.created.lead ?? "nobody"}). Add work with harbor add "TITLE" --project "${r.created.name}".`,
|
|
1556
1636
|
);
|
|
1557
1637
|
}
|
|
1558
1638
|
case "forecast": {
|
|
@@ -1817,26 +1897,132 @@ export async function run(argv, io = {}) {
|
|
|
1817
1897
|
);
|
|
1818
1898
|
return 0;
|
|
1819
1899
|
}
|
|
1900
|
+
case "shore": {
|
|
1901
|
+
const { positionals, values, harbor } = parse(spec(["test", "remove"], ["url", "on", "off"]));
|
|
1902
|
+
const program = text(positionals, "which program");
|
|
1903
|
+
const kinds = (flag) => {
|
|
1904
|
+
if (values[flag] === undefined) return undefined;
|
|
1905
|
+
const list = values[flag]
|
|
1906
|
+
.split(",")
|
|
1907
|
+
.map((k) => k.trim().toLowerCase())
|
|
1908
|
+
.filter(Boolean);
|
|
1909
|
+
const bad = list.find((k) => !["forecast", "health", "notes"].includes(k));
|
|
1910
|
+
if (bad || !list.length)
|
|
1911
|
+
throw new HarborError(`--${flag} takes forecast, health or notes, with commas between.`);
|
|
1912
|
+
return list;
|
|
1913
|
+
};
|
|
1914
|
+
const on = kinds("on");
|
|
1915
|
+
const off = kinds("off");
|
|
1916
|
+
if (on && off && on.some((k) => off.includes(k)))
|
|
1917
|
+
throw new HarborError("Say each kind of post once, with --on or --off.");
|
|
1918
|
+
if (values.remove && (values.url || on || off || values.test))
|
|
1919
|
+
throw new HarborError("--remove goes on its own.");
|
|
1920
|
+
if (!hasFullAccess(creds)) {
|
|
1921
|
+
throw new HarborError("Chat channels need full access. Run harbor login, then try again.");
|
|
1922
|
+
}
|
|
1923
|
+
const r = await client.app({
|
|
1924
|
+
op: "shore",
|
|
1925
|
+
...(harbor ? { harbor } : {}),
|
|
1926
|
+
program,
|
|
1927
|
+
...(values.url ? { url: values.url } : {}),
|
|
1928
|
+
...(on ? { on } : {}),
|
|
1929
|
+
...(off ? { off } : {}),
|
|
1930
|
+
...(values.test ? { test: true } : {}),
|
|
1931
|
+
...(values.remove ? { remove: true } : {}),
|
|
1932
|
+
});
|
|
1933
|
+
if (asJson) {
|
|
1934
|
+
out(jsonText(r));
|
|
1935
|
+
return 0;
|
|
1936
|
+
}
|
|
1937
|
+
if (!r.hook) {
|
|
1938
|
+
out(
|
|
1939
|
+
plain(
|
|
1940
|
+
r.removed
|
|
1941
|
+
? `${r.program} doesn't post to a chat channel any more.`
|
|
1942
|
+
: `${r.program} has no chat channel. harbor shore "${r.program}" --url URL --on forecast,health adds one: make an incoming webhook in the channel's settings and paste its address.`,
|
|
1943
|
+
),
|
|
1944
|
+
);
|
|
1945
|
+
return 0;
|
|
1946
|
+
}
|
|
1947
|
+
const label = {
|
|
1948
|
+
forecast: "morning forecast",
|
|
1949
|
+
health: "health changes",
|
|
1950
|
+
notes: "notes when they open",
|
|
1951
|
+
};
|
|
1952
|
+
const posts = ["forecast", "health", "notes"].filter((k) => r.hook[k]).map((k) => label[k]);
|
|
1953
|
+
const lines = [
|
|
1954
|
+
`${r.program} posts to ${r.hook.service} (${r.hook.hint}): ${posts.length ? posts.join(", ") : "nothing yet; --on forecast,health,notes turns posts on"}.`,
|
|
1955
|
+
];
|
|
1956
|
+
if (r.hook.failures > 0)
|
|
1957
|
+
lines.push(
|
|
1958
|
+
`The last ${r.hook.failures} post${r.hook.failures === 1 ? "" : "s"} didn't go through. Check the address, or --url swaps it.`,
|
|
1959
|
+
);
|
|
1960
|
+
if ("tested" in r)
|
|
1961
|
+
lines.push(
|
|
1962
|
+
r.tested
|
|
1963
|
+
? "Sent a test post."
|
|
1964
|
+
: "The channel didn't take the test post. Check the address.",
|
|
1965
|
+
);
|
|
1966
|
+
out(plain(lines.join("\n")));
|
|
1967
|
+
return 0;
|
|
1968
|
+
}
|
|
1820
1969
|
case "add": {
|
|
1821
1970
|
const { positionals, values, harbor } = parse(
|
|
1822
|
-
spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
|
|
1971
|
+
spec(["over-limit"], ["project", "assignee", "due", "priority", "status", "comment"]),
|
|
1823
1972
|
);
|
|
1824
1973
|
if (!values.project) throw new HarborError("Say which project: --project NAME.");
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1974
|
+
// Several at once: one title a line on stdin, with no title (or -) after add.
|
|
1975
|
+
const piped = positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
|
|
1976
|
+
const stdin = io.stdin ?? process.stdin;
|
|
1977
|
+
const titles =
|
|
1978
|
+
piped && (io.stdin || !process.stdin.isTTY)
|
|
1979
|
+
? (await readAll(stdin))
|
|
1980
|
+
.split(/\r?\n/)
|
|
1981
|
+
.map((l) => l.trim())
|
|
1982
|
+
.filter(Boolean)
|
|
1983
|
+
: [text(positionals, "the task")];
|
|
1984
|
+
if (titles.length === 0) throw new HarborError("Say the task, or give one title a line on stdin.");
|
|
1985
|
+
if (titles.length > 100) throw new HarborError("That's more than 100 tasks; harbor import FILE takes a spreadsheet.");
|
|
1986
|
+
const added = [];
|
|
1987
|
+
for (const title of titles) {
|
|
1988
|
+
const r = await client.call("create_task", {
|
|
1828
1989
|
harbor,
|
|
1829
1990
|
project: values.project,
|
|
1830
|
-
title
|
|
1991
|
+
title,
|
|
1831
1992
|
// Yours unless you say otherwise, as with n in the app.
|
|
1832
1993
|
assignee: values.assignee ?? "me",
|
|
1833
1994
|
due: dueDate(values.due),
|
|
1834
1995
|
priority: values.priority,
|
|
1835
1996
|
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
1836
1997
|
overLimit: values["over-limit"] || undefined,
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
|
|
1998
|
+
}).catch((err) => {
|
|
1999
|
+
if (err instanceof HarborError) {
|
|
2000
|
+
const done = added.length ? ` (added ${added.length} before it)` : "";
|
|
2001
|
+
throw new HarborError(`${hint(err.message, values.project)}${done}`);
|
|
2002
|
+
}
|
|
2003
|
+
throw err;
|
|
2004
|
+
});
|
|
2005
|
+
if (values.comment)
|
|
2006
|
+
r.comment = (
|
|
2007
|
+
await client.call("add_comment", {
|
|
2008
|
+
harbor,
|
|
2009
|
+
task: r.created.key ?? r.created.id,
|
|
2010
|
+
body: values.comment,
|
|
2011
|
+
})
|
|
2012
|
+
).commented;
|
|
2013
|
+
added.push(r);
|
|
2014
|
+
}
|
|
2015
|
+
await stalePromptCache(env);
|
|
2016
|
+
if (asJson) out(jsonText(added.length === 1 ? added[0] : { added }));
|
|
2017
|
+
else
|
|
2018
|
+
out(
|
|
2019
|
+
plain(
|
|
2020
|
+
added
|
|
2021
|
+
.map((r) => `Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`)
|
|
2022
|
+
.join("\n"),
|
|
2023
|
+
),
|
|
2024
|
+
);
|
|
2025
|
+
return 0;
|
|
1840
2026
|
}
|
|
1841
2027
|
case "start":
|
|
1842
2028
|
case "submit":
|
|
@@ -1940,6 +2126,46 @@ export async function run(argv, io = {}) {
|
|
|
1940
2126
|
.join("\n"),
|
|
1941
2127
|
);
|
|
1942
2128
|
}
|
|
2129
|
+
case "week": {
|
|
2130
|
+
const { positionals, values, harbor } = parse(
|
|
2131
|
+
spec(["next", "last", "review", "off"], ["week", "hours", "day-hours"]),
|
|
2132
|
+
);
|
|
2133
|
+
if ([values.next, values.last, values.week].filter(Boolean).length > 1)
|
|
2134
|
+
throw new HarborError("Say one of --next, --last or --week DATE.");
|
|
2135
|
+
const base = values.week ? dueDate(values.week) : opts.today;
|
|
2136
|
+
const shift = values.next ? 7 : values.last ? -7 : 0;
|
|
2137
|
+
const at = new Date(`${base}T12:00:00`);
|
|
2138
|
+
const week = todayLocal(new Date(at.getFullYear(), at.getMonth(), at.getDate() + shift));
|
|
2139
|
+
const number = (v, name) => {
|
|
2140
|
+
if (v === undefined) return undefined;
|
|
2141
|
+
const n = Number(v);
|
|
2142
|
+
if (!Number.isFinite(n) || n <= 0) throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
|
|
2143
|
+
return n;
|
|
2144
|
+
};
|
|
2145
|
+
const [task, day, ...extra] = positionals;
|
|
2146
|
+
if (extra.length) throw new HarborError("Give a task key and a day: harbor week RR-42 wed.");
|
|
2147
|
+
const dayHours = number(values["day-hours"], "day-hours");
|
|
2148
|
+
const show = (r) => [r.done, weekText(r, { ...opts, review: Boolean(values.review) })].filter(Boolean).join("\n\n");
|
|
2149
|
+
if (!task && dayHours === undefined) {
|
|
2150
|
+
if (values.off || values.hours) throw new HarborError("Say which task: harbor week RR-42 wed --hours 3.");
|
|
2151
|
+
return call("get_week", { harbor, week }, show);
|
|
2152
|
+
}
|
|
2153
|
+
if (task && !day && !values.off)
|
|
2154
|
+
throw new HarborError(`Say which day for ${task}: harbor week ${task} wed, or --off to take it off the week.`);
|
|
2155
|
+
return call(
|
|
2156
|
+
"plan_week",
|
|
2157
|
+
{
|
|
2158
|
+
harbor,
|
|
2159
|
+
week,
|
|
2160
|
+
...(task ? { task } : {}),
|
|
2161
|
+
...(day ? { day } : {}),
|
|
2162
|
+
...(values.hours ? { hours: number(values.hours, "hours") } : {}),
|
|
2163
|
+
...(values.off ? { remove: true } : {}),
|
|
2164
|
+
...(dayHours !== undefined ? { dayHours } : {}),
|
|
2165
|
+
},
|
|
2166
|
+
show,
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
1943
2169
|
case "comment": {
|
|
1944
2170
|
const { positionals, harbor } = parse(spec());
|
|
1945
2171
|
const [task, ...words] = positionals;
|
|
@@ -2253,11 +2479,7 @@ export async function run(argv, io = {}) {
|
|
|
2253
2479
|
await stalePromptCache(env);
|
|
2254
2480
|
return 0;
|
|
2255
2481
|
}
|
|
2256
|
-
return call(
|
|
2257
|
-
"fog",
|
|
2258
|
-
{ harbor, project: values.project, limit: 100 },
|
|
2259
|
-
(r) => fogText(r),
|
|
2260
|
-
);
|
|
2482
|
+
return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
|
|
2261
2483
|
}
|
|
2262
2484
|
case "handover": {
|
|
2263
2485
|
const { positionals, values, harbor } = parse(
|
|
@@ -2314,6 +2536,50 @@ export async function run(argv, io = {}) {
|
|
|
2314
2536
|
`${r.updated.key ?? r.updated.title} is ${r.updated.status === "review" ? "in review for a person to check" : "in play"}, with ${r.commentedAs}'s note.`,
|
|
2315
2537
|
);
|
|
2316
2538
|
}
|
|
2539
|
+
case "lifeboats": {
|
|
2540
|
+
const { positionals, values, harbor } = parse(spec([], ["program", "person"]));
|
|
2541
|
+
if (positionals.length > 1)
|
|
2542
|
+
throw new HarborError("harbor lifeboats takes one line number: harbor lifeboats 2.");
|
|
2543
|
+
if (!positionals.length) {
|
|
2544
|
+
if (values.person)
|
|
2545
|
+
throw new HarborError("--person goes with a line: harbor lifeboats 2 --person NAME.");
|
|
2546
|
+
return call("lifeboats", { harbor, program: values.program, limit: 100 }, (r) =>
|
|
2547
|
+
lifeboatsText(r),
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
const n = Number(positionals[0]);
|
|
2551
|
+
if (!Number.isInteger(n) || n < 1)
|
|
2552
|
+
throw new HarborError("Give a line number from harbor lifeboats, like harbor lifeboats 2.");
|
|
2553
|
+
const list = await client.call("lifeboats", {
|
|
2554
|
+
harbor,
|
|
2555
|
+
program: values.program,
|
|
2556
|
+
limit: 100,
|
|
2557
|
+
});
|
|
2558
|
+
const line = list.lines[n - 1];
|
|
2559
|
+
if (!line)
|
|
2560
|
+
throw new HarborError(
|
|
2561
|
+
list.count
|
|
2562
|
+
? `There ${list.count === 1 ? "is 1 line" : `are ${list.count} lines`}; harbor lifeboats lists them.`
|
|
2563
|
+
: "Nothing only one person knows right now.",
|
|
2564
|
+
);
|
|
2565
|
+
return call("follow_work", { harbor, lifeboat: line.id, person: values.person }, (r) =>
|
|
2566
|
+
followedText(r),
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2569
|
+
case "follow": {
|
|
2570
|
+
const { positionals, values, harbor } = parse(spec([], ["person"]));
|
|
2571
|
+
const what = positionals.join(" ").trim();
|
|
2572
|
+
if (!what) throw new HarborError("Say what: harbor follow TASK --person NAME.");
|
|
2573
|
+
if (!values.person)
|
|
2574
|
+
throw new HarborError("Say who: harbor follow TASK --person NAME (or me).");
|
|
2575
|
+
// A task key reads as a task; anything else is a risk's title.
|
|
2576
|
+
const isKey = /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(what);
|
|
2577
|
+
return call(
|
|
2578
|
+
"follow_work",
|
|
2579
|
+
{ harbor, ...(isKey ? { task: what } : { raid: what }), person: values.person },
|
|
2580
|
+
(r) => followedText(r),
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2317
2583
|
case "handback": {
|
|
2318
2584
|
const { positionals, harbor } = parse(spec());
|
|
2319
2585
|
const person = positionals.join(" ").trim();
|
|
@@ -2348,6 +2614,7 @@ export async function run(argv, io = {}) {
|
|
|
2348
2614
|
"decided-on",
|
|
2349
2615
|
"turned-down",
|
|
2350
2616
|
"look-again",
|
|
2617
|
+
"still-stands",
|
|
2351
2618
|
...fields,
|
|
2352
2619
|
],
|
|
2353
2620
|
),
|
|
@@ -2369,6 +2636,7 @@ export async function run(argv, io = {}) {
|
|
|
2369
2636
|
revisitOn:
|
|
2370
2637
|
values["look-again"] === undefined ? undefined : nullable(dueDate(values["look-again"])),
|
|
2371
2638
|
update: values.update,
|
|
2639
|
+
stillStands: values["still-stands"],
|
|
2372
2640
|
...Object.fromEntries(fields.map((f) => [f, values[f]])),
|
|
2373
2641
|
};
|
|
2374
2642
|
const changes = Object.entries(args).filter(
|
package/lib/format.mjs
CHANGED
|
@@ -393,11 +393,52 @@ export function forecastList(r) {
|
|
|
393
393
|
const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
|
|
394
394
|
if (f.none) return `${name} no forecast: ${f.none.replace(/^\w/, (c) => c.toLowerCase())}`;
|
|
395
395
|
const end = p.ends ? ` · ${chance(f.chanceByEnd)} by its end, ${p.ends}` : "";
|
|
396
|
-
|
|
396
|
+
const slip =
|
|
397
|
+
f.costOfSlip?.atLikely >= 100 ? `late adds about ${dollars(f.costOfSlip.atLikely)}` : null;
|
|
398
|
+
return [`${name} ${finishRange(f)}${end}`, ...finishNotes(f), slip]
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.join(" · ");
|
|
397
401
|
})
|
|
398
402
|
.join("\n");
|
|
399
403
|
}
|
|
400
404
|
|
|
405
|
+
/** Money to the nearest hundred, as "$4,800" (src/lib/slip-cost.ts's dollars). */
|
|
406
|
+
function dollars(n) {
|
|
407
|
+
const r = Math.round(Math.abs(n) / 100) * 100;
|
|
408
|
+
return `${n < 0 && r > 0 ? "-" : ""}$${r.toLocaleString("en-US")}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The cost of a slip, as the Forecast tab says it: what each week late
|
|
413
|
+
* costs at the project's recent spend, what the likely and very likely
|
|
414
|
+
* dates add, and where the 85% date leaves the budget. Empty when the
|
|
415
|
+
* project has no budget or recent spend.
|
|
416
|
+
*/
|
|
417
|
+
function slipLines(f) {
|
|
418
|
+
const c = f.costOfSlip;
|
|
419
|
+
if (!c || c.perWeekLate < 50) return [];
|
|
420
|
+
const lines = [
|
|
421
|
+
`Each week late costs about ${dollars(c.perWeekLate)}, from the spend ledger's last eight weeks.`,
|
|
422
|
+
];
|
|
423
|
+
const adds = [
|
|
424
|
+
c.atLikely >= 100 ? `finishing ${f.likely} adds about ${dollars(c.atLikely)}` : null,
|
|
425
|
+
c.atVeryLikely >= 100 && f.veryLikely !== f.likely
|
|
426
|
+
? `${f.veryLikely} about ${dollars(c.atVeryLikely)}`
|
|
427
|
+
: null,
|
|
428
|
+
].filter(Boolean);
|
|
429
|
+
if (adds.length) lines.push(`Past its end date, ${adds.join("; ")}.`);
|
|
430
|
+
const over = c.overBudgetAtVeryLikely;
|
|
431
|
+
if (over !== undefined && f.veryLikely)
|
|
432
|
+
lines.push(
|
|
433
|
+
Math.abs(over) < 100
|
|
434
|
+
? `By ${f.veryLikely} it would use about the whole budget.`
|
|
435
|
+
: over > 0
|
|
436
|
+
? `By ${f.veryLikely} it would end about ${dollars(over)} over budget.`
|
|
437
|
+
: `By ${f.veryLikely} it would still be about ${dollars(-over)} under budget.`,
|
|
438
|
+
);
|
|
439
|
+
return lines;
|
|
440
|
+
}
|
|
441
|
+
|
|
401
442
|
/** One project's forecast, in a few sentences. */
|
|
402
443
|
export function forecastOne(p) {
|
|
403
444
|
const f = p.finish ?? {};
|
|
@@ -426,6 +467,7 @@ export function forecastOne(p) {
|
|
|
426
467
|
`${which} is due ${f.lastDue.due}, later than the crew's pace alone would finish, so the dates wait for it.`,
|
|
427
468
|
);
|
|
428
469
|
}
|
|
470
|
+
lines.push(...slipLines(f));
|
|
429
471
|
return lines.join("\n");
|
|
430
472
|
}
|
|
431
473
|
|
|
@@ -814,6 +856,56 @@ export function exportData(r) {
|
|
|
814
856
|
};
|
|
815
857
|
}
|
|
816
858
|
|
|
859
|
+
const DAY_NAME = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri" };
|
|
860
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
861
|
+
const hours = (h) => (h > 0 && h < 1 ? `${Math.round(h * 60)}m` : `${Math.round(h * 10) / 10}h`);
|
|
862
|
+
const monthDay = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${Number(iso.slice(8, 10))}`;
|
|
863
|
+
|
|
864
|
+
/** get_week as the terminal shows it: the five days with their hours, what's not on a day yet, and how it went. */
|
|
865
|
+
export function weekText(w, { today, review = false, width } = {}) {
|
|
866
|
+
if (!w.days) return w.note ?? "No week to show.";
|
|
867
|
+
const first = w.days[0].date;
|
|
868
|
+
const last = w.days[4].date;
|
|
869
|
+
const out = [
|
|
870
|
+
`Your week · ${monthDay(first)} to ${monthDay(last)} · ${hours(w.planned)} of ${hours(w.capacity)} planned (only you see it)`,
|
|
871
|
+
];
|
|
872
|
+
const reviewing = review || (today && today >= last);
|
|
873
|
+
if (!review) {
|
|
874
|
+
for (const d of w.days) {
|
|
875
|
+
const head = `${DAY_NAME[d.day]} ${Number(d.date.slice(8, 10))}`;
|
|
876
|
+
const load = d.tasks.length ? `${hours(d.hours)} of ${hours(w.dayHours)}` : "nothing planned";
|
|
877
|
+
out.push(
|
|
878
|
+
"",
|
|
879
|
+
`${head} ${load}${d.over ? ` · too full by ${d.overBy}` : ""}${d.date === today ? " · today" : ""}`,
|
|
880
|
+
);
|
|
881
|
+
for (const t of d.tasks) {
|
|
882
|
+
const tail = ` ${hours(t.hours)}${t.status === "done" ? " · done" : ""}`;
|
|
883
|
+
const key = ` ${t.key ?? t.id} `;
|
|
884
|
+
out.push(
|
|
885
|
+
key + fitTitle(t.title, width ? width - 1 - key.length - tail.length : Infinity) + tail,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (w.unplannedCount) {
|
|
890
|
+
out.push("", `Not on a day yet: ${w.unplannedCount}. harbor week KEY DAY puts one there.`);
|
|
891
|
+
for (const t of w.unplanned.slice(0, 5))
|
|
892
|
+
out.push(` ${taskLine(t, { today, mine: true, width: width ? width - 2 : undefined })}`);
|
|
893
|
+
if (w.unplannedCount > 5) out.push(` … and ${w.unplannedCount - 5} more (--json lists 25)`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (reviewing) {
|
|
897
|
+
const r = w.review;
|
|
898
|
+
out.push("", `How it went: ${r.summary}`);
|
|
899
|
+
if (r.planned)
|
|
900
|
+
out.push(` ${hours(r.doneHours)} of the ${hours(r.plannedHours)} planned is done.`);
|
|
901
|
+
if (review) {
|
|
902
|
+
for (const t of r.stillOpen) out.push(` open ${taskLine(t, { today, mine: true })}`);
|
|
903
|
+
for (const t of r.alsoDone) out.push(` also ${taskLine(t, { today, mine: true })}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return out.join("\n");
|
|
907
|
+
}
|
|
908
|
+
|
|
817
909
|
/** fog: the open tasks nobody has touched for three weeks, longest first. */
|
|
818
910
|
export function fogText(r, { sweepHint = true } = {}) {
|
|
819
911
|
const where = r.project ? ` in ${r.project}` : "";
|
|
@@ -835,3 +927,33 @@ export function fogText(r, { sweepHint = true } = {}) {
|
|
|
835
927
|
);
|
|
836
928
|
return lines.join("\n");
|
|
837
929
|
}
|
|
930
|
+
|
|
931
|
+
/** harbor lifeboats: work only one person knows, numbered for harbor lifeboats N. */
|
|
932
|
+
export function lifeboatsText(r) {
|
|
933
|
+
const where = r.program ? ` in ${r.program}` : "";
|
|
934
|
+
if (!r.count) return `Nothing${where} that only one person knows. Good.`;
|
|
935
|
+
const lines = [
|
|
936
|
+
`${r.count} ${r.count === 1 ? "thing" : "things"} only one person knows${where}: nobody else has been assigned, commented or written in ${r.count === 1 ? "it" : "them"}.`,
|
|
937
|
+
"",
|
|
938
|
+
];
|
|
939
|
+
r.lines.forEach((l, n) => {
|
|
940
|
+
lines.push(` ${String(n + 1).padStart(2)}. ${l.what}`);
|
|
941
|
+
const bits = [`only ${l.onlyKnownBy}`, l.detail, l.due ? `due ${l.due}` : null];
|
|
942
|
+
lines.push(` ${bits.filter(Boolean).join(" · ")}`);
|
|
943
|
+
if (l.suggest)
|
|
944
|
+
lines.push(` ${l.suggest.person} could take a second look: ${l.suggest.why}`);
|
|
945
|
+
});
|
|
946
|
+
if (r.more) lines.push("", `And ${r.count - r.lines.length} more.`);
|
|
947
|
+
for (const a of r.away ?? []) lines.push("", a);
|
|
948
|
+
lines.push(
|
|
949
|
+
"",
|
|
950
|
+
"harbor lifeboats N adds the suggested second pair of hands (--person NAME for someone else).",
|
|
951
|
+
);
|
|
952
|
+
return lines.join("\n");
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** What follow_work did. */
|
|
956
|
+
export function followedText(r) {
|
|
957
|
+
const f = r.followed;
|
|
958
|
+
return `${f.person} is a second pair of hands on ${f.what}: they follow it now, and it stays with whoever has it.`;
|
|
959
|
+
}
|