@harborppm/cli 0.84.0 → 0.85.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.
Files changed (3) hide show
  1. package/dist/app.mjs +274 -52
  2. package/lib/cli.mjs +33 -1
  3. package/package.json +1 -1
package/dist/app.mjs CHANGED
@@ -19695,6 +19695,85 @@ function exportName(harborName, now) {
19695
19695
  return `${harborName.normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "harbor"}-${now.toISOString().slice(0, 10)}`;
19696
19696
  }
19697
19697
 
19698
+ // src/lib/custom-field-text.ts
19699
+ init_date_text();
19700
+ const FIELD_TYPE_LABEL = {
19701
+ text: "Text",
19702
+ number: "Number",
19703
+ select: "Dropdown",
19704
+ multi: "Tags",
19705
+ checkbox: "Checkbox",
19706
+ date: "Date",
19707
+ person: "Person",
19708
+ url: "Link"
19709
+ };
19710
+ const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
19711
+ /** A dropdown or tags field: one with a list of options. */
19712
+ function hasOptions(type) {
19713
+ return type === "select" || type === "multi";
19714
+ }
19715
+ /** A dropdown's options from "S, M, L". */
19716
+ function optionsOf(value) {
19717
+ return [...new Set(value.split(",").map((o) => o.trim()).filter(Boolean))];
19718
+ }
19719
+ /** The link a value names when it's an http(s) address, else null (never javascript: and the like). */
19720
+ function safeLink(value) {
19721
+ if (typeof value !== "string") return null;
19722
+ try {
19723
+ const u = new URL(value.trim());
19724
+ return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
19725
+ } catch {
19726
+ return null;
19727
+ }
19728
+ }
19729
+ /** A link from "example.com/x" or "https://…"; null when it isn't a web address. */
19730
+ function readLink(text) {
19731
+ const t = text.trim();
19732
+ if (!t || /\s/.test(t)) return null;
19733
+ const href = safeLink(/^[a-z][a-z0-9+.-]*:/i.test(t) ? t : `https://${t}`);
19734
+ return href && /\./.test(new URL(href).hostname) ? href : null;
19735
+ }
19736
+ /** "example.com/docs/plan" from a link: what a short chip says. */
19737
+ function linkLabel(href) {
19738
+ try {
19739
+ const u = new URL(href);
19740
+ const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
19741
+ const text = u.host.replace(/^www\./, "") + path;
19742
+ return text.length > 40 ? `${text.slice(0, 39)}…` : text;
19743
+ } catch {
19744
+ return href;
19745
+ }
19746
+ }
19747
+ /** A tags field's value as a list, whatever was stored. */
19748
+ function tagsOf(value) {
19749
+ if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
19750
+ return typeof value === "string" && value ? [value] : [];
19751
+ }
19752
+ /** Whether a task carries a value for the field. */
19753
+ function hasFieldValue(def, value) {
19754
+ if (value == null || value === "" || value === false) return false;
19755
+ if (def.type === "multi") return tagsOf(value).length > 0;
19756
+ return true;
19757
+ }
19758
+ /** A task's value for a field as one line: "" when it has none. */
19759
+ function fieldValueText(def, value, nameOf) {
19760
+ if (!hasFieldValue(def, value)) return "";
19761
+ switch (def.type) {
19762
+ case "checkbox": return value ? "Yes" : "";
19763
+ case "multi": return tagsOf(value).join(", ");
19764
+ case "date": {
19765
+ const d = parseIso(String(value));
19766
+ return Number.isNaN(d.getTime()) ? String(value) : format(d, "MMM d, yyyy");
19767
+ }
19768
+ case "person": return nameOf?.(String(value)) ?? "Someone no longer here";
19769
+ case "url": {
19770
+ const href = safeLink(value);
19771
+ return href ? linkLabel(href) : "";
19772
+ }
19773
+ default: return Array.isArray(value) ? value.join(", ") : String(value);
19774
+ }
19775
+ }
19776
+
19698
19777
  // src/lib/exports.ts
