@harborppm/cli 0.97.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,
@@ -35,12 +35,17 @@ import {
35
35
  notesText,
36
36
  messagesText,
37
37
  charterText,
38
+ landfallText,
38
39
  daysText,
39
40
  soundingText,
40
41
  lifeboatsText,
41
42
  followedText,
42
43
  soundingsText,
43
44
  goalsText,
45
+ goalText,
46
+ milestonesText,
47
+ milestoneLine,
48
+ reportsText,
44
49
  templatesText,
45
50
  approvalsText,
46
51
  thanksText,
@@ -171,7 +176,8 @@ export const HELP = `harbor: Harbor from the terminal
171
176
  [--also "NAME, NAME"] [--due DATE|pilot] [--starts DATE]
172
177
  [--priority low|medium|high|urgent]
173
178
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
174
- [--literal] [--template NAME] [--parent TASK]
179
+ [--unless-exists] [--literal] [--template NAME]
180
+ [--parent TASK]
175
181
  [--milestone NAME]
176
182
  reads plain words in TITLE as the
177
183
  website does: @name, by friday,
@@ -258,11 +264,19 @@ export const HELP = `harbor: Harbor from the terminal
258
264
  harbor messages PROJECT ["TEXT"] [--limit N]
259
265
  a project's messages, or write
260
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
261
270
  harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
262
271
  [--calls NAME]
263
272
  a project's five-line charter
264
273
  (why, in, out, done when, who
265
274
  makes the calls): read or set
275
+ harbor landfall PROJECT [--expect T] [--after 30|90|180] [--measure T]
276
+ [--answer yes|partly|no] [--value N] [--note T]
277
+ did a Done project pay off? say
278
+ what should be true, and answer
279
+ on the day
266
280
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
267
281
  a note for later: sealed until
268
282
  its day, milestone, or Off track
@@ -274,6 +288,23 @@ export const HELP = `harbor: Harbor from the terminal
274
288
  the ones waiting on your answer
275
289
  harbor goals [PORTFOLIO] each portfolio's goals, how far
276
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
277
308
  harbor templates [PROJECT] the task templates new tasks
278
309
  start from (harbor add --template)
279
310
  harbor template TASK [--name NAME] save a task as a template in its
@@ -320,8 +351,10 @@ export const HELP = `harbor: Harbor from the terminal
320
351
 
321
352
  Every command takes --json, and --harbor NAME when you're in more than one
322
353
  harbor (or set one with harbor use). harbor help COMMAND shows just that one.
323
- Statuses: backlog, todo, doing, review, done (the board's words work too:
324
- "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.
325
358
  DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
326
359
  Changes show in Harbor's History as yours, "via Harbor CLI". These commands
327
360
  never delete; the full-screen app can, as the website can, and asks first,
@@ -609,6 +642,16 @@ function dueWords(v) {
609
642
  return /^\d{4}-\d{2}-\d{2}$/.test(dueDate(v));
610
643
  }
611
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
+
612
655
  /** --due pilot: the date the owner's queue suggests, set by Harbor. */
613
656
  const PILOT = new Set(["pilot", "suggested"]);
614
657
  const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
@@ -780,6 +823,20 @@ const DETAIL = {
780
823
  " harbor add --project P < titles.txt writes: one task a line of stdin",
781
824
  " harbor add --project P --dry-run reads: says what it would add, and",
782
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.",
783
840
  "",
784
841
  " --project can be left out when the harbor has one open project (First",
785
842
  " voyage aside); the task goes there.",
@@ -788,11 +845,17 @@ const DETAIL = {
788
845
  " venue @Quinn by friday !high #Website\" adds \"Book the venue\" for",
789
846
  " Quinn, due Friday, high priority, in Website. Harbor says what it read.",
790
847
  " A flag wins over the words (they stay in the title), and --literal keeps",
791
- " 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.",
792
853
  "",
793
854
  " --assignee me|none|NAME, --also \"NAME, NAME\" (others on it with the",
794
855
  " owner), --due DATE|pilot, --priority low|medium|high|urgent,",
795
- " --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",
796
859
  " ten lines with --comment add the same comment to all ten. More than 100",
797
860
  " lines is harbor import's job.",
798
861
  "",
@@ -1025,6 +1088,24 @@ const DETAIL = {
1025
1088
  " --no-people. A task already in its project is left alone: by its key",
1026
1089
  " (a Key column, as harbor export writes) or by title.",
1027
1090
  ],
1091
+ landfall: [
1092
+ " harbor landfall PROJECT reads its landfall: what should be",
1093
+ " true some days after it's Done,",
1094
+ " when Harbor asks, and the answer",
1095
+ " Say what landfall looks like:",
1096
+ ' --expect "TEXT" what should be true by then',
1097
+ " --after 30|90|180 days after the project is marked Done to look",
1098
+ " (90 unless you say)",
1099
+ ' --measure "TEXT" what the number counts, if there is one',
1100
+ " Answer it (on its day, or sooner):",
1101
+ " --answer yes|partly|no",
1102
+ " --value N the number on the day",
1103
+ ' --note "TEXT" what you found',
1104
+ " --clear takes the landfall off (with --answer, only the answer), so it",
1105
+ " asks again. The owner is asked in their Inbox on the day; the portfolio",
1106
+ " counts the voyages that paid off. PROJECT is a name, a key like RR, or",
1107
+ " an id.",
1108
+ ],
1028
1109
  charter: [
1029
1110
  " harbor charter PROJECT reads its charter, and any open task",
1030
1111
  " that names something it leaves out",
@@ -1111,6 +1192,62 @@ const DETAIL = {
1111
1192
  " left out), off track first, with how far",
1112
1193
  " along each key result is",
1113
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
+ ],
1114
1251
  soundings: [
1115
1252
  " harbor soundings [PROJECT] reads: open tasks with a sounding",
1116
1253
  " (all projects when left out), and the",
@@ -1314,6 +1451,10 @@ const DETAIL = {
1314
1451
  " One conversation for the whole project, for what isn't about one task;",
1315
1452
  " harbor comment TASK is for that. @Name sends it to that person's Inbox.",
1316
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.",
1317
1458
  ],
1318
1459
  voyage: [
1319
1460
  " harbor voyage PROJECT reads a Done project's voyage log",
@@ -1517,8 +1658,8 @@ const OTHER_NAMES = {
1517
1658
  longpole: "harbor why PROJECT shows what's holding it up.",
1518
1659
  "long-pole": "harbor why PROJECT shows what's holding it up.",
1519
1660
  pilot: "harbor show TASK says the date its owner's queue suggests; --due pilot on add or edit takes it.",
1520
- milestone: "Milestones belong to a program: in the app (harbor), open the program and press m; or on the website.",
1521
- 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.",
1522
1663
  decide: 'harbor decision "TITLE" --project P logs a decision.',
1523
1664
  dock: "harbor try PROJECT is the dry dock.",
1524
1665
  "dry-dock": "harbor try PROJECT is the dry dock.",
@@ -2285,16 +2426,21 @@ export async function run(argv, io = {}) {
2285
2426
  query: positionals.join(" ") || undefined,
2286
2427
  project: values.project,
2287
2428
  assignee: values.assignee,
2288
- status: values.status
2289
- ? values.status
2290
- .split(",")
2291
- .map((x) => x.trim())
2292
- .filter(Boolean)
2293
- .map(statusWord)
2294
- : undefined,
2429
+ status: values.status ? statusList(values.status) : undefined,
2295
2430
  overdue: values.late || undefined,
2296
2431
  };
2297
- 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
+ );
2298
2444
  // --all pages through every match, PAGE at a time (search_tasks' most).
2299
2445
  const PAGE = 50;
2300
2446
  const tasks = [];
@@ -2371,7 +2517,7 @@ export async function run(argv, io = {}) {
2371
2517
  "",
2372
2518
  "Waits on:",
2373
2519
  ...t.waitingOn.map(
2374
- (w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`,
2520
+ (w) => ` ${w.key ?? w.id} ${w.title} (${statusText(w)})`,
2375
2521
  ),
2376
2522
  );
2377
2523
  if (t.waitedOnBy.length)
@@ -2379,7 +2525,7 @@ export async function run(argv, io = {}) {
2379
2525
  "",
2380
2526
  "Holds up:",
2381
2527
  ...t.waitedOnBy.map(
2382
- (w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`,
2528
+ (w) => ` ${w.key ?? w.id} ${w.title} (${statusText(w)})`,
2383
2529
  ),
2384
2530
  );
2385
2531
  if (t.files?.length) lines.push("", "Files:", ...t.files.map((f) => ` ${fileText(f)}`));
@@ -2600,7 +2746,7 @@ export async function run(argv, io = {}) {
2600
2746
  case "add": {
2601
2747
  const { positionals, values, harbor } = parse(
2602
2748
  spec(
2603
- ["over-limit", "dry-run", "literal"],
2749
+ ["over-limit", "dry-run", "literal", "unless-exists"],
2604
2750
  ["project", "assignee", "also", "due", "starts", "priority", "status", "comment", "template", "parent", "milestone"],
2605
2751
  ),
2606
2752
  );
@@ -2638,82 +2784,134 @@ export async function run(argv, io = {}) {
2638
2784
  if (!values.project && !reads.every((r) => r.project))
2639
2785
  values.project = await onlyProject(client, harbor, "--project NAME or #NAME in the title");
2640
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
+ });
2641
2805
  if (values["dry-run"]) {
2642
- const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
2643
- // --due pilot: the date each line would take, each counting the ones before it.
2644
- const pilot = duePilot(values.due)
2645
- ? (
2646
- await client.call("suggest_due", {
2647
- harbor,
2648
- project: values.project,
2649
- assignee: values.assignee ?? "me",
2650
- count: titles.length,
2651
- })
2652
- ).dates
2653
- : null;
2654
- const where = [...new Set(reads.map(projectOf))];
2655
- 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))];
2656
2859
  out(
2657
2860
  jsonText({
2658
2861
  dryRun: true,
2659
- project: where.length === 1 ? where[0] : values.project,
2660
- titles: reads.map((r) => r.title),
2661
- ...(reads.some((r) => r.owner || r.due || r.priority || r.project)
2662
- ? {
2663
- read: reads.map((r) => ({
2664
- project: projectOf(r),
2665
- ...(r.owner ? { assignee: r.owner.value } : {}),
2666
- ...(r.due ? { due: r.due.value } : {}),
2667
- ...(r.priority ? { priority: r.priority.value } : {}),
2668
- })),
2669
- }
2670
- : {}),
2671
- ...(pilot
2672
- ? { due: pilot.map((d) => d.earliest) }
2673
- : values.due
2674
- ? { due: titles.map(() => dueDate(values.due)) }
2675
- : {}),
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,
2676
2866
  }),
2677
2867
  );
2678
- 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;
2679
2899
  out(
2680
2900
  plain(
2681
2901
  [
2682
- `Would add ${n} to ${where.join(" and ")}:`,
2683
- ...reads.flatMap((r, i) => [
2684
- ` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueWords(values.due) ? dayWithWeekday(dueDate(values.due)) : dueDate(values.due)})` : ""}`,
2685
- ...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
2686
- ]),
2687
- ...(pilot
2688
- ? [
2689
- `--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
2690
- ]
2691
- : []),
2902
+ `Would add ${n(would)}${skipped ? `, skip ${skipped}` : ""}${refused ? `; ${refused} would be refused` : ""}. Nothing changed.`,
2903
+ ...body,
2904
+ ...(pilot ? [`--due pilot: ${pilot.says}`] : []),
2692
2905
  ].join("\n"),
2693
2906
  ),
2694
2907
  );
2695
- return 0;
2908
+ }
2909
+ return refused ? 1 : 0;
2696
2910
  }
2697
2911
  const added = [];
2698
2912
  for (const words of reads) {
2699
2913
  const r = await client
2700
- .call("create_task", {
2701
- harbor,
2702
- project: projectOf(words),
2703
- title: words.title || undefined,
2704
- template: values.template,
2705
- // Yours unless you say otherwise, as with n in the app (a template keeps its own owner).
2706
- assignee: values.assignee ?? words.owner?.value ?? (values.template ? undefined : "me"),
2707
- also: values.also === undefined ? undefined : alsoList(values.also),
2708
- due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
2709
- suggestedDue: duePilot(values.due),
2710
- starts: dueDate(values.starts),
2711
- priority: values.priority ?? words.priority?.value,
2712
- status: values.status === undefined ? undefined : statusWord(values.status),
2713
- parent: values.parent,
2714
- milestone: values.milestone,
2715
- overLimit: values["over-limit"] || undefined,
2716
- })
2914
+ .call("create_task", { harbor, ...argsFor(words) })
2717
2915
  .catch((err) => {
2718
2916
  if (err instanceof HarborError) {
2719
2917
  const done = added.length ? ` (added ${added.length} before it)` : "";
@@ -2721,6 +2919,11 @@ export async function run(argv, io = {}) {
2721
2919
  }
2722
2920
  throw err;
2723
2921
  });
2922
+ // --unless-exists found it open already: nothing added, no comment.
2923
+ if (r.existing) {
2924
+ added.push(r);
2925
+ continue;
2926
+ }
2724
2927
  if (values.comment)
2725
2928
  r.comment = (
2726
2929
  await client.call("add_comment", {
@@ -2734,15 +2937,28 @@ export async function run(argv, io = {}) {
2734
2937
  added.push(r);
2735
2938
  }
2736
2939
  await stalePromptCache(env);
2737
- // `added` is always the list, so a script reads one key however many
2738
- // titles it sent; one title also keeps its `created` at the top.
2739
- 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
+ );
2740
2954
  else
2741
2955
  out(
2742
2956
  plain(
2743
2957
  added
2744
2958
  .map((r) =>
2745
- [
2959
+ r.existing
2960
+ ? `Already there, not added again: ${taskLine(r.existing, opts)}`
2961
+ : [
2746
2962
  `Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
2747
2963
  // What the title's words said, so nothing is read without being seen.
2748
2964
  ...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
@@ -2752,7 +2968,7 @@ export async function run(argv, io = {}) {
2752
2968
  ...(values.due ? pilotLines(r, r.created) : []),
2753
2969
  ...(r.created.milestone ? [`Toward the milestone ${r.created.milestone}.`] : []),
2754
2970
  ...(r.milestoneReopened ? [`${r.milestoneReopened} is open again: it has an open task now.`] : []),
2755
- ].join("\n"),
2971
+ ].join("\n"),
2756
2972
  )
2757
2973
  .join("\n"),
2758
2974
  ),
@@ -3286,6 +3502,70 @@ export async function run(argv, io = {}) {
3286
3502
  : `Cleared ${r.project}'s charter.`,
3287
3503
  );
3288
3504
  }
3505
+ case "landfall": {
3506
+ const { positionals, values, harbor } = parse(
3507
+ spec(["clear"], ["expect", "after", "measure", "answer", "value", "note"]),
3508
+ );
3509
+ const project = one(positionals, 'project: harbor landfall "NAME"');
3510
+ const answering =
3511
+ values.answer !== undefined || values.value !== undefined || values.note !== undefined;
3512
+ const setting =
3513
+ values.expect !== undefined || values.after !== undefined || values.measure !== undefined;
3514
+ if (answering && setting) {
3515
+ throw new HarborError("Set the landfall or answer it, one at a time.");
3516
+ }
3517
+ const show = (r) => landfallText(r.project ?? r.name, r.landfall);
3518
+ const json = (r) => ({ project: r.project ?? r.name, landfall: r.landfall ?? null });
3519
+ if (answering || (values.clear && values.answer !== undefined)) {
3520
+ if (values.clear) {
3521
+ return call("answer_landfall", { harbor, project, clear: true }, show, json);
3522
+ }
3523
+ const result = String(values.answer ?? "").toLowerCase();
3524
+ if (!["yes", "partly", "no"].includes(result)) {
3525
+ throw new HarborError("Say --answer yes, partly or no.");
3526
+ }
3527
+ let value;
3528
+ if (values.value !== undefined) {
3529
+ value = Number(String(values.value).replace(/,/g, ""));
3530
+ if (!Number.isFinite(value)) throw new HarborError("--value takes a number, like 120.");
3531
+ }
3532
+ return call(
3533
+ "answer_landfall",
3534
+ {
3535
+ harbor,
3536
+ project,
3537
+ result,
3538
+ ...(value !== undefined ? { value } : {}),
3539
+ ...(values.note !== undefined ? { note: values.note } : {}),
3540
+ },
3541
+ show,
3542
+ json,
3543
+ );
3544
+ }
3545
+ if (values.clear) return call("set_landfall", { harbor, project, clear: true }, show, json);
3546
+ if (setting) {
3547
+ let afterDays;
3548
+ if (values.after !== undefined) {
3549
+ afterDays = Number(String(values.after).replace(/\s*days?$/i, ""));
3550
+ if (![30, 90, 180].includes(afterDays)) {
3551
+ throw new HarborError("--after takes 30, 90 or 180 (days after the project is Done).");
3552
+ }
3553
+ }
3554
+ return call(
3555
+ "set_landfall",
3556
+ {
3557
+ harbor,
3558
+ project,
3559
+ ...(values.expect !== undefined ? { expect: values.expect } : {}),
3560
+ ...(afterDays !== undefined ? { afterDays } : {}),
3561
+ ...(values.measure !== undefined ? { measure: values.measure } : {}),
3562
+ },
3563
+ show,
3564
+ json,
3565
+ );
3566
+ }
3567
+ return call("get_project", { harbor, project }, show, json);
3568
+ }
3289
3569
  case "sound": {
3290
3570
  const { positionals, values, harbor } = parse(spec([], ["answer", "why", "ask", "settle"]));
3291
3571
  const [task, ...extra] = positionals;
@@ -3426,6 +3706,210 @@ export async function run(argv, io = {}) {
3426
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.`,
3427
3707
  );
3428
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
+ }
3429
3913
  case "goals": {
3430
3914
  const { positionals, harbor } = parse(spec());
3431
3915
  const portfolio = positionals.join(" ").trim();
@@ -3522,13 +4006,25 @@ export async function run(argv, io = {}) {
3522
4006
  return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
3523
4007
  }
3524
4008
  case "messages": {
3525
- const { positionals, values, harbor } = parse(spec([], ["limit"]));
4009
+ const { positionals, values, harbor } = parse(spec([], ["limit", "edit", "message"]));
3526
4010
  const [project, ...words] = positionals;
3527
4011
  if (!project)
3528
4012
  throw new HarborError(
3529
4013
  'Give a project, and a message if you\'re writing one: harbor messages "NAME" "TEXT".',
3530
4014
  );
3531
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
+ }
3532
4028
  if (body) {
3533
4029
  if (values.limit)
3534
4030
  throw new HarborError("--limit is for reading; leave it off when you write.");