@harborppm/cli 0.11.2 → 0.12.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/README.md CHANGED
@@ -26,16 +26,16 @@ harbor # name your harbor and its first project, and you're in
26
26
 
27
27
  `harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads; after that the sidebar holds just the screens. Then:
28
28
 
29
- 1. **Today**: what's on you, late first; who's waiting on you; your risks and issues.
29
+ 1. **Today**: today's focus (`f` adds or takes off the task you're on, up to three) and your check-in (`c`); what's on you, late first; who's waiting on you; your risks and issues.
30
30
  2. **Inbox**: the same pings as the website: waiting on you, sign-offs, mentions. Enter opens one; it's marked read.
31
- 3. **Projects**: every portfolio, program and project with its health. A project opens on its board (columns side by side), its list, or its budget and spend ledger (`v`); `e` changes its details: lead, dates, budget, health by hand with a note.
31
+ 3. **Projects**: every portfolio, program and project with its health. A project opens on its board (columns side by side; `f` and `p` show only someone's cards or one priority), its list, or its budget and spend ledger (`v`); `e` changes its details: lead, dates, budget, health by hand with a note.
32
32
  4. **Programs**: a program's projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health.
33
33
  5. **Risks**: the register, worst first. Enter changes one (status, owner, likelihood, impact, mitigation); `n` logs a risk, issue or decision.
34
34
  6. **People**: open and late work and the next four weeks against each person's capacity; Enter changes their capacity, role, or the time they're promised to a program.
35
35
  7. **Roadmap**: a portfolio's programs and projects across the months, in their health's colour, ◆ for milestones.
36
36
  8. **History**: who changed what, for a portfolio, program or project (`s` picks which).
37
37
 
38
- A task changes in place too: title, status, owner, due date (`2026-10-14`, `today`, `+3`), priority, project, notes, checklist steps, comments.
38
+ A task changes in place too: title, status, owner, due date (`2026-10-14`, `today`, `+3`), priority, project, what it waits on (type a key or words), how it repeats, notes, checklist steps, comments (`C` changes or takes out one of yours).
39
39
 
40
40
  Keys: `↑↓` (or `j k`) and `←→` (or `h l`) move, Enter opens, Esc goes back, `:` finds anything (a screen, "new project", a project or program by name), `/` finds a task by its key or words, `n` makes something new, `e` changes what's open, `[` `]` move a card a column, `x` marks it done, `d` deletes (it asks first), `u` undoes your last change, `H` switches harbor, `?` lists them all.
41
41
 
@@ -94,13 +94,17 @@ harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
94
94
  [--priority low|medium|high|urgent] [--status S] [--over-limit]
95
95
  harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
96
96
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
97
- [--priority P] [--status S] [--confirm] [--over-limit]
97
+ [--priority P] [--status S] [--notes TEXT]
98
+ [--repeat weekly|biweekly|monthly|never]
99
+ [--waits-on TASK,TASK|none] [--confirm] [--over-limit]
98
100
  harbor comment TASK "TEXT"
99
101
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
100
102
  harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
101
103
  [--owner NAME] [--due DATE]
102
104
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
103
105
  [--due DATE]
106
+ harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
107
+ harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
104
108
  ```
105
109
 
106
110
  Every command takes `--json` for the raw result (a refusal then comes out as `{"error": "…"}` on standard output, still with exit code 1), and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
package/dist/app.mjs CHANGED
@@ -2088,6 +2088,14 @@ function forecastLine(f) {
2088
2088
  const side = f.variance >= 0 ? "under" : "over";
2089
2089
  return `At this rate it ends at ${money(Math.round(f.eac))} (${money(gap)} ${side} budget)`;
2090
2090
  }
2091
+ const NO_FILTER = {
2092
+ who: "any",
2093
+ priority: "any"
2094
+ };
2095
+ function filterTasks(tasks, f) {
2096
+ if (f.who === "any" && f.priority === "any") return tasks;
2097
+ return tasks.filter((t) => (f.who === "any" || (f.who === "none" ? t.assigneeId === null : t.assigneeId === f.who)) && (f.priority === "any" || t.priority === f.priority));
2098
+ }
2091
2099
 
2092
2100
  //#endregion
2093
2101
  //#region src/tui/theme.ts
@@ -3908,11 +3916,9 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
3908
3916
  });
3909
3917
  }
