@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/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
- return call(
1826
- "create_task",
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: text(positionals, "the task"),
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
- (r) => `Added: ${taskLine(r.created, opts)}`,
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
- return [`${name} ${finishRange(f)}${end}`, ...finishNotes(f)].join(" · ");
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.53.0",
3
+ "version": "0.55.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",