@harborppm/cli 0.59.0 → 0.61.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
@@ -39,9 +39,12 @@ import {
39
39
  lifeboatsText,
40
40
  followedText,
41
41
  soundingsText,
42
+ thanksText,
42
43
  ashoreText,
43
44
  voyageText,
44
45
  whyText,
46
+ bridgeText,
47
+ barnaclesText,
45
48
  handoverText,
46
49
  fogText,
47
50
  agentWorkText,
@@ -97,6 +100,9 @@ export const HELP = `harbor: Harbor from the terminal
97
100
  changes without --apply
98
101
  harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of
99
102
  task waits that sets its date
103
+ harbor barnacles PROJECT work added since the plan was set:
104
+ by week, by who, and the days of
105
+ slip it explains
100
106
  harbor hill [--program P] where each project is on its hill
101
107
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
102
108
  out, past 50 is getting it done
@@ -184,10 +190,17 @@ export const HELP = `harbor: Harbor from the terminal
184
190
  the review: programs worst first,
185
191
  each with its next move (reads
186
192
  only; harbor submit moves a task)
193
+ harbor thanks [--all] [--given] thanks you were given this month
194
+ harbor thanks TASK "LINE" [--to NAME] a one-line thanks for a task
195
+ harbor thanks --milestone NAME|--decision TITLE "LINE" [--to NAME]
196
+ the same for a milestone or a
197
+ decision
187
198
  harbor bridge [--harbor H] run the review as a meeting: one
188
199
  project a screen, what changed
189
200
  since the last one, decisions,
190
201
  actions and risks logged live
202
+ harbor bridge --list [--program P] what changed since the last
203
+ review, as text (or --json)
191
204
  harbor next-move PROGRAM "TEXT" write a program's next move
192
205
  harbor health --program P|--project P|--portfolio P STATE [--note WHY]
193
206
  set its health by hand, or auto
@@ -235,6 +248,8 @@ export const HELP = `harbor: Harbor from the terminal
235
248
  harbor fog keep TASK it's still needed: out of the fog
236
249
  harbor watch [--project P] [--bell] a live ticker of the harbor's
237
250
  changes; yours bright, q stops
251
+ harbor watch --once [--since TIME] [--json] the latest changes (or all since
252
+ TIME), printed once, for scripts
238
253
  harbor lifeboats [--program P] work only one person knows, each
239
254
  with a second pair of hands
240
255
  harbor lifeboats N [--person NAME] adds that second pair of hands
@@ -813,10 +828,18 @@ const DETAIL = {
813
828
  " cards moving, comments, health changes",
814
829
  " --project P one project's changes only",
815
830
  " --bell ring the terminal's bell when something of yours changes",
831
+ " --since TIME start with every change since TIME (14:30 today, or",
832
+ " 2026-10-21, or 2026-10-21T14:30) instead of the last 5",
833
+ " --once print those and stop instead of listening",
834
+ " --json one JSON object a line (at, text, quote, mine, tone;",
835
+ " earlier: true for the ones from before it started)",
816
836
  "",
817
- " Lines about your own work (your tasks, mentions of you, what you lead) are",
818
- " bright; the rest are dim. q or Ctrl+C stops. Made for a narrow split or a",
819
- " tmux pane; it needs full access (harbor login without --read-only).",
837
+ " It starts with the latest few changes and a \"since HH:MM\" line, then",
838
+ " prints each new one as it lands. Lines about your own work (your tasks,",
839
+ " mentions of you, what you lead) are bright; the rest are dim. It runs",
840
+ " until q or Ctrl+C (or until it's killed, when piped or with --json);",
841
+ " --once returns at once. Made for a narrow split or a tmux pane; it needs",
842
+ " full access (harbor login without --read-only).",
820
843
  ],
821
844
  lifeboats: [
822
845
  " harbor lifeboats [--program P] reads: work only one person has",
@@ -873,9 +896,23 @@ const DETAIL = {
873
896
  " Tasks have due dates, not lengths, so a task can finish on its due date",
874
897
  " at the soonest, never before what it waits on, and never in the past.",
875
898
  " Each link's slack is the days it can slip before it moves the next one",
876
- " (or the end); a negative one already does. PROJECT is a name, a key",
899
+ " (or the end); a negative one already does. Open tasks with no due date",
900
+ " can't hold a date, so they're left out and counted. In --json, endDate",
901
+ " (dueDate for a task or milestone) is the date held and daysSpare the",
902
+ " days before it (negative: that many over). PROJECT is a name, a key",
877
903
  " like RR, or an id.",
878
904
  ],
905
+ barnacles: [
906
+ " harbor barnacles PROJECT reads how much the plan has grown",
907
+ "",
908
+ " Counts the tasks made after the baseline was set (without a baseline",
909
+ " time, after the project's first week of tasks), as a",
910
+ " share of what was planned, by week and by who added them. At the",
911
+ " crew's pace (tasks finished a week over the last six) it says how many",
912
+ " days of work that is, and how much of the slip against the planned end",
913
+ " it explains. The weekly brief names projects that grew a lot since",
914
+ " their baseline.",
915
+ ],
879
916
  voyage: [
880
917
  " harbor voyage PROJECT reads a Done project's voyage log",
881
918
  ' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
@@ -909,19 +946,40 @@ const DETAIL = {
909
946
  " enough of it to pick one out; harbor risks lists them. Changes several",
910
947
  " at once, as one change.",
911
948
  ],
949
+ thanks: [
950
+ " harbor thanks reads the thanks you were given in",
951
+ " the last 30 days, newest first",
952
+ " harbor thanks --all everyone's; --given: the ones you gave",
953
+ ' harbor thanks RR-42 "LINE" writes: thanks whoever has the task',
954
+ ' harbor thanks --milestone NAME "LINE" writes: thanks for a milestone (its',
955
+ " sign-off person unless --to says)",
956
+ ' harbor thanks --decision TITLE "LINE" writes: thanks whoever made the call',
957
+ " --to NAME someone else instead",
958
+ "",
959
+ " Fair winds: a thanks shows on the work, on the person's card on People,",
960
+ " in the next brief and in the project's voyage log. Nobody can thank",
961
+ " themselves, and nothing counts or ranks them.",
962
+ ],
912
963
  bridge: [
913
964
  " harbor bridge opens the full-screen app on the",
914
965
  " review meeting (b on the Programs",
915
966
  " screen, or : Run the review meeting)",
967
+ " harbor bridge --list [--program P] prints the agenda instead: what",
968
+ " changed on each project since its",
969
+ " program's last review, every item",
970
+ " (--json for scripts and agents; the",
971
+ " same when piped). Reading it doesn't",
972
+ " count as holding the meeting",
916
973
  "",
917
974
  " One open project a screen, with only what changed since its program was",
918
975
  " last reviewed (a week back the first time): health moves, work finished,",
919
976
  " newly late or added, risks raised, decisions made. ←→ move between",
920
977
  " projects; d logs a decision, a an action (who takes it, due in a week),",
921
- " r a risk, m the program's next move; e ends it. The summary lists what",
922
- " was logged; p posts it to each program's chat channel where one is set",
923
- " (Ship to shore, set on the website), y copies it. The next meeting",
924
- " counts from when this one ended.",
978
+ " r a risk, m the program's next move, v shows every item behind an",
979
+ " \"and N more\" (↑↓ scroll); e ends it, Esc or q leaves without ending",
980
+ " it. The summary lists what was logged; p posts it to each program's",
981
+ " chat channel where one is set (Ship to shore, set on the website), y",
982
+ " copies it. The next meeting counts from when this one ended.",
925
983
  ],
926
984
  review: [
927
985
  " harbor review reads the review: every program,",
@@ -1146,8 +1204,14 @@ export async function run(argv, io = {}) {
1146
1204
  }
1147
1205
 
1148
1206
  // On its own in a terminal: the full-screen app. Piped or scripted: the help.
1149
- if (!command || command === "app" || command === "bridge") {
1150
- const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
1207
+ const inTerminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
1208
+ // harbor bridge --list (or --json, or piped) prints the agenda through the commands below.
1209
+ const bridgeApp =
1210
+ command === "bridge" &&
1211
+ inTerminal &&
1212
+ !rest.some((a) => a === "--list" || a === "--json" || a.startsWith("--program"));
1213
+ if (!command || command === "app" || bridgeApp) {
1214
+ const terminal = inTerminal;
1151
1215
  if (!command && !terminal) {
1152
1216
  out(HELP);
1153
1217
  return 0;
@@ -2624,6 +2688,12 @@ export async function run(argv, io = {}) {
2624
2688
  if (!of) throw new HarborError("Say what: a project, a task key like RR-42, or a milestone.");
2625
2689
  return call("long_pole", { harbor, of }, whyText);
2626
2690
  }
2691
+ case "barnacles": {
2692
+ const { positionals, harbor } = parse(spec());
2693
+ const project = positionals.join(" ").trim();
2694
+ if (!project) throw new HarborError("Say which project: harbor barnacles PROJECT.");
2695
+ return call("scope_growth", { harbor, project }, barnaclesText);
2696
+ }
2627
2697
  case "voyage": {
2628
2698
  const { positionals, harbor } = parse(spec());
2629
2699
  const [project, ...words] = positionals;
@@ -2639,13 +2709,18 @@ export async function run(argv, io = {}) {
2639
2709
  return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
2640
2710
  }
2641
2711
  case "watch": {
2642
- const { positionals, values, harbor } = parse(spec(["bell"], ["project"]));
2712
+ const { positionals, values, harbor } = parse(spec(["bell", "once"], ["project", "since"]));
2643
2713
  none(positionals);
2644
2714
  if (!hasFullAccess(creds))
2645
2715
  throw new HarborError(
2646
2716
  "Watching needs full access, as the full-screen app does. Run harbor login (without --read-only), then try again.",
2647
2717
  );
2648
- const { watchHere } = await loadApp();
2718
+ const { watchHere, watchSince } = await loadApp();
2719
+ const since = values.since ? watchSince(values.since, new Date()) : null;
2720
+ if (values.since && !since)
2721
+ throw new HarborError(
2722
+ `--since takes a time today (14:30) or a date (2026-10-21, or 2026-10-21T14:30), not "${values.since}".`,
2723
+ );
2649
2724
  await watchHere({
2650
2725
  source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
2651
2726
  fetcher,
@@ -2653,7 +2728,10 @@ export async function run(argv, io = {}) {
2653
2728
  saved: creds.harbor ?? null,
2654
2729
  project: values.project,
2655
2730
  bell: Boolean(values.bell),
2656
- colour: colourDepth(io.stdout ?? process.stdout, env) !== "none",
2731
+ colour: !asJson && colourDepth(io.stdout ?? process.stdout, env) !== "none",
2732
+ since,
2733
+ once: Boolean(values.once),
2734
+ json: asJson,
2657
2735
  });
2658
2736
  return 0;
2659
2737
  }
@@ -2692,6 +2770,49 @@ export async function run(argv, io = {}) {
2692
2770
  }
2693
2771
  return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
2694
2772
  }
2773
+ case "bridge": {
2774
+ const { positionals, values, harbor } = parse(spec(["list"], ["program"]));
2775
+ none(positionals);
2776
+ return call("review_meeting", { harbor, program: values.program }, bridgeText);
2777
+ }
2778
+ case "thanks": {
2779
+ const { positionals, values, harbor } = parse(
2780
+ spec(["all", "given"], ["to", "milestone", "decision"]),
2781
+ );
2782
+ const forWork = values.milestone || values.decision;
2783
+ if (!forWork && positionals.length === 0) {
2784
+ return call(
2785
+ "list_thanks",
2786
+ {
2787
+ harbor,
2788
+ person: values.all ? "all" : "me",
2789
+ given: Boolean(values.given),
2790
+ },
2791
+ thanksText,
2792
+ );
2793
+ }
2794
+ const [first, ...rest] = positionals;
2795
+ const task = forWork ? undefined : first;
2796
+ const line = (forWork ? positionals : rest).join(" ").trim();
2797
+ if (task && !TASK_KEY.test(task) && !line) {
2798
+ throw new HarborError(
2799
+ 'Say what for and the line: harbor thanks RR-42 "Caught it before launch" (or --milestone NAME, --decision TITLE).',
2800
+ );
2801
+ }
2802
+ if (!line) throw new HarborError('Add the line of thanks in quotes: harbor thanks RR-42 "Thank you".');
2803
+ return call(
2804
+ "give_thanks",
2805
+ {
2806
+ harbor,
2807
+ task,
2808
+ milestone: values.milestone,
2809
+ decision: values.decision,
2810
+ to: values.to,
2811
+ line,
2812
+ },
2813
+ (r) => `Fair winds: thanked ${r.thanked.to ?? "them"} for ${r.thanked.for}.`,
2814
+ );
2815
+ }
2695
2816
  case "handover": {
2696
2817
  const { positionals, values, harbor } = parse(
2697
2818
  spec(["confirm"], ["to", "until", "note", "keep"]),
package/lib/format.mjs CHANGED
@@ -526,15 +526,66 @@ export function premortemText(r, project = r.project) {
526
526
  }
527
527
 
528
528
  /** A project's voyage log, as get_voyage_log answers it. */
529
+ /** `harbor thanks`: each thanks on two lines, the words first, no counts. */
530
+ export function thanksText(r) {
531
+ if (r.note) return r.note;
532
+ if (!r.thanks.length) {
533
+ return r.person
534
+ ? `No thanks in the last ${r.days} days yet. harbor thanks TASK "LINE" gives one.`
535
+ : `Nobody has thanked anyone in the last ${r.days} days. harbor thanks TASK "LINE" gives one.`;
536
+ }
537
+ return r.thanks
538
+ .map((t) => `“${t.line}”\n ${t.from} to ${t.to}, for ${t.for} · ${t.at.slice(0, 10)}`)
539
+ .join("\n");
540
+ }
541
+
529
542
  export function voyageText(r) {
530
543
  const head = [`Voyage log · ${r.project}${r.done ? ` · Done ${r.finished}` : " · not Done yet"}`];
531
544
  if (r.note) head.push(r.note);
532
545
  head.push((r.stats ?? []).join(" · "));
533
546
  const story = (r.story ?? []).map((s) => ` - ${s}`);
534
547
  const crew = (r.crew ?? []).map((c) => ` - ${c.who ?? "Someone"}: ${c.line}`);
535
- return [...head, "", ...story, ...(crew.length ? ["", "From the crew:", ...crew] : [])].join(
536
- "\n",
548
+ const thanks = (r.thanks ?? []).map(
549
+ (t) => ` - ${t.from ?? "Someone"} to ${t.to ?? "someone"}: “${t.line}”`,
550
+ );
551
+ return [
552
+ ...head,
553
+ "",
554
+ ...story,
555
+ ...(thanks.length ? ["", "Fair winds along the way:", ...thanks] : []),
556
+ ...(crew.length ? ["", "From the crew:", ...crew] : []),
557
+ ].join("\n");
558
+ }
559
+
560
+ /** The review meeting's agenda, as review_meeting answers it: each project that moved, with every item. */
561
+ export function bridgeText(r) {
562
+ const programs = r.programs ?? [];
563
+ if (programs.length === 0) return "No open programs to review.";
564
+ const out = programs.map(
565
+ (p) =>
566
+ `${p.name}: changes since ${p.lastReviewed ? "the last review" : "last week"}, ${p.since.slice(0, 10)}`,
537
567
  );
568
+ for (const p of r.projects ?? []) {
569
+ const facts = [
570
+ p.health,
571
+ p.lead,
572
+ p.ends ? `ends ${p.ends}` : null,
573
+ `${p.late} late of ${p.open} open`,
574
+ p.highRisks ? `${p.highRisks} high risk${p.highRisks === 1 ? "" : "s"}` : null,
575
+ p.nextMilestone ? `next milestone ${p.nextMilestone.name}, ${p.nextMilestone.due}` : null,
576
+ ].filter(Boolean);
577
+ out.push("", `${p.project} (${p.program})`, ` ${facts.join(" · ")}`);
578
+ for (const c of p.changes) {
579
+ if (c.items) {
580
+ out.push(` - ${c.text.split(":")[0]} (${c.items.length}):`);
581
+ for (const item of c.items) out.push(` ${item}`);
582
+ } else out.push(` - ${c.text}`);
583
+ }
584
+ }
585
+ if ((r.projects ?? []).length === 0) out.push("", "Nothing moved since the last review.");
586
+ const quiet = (r.quiet ?? []).map((q) => q.project);
587
+ if (quiet.length) out.push("", `No change on ${quiet.join(", ")}.`);
588
+ return out.join("\n");
538
589
  }
539
590
 
540
591
  /** What's holding something up, as long_pole answers it: the headline, then the line of waits, first blocker at the top. */
@@ -571,7 +622,29 @@ export function whyText(r) {
571
622
  .join(", ")}.`,
572
623
  ]