3910
3918
  const HELP = [
3911
- ["1–8", "the screens, as the sidebar lists them"],
3919
+ ["1–8 / Tab", "the screens / between the sidebar and a screen"],
3912
3920
  [":", "anything: a screen, something new, a name"],
3913
- ["/", "find a task by key or words"],
3914
- ["H", "switch harbor"],
3915
- ["Tab", "between the sidebar and the screen"],
3921
+ ["/ / H", "find a task by key or words / switch harbor"],
3916
3922
  ["↑↓ or j k", "move; ←→ or h l across columns and lists"],
3917
3923
  ["Enter / Esc", "open / back"],
3918
3924
  ["n", "new: a task, or what the screen lists"],
@@ -3923,7 +3929,9 @@ const HELP = [
3923
3929
  ["v", "on a project: board, list, budget"],
3924
3930
  ["m / R", "new milestone on a program / log a risk"],
3925
3931
  ["D", "show or hide Done projects and programs"],
3926
- ["c / a", "on a task: comment / add a checklist step"],
3932
+ ["c / a / C", "on a task: comment / add a step / change yours"],
3933
+ ["f / c", "on Today: focus on a task / check in"],
3934
+ ["f / p", "on a board: filter by owner / priority"],
3927
3935
  ["q", "quit (saves first)"]
3928
3936
  ];
3929
3937
  /**
@@ -4132,7 +4140,7 @@ function healthField(kind, item, go, set, clear) {
4132
4140
  }
4133
4141
  };
4134
4142
  }
4135
- function ProgramPanel({ id, width, height, go, onClose }) {
4143
+ function ProgramPanel({ id, width, height, go, active, onClose }) {
4136
4144
  const { store, today } = useDeps();
4137
4145
  const program = useHarbor((s) => s.programs.find((p) => p.id === id));
4138
4146
  const people = useHarbor((s) => s.people);
@@ -4228,10 +4236,11 @@ function ProgramPanel({ id, width, height, go, onClose }) {
4228
4236
  onClose();
4229
4237
  }, "Delete");
4230
4238
  } },
4239
+ active,
4231
4240
  onClose
4232
4241
  });
4233
4242
  }
4234
- function PortfolioPanel({ id, width, height, go, onClose }) {
4243
+ function PortfolioPanel({ id, width, height, go, active, onClose }) {
4235
4244
  const { store } = useDeps();
4236
4245
  const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === id));
4237
4246
  const people = useHarbor((s) => s.people);
@@ -4295,10 +4304,11 @@ function PortfolioPanel({ id, width, height, go, onClose }) {
4295
4304
  onClose();
4296
4305
  }, "Delete");
4297
4306
  } },
4307
+ active,
4298
4308
  onClose
4299
4309
  });
4300
4310
  }
4301
- function ProjectPanel({ id, width, height, go, onClose }) {
4311
+ function ProjectPanel({ id, width, height, go, active, onClose }) {
4302
4312
  const { store, today } = useDeps();
4303
4313
  const project = useHarbor((s) => s.projects.find((p) => p.id === id));
4304
4314
  const people = useHarbor((s) => s.people);
@@ -4408,10 +4418,11 @@ function ProjectPanel({ id, width, height, go, onClose }) {
4408
4418
  onClose();
4409
4419
  }, "Delete");
4410
4420
  } },
4421
+ active,
4411
4422
  onClose
4412
4423
  });
4413
4424
  }
4414
- function MilestonePanel({ id, width, height, go, onClose }) {
4425
+ function MilestonePanel({ id, width, height, go, active, onClose }) {
4415
4426
  const { store, today } = useDeps();
4416
4427
  const milestone = useHarbor((s) => s.milestones.find((m) => m.id === id));
4417
4428
  const people = useHarbor((s) => s.people);
@@ -4482,10 +4493,11 @@ function MilestonePanel({ id, width, height, go, onClose }) {
4482
4493
  onClose();
4483
4494
  }, "Delete")
4484
4495
  },
4496
+ active,
4485
4497
  onClose
4486
4498
  });
4487
4499
  }
4488
- function RaidPanel({ id, width, height, go, onClose }) {
4500
+ function RaidPanel({ id, width, height, go, active, onClose }) {
4489
4501
  const { store, today } = useDeps();
4490
4502
  const item = useHarbor((s) => s.raidItems.find((r) => r.id === id));
4491
4503
  const people = useHarbor((s) => s.people);
@@ -4632,10 +4644,11 @@ function RaidPanel({ id, width, height, go, onClose }) {
4632
4644
  onClose();
4633
4645
  }, "Delete")
4634
4646
  },
4647
+ active,
4635
4648
  onClose
4636
4649
  });
4637
4650
  }
4638
- function PersonPanel({ id, width, height, go, onClose }) {
4651
+ function PersonPanel({ id, width, height, go, active, onClose }) {
4639
4652
  const { store } = useDeps();
4640
4653
  const person = useHarbor((s) => s.people.find((p) => p.id === id));
4641
4654
  const programs = useHarbor((s) => s.programs);
@@ -4719,6 +4732,7 @@ function PersonPanel({ id, width, height, go, onClose }) {
4719
4732
  ]
4720
4733
  }, a.programId))
4721
4734
  ] }) : null,
4735
+ active,
4722
4736
  onClose
4723
4737
  });
4724
4738
  }
@@ -5036,8 +5050,21 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5036
5050
  people,
5037
5051
  boardConfigs
5038
5052
  ]);
5039
- const columns = useMemo(() => boardOf(data, projectId), [data, projectId]);
5040
- const list = useMemo(() => projectTasks(data, projectId), [data, projectId]);
5053
+ const [filter, setFilter] = useState(NO_FILTER);
5054
+ const columns = useMemo(() => boardOf(data, projectId).map((c) => ({
5055
+ ...c,
5056
+ all: c.tasks.length,
5057
+ tasks: filterTasks(c.tasks, filter)
5058
+ })), [
5059
+ data,
5060
+ projectId,
5061
+ filter
5062
+ ]);
5063
+ const list = useMemo(() => filterTasks(projectTasks(data, projectId), filter), [
5064
+ data,
5065
+ projectId,
5066
+ filter
5067
+ ]);
5041
5068
  const [mode, setMode] = useState("board");
5042
5069
  const [spendAt, setSpendAt] = useState(0);
5043
5070
  const spendEntries = useHarbor((s) => s.spendEntries) ?? [];
@@ -5069,6 +5096,38 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5069
5096
  if (input === "v") return setMode((m) => m === "board" ? "list" : m === "list" ? "budget" : "board");
5070
5097
  if (input === "e") return go.openPanel("project", projectId);
5071
5098
  if (input === "R") return go.create("raid", { projectId });
5099
+ if (input === "f" && mode !== "budget") return go.pick("Show cards assigned to", [
5100
+ {
5101
+ label: "Anyone",
5102
+ value: "any"
5103
+ },
5104
+ {
5105
+ label: "Me",
5106
+ value: store.getState().currentUserId
5107
+ },
5108
+ {
5109
+ label: "Nobody yet",
5110
+ value: "none"
5111
+ },
5112
+ ...people.map((p) => ({
5113
+ label: p.name,
5114
+ value: p.id,
5115
+ hint: p.role
5116
+ }))
5117
+ ], (who) => setFilter((f) => ({
5118
+ ...f,
5119
+ who
5120
+ })));
5121
+ if (input === "p" && mode !== "budget") return go.pick("Show cards of priority", [{
5122
+ label: "Any priority",
5123
+ value: "any"
5124
+ }, ...PRIORITIES.map((p) => ({
5125
+ label: PRIORITY_LABEL[p],
5126
+ value: p
5127
+ }))], (priority) => setFilter((f) => ({
5128
+ ...f,
5129
+ priority
5130
+ })));
5072
5131
  if (mode === "budget") {
5073
5132
  const entries = project ? budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date()).entries : [];
5074
5133
  const entry = entries[Math.min(spendAt, entries.length - 1)];
@@ -5108,6 +5167,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5108
5167
  })
5109
5168
  });
5110
5169
  const { done, total } = projectSummary(data, project);
5170
+ const filtered = [filter.who === "none" ? "nobody's" : filter.who !== "any" ? `${people.find((p) => p.id === filter.who)?.name ?? "someone"}'s` : null, filter.priority !== "any" ? PRIORITY_LABEL[filter.priority].toLowerCase() : null].filter(Boolean);
5111
5171
  const lead = people.find((p) => p.id === project.ownerId);
5112
5172
  const body = height - 3;
5113
5173
  const fit = Math.max(1, Math.floor((width - 2) / 14));
@@ -5156,7 +5216,16 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5156
5216
  lead ? ` · ${lead.name}` : "",
5157
5217
  " · ",
5158
5218
  mode,
5159
- " · v next · e details"
5219
+ filtered.length && mode !== "budget" ? /* @__PURE__ */ jsxs(Text, {
5220
+ color: theme.warn,
5221
+ children: [
5222
+ " · only ",
5223
+ filtered.join(", "),
5224
+ " cards"
5225
+ ]
5226
+ }) : null,
5227
+ " ",
5228
+ "· v next · f p filter · e details"
5160
5229
  ]
