@harborppm/cli 0.76.0 → 0.77.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
@@ -64,6 +64,7 @@ import {
64
64
  import { colourDepth, logoLines } from "./logo.mjs";
65
65
  import { HarborError, createClient } from "./mcp.mjs";
66
66
  import { jsonText, plain } from "./plain.mjs";
67
+ import { dayFromWords, readTaskWords } from "./words.mjs";
67
68
  import {
68
69
  FETCH_LIMIT,
69
70
  SHELLS,
@@ -164,10 +165,14 @@ export const HELP = `harbor: Harbor from the terminal
164
165
  [--also "NAME, NAME"] [--due DATE|pilot]
165
166
  [--priority low|medium|high|urgent]
166
167
  [--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)
168
+ [--literal]
169
+ reads plain words in TITLE as the
170
+ website does: @name, by friday,
171
+ !high, #project (--literal: keep
172
+ them); with no TITLE: one title a
173
+ line from stdin; --due pilot takes
174
+ the date the owner's queue
175
+ suggests (harbor help add)
171
176
  harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
172
177
  move a task to In play, In review
173
178
  or Done
@@ -486,7 +491,11 @@ function daysToWeekday(v, today) {
486
491
  return i < 0 ? null : (i - today.getDay() + 7) % 7;
487
492
  }
488
493
 
489
- /** --due as people say it: today, tomorrow, friday, +3, or a date. Anything else goes to Harbor to judge. */
494
+ /**
495
+ * --due as people say it: today, tomorrow, friday, +3, or a date, and the
496
+ * rest of what the website's new task box reads ("next friday", "in two
497
+ * weeks", "Nov 15"). Anything else goes to Harbor to judge.
498
+ */
490
499
  export function dueDate(v, today = new Date()) {
491
500
  if (v === undefined || v === null) return v;
492
501
  const words = { today: 0, tomorrow: 1 };
@@ -498,11 +507,64 @@ export function dueDate(v, today = new Date()) {
498
507
  : /^[a-z]+$/i.test(v)
499
508
  ? daysToWeekday(v, today)
500
509
  : null;
501
- if (days === null) return v;
510
+ if (days === null) return (typeof v === "string" && dayFromWords(v, todayLocal(today))) || v;
502
511
  const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days);
503
512
  return todayLocal(d);
504
513
  }
505
514
 
515
+ /**
516
+ * Each title read in plain words, as the website's new task box reads it:
517
+ * "Book the venue @Quinn by Friday !high #Website". A flag given says that
518
+ * thing itself, so its words stay in the title. The crew and the projects
519
+ * are asked for only when a title could name one.
520
+ */
521
+ async function readTitles(client, harbor, titles, values, today = todayLocal()) {
522
+ const could = (re) => titles.some((t) => re.test(t));
523
+ const people =
524
+ values.assignee === undefined && could(/(?:^|\s)@|\bassign|\bowner\b/i)
525
+ ? await client
526
+ .call("crew_clocks", { harbor })
527
+ .then((r) => [...(r.clocks ?? []).map((c) => c.who), ...(r.noTimeZone ?? [])])
528
+ .catch(() => [])
529
+ : [];
530
+ const projects =
531
+ values.project === undefined && could(/(?:^|\s)#/)
532
+ ? await client
533
+ .call("list_projects", { harbor, limit: 100 })
534
+ .then((r) => (r.projects ?? []).map((p) => ({ id: p.name, name: p.name })))
535
+ .catch(() => [])
536
+ : [];
537
+ const skip = new Set(
538
+ [
539
+ values.assignee !== undefined && "owner",
540
+ values.due !== undefined && "due",
541
+ values.priority !== undefined && "priority",
542
+ values.project !== undefined && "project",
543
+ ].filter(Boolean),
544
+ );
545
+ return titles.map((title) => {
546
+ const r = readTaskWords(title, {
547
+ today,
548
+ people: people.map((name) => ({ id: name, name })),
549
+ projects,
550
+ skip,
551
+ });
552
+ const read = r.owner || r.due || r.priority || r.project;
553
+ return read && r.title ? r : { title, owner: null, due: null, priority: null, project: null };
554
+ });
555
+ }
556
+
557
+ /** What a title's words said, in a line: "Quinn (@Quinn), due 2026-11-06 (by Friday)". */
558
+ function wordsLine(r) {
559
+ const bits = [
560
+ r.owner && `${r.owner.value} (${r.owner.text})`,
561
+ r.due && `due ${r.due.value} (${r.due.text})`,
562
+ r.priority && `${r.priority.value} priority (${r.priority.text})`,
563
+ r.project && `in ${r.project.value} (${r.project.text})`,
564
+ ].filter(Boolean);
565
+ return bits.length ? `Read from your words: ${bits.join(", ")}.` : null;
566
+ }
567
+
506
568
  /** --due pilot: the date the owner's queue suggests, set by Harbor. */
507
569
  const PILOT = new Set(["pilot", "suggested"]);
508
570
  const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
@@ -591,7 +653,7 @@ export function hint(message, project) {
591
653
  (_, list) => `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
592
654
  )
593
655
  // 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");
656
+ .replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow, friday, next friday or +N days");
595
657
  if (!seen.has(flag)) seen.set(flag, said);
596
658
  }
