@harborppm/cli 0.58.0 → 0.60.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,8 +39,10 @@ import {
39
39
  lifeboatsText,
40
40
  followedText,
41
41
  soundingsText,
42
+ ashoreText,
42
43
  voyageText,
43
44
  whyText,
45
+ barnaclesText,
44
46
  handoverText,
45
47
  fogText,
46
48
  agentWorkText,
@@ -96,6 +98,9 @@ export const HELP = `harbor: Harbor from the terminal
96
98
  changes without --apply
97
99
  harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of
98
100
  task waits that sets its date
101
+ harbor barnacles PROJECT work added since the plan was set:
102
+ by week, by who, and the days of
103
+ slip it explains
99
104
  harbor hill [--program P] where each project is on its hill
100
105
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
101
106
  out, past 50 is getting it done
@@ -213,12 +218,16 @@ export const HELP = `harbor: Harbor from the terminal
213
218
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
214
219
  a note for later: sealed until
215
220
  its day, milestone, or Off track
216
- harbor sound TASK [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
221
+ harbor sound TASK [--answer DAYS|--ask NAMES|--settle DAYS] [--why TEXT]
217
222
  a sounding: ask a few people how
218
223
  many days a task will take, give
219
224
  your answer, or read the spread
220
225
  harbor soundings [PROJECT] open tasks with a sounding, and
221
226
  the ones waiting on your answer
227
+ harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
228
+ public holidays and time away
229
+ coming up, and whose holidays
230
+ the harbor (or someone) takes
222
231
  harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
223
232
  [--keep ID,ID] [--confirm]
224
233
  hand someone's work to the crew
@@ -449,6 +458,8 @@ const FLAG = {
449
458
  days: "the days",
450
459
  why: "--why",
451
460
  people: "--ask",
461
+ countries: "--country",
462
+ country: "--country",
452
463
  };
453
464
 
454
465
  /** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
@@ -731,7 +742,8 @@ const DETAIL = {
731
742
  " harbor sound TASK --ask crew writes: asks you, its owner and whoever",
732
743
  " has open work on its project",
733
744
  ' harbor sound TASK --ask "Sam,Priya" writes: asks those people',
734
- ' harbor sound TASK 4 [--why "TEXT"] writes: your answer, in working days',
745
+ ' harbor sound TASK --answer 4 [--why "TEXT"]',
746
+ " writes: your answer, in working days",
735
747
  " harbor sound TASK --settle 6 writes: the days you agreed on after",
736
748
  " talking a wide spread through",
737
749
  "",
@@ -739,6 +751,19 @@ const DETAIL = {
739
751
  " everyone has. Close guesses give the task an estimate; far-apart ones",
740
752
  " say talk first. The forecast draws on every answer.",
741
753
  ],
754
+ ashore: [
755
+ " harbor ashore [--weeks N] reads: holidays and time away in the",
756
+ " next N weeks (8 unless you say)",
757
+ " harbor ashore --country US,GB writes: whose public holidays the",
758
+ " harbor takes (none for no holidays)",
759
+ " harbor ashore --person NAME --country GB",
760
+ " writes: one person's own country",
761
+ " (harbor to take the harbor's again)",
762
+ "",
763
+ " The forecast, the weeks ahead, the timeline and calendar feeds count",
764
+ " these as days nobody works. Countries: AU BR CA DE ES FR GB IE IT MX NL",
765
+ " NZ US (national holidays only).",
766
+ ],
742
767
  soundings: [
743
768
  " harbor soundings [PROJECT] reads: open tasks with a sounding",
744
769
  " (all projects when left out), and the",
@@ -852,9 +877,23 @@ const DETAIL = {
852
877
  " Tasks have due dates, not lengths, so a task can finish on its due date",
853
878
  " at the soonest, never before what it waits on, and never in the past.",
854
879
  " Each link's slack is the days it can slip before it moves the next one",
855
- " (or the end); a negative one already does. PROJECT is a name, a key",
880
+ " (or the end); a negative one already does. Open tasks with no due date",
881
+ " can't hold a date, so they're left out and counted. In --json, endDate",
882
+ " (dueDate for a task or milestone) is the date held and daysSpare the",
883
+ " days before it (negative: that many over). PROJECT is a name, a key",
856
884
  " like RR, or an id.",
857
885
  ],
886
+ barnacles: [
887
+ " harbor barnacles PROJECT reads how much the plan has grown",
888
+ "",
889
+ " Counts the tasks made after the baseline was set (without a baseline",
890
+ " time, after the project's first week of tasks), as a",
891
+ " share of what was planned, by week and by who added them. At the",
892
+ " crew's pace (tasks finished a week over the last six) it says how many",
893
+ " days of work that is, and how much of the slip against the planned end",
894
+ " it explains. The weekly brief names projects that grew a lot since",
895
+ " their baseline.",
896
+ ],
858
897
  voyage: [
859
898
  " harbor voyage PROJECT reads a Done project's voyage log",
860
899
  ' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
@@ -2024,7 +2063,8 @@ export async function run(argv, io = {}) {
2024
2063
  );
2025
2064
  if (!values.project) throw new HarborError("Say which project: --project NAME.");
2026
2065
  // Several at once: one title a line on stdin, with no title (or -) after add.
2027
- const piped = positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
2066
+ const piped =
2067
+ positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
2028
2068
  const stdin = io.stdin ?? process.stdin;
2029
2069
  const titles =
2030
2070
  piped && (io.stdin || !process.stdin.isTTY)
@@ -2033,28 +2073,34 @@ export async function run(argv, io = {}) {
2033
2073
  .map((l) => l.trim())
2034
2074
  .filter(Boolean)
2035
2075
  : [text(positionals, "the task")];
2036
- if (titles.length === 0) throw new HarborError("Say the task, or give one title a line on stdin.");
2037
- if (titles.length > 100) throw new HarborError("That's more than 100 tasks; harbor import FILE takes a spreadsheet.");
2076
+ if (titles.length === 0)
2077
+ throw new HarborError("Say the task, or give one title a line on stdin.");
2078
+ if (titles.length > 100)
2079
+ throw new HarborError(
2080
+ "That's more than 100 tasks; harbor import FILE takes a spreadsheet.",
2081
+ );
2038
2082
  const added = [];
2039
2083
  for (const title of titles) {
2040
- const r = await client.call("create_task", {
2041
- harbor,
2042
- project: values.project,
2043
- title,
2044
- // Yours unless you say otherwise, as with n in the app.
2045
- assignee: values.assignee ?? "me",
2046
- due: duePilot(values.due) ? undefined : dueDate(values.due),
2047
- suggestedDue: duePilot(values.due),
2048
- priority: values.priority,
2049
- status: values.status === undefined ? undefined : statusWord(values.status),
2050
- overLimit: values["over-limit"] || undefined,
2051
- }).catch((err) => {
2052
- if (err instanceof HarborError) {
2053
- const done = added.length ? ` (added ${added.length} before it)` : "";
2054
- throw new HarborError(`${hint(err.message, values.project)}${done}`);
2055
- }
2056
- throw err;
2057
- });
2084
+ const r = await client
2085
+ .call("create_task", {
2086
+ harbor,
2087
+ project: values.project,
2088
+ title,
2089
+ // Yours unless you say otherwise, as with n in the app.
2090
+ assignee: values.assignee ?? "me",
2091
+ due: duePilot(values.due) ? undefined : dueDate(values.due),
2092
+ suggestedDue: duePilot(values.due),
2093
+ priority: values.priority,
2094
+ status: values.status === undefined ? undefined : statusWord(values.status),
2095
+ overLimit: values["over-limit"] || undefined,
2096
+ })
2097
+ .catch((err) => {
2098
+ if (err instanceof HarborError) {
2099
+ const done = added.length ? ` (added ${added.length} before it)` : "";
2100
+ throw new HarborError(`${hint(err.message, values.project)}${done}`);
2101
+ }
2102
+ throw err;
2103
+ });
2058
2104
  if (values.comment)
2059
2105
  r.comment = (
2060
2106
  await client.call("add_comment", {
@@ -2209,19 +2255,26 @@ export async function run(argv, io = {}) {
2209
2255
  const number = (v, name) => {
2210
2256
  if (v === undefined) return undefined;
2211
2257
  const n = Number(v);
2212
- if (!Number.isFinite(n) || n <= 0) throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
2258
+ if (!Number.isFinite(n) || n <= 0)
2259
+ throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
2213
2260
  return n;
2214
2261
  };
2215
2262
  const [task, day, ...extra] = positionals;
2216
2263
  if (extra.length) throw new HarborError("Give a task key and a day: harbor week RR-42 wed.");
2217
2264
  const dayHours = number(values["day-hours"], "day-hours");
2218
- const show = (r) => [r.done, weekText(r, { ...opts, review: Boolean(values.review) })].filter(Boolean).join("\n\n");
2265
+ const show = (r) =>
2266
+ [r.done, weekText(r, { ...opts, review: Boolean(values.review) })]
2267
+ .filter(Boolean)
2268
+ .join("\n\n");
2219
2269
  if (!task && dayHours === undefined) {
2220
- if (values.off || values.hours) throw new HarborError("Say which task: harbor week RR-42 wed --hours 3.");
2270
+ if (values.off || values.hours)
2271
+ throw new HarborError("Say which task: harbor week RR-42 wed --hours 3.");
2221
2272
  return call("get_week", { harbor, week }, show);
2222
2273
  }
2223
2274
  if (task && !day && !values.off)
2224
- throw new HarborError(`Say which day for ${task}: harbor week ${task} wed, or --off to take it off the week.`);
2275
+ throw new HarborError(
2276
+ `Say which day for ${task}: harbor week ${task} wed, or --off to take it off the week.`,
2277
+ );
2225
2278
  return call(
2226
2279
  "plan_week",
2227
2280
  {
@@ -2485,9 +2538,14 @@ export async function run(argv, io = {}) {
2485
2538
  );
2486
2539
  }
2487
2540
  case "sound": {
2488
- const { positionals, values, harbor } = parse(spec([], ["why", "ask", "settle"]));
2489
- const [task, days, ...extra] = positionals;
2541
+ const { positionals, values, harbor } = parse(spec([], ["answer", "why", "ask", "settle"]));
2542
+ const [task, ...extra] = positionals;
2490
2543
  if (!task) throw new HarborError("Give a task's key, like harbor sound RR-4.");
2544
+ // A bare number used to be the answer; it writes, so it now needs its flag.
2545
+ if (extra.length === 1 && /^\d+(\.\d+)?(d|days?)?$/i.test(extra[0]))
2546
+ throw new HarborError(
2547
+ `To give your answer, say so: harbor sound ${task} --answer ${extra[0]}.`,
2548
+ );
2491
2549
  if (extra.length) throw new HarborError(`Unexpected "${extra.join(" ")}".`);
2492
2550
  const number = (text) => {
2493
2551
  const n = Number(String(text).replace(/d(ays?)?$/i, ""));
@@ -2495,11 +2553,14 @@ export async function run(argv, io = {}) {
2495
2553
  throw new HarborError(`Give days as a number, like 3 or 2.5 (not "${text}").`);
2496
2554
  return n;
2497
2555
  };
2556
+ const days = values.answer;
2498
2557
  const given = [days !== undefined, values.ask !== undefined, values.settle !== undefined];
2499
2558
  if (given.filter(Boolean).length > 1)
2500
2559
  throw new HarborError("Answer, ask or settle: one at a time.");
2501
2560
  if (values.why !== undefined && days === undefined)
2502
- throw new HarborError('--why goes with your answer: harbor sound TASK 4 --why "TEXT".');
2561
+ throw new HarborError(
2562
+ '--why goes with your answer: harbor sound TASK --answer 4 --why "TEXT".',
2563
+ );
2503
2564
  if (values.ask !== undefined) {
2504
2565
  const crew = /^crew$/i.test(values.ask.trim())
2505
2566
  ? undefined
@@ -2507,11 +2568,10 @@ export async function run(argv, io = {}) {
2507
2568
  .split(",")
2508
2569
  .map((x) => x.trim())
2509
2570
  .filter(Boolean);
2510
- return call(
2511
- "take_sounding",
2512
- { harbor, task, ...(crew ? { people: crew } : {}) },
2513
- (r) =>
2514
- `Asked ${r.asked.people.join(", ")} how many days ${r.asked.key ?? r.asked.title} will take. The answers show together once everyone's in.`,
2571
+ return call("take_sounding", { harbor, task, ...(crew ? { people: crew } : {}) }, (r) =>
2572
+ !crew && r.asked.people.length === 1
2573
+ ? `Only you are on this crew, so the sounding on ${r.asked.key ?? r.asked.title} is just your guess. Ask others by name: harbor sound ${task} --ask "Sam,Priya".`
2574
+ : `Asked ${r.asked.people.join(", ")} how many days ${r.asked.key ?? r.asked.title} will take. The answers show together once everyone's in.`,
2515
2575
  );
2516
2576
  }
2517
2577
  if (values.settle !== undefined) {
@@ -2543,12 +2603,51 @@ export async function run(argv, io = {}) {
2543
2603
  soundingsText(r),
2544
2604
  );
2545
2605
  }
2606
+ case "ashore": {
2607
+ const { positionals, values, harbor } = parse(spec([], ["weeks", "country", "person"]));
2608
+ none(positionals);
2609
+ if (values.person && values.country === undefined)
2610
+ throw new HarborError("Give their country too: --person NAME --country GB (or harbor).");
2611
+ if (values.country !== undefined) {
2612
+ if (values.person) {
2613
+ return call(
2614
+ "set_holidays",
2615
+ { harbor, person: values.person, country: values.country },
2616
+ (r) =>
2617
+ r.country === "harbor"
2618
+ ? `${r.person} takes the harbor's holidays.`
2619
+ : `${r.person} takes ${r.country} holidays.`,
2620
+ );
2621
+ }
2622
+ const countries = /^none$/i.test(values.country.trim())
2623
+ ? []
2624
+ : values.country
2625
+ .split(",")
2626
+ .map((c) => c.trim())
2627
+ .filter(Boolean);
2628
+ return call("set_holidays", { harbor, countries }, (r) =>
2629
+ r.harbor.length
2630
+ ? `The harbor takes public holidays from ${r.harbor.map((c) => c.name).join(", ")}.`
2631
+ : "The harbor takes no public holidays now.",
2632
+ );
2633
+ }
2634
+ const weeks = values.weeks === undefined ? 8 : Number(values.weeks);
2635
+ if (!Number.isInteger(weeks) || weeks < 1 || weeks > 52)
2636
+ throw new HarborError("--weeks is a whole number from 1 to 52.");
2637
+ return call("time_ashore", { harbor, days: weeks * 7 }, (r) => ashoreText(r, weeks));
2638
+ }
2546
2639
  case "why": {
2547
2640
  const { positionals, harbor } = parse(spec());
2548
2641
  const of = positionals.join(" ").trim();
2549
2642
  if (!of) throw new HarborError("Say what: a project, a task key like RR-42, or a milestone.");
2550
2643
  return call("long_pole", { harbor, of }, whyText);
2551
2644
  }
2645
+ case "barnacles": {
2646
+ const { positionals, harbor } = parse(spec());
2647
+ const project = positionals.join(" ").trim();
2648
+ if (!project) throw new HarborError("Say which project: harbor barnacles PROJECT.");
2649
+ return call("scope_growth", { harbor, project }, barnaclesText);
2650
+ }
2552
2651
  case "voyage": {
2553
2652
  const { positionals, harbor } = parse(spec());
2554
2653
  const [project, ...words] = positionals;
package/lib/format.mjs CHANGED
@@ -571,7 +571,29 @@ export function whyText(r) {
571
571
  .join(", ")}.`,
572
572
  ]
573
573
  : [];
574
- return [...head, "", ...via, ...lines, ...others].join("\n");
574
+ const note = r.note ? ["", r.note] : [];
575
+ return [...head, "", ...via, ...lines, ...others, ...note].join("\n");
576
+ }
577
+
578
+ /** A project's barnacles, as scope_growth answers it: the growth, its cost in days, by week and by who. */
579
+ export function barnaclesText(r) {
580
+ const head = [`Barnacles · ${r.project}`];
581
+ if (!r.added) {
582
+ head.push(`Nothing added since the plan was set (${r.since.slice(0, 10)}): ${r.planned} task${r.planned === 1 ? "" : "s"}, as planned.`);
583
+ return head.join("\n");
584
+ }
585
+ head.push(`${r.headline} (${r.planned} planned, ${r.addedThisWeek} added this week).`);
586
+ if (r.daysLine) head.push(r.daysLine);
587
+ const top = Math.max(1, ...r.byWeek.map((w) => w.added));
588
+ const weeks = r.byWeek.map(
589
+ (w) => ` w/c ${w.weekOf} ${"█".repeat(Math.max(1, Math.round((w.added / top) * 20)))} ${w.added}`,
590
+ );
591
+ const who = r.byWho.map((w) => `${w.who ?? "not recorded"} ${w.added}`).join(", ");
592
+ const from =
593
+ r.measuredFrom === "when the baseline was set"
594
+ ? `Counted from when the baseline was set, ${r.since.slice(0, 10)}.`
595
+ : `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; setting a baseline counts from then instead.`;
596
+ return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
575
597
  }
576
598
 
577
599
  /** A position on a 21-wide track, the top of the hill marked in the middle. */
@@ -752,7 +774,7 @@ export function soundingText(task, s, { answered = false } = {}) {
752
774
  for (const a of s.answers)
753
775
  lines.push(` You said ${daysText(a.days)}${a.why ? ` · "${a.why}"` : ""}`);
754
776
  if (s.youWereAsked && !s.answers.length)
755
- lines.push(` Give yours: harbor sound ${task.key ?? task.id} DAYS`);
777
+ lines.push(` Give yours: harbor sound ${task.key ?? task.id} --answer DAYS`);
756
778
  return lines.join("\n");
757
779
  }
758
780
  lines.push(` ${spreadLine(s.answers)}`);
@@ -768,6 +790,26 @@ export function soundingText(task, s, { answered = false } = {}) {
768
790
  return lines.join("\n");
769
791
  }
770
792
 
793
+ /** time_ashore: whose holidays count, then the days off coming up, soonest first. */
794
+ export function ashoreText(r, weeks = 8) {
795
+ const lines = [];
796
+ const countries = r.countries ?? [];
797
+ lines.push(
798
+ countries.length
799
+ ? `Time ashore · holidays from ${countries.map((c) => c.name).join(", ")}`
800
+ : "Time ashore · no holiday country set (harbor ashore --country US)",
801
+ );
802
+ for (const p of r.people ?? []) lines.push(` ${p.name} takes ${p.country} holidays`);
803
+ const rows = [
804
+ ...(r.holidays ?? []).map((h) => [h.date, h.name]),
805
+ ...(r.away ?? []).map((a) => [a.from, `${a.name} away, back ${a.back}`]),
806
+ ].sort((a, b) => a[0].localeCompare(b[0]));
807
+ lines.push("");
808
+ if (!rows.length) lines.push(` Nothing in the next ${weeks} weeks.`);
809
+ for (const [day, what] of rows) lines.push(` ${day} ${what}`);
810
+ return lines.join("\n");
811
+ }
812
+
771
813
  /** list_soundings: the ones waiting on you first, then the rest, wide ones flagged. */
772
814
  export function soundingsText(r) {
773
815
  const all = r.soundings ?? [];
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.58.0",
3
+ "version": "0.60.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",