5161
5230
  })
5162
5231
  })]
@@ -5180,7 +5249,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5180
5249
  const on = active && ci === col;
5181
5250
  const r = Math.min(rowByCol[ci] ?? 0, Math.max(0, c.tasks.length - 1));
5182
5251
  const view = windowOf(c.tasks, r, Math.max(1, Math.floor((body - 3) / 3)));
5183
- const over = c.limit != null && c.tasks.length > c.limit;
5252
+ const over = c.limit != null && c.all > c.limit;
5184
5253
  return /* @__PURE__ */ jsxs(Box, {
5185
5254
  flexDirection: "column",
5186
5255
  width: w,
@@ -5205,7 +5274,11 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5205
5274
  " ",
5206
5275
  /* @__PURE__ */ jsxs(Text, {
5207
5276
  color: over ? theme.bad : theme.muted,
5208
- children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
5277
+ children: [
5278
+ c.tasks.length < c.all ? `${c.tasks.length} of ` : "",
5279
+ c.all,
5280
+ c.limit != null ? `/${c.limit}` : ""
5281
+ ]
5209
5282
  })
5210
5283
  ]
5211
5284
  })
@@ -5497,6 +5570,78 @@ function RoadmapView({ active, width, height, go }) {
5497
5570
  });
5498
5571
  }
5499
5572
 
5573
+ //#endregion
5574
+ //#region src/lib/deps.ts
5575
+ /**
5576
+ * Everything that waits on `id`, directly or through others: the items that
5577
+ * would close a loop if `id` waited on them. One walk backwards from `id`,
5578
+ * instead of a walk forwards from every candidate.
5579
+ */
5580
+ function waitingOnChain(items, id) {
5581
+ const waiters = /* @__PURE__ */ new Map();
5582
+ for (const n of items) for (const b of blockerIds(n)) {
5583
+ const list = waiters.get(b);
5584
+ if (list) list.push(n.id);
5585
+ else waiters.set(b, [n.id]);
5586
+ }
5587
+ const out = /* @__PURE__ */ new Set();
5588
+ const stack = [...waiters.get(id) ?? []];
5589
+ while (stack.length) {
5590
+ const cursor = stack.pop();
5591
+ if (out.has(cursor)) continue;
5592
+ out.add(cursor);
5593
+ stack.push(...waiters.get(cursor) ?? []);
5594
+ }
5595
+ return out;
5596
+ }
5597
+ /** The things `id` may wait on: everything except itself and anything already waiting on it, directly or through others. */
5598
+ function blockerOptions(items, id) {
5599
+ const loops = waitingOnChain(items, id);
5600
+ return items.filter((n) => n.id !== id && !loops.has(n.id));
5601
+ }
5602
+
5603
+ //#endregion
5604
+ //#region src/lib/recurrence.ts
5605
+ const REPEAT_LABEL = {
5606
+ weekly: "Every week",
5607
+ biweekly: "Every two weeks",
5608
+ monthly: "Every month"
5609
+ };
5610
+ const iso$1 = (d) => {
5611
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
5612
+ };
5613
+ /** The due date one interval after `from`. */
5614
+ function nextDueDate(from, repeat) {
5615
+ const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
5616
+ if (repeat === "weekly") return iso$1(addDays(d, 7));
5617
+ if (repeat === "biweekly") return iso$1(addDays(d, 14));
5618
+ return iso$1(addMonths(d, 1));
5619
+ }
5620
+ /**
5621
+ * The next occurrence of a repeating task once this one is done: same
5622
+ * title, owner, priority and checklist (unticked), due one interval after
5623
+ * the old due date (or after today when it had none), back in To do.
5624
+ * Null when the task does not repeat.
5625
+ */
5626
+ function nextOccurrence(task, id, today, createdAt) {
5627
+ if (!task.repeat) return null;
5628
+ const anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
5629
+ return {
5630
+ ...task,
5631
+ id,
5632
+ status: "todo",
5633
+ dueDate: nextDueDate(anchor, task.repeat),
5634
+ createdAt,
5635
+ completedAt: null,
5636
+ blockedById: null,
5637
+ blockedByIds: [],
5638
+ checklist: (task.checklist ?? []).map((c) => ({
5639
+ ...c,
5640
+ done: false
5641
+ }))
5642
+ };
5643
+ }
5644
+
5500
5645
  //#endregion
5501
5646
  //#region src/tui/views/task.tsx
