@harborppm/cli 0.53.0 → 0.54.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 +11 -1
- package/dist/app.mjs +24852 -23180
- package/lib/cli.mjs +183 -9
- package/lib/format.mjs +73 -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,
|
|
@@ -112,6 +114,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
112
114
|
[--off]
|
|
113
115
|
a project's public roadmap: read,
|
|
114
116
|
publish or take it down
|
|
117
|
+
harbor shore PROGRAM [--url URL] [--on KINDS] [--off KINDS] [--test]
|
|
118
|
+
[--remove]
|
|
119
|
+
a program's chat channel (Slack,
|
|
120
|
+
Discord, Teams) and its posts
|
|
115
121
|
|
|
116
122
|
harbor init [PROJECT] [--hook] [--dry-run] keep a project's tasks as files in
|
|
117
123
|
this repository (.harbor/), one
|
|
@@ -125,7 +131,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
125
131
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
126
132
|
[--ends DATE]
|
|
127
133
|
harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
128
|
-
[--ends DATE] [--budget N]
|
|
134
|
+
[--ends DATE] [--budget N] [--from "DONE PROJECT"]
|
|
135
|
+
--from: plan it from a finished
|
|
136
|
+
project as that one really ran
|
|
129
137
|
harbor add "TITLE" --project P [--assignee me|none|NAME] [--due DATE]
|
|
130
138
|
[--priority low|medium|high|urgent] [--status S]
|
|
131
139
|
[--over-limit]
|
|
@@ -155,6 +163,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
155
163
|
[--title T] [--description T] [--mitigation T]
|
|
156
164
|
[--contingency T] [--resolution T] [--rationale T]
|
|
157
165
|
[--notes T] [--turned-down T] [--look-again DATE|none]
|
|
166
|
+
[--still-stands WHY]
|
|
158
167
|
one risk, issue or decision in
|
|
159
168
|
full, or change it
|
|
160
169
|
harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
|
|
@@ -201,6 +210,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
201
210
|
weeks, one at a time: still
|
|
202
211
|
needed, a new date, or drop it
|
|
203
212
|
harbor fog keep TASK it's still needed: out of the fog
|
|
213
|
+
harbor lifeboats [--program P] work only one person knows, each
|
|
214
|
+
with a second pair of hands
|
|
215
|
+
harbor lifeboats N [--person NAME] adds that second pair of hands
|
|
216
|
+
harbor follow TASK|RISK --person NAME someone follows it too
|
|
204
217
|
harbor agent [--agent NAME] the tasks given to an AI agent on
|
|
205
218
|
the crew, in the order to do them
|
|
206
219
|
harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
@@ -532,6 +545,23 @@ const DETAIL = {
|
|
|
532
545
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
533
546
|
" name, give the key.",
|
|
534
547
|
],
|
|
548
|
+
shore: [
|
|
549
|
+
" harbor shore PROGRAM reads the program's chat channel and",
|
|
550
|
+
" which posts go there",
|
|
551
|
+
" harbor shore PROGRAM --url URL writes: posts go to this Slack, Discord",
|
|
552
|
+
" or Teams incoming-webhook address",
|
|
553
|
+
" harbor shore PROGRAM --on KINDS writes: turns posts on; KINDS are",
|
|
554
|
+
" forecast, health and notes, with commas",
|
|
555
|
+
" harbor shore PROGRAM --off KINDS writes: turns them off",
|
|
556
|
+
" harbor shore PROGRAM --test writes: sends a test post",
|
|
557
|
+
" harbor shore PROGRAM --remove writes: stops posting and forgets the",
|
|
558
|
+
" address",
|
|
559
|
+
"",
|
|
560
|
+
" forecast is the morning harbor forecast, health a line when the program or",
|
|
561
|
+
" one of its projects changes health, notes a note for later when it opens.",
|
|
562
|
+
" Every post links back to Harbor. A new channel posts nothing until you turn",
|
|
563
|
+
" something on. The address is a secret: once saved, only a hint of it shows.",
|
|
564
|
+
],
|
|
535
565
|
roadmap: [
|
|
536
566
|
" harbor roadmap PROJECT reads whether it's public, and its link",
|
|
537
567
|
" harbor roadmap PROJECT --publish writes: publishes it, or keeps it live",
|
|
@@ -698,6 +728,20 @@ const DETAIL = {
|
|
|
698
728
|
" Piped, or with --json, it lists. The one-at-a-time sweep needs full access",
|
|
699
729
|
" (harbor login without --read-only), since dropping a task deletes it.",
|
|
700
730
|
],
|
|
731
|
+
lifeboats: [
|
|
732
|
+
" harbor lifeboats [--program P] reads: work only one person has",
|
|
733
|
+
" touched (a project, their corner of",
|
|
734
|
+
" one, a task others wait on, their",
|
|
735
|
+
" serious risks), numbered, each with",
|
|
736
|
+
" someone who has room to take a look",
|
|
737
|
+
" harbor lifeboats N [--person NAME] writes: puts that person (or NAME)",
|
|
738
|
+
" on line N as a second pair of hands",
|
|
739
|
+
],
|
|
740
|
+
follow: [
|
|
741
|
+
" harbor follow TASK --person NAME writes: NAME follows the task too,",
|
|
742
|
+
" without taking it over",
|
|
743
|
+
' harbor follow "RISK" --person NAME the same for a risk or issue',
|
|
744
|
+
],
|
|
701
745
|
handback: [
|
|
702
746
|
" harbor handback PERSON writes, straight away: gives back",
|
|
703
747
|
" what was handed over for their time",
|
|
@@ -762,6 +806,10 @@ const DETAIL = {
|
|
|
762
806
|
" today unless --decided-by or",
|
|
763
807
|
" --decided-on say otherwise)",
|
|
764
808
|
' harbor raid "TITLE" --update "TEXT" writes: adds a line to its updates',
|
|
809
|
+
' harbor raid "TITLE" --still-stands "WHY"',
|
|
810
|
+
" writes: a decision looked at again",
|
|
811
|
+
" still stands (\"\" for no reason);",
|
|
812
|
+
" clears its look again",
|
|
765
813
|
"",
|
|
766
814
|
" Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
|
|
767
815
|
" --due DATE|none (review by, or decide by), --title, --description,",
|
|
@@ -1320,6 +1368,12 @@ export async function run(argv, io = {}) {
|
|
|
1320
1368
|
`⚓ Saved on ${result.project.name}. It's on the Forecast tab under Open dry dock; --apply makes it real.`,
|
|
1321
1369
|
),
|
|
1322
1370
|
);
|
|
1371
|
+
else if (result.drops.length)
|
|
1372
|
+
out(
|
|
1373
|
+
plain(
|
|
1374
|
+
`Nothing was changed. --apply --confirm would delete ${result.drops.join(", ")}; --save keeps the changes to show someone.`,
|
|
1375
|
+
),
|
|
1376
|
+
);
|
|
1323
1377
|
else if (result.changes.length)
|
|
1324
1378
|
out(
|
|
1325
1379
|
"Nothing was changed. Add --apply to make these changes, or --save to keep them to show someone.",
|
|
@@ -1530,7 +1584,7 @@ export async function run(argv, io = {}) {
|
|
|
1530
1584
|
}
|
|
1531
1585
|
case "project": {
|
|
1532
1586
|
const { positionals, values, harbor } = parse(
|
|
1533
|
-
spec([], ["program", "lead", "starts", "ends", "budget"]),
|
|
1587
|
+
spec([], ["program", "lead", "starts", "ends", "budget", "from"]),
|
|
1534
1588
|
);
|
|
1535
1589
|
if (!values.program) throw new HarborError("Say which program: --program NAME.");
|
|
1536
1590
|
let budget;
|
|
@@ -1550,9 +1604,18 @@ export async function run(argv, io = {}) {
|
|
|
1550
1604
|
starts: dueDate(values.starts),
|
|
1551
1605
|
ends: dueDate(values.ends),
|
|
1552
1606
|
budget,
|
|
1607
|
+
planFrom: values.from,
|
|
1608
|
+
},
|
|
1609
|
+
(r) => {
|
|
1610
|
+
const opened = `Opened the project ${r.created.name} in ${r.created.program} (${r.created.starts} to ${r.created.ends}, lead ${r.created.lead ?? "nobody"}).`;
|
|
1611
|
+
const f = r.plannedFrom;
|
|
1612
|
+
if (!f)
|
|
1613
|
+
return `${opened} Add work with harbor add "TITLE" --project "${r.created.name}".`;
|
|
1614
|
+
const came = f.risks
|
|
1615
|
+
? `, and ${f.risks} risk${f.risks === 1 ? "" : "s"} that came true`
|
|
1616
|
+
: "";
|
|
1617
|
+
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
1618
|
},
|
|
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
1619
|
);
|
|
1557
1620
|
}
|
|
1558
1621
|
case "forecast": {
|
|
@@ -1817,6 +1880,75 @@ export async function run(argv, io = {}) {
|
|
|
1817
1880
|
);
|
|
1818
1881
|
return 0;
|
|
1819
1882
|
}
|
|
1883
|
+
case "shore": {
|
|
1884
|
+
const { positionals, values, harbor } = parse(spec(["test", "remove"], ["url", "on", "off"]));
|
|
1885
|
+
const program = text(positionals, "which program");
|
|
1886
|
+
const kinds = (flag) => {
|
|
1887
|
+
if (values[flag] === undefined) return undefined;
|
|
1888
|
+
const list = values[flag]
|
|
1889
|
+
.split(",")
|
|
1890
|
+
.map((k) => k.trim().toLowerCase())
|
|
1891
|
+
.filter(Boolean);
|
|
1892
|
+
const bad = list.find((k) => !["forecast", "health", "notes"].includes(k));
|
|
1893
|
+
if (bad || !list.length)
|
|
1894
|
+
throw new HarborError(`--${flag} takes forecast, health or notes, with commas between.`);
|
|
1895
|
+
return list;
|
|
1896
|
+
};
|
|
1897
|
+
const on = kinds("on");
|
|
1898
|
+
const off = kinds("off");
|
|
1899
|
+
if (on && off && on.some((k) => off.includes(k)))
|
|
1900
|
+
throw new HarborError("Say each kind of post once, with --on or --off.");
|
|
1901
|
+
if (values.remove && (values.url || on || off || values.test))
|
|
1902
|
+
throw new HarborError("--remove goes on its own.");
|
|
1903
|
+
if (!hasFullAccess(creds)) {
|
|
1904
|
+
throw new HarborError("Chat channels need full access. Run harbor login, then try again.");
|
|
1905
|
+
}
|
|
1906
|
+
const r = await client.app({
|
|
1907
|
+
op: "shore",
|
|
1908
|
+
...(harbor ? { harbor } : {}),
|
|
1909
|
+
program,
|
|
1910
|
+
...(values.url ? { url: values.url } : {}),
|
|
1911
|
+
...(on ? { on } : {}),
|
|
1912
|
+
...(off ? { off } : {}),
|
|
1913
|
+
...(values.test ? { test: true } : {}),
|
|
1914
|
+
...(values.remove ? { remove: true } : {}),
|
|
1915
|
+
});
|
|
1916
|
+
if (asJson) {
|
|
1917
|
+
out(jsonText(r));
|
|
1918
|
+
return 0;
|
|
1919
|
+
}
|
|
1920
|
+
if (!r.hook) {
|
|
1921
|
+
out(
|
|
1922
|
+
plain(
|
|
1923
|
+
r.removed
|
|
1924
|
+
? `${r.program} doesn't post to a chat channel any more.`
|
|
1925
|
+
: `${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.`,
|
|
1926
|
+
),
|
|
1927
|
+
);
|
|
1928
|
+
return 0;
|
|
1929
|
+
}
|
|
1930
|
+
const label = {
|
|
1931
|
+
forecast: "morning forecast",
|
|
1932
|
+
health: "health changes",
|
|
1933
|
+
notes: "notes when they open",
|
|
1934
|
+
};
|
|
1935
|
+
const posts = ["forecast", "health", "notes"].filter((k) => r.hook[k]).map((k) => label[k]);
|
|
1936
|
+
const lines = [
|
|
1937
|
+
`${r.program} posts to ${r.hook.service} (${r.hook.hint}): ${posts.length ? posts.join(", ") : "nothing yet; --on forecast,health,notes turns posts on"}.`,
|
|
1938
|
+
];
|
|
1939
|
+
if (r.hook.failures > 0)
|
|
1940
|
+
lines.push(
|
|
1941
|
+
`The last ${r.hook.failures} post${r.hook.failures === 1 ? "" : "s"} didn't go through. Check the address, or --url swaps it.`,
|
|
1942
|
+
);
|
|
1943
|
+
if ("tested" in r)
|
|
1944
|
+
lines.push(
|
|
1945
|
+
r.tested
|
|
1946
|
+
? "Sent a test post."
|
|
1947
|
+
: "The channel didn't take the test post. Check the address.",
|
|
1948
|
+
);
|
|
1949
|
+
out(plain(lines.join("\n")));
|
|
1950
|
+
return 0;
|
|
1951
|
+
}
|
|
1820
1952
|
case "add": {
|
|
1821
1953
|
const { positionals, values, harbor } = parse(
|
|
1822
1954
|
spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
|
|
@@ -2253,11 +2385,7 @@ export async function run(argv, io = {}) {
|
|
|
2253
2385
|
await stalePromptCache(env);
|
|
2254
2386
|
return 0;
|
|
2255
2387
|
}
|
|
2256
|
-
return call(
|
|
2257
|
-
"fog",
|
|
2258
|
-
{ harbor, project: values.project, limit: 100 },
|
|
2259
|
-
(r) => fogText(r),
|
|
2260
|
-
);
|
|
2388
|
+
return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
|
|
2261
2389
|
}
|
|
2262
2390
|
case "handover": {
|
|
2263
2391
|
const { positionals, values, harbor } = parse(
|
|
@@ -2314,6 +2442,50 @@ export async function run(argv, io = {}) {
|
|
|
2314
2442
|
`${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
2443
|
);
|
|
2316
2444
|
}
|
|
2445
|
+
case "lifeboats": {
|
|
2446
|
+
const { positionals, values, harbor } = parse(spec([], ["program", "person"]));
|
|
2447
|
+
if (positionals.length > 1)
|
|
2448
|
+
throw new HarborError("harbor lifeboats takes one line number: harbor lifeboats 2.");
|
|
2449
|
+
if (!positionals.length) {
|
|
2450
|
+
if (values.person)
|
|
2451
|
+
throw new HarborError("--person goes with a line: harbor lifeboats 2 --person NAME.");
|
|
2452
|
+
return call("lifeboats", { harbor, program: values.program, limit: 100 }, (r) =>
|
|
2453
|
+
lifeboatsText(r),
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
const n = Number(positionals[0]);
|
|
2457
|
+
if (!Number.isInteger(n) || n < 1)
|
|
2458
|
+
throw new HarborError("Give a line number from harbor lifeboats, like harbor lifeboats 2.");
|
|
2459
|
+
const list = await client.call("lifeboats", {
|
|
2460
|
+
harbor,
|
|
2461
|
+
program: values.program,
|
|
2462
|
+
limit: 100,
|
|
2463
|
+
});
|
|
2464
|
+
const line = list.lines[n - 1];
|
|
2465
|
+
if (!line)
|
|
2466
|
+
throw new HarborError(
|
|
2467
|
+
list.count
|
|
2468
|
+
? `There ${list.count === 1 ? "is 1 line" : `are ${list.count} lines`}; harbor lifeboats lists them.`
|
|
2469
|
+
: "Nothing only one person knows right now.",
|
|
2470
|
+
);
|
|
2471
|
+
return call("follow_work", { harbor, lifeboat: line.id, person: values.person }, (r) =>
|
|
2472
|
+
followedText(r),
|
|
2473
|
+
);
|
|
2474
|
+
}
|
|
2475
|
+
case "follow": {
|
|
2476
|
+
const { positionals, values, harbor } = parse(spec([], ["person"]));
|
|
2477
|
+
const what = positionals.join(" ").trim();
|
|
2478
|
+
if (!what) throw new HarborError("Say what: harbor follow TASK --person NAME.");
|
|
2479
|
+
if (!values.person)
|
|
2480
|
+
throw new HarborError("Say who: harbor follow TASK --person NAME (or me).");
|
|
2481
|
+
// A task key reads as a task; anything else is a risk's title.
|
|
2482
|
+
const isKey = /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(what);
|
|
2483
|
+
return call(
|
|
2484
|
+
"follow_work",
|
|
2485
|
+
{ harbor, ...(isKey ? { task: what } : { raid: what }), person: values.person },
|
|
2486
|
+
(r) => followedText(r),
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2317
2489
|
case "handback": {
|
|
2318
2490
|
const { positionals, harbor } = parse(spec());
|
|
2319
2491
|
const person = positionals.join(" ").trim();
|
|
@@ -2348,6 +2520,7 @@ export async function run(argv, io = {}) {
|
|
|
2348
2520
|
"decided-on",
|
|
2349
2521
|
"turned-down",
|
|
2350
2522
|
"look-again",
|
|
2523
|
+
"still-stands",
|
|
2351
2524
|
...fields,
|
|
2352
2525
|
],
|
|
2353
2526
|
),
|
|
@@ -2369,6 +2542,7 @@ export async function run(argv, io = {}) {
|
|
|
2369
2542
|
revisitOn:
|
|
2370
2543
|
values["look-again"] === undefined ? undefined : nullable(dueDate(values["look-again"])),
|
|
2371
2544
|
update: values.update,
|
|
2545
|
+
stillStands: values["still-stands"],
|
|
2372
2546
|
...Object.fromEntries(fields.map((f) => [f, values[f]])),
|
|
2373
2547
|
};
|
|
2374
2548
|
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
|
|
|
@@ -835,3 +877,33 @@ export function fogText(r, { sweepHint = true } = {}) {
|
|
|
835
877
|
);
|
|
836
878
|
return lines.join("\n");
|
|
837
879
|
}
|
|
880
|
+
|
|
881
|
+
/** harbor lifeboats: work only one person knows, numbered for harbor lifeboats N. */
|
|
882
|
+
export function lifeboatsText(r) {
|
|
883
|
+
const where = r.program ? ` in ${r.program}` : "";
|
|
884
|
+
if (!r.count) return `Nothing${where} that only one person knows. Good.`;
|
|
885
|
+
const lines = [
|
|
886
|
+
`${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"}.`,
|
|
887
|
+
"",
|
|
888
|
+
];
|
|
889
|
+
r.lines.forEach((l, n) => {
|
|
890
|
+
lines.push(` ${String(n + 1).padStart(2)}. ${l.what}`);
|
|
891
|
+
const bits = [`only ${l.onlyKnownBy}`, l.detail, l.due ? `due ${l.due}` : null];
|
|
892
|
+
lines.push(` ${bits.filter(Boolean).join(" · ")}`);
|
|
893
|
+
if (l.suggest)
|
|
894
|
+
lines.push(` ${l.suggest.person} could take a second look: ${l.suggest.why}`);
|
|
895
|
+
});
|
|
896
|
+
if (r.more) lines.push("", `And ${r.count - r.lines.length} more.`);
|
|
897
|
+
for (const a of r.away ?? []) lines.push("", a);
|
|
898
|
+
lines.push(
|
|
899
|
+
"",
|
|
900
|
+
"harbor lifeboats N adds the suggested second pair of hands (--person NAME for someone else).",
|
|
901
|
+
);
|
|
902
|
+
return lines.join("\n");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** What follow_work did. */
|
|
906
|
+
export function followedText(r) {
|
|
907
|
+
const f = r.followed;
|
|
908
|
+
return `${f.person} is a second pair of hands on ${f.what}: they follow it now, and it stays with whoever has it.`;
|
|
909
|
+
}
|