@harborppm/cli 0.62.0 → 0.64.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/args.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * A small argument reader: positionals, `--flag`, `--key value` and
3
- * `--key=value`. Unknown flags are an error, so a typo doesn't quietly do
3
+ * `--key=value`. An option named in `repeat` may be given more than once and
4
+ * reads as a list. Unknown flags are an error, so a typo doesn't quietly do
4
5
  * something else.
5
6
  */
6
- export function parseArgs(argv, { flags = [], options = [] } = {}) {
7
+ export function parseArgs(argv, { flags = [], options = [], repeat = [] } = {}) {
7
8
  const positionals = [];
8
9
  const values = {};
9
10
  for (let i = 0; i < argv.length; i++) {
@@ -20,7 +21,7 @@ export function parseArgs(argv, { flags = [], options = [] } = {}) {
20
21
  if (flags.includes(key)) {
21
22
  if (inline !== undefined) throw new Error(`--${key} takes no value.`);
22
23
  values[key] = true;
23
- } else if (options.includes(key)) {
24
+ } else if (options.includes(key) || repeat.includes(key)) {
24
25
  const value = inline ?? argv[++i];
25
26
  if (
26
27
  value === undefined ||
@@ -29,7 +30,7 @@ export function parseArgs(argv, { flags = [], options = [] } = {}) {
29
30
  ) {
30
31
  throw new Error(`--${key} needs a value.`);
31
32
  }
32
- values[key] = value;
33
+ values[key] = repeat.includes(key) ? [...(values[key] ?? []), value] : value;
33
34
  } else {
34
35
  throw new Error(`Unknown option --${key}.`);
35
36
  }
package/lib/cli.mjs CHANGED
@@ -103,15 +103,23 @@ export const HELP = `harbor: Harbor from the terminal
103
103
  harbor barnacles PROJECT work added since the plan was set:
104
104
  by week, by who, and the days of
105
105
  slip it explains
106
+ harbor baseline PROJECT [--reset] sets a project's baseline: its
107
+ dates now, and growth counted
108
+ from now
106
109
  harbor hill [--program P] where each project is on its hill
107
110
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
108
111
  out, past 50 is getting it done
109
112
  harbor mine what's on you, late first
113
+ harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
114
+ who's working now, or set your
115
+ (or NAME's) time zone and hours
110
116
  harbor tasks [words] [--project P] [--assignee me|none|NAME]
111
117
  [--late] [--status S,S] [--all]
112
118
  the first 20 (--all: every one);
113
119
  done ones only with --status done
114
- harbor show TASK one task (TASK: a key like RR-42)
120
+ harbor show TASK one task (TASK: a key like RR-42),
121
+ with the date its owner's queue
122
+ suggests
115
123
  harbor open [TASK|PROJECT] open it on the website (or Today)
116
124
  harbor invite EMAIL [--viewer] invite someone to your harbor
117
125
  harbor prompt [--shell SHELL] [--refresh] due today and late, for your
@@ -223,9 +231,10 @@ export const HELP = `harbor: Harbor from the terminal
223
231
  harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
224
232
  your line in it
225
233
  harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
226
- [--calls T]
227
- a project's five-line charter:
228
- read it, or write some lines
234
+ [--calls NAME]
235
+ a project's five-line charter
236
+ (why, in, out, done when, who
237
+ makes the calls): read or set
229
238
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
230
239
  a note for later: sealed until
231
240
  its day, milestone, or Off track
@@ -288,9 +297,10 @@ function banner(io, env, words) {
288
297
  const THEMES = ["auto", "light", "dark"];
289
298
 
290
299
  const COMMON = { flags: ["json", "help"], options: ["harbor"] };
291
- const spec = (flags = [], options = []) => ({
300
+ const spec = (flags = [], options = [], repeat = []) => ({
292
301
  flags: [...COMMON.flags, ...flags],
293
302
  options: [...COMMON.options, ...options],
303
+ repeat,
294
304
  });
295
305
 
296
306
  /**
@@ -566,9 +576,14 @@ const DETAIL = {
566
576
  "",
567
577
  " --assignee me|none|NAME, --due DATE|pilot, --priority low|medium|high|urgent,",
568
578
  " --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.",
579
+ " ten lines with --comment add the same comment to all ten. More than 100",
580
+ " lines is harbor import's job.",
581
+ "",
582
+ " --due pilot takes the earliest day the owner's queue suggests: their open",
583
+ " work due first, their weekly pace, time away, what it waits on and how",
584
+ " long their similar tasks took. It needs an owner (--assignee none is",
585
+ " refused), and an empty queue gives their next working day. Several lines",
586
+ " each count the ones before them; with --dry-run it shows each line's date.",
572
587
  ],
573
588
  week: [
574
589
  " harbor week [--next|--last|--week DATE] reads: your week, the days and",
@@ -768,14 +783,21 @@ const DETAIL = {
768
783
  charter: [
769
784
  " harbor charter PROJECT reads its charter, and any open task",
770
785
  " that names something it leaves out",
771
- ' harbor charter PROJECT --why "TEXT" writes: that line; give any of',
772
- " --why, --in, --out, --done-when and",
773
- " --calls at once",
786
+ " Set any line, or all at once:",
787
+ ' --why "TEXT" why the project exists',
788
+ ' --in "A, B" what it covers (the voyage log counts the',
789
+ " finished tasks naming these)",
790
+ ' --out "C, D" what it leaves out (tasks naming these show as',
791
+ " Out of scope?)",
792
+ ' --done-when "TEXT" what finished looks like',
793
+ ' --calls "NAME" who makes the calls when scope is in question',
774
794
  "",
775
- " none clears a line: --out none. Separate items in --in and --out",
776
- " with commas, so Harbor can spot a task that names one. The review lists",
777
- " running projects still without a charter a week after they started.",
778
- " PROJECT is a name, a key like RR, or an id.",
795
+ " Items in --in and --out are split on commas; give --in or --out again",
796
+ ' for an item that has a comma in it: --out "Coupons" --out "Gift cards,',
797
+ ' vouchers". none clears a line: --out none. The review lists running',
798
+ " projects still without a charter a week after they started. A charter",
799
+ " is for the harbor's members: share links and public roadmaps never show",
800
+ " it. PROJECT is a name, a key like RR, or an id.",
779
801
  ],
780
802
  sound: [
781
803
  " harbor sound TASK reads its sounding: who hasn't answered,",
@@ -859,7 +881,9 @@ const DETAIL = {
859
881
  " --project P one project's changes only",
860
882
  " --bell ring the terminal's bell when something of yours changes",
861
883
  " --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",
884
+ " 2026-10-21, or 2026-10-21T14:30) instead of the last 5;",
885
+ " times are this computer's, or UTC with Z or UTC after",
886
+ " (14:30Z), and the header names the zone",
863
887
  " --once print those and stop instead of listening",
864
888
  " --json one JSON object a line (at, text, quote, mine, tone;",
865
889
  " earlier: true for the ones from before it started)",
@@ -932,6 +956,30 @@ const DETAIL = {
932
956
  " days before it (negative: that many over). PROJECT is a name, a key",
933
957
  " like RR, or an id.",
934
958
  ],
959
+ clock: [
960
+ " harbor clock reads everyone's local time, and",
961
+ " who's working now",
962
+ " harbor clock NAME reads one person's",
963
+ " harbor clock [NAME] --zone ZONE writes: sets a time zone (yours",
964
+ " without NAME)",
965
+ " harbor clock [NAME] --hours 9-17 writes: sets working hours",
966
+ "",
967
+ " ZONE is an IANA name like America/New_York or Europe/London, here for",
968
+ " this machine's zone, or none to clear the zone and hours. --hours is",
969
+ " one start and end in that zone, like 9-17, 8:30-16:30 or 9am-5:30pm,",
970
+ " Monday to Friday: one span a day that ends after it starts, so no split",
971
+ " day or overnight shift. Without --hours a day runs 9 to 17. Agents",
972
+ " don't keep hours.",
973
+ ],
974
+ baseline: [
975
+ " harbor baseline PROJECT writes: commits its start and end",
976
+ " dates as its baseline",
977
+ " harbor baseline PROJECT --reset writes: replaces one already set",
978
+ "",
979
+ " The timeline then shows slips against those dates, and harbor barnacles",
980
+ " counts tasks added from now on as growth. On the website a program's",
981
+ " Set baseline does the same for all its projects.",
982
+ ],
935
983
  barnacles: [
936
984
  " harbor barnacles PROJECT reads how much the plan has grown",
937
985
  "",
@@ -1291,6 +1339,7 @@ export async function run(argv, io = {}) {
1291
1339
  const now = await readCredentials(env);
1292
1340
  return now ? { ...c, harbor: now.harbor ?? null, harborName: now.harborName ?? null } : c;
1293
1341
  };
1342
+ const settings = await readSettings(env);
1294
1343
  await runApp({
1295
1344
  source: tokenSource({
1296
1345
  creds,
@@ -1304,9 +1353,20 @@ export async function run(argv, io = {}) {
1304
1353
  const now = (await readCredentials(env)) ?? creds;
1305
1354
  await writeCredentials({ ...now, harbor: h.id, harborName: h.name }, env);
1306
1355
  },
1307
- theme: (await readSettings(env)).theme,
1356
+ theme: settings.theme,
1308
1357
  browse: io.browse ?? openBrowser,
1309
1358
  ...(command === "bridge" ? { startIn: "bridge" } : {}),
1359
+ // When this machine last saw you in each harbor: the app opens on a
1360
+ // catch-up after a few days away (src/lib/welcome-back.ts).
1361
+ seen: {
1362
+ last: (id) => settings.seen?.[id] ?? null,
1363
+ mark: (id, at) => {
1364
+ settings.seen = { ...settings.seen, [id]: at };
1365
+ void readSettings(env)
1366
+ .then((now) => writeSettings({ ...now, seen: { ...now.seen, [id]: at } }, env))
1367
+ .catch(() => {});
1368
+ },
1369
+ },
1310
1370
  });
1311
1371
  return 0;
1312
1372
  }
@@ -1900,6 +1960,8 @@ export async function run(argv, io = {}) {
1900
1960
  ),
1901
1961
  ];
1902
1962
  if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
1963
+ if (t.pilotDue?.fits) lines.push(`${t.pilotDue.says}, so the due date holds.`);
1964
+ else if (t.pilotDue) lines.push(...pilotLines({ pilot: t.pilotDue }, t));
1903
1965
  if (t.description) lines.push("", t.description);
1904
1966
  if (t.checklist.length)
1905
1967
  lines.push("", ...t.checklist.map((c) => ` [${c.done ? "x" : " "}] ${c.step}`));
@@ -2160,11 +2222,38 @@ export async function run(argv, io = {}) {
2160
2222
  );
2161
2223
  if (values["dry-run"]) {
2162
2224
  const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
2163
- if (asJson) out(jsonText({ dryRun: true, project: values.project, titles }));
2225
+ // --due pilot: the date each line would take, each counting the ones before it.
2226
+ const pilot = duePilot(values.due)
2227
+ ? (
2228
+ await client.call("suggest_due", {
2229
+ harbor,
2230
+ project: values.project,
2231
+ assignee: values.assignee ?? "me",
2232
+ count: titles.length,
2233
+ })
2234
+ ).dates
2235
+ : null;
2236
+ if (asJson)
2237
+ out(
2238
+ jsonText({
2239
+ dryRun: true,
2240
+ project: values.project,
2241
+ titles,
2242
+ ...(pilot ? { due: pilot.map((d) => d.earliest) } : {}),
2243
+ }),
2244
+ );
2164
2245
  else
2165
2246
  out(
2166
2247
  plain(
2167
- [`Would add ${n} to ${values.project}:`, ...titles.map((t) => ` ${t}`)].join("\n"),
2248
+ [
2249
+ `Would add ${n} to ${values.project}:`,
2250
+ ...titles.map((t, i) => ` ${t}${pilot ? ` (due ${pilot[i].earliest})` : ""}`),
2251
+ ...(pilot
2252
+ ? [
2253
+ `--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
2254
+ ]
2255
+ : []),
2256
+ ].join("\n"),
2168
2257
  ),
2169
2258
  );
2170
2259
  return 0;
@@ -2393,6 +2482,51 @@ export async function run(argv, io = {}) {
2393
2482
  `Comment added to ${r.commented.task}${r.mentioned?.length ? `; told ${r.mentioned.join(", ")}` : ""}.`,
2394
2483
  );
2395
2484
  }
2485
+ case "clock": {
2486
+ const { positionals, values, harbor } = parse(spec([], ["zone", "hours"]));
2487
+ const who = positionals.join(" ").trim();
2488
+ if (values.zone === undefined && values.hours === undefined) {
2489
+ return call("crew_clocks", { harbor }, (r) => {
2490
+ const rows = who ? r.clocks.filter((c) => nameHas(c.who, who)) : r.clocks;
2491
+ const quiet = who ? r.noTimeZone.filter((n) => nameHas(n, who)) : r.noTimeZone;
2492
+ if (rows.length === 0 && quiet.length === 0) {
2493
+ return who ? `Nobody on the crew matches "${who}".` : "Nobody on the crew yet.";
2494
+ }
2495
+ const width = Math.max(0, ...rows.map((c) => c.who.length));
2496
+ return [
2497
+ ...rows.map(
2498
+ (c) =>
2499
+ `${c.who.padEnd(width)} ${c.localTime.padStart(5)} ${c.zone.replace(/_/g, " ")} ${c.working ? `working (${c.hours})` : (c.picksUp ?? "off")}`,
2500
+ ),
2501
+ ...(quiet.length
2502
+ ? [
2503
+ `${rows.length ? "\n" : ""}No time zone yet: ${quiet.join(", ")}. harbor clock --zone here sets yours.`,
2504
+ ]
2505
+ : []),
2506
+ ].join("\n");
2507
+ });
2508
+ }
2509
+ let zone = values.zone;
2510
+ if (zone?.toLowerCase() === "here") {
2511
+ zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
2512
+ if (!zone) throw new HarborError("This machine doesn't say its time zone. Give one, like --zone Europe/London.");
2513
+ }
2514
+ let starts;
2515
+ let ends;
2516
+ if (values.hours !== undefined) {
2517
+ const m = /^\s*([^-–]+?)\s*[-–]\s*(.+?)\s*$/.exec(values.hours);
2518
+ if (!m) throw new HarborError("--hours is a start and an end, like 9-17 or 8:30-16:30.");
2519
+ [, starts, ends] = m;
2520
+ }
2521
+ return call(
2522
+ "set_clock",
2523
+ { harbor, person: who || "me", zone, starts, ends },
2524
+ (r) =>
2525
+ r.zone
2526
+ ? `${r.person}: ${r.zone.replace(/_/g, " ")}, ${r.hours} on weekdays. ${r.working ? `Working now (${r.localTime} there).` : (r.picksUp ?? "")}`.trim()
2527
+ : `${r.person} has no time zone now.`,
2528
+ );
2529
+ }
2396
2530
  case "checkin": {
2397
2531
  const { positionals, values, harbor } = parse(spec(["on-blocker"]));
2398
2532
  return call(
@@ -2594,11 +2728,15 @@ export async function run(argv, io = {}) {
2594
2728
  }
2595
2729
  case "charter": {
2596
2730
  const { positionals, values, harbor } = parse(
2597
- spec([], ["why", "in", "out", "done-when", "calls"]),
2731
+ spec([], ["why", "done-when", "calls"], ["in", "out"]),
2598
2732
  );
2599
2733
  const project = one(positionals, 'project: harbor charter "NAME"');
2600
- // none clears a line, as --due none does.
2601
- const line = (v) => (v.trim().toLowerCase() === "none" ? "" : v);
2734
+ // none clears a line, as --due none does. --in and --out given more than
2735
+ // once write one item each, as a bulleted list, so an item keeps its commas.
2736
+ const line = (v) => {
2737
+ if (Array.isArray(v)) v = v.length > 1 ? v.map((x) => `- ${x.trim()}`).join("\n") : v[0];
2738
+ return v.trim().toLowerCase() === "none" ? "" : v;
2739
+ };
2602
2740
  const lines = Object.fromEntries(
2603
2741
  [
2604
2742
  ["why", values.why],
@@ -2741,6 +2879,19 @@ export async function run(argv, io = {}) {
2741
2879
  if (!project) throw new HarborError("Say which project: harbor barnacles PROJECT.");
2742
2880
  return call("scope_growth", { harbor, project }, barnaclesText);
2743
2881
  }
2882
+ case "baseline": {
2883
+ const { positionals, values, harbor } = parse(spec(["reset"]));
2884
+ const project = positionals.join(" ").trim();
2885
+ if (!project) throw new HarborError("Say which project: harbor baseline PROJECT.");
2886
+ return call(
2887
+ "set_baseline",
2888
+ { harbor, project, ...(values.reset ? { reset: true } : {}) },
2889
+ (r) =>
2890
+ r.changed
2891
+ ? `Set ${r.project}'s baseline: ${r.baselineStart} to ${r.baselineEnd}. harbor barnacles counts tasks added from now on.`
2892
+ : `${r.project} already has a baseline (${r.baselineStart} to ${r.baselineEnd}); --reset replaces it.`,
2893
+ );
2894
+ }
2744
2895
  case "voyage": {
2745
2896
  const { positionals, harbor } = parse(spec());
2746
2897
  const [project, ...words] = positionals;
package/lib/format.mjs CHANGED
@@ -635,6 +635,7 @@ export function barnaclesText(r) {
635
635
  }
636
636
  head.push(`${r.headline} (${r.planned} planned, ${r.addedThisWeek} added this week).`);
637
637
  if (r.daysLine) head.push(r.daysLine);
638
+ if (r.caveat) head.push(r.caveat);
638
639
  const top = Math.max(1, ...r.byWeek.map((w) => w.added));
639
640
  const weeks = r.byWeek.map(
640
641
  (w) => ` w/c ${w.weekOf} ${"█".repeat(Math.max(1, Math.round((w.added / top) * 20)))} ${w.added}`,
@@ -643,7 +644,7 @@ export function barnaclesText(r) {
643
644
  const from =
644
645
  r.measuredFrom === "when the baseline was set"
645
646
  ? `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
+ : `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; harbor baseline "${r.project}" counts from then instead.`;
647
648
  return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
648
649
  }
649
650
 
@@ -751,12 +752,16 @@ const CHARTER_LINES = [
751
752
  export function charterText(r) {
752
753
  const project = r.name ?? r.project;
753
754
  if (!r.charter) {
754
- return `${project} has no charter yet. Write one, a line at a time:\n harbor charter "${project}" --why "…" --in "…, …" --out "…, …" --done-when "…" --calls "…"`;
755
+ return `${project} has no charter yet. Set any line, or all at once:\n harbor charter "${project}" --why "…" --in "…, …" --out "…, …" --done-when "…" --calls "…"`;
755
756
  }
756
757
  const lines = [`Charter · ${project}`];
757
758
  const width = Math.max(...CHARTER_LINES.map(([, label]) => label.length));
758
759
  for (const [key, label] of CHARTER_LINES) {
759
- if (r.charter[key]) lines.push(` ${label.padEnd(width)} ${r.charter[key]}`);
760
+ // A bulleted In or Out (--in given more than once) keeps its lines lined up.
761
+ if (r.charter[key])
762
+ lines.push(
763
+ ` ${label.padEnd(width)} ${String(r.charter[key]).replace(/\n/g, `\n ${" ".repeat(width)} `)}`,
764
+ );
760
765
  }
761
766
  if (r.outOfScope?.length) {
762
767
  lines.push("", "Out of scope?");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.62.0",
3
+ "version": "0.64.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",