5502
5647
  const FIELDS = [
@@ -5524,19 +5669,34 @@ const FIELDS = [
5524
5669
  field: "project",
5525
5670
  label: "Project"
5526
5671
  },
5672
+ {
5673
+ field: "waits",
5674
+ label: "Waits on"
5675
+ },
5676
+ {
5677
+ field: "repeat",
5678
+ label: "Repeats"
5679
+ },
5527
5680
  {
5528
5681
  field: "notes",
5529
5682
  label: "Notes"
5530
5683
  }
5531
5684
  ];
5685
+ const REPEATS = [
5686
+ null,
5687
+ "weekly",
5688
+ "biweekly",
5689
+ "monthly"
5690
+ ];
5532
5691
  /** Everything about one task, every field editable: ↑↓ to a field, Enter to change it. */
5533
- function TaskPanel({ taskId, width, height, go, onClose }) {
5692
+ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
5534
5693
  const { store, today } = useDeps();
5535
5694
  const tasks = useHarbor((s) => s.tasks);
5536
5695
  const projects = useHarbor((s) => s.projects);
5537
5696
  const people = useHarbor((s) => s.people);
5538
5697
  const comments = useHarbor((s) => s.comments);
5539
5698
  const viewOnly = useHarbor((s) => s.myRole === "viewer" || s.keepPaused);
5699
+ const me = useHarbor((s) => s.currentUserId);
5540
5700
  const task = tasks.find((t) => t.id === taskId);
5541
5701
  const [at, setAt] = useState(0);
5542
5702
  const [editing, setEditing] = useState(null);
@@ -5549,6 +5709,10 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5549
5709
  if (key.downArrow || input === "j") return setAt((i) => step(i, 1, rows));
5550
5710
  if (input === "c") return setEditing({ kind: "comment" });
5551
5711
  if (input === "a") return setEditing({ kind: "step" });
5712
+ if (input === "C") {
5713
+ if (comments.some((c) => c.taskId === task.id && c.authorId === me)) return setEditing({ kind: "mine" });
5714
+ return go.notify("You haven't commented on this task.");
5715
+ }
5552
5716
  if (input === "x") return moveTask(store, go, task, "done");
5553
5717
  if (input === "d") return go.confirm(`Delete ${keyOf({ projects }, task)} “${task.title}”? u undoes it.`, () => {
5554
5718
  store.getState().deleteTask(task.id);
@@ -5561,12 +5725,14 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5561
5725
  kind: "field",
5562
5726
  field: FIELDS[at].field
5563
5727
  });
5564
- }, { isActive: editing === null });
5728
+ }, { isActive: active && editing === null });
5565
5729
  if (!task) return null;
5566
5730
  const project = projects.find((p) => p.id === task.projectId);
5567
5731
  const owner = people.find((p) => p.id === task.assigneeId);
5568
5732
  const waits = openBlockerNames(task, tasks);
5569
5733
  const thread = comments.filter((c) => c.taskId === task.id).slice(-4);
5734
+ const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
5735
+ const blockers = blockersOf(task, tasks);
5570
5736
  const done = () => setEditing(null);
5571
5737
  const value = {
5572
5738
  title: task.title,
@@ -5575,6 +5741,8 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5575
5741
  due: task.dueDate ? `${shortDay(task.dueDate, day)}${isLate(task, day) ? " (late)" : ""}` : "None",
5576
5742
  priority: PRIORITY_LABEL[task.priority],
5577
5743
  project: project ? `${project.name}${project.key ? ` (${project.key})` : ""}` : "",
5744
+ waits: blockers.length ? blockers.map((b) => `${keyOf({ projects }, b)}${b.status === "done" ? " (done)" : ""}`).join(", ") : "Nothing",
5745
+ repeat: task.repeat ? REPEAT_LABEL[task.repeat] : "Never",
5578
5746
  notes: task.description.replace(/\s+/g, " ").trim() || "None"
5579
5747
  };
5580
5748
  let editor = null;
@@ -5595,7 +5763,44 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5595
5763
  done();
5596
5764
  }
5597
5765
  });
5598
- else if (editing?.kind === "field") {
5766
+ else if (editing?.kind === "mine") editor = /* @__PURE__ */ jsx(Picker, {
5767
+ title: "One of your comments: Enter to change it or take it out",
5768
+ choices: mine.map((c) => ({
5769
+ label: c.body.replace(/\s+/g, " "),
5770
+ value: c.id
5771
+ })),
5772
+ initial: mine.length - 1,
5773
+ onCancel: done,
5774
+ onPick: (id) => {
5775
+ const c = mine.find((x) => x.id === id);
5776
+ setEditing(null);
5777
+ go.pick("This comment", [{
5778
+ label: "Change the words",
5779
+ value: "edit"
5780
+ }, {
5781
+ label: "Take it out",
5782
+ value: "delete"
5783
+ }], (what) => {
5784
+ if (what === "edit") setEditing({
5785
+ kind: "reword",
5786
+ id: c.id
5787
+ });
5788
+ else go.confirm("Take out your comment? u undoes it.", () => store.getState().deleteComment(c.id), "Take it out");
5789
+ });
5790
+ }
5791
+ });
5792
+ else if (editing?.kind === "reword") {
5793
+ const c = comments.find((x) => x.id === editing.id);
5794
+ editor = /* @__PURE__ */ jsx(TextField, {
5795
+ label: "Your comment",
5796
+ initial: c?.body ?? "",
5797
+ onCancel: done,
5798
+ onSubmit: (text) => {
5799
+ if (c && text.trim() && text.trim() !== c.body) store.getState().updateComment(c.id, text.trim());
5800
+ done();
5801
+ }
5802
+ });
5803
+ } else if (editing?.kind === "field") {
5599
5804
  const f = editing.field;
5600
5805
  if (f === "title" || f === "notes") editor = /* @__PURE__ */ jsx(TextField, {
5601
5806
  label: f === "title" ? "Title" : "Notes",
@@ -5663,7 +5868,24 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5663
5868
  done();
5664
5869
  }
5665
5870
  });
5666
- } else if (f === "project") {
5871
+ } else if (f === "repeat") editor = /* @__PURE__ */ jsx(Picker, {
5872
+ title: "Repeats (finishing it queues the next one)",
5873
+ choices: REPEATS.map((r) => ({
5874
+ label: r ? REPEAT_LABEL[r] : "Never",
5875
+ value: r
5876
+ })),
5877
+ initial: REPEATS.indexOf(task.repeat ?? null),
5878
+ onCancel: done,
5879
+ onPick: (r) => {
5880
+ store.getState().updateTask(task.id, { repeat: r });
5881
+ done();
5882
+ }
5883
+ });
5884
+ else if (f === "waits") editor = /* @__PURE__ */ jsx(WaitsEditor, {
5885
+ task,
5886
+ onDone: done
5887
+ });
5888
+ else if (f === "project") {
5667
5889
  const choices = projects.map((p) => ({
5668
5890
  label: p.name,
5669
5891
  value: p.id,
@@ -5796,7 +6018,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5796
6018
  editor ?? /* @__PURE__ */ jsx(Text, {
5797
6019
  color: theme.muted,
5798
6020
  wrap: "truncate-end",
5799
- children: "↑↓ field · Enter change · c comment · a add step · space tick · x done · d delete · Esc close"
6021
+ children: "↑↓ field · Enter change · c comment · C yours · a add step · space tick · x done · d delete · Esc close"
5800
6022
  })
5801
6023
  ]
5802
6024
  });
@@ -5819,16 +6041,140 @@ function DueField({ initial, today, onSubmit, onCancel }) {
5819
6041
  })
