@harborppm/cli 0.78.0 → 0.80.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/app.mjs CHANGED
@@ -5913,7 +5913,7 @@ const STATUS_ORDER = [
5913
5913
  "review",
5914
5914
  "done"
5915
5915
  ];
5916
- const PRIORITIES$1 = [
5916
+ const PRIORITIES$2 = [
5917
5917
  "low",
5918
5918
  "medium",
5919
5919
  "high",
@@ -5938,7 +5938,7 @@ function isLate(task, today) {
5938
5938
  function myOpenTasks(data, personId, today) {
5939
5939
  const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
5940
5940
  const rank = (t) => isLate(t, today) ? 0 : t.dueDate ? 1 : 2;
5941
- return mine.sort((a, b) => rank(a) - rank(b) || (a.dueDate ?? "").localeCompare(b.dueDate ?? "") || PRIORITIES$1.indexOf(b.priority) - PRIORITIES$1.indexOf(a.priority));
5941
+ return mine.sort((a, b) => rank(a) - rank(b) || (a.dueDate ?? "").localeCompare(b.dueDate ?? "") || PRIORITIES$2.indexOf(b.priority) - PRIORITIES$2.indexOf(a.priority));
5942
5942
  }
5943
5943
  /** Others' open tasks that wait on one of this person's. */
5944
5944
  function waitingOnMe(data, personId) {
@@ -9834,6 +9834,7 @@ init_work();
9834
9834
  init_clock();
9835
9835
  init_soundings();
9836
9836
  const KIND_RANK = {
9837
+ harbor: -1,
9837
9838
  waiting: 0,
9838
9839
  approval: 1,
9839
9840
  review: 2,
@@ -9843,6 +9844,7 @@ const KIND_RANK = {
9843
9844
  mention: 6
9844
9845
  };
9845
9846
  const PING_KIND_LABEL = {
9847
+ harbor: "Your harbor",
9846
9848
  waiting: "Waiting on you",
9847
9849
  approval: "Sign-offs",
9848
9850
  review: "Reviews due",
@@ -9882,6 +9884,19 @@ function inboxOf(data, personId, now = clockNow()) {
9882
9884
  const pings = [];
9883
9885
  const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
9884
9886
  const since = subDays(now, ASSIGNED_DAYS).toISOString();
9887
+ const me = data.people.find((p) => p.id === personId);
9888
+ if (me?.harborHandedAt && me.harborHandedAt >= since && me.harborHandedById !== personId) {
9889
+ const by = data.people.find((p) => p.id === me.harborHandedById);
9890
+ pings.push({
9891
+ id: `harbor:${me.harborHandedAt}`,
9892
+ kind: "harbor",
9893
+ title: `${by ? firstName$3(by.name) : "The owner"} handed this harbor to you`,
9894
+ detail: "You own it now: invites, roles, billing, webhooks and the whole-harbor download. Settings has them all.",
9895
+ at: me.harborHandedAt,
9896
+ personId: by?.id ?? null,
9897
+ href: "/settings"
9898
+ });
9899
+ }
9885
9900
  for (const task of mine) {
9886
9901
  if (!task.assignedBy || task.assignedBy === personId || !task.assignedAt) continue;
9887
9902
  if (task.assignedAt < since) continue;
@@ -13220,6 +13235,7 @@ const PAGES = {
13220
13235
  projects: "/projects",
13221
13236
  risks: "/risks",
13222
13237
  goals: "/goals",
13238
+ reports: "/reports",
13223
13239
  calendar: "/calendar",
13224
13240
  people: "/people"
13225
13241
  };
@@ -13744,6 +13760,269 @@ function keyResultText(kr, work) {
13744
13760
  }
13745
13761
  const fmt = (n) => Number.isInteger(n) ? n.toLocaleString("en-US") : String(Math.round(n * 100) / 100);
13746
13762
 
13763
+ // src/lib/reports.ts
13764
+ init_date_fns();
13765
+ init_date_text();
13766
+ init_indexes();
13767
+ init_swatches();
13768
+ /** Weeks a "by week" chart shows: back from this week, or ahead for due dates. */
13769
+ const WEEKS_BACK = 12;
13770
+ const WEEKS_AHEAD = 8;
13771
+ /** Bars past this many are summed into "Everything else". */
13772
+ const MAX_BARS = 12;
13773
+ const GROUPS = [
13774
+ "status",
13775
+ "owner",
13776
+ "priority",
13777
+ "project",
13778
+ "program",
13779
+ "week"
13780
+ ];
13781
+ const MEASURE_LABEL = {
13782
+ open: "Open tasks",
13783
+ done: "Finished tasks",
13784
+ all: "All tasks",
13785
+ overdue: "Overdue tasks",
13786
+ hours: "Hours logged"
13787
+ };
13788
+ const GROUP_LABEL = {
13789
+ status: "Status",
13790
+ owner: "Owner",
13791
+ priority: "Priority",
13792
+ project: "Project",
13793
+ program: "Program",
13794
+ week: "Week"
13795
+ };
13796
+ /** The splits a measure can take: hours have no status or priority of their own. */
13797
+ function groupsFor(measure) {
13798
+ return measure === "hours" ? [
13799
+ "owner",
13800
+ "project",
13801
+ "program",
13802
+ "week"
13803
+ ] : GROUPS;
13804
+ }
13805
+ const STATUS_FILL = {
13806
+ backlog: "var(--color-swatch-slate)",
13807
+ todo: "var(--color-swatch-sand)",
13808
+ doing: "var(--color-swatch-sea)",
13809
+ review: "var(--color-swatch-rust)",
13810
+ done: "var(--color-swatch-moss)"
13811
+ };
13812
+ const PRIORITY_FILL = {
13813
+ low: "var(--color-swatch-slate)",
13814
+ medium: "var(--color-swatch-sea)",
13815
+ high: "var(--color-swatch-sand)",
13816
+ urgent: "var(--color-swatch-coral)"
13817
+ };
13818
+ const PRIORITIES$1 = [
13819
+ "urgent",
13820
+ "high",
13821
+ "medium",
13822
+ "low"
13823
+ ];
13824
+ /** Colours handed out in turn where a slice has none of its own. */
13825
+ const CYCLE = [
13826
+ "sea",
13827
+ "sand",
13828
+ "moss",
13829
+ "rust",
13830
+ "slate",
13831
+ "pine",
13832
+ "clay",
13833
+ "sage"
13834
+ ];
13835
+ const swatchFill = (s) => `var(--color-swatch-${s})`;
13836
+ /** The Monday on or before a day or moment (ISO, read in local time), or "" when it isn't a date. */
13837
+ function mondayOf(day) {
13838
+ const d = parseIso(day.length > 10 ? day : `${day}T12:00:00`);
13839
+ if (Number.isNaN(d.getTime())) return "";
13840
+ const back = (d.getDay() + 6) % 7;
13841
+ return format(addDays(d, -back), "yyyy-MM-dd");
13842
+ }
13843
+ /** The projects a chart reads: everything, or what sits under its scope. */
13844
+ function scopeOf(data, scopeId) {
13845
+ if (!scopeId) return {
13846
+ projectIds: null,
13847
+ name: "Whole harbor",
13848
+ found: true
13849
+ };
13850
+ const project = byId(data.projects).get(scopeId);
13851
+ if (project) return {
13852
+ projectIds: /* @__PURE__ */ new Set([project.id]),
13853
+ name: project.name,
13854
+ found: true
13855
+ };
13856
+ const program = byId(data.programs).get(scopeId);
13857
+ if (program) {
13858
+ const ids = data.projects.filter((p) => p.programId === program.id).map((p) => p.id);
13859
+ return {
13860
+ projectIds: new Set(ids),
13861
+ name: program.name,
13862
+ found: true
13863
+ };
13864
+ }
13865
+ const portfolio = byId(data.portfolios).get(scopeId);
13866
+ if (portfolio) {
13867
+ const programIds = new Set(data.programs.filter((p) => p.portfolioId === portfolio.id).map((p) => p.id));
13868
+ const ids = data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id);
13869
+ return {
13870
+ projectIds: new Set(ids),
13871
+ name: portfolio.name,
13872
+ found: true
13873
+ };
13874
+ }
13875
+ return {
13876
+ projectIds: /* @__PURE__ */ new Set(),
13877
+ name: "Deleted",
13878
+ found: false
13879
+ };
13880
+ }
13881
+ /**
13882
+ * What a chart shows today: its slices in a fixed order (statuses and
13883
+ * priorities in board order, weeks in time order, the rest largest first,
13884
+ * past MAX_BARS summed into "Everything else"), with their total.
13885
+ */
13886
+ function chartData(chart, data, today) {
13887
+ const scope = scopeOf(data, chart.scopeId);
13888
+ const inScope = (t) => !scope.projectIds || scope.projectIds.has(t.projectId);
13889
+ const unit = chart.measure === "hours" ? "hours" : "tasks";
13890
+ const groupBy = groupsFor(chart.measure).includes(chart.groupBy) ? chart.groupBy : "owner";
13891
+ const items = [];
13892
+ for (const task of data.tasks) {
13893
+ if (!inScope(task)) continue;
13894
+ const done = task.status === "done";
13895
+ switch (chart.measure) {
13896
+ case "open":
13897
+ if (!done) items.push({
13898
+ task,
13899
+ amount: 1,
13900
+ day: task.dueDate
13901
+ });
13902
+ break;
13903
+ case "done":
13904
+ if (done) items.push({
13905
+ task,
13906
+ amount: 1,
13907
+ day: task.completedAt
13908
+ });
13909
+ break;
13910
+ case "all":
13911
+ items.push({
13912
+ task,
13913
+ amount: 1,
13914
+ day: task.createdAt
13915
+ });
13916
+ break;
13917
+ case "overdue":
13918
+ if (!done && task.dueDate && task.dueDate.slice(0, 10) < today) items.push({
13919
+ task,
13920
+ amount: 1,
13921
+ day: task.dueDate
13922
+ });
13923
+ break;
13924
+ case "hours": for (const entry of task.time ?? []) items.push({
13925
+ task,
13926
+ amount: entry.minutes / 60,
13927
+ day: entry.on,
13928
+ personId: entry.personId
13929
+ });
13930
+ }
13931
+ }
13932
+ const projects = byId(data.projects);
13933
+ const programs = byId(data.programs);
13934
+ const people = byId(data.people);
13935
+ const sums = /* @__PURE__ */ new Map();
13936
+ const add = (key, amount) => sums.set(key, (sums.get(key) ?? 0) + amount);
13937
+ let weeks = [];
13938
+ if (groupBy === "week") {
13939
+ const thisWeek = mondayOf(today);
13940
+ const ahead = chart.measure === "open";
13941
+ const first = ahead ? thisWeek : format(addDays(parseIso(`${thisWeek}T12:00:00`), -77), "yyyy-MM-dd");
13942
+ const count = ahead ? 8 : 12;
13943
+ const start = parseIso(`${first}T12:00:00`);
13944
+ weeks = Array.from({ length: count }, (_, i) => format(addDays(start, 7 * i), "yyyy-MM-dd"));
13945
+ const known = new Set(weeks);
13946
+ for (const item of items) {
13947
+ if (!item.day) continue;
13948
+ const week = mondayOf(item.day);
13949
+ if (known.has(week)) add(week, item.amount);
13950
+ else if (ahead && week < first) add(first, item.amount);
13951
+ }
13952
+ } else for (const item of items) {
13953
+ const t = item.task;
13954
+ add(groupBy === "status" ? t.status : groupBy === "priority" ? t.priority : groupBy === "project" ? t.projectId : groupBy === "program" ? projects.get(t.projectId)?.programId ?? "" : (chart.measure === "hours" ? item.personId : t.assigneeId) ?? "", item.amount);
13955
+ }
13956
+ const round = (n) => unit === "hours" ? Math.round(n * 10) / 10 : n;
13957
+ let slices;
13958
+ if (groupBy === "week") slices = weeks.map((w) => ({
13959
+ key: w,
13960
+ label: format(parseIso(`${w}T12:00:00`), "MMM d"),
13961
+ value: round(sums.get(w) ?? 0),
13962
+ fill: "var(--color-primary)"
13963
+ }));
13964
+ else if (groupBy === "status") slices = TASK_COLUMNS.filter((s) => sums.has(s)).map((s) => ({
13965
+ key: s,
13966
+ label: TASK_STATUS_LABEL[s],
13967
+ value: round(sums.get(s)),
13968
+ fill: STATUS_FILL[s]
13969
+ }));
13970
+ else if (groupBy === "priority") slices = PRIORITIES$1.filter((p) => sums.has(p)).map((p) => ({
13971
+ key: p,
13972
+ label: PRIORITY_LABEL[p],
13973
+ value: round(sums.get(p)),
13974
+ fill: PRIORITY_FILL[p]
13975
+ }));
13976
+ else {
13977
+ const named = [...sums.entries()].map(([key, value], i) => {
13978
+ if (groupBy === "owner") {
13979
+ const person = key ? people.get(key) : void 0;
13980
+ return {
13981
+ key: key || "nobody",
13982
+ label: key ? person?.name ?? "Former member" : "Nobody",
13983
+ value: round(value),
13984
+ fill: key ? swatchFill(CYCLE[i % CYCLE.length]) : "var(--color-swatch-slate)"
13985
+ };
13986
+ }
13987
+ const thing = groupBy === "project" ? projects.get(key) : programs.get(key);
13988
+ return {
13989
+ key: key || "none",
13990
+ label: thing?.name ?? "Deleted",
13991
+ value: round(value),
13992
+ fill: thing?.swatch ? swatchFill(thing.swatch) : swatchFill(CYCLE[i % CYCLE.length])
13993
+ };
13994
+ });
13995
+ named.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
13996
+ if (named.length > 12) {
13997
+ const rest = named.slice(11);
13998
+ slices = [...named.slice(0, 11), {
13999
+ key: "rest",
14000
+ label: `Everything else (${rest.length})`,
14001
+ value: round(rest.reduce((n, s) => n + s.value, 0)),
14002
+ fill: "var(--color-swatch-slate)"
14003
+ }];
14004
+ } else slices = named;
14005
+ }
14006
+ const total = round(slices.reduce((n, s) => n + s.value, 0));
14007
+ return {
14008
+ slices,
14009
+ total,
14010
+ unit,
14011
+ scopeName: scope.name,
14012
+ scopeFound: scope.found
14013
+ };
14014
+ }
14015
+ /** "12 tasks", "1 task", "7.5 hours". */
14016
+ function amountText(value, unit) {
14017
+ if (unit === "hours") return `${value} ${value === 1 ? "hour" : "hours"}`;
14018
+ return `${value} ${value === 1 ? "task" : "tasks"}`;
14019
+ }
14020
+ /** A chart's text for screen readers, agents and the terminal: "Open tasks by owner: Sam 4, Ana 2 (6 tasks)". */
14021
+ function chartText(title, result) {
14022
+ if (result.slices.length === 0) return `${title}: nothing yet`;
14023
+ return `${title}: ${result.slices.map((s) => `${s.label} ${s.value}`).join(", ")} (${amountText(result.total, result.unit)})`;
14024
+ }
14025
+
13747
14026
  // node_modules/zod/v4/core/util.js
13748
14027
  function getEnumValues(entries) {
13749
14028
  const numericValues = Object.values(entries).filter((v) => typeof v === "number");
@@ -18941,7 +19220,7 @@ init_blockers();
18941
19220
  init_format();
18942
19221
  init_indexes();
18943
19222
  const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
18944
- const days$1 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
19223
+ const days$2 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
18945
19224
  const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
18946
19225
  const open = (t) => t.status !== "done";
18947
19226
  /** True when `a` finishes later than `b`, or as late behind a longer line. */
@@ -19008,7 +19287,7 @@ function linksOf(line, walk, target, today) {
19008
19287
  task,
19009
19288
  finish,
19010
19289
  late: Boolean(task.dueDate && task.dueDate.slice(0, 10) < today),
19011
- slack: against ? days$1(finish, against) : null
19290
+ slack: against ? days$2(finish, against) : null
19012
19291
  };
19013
19292
  });
19014
19293
  }
@@ -19020,7 +19299,7 @@ function chainOf(line, walk, target, today) {
19020
19299
  links,
19021
19300
  finish,
19022
19301
  target,
19023
- over: target ? days$1(target, finish) : null,
19302
+ over: target ? days$2(target, finish) : null,
19024
19303
  undated: 0
19025
19304
  };
19026
19305
  }
@@ -19101,17 +19380,17 @@ function milestonePole(milestone, data, now) {
19101
19380
  const others = runs.slice(1).map((r) => ({
19102
19381
  project: r.project,
19103
19382
  finish: r.finish,
19104
- slack: days$1(r.finish, due)
19383
+ slack: days$2(r.finish, due)
19105
19384
  }));
19106
19385
  return {
19107
19386
  kind: "chain",
19108
19387
  links: first.pole.kind === "chain" ? first.pole.links.map((l, i, all) => i === all.length - 1 ? {
19109
19388
  ...l,
19110
- slack: days$1(l.finish, due)
19389
+ slack: days$2(l.finish, due)
19111
19390
  } : l) : [],
19112
19391
  finish: first.finish,
19113
19392
  target: due,
19114
- over: days$1(due, first.finish),
19393
+ over: days$2(due, first.finish),
19115
19394
  via: {
19116
19395
  project: first.project,
19117
19396
  finish: first.finish
@@ -19194,7 +19473,8 @@ function pickData(s) {
19194
19473
  spendEntries: s.spendEntries ?? NONE,
19195
19474
  reflections: s.reflections ?? NONE,
19196
19475
  priorityRules: s.priorityRules ?? null,
19197
- workCalendar: s.workCalendar ?? null
19476
+ workCalendar: s.workCalendar ?? null,
19477
+ dashboards: s.dashboards ?? NONE
19198
19478
  };
19199
19479
  }
19200
19480
  /** The document as sent to the server: normalized and without inline file data. */
@@ -21420,6 +21700,67 @@ const HARBOR_TOOLS = [
21420
21700
  })) };
21421
21701
  }
21422
21702
  }),