19699
19778
  /**
19700
19779
  * The spreadsheets Harbor hands out: a project's tasks, the risk register and
@@ -19723,6 +19802,8 @@ function tasksCsv(data, projectId) {
19723
19802
  const here = byId(tasks);
19724
19803
  const people = byId(data.people);
19725
19804
  const name = (t) => taskKey(t, project) ?? t.title;
19805
+ const fields = project?.customFields ?? [];
19806
+ const nameOf = (id) => people.get(id)?.name;
19726
19807
  return toCsv([
19727
19808
  "Key",
19728
19809
  "Title",
@@ -19732,7 +19813,8 @@ function tasksCsv(data, projectId) {
19732
19813
  "Due",
19733
19814
  "Done",
19734
19815
  "Blocked by",
19735
- "Description"
19816
+ "Description",
19817
+ ...fields.map((f) => f.name)
19736
19818
  ], tasks.map((t) => [
19737
19819
  taskKey(t, project) ?? "",
19738
19820
  t.title,
@@ -19742,7 +19824,8 @@ function tasksCsv(data, projectId) {
19742
19824
  t.dueDate ?? "",
19743
19825
  t.status === "done" ? "yes" : "no",
19744
19826
  blockerIds(t).map((id) => here.get(id)).filter((b) => Boolean(b)).map(name).join("; "),
19745
- t.description
19827
+ t.description,
19828
+ ...fields.map((f) => f.type === "url" ? safeLink(t.customFieldValues?.[f.id]) ?? "" : fieldValueText(f, t.customFieldValues?.[f.id], nameOf))
19746
19829
  ]));
19747
19830
  }
19748
19831
  /** Where a risk, issue or decision lives, by name; null for the whole harbor. */
@@ -20631,6 +20714,26 @@ function soundingOut(data, t, meId, now) {
20631
20714
  };
20632
20715
  }
20633
20716
  const dayOf = (iso) => iso ? iso.slice(0, 10) : null;
20717
+ /** A project's own fields, as set_field takes them (custom-field-text.ts). */
20718
+ function fieldDefsOut(p) {
20719
+ return (p.customFields ?? []).map((f) => ({
20720
+ name: f.name,
20721
+ type: FIELD_TYPE_LABEL[f.type].toLowerCase(),
20722
+ ...hasOptions(f.type) ? { options: f.options ?? [] } : {}
20723
+ }));
20724
+ }
20725
+ /** A task's values for its project's own fields, by field name: { Size: "L" }. */
20726
+ function fieldValuesOut(data, p, t) {
20727
+ if (!p?.customFields?.length || !t.customFieldValues) return null;
20728
+ const people = byId(data.people);
20729
+ const out = {};
20730
+ for (const f of p.customFields) {
20731
+ const v = t.customFieldValues[f.id];
20732
+ if (!hasFieldValue(f, v)) continue;
20733
+ out[f.name] = f.type === "multi" ? tagsOf(v) : f.type === "person" ? nameOf$2(people, String(v)) ?? "someone no longer here" : f.type === "number" || f.type === "checkbox" ? v : f.type === "url" ? safeLink(v) ?? "" : String(v);
20734
+ }
20735
+ return Object.keys(out).length ? out : null;
20736
+ }
20634
20737
  /** A task's approval (src/lib/task-approval.ts), as an agent reads it. */
