@harborppm/cli 0.84.0 → 0.86.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 +397 -91
  2. package/lib/cli.mjs +34 -2
  3. package/package.json +1 -1
package/dist/app.mjs CHANGED
@@ -5266,6 +5266,49 @@ function cascadeSummary(counts, kind) {
5266
5266
  if (list.length === 1) return list[0];
5267
5267
  return `${list.slice(0, -1).join(", ")} and ${list.at(-1)}`;
5268
5268
  }
5269
+ /** `data` with the cut links put back, wherever both ends exist again. */
5270
+ function relink(data, links) {
5271
+ const out = {};
5272
+ const exists = (key) => new Set(data[key].map((r) => r.id));
5273
+ const targetOf = {
5274
+ programs: "programs",
5275
+ projects: "projects",
5276
+ tasks: "tasks",
5277
+ milestones: "projects"
5278
+ };
5279
+ for (const key of [
5280
+ "programs",
5281
+ "projects",
5282
+ "tasks",
5283
+ "milestones"
5284
+ ]) {
5285
+ const cut = links.blockers[key];
5286
+ if (!cut) continue;
5287
+ const there = exists(targetOf[key]);
5288
+ out[key] = data[key].map((row) => {
5289
+ const was = cut[row.id];
5290
+ if (!was) return row;
5291
+ const ids = [.../* @__PURE__ */ new Set([...was, ...blockerIds(row)])].filter((id) => there.has(id));
5292
+ return ids.length === blockerIds(row).length ? row : {
5293
+ ...row,
5294
+ ...blockersPatch(ids)
5295
+ };
5296
+ });
5297
+ }
5298
+ if (Object.keys(links.focus).length) {
5299
+ const tasks = exists("tasks");
5300
+ out.ropes = (data.ropes ?? []).map((r) => {
5301
+ const lost = links.focus[`${r.personId}|${r.date}`];
5302
+ if (!lost) return r;
5303
+ const back = lost.filter((id) => tasks.has(id) && !r.taskIds.includes(id));
5304
+ return back.length ? {
5305
+ ...r,
5306
+ taskIds: [...r.taskIds, ...back]
5307
+ } : r;
5308
+ });
5309
+ }
5310
+ return out;
5311
+ }
5269
5312
  var init_cascade = __esmMin((() => {
5270
5313
  init_blockers();
5271
5314
  }));
@@ -5731,15 +5774,19 @@ function roadmapDoneCount(data, portfolioId) {
5731
5774
  }
5732
5775
 
5733
5776
  // src/lib/search.ts
5777
+ /** Lower case without accents, so "cafe" finds "Café" and "Zoë" finds "Zoe". */
5778
+ function fold(text) {
5779
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
5780
+ }
5734
5781
  function tokensOf(query) {
5735
- return query.toLowerCase().split(/\s+/).filter(Boolean);
5782
+ return fold(query).split(/\s+/).filter(Boolean);
5736
5783
  }
5737
5784
  /** Score a thing by its own name first, then by everything else it carries. */
5738
5785
  function scoreMatch(query, name, ...secondary) {
5739
- const q = query.trim().toLowerCase();
5786
+ const q = fold(query.trim());
5740
5787
  if (!q) return SCORE.anywhere;
5741
5788
  const tokens = tokensOf(q);
5742
- const n = name.toLowerCase();
5789
+ const n = fold(name);
5743
5790
  if (n === q) return SCORE.exact;
5744
5791
  if (n.startsWith(q)) return SCORE.prefix;
5745
5792
  const words = n.split(/\s+/);
@@ -5747,7 +5794,7 @@ function scoreMatch(query, name, ...secondary) {
5747
5794
  const bonus = tokens.every((t) => words.some((w) => w.startsWith(t))) ? 5 : 0;
5748
5795
  return SCORE.name + bonus;
5749
5796
  }
5750
- const blob = [n, ...secondary.filter((s) => Boolean(s)).map((s) => s.toLowerCase())].join(" ");
5797
+ const blob = [n, ...secondary.filter((s) => Boolean(s)).map(fold)].join(" ");
5751
5798
  return tokens.every((t) => blob.includes(t)) ? SCORE.anywhere : 0;
5752
5799
  }
5753
5800
  /** Keep what matches, best first. Ties keep the caller's order. */
@@ -19695,6 +19742,85 @@ function exportName(harborName, now) {
19695
19742
  return `${harborName.normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "harbor"}-${now.toISOString().slice(0, 10)}`;
19696
19743
  }
19697
19744
 