21703
+ tool({
21704
+ name: "list_dashboards",
21705
+ title: "Dashboards",
21706
+ description: "The harbor's dashboards from the Reports page, each chart with what it counts (open, finished, overdue or all tasks, or hours logged), how it's split (status, owner, priority, project, program or week), the part of the harbor it reads, and its numbers as of today, with a one-line text of each." + DATA_NOTE,
21707
+ input: object({
21708
+ harbor: harborArg,
21709
+ dashboard: string().max(200).optional().describe("Dashboard id or name. Default: all.")
21710
+ }),
21711
+ run: (ctx, input) => {
21712
+ const { data } = ctx;
21713
+ const today = todayISO(ctx.now);
21714
+ const all = data.dashboards ?? [];
21715
+ return { dashboards: (input.dashboard ? [findOne(all, input.dashboard, "dashboard")] : all).map((d) => ({
21716
+ id: d.id,
21717
+ name: d.name,
21718
+ charts: d.charts.map((c) => {
21719
+ const result = chartData(c, data, today);
21720
+ return {
21721
+ id: c.id,
21722
+ title: c.title,
21723
+ counts: MEASURE_LABEL[c.measure],
21724
+ splitBy: GROUP_LABEL[c.groupBy],
21725
+ view: c.view,
21726
+ from: result.scopeName,
21727
+ total: amountText(result.total, result.unit),
21728
+ values: result.slices.map((s) => ({
21729
+ label: s.label,
21730
+ value: s.value
21731
+ })),
21732
+ text: chartText(c.title, result)
21733
+ };
21734
+ })
21735
+ })) };
21736
+ }
21737
+ }),
21738
+ tool({
21739
+ name: "list_task_templates",
21740
+ title: "Task templates",
21741
+ description: "A project's task templates (or every project's), to start new tasks from with create_task's template: each one's name, the title and notes it gives, its priority, owner, checklist and how many days after it's made the task is due." + DATA_NOTE,
21742
+ input: object({
21743
+ harbor: harborArg,
21744
+ project: string().max(200).optional().describe("Project id or name. Default: all.")
21745
+ }),
21746
+ run: (ctx, input) => {
21747
+ const { data } = ctx;
21748
+ const projects = input.project ? [findOne(data.projects, input.project, "project")] : data.projects.filter((p) => p.taskTemplates?.length);
21749
+ const name = (id) => id ? data.people.find((p) => p.id === id)?.name ?? null : null;
21750
+ return { templates: projects.flatMap((p) => (p.taskTemplates ?? []).map((t) => ({
21751
+ id: t.id,
21752
+ name: t.name,
21753
+ project: p.name,
21754
+ title: t.title,
21755
+ notes: t.description || void 0,
21756
+ priority: t.priority,
21757
+ owner: name(t.assigneeId),
21758
+ alsoOn: (t.followerIds ?? []).map(name).filter(Boolean),
21759
+ checklist: t.checklist,
21760
+ dueInDays: t.dueInDays
21761
+ }))) };
21762
+ }
21763
+ }),
21423
21764
  tool({
21424
21765
  name: "long_pole",
21425
21766
  title: "What's holding this up",
@@ -22231,7 +22572,8 @@ var init_undo = __esmMin((() => {
22231
22572
  "attachments",
22232
22573
  "checkIns",
22233
22574
  "spendEntries",
22234
- "reflections"
22575
+ "reflections",
22576
+ "dashboards"
22235
22577
  ];
22236
22578
  FOLD_MS = 5e3;
22237
22579
  LEAD = [
@@ -22598,6 +22940,9 @@ function hierarchySlice({ get, write }) {
22598
22940
  ...patch
22599
22941
  } : p) });
22600
22942
  },
22943
+ setDashboards: (dashboards) => {
22944
+ write({ dashboards });
22945
+ },
22601
22946
  updateProgram: (id, patch) => {
22602
22947
  write({ programs: get().programs.map((p) => p.id === id ? {
22603
22948
  ...p,
@@ -22927,6 +23272,16 @@ var init_github_refs = __esmMin((() => {
22927
23272
  }));
22928
23273
 
22929
23274
  // src/lib/automations.ts
23275
+ /** Whether a task meets the rule's "only if" line. */
23276
+ function meetsCondition(rule, task) {
23277
+ const c = rule.onlyIf;
23278
+ if (!c) return true;
23279
+ if (c.priority && task.priority !== c.priority) return false;
23280
+ if (c.assignee) {
23281
+ if (c.assignee === "nobody" ? task.assigneeId != null : task.assigneeId !== c.assignee) return false;
23282
+ }
23283
+ return true;
23284
+ }
22930
23285
  /**
22931
23286
  * Checked once, against the (before, after) pair of a single user-driven
22932
23287
  * mutation — never re-checked against an automation's own effect. That's the
@@ -22944,6 +23299,64 @@ function matches(rule, prev, next, created) {
22944
23299
  default: return false;
22945
23300
  }
22946
23301
  }
23302
+ /** One rule's actions on one task, in order. */
23303
+ function runActions(actions, start, ctx) {
23304
+ let task = start;
23305
+ const comments = [];
23306
+ for (const action of actions) switch (action.type) {
23307
+ case "set_status": {
23308
+ if (!action.status) break;
23309
+ const wasDone = task.status === "done";
23310
+ const completedAt = action.status === "done" ? task.completedAt ?? ctx.now : wasDone ? null : task.completedAt;
23311
+ task = {
23312
+ ...task,
23313
+ status: action.status,
23314
+ completedAt
23315
+ };
23316
+ break;
23317
+ }
23318
+ case "set_priority":
23319
+ if (action.priority) task = {
23320
+ ...task,
23321
+ priority: action.priority
23322
+ };
23323
+ break;
23324
+ case "set_assignee":
23325
+ task = {
23326
+ ...task,
23327
+ assigneeId: action.assigneeId ?? null
23328
+ };
23329
+ break;
23330
+ case "set_due": {
23331
+ const n = Math.min(365, Math.max(0, Math.round(action.inDays ?? 0)));
23332
+ task = {
23333
+ ...task,
23334
+ dueDate: dayAfter(ctx.today, n)
23335
+ };
23336
+ break;
23337
+ }
23338
+ case "add_follower": {
23339
+ const id = action.personId;
23340
+ if (!id || id === task.assigneeId || task.followerIds?.includes(id)) break;
23341
+ task = {
23342
+ ...task,
23343
+ followerIds: [...task.followerIds ?? [], id]
23344
+ };
23345
+ break;
23346
+ }
23347
+ case "add_comment": if (action.commentBody?.trim()) comments.push({
23348
+ taskId: task.id,
23349
+ authorId: ctx.authorId,
23350
+ body: action.commentBody.trim(),
23351
+ createdAt: ctx.now,
23352
+ mentionedIds: []
23353
+ });
23354
+ }
23355
+ return {
23356
+ task,
23357
+ comments
23358
+ };
23359
+ }
22947
23360
  /**
22948
23361
  * Applies every enabled rule on `project` whose trigger matches this one
22949
23362
  * mutation (`prev` -> `next`, or `prev` undefined for a brand-new task) and
@@ -22954,49 +23367,32 @@ function matches(rule, prev, next, created) {
22954
23367
  function applyAutomations(project, prev, next, opts) {
22955
23368
  const rules = project?.automations ?? [];
22956
23369
  const now = opts.now ?? (/* @__PURE__ */ new Date()).toISOString();
23370
+ const today = opts.now ? opts.now.slice(0, 10) : todayISO();
23371
+ const ctx = {
23372
+ authorId: opts.currentUserId,
23373
+ now,
23374
+ today
23375
+ };
22957
23376
  let task = next;
22958
23377
  const comments = [];
22959
23378
  for (const rule of rules) {
22960
- if (!matches(rule, prev, next, opts.created)) continue;
22961
- for (const action of rule.actions) switch (action.type) {
22962
- case "set_status": {
22963
- if (!action.status) break;
22964
- const wasDone = task.status === "done";
22965
- const completedAt = action.status === "done" ? task.completedAt ?? now : wasDone ? null : task.completedAt;
22966
- task = {
22967
- ...task,
22968
- status: action.status,
22969
- completedAt
22970
- };
22971
- break;
22972
- }
22973
- case "set_priority":
22974
- if (action.priority) task = {
22975
- ...task,
22976
- priority: action.priority
22977
- };
22978
- break;
22979
- case "set_assignee":
22980
- task = {
22981
- ...task,
22982
- assigneeId: action.assigneeId ?? null
22983
- };
22984
- break;
22985
- case "add_comment": if (action.commentBody?.trim()) comments.push({
22986
- taskId: task.id,
22987
- authorId: opts.currentUserId,
22988
- body: action.commentBody.trim(),
22989
- createdAt: now,
22990
- mentionedIds: []
22991
- });
22992
- }
23379
+ if (!matches(rule, prev, next, opts.created) || !meetsCondition(rule, next)) continue;
23380
+ const ran = runActions(rule.actions, task, ctx);
23381
+ task = ran.task;
23382
+ comments.push(...ran.comments);
22993
23383
  }
22994
23384
  return {
22995
23385
  task,
22996
23386
  comments
22997
23387
  };
22998
23388
  }
22999
- var init_automations = __esmMin((() => {}));
23389
+ var dayAfter;
23390
+ var init_automations = __esmMin((() => {
23391
+ init_date_fns();
23392
+ init_date_text();
23393
+ init_format();
23394
+ dayAfter = (day, n) => format(addDays(parseIso(day), n), "yyyy-MM-dd");
23395
+ }));
23000
23396
 
23001
23397
  // src/lib/focus.ts
23002
23398
  function focusIdsOf(data, personId, date = todayISO()) {
@@ -24484,7 +24880,7 @@ function pickCollections(doc, keys) {
24484
24880
  function emptyRows() {
24485
24881
  return Object.fromEntries(ROW_COLLECTIONS.map((k) => [k, []]));
24486
24882
  }
24487
- /** A collection as rows; `spendEntries`, `reflections`, `priorityRules` and `workCalendar` may be missing from older documents. */
24883
+ /** A collection as rows; `spendEntries`, `reflections`, `priorityRules`, `workCalendar` and `dashboards` may be missing from older documents. */
24488
24884
  function rowsOf(doc, key) {
24489
24885
  if (isKeyed(key)) return KEYED[key].rows(doc[key]);
24490
24886
  return doc[key] ?? [];
@@ -24569,7 +24965,8 @@ var init_changes = __esmMin((() => {
24569
24965
  "spendEntries",
24570
24966
  "reflections",
24571
24967
  "priorityRules",
24572
- "workCalendar"
24968
+ "workCalendar",
24969
+ "dashboards"
24573
24970
  ];
24574
24971
  ROW_COLLECTIONS = [
24575
24972
  "tasks",
@@ -24590,7 +24987,8 @@ var init_changes = __esmMin((() => {
24590
24987
  "ropes",
24591
24988
  "readPingIds",
24592
24989
  "priorityRules",
24593
- "workCalendar"
24990
+ "workCalendar",
24991
+ "dashboards"
24594
24992
  ];
24595
24993
  KEYED = {
24596
24994
  boardConfigs: {
@@ -28389,7 +28787,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
28389
28787
  if (input === "p" && mode !== "budget") return go.pick("Show cards of priority", [{
28390
28788
  label: "Any priority",
28391
28789
  value: "any"
28392
- }, ...PRIORITIES$1.map((p) => ({
28790
+ }, ...PRIORITIES$2.map((p) => ({
28393
28791
  label: PRIORITY_LABEL[p],
28394
28792
  value: p
28395
28793
  }))], (priority) => setFilter((f) => ({
@@ -29009,13 +29407,18 @@ const TRIGGER_LABEL = {
29009
29407
  task_created: "A task is created",
29010
29408
  status_changed_to: "Status is set to…",
29011
29409
  priority_changed_to: "Priority is set to…",
29012
- assigned: "A task is assigned to anyone"
29410
+ assigned: "A task is assigned to anyone",
29411
+ due_soon: "A task is due soon",
29412
+ overdue: "A task becomes overdue",
29413
+ untouched: "A task sits untouched"
29013
29414
  };
29014
29415
  const ACTION_LABEL = {
29015
29416
  set_status: "Set status to…",
29016
29417
  set_priority: "Set priority to…",
29017
29418
  set_assignee: "Assign to…",
29018
- add_comment: "Post a comment"
29419
+ add_comment: "Post a comment",
29420
+ set_due: "Move the due date",
29421
+ add_follower: "Add a follower"
29019
29422
  };
29020
29423
  function describeAction(a, peopleName) {
29021
29424
  switch (a.type) {
@@ -29023,18 +29426,36 @@ function describeAction(a, peopleName) {
29023
29426
  case "set_priority": return `set priority to ${a.priority ? PRIORITY_LABEL[a.priority] : "…"}`;
29024
29427
  case "set_assignee": return `assign to ${peopleName(a.assigneeId)}`;
29025
29428
  case "add_comment": return `post “${a.commentBody ?? ""}”`;
29429
+ case "set_due": {
29430
+ const n = a.inDays ?? 0;
29431
+ return n === 0 ? "set the due date to that day" : `set the due date ${n} ${n === 1 ? "day" : "days"} out`;
29432
+ }
29433
+ case "add_follower": return `add ${peopleName(a.personId)} as a follower`;
29026
29434
  }
29027
29435
  }
29436
+ const days$1 = (n) => `${n} ${n === 1 ? "day" : "days"}`;
29028
29437
  function describeTrigger(rule) {
29029
29438
  const t = rule.trigger;
29030
29439
  if (t.type === "status_changed_to") return `When status is set to ${t.status ? TASK_STATUS_LABEL[t.status] : "…"}`;
29031
29440
  if (t.type === "priority_changed_to") return `When priority is set to ${t.priority ? PRIORITY_LABEL[t.priority] : "…"}`;
29441
+ if (t.type === "due_soon") return `When a task is due within ${days$1(t.days ?? 2)}`;
29442
+ if (t.type === "untouched") return `When nobody touches a task for ${days$1(t.days ?? 7)}`;
29032
29443
  return `When ${TRIGGER_LABEL[t.type].toLowerCase()}`;
29033
29444
  }
29445
+ /** The "only if" line: "only High priority, owned by Ana", or "" when there's none. */
29446
+ function describeCondition(rule, peopleName) {
29447
+ const c = rule.onlyIf;
29448
+ if (!c) return "";
29449
+ const parts = [];
29450
+ if (c.priority) parts.push(`${PRIORITY_LABEL[c.priority]} priority`);
29451
+ if (c.assignee) parts.push(c.assignee === "nobody" ? "with no owner" : `owned by ${peopleName(c.assignee)}`);
29452
+ return parts.length ? `only ${parts.join(", ")}` : "";
29453
+ }
29034
29454
  /** "When status is set to Done: set priority to Low, post “Shipped”". */
29035
29455
  function describeRule(rule, peopleName) {
29036
29456
  const did = rule.actions.map((a) => describeAction(a, peopleName)).join(", ");
29037
- return `${describeTrigger(rule)}: ${did || "no actions"}`;
29457
+ const only = describeCondition(rule, peopleName);
29458
+ return `${describeTrigger(rule)}${only ? ` (${only})` : ""}: ${did || "no actions"}`;
29038
29459
  }
29039
29460
  function defaultAction(type) {
29040
29461
  if (type === "set_status") return {
@@ -29049,6 +29470,14 @@ function defaultAction(type) {
29049
29470
  type,
29050
29471
  assigneeId: null
29051
29472
  };
29473
+ if (type === "set_due") return {
29474
+ type,
29475
+ inDays: 3
29476
+ };
29477
+ if (type === "add_follower") return {
29478
+ type,
29479
+ personId: null
29480
+ };
29052
29481
  return {
29053
29482
  type,
29054
29483
  commentBody: ""
@@ -29088,6 +29517,7 @@ function readFieldValue(def, text) {
29088
29517
  * starts from, each column (name, shown, cap, place), lanes and what cards
29089
29518
  * show, the project's own fields and its automations. b on a project opens it.
29090
29519
  */
29520
+ init_automations();
29091
29521
  init_board();
29092
29522
  init_swatches();
29093
29523
  const LANES = [
@@ -29122,7 +29552,7 @@ const statusChoices = () => TASK_COLUMNS.map((s) => ({
29122
29552
  label: TASK_STATUS_LABEL[s],
29123
29553
  value: s
29124
29554
  }));
29125
- const priorityChoices = () => PRIORITIES$1.map((p) => ({
29555
+ const priorityChoices = () => PRIORITIES$2.map((p) => ({
29126
29556
  label: PRIORITY_LABEL[p],
29127
29557
  value: p
29128
29558
  }));
@@ -29376,6 +29806,14 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29376
29806
  type,
29377
29807
  priority: p
29378
29808
  }));
29809
+ if (type === "due_soon" || type === "untouched") return go.ask(type === "due_soon" ? "Due within how many days?" : "Untouched for how many days?", (text) => {
29810
+ const n = Math.round(Number(text.trim()));
29811
+ if (!Number.isFinite(n) || n < 1) return;
29812
+ askAction({
29813
+ type,
29814
+ days: Math.min(60, n)
29815
+ });
29816
+ });
29379
29817
  askAction({ type });
29380
29818
  }
29381
29819
  function askAction(trigger) {
@@ -29409,6 +29847,21 @@ function BoardPanel({ id, width, height, go, active, onClose }) {
29409
29847
  ...defaultAction(type),
29410
29848
  assigneeId: pid
29411
29849
  }));
29850
+ if (type === "set_due") return go.ask("Set the due date how many days out?", (text) => {
29851
+ const n = Math.round(Number(text.trim()));
29852
+ if (Number.isFinite(n) && n >= 0) add({
29853
+ type,
29854
+ inDays: Math.min(365, n)
29855
+ });
29856
+ });
29857
+ if (type === "add_follower") return go.pick("Add as a follower", people.map((p) => ({
29858
+ label: p.name,
29859
+ value: p.id,
29860
+ hint: p.role
29861
+ })), (pid) => add({
29862
+ type,
29863
+ personId: pid
29864
+ }));
29412
29865
  go.ask("Comment to post", (text) => text.trim() && add({
29413
29866
  type,
29414
29867
  commentBody: text.trim()
@@ -29807,11 +30260,11 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
29807
30260
  });
29808
30261
  } else if (f === "priority") editor = /* @__PURE__ */ jsx(Picker, {
29809
30262
  title: "Priority",
29810
- choices: PRIORITIES$1.map((p) => ({
30263
+ choices: PRIORITIES$2.map((p) => ({
29811
30264
  label: PRIORITY_LABEL[p],
29812
30265
  value: p
29813
30266
  })),
29814
- initial: PRIORITIES$1.indexOf(task.priority),
30267
+ initial: PRIORITIES$2.indexOf(task.priority),
29815
30268
  onCancel: done,
29816
30269
  onPick: (p) => {
29817
30270
  store.getState().updateTask(task.id, { priority: p });
package/lib/cli.mjs CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  followedText,
41
41
  soundingsText,
42
42
  goalsText,
43
+ templatesText,
43
44
  thanksText,
44
45
  ashoreText,
45
46
  voyageText,
@@ -166,7 +167,7 @@ export const HELP = `harbor: Harbor from the terminal
166
167
  [--also "NAME, NAME"] [--due DATE|pilot]
167
168
  [--priority low|medium|high|urgent]
168
169
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
169
- [--literal]
170
+ [--literal] [--template NAME]
170
171
  reads plain words in TITLE as the
171
172
  website does: @name, by friday,
172
173
  !high, #project (--literal: keep
@@ -263,6 +264,10 @@ export const HELP = `harbor: Harbor from the terminal
263
264
  the ones waiting on your answer
264
265
  harbor goals [PORTFOLIO] each portfolio's goals, how far
265
266
  along and whether on track
267
+ harbor templates [PROJECT] the task templates new tasks
268
+ start from (harbor add --template)
269
+ harbor template TASK [--name NAME] save a task as a template in its
270
+ project
266
271
  harbor ashore [--weeks N] [--country CODES|none] [--person NAME]
267
272
  public holidays and time away
268
273
  coming up, and whose holidays
@@ -557,17 +562,30 @@ async function readTitles(client, harbor, titles, values, today = todayLocal())
557
562
  });
558
563
  }
559
564
 
560
- /** What a title's words said, in a line: "Quinn (@Quinn), due 2026-11-06 (by Friday)". */
565
+ /** "Fri 2026-11-13": a day with its weekday, so "next friday" shows which Friday it became. */
566
+ export function dayWithWeekday(iso) {
567
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return iso;
568
+ const day = WEEKDAYS[new Date(`${iso}T12:00:00`).getDay()];
569
+ return day ? `${day.charAt(0).toUpperCase()}${day.slice(1, 3)} ${iso}` : iso;
570
+ }
571
+
572
+ /** What a title's words said, in a line: "Quinn (@Quinn), due Fri 2026-11-06 (by Friday)". */
561
573
  function wordsLine(r) {
562
574
  const bits = [
563
575
  r.owner && `${r.owner.value} (${r.owner.text})`,
564
- r.due && `due ${r.due.value} (${r.due.text})`,
576
+ r.due && `due ${dayWithWeekday(r.due.value)} (${r.due.text})`,
565
577
  r.priority && `${r.priority.value} priority (${r.priority.text})`,
566
578
  r.project && `in ${r.project.value} (${r.project.text})`,
567
579
  ].filter(Boolean);
568
580
  return bits.length ? `Read from your words: ${bits.join(", ")}.` : null;
569
581
  }
570
582
 
583
+ /** --due given in words rather than as a date or +N, when it reads as a day. */
584
+ function dueWords(v) {
585
+ if (typeof v !== "string" || /^(\d{4}-\d{2}-\d{2}|\+\d{1,3})$/.test(v) || PILOT.has(v.toLowerCase())) return false;
586
+ return /^\d{4}-\d{2}-\d{2}$/.test(dueDate(v));
587
+ }
588
+
571
589
  /** --due pilot: the date the owner's queue suggests, set by Harbor. */
572
590
  const PILOT = new Set(["pilot", "suggested"]);
573
591
  const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
@@ -755,6 +773,11 @@ const DETAIL = {
755
773
  " ten lines with --comment add the same comment to all ten. More than 100",
756
774
  " lines is harbor import's job.",
757
775
  "",
776
+ " --template NAME starts from one of the project's task templates (save one",
777
+ " with Save as template on a task, or harbor template TASK): its title,",
778
+ " notes, priority, owner, others on it, checklist and due offset fill what",
779
+ " the flags don't. Leave out TITLE to take the template's.",
780
+ "",
758
781
  " --due pilot takes the earliest day the owner's queue suggests: their open",
759
782
  " work due first, their weekly pace, time away, what it waits on and how",
760
783
  " long their similar tasks took. It needs an owner (--assignee none is",
@@ -1013,6 +1036,16 @@ const DETAIL = {
1013
1036
  " these as days nobody works. Countries: AU BR CA DE ES FR GB IE IT MX NL",
1014
1037
  " NZ US (national holidays only).",
1015
1038
  ],
1039
+ templates: [
1040
+ " harbor templates [PROJECT] reads: each project's task templates",
1041
+ " (all projects when left out)",
1042
+ " harbor template TASK [--name NAME] writes: saves the task as a template",
1043
+ " in its project, named after it unless",
1044
+ " --name says otherwise",
1045
+ "",
1046
+ " harbor add --project P --template NAME starts a task from one: its title,",
1047
+ " notes, priority, owner, others on it, checklist and due offset.",
1048
+ ],
1016
1049
  goals: [
1017
1050
  " harbor goals [PORTFOLIO] reads: each portfolio's goals (all when",
1018
1051
  " left out), off track first, with how far",
@@ -2465,12 +2498,15 @@ export async function run(argv, io = {}) {
2465
2498
  const { positionals, values, harbor } = parse(
2466
2499
  spec(
2467
2500
  ["over-limit", "dry-run", "literal"],
2468
- ["project", "assignee", "also", "due", "priority", "status", "comment"],
2501
+ ["project", "assignee", "also", "due", "priority", "status", "comment", "template"],
2469
2502
  ),
2470
2503
  );
2504
+ // --template with no title: the template's own title.
2505
+ const fromTemplate = values.template !== undefined && positionals.length === 0;
2471
2506
  // Several at once: one title a line on stdin, with no title (or -) after add.
2472
2507
  const piped =
2473
- positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
2508
+ !fromTemplate &&
2509
+ (positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-"));
2474
2510
  const stdin = io.stdin ?? process.stdin;
2475
2511
  const titles =
2476
2512
  piped && (io.stdin || !process.stdin.isTTY)
@@ -2478,7 +2514,9 @@ export async function run(argv, io = {}) {
2478
2514
  .split(/\r?\n/)
2479
2515
  .map((l) => l.trim())
2480
2516
  .filter(Boolean)
2481
- : [text(positionals, "the task")];
2517
+ : fromTemplate
2518
+ ? [""]
2519
+ : [text(positionals, "the task")];
2482
2520
  if (titles.length === 0)
2483
2521
  throw new HarborError("Say the task, or give one title a line on stdin.");
2484
2522
  if (titles.length > 100)
@@ -2487,7 +2525,7 @@ export async function run(argv, io = {}) {
2487
2525
  );
2488
2526
  // Plain words, as on the website: @person, by Friday, !high, #Project.
2489
2527
  // --literal keeps every title exactly as typed.
2490
- const reads = values.literal
2528
+ const reads = values.literal || fromTemplate
2491
2529
  ? titles.map((title) => ({ title, owner: null, due: null, priority: null, project: null }))
2492
2530
  : await readTitles(client, harbor, titles, values);
2493
2531
  // No --project and no #Project: the harbor's one open project, if that's all there is.
@@ -2537,7 +2575,7 @@ export async function run(argv, io = {}) {
2537
2575
  [
2538
2576
  `Would add ${n} to ${where.join(" and ")}:`,
2539
2577
  ...reads.flatMap((r, i) => [
2540
- ` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
2578
+ ` ${r.title}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueWords(values.due) ? dayWithWeekday(dueDate(values.due)) : dueDate(values.due)})` : ""}`,
2541
2579
  ...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
2542
2580
  ]),
2543
2581
  ...(pilot
@@ -2556,9 +2594,10 @@ export async function run(argv, io = {}) {
2556
2594
  .call("create_task", {
2557
2595
  harbor,
2558
2596
  project: projectOf(words),
2559
- title: words.title,
2560
- // Yours unless you say otherwise, as with n in the app.
2561
- assignee: values.assignee ?? words.owner?.value ?? "me",
2597
+ title: words.title || undefined,
2598
+ template: values.template,
2599
+ // Yours unless you say otherwise, as with n in the app (a template keeps its own owner).
2600
+ assignee: values.assignee ?? words.owner?.value ?? (values.template ? undefined : "me"),
2562
2601
  also: values.also === undefined ? undefined : alsoList(values.also),
2563
2602
  due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
2564
2603
  suggestedDue: duePilot(values.due),
@@ -2598,6 +2637,8 @@ export async function run(argv, io = {}) {
2598
2637
  `Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
2599
2638
  // What the title's words said, so nothing is read without being seen.
2600
2639
  ...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
2640
+ // --due in words ("next friday"): the day it became, weekday and all.
2641
+ ...(dueWords(values.due) ? [`--due ${values.due}: ${dayWithWeekday(dueDate(values.due))}.`] : []),
2601
2642
  // Pilot speaks up only when a date was given that the queue says won't hold.
2602
2643
  ...(values.due ? pilotLines(r, r.created) : []),
2603
2644
  ].join("\n"),
@@ -3175,6 +3216,23 @@ export async function run(argv, io = {}) {
3175
3216
  soundingsText(r),
3176
3217
  );
3177
3218
  }
3219
+ case "templates": {
3220
+ const { positionals, harbor } = parse(spec());
3221
+ const project = positionals.join(" ").trim();
3222
+ return call("list_task_templates", { harbor, ...(project ? { project } : {}) }, (r) =>
3223
+ templatesText(r),
3224
+ );
3225
+ }
3226
+ case "template": {
3227
+ const { positionals, values, harbor } = parse(spec([], ["name"]));
3228
+ const task = text(positionals, "the task");
3229
+ return call(
3230
+ "save_task_template",
3231
+ { harbor, task, ...(values.name ? { name: values.name } : {}) },
3232
+ (r) =>
3233
+ `Saved “${r.template.name}” as a template in ${r.template.project}. harbor add --project "${r.template.project}" --template "${r.template.name}" starts a task from it.`,
3234
+ );
3235
+ }
3178
3236
  case "goals": {
3179
3237
  const { positionals, harbor } = parse(spec());
3180
3238
  const portfolio = positionals.join(" ").trim();
package/lib/format.mjs CHANGED
@@ -1231,6 +1231,30 @@ export function catchupText(r) {
1231
1231
  }
1232
1232
 
1233
1233
  /** list_goals as text: each portfolio's goals, what needs attention first, with their key results. */
1234
+ /** harbor templates: each project's task templates, with what a new task takes from them. */
1235
+ export function templatesText(r) {
1236
+ const list = r.templates ?? [];
1237
+ if (!list.length)
1238
+ return "No task templates yet. harbor template TASK saves one, or Save as template on a task on the website.";
1239
+ const lines = [];
1240
+ let project = null;
1241
+ for (const t of list) {
1242
+ if (t.project !== project) {
1243
+ if (project !== null) lines.push("");
1244
+ lines.push(t.project);
1245
+ project = t.project;
1246
+ }
1247
+ const bits = [];
1248
+ if (t.priority && t.priority !== "medium") bits.push(`${t.priority} priority`);
1249
+ if (t.owner) bits.push(t.owner);
1250
+ if (t.dueInDays != null)
1251
+ bits.push(t.dueInDays === 0 ? "due the day it's made" : `due in ${t.dueInDays} ${t.dueInDays === 1 ? "day" : "days"}`);
1252
+ if (t.checklist?.length) bits.push(`${t.checklist.length} checklist ${t.checklist.length === 1 ? "item" : "items"}`);
1253
+ lines.push(` ${t.name}${t.title !== t.name ? `: ${t.title}` : ""}${bits.length ? ` (${bits.join(", ")})` : ""}`);
1254
+ }
1255
+ return lines.join("\n");
1256
+ }
1257
+
1234
1258
  export function goalsText(r) {
1235
1259
  const order = ["off-track", "at-risk", "not-started", "on-track", "achieved"];
1236
1260
  const withGoals = (r.portfolios ?? []).filter((p) => p.goals?.length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.78.0",
3
+ "version": "0.80.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",