@harborppm/cli 0.52.0 → 0.53.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
@@ -166,6 +166,9 @@ harbor raid "TITLE" [--status S] [--owner me|none|NAME] [--impact 1-5] [--probab
166
166
  harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
167
167
  the review: programs worst first, with next moves
168
168
  (reads only; harbor submit moves a task to In review)
169
+ harbor bridge run the review as a meeting: one project a screen,
170
+ what changed since the last one, log decisions,
171
+ actions and risks live, post the summary to chat
169
172
  harbor next-move PROGRAM "TEXT" write a program's next move
170
173
  harbor health --program P|--project P|--portfolio P STATE [--note WHY]
171
174
  set its health by hand (auto hands it back)
package/dist/app.mjs CHANGED
@@ -3760,7 +3760,7 @@ function projectKeyFor(name, taken) {
3760
3760
  for (let n = 2;; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`;
3761
3761
  }
3762
3762
  /** Whether `a` has the better claim to the number both hold. */
3763
- function first(a, b, stored) {
3763
+ function first$1(a, b, stored) {
3764
3764
  const ka = kept(a, stored);
3765
3765
  if (ka !== kept(b, stored)) return ka;
3766
3766
  return made(a, b) <= 0;
@@ -3808,7 +3808,7 @@ function settleKeys(data, stored) {
3808
3808
  }
3809
3809
  const other = holder.get(n);
3810
3810
  if (other) {
3811
- const [keep, move] = first(other, t, stored) ? [other, t] : [t, other];
3811
+ const [keep, move] = first$1(other, t, stored) ? [other, t] : [t, other];
3812
3812
  holder.set(n, keep);
3813
3813
  loose.push(move);
3814
3814
  } else holder.set(n, t);
@@ -6485,6 +6485,493 @@ function BriefView({ active, width, height, go }) {
6485
6485
  });
6486
6486
  }
6487
6487
 
6488
+ //#endregion
6489
+ //#region src/lib/bridge.ts
6490
+ init_date_fns();
6491
+ init_indexes();
6492
+ init_raid();
6493
+ init_swatches();
6494
+ init_task_keys();
6495
+ init_format();
6496
+ /** How many items of one kind a slide lists before "and N more". */
6497
+ const LIST_CAP = 4;
6498
+ /** When a program's changes are counted from: its last meeting, else a week back. */
6499
+ function bridgeSince(program, now) {
6500
+ if (program.reviewedAt && !Number.isNaN(Date.parse(program.reviewedAt))) return program.reviewedAt;
6501
+ return addDays(now, -7).toISOString();
6502
+ }
6503
+ const after = (at, since) => !!at && !Number.isNaN(Date.parse(at)) && Date.parse(at) > Date.parse(since);
6504
+ const first = (name) => name?.split(" ")[0] ?? null;
6505
+ /** "RR-4 Draft the copy (Sam)", with the key when it has one. */
6506
+ function taskLine$1(t, project, doc) {
6507
+ const key = taskKey(t, project);
6508
+ const who = t.assigneeId ? first(byId(doc.people).get(t.assigneeId)?.name) : null;
6509
+ return `${key ? `${key} ` : ""}${t.title}${who ? ` (${who})` : ""}`;
6510
+ }
6511
+ function listed(prefix, items) {
6512
+ const shown = items.slice(0, 4);
6513
+ const rest = items.length - shown.length;
6514
+ return {
6515
+ text: `${prefix}${shown.join("; ")}${rest > 0 ? `; and ${rest} more` : ""}`,
6516
+ more: rest
6517
+ };
6518
+ }
6519
+ const highRated = (r) => r.kind !== "decision" && isActive(r) && (r.severity === "high" || (r.impact ?? 0) >= 4);
6520
+ /** What changed on one project since `since`: health, work finished, late or added, the register, the hill. */
6521
+ function projectChanges(doc, project, since, now) {
6522
+ const out = [];
6523
+ const today = todayISO(now);
6524
+ const sinceDay = since.slice(0, 10);
6525
+ const history = project.healthHistory ?? [];
6526
+ const moves = history.filter((h) => after(h.at, since));
6527
+ if (moves.length > 0) {
6528
+ const before = history[history.length - moves.length - 1]?.health ?? null;
6529
+ const last = moves[moves.length - 1];
6530
+ const note = last.note?.trim() ? ` “${last.note.trim()}”` : "";
6531
+ const word = (h) => HEALTH_LABEL$1[h] ?? h;
6532
+ out.push({
6533
+ kind: "health",
6534
+ text: before && before !== last.health ? `Went from ${word(before)} to ${word(last.health)}${note}` : `Now ${word(last.health)}${note}`,
6535
+ more: 0
6536
+ });
6537
+ }
6538
+ const tasks = tasksOf(doc.tasks, project.id);
6539
+ const finished = tasks.filter((t) => t.status === "done" && after(t.completedAt, since)).sort((a, b) => (b.completedAt ?? "").localeCompare(a.completedAt ?? ""));
6540
+ if (finished.length) out.push({
6541
+ kind: "finished",
6542
+ ...listed("Finished: ", finished.map((t) => taskLine$1(t, project, doc)))
6543
+ });
6544
+ const late = tasks.filter((t) => t.status !== "done" && t.dueDate && t.dueDate >= sinceDay && t.dueDate < today).sort((a, b) => (a.dueDate ?? "").localeCompare(b.dueDate ?? ""));
6545
+ if (late.length) out.push({
6546
+ kind: "late",
6547
+ ...listed("Now late: ", late.map((t) => taskLine$1(t, project, doc)))
6548
+ });
6549
+ const added = tasks.filter((t) => after(t.createdAt, since) && !finished.includes(t));
6550
+ if (added.length) out.push({
6551
+ kind: "added",
6552
+ ...listed("Added: ", added.map((t) => taskLine$1(t, project, doc)))
6553
+ });
6554
+ const register = doc.raidItems.filter((r) => r.projectId === project.id);
6555
+ const decisions = register.filter((r) => r.kind === "decision" && after(r.updatedAt, since));
6556
+ const decided = decisions.filter((r) => r.status === "decided");
6557
+ if (decided.length) out.push({
6558
+ kind: "decided",
6559
+ ...listed("Decided: ", decided.map((r) => r.title))
6560
+ });
6561
+ const proposed = decisions.filter((r) => r.status === "proposed");
6562
+ if (proposed.length) out.push({
6563
+ kind: "decided",
6564
+ ...listed("To decide: ", proposed.map((r) => r.title))
6565
+ });
6566
+ const raised = register.filter((r) => r.kind !== "decision" && after(r.createdAt, since));
6567
+ if (raised.length) out.push({
6568
+ kind: "raised",
6569
+ ...listed("Raised: ", raised.map((r) => `${r.kind === "issue" ? "issue" : "risk"} ${r.title}`))
6570
+ });
6571
+ const updated = register.filter((r) => r.kind !== "decision" && !raised.includes(r) && after(r.updatedAt, since));
6572
+ if (updated.length) out.push({
6573
+ kind: "updated",
6574
+ ...listed("Updated: ", updated.map((r) => r.title))
6575
+ });
6576
+ if (after(project.hillMovedAt, since) && typeof project.hillAt === "number") out.push({
6577
+ kind: "hill",
6578
+ text: project.hillAt < 50 ? "Moved on the hill, still figuring it out" : "Moved on the hill, getting it done",
6579
+ more: 0
6580
+ });
6581
+ return out;
6582
+ }
6583
+ /**
6584
+ * The meeting's slides: the open projects of the given programs (in the
6585
+ * review's order), each with what changed. Projects where nothing moved
6586
+ * come back as `quiet`, named on the summary rather than given a screen.
6587
+ */
6588
+ function bridgeSlides(doc, programs, now) {
6589
+ const today = todayISO(now);
6590
+ const slides = [];
6591
+ const quiet = [];
6592
+ const rank = {
6593
+ "off-track": 0,
6594
+ "at-risk": 1,
6595
+ "on-track": 2,
6596
+ paused: 3,
6597
+ done: 4
6598
+ };
6599
+ for (const program of programs) {
6600
+ if (program.health === "done") continue;
6601
+ const since = bridgeSince(program, now);
6602
+ const milestone = doc.milestones.filter((m) => m.programId === program.id && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate))[0] ?? null;
6603
+ const projects = doc.projects.filter((p) => p.programId === program.id && p.health !== "done").sort((a, b) => rank[a.health] - rank[b.health] || a.endDate.localeCompare(b.endDate));
6604
+ for (const project of projects) {
6605
+ const changes = projectChanges(doc, project, since, now);
6606
+ if (changes.length === 0) {
6607
+ quiet.push({
6608
+ program,
6609
+ project
6610
+ });
6611
+ continue;
6612
+ }
6613
+ const tasks = tasksOf(doc.tasks, project.id);
6614
+ slides.push({
6615
+ program,
6616
+ project,
6617
+ since,
6618
+ changes,
6619
+ late: tasks.filter((t) => t.status !== "done" && !!t.dueDate && t.dueDate < today).length,
6620
+ open: tasks.filter((t) => t.status !== "done").length,
6621
+ highRisks: doc.raidItems.filter((r) => r.projectId === project.id && highRated(r)).length,
6622
+ milestone
6623
+ });
6624
+ }
6625
+ }
6626
+ return {
6627
+ slides,
6628
+ quiet
6629
+ };
6630
+ }
6631
+ /**
6632
+ * The end summary, one part per program: projects covered and what was
6633
+ * logged on each, read back from the document (so an edit made since shows,
6634
+ * and an item since deleted drops out).
6635
+ */
6636
+ function meetingSummary(doc, slides, logged) {
6637
+ const projects = byId(doc.projects);
6638
+ const people = byId(doc.people);
6639
+ const tasks = byId(doc.tasks);
6640
+ const raid = byId(doc.raidItems);
6641
+ const parts = /* @__PURE__ */ new Map();
6642
+ const partOf = (program) => {
6643
+ let part = parts.get(program.id);
6644
+ if (!part) {
6645
+ part = {
6646
+ program,
6647
+ covered: [],
6648
+ lines: []
6649
+ };
6650
+ parts.set(program.id, part);
6651
+ }
6652
+ return part;
6653
+ };
6654
+ for (const s of slides) partOf(s.program).covered.push(s.project);
6655
+ for (const item of logged) {
6656
+ if (item.kind === "action") {
6657
+ const t = tasks.get(item.id);
6658
+ const project = t ? projects.get(t.projectId) : void 0;
6659
+ const program = project ? doc.programs.find((p) => p.id === project.programId) : void 0;
6660
+ if (!t || !project || !program) continue;
6661
+ const who = t.assigneeId ? first(people.get(t.assigneeId)?.name) : null;
6662
+ const key = taskKey(t, project);
6663
+ partOf(program).lines.push({
6664
+ kind: "action",
6665
+ text: `${key ? `${key} ` : ""}${t.title}${who ? `, ${who}` : ""}${t.dueDate ? `, due ${t.dueDate}` : ""}`
6666
+ });
6667
+ continue;
6668
+ }
6669
+ const r = raid.get(item.id);
6670
+ const project = r?.projectId ? projects.get(r.projectId) : void 0;
6671
+ const program = project ? doc.programs.find((p) => p.id === project.programId) : void 0;
6672
+ if (!r || !program) continue;
6673
+ const said = r.kind === "decision" && r.resolution?.trim() ? `: ${r.resolution.trim()}` : "";
6674
+ partOf(program).lines.push({
6675
+ kind: item.kind,
6676
+ text: `${r.title}${said}`
6677
+ });
6678
+ }
6679
+ return [...parts.values()];
6680
+ }
6681
+ const LABEL = {
6682
+ decision: "Decided",
6683
+ action: "Action",
6684
+ risk: "Risk"
6685
+ };
6686
+ /** One program's part as the post for its chat channel: plain lines, capped well under every service's limit. */
6687
+ function summaryText(part, minutes) {
6688
+ const names = part.covered.map((p) => p.name);
6689
+ const head = `Review of ${part.program.name}: ${names.length} project${names.length === 1 ? "" : "s"} covered${minutes > 0 ? ` in ${minutes} min` : ""}${names.length ? ` (${names.join(", ")})` : ""}.`;
6690
+ const lines = part.lines.map((l) => `${LABEL[l.kind]}: ${l.text}`);
6691
+ return `${head}\n${lines.length ? lines.join("\n") : "Nothing new logged."}`.slice(0, 2e3);
6692
+ }
6693
+ /** The ids of programs whose projects the meeting covered, to stamp `reviewedAt` on. */
6694
+ function reviewedPrograms(slides, quiet) {
6695
+ return [.../* @__PURE__ */ new Set([...slides.map((s) => s.program.id), ...quiet.map((q) => q.program.id)])];
6696
+ }
6697
+
6698
+ //#endregion
6699
+ //#region src/tui/views/bridge.tsx
6700
+ init_date_fns();
6701
+ init_date_text();
6702
+ const WORD = {
6703
+ decision: "Decided",
6704
+ action: "Action",
6705
+ risk: "Risk"
6706
+ };
6707
+ /**
6708
+ * Bridge mode, as on the website's review: the meeting one project at a
6709
+ * time with only what changed since the program was last reviewed (←→ or
6710
+ * j k), d a decision, a an action, r a risk, m the program's next move, e
6711
+ * ends it. The summary lists what was logged; p posts it to each program's
6712
+ * chat channel where one is set, y copies it.
6713
+ */
6714
+ function BridgeView({ active, width, height, go, programIds, onBack }) {
6715
+ const { store, transport, today } = useDeps();
6716
+ const workspaceId = useHarbor((s) => s.workspaceId);
6717
+ const viewOnly = useHarbor((s) => s.myRole === "viewer" || s.keepPaused);
6718
+ const programs = useHarbor((s) => s.programs);
6719
+ const projects = useHarbor((s) => s.projects);
6720
+ const tasks = useHarbor((s) => s.tasks);
6721
+ const raidItems = useHarbor((s) => s.raidItems);
6722
+ const people = useHarbor((s) => s.people);
6723
+ const milestones = useHarbor((s) => s.milestones);
6724
+ const me = useHarbor((s) => s.currentUserId);
6725
+ const { stdout } = useStdout();
6726
+ const data = useMemo(() => ({
6727
+ programs,
6728
+ projects,
6729
+ tasks,
6730
+ raidItems,
6731
+ people,
6732
+ milestones
6733
+ }), [
6734
+ programs,
6735
+ projects,
6736
+ tasks,
6737
+ raidItems,
6738
+ people,
6739
+ milestones
6740
+ ]);
6741
+ const [started] = useState(() => /* @__PURE__ */ new Date());
6742
+ const [{ slides, quiet }] = useState(() => {
6743
+ const picked = programIds.map((id) => store.getState().programs.find((p) => p.id === id)).filter((p) => p !== void 0);
6744
+ return bridgeSlides(store.getState(), picked, started);
6745
+ });
6746
+ const [index, setIndex] = useState(0);
6747
+ const [logged, setLogged] = useState([]);
6748
+ const [ended, setEnded] = useState(slides.length === 0 ? started : null);
6749
+ const [channels, setChannels] = useState({});
6750
+ const parts = useMemo(() => meetingSummary(data, slides, logged), [
6751
+ data,
6752
+ slides,
6753
+ logged
6754
+ ]);
6755
+ const minutes = ended ? Math.round((ended.getTime() - started.getTime()) / 6e4) : 0;
6756
+ useEffect(() => {
6757
+ if (!ended || !transport.meetingPost || viewOnly || !workspaceId) return;
6758
+ let live = true;
6759
+ for (const part of parts) transport.meetingPost(workspaceId, part.program.id).then((a) => {
6760
+ if (live && a.set && a.service) setChannels((c) => ({
6761
+ ...c,
6762
+ [part.program.id]: a.service
6763
+ }));
6764
+ }).catch(() => {});
6765
+ return () => {
6766
+ live = false;
6767
+ };
6768
+ }, [ended]);
6769
+ const end = () => {
6770
+ const at = /* @__PURE__ */ new Date();
6771
+ setEnded(at);
6772
+ if (!viewOnly) store.getState().markReviewed(reviewedPrograms(slides, quiet), at.toISOString());
6773
+ };
6774
+ const slide = slides[index];
6775
+ const program = slide ? programs.find((p) => p.id === slide.program.id) ?? slide.program : null;
6776
+ const add = (kind, id) => setLogged((l) => [...l, {
6777
+ kind,
6778
+ id
6779
+ }]);
6780
+ const logDecision = () => {
6781
+ if (!slide) return;
6782
+ go.ask(`Decision on ${slide.project.name}: what was it about?`, (title) => {
6783
+ if (!title.trim()) return;
6784
+ go.ask("What was decided? (Enter to skip)", (said) => {
6785
+ add("decision", store.getState().addRaidItem({
6786
+ kind: "decision",
6787
+ title: title.trim(),
6788
+ projectId: slide.project.id,
6789
+ ownerId: me,
6790
+ status: "decided",
6791
+ resolution: said.trim(),
6792
+ decidedById: me,
6793
+ decidedAt: today()
6794
+ }));
6795
+ go.notify("Decision logged.");
6796
+ });
6797
+ });
6798
+ };
6799
+ const logAction = () => {
6800
+ if (!slide) return;
6801
+ go.ask(`Action on ${slide.project.name}: what needs doing?`, (title) => {
6802
+ if (!title.trim()) return;
6803
+ const lead = people.find((p) => p.id === slide.project.ownerId);
6804
+ go.pick("Who takes it?", [
6805
+ ...lead ? [{
6806
+ label: lead.name,
6807
+ value: lead.id,
6808
+ hint: "leads it"
6809
+ }] : [],
6810
+ ...people.filter((p) => p.id !== lead?.id && p.kind !== "agent").map((p) => ({
6811
+ label: p.name,
6812
+ value: p.id,
6813
+ hint: p.role
6814
+ })),
6815
+ {
6816
+ label: "Nobody yet",
6817
+ value: null
6818
+ }
6819
+ ], (who) => {
6820
+ const due = addDays(/* @__PURE__ */ new Date(`${today()}T12:00:00`), 7).toISOString().slice(0, 10);
6821
+ add("action", store.getState().addTask({
6822
+ projectId: slide.project.id,
6823
+ title: title.trim(),
6824
+ description: "",
6825
+ status: "todo",
6826
+ priority: "medium",
6827
+ assigneeId: who ?? null,
6828
+ dueDate: due
6829
+ }, { allowOverCap: true }));
6830
+ go.notify(`Action added, due ${shortDay(due, today())}.`);
6831
+ });
6832
+ });
6833
+ };
6834
+ const logRisk = () => {
6835
+ if (!slide) return;
6836
+ go.ask(`Risk on ${slide.project.name}: what could go wrong?`, (title) => {
6837
+ if (!title.trim()) return;
6838
+ add("risk", store.getState().addRaidItem({
6839
+ kind: "risk",
6840
+ title: title.trim(),
6841
+ projectId: slide.project.id,
6842
+ ownerId: slide.project.ownerId || null,
6843
+ status: "open"
6844
+ }));
6845
+ go.notify("Risk raised.");
6846
+ });
6847
+ };
6848
+ const post = async () => {
6849
+ const targets = parts.filter((p) => channels[p.program.id]);
6850
+ if (!transport.meetingPost || !workspaceId || targets.length === 0) return go.notify("None of these programs has a chat channel. Set one on the website's program page.");
6851
+ let ok = 0;
6852
+ for (const part of targets) if ((await transport.meetingPost(workspaceId, part.program.id, summaryText(part, minutes)).catch((e) => {
6853
+ go.notify(`Didn't post: ${e.message}`);
6854
+ return null;
6855
+ }))?.posted) ok++;
6856
+ if (ok) go.notify(`Posted to ${ok} channel${ok === 1 ? "" : "s"}.`);
6857
+ };
6858
+ useInput((input, key) => {
6859
+ if (ended) {
6860
+ if (key.escape) return onBack();
6861
+ if (input === "y") {
6862
+ stdout.write(copySequence(parts.map((p) => summaryText(p, minutes)).join("\n\n")));
6863
+ return go.notify("Copied the summary, if this terminal lets apps copy.");
6864
+ }
6865
+ if (input === "p" && !viewOnly) return void post();
6866
+ return;
6867
+ }
6868
+ if (key.escape || input === "e") return end();
6869
+ if (key.rightArrow || input === "j" || input === " ") return setIndex((i) => Math.min(slides.length - 1, i + 1));
6870
+ if (key.leftArrow || input === "k") return setIndex((i) => Math.max(0, i - 1));
6871
+ if (viewOnly) return;
6872
+ if (input === "d") return logDecision();
6873
+ if (input === "a") return logAction();
6874
+ if (input === "r") return logRisk();
6875
+ if (input === "m" && program) return go.ask(`Next move for ${program.name}`, (text) => store.getState().setNextMove(program.id, text.trim()), program.nextMove ?? "");
6876
+ }, { isActive: active });
6877
+ const w = width - 2;
6878
+ if (ended) {
6879
+ const text = [
6880
+ parts.length ? `${parts.reduce((n, p) => n + p.covered.length, 0)} projects covered${minutes ? ` in ${minutes} min` : ""}. The next meeting shows what changes from here.` : "Nothing changed since the last review.",
6881
+ ...parts.flatMap((part) => [
6882
+ "",
6883
+ `${part.program.name}${channels[part.program.id] ? ` · ${channels[part.program.id]} channel` : ""}`,
6884
+ `Covered: ${part.covered.map((p) => p.name).join(", ") || "none"}`,
6885
+ ...part.lines.length ? part.lines.map((l) => `• ${WORD[l.kind]}: ${l.text}`) : ["Nothing new logged."]
6886
+ ]),
6887
+ ...quiet.length ? ["", `No change on ${quiet.map((q) => q.project.name).join(", ")}.`] : []
6888
+ ].join("\n");
6889
+ const lines = wrapLines(text, w).slice(0, Math.max(1, height - 2));
6890
+ return /* @__PURE__ */ jsxs(Box, {
6891
+ flexDirection: "column",
6892
+ paddingX: 1,
6893
+ width,
6894
+ children: [/* @__PURE__ */ jsx(Heading, {
6895
+ right: `${Object.keys(channels).length && !viewOnly ? "p post · " : ""}y copy · Esc back`,
6896
+ children: "Meeting summary"
6897
+ }), lines.map((line, i) => /* @__PURE__ */ jsx(Text, {
6898
+ wrap: "truncate-end",
6899
+ children: line || " "
6900
+ }, i))]
6901
+ });
6902
+ }
6903
+ if (!slide || !program) return null;
6904
+ const owner = people.find((p) => p.id === slide.project.ownerId);
6905
+ const here = meetingSummary(data, [], logged.filter((l) => l.kind === "action" ? tasks.some((t) => t.id === l.id && t.projectId === slide.project.id) : raidItems.some((r) => r.id === l.id && r.projectId === slide.project.id))).flatMap((p) => p.lines);
6906
+ const text = [
6907
+ [
6908
+ HEALTH_LABEL[slide.project.health],
6909
+ owner?.name,
6910
+ `ends ${shortDay(slide.project.endDate, today())}`,
6911
+ `${slide.late} late of ${slide.open} open`,
6912
+ slide.highRisks ? `${slide.highRisks} high risk${slide.highRisks === 1 ? "" : "s"}` : null
6913
+ ].filter(Boolean).join(" · "),
6914
+ slide.milestone ? `Next milestone: ${slide.milestone.name}, ${shortDay(slide.milestone.dueDate, today())}` : "",
6915
+ "",
6916
+ `Since ${program.reviewedAt ? "the last review" : "last week"}, ${timeAgo(new Date(slide.since), started)}:`,
6917
+ ...slide.changes.map((c) => `• ${c.text}`),
6918
+ ...here.length ? [
6919
+ "",
6920
+ "Logged in this meeting:",
6921
+ ...here.map((l) => `• ${WORD[l.kind]}: ${l.text}`)
6922
+ ] : [],
6923
+ "",
6924
+ `Next move: ${program.nextMove?.trim() || "none yet"}`
6925
+ ].filter((l, i, all) => l !== "" || all[i - 1] !== "").join("\n");
6926
+ const lines = wrapLines(text, w).slice(0, Math.max(1, height - 3));
6927
+ return /* @__PURE__ */ jsxs(Box, {
6928
+ flexDirection: "column",
6929
+ paddingX: 1,
6930
+ width,
6931
+ children: [
6932
+ /* @__PURE__ */ jsxs(Heading, {
6933
+ right: `${index + 1} of ${slides.length} · ${program.name}`,
6934
+ children: [/* @__PURE__ */ jsx(Text, {
6935
+ color: theme.accent,
6936
+ children: "Meeting ▸ "
6937
+ }), slide.project.name]
6938
+ }),
6939
+ lines.map((line, i) => /* @__PURE__ */ jsx(Text, {
6940
+ wrap: "truncate-end",
6941
+ children: line || " "
6942
+ }, i)),
6943
+ /* @__PURE__ */ jsxs(Text, {
6944
+ color: theme.muted,
6945
+ wrap: "truncate-end",
6946
+ children: [
6947
+ "←→ projects",
6948
+ viewOnly ? "" : " · d decision · a action · r risk · m next move",
6949
+ " · e end"
6950
+ ]
6951
+ })
6952
+ ]
6953
+ });
6954
+ }
6955
+
6956
+ //#endregion
6957
+ //#region src/lib/review.ts
6958
+ init_indexes();
6959
+ init_derived_health();
6960
+ /** A program is someone's when they lead it, lead one of its projects or hold a task in one. */
6961
+ function programTouches(program, data, personId) {
6962
+ if (program.ownerId === personId) return true;
6963
+ return data.projects.some((p) => p.programId === program.id && (p.ownerId === personId || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId)));
6964
+ }
6965
+ /**
6966
+ * The review's programs: worst health first, then soonest end, narrowed to
6967
+ * one person's and one portfolio's when asked. Done ones are left in; the
6968
+ * website, the terminal and the agents' tool each tuck them away themselves.
6969
+ */
6970
+ function reviewPrograms(data, opts = {}) {
6971
+ const { ownerId, portfolioId } = opts;
6972
+ return data.programs.filter((p) => (!portfolioId || p.portfolioId === portfolioId) && (!ownerId || programTouches(p, data, ownerId))).sort((a, b) => rankHealth(a.health) - rankHealth(b.health) || a.endDate.localeCompare(b.endDate));
6973
+ }
6974
+
6488
6975
  //#endregion