19745
+ // src/lib/custom-field-text.ts
19746
+ init_date_text();
19747
+ const FIELD_TYPE_LABEL = {
19748
+ text: "Text",
19749
+ number: "Number",
19750
+ select: "Dropdown",
19751
+ multi: "Tags",
19752
+ checkbox: "Checkbox",
19753
+ date: "Date",
19754
+ person: "Person",
19755
+ url: "Link"
19756
+ };
19757
+ const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
19758
+ /** A dropdown or tags field: one with a list of options. */
19759
+ function hasOptions(type) {
19760
+ return type === "select" || type === "multi";
19761
+ }
19762
+ /** A dropdown's options from "S, M, L". */
19763
+ function optionsOf(value) {
19764
+ return [...new Set(value.split(",").map((o) => o.trim()).filter(Boolean))];
19765
+ }
19766
+ /** The link a value names when it's an http(s) address, else null (never javascript: and the like). */
19767
+ function safeLink(value) {
19768
+ if (typeof value !== "string") return null;
19769
+ try {
19770
+ const u = new URL(value.trim());
19771
+ return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
19772
+ } catch {
19773
+ return null;
19774
+ }
19775
+ }
19776
+ /** A link from "example.com/x" or "https://…"; null when it isn't a web address. */
19777
+ function readLink(text) {
19778
+ const t = text.trim();
19779
+ if (!t || /\s/.test(t)) return null;
19780
+ const href = safeLink(/^[a-z][a-z0-9+.-]*:/i.test(t) ? t : `https://${t}`);
19781
+ return href && /\./.test(new URL(href).hostname) ? href : null;
19782
+ }
19783
+ /** "example.com/docs/plan" from a link: what a short chip says. */
19784
+ function linkLabel(href) {
19785
+ try {
19786
+ const u = new URL(href);
19787
+ const path = u.pathname === "/" ? "" : u.pathname.replace(/\/$/, "");
19788
+ const text = u.host.replace(/^www\./, "") + path;
19789
+ return text.length > 40 ? `${text.slice(0, 39)}…` : text;
19790
+ } catch {
19791
+ return href;
19792
+ }
19793
+ }
19794
+ /** A tags field's value as a list, whatever was stored. */
19795
+ function tagsOf(value) {
19796
+ if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
19797
+ return typeof value === "string" && value ? [value] : [];
19798
+ }
19799
+ /** Whether a task carries a value for the field. */
19800
+ function hasFieldValue(def, value) {
19801
+ if (value == null || value === "" || value === false) return false;
19802
+ if (def.type === "multi") return tagsOf(value).length > 0;
19803
+ return true;
19804
+ }
19805
+ /** A task's value for a field as one line: "" when it has none. */
19806
+ function fieldValueText(def, value, nameOf) {
19807
+ if (!hasFieldValue(def, value)) return "";
19808
+ switch (def.type) {
19809
+ case "checkbox": return value ? "Yes" : "";
19810
+ case "multi": return tagsOf(value).join(", ");
19811
+ case "date": {
19812
+ const d = parseIso(String(value));
19813
+ return Number.isNaN(d.getTime()) ? String(value) : format(d, "MMM d, yyyy");
19814
+ }
19815
+ case "person": return nameOf?.(String(value)) ?? "Someone no longer here";
19816
+ case "url": {
19817
+ const href = safeLink(value);
19818
+ return href ? linkLabel(href) : "";
19819
+ }
19820
+ default: return Array.isArray(value) ? value.join(", ") : String(value);
19821
+ }
19822
+ }
19823
+
19698
19824
  // src/lib/exports.ts
19699
19825
  /**
19700
19826
  * The spreadsheets Harbor hands out: a project's tasks, the risk register and
@@ -19723,6 +19849,8 @@ function tasksCsv(data, projectId) {
19723
19849
  const here = byId(tasks);
19724
19850
  const people = byId(data.people);
19725
19851
  const name = (t) => taskKey(t, project) ?? t.title;
19852
+ const fields = project?.customFields ?? [];
19853
+ const nameOf = (id) => people.get(id)?.name;
19726
19854
  return toCsv([
19727
19855
  "Key",
19728
19856
  "Title",
@@ -19732,7 +19860,8 @@ function tasksCsv(data, projectId) {
19732
19860
  "Due",
19733
19861
  "Done",
19734
19862
  "Blocked by",
19735
- "Description"
19863
+ "Description",
19864
+ ...fields.map((f) => f.name)
19736
19865
  ], tasks.map((t) => [
19737
19866
  taskKey(t, project) ?? "",
19738
19867
  t.title,
@@ -19742,7 +19871,8 @@ function tasksCsv(data, projectId) {
19742
19871
  t.dueDate ?? "",
19743
19872
  t.status === "done" ? "yes" : "no",
19744
19873
  blockerIds(t).map((id) => here.get(id)).filter((b) => Boolean(b)).map(name).join("; "),
19745
- t.description
19874
+ t.description,
19875
+ ...fields.map((f) => f.type === "url" ? safeLink(t.customFieldValues?.[f.id]) ?? "" : fieldValueText(f, t.customFieldValues?.[f.id], nameOf))
19746
19876
  ]));
19747
19877
  }
19748
19878
  /** Where a risk, issue or decision lives, by name; null for the whole harbor. */
@@ -20631,6 +20761,26 @@ function soundingOut(data, t, meId, now) {
20631
20761
  };
20632
20762
  }
20633
20763
  const dayOf = (iso) => iso ? iso.slice(0, 10) : null;
20764
+ /** A project's own fields, as set_field takes them (custom-field-text.ts). */
20765
+ function fieldDefsOut(p) {
20766
+ return (p.customFields ?? []).map((f) => ({
20767
+ name: f.name,
20768
+ type: FIELD_TYPE_LABEL[f.type].toLowerCase(),
20769
+ ...hasOptions(f.type) ? { options: f.options ?? [] } : {}
20770
+ }));
20771
+ }
20772
+ /** A task's values for its project's own fields, by field name: { Size: "L" }. */
20773
+ function fieldValuesOut(data, p, t) {
20774
+ if (!p?.customFields?.length || !t.customFieldValues) return null;
20775
+ const people = byId(data.people);
20776
+ const out = {};
20777
+ for (const f of p.customFields) {
20778
+ const v = t.customFieldValues[f.id];
20779
+ if (!hasFieldValue(f, v)) continue;
20780
+ 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);
20781
+ }
20782
+ return Object.keys(out).length ? out : null;
20783
+ }
20634
20784
  /** A task's approval (src/lib/task-approval.ts), as an agent reads it. */
