@harborppm/cli 0.43.0 → 0.45.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
@@ -36,7 +36,9 @@ import {
36
36
  voyageText,
37
37
  handoverText,
38
38
  agentWorkText,
39
+ raidDetail,
39
40
  raidList,
41
+ reviewList,
40
42
  programTree,
41
43
  projectList,
42
44
  taskLine,
@@ -113,7 +115,9 @@ export const HELP = `harbor: Harbor from the terminal
113
115
  harbor add "TITLE" --project P [--assignee me|none|NAME] [--due DATE]
114
116
  [--priority low|medium|high|urgent] [--status S]
115
117
  [--over-limit]
116
- harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
118
+ harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
119
+ move a task to In play, In review
120
+ or Done
117
121
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
118
122
  [--priority P] [--status S] [--notes TEXT]
119
123
  [--repeat weekly|biweekly|monthly|never]
@@ -130,6 +134,20 @@ export const HELP = `harbor: Harbor from the terminal
130
134
  harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
131
135
  the register, worst first
132
136
  (--closed adds closed ones)
137
+ harbor raid "TITLE" [--status S] [--owner me|none|NAME] [--impact 1-5]
138
+ [--probability 1-5] [--due DATE|none] [--update TEXT]
139
+ [--title T] [--description T] [--mitigation T]
140
+ [--contingency T] [--resolution T] [--rationale T]
141
+ [--notes T]
142
+ one risk, issue or decision in
143
+ full, or change it
144
+ harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
145
+ the review: programs worst first,
146
+ each with its next move (reads
147
+ only; harbor submit moves a task)
148
+ harbor next-move PROGRAM "TEXT" write a program's next move
149
+ harbor health --program P|--project P|--portfolio P STATE [--note WHY]
150
+ set its health by hand, or auto
133
151
  harbor brief [--portfolio P] [--all] [--say]
134
152
  the weekly brief, ready to send
135
153
  (--all lists every late and done
@@ -320,6 +338,11 @@ const FLAG = {
320
338
  query: "the words",
321
339
  task: "the task",
322
340
  note: "the check-in",
341
+ health: "the health",
342
+ decidedBy: "--decided-by",
343
+ decidedOn: "--decided-on",
344
+ item: "the title",
345
+ text: "the next move",
323
346
  waitsOn: "--waits-on",
324
347
  repeat: "--repeat",
325
348
  description: "--notes",
@@ -609,6 +632,59 @@ const DETAIL = {
609
632
  " PROJECT is a name, a key like RR, or an id. When two projects share a",
610
633
  " name, give the key.",
611
634
  ],
635
+ raid: [
636
+ ' harbor raid "TITLE" reads one risk, issue or decision in',
637
+ " full: rating, owner, dates, what's",
638
+ " written about it and its updates",
639
+ ' harbor raid "TITLE" --status S writes: moves it on. A risk is open,',
640
+ " watching, mitigated or closed; an",
641
+ " issue open, resolved or closed; a",
642
+ " decision proposed, decided or",
643
+ " superseded (decided names you and",
644
+ " today unless --decided-by or",
645
+ " --decided-on say otherwise)",
646
+ ' harbor raid "TITLE" --update "TEXT" writes: adds a line to its updates',
647
+ "",
648
+ " Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
649
+ " --due DATE|none (review by, or decide by), --title, --description,",
650
+ " --mitigation and --contingency (risks), --resolution (issues and",
651
+ " decisions), --rationale (decisions) and --notes. TITLE is its title or",
652
+ " enough of it to pick one out; harbor risks lists them. Changes several",
653
+ " at once, as one change.",
654
+ ],
655
+ review: [
656
+ " harbor review reads the review: every program,",
657
+ " worst health first, then soonest end,",
658
+ " with its lead, late work, priority and",
659
+ " next move (done ones with --done)",
660
+ " harbor review --mine only programs you lead, lead a",
661
+ " project in or hold a task in",
662
+ " harbor review --owner NAME the same for someone else",
663
+ " harbor review --portfolio P one portfolio's",
664
+ ' harbor review "WORDS" only programs whose name has them',
665
+ "",
666
+ " The review only reads. To move a task to In review, run harbor submit",
667
+ " TASK (harbor review TASK did that before 0.45.0, and now says so).",
668
+ ' Write a next move with harbor next-move PROGRAM "TEXT"; set a health by',
669
+ " hand with harbor health. harbor export review prints it as CSV.",
670
+ ],
671
+ "next-move": [
672
+ ' harbor next-move PROGRAM "TEXT" writes: the line the review and the',
673
+ " brief show for what happens next",
674
+ ' harbor next-move PROGRAM "" writes: clears it',
675
+ ],
676
+ health: [
677
+ " harbor health --program P STATE --note WHY",
678
+ " writes: sets a program's health by hand",
679
+ " harbor health --project P STATE --note WHY",
680
+ " harbor health --portfolio P STATE --note WHY",
681
+ " harbor health --program P auto writes: health follows late work again",
682
+ "",
683
+ " STATE is on-track, at-risk, off-track, paused or done. Health is worked",
684
+ " out from late work; setting it by hand needs a note saying why (done",
685
+ " doesn't), and the note shows beside it everywhere. Paused or done on a",
686
+ " program carries to its projects.",
687
+ ],
612
688
  risk: [
613
689
  ' harbor risk "TITLE" --project P writes: logs a risk on the project',
614
690
  ' harbor risk "TITLE" --program P writes: logs one on the program',
@@ -718,6 +794,13 @@ async function harborNamed(client, key) {
718
794
 
719
795
  const TASK_KEY = /^[A-Za-z][A-Za-z0-9]{0,9}-\d+$/;
720
796
 
797
+ /** True when every word of `words` is somewhere in `name`, any case. */
798
+ const nameHas = (name, words) =>
799
+ words
800
+ .toLowerCase()
801
+ .split(/\s+/)
802
+ .every((w) => name.toLowerCase().includes(w));
803
+
721
804
  /**
722
805
  * A task by its key, else a project by name, else a task by id: its web
723
806
  * link and what to call it. Harbor's own refusal when nothing matches.
@@ -848,6 +931,7 @@ export async function run(argv, io = {}) {
848
931
  await writeCredentials({ ...now, harbor: h.id, harborName: h.name }, env);
849
932
  },
850
933
  theme: (await readSettings(env)).theme,
934
+ browse: io.browse ?? openBrowser,
851
935
  });
852
936
  return 0;
853
937
  }
@@ -1510,10 +1594,42 @@ export async function run(argv, io = {}) {
1510
1594
  );
1511
1595
  }
1512
1596
  case "start":
1597
+ case "submit":
1513
1598
  case "review":
1514
1599
  case "done": {
1515
- const { positionals, values, harbor } = parse(spec(["confirm", "over-limit"]));
1516
- const status = { start: "doing", review: "review", done: "done" }[command];
1600
+ const { positionals, values, harbor } = parse(
1601
+ command === "review"
1602
+ ? spec(["confirm", "over-limit", "mine", "done"], ["owner", "portfolio"])
1603
+ : spec(["confirm", "over-limit"]),
1604
+ );
1605
+ if (command === "review") {
1606
+ // The review only reads: a task key here once moved the task, so it says where that went.
1607
+ const words = positionals.join(" ").trim();
1608
+ if (TASK_KEY.test(words)) {
1609
+ throw new HarborError(
1610
+ `harbor review only reads the review now. To move ${words} to In review, run harbor submit ${words}.`,
1611
+ );
1612
+ }
1613
+ if (values.confirm || values["over-limit"]) {
1614
+ throw new HarborError("--confirm and --over-limit are for moving a task: harbor submit TASK.");
1615
+ }
1616
+ if (values.mine && values.owner) throw new HarborError("Say --mine or --owner, not both.");
1617
+ return call(
1618
+ "program_review",
1619
+ {
1620
+ harbor,
1621
+ owner: values.mine ? "me" : values.owner,
1622
+ portfolio: values.portfolio,
1623
+ includeDone: Boolean(values.done),
1624
+ },
1625
+ (r) =>
1626
+ words && r.programs.length === 0
1627
+ ? `No program in the review matches "${words}".`
1628
+ : reviewList(r),
1629
+ (r) => (words ? { ...r, programs: r.programs.filter((p) => nameHas(p.name, words)) } : r),
1630
+ );
1631
+ }
1632
+ const status = { start: "doing", submit: "review", done: "done" }[command];
1517
1633
  return call(
1518
1634
  "update_task",
1519
1635
  {
@@ -1866,6 +1982,99 @@ export async function run(argv, io = {}) {
1866
1982
  : `${r.handedBack.person} is back. Nothing was still waiting to go back.`,
1867
1983
  );
1868
1984
  }
1985
+ case "raid": {
1986
+ const fields = [
1987
+ "title",
1988
+ "description",
1989
+ "mitigation",
1990
+ "contingency",
1991
+ "resolution",
1992
+ "rationale",
1993
+ "notes",
1994
+ ];
1995
+ const { positionals, values, harbor } = parse(
1996
+ spec(
1997
+ [],
1998
+ [
1999
+ "status",
2000
+ "owner",
2001
+ "impact",
2002
+ "probability",
2003
+ "due",
2004
+ "update",
2005
+ "decided-by",
2006
+ "decided-on",
2007
+ ...fields,
2008
+ ],
2009
+ ),
2010
+ );
2011
+ const item = text(positionals, "which one: its title, as harbor risks lists it");
2012
+ const scale = (v, name) => (v === "none" ? null : level(v, name));
2013
+ const args = {
2014
+ harbor,
2015
+ item,
2016
+ status: values.status?.trim().toLowerCase(),
2017
+ owner: values.owner,
2018
+ impact: scale(values.impact, "impact"),
2019
+ probability: scale(values.probability, "probability"),
2020
+ due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
2021
+ decidedBy: values["decided-by"],
2022
+ decidedOn:
2023
+ values["decided-on"] === undefined ? undefined : nullable(dueDate(values["decided-on"])),
2024
+ update: values.update,
2025
+ ...Object.fromEntries(fields.map((f) => [f, values[f]])),
2026
+ };
2027
+ const changes = Object.entries(args).filter(
2028
+ ([k, v]) => !["harbor", "item"].includes(k) && v !== undefined,
2029
+ );
2030
+ if (!changes.length) {
2031
+ // --json gives the item itself, as harbor show does a task.
2032
+ return call("get_raid", { harbor, item }, raidDetail, (r) => r.item ?? r);
2033
+ }
2034
+ return call("update_raid", args, (r) => `Updated.\n\n${raidDetail(r.updated)}`);
2035
+ }
2036
+ case "next-move": {
2037
+ const { positionals, harbor } = parse(spec([]));
2038
+ if (positionals.length < 2) {
2039
+ throw new HarborError(
2040
+ 'Give the program and its next move: harbor next-move "PROGRAM" "TEXT". harbor review shows them.',
2041
+ );
2042
+ }
2043
+ const [program, ...words] = positionals;
2044
+ return call("set_next_move", { harbor, program, text: words.join(" ") }, (r) =>
2045
+ r.nextMove
2046
+ ? `Next move for ${r.program}: ${r.nextMove}`
2047
+ : `Cleared the next move for ${r.program}.`,
2048
+ );
2049
+ }
2050
+ case "health": {
2051
+ const { positionals, values, harbor } = parse(
2052
+ spec([], ["program", "project", "portfolio", "note"]),
2053
+ );
2054
+ const state = one(positionals, "health: on-track, at-risk, off-track, paused, done or auto")
2055
+ .trim()
2056
+ .toLowerCase()
2057
+ .replace(/[\s_]+/g, "-");
2058
+ const word = { "on-track": "on track", "at-risk": "at risk", "off-track": "off track" };
2059
+ return call(
2060
+ "set_health",
2061
+ {
2062
+ harbor,
2063
+ program: values.program,
2064
+ project: values.project,
2065
+ portfolio: values.portfolio,
2066
+ health: state,
2067
+ note: values.note,
2068
+ },
2069
+ (r) => {
2070
+ const name = r.program ?? r.project ?? r.portfolio;
2071
+ const h = word[r.health] ?? r.health;
2072
+ return r.setByHand
2073
+ ? `${name} is ${h}, set by hand${r.note ? `: ${r.note}` : ""}.`
2074
+ : `${name}'s health follows its work again: ${h}.`;
2075
+ },
2076
+ );
2077
+ }
1869
2078
  case "risk":
1870
2079
  case "issue":
1871
2080
  case "decision": {
package/lib/format.mjs CHANGED
@@ -270,6 +270,76 @@ export function raidList(r, words = "") {
270
270
  return lines.join("\n") + more;
271
271
  }
272
272
 
273
+ /**
274
+ * One item of the register in full, as get_raid and update_raid give it:
275
+ * what it is and where, its rating, every written field, then its updates.
276
+ */
277
+ export function raidDetail(x) {
278
+ const out = [`${x.kind}: ${x.title}`];
279
+ const facts = [
280
+ x.status,
281
+ x.probability != null ? `likelihood ${x.probability}` : null,
282
+ x.impact != null ? `impact ${x.impact}` : null,
283
+ x.score != null ? `score ${x.score}` : null,
284
+ x.owner ? `owner ${x.owner}` : "no owner yet",
285
+ x.project ?? x.program ?? x.portfolio ?? null,
286
+ x.due ? `${(x.dueMeans ?? "due").toLowerCase()} ${x.due}` : null,
287
+ x.decidedBy ? `decided by ${x.decidedBy}${x.decidedOn ? ` on ${x.decidedOn}` : ""}` : null,
288
+ ].filter(Boolean);
289
+ out.push(facts.join(" · "));
290
+ const words = {
291
+ risk: {
292
+ description: "What could happen",
293
+ mitigation: "How we reduce it",
294
+ contingency: "If it happens",
295
+ },
296
+ issue: { description: "What's going on", resolution: "How it gets resolved" },
297
+ decision: { description: "Context", resolution: "The decision", rationale: "Why" },
298
+ }[x.kind?.toLowerCase()] ?? { description: "Description" };
299
+ for (const [key, label] of Object.entries({ ...words, notes: "Notes" })) {
300
+ if (x[key])
301
+ out.push(
302
+ "",
303
+ `${label}:`,
304
+ ...String(x[key])
305
+ .split("\n")
306
+ .map((l) => ` ${l}`),
307
+ );
308
+ }
309
+ if (x.updates?.length) {
310
+ out.push("", "Updates:");
311
+ for (const u of x.updates) out.push(` ${u.on ?? ""} ${u.by}: ${u.body.replace(/\s+/g, " ")}`);
312
+ }
313
+ return out.join("\n");
314
+ }
315
+
316
+ /**
317
+ * The review, as program_review gives it: programs worst first, each with
318
+ * its lead, end, late work and priority, and the next move under it.
319
+ */
320
+ export function reviewList(r) {
321
+ const programs = r.programs ?? [];
322
+ const more = hiddenNote(r.done ?? 0, "program");
323
+ if (!programs.length) {
324
+ return r.done ? `Every program here is done.${more.replace("\n", " ")}` : "No programs here.";
325
+ }
326
+ const out = [];
327
+ for (const p of programs) {
328
+ const said = health(p.health) + (p.setByHand ? ` by hand${p.note ? `: ${p.note}` : ""}` : "");
329
+ const bits = [
330
+ said,
331
+ p.lead,
332
+ p.ends ? `ends ${p.ends}` : null,
333
+ p.late ? `${p.late} late` : null,
334
+ p.priority ?? null,
335
+ p.portfolio ?? null,
336
+ ].filter(Boolean);
337
+ out.push(`${p.name} (${bits.join(" · ")})`);
338
+ out.push(` ${p.nextMove ? `next: ${p.nextMove}` : "no next move yet"}`);
339
+ }
340
+ return out.join("\n") + more;
341
+ }
342
+
273
343
  /** "over 95%", in steps of five, as the website says it (src/lib/tide.ts chanceText). */
274
344
  function chance(c) {
275
345
  if (c < 0.05) return "under 5%";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.43.0",
3
+ "version": "0.45.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",