573
624
  : [];
574
- return [...head, "", ...via, ...lines, ...others].join("\n");
625
+ const note = r.note ? ["", r.note] : [];
626
+ return [...head, "", ...via, ...lines, ...others, ...note].join("\n");
627
+ }
628
+
629
+ /** A project's barnacles, as scope_growth answers it: the growth, its cost in days, by week and by who. */
630
+ export function barnaclesText(r) {
631
+ const head = [`Barnacles · ${r.project}`];
632
+ if (!r.added) {
633
+ head.push(`Nothing added since the plan was set (${r.since.slice(0, 10)}): ${r.planned} task${r.planned === 1 ? "" : "s"}, as planned.`);
634
+ return head.join("\n");
635
+ }
636
+ head.push(`${r.headline} (${r.planned} planned, ${r.addedThisWeek} added this week).`);
637
+ if (r.daysLine) head.push(r.daysLine);
638
+ const top = Math.max(1, ...r.byWeek.map((w) => w.added));
639
+ const weeks = r.byWeek.map(
640
+ (w) => ` w/c ${w.weekOf} ${"█".repeat(Math.max(1, Math.round((w.added / top) * 20)))} ${w.added}`,
641
+ );
642
+ const who = r.byWho.map((w) => `${w.who ?? "not recorded"} ${w.added}`).join(", ");
643
+ const from =
644
+ r.measuredFrom === "when the baseline was set"
645
+ ? `Counted from when the baseline was set, ${r.since.slice(0, 10)}.`
646
+ : `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; setting a baseline counts from then instead.`;
647
+ return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
575
648
  }
576
649
 
577
650
  /** A position on a 21-wide track, the top of the hill marked in the middle. */
package/lib/mcp.mjs CHANGED
@@ -53,8 +53,15 @@ export function createClient({ creds, save, fetcher = fetch, now = Date.now }) {
53
53
  const msg = await res.json();
54
54
  if (msg.error) throw new HarborError(msg.error.message);
55
55
  const result = msg.result ?? {};
56
- if (result.isError)
57
- throw new HarborError(result.content?.map((c) => c.text).join("\n") || "Harbor said no.");
56
+ if (result.isError) {
57
+ const said = result.content?.map((c) => c.text).join("\n") || "Harbor said no.";
58
+ // A command newer than the site it talks to: the tool ships with the next deploy.
59
+ if (/^No tool named "/.test(said))
60
+ throw new HarborError(
61
+ `${new URL(current.server).host} doesn't have this yet. Try again after the next site update.`,
62
+ );
63
+ throw new HarborError(said);
64
+ }
58
65
  return result.structuredContent ?? JSON.parse(result.content?.[0]?.text ?? "{}");
59
66
  },
60
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.59.0",
3
+ "version": "0.61.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",