6489
6976
  //#region src/tui/moves.ts
6490
6977
  init_board();
@@ -7747,26 +8234,6 @@ var init_load = __esmMin((() => {
7747
8234
  };
7748
8235
  }));
7749
8236
 
7750
- //#endregion
7751
- //#region src/lib/review.ts
7752
- init_load();
7753
- init_indexes();
7754
- init_derived_health();
7755
- /** A program is someone's when they lead it, lead one of its projects or hold a task in one. */
7756
- function programTouches(program, data, personId) {
7757
- if (program.ownerId === personId) return true;
7758
- return data.projects.some((p) => p.programId === program.id && (p.ownerId === personId || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId)));
7759
- }
7760
- /**
7761
- * The review's programs: worst health first, then soonest end, narrowed to
7762
- * one person's and one portfolio's when asked. Done ones are left in; the
7763
- * website, the terminal and the agents' tool each tuck them away themselves.
7764
- */
7765
- function reviewPrograms(data, opts = {}) {
7766
- const { ownerId, portfolioId } = opts;
7767
- return data.programs.filter((p) => (!portfolioId || p.portfolioId === portfolioId) && (!ownerId || programTouches(p, data, ownerId))).sort((a, b) => rankHealth(a.health) - rankHealth(b.health) || a.endDate.localeCompare(b.endDate));
7768
- }
7769
-
7770
8237
  //#endregion
