@harborppm/cli 0.60.0 → 0.62.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,11 @@ import {
39
39
  lifeboatsText,
40
40
  followedText,
41
41
  soundingsText,
42
+ thanksText,
42
43
  ashoreText,
43
44
  voyageText,
44
45
  whyText,
46
+ bridgeText,
45
47
  barnaclesText,
46
48
  handoverText,
47
49
  fogText,
@@ -143,10 +145,11 @@ export const HELP = `harbor: Harbor from the terminal
143
145
  project as that one really ran
144
146
  harbor add "TITLE" --project P [--assignee me|none|NAME]
145
147
  [--due DATE|pilot] [--priority low|medium|high|urgent]
146
- [--status S] [--comment TEXT] [--over-limit]
148
+ [--status S] [--comment TEXT] [--over-limit] [--dry-run]
147
149
  with no TITLE: one title a line
148
150
  from stdin; --due pilot takes the
149
151
  date the owner's queue suggests
152
+ (harbor help add)
150
153
  harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
151
154
  move a task to In play, In review
152
155
  or Done
@@ -161,9 +164,10 @@ export const HELP = `harbor: Harbor from the terminal
161
164
  your own week: tasks on days
162
165
  against your hours, only for
163
166
  you; --review: planned vs done
164
- harbor week TASK DAY [--hours N] put a task on a day: mon to fri
165
- harbor week TASK --off [DAY] take it off that day, or the week
167
+ harbor week TASK --day DAY [--hours N] put a task on a day: mon to fri
168
+ harbor week TASK --off [--day DAY] take it off that day, or the week
166
169
  harbor week --day-hours N the hours you work a day
170
+ (harbor help week)
167
171
  harbor comment TASK "TEXT"
168
172
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
169
173
  harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
@@ -188,10 +192,17 @@ export const HELP = `harbor: Harbor from the terminal
188
192
  the review: programs worst first,
189
193
  each with its next move (reads
190
194
  only; harbor submit moves a task)
195
+ harbor thanks [--all] [--given] thanks you were given this month
196
+ harbor thanks TASK "LINE" [--to NAME] a one-line thanks for a task
197
+ harbor thanks --milestone NAME|--decision TITLE "LINE" [--to NAME]
198
+ the same for a milestone or a
199
+ decision
191
200
  harbor bridge [--harbor H] run the review as a meeting: one
192
201
  project a screen, what changed
193
202
  since the last one, decisions,
194
203
  actions and risks logged live
204
+ harbor bridge --list [--program P] what changed since the last
205
+ review, as text (or --json)
195
206
  harbor next-move PROGRAM "TEXT" write a program's next move
196
207
  harbor health --program P|--project P|--portfolio P STATE [--note WHY]
197
208
  set its health by hand, or auto
@@ -239,6 +250,8 @@ export const HELP = `harbor: Harbor from the terminal
239
250
  harbor fog keep TASK it's still needed: out of the fog
240
251
  harbor watch [--project P] [--bell] a live ticker of the harbor's
241
252
  changes; yours bright, q stops
253
+ harbor watch --once [--since TIME] [--json] the latest changes (or all since
254
+ TIME), printed once, for scripts
242
255
  harbor lifeboats [--program P] work only one person knows, each
243
256
  with a second pair of hands
244
257
  harbor lifeboats N [--person NAME] adds that second pair of hands
@@ -544,6 +557,34 @@ export function voyageForTerminal(r, project = r.project) {
544
557
 
545
558
  /** Commands whose forms do different things: one line each, saying which read and which write. */
546
559
  const DETAIL = {
560
+ add: [
561
+ ' harbor add "TITLE" --project P writes: one task, yours unless',
562
+ " --assignee says otherwise",
563
+ " harbor add --project P < titles.txt writes: one task a line of stdin",
564
+ " harbor add --project P --dry-run reads: says what it would add, and",
565
+ " adds nothing",
566
+ "",
567
+ " --assignee me|none|NAME, --due DATE|pilot, --priority low|medium|high|urgent,",
568
+ " --status S, --comment TEXT and --over-limit apply to every task added, so",
569
+ " ten lines with --comment add the same comment to all ten. --due pilot",
570
+ " takes the date the owner's queue suggests. More than 100 lines is harbor",
571
+ " import's job.",
572
+ ],
573
+ week: [
574
+ " harbor week [--next|--last|--week DATE] reads: your week, the days and",
575
+ " their hours, and what's not on a",
576
+ " day yet",
577
+ " harbor week --review reads: what you planned against",
578
+ " what you finished",
579
+ " harbor week TASK --day DAY [--hours N] writes: puts the task on that day",
580
+ " (mon to fri), 2 hours unless --hours",
581
+ " harbor week TASK --off [--day DAY] writes: takes it off that day, or",
582
+ " off the week",
583
+ " harbor week --day-hours N writes: the hours you work a day",
584
+ "",
585
+ " Only you see your week. Tasks in review are left out of what's not on a",
586
+ " day yet, since they wait on someone else.",
587
+ ],
547
588
  init: [
548
589
  " harbor init [PROJECT] [--hook] writes: .harbor/ at the top of this",
549
590
  " repository, one file per task in",
@@ -817,10 +858,18 @@ const DETAIL = {
817
858
  " cards moving, comments, health changes",
818
859
  " --project P one project's changes only",
819
860
  " --bell ring the terminal's bell when something of yours changes",
861
+ " --since TIME start with every change since TIME (14:30 today, or",
862
+ " 2026-10-21, or 2026-10-21T14:30) instead of the last 5",
863
+ " --once print those and stop instead of listening",
864
+ " --json one JSON object a line (at, text, quote, mine, tone;",
865
+ " earlier: true for the ones from before it started)",
820
866
  "",
821
- " Lines about your own work (your tasks, mentions of you, what you lead) are",
822
- " bright; the rest are dim. q or Ctrl+C stops. Made for a narrow split or a",
823
- " tmux pane; it needs full access (harbor login without --read-only).",
867
+ " It starts with the latest few changes and a \"since HH:MM\" line, then",
868
+ " prints each new one as it lands. Lines about your own work (your tasks,",
869
+ " mentions of you, what you lead) are bright; the rest are dim. It runs",
870
+ " until q or Ctrl+C (or until it's killed, when piped or with --json);",
871
+ " --once returns at once. Made for a narrow split or a tmux pane; it needs",
872
+ " full access (harbor login without --read-only).",
824
873
  ],
825
874
  lifeboats: [
826
875
  " harbor lifeboats [--program P] reads: work only one person has",
@@ -927,19 +976,40 @@ const DETAIL = {
927
976
  " enough of it to pick one out; harbor risks lists them. Changes several",
928
977
  " at once, as one change.",
929
978
  ],
979
+ thanks: [
980
+ " harbor thanks reads the thanks you were given in",
981
+ " the last 30 days, newest first",
982
+ " harbor thanks --all everyone's; --given: the ones you gave",
983
+ ' harbor thanks RR-42 "LINE" writes: thanks whoever has the task',
984
+ ' harbor thanks --milestone NAME "LINE" writes: thanks for a milestone (its',
985
+ " sign-off person unless --to says)",
986
+ ' harbor thanks --decision TITLE "LINE" writes: thanks whoever made the call',
987
+ " --to NAME someone else instead",
988
+ "",
989
+ " Fair winds: a thanks shows on the work, on the person's card on People,",
990
+ " in the next brief and in the project's voyage log. Nobody can thank",
991
+ " themselves, and nothing counts or ranks them.",
992
+ ],
930
993
  bridge: [
931
994
  " harbor bridge opens the full-screen app on the",
932
995
  " review meeting (b on the Programs",
933
996
  " screen, or : Run the review meeting)",
997
+ " harbor bridge --list [--program P] prints the agenda instead: what",
998
+ " changed on each project since its",
999
+ " program's last review, every item",
1000
+ " (--json for scripts and agents; the",
1001
+ " same when piped). Reading it doesn't",
1002
+ " count as holding the meeting",
934
1003
  "",
935
1004
  " One open project a screen, with only what changed since its program was",
936
1005
  " last reviewed (a week back the first time): health moves, work finished,",
937
1006
  " newly late or added, risks raised, decisions made. ←→ move between",
938
1007
  " projects; d logs a decision, a an action (who takes it, due in a week),",
939
- " r a risk, m the program's next move; e ends it. The summary lists what",
940
- " was logged; p posts it to each program's chat channel where one is set",
941
- " (Ship to shore, set on the website), y copies it. The next meeting",
942
- " counts from when this one ended.",
1008
+ " r a risk, m the program's next move, v shows every item behind an",
1009
+ " \"and N more\" (↑↓ scroll); e ends it, Esc or q leaves without ending",
1010
+ " it. The summary lists what was logged; p posts it to each program's",
1011
+ " chat channel where one is set (Ship to shore, set on the website), y",
1012
+ " copies it. The next meeting counts from when this one ended.",
943
1013
  ],
944
1014
  review: [
945
1015
  " harbor review reads the review: every program,",
@@ -1164,8 +1234,14 @@ export async function run(argv, io = {}) {
1164
1234
  }
1165
1235
 
1166
1236
  // On its own in a terminal: the full-screen app. Piped or scripted: the help.
1167
- if (!command || command === "app" || command === "bridge") {
1168
- const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
1237
+ const inTerminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
1238
+ // harbor bridge --list (or --json, or piped) prints the agenda through the commands below.
1239
+ const bridgeApp =
1240
+ command === "bridge" &&
1241
+ inTerminal &&
1242
+ !rest.some((a) => a === "--list" || a === "--json" || a.startsWith("--program"));
1243
+ if (!command || command === "app" || bridgeApp) {
1244
+ const terminal = inTerminal;
1169
1245
  if (!command && !terminal) {
1170
1246
  out(HELP);
1171
1247
  return 0;
@@ -2059,7 +2135,10 @@ export async function run(argv, io = {}) {
2059
2135
  }
2060
2136
  case "add": {
2061
2137
  const { positionals, values, harbor } = parse(
2062
- spec(["over-limit"], ["project", "assignee", "due", "priority", "status", "comment"]),
2138
+ spec(
2139
+ ["over-limit", "dry-run"],
2140
+ ["project", "assignee", "due", "priority", "status", "comment"],
2141
+ ),
2063
2142
  );
2064
2143
  if (!values.project) throw new HarborError("Say which project: --project NAME.");
2065
2144
  // Several at once: one title a line on stdin, with no title (or -) after add.
@@ -2079,6 +2158,17 @@ export async function run(argv, io = {}) {
2079
2158
  throw new HarborError(
2080
2159
  "That's more than 100 tasks; harbor import FILE takes a spreadsheet.",
2081
2160
  );
2161
+ if (values["dry-run"]) {
2162
+ const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
2163
+ if (asJson) out(jsonText({ dryRun: true, project: values.project, titles }));
2164
+ else
2165
+ out(
2166
+ plain(
2167
+ [`Would add ${n} to ${values.project}:`, ...titles.map((t) => ` ${t}`)].join("\n"),
2168
+ ),
2169
+ );
2170
+ return 0;
2171
+ }
2082
2172
  const added = [];
2083
2173
  for (const title of titles) {
2084
2174
  const r = await client
@@ -2244,7 +2334,7 @@ export async function run(argv, io = {}) {
2244
2334
  }
2245
2335
  case "week": {
2246
2336
  const { positionals, values, harbor } = parse(
2247
- spec(["next", "last", "review", "off"], ["week", "hours", "day-hours"]),
2337
+ spec(["next", "last", "review", "off"], ["week", "hours", "day-hours", "day"]),
2248
2338
  );
2249
2339
  if ([values.next, values.last, values.week].filter(Boolean).length > 1)
2250
2340
  throw new HarborError("Say one of --next, --last or --week DATE.");
@@ -2259,21 +2349,24 @@ export async function run(argv, io = {}) {
2259
2349
  throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
2260
2350
  return n;
2261
2351
  };
2262
- const [task, day, ...extra] = positionals;
2263
- if (extra.length) throw new HarborError("Give a task key and a day: harbor week RR-42 wed.");
2352
+ // The day comes as --day; a bare second word still works, as it did first.
2353
+ const [task, bareDay, ...extra] = positionals;
2354
+ if (extra.length || (bareDay && values.day))
2355
+ throw new HarborError("Give a task key and a day: harbor week RR-42 --day wed.");
2356
+ const day = values.day ?? bareDay;
2264
2357
  const dayHours = number(values["day-hours"], "day-hours");
2265
2358
  const show = (r) =>
2266
2359
  [r.done, weekText(r, { ...opts, review: Boolean(values.review) })]
2267
2360
  .filter(Boolean)
2268
2361
  .join("\n\n");
2269
2362
  if (!task && dayHours === undefined) {
2270
- if (values.off || values.hours)
2271
- throw new HarborError("Say which task: harbor week RR-42 wed --hours 3.");
2363
+ if (values.off || values.hours || values.day)
2364
+ throw new HarborError("Say which task: harbor week RR-42 --day wed --hours 3.");
2272
2365
  return call("get_week", { harbor, week }, show);
2273
2366
  }
2274
2367
  if (task && !day && !values.off)
2275
2368
  throw new HarborError(
2276
- `Say which day for ${task}: harbor week ${task} wed, or --off to take it off the week.`,
2369
+ `Say which day for ${task}: harbor week ${task} --day wed, or --off to take it off the week.`,
2277
2370
  );
2278
2371
  return call(
2279
2372
  "plan_week",
@@ -2663,13 +2756,18 @@ export async function run(argv, io = {}) {
2663
2756
  return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
2664
2757
  }
2665
2758
  case "watch": {
2666
- const { positionals, values, harbor } = parse(spec(["bell"], ["project"]));
2759
+ const { positionals, values, harbor } = parse(spec(["bell", "once"], ["project", "since"]));
2667
2760
  none(positionals);
2668
2761
  if (!hasFullAccess(creds))
2669
2762
  throw new HarborError(
2670
2763
  "Watching needs full access, as the full-screen app does. Run harbor login (without --read-only), then try again.",
2671
2764
  );
2672
- const { watchHere } = await loadApp();
2765
+ const { watchHere, watchSince } = await loadApp();
2766
+ const since = values.since ? watchSince(values.since, new Date()) : null;
2767
+ if (values.since && !since)
2768
+ throw new HarborError(
2769
+ `--since takes a time today (14:30) or a date (2026-10-21, or 2026-10-21T14:30), not "${values.since}".`,
2770
+ );
2673
2771
  await watchHere({
2674
2772
  source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
2675
2773
  fetcher,
@@ -2677,7 +2775,10 @@ export async function run(argv, io = {}) {
2677
2775
  saved: creds.harbor ?? null,
2678
2776
  project: values.project,
2679
2777
  bell: Boolean(values.bell),
2680
- colour: colourDepth(io.stdout ?? process.stdout, env) !== "none",
2778
+ colour: !asJson && colourDepth(io.stdout ?? process.stdout, env) !== "none",
2779
+ since,
2780
+ once: Boolean(values.once),
2781
+ json: asJson,
2681
2782
  });
2682
2783
  return 0;
2683
2784
  }
@@ -2716,6 +2817,49 @@ export async function run(argv, io = {}) {
2716
2817
  }
2717
2818
  return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
2718
2819
  }
2820
+ case "bridge": {
2821
+ const { positionals, values, harbor } = parse(spec(["list"], ["program"]));
2822
+ none(positionals);
2823
+ return call("review_meeting", { harbor, program: values.program }, bridgeText);
2824
+ }
2825
+ case "thanks": {
2826
+ const { positionals, values, harbor } = parse(
2827
+ spec(["all", "given"], ["to", "milestone", "decision"]),
2828
+ );
2829
+ const forWork = values.milestone || values.decision;
2830
+ if (!forWork && positionals.length === 0) {
2831
+ return call(
2832
+ "list_thanks",
2833
+ {
2834
+ harbor,
2835
+ person: values.all ? "all" : "me",
2836
+ given: Boolean(values.given),
2837
+ },
2838
+ thanksText,
2839
+ );
2840
+ }
2841
+ const [first, ...rest] = positionals;
2842
+ const task = forWork ? undefined : first;
2843
+ const line = (forWork ? positionals : rest).join(" ").trim();
2844
+ if (task && !TASK_KEY.test(task) && !line) {
2845
+ throw new HarborError(
2846
+ 'Say what for and the line: harbor thanks RR-42 "Caught it before launch" (or --milestone NAME, --decision TITLE).',
2847
+ );
2848
+ }
2849
+ if (!line) throw new HarborError('Add the line of thanks in quotes: harbor thanks RR-42 "Thank you".');
2850
+ return call(
2851
+ "give_thanks",
2852
+ {
2853
+ harbor,
2854
+ task,
2855
+ milestone: values.milestone,
2856
+ decision: values.decision,
2857
+ to: values.to,
2858
+ line,
2859
+ },
2860
+ (r) => `Fair winds: thanked ${r.thanked.to ?? "them"} for ${r.thanked.for}.`,
2861
+ );
2862
+ }
2719
2863
  case "handover": {
2720
2864
  const { positionals, values, harbor } = parse(
2721
2865
  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}”`,
537
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)}`,
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. */
@@ -978,8 +1029,16 @@ export function weekText(w, { today, review = false, width } = {}) {
978
1029
  out.push("", `Not on a day yet: ${w.unplannedCount}. harbor week KEY DAY puts one there.`);
979
1030
  for (const t of w.unplanned.slice(0, 5))
980
1031
  out.push(` ${taskLine(t, { today, mine: true, width: width ? width - 2 : undefined })}`);
981
- if (w.unplannedCount > 5) out.push(` … and ${w.unplannedCount - 5} more (--json lists 25)`);
1032
+ if (w.unplannedCount > 5)
1033
+ out.push(
1034
+ ` … and ${w.unplannedCount - 5} more (--json lists ${w.unplannedCount > w.unplanned.length ? w.unplanned.length : "all"})`,
1035
+ );
982
1036
  }
1037
+ if (w.inReviewCount)
1038
+ out.push(
1039
+ "",
1040
+ `${w.inReviewCount} of yours ${w.inReviewCount === 1 ? "is" : "are"} in review, waiting on someone else, so ${w.inReviewCount === 1 ? "it's" : "they're"} left out.`,
1041
+ );
983
1042
  }
984
1043
  if (reviewing) {
985
1044
  const r = w.review;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.60.0",
3
+ "version": "0.62.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",