597
659
  return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
@@ -678,6 +740,12 @@ const DETAIL = {
678
740
  " --project can be left out when the harbor has one open project (First",
679
741
  " voyage aside); the task goes there.",
680
742
  "",
743
+ " Plain words in a title are read as the website reads them: \"Book the",
744
+ " venue @Quinn by friday !high #Website\" adds \"Book the venue\" for",
745
+ " Quinn, due Friday, high priority, in Website. Harbor says what it read.",
746
+ " A flag wins over the words (they stay in the title), and --literal keeps",
747
+ " every title exactly as typed.",
748
+ "",
681
749
  " --assignee me|none|NAME, --also \"NAME, NAME\" (others on it with the",
682
750
  " owner), --due DATE|pilot, --priority low|medium|high|urgent,",
683
751
  " --status S, --comment TEXT and --over-limit apply to every task added, so",
@@ -2388,12 +2456,10 @@ export async function run(argv, io = {}) {
2388
2456
  case "add": {
2389
2457
  const { positionals, values, harbor } = parse(
2390
2458
  spec(
2391
- ["over-limit", "dry-run"],
2459
+ ["over-limit", "dry-run", "literal"],
2392
2460
  ["project", "assignee", "also", "due", "priority", "status", "comment"],
2393
2461
  ),
2394
2462
  );
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
2463
  // Several at once: one title a line on stdin, with no title (or -) after add.
2398
2464
  const piped =
2399
2465
  positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
@@ -2411,6 +2477,15 @@ export async function run(argv, io = {}) {
2411
2477
  throw new HarborError(
2412
2478
  "That's more than 100 tasks; harbor import FILE takes a spreadsheet.",
2413
2479
  );
2480
+ // Plain words, as on the website: @person, by Friday, !high, #Project.
2481
+ // --literal keeps every title exactly as typed.
2482
+ const reads = values.literal
2483
+ ? titles.map((title) => ({ title, owner: null, due: null, priority: null, project: null }))
2484
+ : await readTitles(client, harbor, titles, values);
2485
+ // No --project and no #Project: the harbor's one open project, if that's all there is.
2486
+ if (!values.project && !reads.every((r) => r.project))
2487
+ values.project = await onlyProject(client, harbor, "--project NAME or #NAME in the title");
2488
+ const projectOf = (r) => values.project ?? r.project?.value;
2414
2489
  if (values["dry-run"]) {
2415
2490
  const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
2416
2491
  // --due pilot: the date each line would take, each counting the ones before it.
@@ -2424,12 +2499,23 @@ export async function run(argv, io = {}) {
2424
2499
  })
2425
2500
  ).dates
2426
2501
  : null;
2502
+ const where = [...new Set(reads.map(projectOf))];
2427
2503
  if (asJson)
2428
2504
  out(
2429
2505
  jsonText({
2430
2506
  dryRun: true,
2431
- project: values.project,
2432
- titles,
2507
+ project: where.length === 1 ? where[0] : values.project,
2508
+ titles: reads.map((r) => r.title),
2509
+ ...(reads.some((r) => r.owner || r.due || r.priority || r.project)
2510
+ ? {
2511
+ read: reads.map((r) => ({
2512
+ project: projectOf(r),
2513
+ ...(r.owner ? { assignee: r.owner.value } : {}),
2514
+ ...(r.due ? { due: r.due.value } : {}),
2515
+ ...(r.priority ? { priority: r.priority.value } : {}),
2516
+ })),
2517
+ }
2518
+ : {}),
2433
2519
  ...(pilot
2434
2520
  ? { due: pilot.map((d) => d.earliest) }
2435
2521
  : values.due
@@ -2441,11 +2527,11 @@ export async function run(argv, io = {}) {
2441
2527
  out(
2442
2528
  plain(
2443
2529
  [
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
- ),
2530
+ `Would add ${n} to ${where.join(" and ")}:`,
2531
+ ...reads.flatMap((r, i) => [
2532
+ ` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
2533
+ ...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
2534
+ ]),
2449
2535
  ...(pilot
2450
2536
  ? [
2451
2537
  `--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
@@ -2457,25 +2543,25 @@ export async function run(argv, io = {}) {
2457
2543
  return 0;
2458
2544
  }
2459
2545
  const added = [];
2460
- for (const title of titles) {
2546
+ for (const words of reads) {
2461
2547
  const r = await client
2462
2548
  .call("create_task", {
2463
2549
  harbor,
2464
- project: values.project,
2465
- title,
2550
+ project: projectOf(words),
2551
+ title: words.title,
2466
2552
  // Yours unless you say otherwise, as with n in the app.
2467
- assignee: values.assignee ?? "me",
2553
+ assignee: values.assignee ?? words.owner?.value ?? "me",
2468
2554
  also: values.also === undefined ? undefined : alsoList(values.also),
2469
- due: duePilot(values.due) ? undefined : dueDate(values.due),
2555
+ due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
2470
2556
  suggestedDue: duePilot(values.due),
2471
- priority: values.priority,
2557
+ priority: values.priority ?? words.priority?.value,
2472
2558
  status: values.status === undefined ? undefined : statusWord(values.status),
2473
2559
  overLimit: values["over-limit"] || undefined,
2474
2560
  })
2475
2561
  .catch((err) => {
2476
2562
  if (err instanceof HarborError) {
2477
2563
  const done = added.length ? ` (added ${added.length} before it)` : "";
2478
- throw new HarborError(`${hint(err.message, values.project)}${done}`);
2564
+ throw new HarborError(`${hint(err.message, projectOf(words))}${done}`);
2479
2565
  }
2480
2566
  throw err;
2481
2567
  });
@@ -2487,6 +2573,8 @@ export async function run(argv, io = {}) {
2487
2573
  body: values.comment,
2488
2574
  })
2489
2575
  ).commented;
2576
+ const line = wordsLine(words);
2577
+ if (line) r.read = line;
2490
2578
  added.push(r);
2491
2579
  }
2492
2580
  await stalePromptCache(env);
@@ -2500,6 +2588,8 @@ export async function run(argv, io = {}) {
2500
2588
  .map((r) =>
2501
2589
  [
2502
2590
  `Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
2591
+ // What the title's words said, so nothing is read without being seen.
2592
+ ...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
2503
2593
  // Pilot speaks up only when a date was given that the queue says won't hold.
2504
2594
  ...(values.due ? pilotLines(r, r.created) : []),
2505
2595
  ].join("\n"),
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.77.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",