20635
20785
  function approvalOut(data, a) {
20636
20786
  const people = byId(data.people);
@@ -20993,7 +21143,7 @@ const HARBOR_TOOLS = [
20993
21143
  tool({
20994
21144
  name: "get_project",
20995
21145
  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,
21146
+ 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
21147
  input: object({
20998
21148
  harbor: harborArg,
20999
21149
  project: string().min(1).max(200).describe("Project id or name.")
@@ -21054,6 +21204,7 @@ const HARBOR_TOOLS = [
21054
21204
  waitsOn,
21055
21205
  milestones,
21056
21206
  openRaid: raid,
21207
+ ...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
21057
21208
  finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
21058
21209
  ...charterOf(p, tasks),
21059
21210
  ...notesOf(ctx, p),
@@ -21098,12 +21249,13 @@ const HARBOR_TOOLS = [
21098
21249
  if (input.query) {
21099
21250
  const q = input.query;
21100
21251
  const named = findByKey(data, q);
21252
+ const names = new Map(data.projects.map((p) => [p.id, p.name]));
21101
21253
  list = list.map((t) => ({
21102
21254
  t,
21103
- s: scoreMatch(q, t.title, t.description)
21255
+ s: scoreMatch(q, t.title, t.description, names.get(t.projectId))
21104
21256
  })).filter((h) => h.s > 0 && h.t !== named).sort((a, b) => b.s - a.s).map((h) => h.t);
21105
21257
  if (named) list.unshift(named);
21106
- if (!input.status?.length) doneMatches = data.tasks.filter((t) => t.status === "done" && t !== named && fits(t) && scoreMatch(q, t.title, t.description) > 0).length;
21258
+ if (!input.status?.length) doneMatches = data.tasks.filter((t) => t.status === "done" && t !== named && fits(t) && scoreMatch(q, t.title, t.description, names.get(t.projectId)) > 0).length;
21107
21259
  } else list = [...list].sort((a, b) => a.status === "done" && b.status === "done" ? (b.completedAt ?? "").localeCompare(a.completedAt ?? "") : Number(a.status === "done") - Number(b.status === "done") || (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999"));
21108
21260
  return {
21109
21261
  ...doneMatches ? { doneMatches } : {},
@@ -21115,7 +21267,7 @@ const HARBOR_TOOLS = [
21115
21267
  tool({
21116
21268
  name: "get_task",
21117
21269
  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,
21270
+ 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
21271
  input: object({
21120
21272
  harbor: harborArg,
21121
21273
  task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
@@ -21169,6 +21321,7 @@ const HARBOR_TOOLS = [
21169
21321
  waitedOnBy: waiters.map(brief),
21170
21322
  ...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
21171
21323
  ...t.approval ? { approval: approvalOut(data, t.approval) } : {},
21324
+ ...((v) => v ? { fields: v } : {})(fieldValuesOut(data, project, t)),
21172
21325
  ...pilot ? { pilotDue: {
21173
21326
  earliest: pilot.earliest,
21174
21327
  fits: pilot.fits,
@@ -22773,7 +22926,10 @@ function withStatus(task, status) {
22773
22926
  touchedAt: stamp()
22774
22927
  };
22775
22928
  if (status === "done") next.completedAt = stamp();
22776
- else if (task.status === "done") next.completedAt = null;
22929
+ else if (task.status === "done") {
22930
+ next.completedAt = null;
22931
+ if (task.approval?.status === "approved") next.approval = null;
22932
+ }
22777
22933
  return next;
22778
22934
  }
22779
22935
  var init_shared = __esmMin((() => {}));
@@ -22946,6 +23102,26 @@ var init_session = __esmMin((() => {
22946
23102
  init_format();
22947
23103
  }));
22948
23104
 
23105
+ // src/lib/assignment.ts
23106
+ /**
23107
+ * Record who set a task's assignee and when. Only a change of assignee
23108
+ * stamps; clearing the assignee clears the stamp.
23109
+ */
23110
+ function stampAssignment(before, next, by, at) {
23111
+ if (before && before.assigneeId === next.assigneeId) return next;
23112
+ if (!next.assigneeId) return {
23113
+ ...next,
23114
+ assignedBy: null,
23115
+ assignedAt: null
23116
+ };
23117
+ return {
23118
+ ...next,
23119
+ assignedBy: by,
23120
+ assignedAt: at
23121
+ };
23122
+ }
23123
+ var init_assignment = __esmMin((() => {}));
23124
+
22949
23125
  // src/lib/first-week.ts
22950
23126
  /** The program launch named for you, when nobody has renamed it yet. */
22951
23127
  function starterProgram(data) {
@@ -23272,13 +23448,13 @@ function hierarchySlice({ get, write }) {
23272
23448
  const program = get().programs.find((p) => p.id === programId);
23273
23449
  get().log(`moved ${ids.length} project${ids.length === 1 ? "" : "s"} to ${program?.name ?? "a program"}`, `/programs/${programId}`);
23274
23450
  },
23275
- restoreRows: (rows) => {
23451
+ restoreRows: (rows, links) => {
23276
23452
  const state = get();
23277
23453
  const back = (current, add) => {
23278
23454
  const have = new Set((current ?? []).map((r) => r.id));
23279
23455
  return [...current ?? [], ...(add ?? []).filter((r) => !have.has(r.id))];
23280
23456
  };
23281
- write({
23457
+ const next = {
23282
23458
  portfolios: back(state.portfolios, rows.portfolios),
23283
23459
  programs: back(state.programs, rows.programs),
23284
23460
  projects: back(state.projects, rows.projects),
@@ -23294,7 +23470,14 @@ function hierarchySlice({ get, write }) {
23294
23470
  ...rows.boardConfigs ?? {},
23295
23471
  ...state.boardConfigs ?? {}
23296
23472
  }
23297
- });
23473
+ };
23474
+ write(links ? {
23475
+ ...next,
23476
+ ...relink({
23477
+ ...state,
23478
+ ...next
23479
+ }, links)
23480
+ } : next);
23298
23481
  const name = rows.portfolios?.[0]?.name ?? rows.programs?.[0]?.name ?? rows.projects?.[0]?.name;
23299
23482
  if (name) get().log(`put back “${name}”`);
23300
23483
  },
@@ -23305,10 +23488,13 @@ function hierarchySlice({ get, write }) {
23305
23488
  const ids = have(current);
23306
23489
  return [...current, ...(more ?? []).filter((r) => !ids.has(r.id))];
23307
23490
  };
23491
+ const small = (rows.tasks?.length ?? 0) <= 20;
23492
+ const at = stamp();
23493
+ const incoming = small ? rows.tasks?.map((t) => stampAssignment(void 0, t, state.currentUserId, at)) : rows.tasks;
23308
23494
  write({
23309
23495
  programs: add(state.programs, rows.programs),
23310
23496
  projects: add(state.projects, rows.projects),
23311
- tasks: add(state.tasks, rows.tasks),
23497
+ tasks: add(state.tasks, incoming),
23312
23498
  people: add(state.people, rows.people),
23313
23499
  raidItems: add(state.raidItems, rows.raidItems)
23314
23500
  });
@@ -23379,6 +23565,7 @@ function hierarchySlice({ get, write }) {
23379
23565
  }
23380
23566
  };
23381
23567
  }
23568
+ var SMALL_IMPORT;
23382
23569
  var init_hierarchy = __esmMin((() => {
23383
23570
  init_board();
23384
23571
  init_cascade();
@@ -23388,11 +23575,13 @@ var init_hierarchy = __esmMin((() => {
23388
23575
  init_health_history();
23389
23576
  init_derived_health();
23390
23577
  init_shared();
23578
+ init_assignment();
23391
23579
  init_hill();
23392
23580
  init_charter_lines();
23393
23581
  init_format();
23394
23582
  init_empty();
23395
23583
  init_first_week();
23584
+ SMALL_IMPORT = 20;
23396
23585
  }));
23397
23586
 
23398
23587
  // src/lib/github-refs.ts
@@ -23441,6 +23630,16 @@ function runActions(actions, start, ctx) {
23441
23630
  for (const action of actions) switch (action.type) {
23442
23631
  case "set_status": {
23443
23632
  if (!action.status) break;
23633
+ if (action.status === "done" && task.status !== "done" && ctx.waiting?.(task)) {
23634
+ comments.push({
23635
+ taskId: task.id,
23636
+ authorId: ctx.authorId,
23637
+ body: "Left open: this still waits on unfinished work, so the rule didn’t finish it.",
23638
+ createdAt: ctx.now,
23639
+ mentionedIds: []
23640
+ });
23641
+ break;
23642
+ }
23444
23643
  const wasDone = task.status === "done";
23445
23644
  const completedAt = action.status === "done" ? task.completedAt ?? ctx.now : wasDone ? null : task.completedAt;
23446
23645
  task = {
@@ -23506,7 +23705,8 @@ function applyAutomations(project, prev, next, opts) {
23506
23705
  const ctx = {
23507
23706
  authorId: opts.currentUserId,
23508
23707
  now,
23509
- today
23708
+ today,
23709
+ waiting: opts.waiting
23510
23710
  };
23511
23711
  let task = next;
23512
23712
  const comments = [];
@@ -23612,26 +23812,6 @@ var init_recurrence = __esmMin((() => {
23612
23812
  step = (d, repeat, n) => repeat === "weekly" ? addDays(d, 7 * n) : repeat === "biweekly" ? addDays(d, 14 * n) : addMonths$1(d, n);
23613
23813
  }));
23614
23814
 
23615
- // src/lib/assignment.ts
23616
- /**
23617
- * Record who set a task's assignee and when. Only a change of assignee
23618
- * stamps; clearing the assignee clears the stamp.
23619
- */
23620
- function stampAssignment(before, next, by, at) {
23621
- if (before && before.assigneeId === next.assigneeId) return next;
23622
- if (!next.assigneeId) return {
23623
- ...next,
23624
- assignedBy: null,
23625
- assignedAt: null
23626
- };
23627
- return {
23628
- ...next,
23629
- assignedBy: by,
23630
- assignedAt: at
23631
- };
23632
- }
23633
- var init_assignment = __esmMin((() => {}));
23634
-
23635
23815
  // src/lib/bulk.ts
23636
23816
  /** A task's due date moved by `days`, or null when it has none. */
23637
23817
  function shiftedDue(due, days) {
@@ -23728,7 +23908,8 @@ function tasksSlice({ set, get, write }) {
23728
23908
  const project = get().projects.find((p) => p.id === t.projectId);
23729
23909
  const { task: automated, comments: autoComments } = applyAutomations(project, void 0, row, {
23730
23910
  created: true,
23731
- currentUserId: get().currentUserId
23911
+ currentUserId: get().currentUserId,
23912
+ waiting: (t) => isBlocked$1(t, get().tasks)
23732
23913
  });
23733
23914
  const finalRow = stampAssignment(void 0, automated, get().currentUserId, stamp());
23734
23915
  write({
@@ -23771,12 +23952,16 @@ function tasksSlice({ set, get, write }) {
23771
23952
  if (next.assigneeId && next.followerIds?.includes(next.assigneeId)) next.followerIds = alsoOnIds(next);
23772
23953
  if (patch.status && patch.status !== row.status) {
23773
23954
  if (patch.status === "done" && !patch.completedAt) next.completedAt = stamp();
23774
- else if (row.status === "done" && patch.status !== "done") next.completedAt = null;
23955
+ else if (row.status === "done" && patch.status !== "done") {
23956
+ next.completedAt = null;
23957
+ if (row.approval?.status === "approved" && !("approval" in patch)) next.approval = null;
23958
+ }
23775
23959
  if (patch.status === "done" && isBlocked$1(row, get().tasks)) Object.assign(next, blockersPatch([]));
23776
23960
  }
23777
23961
  const { task: finalTask, comments: autoComments } = applyAutomations(project, row, next, {
23778
23962
  created: false,
23779
- currentUserId: get().currentUserId
23963
+ currentUserId: get().currentUserId,
23964
+ waiting: (t) => isBlocked$1(t, get().tasks)
23780
23965
  });
23781
23966
  newComments.push(...autoComments.map((c) => ({
23782
23967
  ...c,
@@ -23815,7 +24000,8 @@ function tasksSlice({ set, get, write }) {
23815
24000
  const project = get().projects.find((p) => p.id === task.projectId);
23816
24001
  const { task: finalRow, comments: autoComments } = applyAutomations(project, task, nextRow, {
23817
24002
  created: false,
23818
- currentUserId: get().currentUserId
24003
+ currentUserId: get().currentUserId,
24004
+ waiting: (t) => isBlocked$1(t, get().tasks)
23819
24005
  });
23820
24006
  const follow = status === "done" ? nextOccurrence(finalRow, uid("t"), todayISO(), stamp(), tasksOf(get().tasks, task.projectId)) : null;
23821
24007
  const moved = get().tasks.map((row) => row.id === id ? finalRow : row);
@@ -23897,7 +24083,8 @@ function tasksSlice({ set, get, write }) {
23897
24083
  },
23898
24084
  updateComment: (id, body) => {
23899
24085
  const comment = get().comments.find((c) => c.id === id);
23900
- if (!comment || comment.authorId !== get().currentUserId) return;
24086
+ if (!comment) return;
24087
+ if (comment.authorId !== get().currentUserId && get().myRole !== "owner") return;
23901
24088
  const trimmed = body.trim();
23902
24089
  if (!trimmed) return;
23903
24090
  const mentionedIds = parseMentions(trimmed, get().people).filter((pid) => pid !== get().currentUserId);
@@ -23910,7 +24097,8 @@ function tasksSlice({ set, get, write }) {
23910
24097
  },
23911
24098
  deleteComment: (id) => {
23912
24099
  const comment = get().comments.find((c) => c.id === id);
23913
- if (!comment || comment.authorId !== get().currentUserId) return;
24100
+ if (!comment) return;
24101
+ if (comment.authorId !== get().currentUserId && get().myRole !== "owner") return;
23914
24102
  set({ comments: get().comments.filter((c) => c.id !== id) });
23915
24103
  },
23916
24104
  addChecklistItem: (taskId, title) => {
@@ -24091,7 +24279,8 @@ function tasksSlice({ set, get, write }) {
24091
24279
  }
24092
24280
  const { task: automated, comments } = applyAutomations(projects.get(row.projectId), row, next, {
24093
24281
  created: false,
24094
- currentUserId: me
24282
+ currentUserId: me,
24283
+ waiting: (t) => isBlocked$1(t, s.tasks)
24095
24284
  });
24096
24285
  newComments.push(...comments.map((c) => ({
24097
24286
  ...c,
@@ -24251,6 +24440,7 @@ var init_raid_history = __esmMin((() => {}));
24251
24440
  function milestonesRaidSlice({ set, get, write }) {
24252
24441
  return {
24253
24442
  toggleMilestone: (id) => {
24443
+ if (get().milestones.find((m) => m.id === id)?.approverId) return;
24254
24444
  write({ milestones: get().milestones.map((m) => m.id === id ? {
24255
24445
  ...m,
24256
24446
  done: !m.done
@@ -26371,7 +26561,7 @@ function fieldsOf(data, task) {
26371
26561
  };
26372
26562
  }
26373
26563
  /** 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) {
26564
+ function personNamed$2(people, name) {
26375
26565
  const want = name.trim().toLowerCase();
26376
26566
  if (!want) return null;
26377
26567
  const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
@@ -26413,7 +26603,7 @@ function taskPatch(data, fields, say, label) {
26413
26603
  if (fields.repeat !== void 0) patch.repeat = fields.repeat;
26414
26604
  if (fields.notes !== void 0) patch.description = fields.notes;
26415
26605
  if (fields.owner !== void 0) {
26416
- const person = fields.owner ? personNamed$1(data.people, fields.owner) : null;
26606
+ const person = fields.owner ? personNamed$2(data.people, fields.owner) : null;
26417
26607
  if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
26418
26608
  patch.assigneeId = person?.id ?? null;
26419
26609
  }
@@ -26574,7 +26764,7 @@ function applyFolder(store, projectId, files, base, opts) {
26574
26764
  let fresh = 0;
26575
26765
  if (opts.fileWins) for (const text of files.values()) {
26576
26766
  const owner = parseTask(text).fields?.owner;
26577
- if (owner && !personNamed$1(get().people, owner)) {
26767
+ if (owner && !personNamed$2(get().people, owner)) {
26578
26768
  const { id: _id, ...person } = localPerson(owner, "");
26579
26769
  get().addPerson(person);
26580
26770
  }
@@ -26836,7 +27026,7 @@ async function moveLocalTasks(folder, store, projectId, say) {
26836
27026
  };
26837
27027
  for (const t of local.tasks) {
26838
27028
  const owner = t.assigneeId ? local.people.find((p) => p.id === t.assigneeId)?.name : null;
26839
- const person = owner ? personNamed$1(get().people, owner) : null;
27029
+ const person = owner ? personNamed$2(get().people, owner) : null;
26840
27030
  if (owner && !person) say(`${t.title}: nobody called "${owner}" is in the harbor, so it has no owner.`);
26841
27031
  const id = get().addTask({
26842
27032
  projectId,
@@ -27062,7 +27252,7 @@ function taskIn(data, project, key) {
27062
27252
  if (!task || task.projectId !== project.id) throw new HarborError(`No task ${key} in ${project.name}. Use its key, like ${project.key ?? "RR"}-4.`);
27063
27253
  return task;
27064
27254
  }
27065
- const personNamed = (data, name) => {
27255
+ const personNamed$1 = (data, name) => {
27066
27256
  try {
27067
27257
  return findOne(data.people, name, "person");
27068
27258
  } catch (e) {
@@ -27103,7 +27293,7 @@ function changesFrom(data, project, flags, now) {
27103
27293
  for (const [key, who] of pairs(flags.give, "give", "KEY=NAME (RL-4=Sam, RL-5=none)")) add({
27104
27294
  kind: "assign",
27105
27295
  taskId: taskIn(data, project, key).id,
27106
- assigneeId: who === "none" ? null : personNamed(data, who).id
27296
+ assigneeId: who === "none" ? null : personNamed$1(data, who).id
27107
27297
  });
27108
27298
  if (flags.end) add({
27109
27299
  kind: "end",
@@ -27115,13 +27305,13 @@ function changesFrom(data, project, flags, now) {
27115
27305
  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
27306
  add({
27117
27307
  kind: "borrow",
27118
- personId: personNamed(data, name.trim()).id,
27308
+ personId: personNamed$1(data, name.trim()).id,
27119
27309
  days
27120
27310
  });
27121
27311
  }
27122
27312
  for (const name of list(flags.release)) add({
27123
27313
  kind: "release",
27124
- personId: personNamed(data, name).id
27314
+ personId: personNamed$1(data, name).id
27125
27315
  });
27126
27316
  return changes;
27127
27317
  }
@@ -28305,8 +28495,12 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
28305
28495
  } else if (key.upArrow || input === "k") setMAt((i) => step$1(i, -1, marks.length));
28306
28496
  else if (key.downArrow || input === "j") setMAt((i) => step$1(i, 1, marks.length));
28307
28497
  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");
28498
+ else if (input === " " && mark) {
28499
+ if (mark.approverId) {
28500
+ const approver = people.find((p) => p.id === mark.approverId);
28501
+ go.notify(`${approver?.name ?? "The approver"} ticks this off by signing it off.`);
28502
+ } else store.getState().toggleMilestone(mark.id);
28503
+ } else if (input === "d" && mark) go.confirm(`Delete the milestone “${mark.name}”? u undoes it.`, () => store.getState().deleteMilestone(mark.id), "Delete");
28310
28504
  }, { isActive: active });
28311
28505
  if (!program) return /* @__PURE__ */ jsx(Text, {
28312
28506
  color: theme.muted,
@@ -29660,33 +29854,6 @@ function defaultAction(type) {
29660
29854
  };
29661
29855
  }
29662
29856
 
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
29857
  // src/tui/views/setup.tsx
29691
29858
  /**
29692
29859
  * A project's board setup, as the website's board settings have it: where it
@@ -29743,7 +29910,8 @@ function columnValue(c) {
29743
29910
  }
29744
29911
  function fieldValue(f) {
29745
29912
  const kind = FIELD_TYPE_LABEL[f.type];
29746
- return f.type === "select" && f.options?.length ? `${kind}: ${f.options.join(", ")}` : kind;
29913
+ const head = hasOptions(f.type) && f.options?.length ? `${kind}: ${f.options.join(", ")}` : kind;
29914
+ return f.onCards ? `${head} · on cards` : head;
29747
29915
  }
29748
29916
  function BoardPanel({ id, width, height, go, active, onClose }) {
29749
29917
  const { store } = useDeps();
@@ -29892,10 +30060,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29892
30060
  label: "Rename it",
29893
30061
  value: "rename"
29894
30062
  },
29895
- ...f.type === "select" ? [{
30063
+ ...hasOptions(f.type) ? [{
29896
30064
  label: "Change its options",
29897
30065
  value: "options"
29898
30066
  }] : [],
30067
+ {
30068
+ label: f.onCards ? "Stop showing it on cards" : "Show it on board cards",
30069
+ value: "cards"
30070
+ },
29899
30071
  {
29900
30072
  label: "Take it out",
29901
30073
  value: "remove"
@@ -29904,6 +30076,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29904
30076
  onPick: (what) => {
29905
30077
  const s = store.getState();
29906
30078
  if (what === "rename") return go.ask("Field name", (text) => text.trim() && s.updateCustomField(id, f.id, { name: text.trim() }), f.name);
30079
+ if (what === "cards") return s.updateCustomField(id, f.id, { onCards: f.onCards ? void 0 : true });
29907
30080
  if (what === "options") return go.ask("Options, split by commas", (text) => s.updateCustomField(id, f.id, { options: optionsOf(text) }), (f.options ?? []).join(", "));
29908
30081
  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
30082
  }
@@ -29924,7 +30097,7 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29924
30097
  value: t
29925
30098
  })), (t) => {
29926
30099
  const type = t;
29927
- if (type !== "select") return void store.getState().addCustomField(id, {
30100
+ if (!hasOptions(type)) return void store.getState().addCustomField(id, {
29928
30101
  name: trimmed,
29929
30102
  type
29930
30103
  });
@@ -30058,6 +30231,113 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
30058
30231
  });
30059
30232
  }
30060
30233
 
30234
+ // src/lib/date-picker.ts
30235
+ /**
30236
+ * The date picker's rules: the days a month's grid shows (always six weeks,
30237
+ * so the calendar keeps one height and stops jumping as you flip months),
30238
+ * the years its year list offers, and what a typed date means. Pure.
30239
+ */
30240
+ /**
30241
+ * The day a typed date names, as an ISO day, or null when it names none.
30242
+ * Reads what the task composer reads ("Mar 14", "14 March 2027", "3/14",
30243
+ * "2027-03-14", "next friday", "in 2 weeks", "tomorrow"), plus a year after
30244
+ * a slash date in full ("3/14/2027"), and full stops or dashes for slashes.
30245
+ */
30246
+ function typedDate(text, today) {
30247
+ 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}`);
30248
+ if (!t) return null;
30249
+ return dayFromWords(t, today);
30250
+ }
30251
+
30252
+ // src/lib/custom-field-read.ts
30253
+ /**
30254
+ * What someone typed for one of a project's own fields, as the value kept on
30255
+ * the task: in the terminal, the CLI and AI apps (the website's drawer has a
30256
+ * control per type). Pure.
30257
+ */
30258
+ const YES = /* @__PURE__ */ new Set([
30259
+ "yes",
30260
+ "y",
30261
+ "true",
30262
+ "on",
30263
+ "1",
30264
+ "x",
30265
+ "✓"
30266
+ ]);
30267
+ const NO = /* @__PURE__ */ new Set([
30268
+ "no",
30269
+ "n",
30270
+ "false",
30271
+ "off",
30272
+ "0"
30273
+ ]);
30274
+ function option(def, text) {
30275
+ const t = text.trim().toLowerCase();
30276
+ return (def.options ?? []).find((o) => o.toLowerCase() === t);
30277
+ }
30278
+ /** The person a name names: their whole name, first name, or id; one match only. */
30279
+ function personNamed(people, text) {
30280
+ const t = text.trim().replace(/^@/, "").toLowerCase();
30281
+ if (!t) return void 0;
30282
+ const exact = people.filter((p) => p.id === t || p.name.toLowerCase() === t);
30283
+ if (exact.length === 1) return exact[0];
30284
+ const first = people.filter((p) => p.name.toLowerCase().split(/\s+/)[0] === t);
30285
+ if (first.length === 1) return first[0];
30286
+ return first.length > 1 ? "many" : void 0;
30287
+ }
30288
+ /**
30289
+ * A typed value for a field: an empty string clears it. Says what's wrong
30290
+ * when it can't be read (an option the field doesn't have, a name that
30291
+ * matches nobody, a day it can't read).
30292
+ */
30293
+ function readFieldValue(def, text, ctx) {
30294
+ const t = text.trim();
30295
+ if (!t) return { value: def.type === "multi" ? [] : null };
30296
+ switch (def.type) {
30297
+ case "number": {
30298
+ const n = Number(t.replace(/,/g, ""));
30299
+ return Number.isFinite(n) ? { value: n } : { error: `“${t}” isn't a number.` };
30300
+ }
30301
+ case "checkbox": {
30302
+ const l = t.toLowerCase();
30303
+ if (YES.has(l)) return { value: true };
30304
+ if (NO.has(l)) return { value: false };
30305
+ return { error: "Say yes or no." };
30306
+ }
30307
+ case "select": {
30308
+ const o = option(def, t);
30309
+ return o ? { value: o } : { error: offer(def, t) };
30310
+ }
30311
+ case "multi": {
30312
+ const picked = [];
30313
+ for (const part of t.split(",").map((p) => p.trim()).filter(Boolean)) {
30314
+ const o = option(def, part);
30315
+ if (!o) return { error: offer(def, part) };
30316
+ if (!picked.includes(o)) picked.push(o);
30317
+ }
30318
+ return { value: picked };
30319
+ }
30320
+ case "date": {
30321
+ const day = typedDate(t, ctx.today);
30322
+ return day ? { value: day } : { error: `Couldn't read “${t}” as a day.` };
30323
+ }
30324
+ case "person": {
30325
+ const p = personNamed(ctx.people, t);
30326
+ if (p === "many") return { error: `More than one person is called “${t}”; use the full name.` };
30327
+ return p ? { value: p.id } : { error: `Nobody here is called “${t}”.` };
30328
+ }
30329
+ case "url": {
30330
+ const href = readLink(t);
30331
+ return href ? { value: href } : { error: `“${t}” isn't a web address.` };
30332
+ }
30333
+ default: return { value: t.slice(0, 500) };
30334
+ }
30335
+ }
30336
+ function offer(def, t) {
30337
+ const opts = def.options ?? [];
30338
+ return opts.length ? `“${t}” isn't one of ${def.name}'s options: ${opts.join(", ")}.` : `${def.name} has no options yet.`;
30339
+ }
30340
+
30061
30341
  // src/tui/views/sounding.ts
30062
30342
  init_soundings();
30063
30343
  const nameOf = (people, id) => people.find((p) => p.id === id)?.name ?? "someone";
@@ -30310,6 +30590,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30310
30590
  const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
30311
30591
  const blockers = blockersOf(task, tasks);
30312
30592
  const done = () => setEditing(null);
30593
+ const nameOf = (id) => people.find((p) => p.id === id)?.name;
30313
30594
  const setCustom = (fieldId, value) => store.getState().setTaskCustomFieldValue(task.id, fieldId, value);
30314
30595
  function editCustom(f) {
30315
30596
  if (!task) return;
@@ -30322,6 +30603,13 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30322
30603
  label: o,
30323
30604
  value: o
30324
30605
  }))], (v) => setCustom(f.id, v));
30606
+ if (f.type === "person") return go.pick(f.name, [{
30607
+ label: "Nobody",
30608
+ value: null
30609
+ }, ...people.filter((p) => p.kind !== "agent").map((p) => ({
30610
+ label: p.name,
30611
+ value: p.id
30612
+ }))], (v) => setCustom(f.id, v));
30325
30613
  setEditing({
30326
30614
  kind: "custom",
30327
30615
  fieldId: f.id
@@ -30409,7 +30697,9 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30409
30697
  const f = own.find((x) => x.id === editing.fieldId);
30410
30698
  if (f) editor = /* @__PURE__ */ jsx(CustomField, {
30411
30699
  def: f,
30412
- initial: fieldValueText(f, task.customFieldValues?.[f.id]),
30700
+ initial: typedText(f, task.customFieldValues?.[f.id]),
30701
+ today: day,
30702
+ people,
30413
30703
  onCancel: done,
30414
30704
  onSubmit: (v) => {
30415
30705
  setCustom(f.id, v);
@@ -30605,7 +30895,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30605
30895
  color: theme.muted,
30606
30896
  children: f.name.slice(0, 8).padEnd(9)
30607
30897
  }),
30608
- fieldValueText(f, task.customFieldValues?.[f.id]) || "—"
30898
+ fieldValueText(f, task.customFieldValues?.[f.id], nameOf) || "—"
30609
30899
  ]
30610
30900
  }, f.id);
30611
30901
  }),
@@ -30696,18 +30986,34 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
30696
30986
  ]
30697
30987
  });
30698
30988
  }
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 }) {
30989
+ /** What a field's value reads as when it's typed again: the day or link as kept. */
30990
+ function typedText(def, value) {
30991
+ if (value == null || value === false) return "";
30992
+ if (def.type === "multi") return tagsOf(value).join(", ");
30993
+ if (def.type === "checkbox") return "yes";
30994
+ return String(value);
30995
+ }
30996
+ const FIELD_HINT = {
30997
+ number: "A number, or empty for none",
30998
+ date: "A day (2026-12-01, friday, in 2 weeks), or empty for none",
30999
+ url: "A web address, or empty for none"
31000
+ };
31001
+ /** A typed field of the project's own; a value that can't be read says why. */
31002
+ function CustomField({ def, initial, today, people, onSubmit, onCancel }) {
30701
31003
  const [error, setError] = useState(null);
31004
+ 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
31005
  return /* @__PURE__ */ jsx(TextField, {
30703
31006
  label: def.name,
30704
31007
  initial,
30705
- hint: error ?? (def.type === "number" ? "A number, or empty for none" : "Empty for none"),
31008
+ hint: error ?? hint,
30706
31009
  onCancel,
30707
31010
  onSubmit: (text) => {
30708
- const v = readFieldValue(def, text);
30709
- if (v === void 0) return setError(`“${text}” isn't a number.`);
30710
- onSubmit(v);
31011
+ const read = readFieldValue(def, text, {
31012
+ today,
31013
+ people
31014
+ });
31015
+ if ("error" in read) return setError(read.error);
31016
+ onSubmit(read.value);
30711
31017
  }
30712
31018
  });
30713
31019
  }
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);
@@ -2725,7 +2737,7 @@ export async function run(argv, io = {}) {
2725
2737
  },
2726
2738
  (r) =>
2727
2739
  [
2728
- `${r.updated.title}: ${{ doing: "started", review: "in review", done: "done" }[status]}.`,
2740
+ `${r.updated.title}: ${r.reopened ? "reopened, " : ""}${{ doing: "started", review: "in review", done: "done" }[status]}.`,
2729
2741
  r.queuedNext ? `Next one: ${taskLine(r.queuedNext, opts)}` : "",
2730
2742
  r.projectFinished
2731
2743
  ? `That was the last open task in ${r.projectFinished}. Mark the project Done in the app (harbor) or on the website.`
@@ -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.86.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",