7771
8238
  //#region src/lib/priority.ts
7772
8239
  /** Rules with anything missing or out of range brought back to the defaults. */
@@ -7804,6 +8271,7 @@ var init_priority = __esmMin((() => {
7804
8271
 
7805
8272
  //#endregion
7806
8273
  //#region src/tui/views/heat.tsx
8274
+ init_load();
7807
8275
  init_priority();
7808
8276
  init_raid();
7809
8277
  const CELL = 7;
@@ -9242,7 +9710,7 @@ function ProgramsView({ active, width, height, go }) {
9242
9710
  const where = portfolio ? ` in ${portfolios.find((p) => p.id === portfolio)?.name ?? "a portfolio"}` : "";
9243
9711
  return /* @__PURE__ */ jsx(RowList, {
9244
9712
  title: `${whose}${where}`,
9245
- right: `${count(listed.length, "program")}${done ? ` · ${done} done ${showDone ? "shown" : "hidden"} (D)` : ""} · m next move · h health · o p narrow`,
9713
+ right: `${count(listed.length, "program")}${done ? ` · ${done} done ${showDone ? "shown" : "hidden"} (D)` : ""} · b run the meeting · m next move · h health · o p narrow`,
9246
9714
  rows,
9247
9715
  empty: done ? "Every program here is done. D shows them." : owner || portfolio ? "Nothing here. o and p change what's shown." : "No programs yet. n adds one.",
9248
9716
  active,
@@ -9255,6 +9723,7 @@ function ProgramsView({ active, width, height, go }) {
9255
9723
  go.create("program", portfolioId ? { portfolioId } : {});
9256
9724
  },
9257
9725
  e: (r) => r && go.openPanel("program", r.id),
9726
+ b: () => split.open.length ? go.openBridge(split.open.map((p) => p.id)) : go.notify("No open programs here to review."),
9258
9727
  m: (r) => {
9259
9728
  const program = r && programs.find((p) => p.id === r.id);
9260
9729
  if (!program) return;
@@ -12858,6 +13327,8 @@ const NAV = [
12858
13327
  const navKey = (i) => i < 10 ? String((i + 1) % 10) : "C";
12859
13328
  /** The sidebar's width: its longest line ("9 Calendar", ": Anything") with room either side. */
12860
13329
  const NAV_WIDTH = 15;
13330
+ /** Every open program, in the review's order: what a meeting started from the palette covers. */
13331
+ const openReviewIds = (data) => reviewPrograms(data).filter((p) => p.health !== "done").map((p) => p.id);
12861
13332
  /** What the corner says about saving, and in which of the theme's colours. */
12862
13333
  const SAVE_LABEL = {
12863
13334
  saved: ["● saved", "good"],
@@ -12866,7 +13337,7 @@ const SAVE_LABEL = {
12866
13337
  retrying: ["● not saved, retrying", "bad"],
12867
13338
  offline: ["● offline, will save", "bad"]
12868
13339
  };
12869
- function App({ ui }) {
13340
+ function App({ ui, startIn }) {
12870
13341
  const { store, sync, account, harbors, today, remember } = useDeps();
12871
13342
  const { exit } = useApp();
12872
13343
  const { columns, rows } = useWindowSize();
@@ -12879,7 +13350,11 @@ function App({ ui }) {
12879
13350
  const save = useStore(ui, (s) => s.save);
12880
13351
  const notice = useStore(ui, (s) => s.notice);
12881
13352
  const [screen, setScreen] = useState("today");
12882
- const [stack, setStack] = useState([]);
13353
+ const [stack, setStack] = useState(() => startIn === "bridge" ? [{
13354
+ kind: "bridge",
13355
+ id: "bridge-start",
13356
+ programIds: openReviewIds(store.getState())
13357
+ }] : []);
12883
13358
  const [panel, setPanel] = useState(null);
12884
13359
  const [overlay, setOverlay] = useState(null);
12885
13360
  const [focus, setFocus] = useState("main");
@@ -12916,6 +13391,11 @@ function App({ ui }) {
12916
13391
  kind: "portfolio",
12917
13392
  id
12918
13393
  }),
13394
+ openBridge: (programIds) => push({
13395
+ kind: "bridge",
13396
+ id: `bridge-${Date.now()}`,
13397
+ programIds
13398
+ }),
12919
13399
  openPanel: (kind, id) => {
12920
13400
  setPanel({
12921
13401
  kind,
@@ -13009,6 +13489,11 @@ function App({ ui }) {
13009
13489
  hint: "make",
13010
13490
  run: () => go.create("raid")
13011
13491
  },
13492
+ {
13493
+ label: "Run the review meeting",
13494
+ hint: "bridge",
13495
+ run: () => go.openBridge(openReviewIds(store.getState()))
13496
+ },
13012
13497
  {
13013
13498
  label: "Find a task",
13014
13499
  hint: "/",
@@ -13045,7 +13530,7 @@ function App({ ui }) {
13045
13530
  if (input === "?") return setOverlay({ kind: "help" });
13046
13531
  if (input === "H") return setOverlay({ kind: "harbors" });
13047
13532
  if (input === "C") return goScreen("crew");
13048
- if (input === "r") {
13533
+ if (input === "r" && page?.kind !== "bridge") {
13049
13534
  sync.retryNow();
13050
13535
  return notify("Saving now.");
13051
13536
  }
@@ -13178,7 +13663,7 @@ function App({ ui }) {
13178
13663
  close();
13179
13664
  then(text);
13180
13665
  }
13181
- })
13666
+ }, `${label}\u0000${initial ?? ""}`)
13182
13667
  });
13183
13668
  } else if (overlay?.kind === "read") overlayNode = /* @__PURE__ */ jsx(Letter, {
13184
13669
  title: overlay.title,
@@ -13248,7 +13733,12 @@ function App({ ui }) {
13248
13733
  id: panel.id,
13249
13734
  ...panelProps
13250
13735
  });
13251
- const hints = panel ? [["Esc", "back"], ["u", "undo"]] : page?.kind === "project" ? [
13736
+ const hints = panel ? [["Esc", "back"], ["u", "undo"]] : page?.kind === "bridge" ? [
13737
+ ["←→", "projects"],
13738
+ ["d a r", "log"],
13739
+ ["e", "end"],
13740
+ ["Esc", "end/back"]
13741
+ ] : page?.kind === "project" ? [
13252
13742
  ["←→↑↓", "move"],
13253
13743
  ["Enter", "open"],
13254
13744
  ["n", "new"],
@@ -13279,7 +13769,12 @@ function App({ ui }) {
13279
13769
  ["q", "quit"]
13280
13770
  ];
13281
13771
  let screenNode;
13282
- if (page?.kind === "project") screenNode = /* @__PURE__ */ jsx(ProjectView, {
13772
+ if (page?.kind === "bridge") screenNode = /* @__PURE__ */ jsx(BridgeView, {
13773
+ ...viewProps,
13774
+ programIds: page.programIds,
13775
+ onBack: back
13776
+ }, page.id);
13777
+ else if (page?.kind === "project") screenNode = /* @__PURE__ */ jsx(ProjectView, {
13283
13778
  ...viewProps,
13284
13779
  projectId: page.id,
13285
13780
  onBack: back
@@ -14405,6 +14900,15 @@ function hierarchySlice({ get, write }) {
14405
14900
  nextMove: text
14406
14901
  } : p) });
14407
14902
  },
14903
+ markReviewed: (programIds, at) => {
14904
+ const ids = new Set(programIds);
14905
+ if (ids.size === 0) return;
14906
+ const when = at ?? stamp();
14907
+ write({ programs: get().programs.map((p) => ids.has(p.id) ? {
14908
+ ...p,
14909
+ reviewedAt: when
14910
+ } : p) });
14911
+ },
14408
14912
  setHealthOverride: (kind, id, health, note) => {
14409
14913
  const at = stamp();
14410
14914
  const body = {
@@ -16486,7 +16990,8 @@ function plainTransport(t) {
16486
16990
  history: async (query) => plainDeep(await t.history(query)),
16487
16991
  create: async (name) => plainDeep(await t.create(name)),
16488
16992
  crew: async (workspaceId, act) => plainDeep(await t.crew(workspaceId, act)),
16489
- invite: async (workspaceId, email, role) => plainDeep(await t.invite(workspaceId, email, role))
16993
+ invite: async (workspaceId, email, role) => plainDeep(await t.invite(workspaceId, email, role)),
16994
+ ...t.meetingPost ? { meetingPost: async (workspaceId, programId, text) => plainDeep(await t.meetingPost(workspaceId, programId, text)) } : {}
16490
16995
  };
16491
16996
  }
16492
16997
  function httpTransport(source, fetcher = fetch) {
@@ -16550,6 +17055,12 @@ function httpTransport(source, fetcher = fetch) {
16550
17055
  harbor: workspaceId,
16551
17056
  email,
16552
17057
  role
17058
+ }),
17059
+ meetingPost: (workspaceId, programId, text) => post({
17060
+ op: "meeting-post",
17061
+ workspaceId,
17062
+ programId,
17063
+ ...text ? { text } : {}
16553
17064
  })
16554
17065
  };
16555
17066
  }
@@ -17274,6 +17785,7 @@ function memoryHarbor(start, opts) {
17274
17785
  id,
17275
17786
  name
17276
17787
  });
17788
+ const posts = [];
17277
17789
  return {
17278
17790
  transport: {
17279
17791
  crew: opts.noCrew ? async () => Promise.reject(new TransportError(opts.noCrew, 404)) : crew.crew,
@@ -17317,6 +17829,23 @@ function memoryHarbor(start, opts) {
17317
17829
  };
17318
17830
  return save(sent);
17319
17831
  },
17832
+ async meetingPost(_id, programId, text) {
17833
+ unreachable();
17834
+ const service = opts.channels?.[programId] ?? null;
17835
+ if (!text) return {
17836
+ set: Boolean(service),
17837
+ service
17838
+ };
17839
+ if (service) posts.push({
17840
+ programId,
17841
+ text
17842
+ });
17843
+ return {
17844
+ set: Boolean(service),
17845
+ service,
17846
+ posted: Boolean(service)
17847
+ };
17848
+ },
17320
17849
  async create(made) {
17321
17850
  return { harbor: {
17322
17851
  id,
@@ -17360,6 +17889,8 @@ function memoryHarbor(start, opts) {
17360
17889
  }
17361
17890
  },
17362
17891
  now,
17892
+ /** What was posted to the pretend chat channels, oldest first. */
17893
+ posts,
17363
17894
  /** How many versions this harbor has been through. */
17364
17895
  get versions() {
17365
17896
  return versions.length;
@@ -26047,7 +26578,10 @@ async function startApp(deps, options = {}) {
26047
26578
  remember: deps.remember,
26048
26579
  browse: deps.browse
26049
26580
  },
26050
- children: /* @__PURE__ */ jsx(App, { ui })
26581
+ children: /* @__PURE__ */ jsx(App, {
26582
+ ui,
26583
+ startIn: deps.startIn
26584
+ })
26051
26585
  }));
26052
26586
  return {
26053
26587
  instance,
@@ -26125,7 +26659,8 @@ async function runApp(opts) {
26125
26659
  theme,
26126
26660
  firstRun,
26127
26661
  remember: opts.remember,
26128
- browse: opts.browse
26662
+ browse: opts.browse,
26663
+ startIn: opts.startIn
26129
26664
  }, { alternateScreen: true });
26130
26665
  await instance.waitUntilExit();
26131
26666
  }
package/lib/cli.mjs CHANGED
@@ -161,6 +161,10 @@ export const HELP = `harbor: Harbor from the terminal
161
161
  the review: programs worst first,
162
162
  each with its next move (reads
163
163
  only; harbor submit moves a task)
164
+ harbor bridge [--harbor H] run the review as a meeting: one
165
+ project a screen, what changed
166
+ since the last one, decisions,
167
+ actions and risks logged live
164
168
  harbor next-move PROGRAM "TEXT" write a program's next move
165
169
  harbor health --program P|--project P|--portfolio P STATE [--note WHY]
166
170
  set its health by hand, or auto
@@ -767,6 +771,20 @@ const DETAIL = {
767
771
  " enough of it to pick one out; harbor risks lists them. Changes several",
768
772
  " at once, as one change.",
769
773
  ],
774
+ bridge: [
775
+ " harbor bridge opens the full-screen app on the",
776
+ " review meeting (b on the Programs",
777
+ " screen, or : Run the review meeting)",
778
+ "",
779
+ " One open project a screen, with only what changed since its program was",
780
+ " last reviewed (a week back the first time): health moves, work finished,",
781
+ " newly late or added, risks raised, decisions made. ←→ move between",
782
+ " projects; d logs a decision, a an action (who takes it, due in a week),",
783
+ " r a risk, m the program's next move; e ends it. The summary lists what",
784
+ " was logged; p posts it to each program's chat channel where one is set",
785
+ " (Ship to shore, set on the website), y copies it. The next meeting",
786
+ " counts from when this one ended.",
787
+ ],
770
788
  review: [
771
789
  " harbor review reads the review: every program,",
772
790
  " worst health first, then soonest end,",
@@ -990,7 +1008,7 @@ export async function run(argv, io = {}) {
990
1008
  }
991
1009
 
992
1010
  // On its own in a terminal: the full-screen app. Piped or scripted: the help.
993
- if (!command || command === "app") {
1011
+ if (!command || command === "app" || command === "bridge") {
994
1012
  const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
995
1013
  if (!command && !terminal) {
996
1014
  out(HELP);
@@ -999,7 +1017,7 @@ export async function run(argv, io = {}) {
999
1017
  if (!terminal) throw new HarborError("The full-screen app needs a terminal.");
1000
1018
  const { values } = parseArgs(rest, { flags: [], options: ["harbor", "server"] });
1001
1019
  // In a folder with a local plan (harbor init --local), the app opens on it.
1002
- if (!values.harbor && !values.server) {
1020
+ if (!values.harbor && !values.server && command !== "bridge") {
1003
1021
  const bundled = await loadApp();
1004
1022
  const dir = await bundled.localPlanAt(io.cwd ?? process.cwd());
1005
1023
  if (dir) {
@@ -1056,6 +1074,7 @@ export async function run(argv, io = {}) {
1056
1074
  },
1057
1075
  theme: (await readSettings(env)).theme,
1058
1076
  browse: io.browse ?? openBrowser,
1077
+ ...(command === "bridge" ? { startIn: "bridge" } : {}),
1059
1078
  });
1060
1079
  return 0;
1061
1080
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.52.0",
3
+ "version": "0.53.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",