@harborppm/cli 0.98.0 → 0.99.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
@@ -21,8 +21,8 @@ import {
21
21
  importText,
22
22
  forecastList,
23
23
  forecastOne,
24
- STATUS,
25
24
  statusWord,
25
+ statusText,
26
26
  exportData,
27
27
  harbors,
28
28
  whoami,
@@ -42,6 +42,10 @@ import {
42
42
  followedText,
43
43
  soundingsText,
44
44
  goalsText,
45
+ goalText,
46
+ milestonesText,
47
+ milestoneLine,
48
+ reportsText,
45
49
  templatesText,
46
50
  approvalsText,
47
51
  thanksText,
@@ -172,7 +176,8 @@ export const HELP = `harbor: Harbor from the terminal
172
176
  [--also "NAME, NAME"] [--due DATE|pilot] [--starts DATE]
173
177
  [--priority low|medium|high|urgent]
174
178
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
175
- [--literal] [--template NAME] [--parent TASK]
179
+ [--unless-exists] [--literal] [--template NAME]
180
+ [--parent TASK]
176
181
  [--milestone NAME]
177
182
  reads plain words in TITLE as the
178
183
  website does: @name, by friday,
@@ -259,6 +264,9 @@ export const HELP = `harbor: Harbor from the terminal
259
264
  harbor messages PROJECT ["TEXT"] [--limit N]
260
265
  a project's messages, or write
261
266
  one (@Name tells them)
267
+ harbor messages PROJECT --edit "TEXT" [--message ID]
268
+ fix your latest message (or that
269
+ one) in its project
262
270
  harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
263
271
  [--calls NAME]
264
272
  a project's five-line charter
@@ -280,6 +288,23 @@ export const HELP = `harbor: Harbor from the terminal
280
288
  the ones waiting on your answer
281
289
  harbor goals [PORTFOLIO] each portfolio's goals, how far
282
290
  along and whether on track
291
+ harbor goal "GOAL" [--title T] [--owner NAME] [--due DATE|none] [--why T]
292
+ [--link WORK] [--unlink WORK] [--kr "KR" [--at N|--target N]]
293
+ change a goal, count work toward
294
+ it, or move a key result
295
+ harbor goal "TITLE" --new [--portfolio P] [--owner NAME] [--due DATE]
296
+ set a new goal (harbor help goal)
297
+ harbor milestones [PROGRAM] [--project P] [--done]
298
+ milestones soonest first: tasks
299
+ done, sign-off, what they wait on
300
+ harbor milestone "NAME" [--program P] [--due DATE] [--rename T]
301
+ [--reached|--open] [--signs-off NAME|none]
302
+ [--approve|--changes NOTE]
303
+ change one, or sign it off
304
+ harbor milestone "NAME" --new --program P --due DATE
305
+ add one to a program
306
+ harbor reports [DASHBOARD] the Reports page's dashboards,
307
+ each chart as its numbers
283
308
  harbor templates [PROJECT] the task templates new tasks
284
309
  start from (harbor add --template)
285
310
  harbor template TASK [--name NAME] save a task as a template in its
@@ -326,8 +351,10 @@ export const HELP = `harbor: Harbor from the terminal
326
351
 
327
352
  Every command takes --json, and --harbor NAME when you're in more than one
328
353
  harbor (or set one with harbor use). harbor help COMMAND shows just that one.
329
- Statuses: backlog, todo, doing, review, done (the board's words work too:
330
- "to do", "in play"; --json says doing for In play).
354
+ Statuses: backlog, todo, doing, review, done. The board's words work
355
+ too ("to do", "in play", "in progress", "in review"), and harbor tasks
356
+ takes --status all. Lists say your board's own name for a column it
357
+ renamed; --json keeps Harbor's word in status and the board's in column.
331
358
  DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
332
359
  Changes show in Harbor's History as yours, "via Harbor CLI". These commands
333
360
  never delete; the full-screen app can, as the website can, and asks first,
@@ -615,6 +642,16 @@ function dueWords(v) {
615
642
  return /^\d{4}-\d{2}-\d{2}$/.test(dueDate(v));
616
643
  }
617
644
 
645
+ /** --status S,S as Harbor's words; "all" is every status, done ones included. */
646
+ function statusList(typed) {
647
+ const list = typed
648
+ .split(",")
649
+ .map((x) => x.trim())
650
+ .filter(Boolean);
651
+ if (list.some((x) => x.toLowerCase() === "all")) return ["backlog", "todo", "doing", "review", "done"];
652
+ return list.map(statusWord);
653
+ }
654
+
618
655
  /** --due pilot: the date the owner's queue suggests, set by Harbor. */
619
656
  const PILOT = new Set(["pilot", "suggested"]);
620
657
  const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
@@ -786,6 +823,20 @@ const DETAIL = {
786
823
  " harbor add --project P < titles.txt writes: one task a line of stdin",
787
824
  " harbor add --project P --dry-run reads: says what it would add, and",
788
825
  " adds nothing",
826
+ " harbor add \"TITLE\" --unless-exists writes: only if no open task in the",
827
+ " project has that title (safe to retry)",
828
+ "",
829
+ " --dry-run asks Harbor to add them on a copy that's thrown away, so it",
830
+ " shows each one as it would really be: its likely key, column, owner,",
831
+ " priority, dates, parent and milestone, the words read from its title,",
832
+ " an open task that already has the title, and anything Harbor would",
833
+ " refuse (a full column, an unknown person) with why. It exits 1 when",
834
+ " something would be refused. --json lists the same under tasks.",
835
+ "",
836
+ " --unless-exists skips a title the project already has open (case and",
837
+ " spacing aside), saying which task it found; a done one doesn't count,",
838
+ " so a weekly chore is still added. --json lists added and skipped tasks,",
839
+ " each with its key, the same shape for one title or a hundred.",
789
840
  "",
790
841
  " --project can be left out when the harbor has one open project (First",
791
842
  " voyage aside); the task goes there.",
@@ -794,11 +845,17 @@ const DETAIL = {
794
845
  " venue @Quinn by friday !high #Website\" adds \"Book the venue\" for",
795
846
  " Quinn, due Friday, high priority, in Website. Harbor says what it read.",
796
847
  " A flag wins over the words (they stay in the title), and --literal keeps",
797
- " every title exactly as typed.",
848
+ " every title exactly as typed. What's read: a person after @ or \"assign",
849
+ " to\"; a day after by, due, on, before or until, or as the very last",
850
+ " words (\"Call the mill friday\"), never after to, for or from (\"Move",
851
+ " the call to Friday\" keeps it); !high or \"high priority\"; #Project.",
852
+ " With --dry-run --literal it says what the words would have read as.",
798
853
  "",
799
854
  " --assignee me|none|NAME, --also \"NAME, NAME\" (others on it with the",
800
855
  " owner), --due DATE|pilot, --priority low|medium|high|urgent,",
801
- " --status S, --comment TEXT and --over-limit apply to every task added, so",
856
+ " --status S (backlog, todo, doing, review or done; \"in play\", \"in",
857
+ " progress\" and the board's other words work too), --comment TEXT and",
858
+ " --over-limit apply to every task added, so",
802
859
  " ten lines with --comment add the same comment to all ten. More than 100",
803
860
  " lines is harbor import's job.",
804
861
  "",
@@ -1135,6 +1192,62 @@ const DETAIL = {
1135
1192
  " left out), off track first, with how far",
1136
1193
  " along each key result is",
1137
1194
  ],
1195
+ goal: [
1196
+ ' harbor goal "GOAL" reads: one goal and its key results',
1197
+ ' harbor goal "TITLE" --new [--portfolio P] [--owner NAME] [--due DATE] [--why T]',
1198
+ " writes: sets a new goal on a portfolio",
1199
+ ' harbor goal "GOAL" --title T --owner NAME --due DATE|none --starts DATE|none',
1200
+ " --why T writes: changes it",
1201
+ ' harbor goal "GOAL" --link WORK writes: counts a program, project or',
1202
+ " task (RR-42) toward it; --unlink stops",
1203
+ ' harbor goal "GOAL" --kr "KR" --at N writes: moves a number key result',
1204
+ ' harbor goal "GOAL" --kr "KR" --target N [--start N] [--unit U]',
1205
+ " writes: adds that number key result, or",
1206
+ " changes its numbers",
1207
+ ' harbor goal "GOAL" --kr "KR" --counts "WORK, WORK"',
1208
+ " writes: adds a key result counting work",
1209
+ "",
1210
+ " GOAL is its title, or enough of it to tell it apart. --link and",
1211
+ " --unlink take names separated by commas.",
1212
+ ],
1213
+ milestones: [
1214
+ " harbor milestones [PROGRAM] reads: a program's open milestones",
1215
+ " (every program's when left out),",
1216
+ " soonest first",
1217
+ " harbor milestones --project P reads: the milestones of P's program",
1218
+ "",
1219
+ " Each says its due day and whether it's late, how many of the tasks tied",
1220
+ " to it are done, who signs it off and where that stands, and the",
1221
+ " projects it waits on. --done adds the reached ones.",
1222
+ ],
1223
+ milestone: [
1224
+ ' harbor milestone "NAME" reads: one milestone',
1225
+ ' harbor milestone "NAME" --new --program P --due DATE',
1226
+ " writes: adds it to the program",
1227
+ ' harbor milestone "NAME" --due DATE --rename T',
1228
+ " writes: moves or renames it",
1229
+ ' harbor milestone "NAME" --reached|--open',
1230
+ " writes: ticks it reached, or opens it",
1231
+ ' harbor milestone "NAME" --signs-off NAME|none',
1232
+ " writes: who signs it off (a stage gate)",
1233
+ ' harbor milestone "NAME" --approve | --changes "WHAT"',
1234
+ " writes: your sign-off, if it's yours",
1235
+ "",
1236
+ " A stage gate is reached only by its sign-off, so --reached is refused",
1237
+ " on one. A milestone with tasks tied to it (harbor add --milestone) is",
1238
+ " reached when they're done and opens again when one reopens. Without",
1239
+ " --new a name it doesn't know is refused with the program's milestones,",
1240
+ " so a typo never adds a second one. --program narrows the name when two",
1241
+ " programs use it.",
1242
+ ],
1243
+ reports: [
1244
+ " harbor reports reads: every dashboard on the Reports",
1245
+ " page, each chart as its numbers",
1246
+ " harbor reports DASHBOARD reads: that one",
1247
+ "",
1248
+ " Each chart says what it counts, how it's split and the part of the",
1249
+ " harbor it reads. Dashboards are made and changed on the website.",
1250
+ ],
1138
1251
  soundings: [
1139
1252
  " harbor soundings [PROJECT] reads: open tasks with a sounding",
1140
1253
  " (all projects when left out), and the",
@@ -1338,6 +1451,10 @@ const DETAIL = {
1338
1451
  " One conversation for the whole project, for what isn't about one task;",
1339
1452
  " harbor comment TASK is for that. @Name sends it to that person's Inbox.",
1340
1453
  " Reading shows the newest 50, oldest first; --limit N shows up to 500.",
1454
+ ' harbor messages PROJECT --edit "TEXT" writes: changes your latest message',
1455
+ " there (--message ID: that one; --json",
1456
+ " on a read gives the ids). It shows as",
1457
+ " edited; only the writer can change one.",
1341
1458
  ],
1342
1459
  voyage: [
1343
1460
  " harbor voyage PROJECT reads a Done project's voyage log",
@@ -1541,8 +1658,8 @@ const OTHER_NAMES = {
1541
1658
  longpole: "harbor why PROJECT shows what's holding it up.",
1542
1659
  "long-pole": "harbor why PROJECT shows what's holding it up.",
1543
1660
  pilot: "harbor show TASK says the date its owner's queue suggests; --due pilot on add or edit takes it.",
1544
- milestone: "Milestones belong to a program: in the app (harbor), open the program and press m; or on the website.",
1545
- milestones: "The app's program screen lists them: harbor, then 4, and Enter on a program.",
1661
+ dashboards: "harbor reports lists the Reports page's dashboards, each chart as its numbers.",
1662
+ report: "harbor report PROGRAM is a program's status report; harbor reports lists the dashboards.",
1546
1663
  decide: 'harbor decision "TITLE" --project P logs a decision.',
1547
1664
  dock: "harbor try PROJECT is the dry dock.",
1548
1665
  "dry-dock": "harbor try PROJECT is the dry dock.",
@@ -2309,16 +2426,21 @@ export async function run(argv, io = {}) {
2309
2426
  query: positionals.join(" ") || undefined,
2310
2427
  project: values.project,
2311
2428
  assignee: values.assignee,
2312
- status: values.status
2313
- ? values.status
2314
- .split(",")
2315
- .map((x) => x.trim())
2316
- .filter(Boolean)
2317
- .map(statusWord)
2318
- : undefined,
2429
+ status: values.status ? statusList(values.status) : undefined,
2319
2430
  overdue: values.late || undefined,
2320
2431
  };
2321
- if (!values.all) return call("search_tasks", args, (r) => taskList(r, opts));
2432
+ // Capped at 20 without --all: --json says so too (more, and how to get them),
2433
+ // so a script counting tasks can't take the first page for all of them.
2434
+ if (!values.all)
2435
+ return call(
2436
+ "search_tasks",
2437
+ args,
2438
+ (r) => taskList(r, opts),
2439
+ (r) =>
2440
+ r.total > (r.tasks?.length ?? 0)
2441
+ ? { ...r, more: r.total - r.tasks.length, note: "Capped at 20; --all lists every one." }
2442
+ : r,
2443
+ );
2322
2444
  // --all pages through every match, PAGE at a time (search_tasks' most).
2323
2445
  const PAGE = 50;
2324
2446
  const tasks = [];
@@ -2395,7 +2517,7 @@ export async function run(argv, io = {}) {
2395
2517
  "",
2396
2518
  "Waits on:",
2397
2519
  ...t.waitingOn.map(
2398
- (w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`,
2520
+ (w) => ` ${w.key ?? w.id} ${w.title} (${statusText(w)})`,
2399
2521
  ),
2400
2522
  );
2401
2523
  if (t.waitedOnBy.length)
@@ -2403,7 +2525,7 @@ export async function run(argv, io = {}) {
2403
2525
  "",
2404
2526
  "Holds up:",
2405
2527
  ...t.waitedOnBy.map(
2406
- (w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`,
2528
+ (w) => ` ${w.key ?? w.id} ${w.title} (${statusText(w)})`,
2407
2529
  ),
2408
2530
  );
2409
2531
  if (t.files?.length) lines.push("", "Files:", ...t.files.map((f) => ` ${fileText(f)}`));
@@ -2624,7 +2746,7 @@ export async function run(argv, io = {}) {
2624
2746
  case "add": {
2625
2747
  const { positionals, values, harbor } = parse(
2626
2748
  spec(
2627
- ["over-limit", "dry-run", "literal"],
2749
+ ["over-limit", "dry-run", "literal", "unless-exists"],
2628
2750
  ["project", "assignee", "also", "due", "starts", "priority", "status", "comment", "template", "parent", "milestone"],
2629
2751
  ),
2630
2752
  );
@@ -2662,82 +2784,134 @@ export async function run(argv, io = {}) {
2662
2784
  if (!values.project && !reads.every((r) => r.project))
2663
2785
  values.project = await onlyProject(client, harbor, "--project NAME or #NAME in the title");
2664
2786
  const projectOf = (r) => values.project ?? r.project?.value;
2787
+ // What create_task gets for one line, the same for a dry run and the real thing.
2788
+ const argsFor = (words) => ({
2789
+ project: projectOf(words),
2790
+ title: words.title || undefined,
2791
+ template: values.template,
2792
+ // Yours unless you say otherwise, as with n in the app (a template keeps its own owner).
2793
+ assignee: values.assignee ?? words.owner?.value ?? (values.template ? undefined : "me"),
2794
+ also: values.also === undefined ? undefined : alsoList(values.also),
2795
+ due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
2796
+ suggestedDue: duePilot(values.due),
2797
+ starts: dueDate(values.starts),
2798
+ priority: values.priority ?? words.priority?.value,
2799
+ status: values.status === undefined ? undefined : statusWord(values.status),
2800
+ parent: values.parent,
2801
+ milestone: values.milestone,
2802
+ overLimit: values["over-limit"] || undefined,
2803
+ unlessExists: values["unless-exists"] || undefined,
2804
+ });
2665
2805
  if (values["dry-run"]) {
2666
- const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
2667
- // --due pilot: the date each line would take, each counting the ones before it.
2668
- const pilot = duePilot(values.due)
2669
- ? (
2670
- await client.call("suggest_due", {
2671
- harbor,
2672
- project: values.project,
2673
- assignee: values.assignee ?? "me",
2674
- count: titles.length,
2675
- })
2676
- ).dates
2677
- : null;
2678
- const where = [...new Set(reads.map(projectOf))];
2679
- if (asJson)
2806
+ // Harbor runs each one on a copy and says what it would be; nothing is saved.
2807
+ const preview = await client
2808
+ .call("preview_tasks", { harbor, tasks: reads.map(argsFor) })
2809
+ .catch((err) => {
2810
+ if (err instanceof HarborError) throw new HarborError(hint(err.message, values.project));
2811
+ throw err;
2812
+ });
2813
+ // --literal: what the words would have read as, so it's plain what it kept.
2814
+ const unread =
2815
+ values.literal && !fromTemplate ? await readTitles(client, harbor, titles, values) : null;
2816
+ const lines = preview.tasks.map((p, i) => {
2817
+ const t = p.created;
2818
+ const words = reads[i];
2819
+ return {
2820
+ title: t?.title ?? p.title ?? words.title,
2821
+ ...(t
2822
+ ? {
2823
+ key: t.key ?? null,
2824
+ project: t.project,
2825
+ status: t.status,
2826
+ ...(t.column ? { column: t.column } : {}),
2827
+ assignee: t.assignee,
2828
+ ...(t.alsoOn ? { alsoOn: t.alsoOn } : {}),
2829
+ priority: t.priority,
2830
+ due: t.due,
2831
+ ...(t.starts ? { starts: t.starts } : {}),
2832
+ ...(t.subtaskOf ? { subtaskOf: t.subtaskOf } : {}),
2833
+ ...(t.milestone ? { milestone: t.milestone } : {}),
2834
+ }
2835
+ : {}),
2836
+ ...(values.template ? { template: values.template } : {}),
2837
+ ...(values.comment ? { comment: values.comment } : {}),
2838
+ ...(words.owner || words.due || words.priority || words.project
2839
+ ? {
2840
+ read: {
2841
+ ...(words.owner ? { assignee: words.owner.value, from: words.owner.text } : {}),
2842
+ ...(words.due ? { due: words.due.value } : {}),
2843
+ ...(words.priority ? { priority: words.priority.value } : {}),
2844
+ ...(words.project ? { project: words.project.value } : {}),
2845
+ },
2846
+ }
2847
+ : {}),
2848
+ ...(unread && wordsLine(unread[i]) ? { keptWords: wordsLine(unread[i]) } : {}),
2849
+ ...(p.sameAs ? { sameAs: p.sameAs } : {}),
2850
+ ...(p.sameAs && values["unless-exists"] ? { skipped: true } : {}),
2851
+ ...(p.refused ? { refused: p.refused } : {}),
2852
+ };
2853
+ });
2854
+ const refused = lines.filter((l) => l.refused).length;
2855
+ const skipped = lines.filter((l) => l.skipped).length;
2856
+ const would = lines.length - refused - skipped;
2857
+ if (asJson) {
2858
+ const where = [...new Set(lines.map((l) => l.project).filter(Boolean))];
2680
2859
  out(
2681
2860
  jsonText({
2682
2861
  dryRun: true,
2683
- project: where.length === 1 ? where[0] : values.project,
2684
- titles: reads.map((r) => r.title),
2685
- ...(reads.some((r) => r.owner || r.due || r.priority || r.project)
2686
- ? {
2687
- read: reads.map((r) => ({
2688
- project: projectOf(r),
2689
- ...(r.owner ? { assignee: r.owner.value } : {}),
2690
- ...(r.due ? { due: r.due.value } : {}),
2691
- ...(r.priority ? { priority: r.priority.value } : {}),
2692
- })),
2693
- }
2694
- : {}),
2695
- ...(pilot
2696
- ? { due: pilot.map((d) => d.earliest) }
2697
- : values.due
2698
- ? { due: titles.map(() => dueDate(values.due)) }
2699
- : {}),
2862
+ literal: Boolean(values.literal),
2863
+ project: where.length === 1 ? where[0] : (values.project ?? null),
2864
+ titles: lines.map((l) => l.title),
2865
+ tasks: lines,
2700
2866
  }),
2701
2867
  );
2702
- else
2868
+ } else {
2869
+ const n = (k) => (k === 1 ? "1 task" : `${k} tasks`);
2870
+ const body = lines.flatMap((l, i) => {
2871
+ if (l.refused) return [` ${l.title}`, ` Would be refused: ${l.refused}`];
2872
+ const bits = [
2873
+ l.key ? `as ${l.key}` : null,
2874
+ statusText(l),
2875
+ l.assignee ?? "nobody's",
2876
+ l.alsoOn ? `with ${l.alsoOn.join(", ")}` : null,
2877
+ l.priority !== "medium" ? `${l.priority} priority` : null,
2878
+ l.due ? `due ${dayWithWeekday(l.due)}` : "no due date",
2879
+ l.starts ? `starts ${l.starts}` : null,
2880
+ l.subtaskOf ? `inside ${l.subtaskOf}` : null,
2881
+ l.milestone ? `toward ${l.milestone}` : null,
2882
+ l.project,
2883
+ ].filter(Boolean);
2884
+ const words = reads[i];
2885
+ return [
2886
+ ` ${l.title}${l.skipped ? " (already there, skipped)" : ""}`,
2887
+ ` ${bits.join(" · ")}`,
2888
+ ...(wordsLine(words) ? [` ${wordsLine(words)} --literal keeps them in the title.`] : []),
2889
+ ...(l.keptWords
2890
+ ? [` --literal kept the words as typed. ${l.keptWords.replace("Read from your words", "Without it, they'd read as")}`]
2891
+ : []),
2892
+ ...(l.comment ? [` with your comment: ${l.comment}`] : []),
2893
+ ...(l.sameAs && !l.skipped
2894
+ ? [` ${l.sameAs} is already open with this title; --unless-exists skips it.`]
2895
+ : []),
2896
+ ];
2897
+ });
2898
+ const pilot = duePilot(values.due) && preview.tasks.find((p) => p.pilot)?.pilot;
2703
2899
  out(
2704
2900
  plain(
2705
2901
  [
2706
- `Would add ${n} to ${where.join(" and ")}:`,
2707
- ...reads.flatMap((r, i) => [
2708
- ` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueWords(values.due) ? dayWithWeekday(dueDate(values.due)) : dueDate(values.due)})` : ""}`,
2709
- ...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
2710
- ]),
2711
- ...(pilot
2712
- ? [
2713
- `--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
2714
- ]
2715
- : []),
2902
+ `Would add ${n(would)}${skipped ? `, skip ${skipped}` : ""}${refused ? `; ${refused} would be refused` : ""}. Nothing changed.`,
2903
+ ...body,
2904
+ ...(pilot ? [`--due pilot: ${pilot.says}`] : []),
2716
2905
  ].join("\n"),
2717
2906
  ),
2718
2907
  );
2719
- return 0;
2908
+ }
2909
+ return refused ? 1 : 0;
2720
2910
  }
2721
2911
  const added = [];
2722
2912
  for (const words of reads) {
2723
2913
  const r = await client
2724
- .call("create_task", {
2725
- harbor,
2726
- project: projectOf(words),
2727
- title: words.title || undefined,
2728
- template: values.template,
2729
- // Yours unless you say otherwise, as with n in the app (a template keeps its own owner).
2730
- assignee: values.assignee ?? words.owner?.value ?? (values.template ? undefined : "me"),
2731
- also: values.also === undefined ? undefined : alsoList(values.also),
2732
- due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
2733
- suggestedDue: duePilot(values.due),
2734
- starts: dueDate(values.starts),
2735
- priority: values.priority ?? words.priority?.value,
2736
- status: values.status === undefined ? undefined : statusWord(values.status),
2737
- parent: values.parent,
2738
- milestone: values.milestone,
2739
- overLimit: values["over-limit"] || undefined,
2740
- })
2914
+ .call("create_task", { harbor, ...argsFor(words) })
2741
2915
  .catch((err) => {
2742
2916
  if (err instanceof HarborError) {
2743
2917
  const done = added.length ? ` (added ${added.length} before it)` : "";
@@ -2745,6 +2919,11 @@ export async function run(argv, io = {}) {
2745
2919
  }
2746
2920
  throw err;
2747
2921
  });
2922
+ // --unless-exists found it open already: nothing added, no comment.
2923
+ if (r.existing) {
2924
+ added.push(r);
2925
+ continue;
2926
+ }
2748
2927
  if (values.comment)
2749
2928
  r.comment = (
2750
2929
  await client.call("add_comment", {
@@ -2758,15 +2937,28 @@ export async function run(argv, io = {}) {
2758
2937
  added.push(r);
2759
2938
  }
2760
2939
  await stalePromptCache(env);
2761
- // `added` is always the list, so a script reads one key however many
2762
- // titles it sent; one title also keeps its `created` at the top.
2763
- if (asJson) out(jsonText(added.length === 1 ? { ...added[0], added } : { added }));
2940
+ const made = added.filter((r) => r.created);
2941
+ const kept = added.filter((r) => r.existing);
2942
+ // One shape however many titles: `added` lists what was added and
2943
+ // `skipped` what --unless-exists found open already, each entry with
2944
+ // its key at the top; one title added also keeps its `created`.
2945
+ const entry = (r, t) => ({ key: t.key ?? null, id: t.id, title: t.title, ...r });
2946
+ if (asJson)
2947
+ out(
2948
+ jsonText({
2949
+ ...(made.length === 1 && added.length === 1 ? made[0] : {}),
2950
+ added: made.map((r) => entry(r, r.created)),
2951
+ skipped: kept.map((r) => entry(r, r.existing)),
2952
+ }),
2953
+ );
2764
2954
  else
2765
2955
  out(
2766
2956
  plain(
2767
2957
  added
2768
2958
  .map((r) =>
2769
- [
2959
+ r.existing
2960
+ ? `Already there, not added again: ${taskLine(r.existing, opts)}`
2961
+ : [
2770
2962
  `Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
2771
2963
  // What the title's words said, so nothing is read without being seen.
2772
2964
  ...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
@@ -2776,7 +2968,7 @@ export async function run(argv, io = {}) {
2776
2968
  ...(values.due ? pilotLines(r, r.created) : []),
2777
2969
  ...(r.created.milestone ? [`Toward the milestone ${r.created.milestone}.`] : []),
2778
2970
  ...(r.milestoneReopened ? [`${r.milestoneReopened} is open again: it has an open task now.`] : []),
2779
- ].join("\n"),
2971
+ ].join("\n"),
2780
2972
  )
2781
2973
  .join("\n"),
2782
2974
  ),
@@ -3514,6 +3706,210 @@ export async function run(argv, io = {}) {
3514
3706
  `Saved “${r.template.name}” as a template in ${r.template.project}. harbor add --project "${r.template.project}" --template "${r.template.name}" starts a task from it.`,
3515
3707
  );
3516
3708
  }
3709
+ case "milestones": {
3710
+ const { positionals, values, harbor } = parse(spec(["done"], ["project", "program"]));
3711
+ const program = values.program ?? (positionals.join(" ").trim() || undefined);
3712
+ if (program && values.project) throw new HarborError("Say a program or --project, not both.");
3713
+ return call(
3714
+ "list_milestones",
3715
+ { harbor, program, project: values.project, done: Boolean(values.done) },
3716
+ milestonesText,
3717
+ );
3718
+ }
3719
+ case "milestone": {
3720
+ const { positionals, values, harbor } = parse(
3721
+ spec(
3722
+ ["new", "reached", "open", "approve"],
3723
+ ["program", "due", "rename", "signs-off", "changes"],
3724
+ ),
3725
+ );
3726
+ const name = text(positionals, "the milestone");
3727
+ if (values.reached && values.open) throw new HarborError("Say --reached or --open, not both.");
3728
+ const changes =
3729
+ values.new ||
3730
+ values.reached ||
3731
+ values.open ||
3732
+ values.approve ||
3733
+ ["due", "rename", "signs-off", "changes"].some((k) => values[k] !== undefined);
3734
+ if (!changes) {
3735
+ // Reading one: the program's (or harbor's) milestones, reached ones too.
3736
+ const r = await client.call("list_milestones", { harbor, program: values.program, done: true });
3737
+ const want = name.toLowerCase();
3738
+ const same = r.milestones.filter((m) => m.name.toLowerCase() === want);
3739
+ const near = same.length ? same : r.milestones.filter((m) => m.name.toLowerCase().includes(want));
3740
+ if (near.length !== 1)
3741
+ throw new HarborError(
3742
+ near.length
3743
+ ? `"${name}" could be ${near.map((m) => `${m.name} (${m.program})`).join(" or ")}. Say --program.`
3744
+ : `No milestone matches "${name}". harbor milestones lists them.`,
3745
+ );
3746
+ out(asJson ? jsonText({ milestone: near[0] }) : plain(milestoneLine(near[0])));
3747
+ return 0;
3748
+ }
3749
+ const due = values.due === undefined ? undefined : dueDate(values.due);
3750
+ return call(
3751
+ "set_milestone",
3752
+ {
3753
+ harbor,
3754
+ milestone: name,
3755
+ program: values.program,
3756
+ new: Boolean(values.new),
3757
+ name: values.rename,
3758
+ due,
3759
+ reached: values.reached ? true : values.open ? false : undefined,
3760
+ signOff: values["signs-off"],
3761
+ approve: values.approve || undefined,
3762
+ changes: values.changes,
3763
+ },
3764
+ (r) => (r.added ? `Added to ${r.added.program}: ${milestoneLine(r.added, { program: false })}` : milestoneLine(r.milestone)),
3765
+ );
3766
+ }
3767
+ case "goal": {
3768
+ const { positionals, values, harbor } = parse(
3769
+ spec(
3770
+ ["new"],
3771
+ ["portfolio", "title", "owner", "due", "starts", "why", "link", "unlink", "kr", "at", "target", "start", "unit", "counts"],
3772
+ ),
3773
+ );
3774
+ const ref = text(positionals, "the goal");
3775
+ const num = (flag) => {
3776
+ if (values[flag] === undefined) return undefined;
3777
+ const n = Number(String(values[flag]).replace(/,/g, ""));
3778
+ if (!Number.isFinite(n) || String(values[flag]).trim() === "")
3779
+ throw new HarborError(`--${flag} is a number, like 12 or 2.5.`);
3780
+ return n;
3781
+ };
3782
+ const list = (flag) =>
3783
+ values[flag] === undefined
3784
+ ? undefined
3785
+ : String(values[flag])
3786
+ .split(",")
3787
+ .map((x) => x.trim())
3788
+ .filter(Boolean);
3789
+ const day = (flag) =>
3790
+ values[flag] === undefined ? undefined : /^none$/i.test(values[flag]) ? "none" : dueDate(values[flag]);
3791
+ if (values.new) {
3792
+ if (values.kr && (values.at !== undefined || values.counts === undefined) && values.target === undefined)
3793
+ throw new HarborError("A new number key result needs --target N (or --counts WORK for work).");
3794
+ return call(
3795
+ "set_goal",
3796
+ {
3797
+ harbor,
3798
+ portfolio: values.portfolio,
3799
+ title: ref,
3800
+ owner: values.owner,
3801
+ due: day("due") === "none" ? undefined : day("due"),
3802
+ why: values.why,
3803
+ keyResults: values.kr
3804
+ ? [
3805
+ values.counts !== undefined
3806
+ ? { title: values.kr, kind: "work", counts: list("counts") }
3807
+ : { title: values.kr, kind: "number", start: num("start"), target: num("target"), unit: values.unit },
3808
+ ]
3809
+ : [],
3810
+ },
3811
+ (r) => `Set on ${r.goal.portfolio}: ${r.goal.title} (${r.goal.progress}).${r.goal.keyResults.length ? "" : ` Add a key result with harbor goal "${r.goal.title}" --kr "TITLE" --target N.`}`,
3812
+ );
3813
+ }
3814
+ // The goal as Harbor has it, for its id and key results.
3815
+ const goals = await client.call("list_goals", { harbor, ...(values.portfolio ? { portfolio: values.portfolio } : {}) });
3816
+ const all = (goals.portfolios ?? []).flatMap((pf) => (pf.goals ?? []).map((g) => ({ pf, g })));
3817
+ const want = ref.trim().toLowerCase();
3818
+ const exact = all.filter((x) => x.g.title.trim().toLowerCase() === want || x.g.id === ref);
3819
+ const near = exact.length ? exact : all.filter((x) => x.g.title.toLowerCase().includes(want));
3820
+ if (near.length !== 1)
3821
+ throw new HarborError(
3822
+ near.length
3823
+ ? `"${ref}" could be ${near.map((x) => `"${x.g.title}" (${x.pf.name})`).join(" or ")}. Say more of it, or --portfolio.`
3824
+ : `No goal matches "${ref}".${all.length ? ` harbor goals lists them.` : ` Set one with harbor goal "TITLE" --new.`}`,
3825
+ );
3826
+ const { pf, g } = near[0];
3827
+ const edits = ["title", "owner", "due", "starts", "why", "link", "unlink"].some((k) => values[k] !== undefined);
3828
+ if (!values.kr && !edits) {
3829
+ out(asJson ? jsonText({ goal: g, portfolio: pf.name }) : plain(goalText(g, pf.name)));
3830
+ return 0;
3831
+ }
3832
+ if (!values.kr && ["at", "target", "start", "unit", "counts"].some((k) => values[k] !== undefined))
3833
+ throw new HarborError('Say which key result with --kr "TITLE".');
3834
+ const results = [];
3835
+ if (edits)
3836
+ results.push(
3837
+ await client.call("edit_goal", {
3838
+ harbor,
3839
+ goal: g.id,
3840
+ title: values.title,
3841
+ owner: values.owner,
3842
+ due: day("due"),
3843
+ starts: day("starts"),
3844
+ why: values.why,
3845
+ link: list("link"),
3846
+ unlink: list("unlink"),
3847
+ }),
3848
+ );
3849
+ if (values.kr) {
3850
+ const kr = g.keyResults.find((k) => k.title.trim().toLowerCase() === values.kr.trim().toLowerCase());
3851
+ const shape = ["target", "start", "unit"].some((k) => values[k] !== undefined);
3852
+ if (!kr) {
3853
+ if (values.at !== undefined && !shape)
3854
+ throw new HarborError(
3855
+ `"${g.title}" has no key result "${values.kr}"${g.keyResults.length ? ` (it has ${g.keyResults.map((k) => `"${k.title}"`).join(", ")})` : ""}. --target N adds one.`,
3856
+ );
3857
+ if (values.counts === undefined && values.target === undefined)
3858
+ throw new HarborError("A new key result needs --target N for a number, or --counts WORK to count work.");
3859
+ results.push(
3860
+ await client.call("set_goal", {
3861
+ harbor,
3862
+ portfolio: pf.name,
3863
+ goal: g.id,
3864
+ keyResults: [
3865
+ values.counts !== undefined
3866
+ ? { title: values.kr, kind: "work", counts: list("counts") }
3867
+ : { title: values.kr, kind: "number", start: num("start"), target: num("target"), unit: values.unit },
3868
+ ],
3869
+ }),
3870
+ );
3871
+ } else {
3872
+ if (shape || values.counts !== undefined)
3873
+ results.push(
3874
+ await client.call("edit_goal", {
3875
+ harbor,
3876
+ goal: g.id,
3877
+ keyResult: { ref: kr.id, start: num("start"), target: num("target"), unit: values.unit },
3878
+ ...(values.counts !== undefined ? { link: list("counts") } : {}),
3879
+ }),
3880
+ );
3881
+ if (values.at !== undefined) {
3882
+ if (kr.kind !== "number")
3883
+ throw new HarborError(`"${kr.title}" counts work, so it moves as its tasks finish.`);
3884
+ results.push(
3885
+ await client.call("update_key_result", { harbor, goal: g.id, keyResult: kr.id, value: num("at") }),
3886
+ );
3887
+ }
3888
+ if (values.at === undefined && !shape && values.counts === undefined)
3889
+ throw new HarborError(`Say what to do with "${kr.title}": --at N, or --target N to change its numbers.`);
3890
+ }
3891
+ }
3892
+ const last = results.at(-1);
3893
+ const goal = last.goal;
3894
+ if (asJson) out(jsonText(last));
3895
+ else
3896
+ out(
3897
+ plain(
3898
+ [
3899
+ `${goal.title}: ${goal.progress}${goal.owner !== undefined ? ` · ${goal.owner ?? "nobody"}` : ""}${goal.due ? ` · by ${goal.due}` : ""}.`,
3900
+ ...(last.keyResult
3901
+ ? [` ${last.keyResult.title} · ${last.keyResult.text}`]
3902
+ : (goal.keyResults ?? []).map((k) => ` ${k.title} · ${k.text}`)),
3903
+ ].join("\n"),
3904
+ ),
3905
+ );
3906
+ return 0;
3907
+ }
3908
+ case "reports": {
3909
+ const { positionals, harbor } = parse(spec());
3910
+ const dashboard = positionals.join(" ").trim();
3911
+ return call("list_dashboards", { harbor, ...(dashboard ? { dashboard } : {}) }, reportsText);
3912
+ }
3517
3913
  case "goals": {
3518
3914
  const { positionals, harbor } = parse(spec());
3519
3915
  const portfolio = positionals.join(" ").trim();
@@ -3610,13 +4006,25 @@ export async function run(argv, io = {}) {
3610
4006
  return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
3611
4007
  }
3612
4008
  case "messages": {
3613
- const { positionals, values, harbor } = parse(spec([], ["limit"]));
4009
+ const { positionals, values, harbor } = parse(spec([], ["limit", "edit", "message"]));
3614
4010
  const [project, ...words] = positionals;
3615
4011
  if (!project)
3616
4012
  throw new HarborError(
3617
4013
  'Give a project, and a message if you\'re writing one: harbor messages "NAME" "TEXT".',
3618
4014
  );
3619
4015
  const body = words.join(" ").trim();
4016
+ if (values.edit !== undefined || values.message !== undefined) {
4017
+ const edit = (values.edit ?? "").trim() || body;
4018
+ if (!edit) throw new HarborError('Say the new words: harbor messages "NAME" --edit "TEXT".');
4019
+ if (values.edit && body) throw new HarborError("Give the new words once, after --edit.");
4020
+ if (values.limit) throw new HarborError("--limit is for reading; leave it off when you edit.");
4021
+ return call(
4022
+ "edit_message",
4023
+ { harbor, project, body: edit, ...(values.message ? { message: values.message } : {}) },
4024
+ (r) =>
4025
+ `Changed your message in ${r.edited.project}${r.mentioned?.length ? `; told ${r.mentioned.join(", ")}` : ""}.`,
4026
+ );
4027
+ }
3620
4028
  if (body) {
3621
4029
  if (values.limit)
3622
4030
  throw new HarborError("--limit is for reading; leave it off when you write.");