@harborppm/cli 0.21.0 → 0.24.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
@@ -17,6 +17,8 @@ import {
17
17
  writeSettings,
18
18
  } from "./config.mjs";
19
19
  import {
20
+ forecastList,
21
+ forecastOne,
20
22
  STATUS,
21
23
  harbors,
22
24
  limitsText,
@@ -61,6 +63,7 @@ export const HELP = `harbor: Harbor from the terminal
61
63
  harbor projects [words] [--program P] [--done]
62
64
  projects with health and open work
63
65
  (--json adds each board's limits)
66
+ harbor forecast [PROJECT] [--program P] when projects will likely finish
64
67
  harbor mine what's on you, late first
65
68
  harbor tasks [words] [--project P] [--assignee me|none|NAME]
66
69
  [--late] [--status S,S]
@@ -97,6 +100,7 @@ export const HELP = `harbor: Harbor from the terminal
97
100
  harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
98
101
  the register, worst first
99
102
  (--closed adds closed ones)
103
+ harbor brief [--portfolio P] the weekly brief, ready to send
100
104
  harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
101
105
  a project's pre-mortem: start,
102
106
  answer or read it
@@ -824,6 +828,12 @@ export async function run(argv, io = {}) {
824
828
  `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}".`,
825
829
  );
826
830
  }
831
+ case "forecast": {
832
+ const { positionals, values, harbor } = parse(spec([], ["program"]));
833
+ const project = positionals.join(" ").trim();
834
+ if (project) return call("get_project", { harbor, project }, forecastOne);
835
+ return call("list_projects", { harbor, program: values.program, limit: 100 }, forecastList);
836
+ }
827
837
  case "mine": {
828
838
  const { positionals, harbor } = parse(spec());
829
839
  none(positionals);
@@ -1107,6 +1117,11 @@ export async function run(argv, io = {}) {
1107
1117
  (r) => raidList(r, positionals.join(" ")),
1108
1118
  );
1109
1119
  }
1120
+ case "brief": {
1121
+ const { positionals, values, harbor } = parse(spec([], ["portfolio"]));
1122
+ none(positionals);
1123
+ return call("weekly_brief", { harbor, portfolio: values.portfolio }, (r) => r.text);
1124
+ }
1110
1125
  case "premortem": {
1111
1126
  const { positionals, values, harbor } = parse(spec(["start"]));
1112
1127
  const [project, ...words] = positionals;
package/lib/format.mjs CHANGED
@@ -199,6 +199,55 @@ export function raidList(r, words = "") {
199
199
  return lines.join("\n") + more;
200
200
  }
201
201
 
202
+ /** "over 95%", in steps of five, as the website says it (src/lib/tide.ts chanceText). */
203
+ function chance(c) {
204
+ if (c < 0.05) return "under 5%";
205
+ if (c > 0.95) return "over 95%";
206
+ return `${Math.round(c * 20) * 5}%`;
207
+ }
208
+
209
+ function finishRange(f) {
210
+ if (!f.likely) return "not in the next two years at this pace";
211
+ return `probably ${f.likely}${f.veryLikely ? `, very likely by ${f.veryLikely}` : ""}`;
212
+ }
213
+
214
+ /** When each open project will likely finish at its crew's recent pace. */
215
+ export function forecastList(r) {
216
+ const projects = (r.projects ?? []).filter((p) => p.health !== "done");
217
+ if (!projects.length) return "No open projects match.";
218
+ const width = Math.min(32, Math.max(...projects.map((p) => p.name.length)));
219
+ return projects
220
+ .map((p) => {
221
+ const f = p.finish ?? {};
222
+ const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
223
+ if (f.none) return `${name} no forecast: ${f.none.replace(/^\w/, (c) => c.toLowerCase())}`;
224
+ const end = p.ends ? ` · ${chance(f.chanceByEnd)} by its end, ${p.ends}` : "";
225
+ return `${name} ${finishRange(f)}${end}${f.rough ? " · rough" : ""}`;
226
+ })
227
+ .join("\n");
228
+ }
229
+
230
+ /** One project's forecast, in a few sentences. */
231
+ export function forecastOne(p) {
232
+ const f = p.finish ?? {};
233
+ if (f.none) return `${p.name}: ${f.none}`;
234
+ const range = finishRange(f);
235
+ const lines = [
236
+ f.likely
237
+ ? `${p.name} will ${range.replace(/^probably/, "probably finish")}${f.almostCertain ? `, almost certainly by ${f.almostCertain}` : ""}.`
238
+ : `${p.name} won't finish in the next two years at this pace.`,
239
+ ];
240
+ if (p.ends) {
241
+ const c = chance(f.chanceByEnd);
242
+ lines.push(`${c[0].toUpperCase()}${c.slice(1)} chance of making its end date, ${p.ends}.`);
243
+ }
244
+ lines.push(
245
+ `${f.openTasks} task${f.openTasks === 1 ? "" : "s"} open; the crew finishes about ${f.finishedPerWeek} a week (${f.basedOn}).`,
246
+ );
247
+ if (f.rough) lines.push("Only a few tasks have finished, so treat this as rough.");
248
+ return lines.join("\n");
249
+ }
250
+
202
251
  /** A project's pre-mortem, as get_premortem answers it. */
203
252
  export function premortemText(r) {
204
253
  if (!r.running) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.21.0",
3
+ "version": "0.24.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",