5820
6042
  });
5821
6043
  }
6044
+ /**
6045
+ * What a task waits on. Empty, it lists the current ones (Enter stops
6046
+ * waiting on one); typing finds others by key or words (Enter adds it).
6047
+ * Tasks already waiting on this one aren't offered, so nothing loops.
6048
+ */
6049
+ function WaitsEditor({ task, onDone }) {
6050
+ const { store } = useDeps();
6051
+ const tasks = useHarbor((s) => s.tasks);
6052
+ const projects = useHarbor((s) => s.projects);
6053
+ const [query, setQuery] = useState("");
6054
+ const [at, setAt] = useState(0);
6055
+ const current = blockersOf(task, tasks);
6056
+ const hits = useMemo(() => {
6057
+ if (!query.trim()) return [];
6058
+ const taken = new Set(current.map((b) => b.id));
6059
+ const open = blockerOptions(tasks, task.id).filter((t) => !taken.has(t.id));
6060
+ return searchTasks(open, projects, query.trim(), 8).map((h) => h.item);
6061
+ }, [
6062
+ query,
6063
+ tasks,
6064
+ projects,
6065
+ task.id,
6066
+ current
6067
+ ]);
6068
+ const list = query.trim() ? hits : current;
6069
+ const set = (ids) => store.getState().updateTask(task.id, blockersPatch(ids));
6070
+ useInput((input, key) => {
6071
+ if (key.escape) return onDone();
6072
+ if (key.upArrow) return setAt((i) => step(i, -1, list.length));
6073
+ if (key.downArrow) return setAt((i) => step(i, 1, list.length));
6074
+ if (key.return) {
6075
+ const pick = list[Math.min(at, list.length - 1)];
6076
+ if (!pick) return onDone();
6077
+ if (query.trim()) {
6078
+ set([...current.map((b) => b.id), pick.id]);
6079
+ setQuery("");
6080
+ } else set(current.filter((b) => b.id !== pick.id).map((b) => b.id));
6081
+ return setAt(0);
6082
+ }
6083
+ if (key.backspace || key.delete) {
6084
+ setAt(0);
6085
+ return setQuery((q) => q.slice(0, -1));
6086
+ }
6087
+ if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
6088
+ if (input) {
6089
+ setAt(0);
6090
+ setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
6091
+ }
6092
+ });
6093
+ return /* @__PURE__ */ jsxs(Box, {
6094
+ flexDirection: "column",
6095
+ borderStyle: "round",
6096
+ borderColor: theme.border,
6097
+ paddingX: 1,
6098
+ children: [
6099
+ /* @__PURE__ */ jsx(Text, {
6100
+ bold: true,
6101
+ children: "Waits on"
6102
+ }),
6103
+ /* @__PURE__ */ jsxs(Text, { children: [query || /* @__PURE__ */ jsx(Text, {
6104
+ color: theme.muted,
6105
+ children: "type a key like RR-42, or words, to add one"
6106
+ }), /* @__PURE__ */ jsx(Text, {
6107
+ inverse: true,
6108
+ children: " "
6109
+ })] }),
6110
+ list.length === 0 ? /* @__PURE__ */ jsx(Text, {
6111
+ color: theme.muted,
6112
+ children: query.trim() ? "Nothing matches." : "Nothing yet."
6113
+ }) : null,
6114
+ list.map((t, i) => {
6115
+ const on = i === Math.min(at, list.length - 1);
6116
+ return /* @__PURE__ */ jsxs(Text, {
6117
+ color: on ? theme.selected : void 0,
6118
+ bold: on,
6119
+ wrap: "truncate-end",
6120
+ children: [
6121
+ on ? "› " : " ",
6122
+ /* @__PURE__ */ jsx(Text, {
6123
+ color: theme.muted,
6124
+ children: keyOf({ projects }, t).padEnd(7)
6125
+ }),
6126
+ t.title,
6127
+ /* @__PURE__ */ jsxs(Text, {
6128
+ color: theme.muted,
6129
+ children: [" · ", STATUS_LABEL[t.status]]
6130
+ })
6131
+ ]
6132
+ }, t.id);
6133
+ }),
6134
+ /* @__PURE__ */ jsxs(Text, {
6135
+ color: theme.muted,
6136
+ children: [query.trim() ? "Enter adds it" : "Enter stops waiting on it", " · Esc done"]
6137
+ })
6138
+ ]
6139
+ });
6140
+ }
6141
+
6142
+ //#endregion
6143
+ //#region src/lib/focus.ts
6144
+ const FOCUS_CAP = 3;
6145
+ const CHECK_IN_QUESTION = "On the blocker, or on something else?";
6146
+ function focusIdsOf(data, personId, date = todayISO()) {
6147
+ return (data.ropes ?? []).find((r) => r.personId === personId && r.date === date)?.taskIds ?? [];
6148
+ }
6149
+ function upsertFocus(ropes, personId, date, taskIds) {
6150
+ const rest = (ropes ?? []).filter((r) => !(r.personId === personId && r.date === date));
6151
+ if (taskIds.length === 0) return rest;
6152
+ return [...rest, {
6153
+ personId,
6154
+ date,
6155
+ taskIds: taskIds.slice(0, 3)
6156
+ }];
6157
+ }
6158
+ function myCheckIn(data, personId, date = todayISO()) {
6159
+ return (data.checkIns ?? []).find((c) => c.personId === personId && c.date === date);
6160
+ }
5822
6161
 