20635
20738
  function approvalOut(data, a) {
20636
20739
  const people = byId(data.people);
@@ -20993,7 +21096,7 @@ const HARBOR_TOOLS = [
20993
21096
  tool({
20994
21097
  name: "get_project",
20995
21098
  title: "Project details",
20996
- description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), and its url on the website." + DATA_NOTE,
21099
+ description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
20997
21100
  input: object({
20998
21101
  harbor: harborArg,
20999
21102
  project: string().min(1).max(200).describe("Project id or name.")
@@ -21054,6 +21157,7 @@ const HARBOR_TOOLS = [
21054
21157
  waitsOn,
21055
21158
  milestones,
21056
21159
  openRaid: raid,
21160
+ ...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
21057
21161
  finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
21058
21162
  ...charterOf(p, tasks),
21059
21163
  ...notesOf(ctx, p),
@@ -21115,7 +21219,7 @@ const HARBOR_TOOLS = [
21115
21219
  tool({
21116
21220
  name: "get_task",
21117
21221
  title: "Task details",
21118
- description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), and its url on the website." + DATA_NOTE,
21222
+ description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), its values for the project's own fields by name (fields, when it has any; set them with set_field), and its url on the website." + DATA_NOTE,
21119
21223
  input: object({
21120
21224
  harbor: harborArg,
21121
21225
  task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
@@ -21169,6 +21273,7 @@ const HARBOR_TOOLS = [
21169
21273
  waitedOnBy: waiters.map(brief),
21170
21274
  ...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
21171
21275
  ...t.approval ? { approval: approvalOut(data, t.approval) } : {},
21276
+ ...((v) => v ? { fields: v } : {})(fieldValuesOut(data, project, t)),
21172
21277
  ...pilot ? { pilotDue: {
21173
21278
  earliest: pilot.earliest,
21174
21279
  fits: pilot.fits,
@@ -24251,6 +24356,7 @@ var init_raid_history = __esmMin((() => {}));
24251
24356
  function milestonesRaidSlice({ set, get, write }) {
24252
24357
  return {
24253
24358
  toggleMilestone: (id) => {
24359
+ if (get().milestones.find((m) => m.id === id)?.approverId) return;
24254
24360
  write({ milestones: get().milestones.map((m) => m.id === id ? {
24255
24361
  ...m,
24256
24362
  done: !m.done
@@ -26371,7 +26477,7 @@ function fieldsOf(data, task) {
26371
26477
  };
26372
26478
  }
26373
26479
  /** The person a file's owner names: their whole name, else a first name or initials only they have. */
26374
- function personNamed$1(people, name) {
26480
+ function personNamed$2(people, name) {
26375
26481
  const want = name.trim().toLowerCase();
26376
26482
  if (!want) return null;
26377
26483
  const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
@@ -26413,7 +26519,7 @@ function taskPatch(data, fields, say, label) {
26413
26519
  if (fields.repeat !== void 0) patch.repeat = fields.repeat;
26414
26520
  if (fields.notes !== void 0) patch.description = fields.notes;
26415
26521
  if (fields.owner !== void 0) {
26416
- const person = fields.owner ? personNamed$1(data.people, fields.owner) : null;
26522
+ const person = fields.owner ? personNamed$2(data.people, fields.owner) : null;
26417
26523
  if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
26418
26524
  patch.assigneeId = person?.id ?? null;
26419
26525
  }
@@ -26574,7 +26680,7 @@ function applyFolder(store, projectId, files, base, opts) {
26574
26680
  let fresh = 0;
26575
26681
  if (opts.fileWins) for (const text of files.values()) {
26576
26682
  const owner = parseTask(text).fields?.owner;
26577
- if (owner && !personNamed$1(get().people, owner)) {
26683
+ if (owner && !personNamed$2(get().people, owner)) {
26578
26684
  const { id: _id, ...person } = localPerson(owner, "");
26579
26685
  get().addPerson(person);
26580
26686
  }
@@ -26836,7 +26942,7 @@ async function moveLocalTasks(folder, store, projectId, say) {
26836
26942
  };
26837
26943
  for (const t of local.tasks) {
26838
26944
  const owner = t.assigneeId ? local.people.find((p) => p.id === t.assigneeId)?.name : null;
26839
- const person = owner ? personNamed$1(get().people, owner) : null;
26945
+ const person = owner ? personNamed$2(get().people, owner) : null;
26840
26946
  if (owner && !person) say(`${t.title}: nobody called "${owner}" is in the harbor, so it has no owner.`);
26841
26947
  const id = get().addTask({
26842
26948
  projectId,
@@ -27062,7 +27168,7 @@ function taskIn(data, project, key) {
27062
27168
  if (!task || task.projectId !== project.id) throw new HarborError(`No task ${key} in ${project.name}. Use its key, like ${project.key ?? "RR"}-4.`);
27063
27169
  return task;
27064
27170
  }
27065
- const personNamed = (data, name) => {
27171
+ const personNamed$1 = (data, name) => {
27066
27172
  try {
27067
27173
  return findOne(data.people, name, "person");
27068
27174
  } catch (e) {
@@ -27103,7 +27209,7 @@ function changesFrom(data, project, flags, now) {
27103
27209
  for (const [key, who] of pairs(flags.give, "give", "KEY=NAME (RL-4=Sam, RL-5=none)")) add({
27104
27210
  kind: "assign",
27105
27211
  taskId: taskIn(data, project, key).id,
27106
- assigneeId: who === "none" ? null : personNamed(data, who).id
27212
+ assigneeId: who === "none" ? null : personNamed$1(data, who).id
27107
27213
  });
27108
27214
  if (flags.end) add({
27109
27215
  kind: "end",
@@ -27115,13 +27221,13 @@ function changesFrom(data, project, flags, now) {
27115
27221
  if (!Number.isInteger(days) || days < 1 || days > 5) throw new HarborError("--borrow takes NAME or NAME=DAYS, days a week from 1 to 5.");
27116
27222
  add({
27117
27223
  kind: "borrow",
27118
- personId: personNamed(data, name.trim()).id,
27224
+ personId: personNamed$1(data, name.trim()).id,
27119
27225
  days
27120
27226
  });
27121
27227
  }
27122
27228
  for (const name of list(flags.release)) add({
27123
27229
  kind: "release",
27124
- personId: personNamed(data, name).id
27230
+ personId: personNamed$1(data, name).id
27125
27231
  });
27126
27232
  return changes;
27127
27233
  }
@@ -28305,8 +28411,12 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
28305
28411
  } else if (key.upArrow || input === "k") setMAt((i) => step$1(i, -1, marks.length));
28306
28412
  else if (key.downArrow || input === "j") setMAt((i) => step$1(i, 1, marks.length));
28307
28413
  else if (key.return && mark) go.openPanel("milestone", mark.id);
28308
- else if (input === " " && mark) store.getState().toggleMilestone(mark.id);
28309
- else if (input === "d" && mark) go.confirm(`Delete the milestone “${mark.name}”? u undoes it.`, () => store.getState().deleteMilestone(mark.id), "Delete");
28414
+ else if (input === " " && mark) {
28415
+ if (mark.approverId) {
28416
+ const approver = people.find((p) => p.id === mark.approverId);
28417
+ go.notify(`${approver?.name ?? "The approver"} ticks this off by signing it off.`);
28418
+ } else store.getState().toggleMilestone(mark.id);
28419
+ } else if (input === "d" && mark) go.confirm(`Delete the milestone “${mark.name}”? u undoes it.`, () => store.getState().deleteMilestone(mark.id), "Delete");
28310
28420
  }, { isActive: active });
28311
28421
  if (!program) return /* @__PURE__ */ jsx(Text, {
28312
28422
  color: theme.muted,
@@ -29660,33 +29770,6 @@ function defaultAction(type) {
29660
29770
  };
29661
29771
  }
29662
29772
 
29663
- // src/lib/custom-field-text.ts
29664
- const FIELD_TYPE_LABEL = {
29665
- text: "Text",
29666
- number: "Number",
29667
- select: "Dropdown",
29668
- checkbox: "Checkbox"
29669
- };
29670
- const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
29671
- /** A dropdown's options from "S, M, L". */
29672
- function optionsOf(value) {
29673
- return value.split(",").map((o) => o.trim()).filter(Boolean);
29674
- }
29675
- /** A task's value for a field as one line: "" when it has none. */
29676
- function fieldValueText(def, value) {
29677
- if (value == null || value === "") return "";
29678
- if (def.type === "checkbox") return value ? "Yes" : "";
29679
- return String(value);
29680
- }
29681
- /** What someone typed for a text or number field; undefined when a number can't be read. */
29682
- function readFieldValue(def, text) {
29683
- const t = text.trim();
29684
- if (!t) return null;
29685
- if (def.type !== "number") return t;
29686
- const n = Number(t.replace(/,/g, ""));
29687
- return Number.isFinite(n) ? n : void 0;
29688
- }
29689
-
29690
29773
  // src/tui/views/setup.tsx
29691
29774
  /**
29692
29775
  * A project's board setup, as the website's board settings have it: where it
@@ -29743,7 +29826,8 @@ function columnValue(c) {
29743
29826
  }
29744
29827
  function fieldValue(f) {
29745
29828
  const kind = FIELD_TYPE_LABEL[f.type];
29746
- return f.type === "select" && f.options?.length ? `${kind}: ${f.options.join(", ")}` : kind;
29829
+ const head = hasOptions(f.type) && f.options?.length ? `${kind}: ${f.options.join(", ")}` : kind;
29830
+ return f.onCards ? `${head} · on cards` : head;
29747
29831
  }
29748
29832
  function BoardPanel({ id, width, height, go, active, onClose }) {
29749
29833
  const { store } = useDeps();
@@ -29892,10 +29976,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29892
29976
  label: "Rename it",
29893
29977
  value: "rename"
29894
29978
  },
29895
- ...f.type === "select" ? [{
29979
+ ...hasOptions(f.type) ? [{
29896
29980
  label: "Change its options",
29897
29981
  value: "options"
29898
29982
  }] : [],
29983
+ {
29984
+ label: f.onCards ? "Stop showing it on cards" : "Show it on board cards",
29985
+ value: "cards"
29986
+ },
29899
29987
  {
29900
29988
  label: "Take it out",
29901
29989
  value: "remove"
@@ -29904,6 +29992,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29904
29992
  onPick: (what) => {
29905
29993
  const s = store.getState();
29906
29994
  if (what === "rename") return go.ask("Field name", (text) => text.trim() && s.updateCustomField(id, f.id, { name: text.trim() }), f.name);
29995
+ if (what === "cards") return s.updateCustomField(id, f.id, { onCards: f.onCards ? void 0 : true });
29907
29996
  if (what === "options") return go.ask("Options, split by commas", (text) => s.updateCustomField(id, f.id, { options: optionsOf(text) }), (f.options ?? []).join(", "));
29908
29997
  go.confirm(`Take out the field “${f.name}” and every task's value for it? u undoes it.`, () => s.removeCustomField(id, f.id), "Take it out");
29909
29998
  }
@@ -29924,7 +30013,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29924
30013
  value: t
29925
30014
  })), (t) => {
29926
30015
  const type = t;
29927
- if (type !== "select") return void store.getState().addCustomField(id, {
30016
+ if (!hasOptions(type)) return void store.getState().addCustomField(id, {
29928
30017
  name: trimmed,
29929
30018
  type
29930
30019
  });
@@ -30058,6 +30147,113 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
30058
30147
  });
30059
30148
  }
30060
30149
 
30150
+ // src/lib/date-picker.ts
30151
+ /**
30152
+ * The date picker's rules: the days a month's grid shows (always six weeks,
30153
+ * so the calendar keeps one height and stops jumping as you flip months),
30154
+ * the years its year list offers, and what a typed date means. Pure.
30155
+ */
30156
+ /**
30157
+ * The day a typed date names, as an ISO day, or null when it names none.
30158
+ * Reads what the task composer reads ("Mar 14", "14 March 2027", "3/14",
30159
+ * "2027-03-14", "next friday", "in 2 weeks", "tomorrow"), plus a year after
30160
+ * a slash date in full ("3/14/2027"), and full stops or dashes for slashes.
30161
+ */
30162
+ function typedDate(text, today) {
30163
+ const t = text.trim().replace(/\s+/g, " ").replace(/^(\d{1,2})[.-](\d{1,2})(?:[.-](\d{2,4}))?$/, (_, m, d, y) => y ? `${m}/${d}/${y}` : `${m}/${d}`);
30164
+ if (!t) return null;
30165
+ return dayFromWords(t, today);
30166
+ }
30167
+
30168
+ // src/lib/custom-field-read.ts
30169
+ /**
30170
+ * What someone typed for one of a project's own fields, as the value kept on
30171
+ * the task: in the terminal, the CLI and AI apps (the website's drawer has a
30172
+ * control per type). Pure.
30173
+ */
30174
+ const YES = /* @__PURE__ */ new Set([
30175
+ "yes",
30176
+ "y",
30177
+ "true",
30178
+ "on",
30179
+ "1",
30180
+ "x",
30181
+ "✓"
30182
+ ]);
30183
+ const NO = /* @__PURE__ */ new Set([
30184
+ "no",
30185
+ "n",
30186
+ "false",
30187
+ "off",
30188
+ "0"
30189
+ ]);
30190
+ function option(def, text) {
30191
+ const t = text.trim().toLowerCase();
30192
+ return (def.options ?? []).find((o) => o.toLowerCase() === t);
30193
+ }
30194
+ /** The person a name names: their whole name, first name, or id; one match only. */
30195
+ function personNamed(people, text) {
30196
+ const t = text.trim().replace(/^@/, "").toLowerCase();
30197
+ if (!t) return void 0;
30198
+ const exact = people.filter((p) => p.id === t || p.name.toLowerCase() === t);
30199
+ if (exact.length === 1) return exact[0];
30200
+ const first = people.filter((p) => p.name.toLowerCase().split(/\s+/)[0] === t);
30201
+ if (first.length === 1) return first[0];
30202
+ return first.length > 1 ? "many" : void 0;
30203
+ }
30204
+ /**
30205
+ * A typed value for a field: an empty string clears it. Says what's wrong
30206
+ * when it can't be read (an option the field doesn't have, a name that
30207
+ * matches nobody, a day it can't read).
30208
+ */
30209
+ function readFieldValue(def, text, ctx) {
30210
+ const t = text.trim();
30211
+ if (!t) return { value: def.type === "multi" ? [] : null };
30212
+ switch (def.type) {
30213
+ case "number": {
30214
+ const n = Number(t.replace(/,/g, ""));
30215
+ return Number.isFinite(n) ? { value: n } : { error: `“${t}” isn't a number.` };
30216
+ }
30217
+ case "checkbox": {
30218
+ const l = t.toLowerCase();
30219
+ if (YES.has(l)) return { value: true };
30220
+ if (NO.has(l)) return { value: false };
30221
+ return { error: "Say yes or no." };
30222
+ }
30223
+ case "select": {
30224
+ const o = option(def, t);
30225
+ return o ? { value: o } : { error: offer(def, t) };
30226
+ }
30227
+ case "multi": {
30228
+ const picked = [];
30229
+ for (const part of t.split(",").map((p) => p.trim()).filter(Boolean)) {
30230
+ const o = option(def, part);
30231
+ if (!o) return { error: offer(def, part) };
30232
+ if (!picked.includes(o)) picked.push(o);
30233
+ }
30234
+ return { value: picked };
30235
+ }
30236
+ case "date": {
30237
+ const day = typedDate(t, ctx.today);
30238
+ return day ? { value: day } : { error: `Couldn't read “${t}” as a day.` };
30239
+ }
30240
+ case "person": {
30241
+ const p = personNamed(ctx.people, t);
30242
+ if (p === "many") return { error: `More than one person is called “${t}”; use the full name.` };
30243
+ return p ? { value: p.id } : { error: `Nobody here is called “${t}”.` };
30244
+ }
30245
+ case "url": {
30246
+ const href = readLink(t);
30247
+ return href ? { value: href } : { error: `“${t}” isn't a web address.` };
30248
+ }
30249
+ default: return { value: t.slice(0, 500) };
30250
+ }
30251
+ }
30252
+ function offer(def, t) {
30253
+ const opts = def.options ?? [];
30254
+ return opts.length ? `“${t}” isn't one of ${def.name}'s options: ${opts.join(", ")}.` : `${def.name} has no options yet.`;
30255
+ }
30256
+
30061
30257
  // src/tui/views/sounding.ts
30062
30258
  init_soundings();
30063
30259
  const nameOf = (people, id) => people.find((p) => p.id === id)?.name ?? "someone";
@@ -30310,6 +30506,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30310
30506
  const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
30311
30507
  const blockers = blockersOf(task, tasks);
30312
30508
  const done = () => setEditing(null);
30509
+ const nameOf = (id) => people.find((p) => p.id === id)?.name;
30313
30510
  const setCustom = (fieldId, value) => store.getState().setTaskCustomFieldValue(task.id, fieldId, value);
30314
30511
  function editCustom(f) {
30315
30512
  if (!task) return;
@@ -30322,6 +30519,13 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30322
30519
  label: o,
30323
30520
  value: o
30324
30521
  }))], (v) => setCustom(f.id, v));
30522
+ if (f.type === "person") return go.pick(f.name, [{
30523
+ label: "Nobody",
30524
+ value: null
30525
+ }, ...people.filter((p) => p.kind !== "agent").map((p) => ({
30526
+ label: p.name,
30527
+ value: p.id
30528
+ }))], (v) => setCustom(f.id, v));
30325
30529
  setEditing({
30326
30530
  kind: "custom",
30327
30531
  fieldId: f.id
@@ -30409,7 +30613,9 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30409
30613
  const f = own.find((x) => x.id === editing.fieldId);
30410
30614
  if (f) editor = /* @__PURE__ */ jsx(CustomField, {
30411
30615
  def: f,
30412
- initial: fieldValueText(f, task.customFieldValues?.[f.id]),
30616
+ initial: typedText(f, task.customFieldValues?.[f.id]),
30617
+ today: day,
30618
+ people,
30413
30619
  onCancel: done,
30414
30620
  onSubmit: (v) => {
30415
30621
  setCustom(f.id, v);
@@ -30605,7 +30811,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30605
30811
  color: theme.muted,
30606
30812
  children: f.name.slice(0, 8).padEnd(9)
30607
30813
  }),
30608
- fieldValueText(f, task.customFieldValues?.[f.id]) || "—"
30814
+ fieldValueText(f, task.customFieldValues?.[f.id], nameOf) || "—"
30609
30815
  ]
30610
30816
  }, f.id);
30611
30817
  }),
@@ -30696,18 +30902,34 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30696
30902
  ]
30697
30903
  });
30698
30904
  }
30699
- /** A text or number field of the project's own; a number that can't be read says so. */
30700
- function CustomField({ def, initial, onSubmit, onCancel }) {
30905
+ /** What a field's value reads as when it's typed again: the day or link as kept. */
30906
+ function typedText(def, value) {
30907
+ if (value == null || value === false) return "";
30908
+ if (def.type === "multi") return tagsOf(value).join(", ");
30909
+ if (def.type === "checkbox") return "yes";
30910
+ return String(value);
30911
+ }
30912
+ const FIELD_HINT = {
30913
+ number: "A number, or empty for none",
30914
+ date: "A day (2026-12-01, friday, in 2 weeks), or empty for none",
30915
+ url: "A web address, or empty for none"
30916
+ };
30917
+ /** A typed field of the project's own; a value that can't be read says why. */
30918
+ function CustomField({ def, initial, today, people, onSubmit, onCancel }) {
30701
30919
  const [error, setError] = useState(null);
30920
+ const hint = def.type === "multi" ? `Any of ${(def.options ?? []).join(", ") || "(no tags yet)"}, split by commas` : FIELD_HINT[def.type] ?? `${FIELD_TYPE_LABEL[def.type]}, or empty for none`;
30702
30921
  return /* @__PURE__ */ jsx(TextField, {
30703
30922
  label: def.name,
30704
30923
  initial,
30705
- hint: error ?? (def.type === "number" ? "A number, or empty for none" : "Empty for none"),
30924
+ hint: error ?? hint,
30706
30925
  onCancel,
30707
30926
  onSubmit: (text) => {
30708
- const v = readFieldValue(def, text);
30709
- if (v === void 0) return setError(`“${text}” isn't a number.`);
30710
- onSubmit(v);
30927
+ const read = readFieldValue(def, text, {
30928
+ today,
30929
+ people
30930
+ });
30931
+ if ("error" in read) return setError(read.error);
30932
+ onSubmit(read.value);
30711
30933
  }
30712
30934
  });
30713
30935
  }
package/lib/cli.mjs CHANGED
@@ -273,6 +273,8 @@ export const HELP = `harbor: Harbor from the terminal
273
273
  approve a task (finishes it), send
274
274
  it back, or ask someone to approve
275
275
  harbor approvals tasks waiting on your approval
276
+ harbor field TASK NAME VALUE set one of the project's own
277
+ fields on a task ("" clears it)
276
278
  harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
277
279
  public holidays and time away
278
280
  coming up, and whose holidays
@@ -1067,6 +1069,14 @@ const DETAIL = {
1067
1069
  " oldest ask first",
1068
1070
  " harbor approve TASK [--changes NOTE] writes: approve it or send it back",
1069
1071
  ],
1072
+ field: [
1073
+ " harbor field TASK NAME VALUE writes: sets one of the project's own",
1074
+ " fields on a task",
1075
+ "",
1076
+ " Type the value as you would say it: a dropdown's option, tags split by",
1077
+ ' commas, a person\'s name ("me" for you), a day (2026-12-01, friday), a',
1078
+ ' web address, or yes or no. "" clears it. harbor show TASK lists them.',
1079
+ ],
1070
1080
  goals: [
1071
1081
  " harbor goals [PORTFOLIO] reads: each portfolio's goals (all when",
1072
1082
  " left out), off track first, with how far",
@@ -2279,6 +2289,8 @@ export async function run(argv, io = {}) {
2279
2289
  ];
2280
2290
  if (t.alsoOn?.length) lines.push(`Also on it: ${t.alsoOn.join(", ")}.`);
2281
2291
  if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
2292
+ for (const [name, v] of Object.entries(t.fields ?? {}))
2293
+ lines.push(`${name}: ${Array.isArray(v) ? v.join(", ") : v === true ? "yes" : v}`);
2282
2294
  if (t.pilotDue?.fits) lines.push(`${t.pilotDue.says}, so the due date holds.`);
2283
2295
  else if (t.pilotDue) lines.push(...pilotLines({ pilot: t.pilotDue }, t));
2284
2296
  if (t.description) lines.push("", t.description);
@@ -3272,7 +3284,27 @@ export async function run(argv, io = {}) {
3272
3284
  case "approvals": {
3273
3285
  const { positionals, harbor } = parse(spec());
3274
3286
  if (positionals.length) throw new HarborError(`Unexpected "${positionals.join(" ")}".`);
3275
- return call("my_work", { harbor, limit: 1 }, (r) => approvalsText(r));
3287
+ return call(
3288
+ "my_work",
3289
+ { harbor, limit: 1 },
3290
+ (r) => approvalsText(r),
3291
+ (r) => ({ approvalsToGive: r.approvalsToGive ?? [] }),
3292
+ );
3293
+ }
3294
+ case "field": {
3295
+ const { positionals, harbor } = parse(spec());
3296
+ const [task, field, ...value] = positionals;
3297
+ if (!task || !field)
3298
+ throw new HarborError('Give a task, a field and a value, like harbor field RR-4 Size L (or "" to clear).');
3299
+ return call("set_field", { harbor, task, field, value: value.join(" ") }, (r) => {
3300
+ const name = r.set.key ?? r.set.title;
3301
+ const said = Object.entries(r.set.fields ?? {}).find(
3302
+ ([n]) => n.toLowerCase() === field.trim().toLowerCase(),
3303
+ );
3304
+ if (!said) return `Cleared ${field} on ${name}.`;
3305
+ const v = said[1];
3306
+ return `${name}: ${said[0]} is ${Array.isArray(v) ? v.join(", ") : v === true ? "yes" : v}.`;
3307
+ });
3276
3308
  }
3277
3309
  case "templates": {
3278
3310
  const { positionals, harbor } = parse(spec());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.84.0",
3
+ "version": "0.85.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",