@harborppm/cli 0.52.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/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,12 +163,17 @@ 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]
161
170
  the review: programs worst first,
162
171
  each with its next move (reads
163
172
  only; harbor submit moves a task)
173
+ harbor bridge [--harbor H] run the review as a meeting: one
174
+ project a screen, what changed
175
+ since the last one, decisions,
176
+ actions and risks logged live
164
177
  harbor next-move PROGRAM "TEXT" write a program's next move
165
178
  harbor health --program P|--project P|--portfolio P STATE [--note WHY]
166
179
  set its health by hand, or auto
@@ -197,6 +210,10 @@ export const HELP = `harbor: Harbor from the terminal
197
210
  weeks, one at a time: still
198
211
  needed, a new date, or drop it
199
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
200
217
  harbor agent [--agent NAME] the tasks given to an AI agent on
201
218
  the crew, in the order to do them
202
219
  harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
@@ -528,6 +545,23 @@ const DETAIL = {
528
545
  " PROJECT is a name, a key like RR, or an id. When two projects share a",
529
546
  " name, give the key.",
530
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
+ ],
531
565
  roadmap: [
532
566
  " harbor roadmap PROJECT reads whether it's public, and its link",
533
567
  " harbor roadmap PROJECT --publish writes: publishes it, or keeps it live",
@@ -694,6 +728,20 @@ const DETAIL = {
694
728
  " Piped, or with --json, it lists. The one-at-a-time sweep needs full access",
695
729
  " (harbor login without --read-only), since dropping a task deletes it.",
696
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
+ ],
697
745
  handback: [
698
746
  " harbor handback PERSON writes, straight away: gives back",
699
747
  " what was handed over for their time",
@@ -758,6 +806,10 @@ const DETAIL = {
758
806
  " today unless --decided-by or",
759
807
  " --decided-on say otherwise)",
760
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",
761
813
  "",
762
814
  " Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
763
815
  " --due DATE|none (review by, or decide by), --title, --description,",
@@ -767,6 +819,20 @@ const DETAIL = {
767
819
  " enough of it to pick one out; harbor risks lists them. Changes several",
768
820
  " at once, as one change.",
769
821
  ],
822
+ bridge: [
823
+ " harbor bridge opens the full-screen app on the",
824
+ " review meeting (b on the Programs",
825
+ " screen, or : Run the review meeting)",
826
+ "",
827
+ " One open project a screen, with only what changed since its program was",
828
+ " last reviewed (a week back the first time): health moves, work finished,",
829
+ " newly late or added, risks raised, decisions made. ←→ move between",
830
+ " projects; d logs a decision, a an action (who takes it, due in a week),",
831
+ " r a risk, m the program's next move; e ends it. The summary lists what",
832
+ " was logged; p posts it to each program's chat channel where one is set",
833
+ " (Ship to shore, set on the website), y copies it. The next meeting",
834
+ " counts from when this one ended.",
835
+ ],
770
836
  review: [
771
837
  " harbor review reads the review: every program,",
772
838
  " worst health first, then soonest end,",
@@ -990,7 +1056,7 @@ export async function run(argv, io = {}) {
990
1056
  }
991
1057
 
992
1058
  // On its own in a terminal: the full-screen app. Piped or scripted: the help.
993
- if (!command || command === "app") {
1059
+ if (!command || command === "app" || command === "bridge") {
994
1060
  const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
995
1061
  if (!command && !terminal) {
996
1062
  out(HELP);
@@ -999,7 +1065,7 @@ export async function run(argv, io = {}) {
999
1065
  if (!terminal) throw new HarborError("The full-screen app needs a terminal.");
1000
1066
  const { values } = parseArgs(rest, { flags: [], options: ["harbor", "server"] });
1001
1067
  // In a folder with a local plan (harbor init --local), the app opens on it.
1002
- if (!values.harbor && !values.server) {
1068
+ if (!values.harbor && !values.server && command !== "bridge") {
1003
1069
  const bundled = await loadApp();
1004
1070
  const dir = await bundled.localPlanAt(io.cwd ?? process.cwd());
1005
1071
  if (dir) {
@@ -1056,6 +1122,7 @@ export async function run(argv, io = {}) {
1056
1122
  },
1057
1123
  theme: (await readSettings(env)).theme,
1058
1124
  browse: io.browse ?? openBrowser,
1125
+ ...(command === "bridge" ? { startIn: "bridge" } : {}),
1059
1126
  });
1060
1127
  return 0;
1061
1128
  }
@@ -1301,6 +1368,12 @@ export async function run(argv, io = {}) {
1301
1368
  `⚓ Saved on ${result.project.name}. It's on the Forecast tab under Open dry dock; --apply makes it real.`,
1302
1369
  ),
1303
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
+ );
1304
1377
  else if (result.changes.length)
1305
1378
  out(
1306
1379
  "Nothing was changed. Add --apply to make these changes, or --save to keep them to show someone.",
@@ -1511,7 +1584,7 @@ export async function run(argv, io = {}) {
1511
1584
  }
1512
1585
  case "project": {
1513
1586
  const { positionals, values, harbor } = parse(
1514
- spec([], ["program", "lead", "starts", "ends", "budget"]),
1587
+ spec([], ["program", "lead", "starts", "ends", "budget", "from"]),
1515
1588
  );
1516
1589
  if (!values.program) throw new HarborError("Say which program: --program NAME.");
1517
1590
  let budget;
@@ -1531,9 +1604,18 @@ export async function run(argv, io = {}) {
1531
1604
  starts: dueDate(values.starts),
1532
1605
  ends: dueDate(values.ends),
1533
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}`;
1534
1618
  },
1535
- (r) =>
1536
- `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}".`,
1537
1619
  );
1538
1620
  }
1539
1621
  case "forecast": {
@@ -1798,6 +1880,75 @@ export async function run(argv, io = {}) {
1798
1880
  );
1799
1881
  return 0;
1800
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
+ }
1801
1952
  case "add": {
1802
1953
  const { positionals, values, harbor } = parse(
1803
1954
  spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
@@ -2234,11 +2385,7 @@ export async function run(argv, io = {}) {
2234
2385
  await stalePromptCache(env);
2235
2386
  return 0;
2236
2387
  }
2237
- return call(
2238
- "fog",
2239
- { harbor, project: values.project, limit: 100 },
2240
- (r) => fogText(r),
2241
- );
2388
+ return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
2242
2389
  }
2243
2390
  case "handover": {
2244
2391
  const { positionals, values, harbor } = parse(
@@ -2295,6 +2442,50 @@ export async function run(argv, io = {}) {
2295
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.`,
2296
2443
  );
2297
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
+ }
2298
2489
  case "handback": {
2299
2490
  const { positionals, harbor } = parse(spec());
2300
2491
  const person = positionals.join(" ").trim();
@@ -2329,6 +2520,7 @@ export async function run(argv, io = {}) {
2329
2520
  "decided-on",
2330
2521
  "turned-down",
2331
2522
  "look-again",
2523
+ "still-stands",
2332
2524
  ...fields,
2333
2525
  ],
2334
2526
  ),
@@ -2350,6 +2542,7 @@ export async function run(argv, io = {}) {
2350
2542
  revisitOn:
2351
2543
  values["look-again"] === undefined ? undefined : nullable(dueDate(values["look-again"])),
2352
2544
  update: values.update,
2545
+ stillStands: values["still-stands"],
2353
2546
  ...Object.fromEntries(fields.map((f) => [f, values[f]])),
2354
2547
  };
2355
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
- 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
 
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.52.0",
3
+ "version": "0.54.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",