@harborppm/cli 0.76.0 → 0.78.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/dist/words.mjs ADDED
@@ -0,0 +1,301 @@
1
+ // Built from src/lib/plain-words.ts by cli/build.mjs. Edit the source, not this file.
2
+ // src/lib/plain-words.ts
3
+ const DAYS = [
4
+ "sunday",
5
+ "monday",
6
+ "tuesday",
7
+ "wednesday",
8
+ "thursday",
9
+ "friday",
10
+ "saturday"
11
+ ];
12
+ const MONTHS = [
13
+ "january",
14
+ "february",
15
+ "march",
16
+ "april",
17
+ "may",
18
+ "june",
19
+ "july",
20
+ "august",
21
+ "september",
22
+ "october",
23
+ "november",
24
+ "december"
25
+ ];
26
+ const NUMBER_WORDS = {
27
+ a: 1,
28
+ an: 1,
29
+ one: 1,
30
+ two: 2,
31
+ three: 3,
32
+ four: 4,
33
+ five: 5,
34
+ six: 6,
35
+ seven: 7,
36
+ eight: 8,
37
+ nine: 9,
38
+ ten: 10
39
+ };
40
+ const loose = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
41
+ function shift(iso, days) {
42
+ const d = /* @__PURE__ */ new Date(`${iso}T12:00:00Z`);
43
+ d.setUTCDate(d.getUTCDate() + days);
44
+ return d.toISOString().slice(0, 10);
45
+ }
46
+ function weekday(iso) {
47
+ return (/* @__PURE__ */ new Date(`${iso}T12:00:00Z`)).getUTCDay();
48
+ }
49
+ function dayOf(y, m, d) {
50
+ const date = new Date(Date.UTC(y, m - 1, d, 12));
51
+ if (date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
52
+ return date.toISOString().slice(0, 10);
53
+ }
54
+ /** A month and day with no year: this year's, or next year's once it has passed. */
55
+ function nextMonthDay(today, m, d) {
56
+ const year = Number(today.slice(0, 4));
57
+ const thisYear = dayOf(year, m, d);
58
+ if (!thisYear) return null;
59
+ return thisYear >= today ? thisYear : dayOf(year + 1, m, d);
60
+ }
61
+ const monthIndex = (word) => {
62
+ const w = word.toLowerCase().replace(/\.$/, "");
63
+ if (w.length < 3) return -1;
64
+ return MONTHS.findIndex((m) => m.startsWith(w) && (w.length >= 3 || m === w));
65
+ };
66
+ const DAY_WORD = "(?:sun|mon|tues?|wed(?:nes)?|thu(?:rs?)?|fri|sat(?:ur)?)(?:day)?";
67
+ const MONTH_WORD = "(?:jan|feb|mar|apr|may|jun|jul|aug|sept?|oct|nov|dec)[a-z]*\\.?";
68
+ /** Every way of saying a day, longest first so "next friday" wins over "friday". */
69
+ const WHEN = [
70
+ "today",
71
+ "tonight",
72
+ "tomorrow",
73
+ "tmrw",
74
+ "the day after tomorrow",
75
+ "end of (?:the )?week",
76
+ "this week",
77
+ "next week",
78
+ "end of (?:the )?month",
79
+ "next month",
80
+ `in (?:\\d{1,2}|a|an|one|two|three|four|five|six|seven|eight|nine|ten) (?:days?|weeks?)`,
81
+ `(?:this |next |on )?${DAY_WORD}`,
82
+ `${MONTH_WORD} \\d{1,2}(?:st|nd|rd|th)?(?:,? \\d{4})?`,
83
+ `\\d{1,2}(?:st|nd|rd|th)? (?:of )?${MONTH_WORD}(?:,? \\d{4})?`,
84
+ "\\d{4}-\\d{1,2}-\\d{1,2}",
85
+ "\\d{1,2}/\\d{1,2}(?:/\\d{2,4})?"
86
+ ].join("|");
87
+ const LEAD = "(?:by|due|due on|due by|on|before|until|till)";
88
+ /** The day a phrase names, or null when it names none. */
89
+ function dayFromWords(phrase, today) {
90
+ const p = phrase.toLowerCase().replace(/\s+/g, " ").replace(/^on /, "").trim();
91
+ if (p === "today" || p === "tonight") return today;
92
+ if (p === "tomorrow" || p === "tmrw") return shift(today, 1);
93
+ if (p === "the day after tomorrow") return shift(today, 2);
94
+ const dow = weekday(today);
95
+ if (/^end of (the )?week$/.test(p) || p === "this week") return shift(today, (5 - dow + 7) % 7);
96
+ if (p === "next week") return shift(today, (1 - dow + 7) % 7 || 7);
97
+ if (p === "next month") {
98
+ const [y, m] = [Number(today.slice(0, 4)), Number(today.slice(5, 7))];
99
+ return m === 12 ? dayOf(y + 1, 1, 1) : dayOf(y, m + 1, 1);
100
+ }
101
+ if (/^end of (the )?month$/.test(p)) {
102
+ const [y, m] = [Number(today.slice(0, 4)), Number(today.slice(5, 7))];
103
+ return shift(m === 12 ? dayOf(y + 1, 1, 1) : dayOf(y, m + 1, 1), -1);
104
+ }
105
+ let m = p.match(/^in (\w+) (day|week)s?$/);
106
+ if (m) {
107
+ const n = /^\d+$/.test(m[1]) ? Number(m[1]) : NUMBER_WORDS[m[1]];
108
+ if (n == null || n > 366) return null;
109
+ return shift(today, m[2] === "week" ? n * 7 : n);
110
+ }
111
+ m = p.match(/^(this |next )?([a-z]+)$/);
112
+ if (m) {
113
+ const target = DAYS.findIndex((d) => d.startsWith(m[2].slice(0, 3)) && m[2].length >= 3);
114
+ if (target >= 0 && new RegExp(`^${DAY_WORD}$`).test(m[2])) {
115
+ const ahead = (target - dow + 7) % 7 || 7;
116
+ return shift(today, m[1] === "next " ? ahead + 7 : ahead);
117
+ }
118
+ }
119
+ m = p.match(/^([a-z]+\.?) (\d{1,2})(?:st|nd|rd|th)?(?:,? (\d{4}))?$/);
120
+ if (m && monthIndex(m[1]) >= 0) {
121
+ const month = monthIndex(m[1]) + 1;
122
+ return m[3] ? dayOf(Number(m[3]), month, Number(m[2])) : nextMonthDay(today, month, Number(m[2]));
123
+ }
124
+ m = p.match(/^(\d{1,2})(?:st|nd|rd|th)? (?:of )?([a-z]+\.?)(?:,? (\d{4}))?$/);
125
+ if (m && monthIndex(m[2]) >= 0) {
126
+ const month = monthIndex(m[2]) + 1;
127
+ return m[3] ? dayOf(Number(m[3]), month, Number(m[1])) : nextMonthDay(today, month, Number(m[1]));
128
+ }
129
+ m = p.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
130
+ if (m) return dayOf(Number(m[1]), Number(m[2]), Number(m[3]));
131
+ m = p.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
132
+ if (m) {
133
+ if (!m[3]) return nextMonthDay(today, Number(m[1]), Number(m[2]));
134
+ return dayOf(Number(m[3]) < 100 ? 2e3 + Number(m[3]) : Number(m[3]), Number(m[1]), Number(m[2]));
135
+ }
136
+ return null;
137
+ }
138
+ const PRIORITY_WORDS = {
139
+ urgent: "urgent",
140
+ asap: "urgent",
141
+ top: "urgent",
142
+ p0: "urgent",
143
+ high: "high",
144
+ p1: "high",
145
+ medium: "medium",
146
+ normal: "medium",
147
+ p2: "medium",
148
+ low: "low",
149
+ p3: "low"
150
+ };
151
+ /** Removes found spans from the text, tidying the spaces and commas they leave. */
152
+ function without(text, spans) {
153
+ let out = text;
154
+ for (const s of [...spans].sort((a, b) => b.start - a.start)) out = `${out.slice(0, s.start)} ${out.slice(s.end)}`;
155
+ return out.replace(/\s+([,.;:!?])/g, "$1").replace(/[,;:]+([,.;:!?])/g, "$1").replace(/\s{2,}/g, " ").replace(/(?:[\s,;:–—-]+)$/u, "").replace(/^[\s,;:]+/, "").trim();
156
+ }
157
+ /** What a new task's words say, and the title they leave. */
158
+ function readTaskWords(input, ctx) {
159
+ const skip = ctx.skip ?? /* @__PURE__ */ new Set();
160
+ const text = input;
161
+ const taken = [];
162
+ const free = (start, end) => !taken.some((t) => start < t.end && end > t.start);
163
+ let priority = null;
164
+ if (!skip.has("priority")) for (const re of [
165
+ /(?:^|\s)!(urgent|asap|high|medium|normal|low|p[0-3])\b/gi,
166
+ /(?:^|[\s,])(urgent|top|high|medium|normal|low) priority\b/gi,
167
+ /(?:^|[\s,])priority (urgent|high|medium|normal|low|p[0-3])\b/gi,
168
+ /(?:^|[\s,])(urgent|asap)$/gi
169
+ ]) {
170
+ const m = re.exec(text);
171
+ if (m) {
172
+ const lead = m[0].length - m[0].trimStart().length;
173
+ const start = m.index + lead + (m[0].trimStart().startsWith(",") ? 1 : 0);
174
+ priority = {
175
+ value: PRIORITY_WORDS[m[1].toLowerCase()],
176
+ start,
177
+ end: m.index + m[0].length
178
+ };
179
+ taken.push(priority);
180
+ break;
181
+ }
182
+ }
183
+ let project = null;
184
+ if (!skip.has("project")) for (const m of text.matchAll(/(?:^|\s)#([\p{L}\p{N}_-]+)/gu)) {
185
+ const key = loose(m[1]);
186
+ if (!key) continue;
187
+ const exact = ctx.projects.filter((p) => loose(p.name) === key);
188
+ const begins = ctx.projects.filter((p) => loose(p.name).startsWith(key));
189
+ const hit = exact.length === 1 ? exact[0] : begins.length === 1 ? begins[0] : null;
190
+ if (!hit) continue;
191
+ const start = m.index + (m[0].length - m[0].trimStart().length);
192
+ project = {
193
+ value: hit.id,
194
+ start,
195
+ end: m.index + m[0].length
196
+ };
197
+ taken.push(project);
198
+ break;
199
+ }
200
+ let owner = null;
201
+ if (!skip.has("owner")) {
202
+ const byKey = (key) => {
203
+ const full = ctx.people.filter((p) => loose(p.name) === key);
204
+ if (full.length === 1) return full[0];
205
+ const first = ctx.people.filter((p) => loose(p.name.split(/\s+/)[0] ?? "") === key);
206
+ if (first.length === 1) return first[0];
207
+ const begins = ctx.people.filter((p) => loose(p.name).startsWith(key));
208
+ return key.length >= 3 && begins.length === 1 ? begins[0] : null;
209
+ };
210
+ for (const m of text.matchAll(/(?:^|\s)@([\p{L}\p{N}._-]+)/gu)) {
211
+ const hit = byKey(loose(m[1]));
212
+ if (!hit) continue;
213
+ const start = m.index + (m[0].length - m[0].trimStart().length);
214
+ if (!free(start, m.index + m[0].length)) continue;
215
+ owner = {
216
+ value: hit.id,
217
+ start,
218
+ end: m.index + m[0].length
219
+ };
220
+ break;
221
+ }
222
+ if (!owner) for (const m of text.matchAll(/(?:^|[\s,])(?:assign(?:ed)? (?:it )?to|owner(?: is)?:?) (\p{L}+(?: \p{L}+)?)/giu)) {
223
+ const words = m[1].split(" ");
224
+ const two = words.length === 2 ? byKey(loose(m[1])) : null;
225
+ const one = byKey(loose(words[0]));
226
+ const hit = two ?? one;
227
+ if (!hit) continue;
228
+ const lead = m[0].length - m[0].replace(/^[\s,]+/, "").length;
229
+ const start = m.index + lead;
230
+ const end = two ? m.index + m[0].length : m.index + m[0].length - (words.length === 2 ? words[1].length + 1 : 0);
231
+ if (!free(start, end)) continue;
232
+ owner = {
233
+ value: hit.id,
234
+ start,
235
+ end
236
+ };
237
+ break;
238
+ }
239
+ if (owner) taken.push(owner);
240
+ }
241
+ let due = null;
242
+ if (!skip.has("due")) {
243
+ const led = new RegExp(`(?:^|[\\s,])(${LEAD} (${WHEN}))(?=$|[\\s,.;:!?])`, "gi");
244
+ for (const m of text.matchAll(led)) {
245
+ const day = dayFromWords(m[2], ctx.today);
246
+ if (!day) continue;
247
+ const start = m.index + m[0].indexOf(m[1]);
248
+ const end = start + m[1].length;
249
+ if (!free(start, end)) continue;
250
+ due = {
251
+ value: day,
252
+ start,
253
+ end
254
+ };
255
+ break;
256
+ }
257
+ if (!due) {
258
+ const rest = without(text, taken);
259
+ const tail = new RegExp(`(?:^|[\\s,])(${WHEN})[.!]?$`, "i").exec(rest);
260
+ const short = tail && /^(?:sun|mon|tue|wed|thu|fri|sat)$/i.test(tail[1]);
261
+ const said = tail && /\b(?:to|for|from|after|with|since)$/i.test(rest.slice(0, tail.index).trimEnd());
262
+ if (tail && !short && !said && rest.trim().length > tail[1].length) {
263
+ const day = dayFromWords(tail[1], ctx.today);
264
+ if (day) {
265
+ const at = text.toLowerCase().lastIndexOf(tail[1].toLowerCase());
266
+ if (at >= 0 && free(at, at + tail[1].length)) due = {
267
+ value: day,
268
+ start: at,
269
+ end: at + tail[1].length
270
+ };
271
+ }
272
+ }
273
+ }
274
+ if (due) taken.push(due);
275
+ }
276
+ const span = (f) => f ? text.slice(f.start, f.end).trim() : "";
277
+ let title = without(text, taken);
278
+ const first = text.search(/\S/);
279
+ if (taken.some((t) => t.start === first)) title = title.charAt(0).toUpperCase() + title.slice(1);
280
+ return {
281
+ title,
282
+ due: due ? {
283
+ value: due.value,
284
+ text: span(due)
285
+ } : null,
286
+ owner: owner ? {
287
+ value: owner.value,
288
+ text: span(owner)
289
+ } : null,
290
+ priority: priority ? {
291
+ value: priority.value,
292
+ text: span(priority)
293
+ } : null,
294
+ project: project ? {
295
+ value: project.value,
296
+ text: span(project)
297
+ } : null
298
+ };
299
+ }
300
+
301
+ export { dayFromWords, readTaskWords };
package/lib/cli.mjs CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  lifeboatsText,
40
40
  followedText,
41
41
  soundingsText,
42
+ goalsText,
42
43
  thanksText,
43
44
  ashoreText,
44
45
  voyageText,
@@ -64,6 +65,7 @@ import {
64
65
  import { colourDepth, logoLines } from "./logo.mjs";
65
66
  import { HarborError, createClient } from "./mcp.mjs";
66
67
  import { jsonText, plain } from "./plain.mjs";
68
+ import { dayFromWords, readTaskWords } from "./words.mjs";
67
69
  import {
68
70
  FETCH_LIMIT,
69
71
  SHELLS,
@@ -164,10 +166,14 @@ export const HELP = `harbor: Harbor from the terminal
164
166
  [--also "NAME, NAME"] [--due DATE|pilot]
165
167
  [--priority low|medium|high|urgent]
166
168
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
167
- with no TITLE: one title a line
168
- from stdin; --due pilot takes the
169
- date the owner's queue suggests
170
- (harbor help add)
169
+ [--literal]
170
+ reads plain words in TITLE as the
171
+ website does: @name, by friday,
172
+ !high, #project (--literal: keep
173
+ them); with no TITLE: one title a
174
+ line from stdin; --due pilot takes
175
+ the date the owner's queue
176
+ suggests (harbor help add)
171
177
  harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
172
178
  move a task to In play, In review
173
179
  or Done
@@ -255,6 +261,8 @@ export const HELP = `harbor: Harbor from the terminal
255
261
  your answer, or read the spread
256
262
  harbor soundings [PROJECT] open tasks with a sounding, and
257
263
  the ones waiting on your answer
264
+ harbor goals [PORTFOLIO] each portfolio's goals, how far
265
+ along and whether on track
258
266
  harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
259
267
  public holidays and time away
260
268
  coming up, and whose holidays
@@ -486,7 +494,11 @@ function daysToWeekday(v, today) {
486
494
  return i < 0 ? null : (i - today.getDay() + 7) % 7;
487
495
  }
488
496
 
489
- /** --due as people say it: today, tomorrow, friday, +3, or a date. Anything else goes to Harbor to judge. */
497
+ /**
498
+ * --due as people say it: today, tomorrow, friday, +3, or a date, and the
499
+ * rest of what the website's new task box reads ("next friday", "in two
500
+ * weeks", "Nov 15"). Anything else goes to Harbor to judge.
501
+ */
490
502
  export function dueDate(v, today = new Date()) {
491
503
  if (v === undefined || v === null) return v;
492
504
  const words = { today: 0, tomorrow: 1 };
@@ -498,11 +510,64 @@ export function dueDate(v, today = new Date()) {
498
510
  : /^[a-z]+$/i.test(v)
499
511
  ? daysToWeekday(v, today)
500
512
  : null;
501
- if (days === null) return v;
513
+ if (days === null) return (typeof v === "string" && dayFromWords(v, todayLocal(today))) || v;
502
514
  const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days);
503
515
  return todayLocal(d);
504
516
  }
505
517
 
518
+ /**
519
+ * Each title read in plain words, as the website's new task box reads it:
520
+ * "Book the venue @Quinn by Friday !high #Website". A flag given says that
521
+ * thing itself, so its words stay in the title. The crew and the projects
522
+ * are asked for only when a title could name one.
523
+ */
524
+ async function readTitles(client, harbor, titles, values, today = todayLocal()) {
525
+ const could = (re) => titles.some((t) => re.test(t));
526
+ const people =
527
+ values.assignee === undefined && could(/(?:^|\s)@|\bassign|\bowner\b/i)
528
+ ? await client
529
+ .call("crew_clocks", { harbor })
530
+ .then((r) => [...(r.clocks ?? []).map((c) => c.who), ...(r.noTimeZone ?? [])])
531
+ .catch(() => [])
532
+ : [];
533
+ const projects =
534
+ values.project === undefined && could(/(?:^|\s)#/)
535
+ ? await client
536
+ .call("list_projects", { harbor, limit: 100 })
537
+ .then((r) => (r.projects ?? []).map((p) => ({ id: p.name, name: p.name })))
538
+ .catch(() => [])
539
+ : [];
540
+ const skip = new Set(
541
+ [
542
+ values.assignee !== undefined && "owner",
543
+ values.due !== undefined && "due",
544
+ values.priority !== undefined && "priority",
545
+ values.project !== undefined && "project",
546
+ ].filter(Boolean),
547
+ );
548
+ return titles.map((title) => {
549
+ const r = readTaskWords(title, {
550
+ today,
551
+ people: people.map((name) => ({ id: name, name })),
552
+ projects,
553
+ skip,
554
+ });
555
+ const read = r.owner || r.due || r.priority || r.project;
556
+ return read && r.title ? r : { title, owner: null, due: null, priority: null, project: null };
557
+ });
558
+ }
559
+
560
+ /** What a title's words said, in a line: "Quinn (@Quinn), due 2026-11-06 (by Friday)". */
561
+ function wordsLine(r) {
562
+ const bits = [
563
+ r.owner && `${r.owner.value} (${r.owner.text})`,
564
+ r.due && `due ${r.due.value} (${r.due.text})`,
565
+ r.priority && `${r.priority.value} priority (${r.priority.text})`,
566
+ r.project && `in ${r.project.value} (${r.project.text})`,
567
+ ].filter(Boolean);
568
+ return bits.length ? `Read from your words: ${bits.join(", ")}.` : null;
569
+ }
570
+
506
571
  /** --due pilot: the date the owner's queue suggests, set by Harbor. */
507
572
  const PILOT = new Set(["pilot", "suggested"]);
508
573
  const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
@@ -591,7 +656,7 @@ export function hint(message, project) {
591
656
  (_, list) => `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
592
657
  )
593
658
  // The CLI reads today, tomorrow and +N before Harbor sees the date.
594
- .replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow, friday or +N days");
659
+ .replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow, friday, next friday or +N days");
595
660
  if (!seen.has(flag)) seen.set(flag, said);
596
661
  }
597
662
  return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
@@ -678,6 +743,12 @@ const DETAIL = {
678
743
  " --project can be left out when the harbor has one open project (First",
679
744
  " voyage aside); the task goes there.",
680
745
  "",
746
+ " Plain words in a title are read as the website reads them: \"Book the",
747
+ " venue @Quinn by friday !high #Website\" adds \"Book the venue\" for",
748
+ " Quinn, due Friday, high priority, in Website. Harbor says what it read.",
749
+ " A flag wins over the words (they stay in the title), and --literal keeps",
750
+ " every title exactly as typed.",
751
+ "",
681
752
  " --assignee me|none|NAME, --also \"NAME, NAME\" (others on it with the",
682
753
  " owner), --due DATE|pilot, --priority low|medium|high|urgent,",
683
754
  " --status S, --comment TEXT and --over-limit apply to every task added, so",
@@ -942,6 +1013,11 @@ const DETAIL = {
942
1013
  " these as days nobody works. Countries: AU BR CA DE ES FR GB IE IT MX NL",
943
1014
  " NZ US (national holidays only).",
944
1015
  ],
1016
+ goals: [
1017
+ " harbor goals [PORTFOLIO] reads: each portfolio's goals (all when",
1018
+ " left out), off track first, with how far",
1019
+ " along each key result is",
1020
+ ],
945
1021
  soundings: [
946
1022
  " harbor soundings [PROJECT] reads: open tasks with a sounding",
947
1023
  " (all projects when left out), and the",
@@ -2388,12 +2464,10 @@ export async function run(argv, io = {}) {
2388
2464
  case "add": {
2389
2465
  const { positionals, values, harbor } = parse(
2390
2466
  spec(
2391
- ["over-limit", "dry-run"],
2467
+ ["over-limit", "dry-run", "literal"],
2392
2468
  ["project", "assignee", "also", "due", "priority", "status", "comment"],
2393
2469
  ),
2394
2470
  );
2395
- // No --project: the harbor's one open project, if that's all there is.
2396
- if (!values.project) values.project = await onlyProject(client, harbor);
2397
2471
  // Several at once: one title a line on stdin, with no title (or -) after add.
2398
2472
  const piped =
2399
2473
  positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
@@ -2411,6 +2485,15 @@ export async function run(argv, io = {}) {
2411
2485
  throw new HarborError(
2412
2486
  "That's more than 100 tasks; harbor import FILE takes a spreadsheet.",
2413
2487
  );
2488
+ // Plain words, as on the website: @person, by Friday, !high, #Project.
2489
+ // --literal keeps every title exactly as typed.
2490
+ const reads = values.literal
2491
+ ? titles.map((title) => ({ title, owner: null, due: null, priority: null, project: null }))
2492
+ : await readTitles(client, harbor, titles, values);
2493
+ // No --project and no #Project: the harbor's one open project, if that's all there is.
2494
+ if (!values.project && !reads.every((r) => r.project))
2495
+ values.project = await onlyProject(client, harbor, "--project NAME or #NAME in the title");
2496
+ const projectOf = (r) => values.project ?? r.project?.value;
2414
2497
  if (values["dry-run"]) {
2415
2498
  const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
2416
2499
  // --due pilot: the date each line would take, each counting the ones before it.
@@ -2424,12 +2507,23 @@ export async function run(argv, io = {}) {
2424
2507
  })
2425
2508
  ).dates
2426
2509
  : null;
2510
+ const where = [...new Set(reads.map(projectOf))];
2427
2511
  if (asJson)
2428
2512
  out(
2429
2513
  jsonText({
2430
2514
  dryRun: true,
2431
- project: values.project,
2432
- titles,
2515
+ project: where.length === 1 ? where[0] : values.project,
2516
+ titles: reads.map((r) => r.title),
2517
+ ...(reads.some((r) => r.owner || r.due || r.priority || r.project)
2518
+ ? {
2519
+ read: reads.map((r) => ({
2520
+ project: projectOf(r),
2521
+ ...(r.owner ? { assignee: r.owner.value } : {}),
2522
+ ...(r.due ? { due: r.due.value } : {}),
2523
+ ...(r.priority ? { priority: r.priority.value } : {}),
2524
+ })),
2525
+ }
2526
+ : {}),
2433
2527
  ...(pilot
2434
2528
  ? { due: pilot.map((d) => d.earliest) }
2435
2529
  : values.due
@@ -2441,11 +2535,11 @@ export async function run(argv, io = {}) {
2441
2535
  out(
2442
2536
  plain(
2443
2537
  [
2444
- `Would add ${n} to ${values.project}:`,
2445
- ...titles.map(
2446
- (t, i) =>
2447
- ` ${t}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
2448
- ),
2538
+ `Would add ${n} to ${where.join(" and ")}:`,
2539
+ ...reads.flatMap((r, i) => [
2540
+ ` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
2541
+ ...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
2542
+ ]),
2449
2543
  ...(pilot
2450
2544
  ? [
2451
2545
  `--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
@@ -2457,25 +2551,25 @@ export async function run(argv, io = {}) {
2457
2551
  return 0;
2458
2552
  }
2459
2553
  const added = [];
2460
- for (const title of titles) {
2554
+ for (const words of reads) {
2461
2555
  const r = await client
2462
2556
  .call("create_task", {
2463
2557
  harbor,
2464
- project: values.project,
2465
- title,
2558
+ project: projectOf(words),
2559
+ title: words.title,
2466
2560
  // Yours unless you say otherwise, as with n in the app.
2467
- assignee: values.assignee ?? "me",
2561
+ assignee: values.assignee ?? words.owner?.value ?? "me",
2468
2562
  also: values.also === undefined ? undefined : alsoList(values.also),
2469
- due: duePilot(values.due) ? undefined : dueDate(values.due),
2563
+ due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
2470
2564
  suggestedDue: duePilot(values.due),
2471
- priority: values.priority,
2565
+ priority: values.priority ?? words.priority?.value,
2472
2566
  status: values.status === undefined ? undefined : statusWord(values.status),
2473
2567
  overLimit: values["over-limit"] || undefined,
2474
2568
  })
2475
2569
  .catch((err) => {
2476
2570
  if (err instanceof HarborError) {
2477
2571
  const done = added.length ? ` (added ${added.length} before it)` : "";
2478
- throw new HarborError(`${hint(err.message, values.project)}${done}`);
2572
+ throw new HarborError(`${hint(err.message, projectOf(words))}${done}`);
2479
2573
  }
2480
2574
  throw err;
2481
2575
  });
@@ -2487,6 +2581,8 @@ export async function run(argv, io = {}) {
2487
2581
  body: values.comment,
2488
2582
  })
2489
2583
  ).commented;
2584
+ const line = wordsLine(words);
2585
+ if (line) r.read = line;
2490
2586
  added.push(r);
2491
2587
  }
2492
2588
  await stalePromptCache(env);
@@ -2500,6 +2596,8 @@ export async function run(argv, io = {}) {
2500
2596
  .map((r) =>
2501
2597
  [
2502
2598
  `Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
2599
+ // What the title's words said, so nothing is read without being seen.
2600
+ ...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
2503
2601
  // Pilot speaks up only when a date was given that the queue says won't hold.
2504
2602
  ...(values.due ? pilotLines(r, r.created) : []),
2505
2603
  ].join("\n"),
@@ -3077,6 +3175,13 @@ export async function run(argv, io = {}) {
3077
3175
  soundingsText(r),
3078
3176
  );
3079
3177
  }
3178
+ case "goals": {
3179
+ const { positionals, harbor } = parse(spec());
3180
+ const portfolio = positionals.join(" ").trim();
3181
+ return call("list_goals", { harbor, ...(portfolio ? { portfolio } : {}) }, (r) =>
3182
+ goalsText(r),
3183
+ );
3184
+ }
3080
3185
  case "ashore": {
3081
3186
  const { positionals, values, harbor } = parse(spec([], ["weeks", "country", "person"]));
3082
3187
  none(positionals);
package/lib/format.mjs CHANGED
@@ -1229,3 +1229,29 @@ export function catchupText(r) {
1229
1229
  if (more) out.push("", `${r.handed.length} handed to you in all; harbor catchup --json lists them.`);
1230
1230
  return out.join("\n");
1231
1231
  }
1232
+
1233
+ /** list_goals as text: each portfolio's goals, what needs attention first, with their key results. */
1234
+ export function goalsText(r) {
1235
+ const order = ["off-track", "at-risk", "not-started", "on-track", "achieved"];
1236
+ const withGoals = (r.portfolios ?? []).filter((p) => p.goals?.length);
1237
+ if (!withGoals.length)
1238
+ return "No goals yet. Set one at the top of a portfolio's page on the website.";
1239
+ const lines = [];
1240
+ for (const p of withGoals) {
1241
+ if (lines.length) lines.push("");
1242
+ lines.push(p.name);
1243
+ const goals = [...p.goals].sort(
1244
+ (a, b) =>
1245
+ order.indexOf(a.status) - order.indexOf(b.status) ||
1246
+ (a.due ?? "9999").localeCompare(b.due ?? "9999"),
1247
+ );
1248
+ for (const g of goals) {
1249
+ const bits = [`${g.progress}%`, g.statusText.toLowerCase()];
1250
+ if (g.owner) bits.push(g.owner);
1251
+ if (g.due && g.status !== "achieved") bits.push(`by ${monthDay(g.due)}`);
1252
+ lines.push(` ${g.title} · ${bits.join(" · ")}`);
1253
+ for (const k of g.keyResults) lines.push(` ${k.title} · ${k.text}`);
1254
+ }
1255
+ }
1256
+ return lines.join("\n");
1257
+ }
package/lib/words.mjs ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A task in plain words, read the way the website's new task box reads it
3
+ * ("Book the venue @Quinn by Friday !high #Website"): src/lib/plain-words.ts
4
+ * itself. A checkout runs that file as it is (Node strips its types); the
5
+ * published package carries it bundled as dist/words.mjs by build.mjs.
6
+ */
7
+ async function load() {
8
+ try {
9
+ return await import("../../src/lib/plain-words.ts");
10
+ } catch {
11
+ return await import("../dist/words.mjs");
12
+ }
13
+ }
14
+
15
+ const words = await load();
16
+
17
+ /** @type {typeof import("../../src/lib/plain-words.ts").readTaskWords} */
18
+ export const readTaskWords = words.readTaskWords;
19
+ /** @type {typeof import("../../src/lib/plain-words.ts").dayFromWords} */
20
+ export const dayFromWords = words.dayFromWords;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.76.0",
3
+ "version": "0.78.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",