5823
6162
  //#endregion
5824
6163
  //#region src/tui/views/today.tsx
5825
- /** What's on you: your open tasks, late first; who waits on you; your risks and issues. */
6164
+ /**
6165
+ * What's on you: today's focus and check-in, your open tasks, late first;
6166
+ * who waits on you; your risks and issues.
6167
+ */
5826
6168
  function TodayView({ active, width, height, go }) {
5827
6169
  const { store, today } = useDeps();
5828
6170
  const tasks = useHarbor((s) => s.tasks);
5829
6171
  const projects = useHarbor((s) => s.projects);
5830
6172
  const raidItems = useHarbor((s) => s.raidItems);
5831
6173
  const me = useHarbor((s) => s.currentUserId);
6174
+ const ropes = useHarbor((s) => s.ropes);
6175
+ const checkIns = useHarbor((s) => s.checkIns);
6176
+ const focus = focusIdsOf({ ropes }, me).map((id) => tasks.find((t) => t.id === id)).filter((t) => t !== void 0 && t.status !== "done");
6177
+ const said = myCheckIn({ checkIns }, me);
5832
6178
  const day = today();
5833
6179
  const data = useMemo(() => ({
5834
6180
  tasks,
@@ -5857,8 +6203,24 @@ function TodayView({ active, width, height, go }) {
5857
6203
  else if (key.return && selected) go.openTask(selected.id);
5858
6204
  else if (input === "x" && selected) moveTask(store, go, selected, "done");
5859
6205
  else if (input === "n") go.newTask(selected?.projectId ?? null);
6206
+ else if (input === "f" && selected) {
6207
+ if (store.getState().toggleFocus(selected.id) === "full") go.notify(`Today's focus holds ${3}. f on one of them takes it off first.`);
6208
+ } else if (input === "c") go.ask("Check in: what are you on today? (one sentence)", (text) => {
6209
+ const note = text.trim().slice(0, 280);
6210
+ if (!note) return;
6211
+ go.pick(CHECK_IN_QUESTION, [{
6212
+ label: "On the blocker",
6213
+ value: true
6214
+ }, {
6215
+ label: "Other work",
6216
+ value: false
6217
+ }], (onDrum) => {
6218
+ store.getState().submitCheckIn(note, onDrum === true);
6219
+ go.notify("Checked in for today.");
6220
+ });
6221
+ }, said?.body);
5860
6222
  }, { isActive: active });
5861
- const extra = (waits.length ? waits.length + 2 : 0) + (risks.length ? Math.min(risks.length, 5) + 2 : 0);
6223
+ const extra = (focus.length ? focus.length + 2 : 0) + 1 + (waits.length ? waits.length + 2 : 0) + (risks.length ? Math.min(risks.length, 5) + 2 : 0);
5862
6224
  const rows = Math.max(3, height - 2 - extra);
5863
6225
  const view = windowOf(mine, at, rows);
5864
6226
  return /* @__PURE__ */ jsxs(Box, {
@@ -5867,8 +6229,32 @@ function TodayView({ active, width, height, go }) {
5867
6229
  height,
5868
6230
  paddingX: 1,
5869
6231
  children: [
6232
+ focus.length > 0 && /* @__PURE__ */ jsxs(Box, {
6233
+ flexDirection: "column",
6234
+ marginBottom: 1,
6235
+ children: [/* @__PURE__ */ jsx(Heading, {
6236
+ right: `${focus.length} of ${3} · f takes one off`,
6237
+ children: "Today's focus"
6238
+ }), focus.map((t) => /* @__PURE__ */ jsxs(Text, {
6239
+ wrap: "truncate-end",
6240
+ children: [
6241
+ " ",
6242
+ /* @__PURE__ */ jsx(Text, {
6243
+ color: theme.accent,
6244
+ children: keyOf({ projects }, t)
6245
+ }),
6246
+ " ",
6247
+ t.title
6248
+ ]
6249
+ }, t.id))]
6250
+ }),
6251
+ /* @__PURE__ */ jsx(Text, {
6252
+ color: theme.muted,
6253
+ wrap: "truncate-end",
6254
+ children: said ? `Your check-in: ${said.body}${said.onDrum ? " (on the blocker)" : ""} · c changes it` : "No check-in today · c checks in"
6255
+ }),
5870
6256
  /* @__PURE__ */ jsx(Heading, {
5871
- right: `${mine.length} open${late ? ` · ${late} late` : ""}`,
6257
+ right: `${mine.length} open${late ? ` · ${late} late` : ""} · f focus`,
5872
6258
  children: "On you"
5873
6259
  }),
5874
6260
  mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
@@ -6288,6 +6674,7 @@ function App({ ui }) {
6288
6674
  width: mainWidth,
6289
6675
  height: bodyHeight,
6290
6676
  go,
6677
+ active: !covered,
6291
6678
  onClose: () => setPanel(null)
6292
6679
  };
6293
6680
  let panelNode = null;
@@ -6327,6 +6714,7 @@ function App({ ui }) {
6327
6714
  ["x", "done"],
6328
6715
  ["d", "delete"],
6329
6716
  ["v", "board/list/budget"],
6717
+ ["f p", "filter"],
6330
6718
  ["e", "details"],
6331
6719
  ["Esc", "back"]
6332
6720
  ] : page ? [
@@ -6453,9 +6841,8 @@ function App({ ui }) {
6453
6841
  display: overlayNode || panelNode ? "none" : "flex",
6454
6842
  children: screenNode
6455
6843
  }),
6456
- panelNode && !overlayNode ? panelNode : null,
6457
- panelNode && overlayNode ? /* @__PURE__ */ jsx(Box, {
6458
- display: "none",
6844
+ panelNode ? /* @__PURE__ */ jsx(Box, {
6845
+ display: overlayNode ? "none" : "flex",
6459
6846
  children: panelNode
6460
6847
  }) : null
6461
6848
  ]
@@ -6493,7 +6880,7 @@ function recordHealth(history, health, at, override, note) {
6493
6880
 
6494
6881
  //#endregion
6495
6882
  //#region src/lib/derived-health.ts
6496
- const iso$1 = (d) => {
6883
+ const iso = (d) => {
6497
6884
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
6498
6885
  };
6499
6886
  function parseDate(value) {
@@ -6536,7 +6923,7 @@ let overdueDay = "";
6536
6923
  const overdueByDue = /* @__PURE__ */ new Map();
6537
6924
  function daysOverdue(dueDate, now) {
6538
6925
  if (!dueDate) return 0;
6539
- const today = iso$1(now);
6926
+ const today = iso(now);
6540
6927
  if (today !== overdueDay) {
6541
6928
  overdueDay = today;
6542
6929
  overdueByDue.clear();
@@ -6557,7 +6944,7 @@ let untilDay = "";
6557
6944
  const untilByDate = /* @__PURE__ */ new Map();
6558
6945
  function daysUntil(date, now) {
6559
6946
  if (!date) return null;
6560
- const today = iso$1(now);
6947
+ const today = iso(now);
6561
6948
  if (today !== untilDay) {
6562
6949
  untilDay = today;
6563
6950
  untilByDate.clear();
@@ -6618,7 +7005,7 @@ function effectiveProjectHealth(project, data, now) {
6618
7005
  }
6619
7006
  /** Milestones past their date and still open. */
6620
7007
  function lateMilestonesOf(milestones, now) {
6621
- const today = iso$1(now);
7008
+ const today = iso(now);
6622
7009
  return milestones.filter((m) => !m.done && m.dueDate < today);
6623
7010
  }
6624
7011
  /**
@@ -7841,56 +8228,6 @@ function applyAutomations(project, prev, next, opts) {
7841
8228
  };
7842
8229
  }
7843
8230
 
7844
- //#endregion
7845
- //#region src/lib/focus.ts
7846
- const FOCUS_CAP = 3;
7847
- function upsertFocus(ropes, personId, date, taskIds) {
7848
- const rest = (ropes ?? []).filter((r) => !(r.personId === personId && r.date === date));
7849
- if (taskIds.length === 0) return rest;
7850
- return [...rest, {
7851
- personId,
7852
- date,
7853
- taskIds: taskIds.slice(0, 3)
7854
- }];
7855
- }
7856
-
7857
- //#endregion
7858
- //#region src/lib/recurrence.ts
7859
- const iso = (d) => {
7860
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
7861
- };
7862
- /** The due date one interval after `from`. */
7863
- function nextDueDate(from, repeat) {
7864
- const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
7865
- if (repeat === "weekly") return iso(addDays(d, 7));
7866
- if (repeat === "biweekly") return iso(addDays(d, 14));
7867
- return iso(addMonths(d, 1));
7868
- }
7869
- /**
7870
- * The next occurrence of a repeating task once this one is done: same
7871
- * title, owner, priority and checklist (unticked), due one interval after
7872
- * the old due date (or after today when it had none), back in To do.
7873
- * Null when the task does not repeat.
7874
- */
7875
- function nextOccurrence(task, id, today, createdAt) {
7876
- if (!task.repeat) return null;
7877
- const anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
7878
- return {
7879
- ...task,
7880
- id,
7881
- status: "todo",
7882
- dueDate: nextDueDate(anchor, task.repeat),
7883
- createdAt,
7884
- completedAt: null,
7885
- blockedById: null,
7886
- blockedByIds: [],
7887
- checklist: (task.checklist ?? []).map((c) => ({
7888
- ...c,
7889
- done: false
7890
- }))
7891
- };
7892
- }
7893
-
7894
8231
  //#endregion
7895
8232
  //#region src/lib/assignment.ts
7896
8233
  /**
package/lib/cli.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  harbors,
21
21
  myWork,
22
+ raidList,
22
23
  programTree,
23
24
  projectList,
24
25
  taskLine,
@@ -59,13 +60,18 @@ export const HELP = `harbor: Harbor from the terminal
59
60
  [--over-limit]
60
61
  harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
61
62
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
62
- [--priority P] [--status S] [--confirm] [--over-limit]
63
+ [--priority P] [--status S] [--notes TEXT]
64
+ [--repeat weekly|biweekly|monthly|never]
65
+ [--waits-on TASK,TASK|none] [--confirm] [--over-limit]
63
66
  harbor comment TASK "TEXT"
64
67
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
65
68
  harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
66
69
  [--owner NAME] [--due DATE]
67
70
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
68
71
  [--due DATE]
72
+ harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
73
+ harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
74
+ the register, worst first
69
75
 
70
76
  Every command takes --json, and --harbor NAME when you're in more than one
71
77
  harbor (or set one with harbor use). harbor help COMMAND shows just that one.
@@ -144,6 +150,7 @@ const level = (v, name) => {
144
150
  return n;
145
151
  };
146
152
  const nullable = (v) => (v === "none" ? null : v);
153
+ const REPEAT_WORD = { weekly: "every week", biweekly: "every two weeks", monthly: "every month" };
147
154
 
148
155
  /** --due as people say it: today, tomorrow, +3, or a date. Anything else goes to Harbor to judge. */
149
156
  export function dueDate(v, today = new Date()) {
@@ -176,6 +183,9 @@ const FLAG = {
176
183
  query: "the words",
177
184
  task: "the task",
178
185
  note: "the check-in",
186
+ waitsOn: "--waits-on",
187
+ repeat: "--repeat",
188
+ description: "--notes",
179
189
  body: "the comment",
180
190
  };
181
191
 
@@ -213,7 +223,7 @@ export function hint(message) {
213
223
  .replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
214
224
  .replace(
215
225
  /^Nothing to change: pass a field to update\.$/,
216
- "Nothing to change. Say what with --title, --assignee, --due, --priority or --status.",
226
+ "Nothing to change. Say what with --title, --assignee, --due, --priority, --status, --notes, --repeat or --waits-on.",
217
227
  )
218
228
  .replace(
219
229
  /^This connection was allowed to read only\..*$/s,
@@ -782,8 +792,12 @@ export async function run(argv, io = {}) {
782
792
  }
783
793
  case "edit": {
784
794
  const { positionals, values, harbor } = parse(
785
- spec(["confirm", "over-limit"], ["title", "assignee", "due", "priority", "status"]),
795
+ spec(
796
+ ["confirm", "over-limit"],
797
+ ["title", "assignee", "due", "priority", "status", "notes", "repeat", "waits-on"],
798
+ ),
786
799
  );
800
+ const waits = values["waits-on"];
787
801
  return call(
788
802
  "update_task",
789
803
  {
@@ -794,10 +808,28 @@ export async function run(argv, io = {}) {
794
808
  due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
795
809
  priority: values.priority,
796
810
  status: values.status,
811
+ description: values.notes,
812
+ repeat: values.repeat,
813
+ waitsOn:
814
+ waits === undefined
815
+ ? undefined
816
+ : waits === "none"
817
+ ? []
818
+ : waits
819
+ .split(",")
820
+ .map((x) => x.trim())
821
+ .filter(Boolean),
797
822
  confirmed: values.confirm || undefined,
798
823
  overLimit: values["over-limit"] || undefined,
799
824
  },
800
- (r) => `Updated: ${taskLine(r.updated, opts)}`,
825
+ (r) =>
826
+ [
827
+ `Updated: ${taskLine(r.updated, opts)}`,
828
+ r.updated.waitsOn ? `Waits on ${r.updated.waitsOn.join(", ")}.` : null,
829
+ r.updated.repeat ? `Repeats ${REPEAT_WORD[r.updated.repeat]}.` : null,
830
+ ]
831
+ .filter(Boolean)
832
+ .join("\n"),
801
833
  );
802
834
  }
803
835
  case "comment": {
@@ -823,13 +855,34 @@ export async function run(argv, io = {}) {
823
855
  () => "Checked in for today.",
824
856
  );
825
857
  }
858
+ case "risks": {
859
+ const { positionals, values, harbor } = parse(spec(["closed"], ["kind", "project"]));
860
+ const kind = values.kind
861
+ ?.split(",")
862
+ .map((x) => x.trim().replace(/s$/, ""))
863
+ .filter(Boolean);
864
+ const bad = kind?.find((k) => !["risk", "issue", "decision"].includes(k));
865
+ if (bad) throw new HarborError(`--kind is risk, issue or decision (not "${bad}").`);
866
+ return call(
867
+ "list_risks",
868
+ {
869
+ harbor,
870
+ kind,
871
+ project: values.project,
872
+ includeClosed: Boolean(values.closed),
873
+ limit: 50,
874
+ },
875
+ (r) => raidList(r, positionals.join(" ")),
876
+ );
877
+ }
826
878
  case "risk":
827
- case "issue": {
879
+ case "issue":
880
+ case "decision": {
828
881
  const { positionals, values, harbor } = parse(
829
882
  spec(
830
883
  [],
831
- ["project", "program", "owner", "impact", "due"].concat(
832
- command === "risk" ? ["probability"] : [],
884
+ ["project", "program", "owner", "due"].concat(
885
+ command === "risk" ? ["impact", "probability"] : command === "issue" ? ["impact"] : [],
833
886
  ),
834
887
  ),
835
888
  );
package/lib/format.mjs CHANGED
@@ -158,3 +158,28 @@ export function todayLocal(d = new Date()) {
158
158
  const p = (n) => String(n).padStart(2, "0");
159
159
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
160
160
  }
161
+
162
+ /** The register, worst first: kind, title, then status, scores, owner and where it sits. */
163
+ export function raidList(r, words = "") {
164
+ const want = words.trim().toLowerCase();
165
+ const items = (r.items ?? []).filter(
166
+ (x) => !want || `${x.title} ${x.description ?? ""}`.toLowerCase().includes(want),
167
+ );
168
+ if (!items.length) return want ? "Nothing in the register matches." : "The register is clear.";
169
+ const lines = items.map((x) => {
170
+ const bits = [
171
+ x.status?.toLowerCase(),
172
+ x.probability != null ? `likelihood ${x.probability}` : null,
173
+ x.impact != null ? `impact ${x.impact}` : null,
174
+ x.owner,
175
+ x.project ?? x.program ?? x.portfolio ?? null,
176
+ x.due ? `${x.kind === "Decision" ? "decide by" : "review by"} ${x.due}` : null,
177
+ ].filter(Boolean);
178
+ return `${x.kind.toLowerCase().padEnd(8)} ${x.title} (${bits.join(" · ")})`;
179
+ });
180
+ const more =
181
+ !want && r.total > items.length
182
+ ? `\n…and ${r.total - items.length} more. Narrow it with --kind or --project.`
183
+ : "";
184
+ return lines.join("\n") + more;
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.11.2",
3
+ "version": "0.12.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",