@harborppm/cli 0.44.0 → 0.45.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
@@ -2851,6 +2851,17 @@ var init_board = __esmMin((() => {
2851
2851
  ];
2852
2852
  }));
2853
2853
 
2854
+ //#endregion
2855
+ //#region src/lib/keep.ts
2856
+ init_board();
2857
+ /** Whole days left in the trial, rounded up: 30 on the first day, 1 on the last. 0 once it's over. */
2858
+ function trialDaysLeft(trialEndsAt, now = /* @__PURE__ */ new Date()) {
2859
+ if (!trialEndsAt) return 0;
2860
+ const ms = Date.parse(trialEndsAt) - now.getTime();
2861
+ if (!Number.isFinite(ms) || ms <= 0) return 0;
2862
+ return Math.ceil(ms / 864e5);
2863
+ }
2864
+
2854
2865
  //#endregion
2855
2866
  //#region src/lib/cascade.ts
2856
2867
  /** What deleting a portfolio, program, or project takes with it, and the data left behind. Pure. */
@@ -2912,7 +2923,6 @@ var init_cascade = __esmMin((() => {
2912
2923
 
2913
2924
  //#endregion
2914
2925
  //#region src/lib/forecast.ts
2915
- init_board();
2916
2926
  init_cascade();
2917
2927
  init_date_fns();
2918
2928
  const day$4 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
@@ -3781,6 +3791,137 @@ var init_work = __esmMin((() => {
3781
3791
  escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3782
3792
  }));
3783
3793
 
3794
+ //#endregion
3795
+ //#region src/lib/premortem.ts
3796
+ function premortemPhase(project, now) {
3797
+ const pm = project.premortem;
3798
+ if (!pm) return "none";
3799
+ return now.getTime() < Date.parse(pm.revealAt) ? "answering" : "revealed";
3800
+ }
3801
+ /** Whole seconds until the answers show; 0 once they have. */
3802
+ function secondsLeft(pm, now) {
3803
+ if (!pm) return 0;
3804
+ return Math.max(0, Math.ceil((Date.parse(pm.revealAt) - now.getTime()) / 1e3));
3805
+ }
3806
+ /** "4:05" */
3807
+ function clockText(seconds) {
3808
+ const s = Math.max(0, Math.round(seconds));
3809
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
3810
+ }
3811
+ /** A new pre-mortem started now: answers show together five minutes on. */
3812
+ function newPremortem(startedById, now) {
3813
+ return {
3814
+ startedAt: now.toISOString(),
3815
+ startedById,
3816
+ revealAt: new Date(now.getTime() + 5 * 6e4).toISOString()
3817
+ };
3818
+ }
3819
+ /**
3820
+ * The question, set just after the project's end date: "It's December.
3821
+ * Platform rebuild failed. What happened?" A project already past its end
3822
+ * date (or with none) is asked about three months on from now instead.
3823
+ */
3824
+ function premortemQuestion(project, now) {
3825
+ const end = project.endDate ? parseIso(project.endDate) : null;
3826
+ const later = new Date(now.getFullYear(), now.getMonth() + 3, 1);
3827
+ const when = end && !Number.isNaN(end.getTime()) && end.getTime() > now.getTime() ? new Date(end.getFullYear(), end.getMonth() + (end.getDate() > 20 ? 1 : 0), 1) : later;
3828
+ return `It's ${format(when, "MMMM")}${when.getFullYear() === now.getFullYear() ? "" : ` ${when.getFullYear()}`}. ${project.name.trim() || "This project"} failed. What happened?`;
3829
+ }
3830
+ /** This project's pre-mortem answers, oldest first. */
3831
+ function answersOf(reflections, projectId) {
3832
+ return (reflections ?? []).filter((r) => r.projectId === projectId && r.kind === "premortem").sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3833
+ }
3834
+ /** What one person may see: their own answers always; everyone's once they show. */
3835
+ function visibleAnswers(answers, phase, personId) {
3836
+ return phase === "revealed" ? [...answers] : answers.filter((a) => a.authorId === personId);
3837
+ }
3838
+ /** How the answers came out: "3 added to Risks & issues · 2 merged as duplicates". */
3839
+ function outcomeText(answers) {
3840
+ const raised = answers.filter((a) => a.raidId && !a.merged).length;
3841
+ const merged = answers.filter((a) => a.raidId && a.merged).length;
3842
+ const skipped = answers.filter((a) => a.skipped && !a.raidId).length;
3843
+ return [
3844
+ raised ? `${raised} added to Risks & issues` : "",
3845
+ merged ? `${merged} merged as duplicate${merged === 1 ? "" : "s"}` : "",
3846
+ skipped ? `${skipped} left out` : ""
3847
+ ].filter(Boolean).join(" · ");
3848
+ }
3849
+ /**
3850
+ * A risk's title from an answer: its first sentence, kept under 120
3851
+ * characters at a word boundary, without a closing full stop.
3852
+ */
3853
+ function riskTitle(answer) {
3854
+ const flat = answer.replace(/\s+/g, " ").trim();
3855
+ const first = /^(.+?[.!?])(\s|$)/.exec(flat)?.[1] ?? flat;
3856
+ let title = first.length <= 120 ? first : `${first.slice(0, 117).replace(/\s+\S*$/, "")}…`;
3857
+ title = title.replace(/\.$/, "");
3858
+ return title;
3859
+ }
3860
+ /**
3861
+ * The risk an answer becomes: on its project, open, unrated, with the whole
3862
+ * answer as what could happen. Scoped to the project alone, like a risk
3863
+ * logged on the project page; the program and portfolio roll it up from there.
3864
+ */
3865
+ function riskFromAnswer(answer, project) {
3866
+ return {
3867
+ kind: "risk",
3868
+ title: riskTitle(answer.body),
3869
+ description: `${answer.body.trim()}\n\nRaised in the pre-mortem.`,
3870
+ projectId: project.id,
3871
+ status: "open",
3872
+ ownerId: null,
3873
+ dueDate: null
3874
+ };
3875
+ }
3876
+ /** The risk's description with another answer folded in as the same worry. */
3877
+ function withFoldedAnswer(description, answer) {
3878
+ const base = description.trim();
3879
+ return `${base}${base ? "\n\n" : ""}Also raised: ${answer.trim()}`;
3880
+ }
3881
+ /** The words that carry an answer's meaning, crudely singular. */
3882
+ function contentWords(text) {
3883
+ const words = text.toLowerCase().replace(/[’']/g, "").split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 2 && !STOP.has(w)).map((w) => w.length > 4 && w.endsWith("s") && !w.endsWith("ss") ? w.slice(0, -1) : w);
3884
+ return new Set(words);
3885
+ }
3886
+ /** True when two answers name the same worry: most of their meaningful words are shared. */
3887
+ function looksAlike(a, b) {
3888
+ const x = contentWords(a);
3889
+ const y = contentWords(b);
3890
+ if (x.size === 0 || y.size === 0) return false;
3891
+ let shared = 0;
3892
+ for (const w of x) if (y.has(w)) shared += 1;
3893
+ if (shared < 2) return false;
3894
+ return shared / Math.min(x.size, y.size) >= .5;
3895
+ }
3896
+ /**
3897
+ * The risk an answer probably repeats: one raised from an earlier answer in
3898
+ * this pre-mortem whose words it mostly shares. Null when none does.
3899
+ */
3900
+ function likelySame(answer, answers, risks) {
3901
+ const raised = new Map(risks.map((r) => [r.id, r]));
3902
+ for (const other of answers) {
3903
+ if (other.id === answer.id || !other.raidId || other.merged) continue;
3904
+ const risk = raised.get(other.raidId);
3905
+ if (risk && looksAlike(answer.body, other.body)) return risk;
3906
+ }
3907
+ return null;
3908
+ }
3909
+ /** Whether a project still looks new enough for the pre-mortem to be the obvious next step. */
3910
+ function invitesPremortem(project, openRisks, today) {
3911
+ if (project.premortem || project.health === "done") return false;
3912
+ if (openRisks > 0) return false;
3913
+ const start = project.startDate?.slice(0, 10) ?? today;
3914
+ const started = parseIso(start);
3915
+ return (parseIso(today).getTime() - started.getTime()) / 864e5 <= 30;
3916
+ }
3917
+ var PREMORTEM_MINUTES, isPending, STOP;
3918
+ var init_premortem = __esmMin((() => {
3919
+ init_date_text();
3920
+ PREMORTEM_MINUTES = 5;
3921
+ isPending = (a) => !a.raidId && !a.skipped;
3922
+ STOP = new Set("the a an and or but of to in on for with at by from into onto was were be been being is are it its this that these those we our us they them their he she his her i my me you your had has have did do does not no so too very just than then there here what which who because about after before over under again more most some any all each other only own same can could would should will got get project failed happened went".split(" "));
3923
+ }));
3924
+
3784
3925
  //#endregion
3785
3926
  //#region src/tui/model.ts
3786
3927
  init_date_fns();
@@ -3792,6 +3933,7 @@ init_search();
3792
3933
  init_spend();
3793
3934
  init_task_keys();
3794
3935
  init_work();
3936
+ init_premortem();
3795
3937
  const STATUS_LABEL = {
3796
3938
  backlog: "Backlog",
3797
3939
  todo: "To do",
@@ -4177,6 +4319,214 @@ function markdownToPlain(md) {
4177
4319
  function healthMoves(history, today, max = 3) {
4178
4320
  return [...history ?? []].slice(-max).reverse().map((h) => `${shortDay(h.at.slice(0, 10), today).padEnd(7)} ${HEALTH_LABEL[h.health]}${h.override ? ` (set by hand${h.note ? `: ${h.note}` : ""})` : ""}`);
4179
4321
  }
4322
+ /**
4323
+ * A project's pre-mortem for the terminal: whether one runs, the question,
4324
+ * the time left while answers are private, the answers this person may see
4325
+ * and how they came out. The website's PremortemCard, as lines.
4326
+ */
4327
+ function premortemOf(data, project, me, now) {
4328
+ const phase = premortemPhase(project, now);
4329
+ if (phase === "none") return {
4330
+ phase,
4331
+ question: null,
4332
+ left: 0,
4333
+ rows: [],
4334
+ outcome: "",
4335
+ total: 0
4336
+ };
4337
+ const answers = answersOf(data.reflections, project.id);
4338
+ const risks = data.raidItems.filter((r) => r.projectId === project.id);
4339
+ const raised = byId(risks);
4340
+ const rows = visibleAnswers(answers, phase, me).map((a) => {
4341
+ const risk = a.raidId ? raised.get(a.raidId) ?? null : null;
4342
+ return {
4343
+ answer: a,
4344
+ mine: a.authorId === me,
4345
+ tag: a.raidId ? a.merged ? "Merged" : "Risk" : a.skipped ? "Left out" : null,
4346
+ risk,
4347
+ same: phase === "revealed" && isPending(a) ? likelySame(a, answers, risks) : null
4348
+ };
4349
+ });
4350
+ return {
4351
+ phase,
4352
+ question: premortemQuestion(project, new Date(project.premortem.startedAt)),
4353
+ left: secondsLeft(project.premortem, now),
4354
+ rows,
4355
+ outcome: phase === "revealed" ? outcomeText(answers) : "",
4356
+ total: answers.length
4357
+ };
4358
+ }
4359
+ /** The pre-mortem's state in a few words for the project's header, or null when there's nothing to say. */
4360
+ function premortemNote(data, project, now, today) {
4361
+ const phase = premortemPhase(project, now);
4362
+ if (phase === "none") {
4363
+ const open = data.raidItems.filter((r) => r.projectId === project.id && isActive(r)).length;
4364
+ return invitesPremortem(project, open, today) ? "P runs a pre-mortem" : null;
4365
+ }
4366
+ if (phase === "answering") return `pre-mortem: answers show in ${clockText(secondsLeft(project.premortem, now))} · P`;
4367
+ const pending = answersOf(data.reflections, project.id).filter(isPending).length;
4368
+ return pending ? `pre-mortem: ${pending} to sort · P` : "pre-mortem · P";
4369
+ }
4370
+ const ROLE_LABEL = {
4371
+ owner: "Owner",
4372
+ member: "Can edit",
4373
+ viewer: "View only"
4374
+ };
4375
+ /** The address without its scheme: harborppm.com/share/… */
4376
+ const bare = (url) => url.replace(/^https?:\/\//, "");
4377
+ /**
4378
+ * Everything the Crew screen lists, in the website's words: who's aboard,
4379
+ * and for the owner the letters still out, share links with who gets their
4380
+ * Monday update, and intake forms; then the person's own connected apps,
4381
+ * email choices, and how the harbor is kept.
4382
+ */
4383
+ function crewRows(crew, data, now) {
4384
+ const owner = crew.harbor.role === "owner";
4385
+ const rows = [];
4386
+ const nameWidth = Math.min(24, Math.max(0, ...crew.members.map((m) => (m.name ?? "").length)));
4387
+ rows.push({
4388
+ kind: "heading",
4389
+ text: `Aboard · ${crew.members.length}`
4390
+ });
4391
+ for (const m of crew.members) rows.push({
4392
+ kind: "member",
4393
+ text: (m.name || m.email || "Someone").padEnd(nameWidth),
4394
+ detail: [
4395
+ ROLE_LABEL[m.role] ?? m.role,
4396
+ m.email,
4397
+ m.userId === crew.you ? "you" : null
4398
+ ].filter(Boolean).join(" · "),
4399
+ userId: m.userId,
4400
+ role: m.role,
4401
+ changeable: owner && m.role !== "owner"
4402
+ });
4403
+ if (!owner) {
4404
+ const by = crew.members.find((m) => m.role === "owner");
4405
+ rows.push({
4406
+ kind: "note",
4407
+ text: `${by?.name || "The owner"} invites people and makes share links and intake forms.`
4408
+ });
4409
+ } else {
4410
+ rows.push({
4411
+ kind: "heading",
4412
+ text: "Letters still out"
4413
+ });
4414
+ if (!crew.invites.length) rows.push({
4415
+ kind: "note",
4416
+ text: "None. i invites someone by email."
4417
+ });
4418
+ for (const i of crew.invites) rows.push({
4419
+ kind: "invite",
4420
+ text: i.email || i.name || "A link for anyone",
4421
+ detail: `${ROLE_LABEL[i.role]} · asked ${timeAgo(new Date(i.createdAt), now)}`,
4422
+ token: i.token,
4423
+ url: i.url
4424
+ });
4425
+ rows.push({
4426
+ kind: "heading",
4427
+ text: "Share links: a portfolio, read-only, for anyone with the link"
4428
+ });
4429
+ if (!crew.shares.length) rows.push({
4430
+ kind: "note",
4431
+ text: "None. s makes one for a portfolio."
4432
+ });
4433
+ const portfolios = byId(data.portfolios);
4434
+ for (const s of crew.shares) {
4435
+ const getting = s.subscribers.filter((x) => !x.stopped).length;
4436
+ rows.push({
4437
+ kind: "share",
4438
+ text: portfolios.get(s.portfolioId)?.name ?? "A portfolio no longer here",
4439
+ detail: `${bare(s.url)} · ${getting ? `${getting} get the Monday update` : "no Monday update yet"}`,
4440
+ token: s.token,
4441
+ url: s.url
4442
+ });
4443
+ for (const x of s.subscribers) rows.push({
4444
+ kind: "subscriber",
4445
+ text: ` ${x.email}`,
4446
+ detail: x.stopped ? "stopped it themselves" : "gets the Monday update",
4447
+ token: s.token,
4448
+ email: x.email,
4449
+ stopped: x.stopped
4450
+ });
4451
+ }
4452
+ rows.push({
4453
+ kind: "heading",
4454
+ text: "Intake forms: anyone with the link adds a request to a project"
4455
+ });
4456
+ if (!crew.forms.length) rows.push({
4457
+ kind: "note",
4458
+ text: "None. f makes one for a project."
4459
+ });
4460
+ const projects = byId(data.projects);
4461
+ for (const f of crew.forms) rows.push({
4462
+ kind: "form",
4463
+ text: projects.get(f.projectId)?.name ?? "A project no longer here",
4464
+ detail: bare(f.url),
4465
+ token: f.token,
4466
+ url: f.url
4467
+ });
4468
+ }
4469
+ rows.push({
4470
+ kind: "heading",
4471
+ text: "Apps you connected"
4472
+ });
4473
+ if (!crew.apps.length) rows.push({
4474
+ kind: "note",
4475
+ text: "None."
4476
+ });
4477
+ for (const a of crew.apps) rows.push({
4478
+ kind: "app",
4479
+ text: a.name,
4480
+ detail: [
4481
+ a.fullAccess ? "full access" : a.canWrite ? "reads and makes changes" : "reads only",
4482
+ a.returnsTo,
4483
+ a.active ? null : "signed out",
4484
+ a.thisApp ? "this app" : null
4485
+ ].filter(Boolean).join(" · "),
4486
+ clientId: a.clientId,
4487
+ name: a.name,
4488
+ thisApp: a.thisApp
4489
+ });
4490
+ rows.push({
4491
+ kind: "heading",
4492
+ text: "Your email"
4493
+ });
4494
+ rows.push({
4495
+ kind: "email",
4496
+ text: "Monday digest of the week ahead",
4497
+ detail: crew.email.weeklyDigest ? "on" : "off",
4498
+ which: "weeklyDigest",
4499
+ on: crew.email.weeklyDigest
4500
+ });
4501
+ rows.push({
4502
+ kind: "email",
4503
+ text: "When a task is assigned to you",
4504
+ detail: crew.email.assignments ? "on" : "off",
4505
+ which: "assignments",
4506
+ on: crew.email.assignments
4507
+ });
4508
+ rows.push({
4509
+ kind: "heading",
4510
+ text: "Keep"
4511
+ });
4512
+ rows.push({
4513
+ kind: "keep",
4514
+ text: keepText(crew.keep, now),
4515
+ detail: bare(crew.keepUrl),
4516
+ url: crew.keepUrl
4517
+ });
4518
+ return rows;
4519
+ }
4520
+ /** How the harbor is kept, in a few words. */
4521
+ function keepText(keep, now) {
4522
+ if (keep.kept) return `Kept, paid ${keep.interval === "year" ? "yearly" : "monthly"}`;
4523
+ if (keep.paused) return "The free trial has ended: view only until someone keeps it";
4524
+ if (keep.trialEndsAt) {
4525
+ const days = trialDaysLeft(keep.trialEndsAt, now);
4526
+ return `Free trial, ${days} day${days === 1 ? "" : "s"} left`;
4527
+ }
4528
+ return "Free";
4529
+ }
4180
4530
 
4181
4531
  //#endregion
4182
4532
  //#region src/tui/theme.ts
@@ -6003,6 +6353,289 @@ function CalendarView({ active, width, height, go }) {
6003
6353
  });
6004
6354
  }
6005
6355
 
6356
+ //#endregion
6357
+ //#region src/tui/views/crew.tsx
6358
+ /**
6359
+ * The Crew screen: who's aboard and what they can do, and for the owner the
6360
+ * letters still out, share links (with their Monday update) and intake
6361
+ * forms; then your own connected apps, email choices and how the harbor is
6362
+ * kept. None of it is in the harbor's document, so it's asked of Harbor on
6363
+ * opening and after each change (mcp/app.server.ts answerCrew).
6364
+ */
6365
+ init_clock();
6366
+ const actionable = (r) => r.kind !== "heading" && r.kind !== "note";
6367
+ /** What the chosen line's keys do, for the line along the bottom. */
6368
+ function rowHints(r, owner) {
6369
+ if (!r) return [];
6370
+ switch (r.kind) {
6371
+ case "member": return r.changeable ? ["Enter change role"] : [];
6372
+ case "invite":
6373
+ case "form": return ["y copy link", "w withdraw"];
6374
+ case "share": return [
6375
+ "y copy link",
6376
+ "m add to the Monday update",
6377
+ "w withdraw"
6378
+ ];
6379
+ case "subscriber": return r.stopped ? [] : ["w take off the update"];
6380
+ case "app": return r.thisApp ? [] : ["d disconnect"];
6381
+ case "email": return [`Enter turn ${r.on ? "off" : "on"}`];
6382
+ case "keep": return [owner ? "Enter open Keep in the browser" : "Enter open in the browser"];
6383
+ default: return [];
6384
+ }
6385
+ }
6386
+ function CrewView({ active, width, height, go }) {
6387
+ const { transport, browse } = useDeps();
6388
+ const { stdout } = useStdout();
6389
+ const workspaceId = useHarbor((s) => s.workspaceId);
6390
+ const portfolios = useHarbor((s) => s.portfolios);
6391
+ const projects = useHarbor((s) => s.projects);
6392
+ const [crew, setCrew] = useState(null);
6393
+ const [error, setError] = useState(null);
6394
+ const [busy, setBusy] = useState(false);
6395
+ const [at, setAt] = useState(0);
6396
+ useEffect(() => {
6397
+ if (!workspaceId) return;
6398
+ let live = true;
6399
+ setError(null);
6400
+ transport.crew(workspaceId).then((c) => live && setCrew(c), (e) => live && setError(e.message));
6401
+ return () => {
6402
+ live = false;
6403
+ };
6404
+ }, [transport, workspaceId]);
6405
+ const rows = useMemo(() => crew ? crewRows(crew, {
6406
+ portfolios,
6407
+ projects
6408
+ }, clockNow()) : [], [
6409
+ crew,
6410
+ portfolios,
6411
+ projects
6412
+ ]);
6413
+ const picks = rows.map((r, i) => actionable(r) ? i : -1).filter((i) => i >= 0);
6414
+ const chosen = picks.length ? picks[Math.min(at, picks.length - 1)] : -1;
6415
+ const row = chosen >= 0 ? rows[chosen] : void 0;
6416
+ const owner = crew?.harbor.role === "owner";
6417
+ /** One change, then the screen as Harbor now has it; `said` words the notice. */
6418
+ const run = (act, said) => {
6419
+ if (!workspaceId || busy) return;
6420
+ setBusy(true);
6421
+ transport.crew(workspaceId, act).then((c) => {
6422
+ setCrew(c);
6423
+ const words = said?.(c);
6424
+ if (words) go.notify(words);
6425
+ }).catch((e) => go.notify(e.message)).finally(() => setBusy(false));
6426
+ };
6427
+ const copy = (url, what) => {
6428
+ stdout.write(copySequence(url));
6429
+ go.notify(`Copied the ${what}, if this terminal lets apps copy: ${url}`);
6430
+ };
6431
+ const open = (url) => {
6432
+ if (browse) {
6433
+ browse(url);
6434
+ go.notify(`Opening ${url.replace(/^https?:\/\//, "")} in your browser.`);
6435
+ } else go.notify(`Open ${url} in a browser.`);
6436
+ };
6437
+ const invite = () => go.ask("Invite by email", (address) => {
6438
+ const email = address.trim();
6439
+ if (!email || !workspaceId) return;
6440
+ go.pick(`What can ${email} do?`, [{
6441
+ label: "Can edit",
6442
+ value: "member",
6443
+ hint: "change plans, move work"
6444
+ }, {
6445
+ label: "View only",
6446
+ value: "viewer",
6447
+ hint: "read and comment"
6448
+ }], (role) => {
6449
+ setBusy(true);
6450
+ transport.invite(workspaceId, email, role).then(async (made) => {
6451
+ if (made.link) {
6452
+ stdout.write(copySequence(made.link));
6453
+ go.notify(`Your own address isn't confirmed yet, so the letter wasn't mailed. Its link is copied: ${made.link}`);
6454
+ } else go.notify(`${made.again ? "Asked again" : "Invited"} ${made.email}. The letter is on its way${made.again ? "; the earlier link stopped working" : ""}.`);
6455
+ setCrew(await transport.crew(workspaceId));
6456
+ }).catch((e) => go.notify(e.message)).finally(() => setBusy(false));
6457
+ });
6458
+ });
6459
+ useInput((input, key) => {
6460
+ if (key.upArrow || input === "k") return setAt((i) => Math.max(0, i - 1));
6461
+ if (key.downArrow || input === "j") return setAt((i) => Math.min(picks.length - 1, i + 1));
6462
+ if (key.pageDown) return setAt((i) => Math.min(picks.length - 1, i + 10));
6463
+ if (key.pageUp) return setAt((i) => Math.max(0, i - 10));
6464
+ if (!crew) return;
6465
+ if (owner && input === "i") return invite();
6466
+ if (owner && input === "s") {
6467
+ if (!portfolios.length) return go.notify("There's no portfolio to share yet.");
6468
+ return go.pick("Share which portfolio, read-only?", portfolios.map((p) => ({
6469
+ label: p.name,
6470
+ value: p.id
6471
+ })), (id) => run({
6472
+ act: "share",
6473
+ portfolioId: id
6474
+ }, (c) => {
6475
+ const url = c.url;
6476
+ if (url) stdout.write(copySequence(url));
6477
+ return url ? `Made a share link and copied it: ${url}` : null;
6478
+ }));
6479
+ }
6480
+ if (owner && input === "f") {
6481
+ if (!projects.length) return go.notify("There's no project for a form yet.");
6482
+ return go.pick("A form that adds requests to which project?", projects.map((p) => ({
6483
+ label: p.name,
6484
+ value: p.id
6485
+ })), (id) => run({
6486
+ act: "intake",
6487
+ projectId: id
6488
+ }, (c) => {
6489
+ const url = c.url;
6490
+ if (url) stdout.write(copySequence(url));
6491
+ return url ? `Made an intake form and copied its link: ${url}` : null;
6492
+ }));
6493
+ }
6494
+ if (!row) return;
6495
+ if (key.return) {
6496
+ if (row.kind === "member" && row.changeable) return go.pick(`What can ${row.text.trim()} do?`, [{
6497
+ label: "Can edit",
6498
+ value: "member",
6499
+ hint: "change plans, move work"
6500
+ }, {
6501
+ label: "View only",
6502
+ value: "viewer",
6503
+ hint: "read and comment"
6504
+ }], (role) => run({
6505
+ act: "role",
6506
+ userId: row.userId,
6507
+ role
6508
+ }, () => `${row.text.trim()}: ${ROLE_LABEL[role]}.`));
6509
+ if (row.kind === "email") return run({
6510
+ act: "email",
6511
+ [row.which]: !row.on
6512
+ });
6513
+ if (row.kind === "keep") return open(row.url);
6514
+ if (row.kind === "invite" || row.kind === "share" || row.kind === "form") return copy(row.url, "link");
6515
+ return;
6516
+ }
6517
+ if (input === "y" && (row.kind === "invite" || row.kind === "share" || row.kind === "form")) return copy(row.url, "link");
6518
+ if (input === "o" && row.kind === "keep") return open(row.url);
6519
+ if (input === "m" && row.kind === "share") return go.ask("Who gets the Monday update? (addresses, commas between)", (text) => run({
6520
+ act: "subscribe",
6521
+ token: row.token,
6522
+ emails: text
6523
+ }, (c) => {
6524
+ const r = c;
6525
+ if (r.needsVerifiedEmail) return "Confirm your own address first (the letter in your inbox), then add people.";
6526
+ return [
6527
+ r.added?.length ? `Added ${r.added.join(", ")}.` : "Nobody added.",
6528
+ r.alreadyThere ? `${r.alreadyThere} already get it.` : "",
6529
+ r.invalid?.length ? `Not addresses: ${r.invalid.join(", ")}.` : "",
6530
+ r.overLimit ? `${r.overLimit} over the limit of 25.` : ""
6531
+ ].filter(Boolean).join(" ");
6532
+ }));
6533
+ if (input === "w") {
6534
+ if (row.kind === "invite") return go.confirm(`Withdraw the letter to ${row.text}? Its link stops working.`, () => run({
6535
+ act: "withdraw-invite",
6536
+ token: row.token
6537
+ }, () => "Withdrawn."), "Withdraw");
6538
+ if (row.kind === "share") return go.confirm(`Turn off the share link to ${row.text}? Anyone holding it loses the page, and its Monday update stops.`, () => run({
6539
+ act: "withdraw-share",
6540
+ token: row.token
6541
+ }, () => "Turned off."), "Turn it off");
6542
+ if (row.kind === "form") return go.confirm(`Turn off the intake form for ${row.text}? Its link stops taking requests.`, () => run({
6543
+ act: "withdraw-intake",
6544
+ token: row.token
6545
+ }, () => "Turned off."), "Turn it off");
6546
+ if (row.kind === "subscriber" && !row.stopped) return go.confirm(`Take ${row.email} off the Monday update?`, () => run({
6547
+ act: "unsubscribe",
6548
+ token: row.token,
6549
+ email: row.email
6550
+ }, () => `${row.email} won't get it any more.`), "Take them off");
6551
+ }
6552
+ if (input === "d" && row.kind === "app") {
6553
+ if (row.thisApp) return go.notify("That's this app. harbor logout disconnects it from here.");
6554
+ return go.confirm(`Disconnect ${row.name}? It stops reading Harbor as you until you connect it again.`, () => run({
6555
+ act: "disconnect",
6556
+ clientId: row.clientId
6557
+ }, () => `Disconnected ${row.name}.`), "Disconnect");
6558
+ }
6559
+ }, { isActive: active });
6560
+ if (error) return /* @__PURE__ */ jsxs(Box, {
6561
+ flexDirection: "column",
6562
+ width,
6563
+ height,
6564
+ paddingX: 1,
6565
+ children: [/* @__PURE__ */ jsx(Heading, { children: "Crew" }), /* @__PURE__ */ jsx(Text, {
6566
+ color: theme.muted,
6567
+ wrap: "wrap",
6568
+ children: error
6569
+ })]
6570
+ });
6571
+ if (!crew) return /* @__PURE__ */ jsxs(Box, {
6572
+ flexDirection: "column",
6573
+ width,
6574
+ height,
6575
+ paddingX: 1,
6576
+ children: [/* @__PURE__ */ jsx(Heading, { children: "Crew" }), /* @__PURE__ */ jsx(Text, {
6577
+ color: theme.muted,
6578
+ children: "Asking Harbor…"
6579
+ })]
6580
+ });
6581
+ const hints = [...rowHints(row, owner), ...owner ? [
6582
+ "i invite",
6583
+ "s share link",
6584
+ "f intake form"
6585
+ ] : []];
6586
+ const view = windowOf(rows, Math.max(0, chosen), Math.max(3, height - 3));
6587
+ return /* @__PURE__ */ jsxs(Box, {
6588
+ flexDirection: "column",
6589
+ width,
6590
+ height,
6591
+ paddingX: 1,
6592
+ children: [
6593
+ /* @__PURE__ */ jsx(Heading, {
6594
+ right: owner ? "you're the owner" : `you ${crew.harbor.role === "viewer" ? "can view" : "can edit"}`,
6595
+ children: "Crew"
6596
+ }),
6597
+ view.items.map((r, n) => {
6598
+ const i = view.start + n;
6599
+ if (r.kind === "heading") return /* @__PURE__ */ jsx(Text, {
6600
+ bold: true,
6601
+ wrap: "truncate-end",
6602
+ children: r.text
6603
+ }, i);
6604
+ if (r.kind === "note") return /* @__PURE__ */ jsxs(Text, {
6605
+ color: theme.muted,
6606
+ wrap: "truncate-end",
6607
+ children: [" ", r.text]
6608
+ }, i);
6609
+ const on = active && i === chosen;
6610
+ return /* @__PURE__ */ jsxs(Text, {
6611
+ color: on ? theme.selected : void 0,
6612
+ bold: on,
6613
+ wrap: "truncate-end",
6614
+ children: [
6615
+ on ? "› " : " ",
6616
+ r.text,
6617
+ /* @__PURE__ */ jsxs(Text, {
6618
+ color: theme.muted,
6619
+ children: [" ", r.detail]
6620
+ })
6621
+ ]
6622
+ }, i);
6623
+ }),
6624
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
6625
+ crew.pretend ? /* @__PURE__ */ jsx(Text, {
6626
+ color: theme.muted,
6627
+ wrap: "truncate-end",
6628
+ children: "This crew is pretend: nothing here is mailed or kept."
6629
+ }) : null,
6630
+ /* @__PURE__ */ jsx(Text, {
6631
+ color: theme.muted,
6632
+ wrap: "truncate-end",
6633
+ children: busy ? "Asking Harbor…" : hints.join(" · ")
6634
+ })
6635
+ ]
6636
+ });
6637
+ }
6638
+
6006
6639
  //#endregion
6007
6640
  //#region src/lib/hill.ts
6008
6641
  /** A position kept on the scale, in whole steps. */
@@ -8729,7 +9362,7 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
8729
9362
  const HELP = [
8730
9363
  ["0–9 / Tab", "the screens / between the sidebar and a screen"],
8731
9364
  [":", "anything: a screen, something new, a name"],
8732
- ["/ / H", "find a task by key or words / switch harbor"],
9365
+ ["/ / H / C", "find a task / switch harbor / the crew"],
8733
9366
  ["↑↓ or j k", "move; ←→ or h l across columns and lists"],
8734
9367
  ["Enter / Esc", "open / back"],
8735
9368
  ["n", "new: a task, or what the screen lists"],
@@ -8738,7 +9371,7 @@ const HELP = [
8738
9371
  ["x / d", "mark done / delete (asks first)"],
8739
9372
  ["u / r", "undo your last change / retry saving"],
8740
9373
  ["v", "switch layout: board, list, budget, by project"],
8741
- ["m / R", "new milestone on a program / log a risk"],
9374
+ ["m / R / P", "a milestone / a risk / a project's pre-mortem"],
8742
9375
  ["D", "show or hide Done projects and programs"],
8743
9376
  ["c / a / C", "on a task: comment / add a step / change yours"],
8744
9377
  ["f / c", "on Today: focus on a task / check in"],
@@ -8906,6 +9539,195 @@ function Palette({ width, base, open, onClose }) {
8906
9539
  });
8907
9540
  }
8908
9541
 
9542
+ //#endregion
9543
+ //#region src/tui/views/premortem.tsx
9544
+ /**
9545
+ * A project's pre-mortem in the terminal, as the website's card runs it:
9546
+ * start one, answer alone while the clock runs, then turn each answer into
9547
+ * a risk, fold it into one already raised, or leave it out.
9548
+ */
9549
+ init_clock();
9550
+ init_premortem();
9551
+ const cut = (s, n) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
9552
+ function PremortemPanel({ id, width, height, go, active, onClose }) {
9553
+ const { store, today } = useDeps();
9554
+ const project = useHarbor((s) => s.projects.find((p) => p.id === id));
9555
+ const reflections = useHarbor((s) => s.reflections);
9556
+ const raidItems = useHarbor((s) => s.raidItems);
9557
+ const me = useHarbor((s) => s.currentUserId);
9558
+ const viewOnly = useHarbor((s) => s.myRole === "viewer" || Boolean(s.keepPaused));
9559
+ const [at, setAt] = useState(0);
9560
+ const [, setBeat] = useState(0);
9561
+ const pm = project ? premortemOf({
9562
+ reflections,
9563
+ raidItems
9564
+ }, project, me, clockNow()) : null;
9565
+ const ticking = pm?.phase === "answering";
9566
+ useEffect(() => {
9567
+ if (!ticking) return;
9568
+ const t = setInterval(() => setBeat((n) => n + 1), 1e3);
9569
+ return () => clearInterval(t);
9570
+ }, [ticking]);
9571
+ const rows = pm?.rows ?? [];
9572
+ const row = rows[Math.min(at, rows.length - 1)];
9573
+ const s = () => store.getState();
9574
+ const answer = () => go.ask("Your answer: what went wrong?", (text) => {
9575
+ if (text.trim() && s().answerPremortem(id, text)) {
9576
+ setAt(rows.length);
9577
+ go.notify(pm?.phase === "answering" ? "Added. Nobody sees it until the time is up." : "Added.");
9578
+ }
9579
+ });
9580
+ useInput((input, key) => {
9581
+ if (key.escape) return onClose();
9582
+ if (!project || !pm) return;
9583
+ if (key.upArrow || input === "k") return setAt((i) => step(i, -1, rows.length));
9584
+ if (key.downArrow || input === "j") return setAt((i) => step(i, 1, rows.length));
9585
+ if (key.return && row?.risk) return go.openPanel("raid", row.risk.id);
9586
+ if (viewOnly) return;
9587
+ if (pm.phase === "none") {
9588
+ if (input === "s") s().startPremortem(id);
9589
+ return;
9590
+ }
9591
+ if (input === "a") return answer();
9592
+ if (pm.phase === "answering") {
9593
+ if (input === "S") return s().showPremortemAnswers(id);
9594
+ if (input === "t" && row?.mine) return go.confirm(`Take back “${cut(row.answer.body.replace(/\s+/g, " "), 40)}”? u undoes it.`, () => s().deleteReflection(row.answer.id), "Take it back");
9595
+ return;
9596
+ }
9597
+ if (!row) return;
9598
+ const pending = !row.answer.raidId && !row.answer.skipped;
9599
+ if (input === "r" && pending) {
9600
+ if (s().raisePremortemAnswer(row.answer.id)) go.notify("Added to Risks. Enter opens it.");
9601
+ } else if (input === "m" && pending && row.same) {
9602
+ s().foldPremortemAnswer(row.answer.id, row.same.id);
9603
+ go.notify(`Folded into “${cut(row.same.title, 40)}”.`);
9604
+ } else if (input === "l" && !row.answer.raidId) s().skipPremortemAnswer(row.answer.id, !row.answer.skipped);
9605
+ }, { isActive: active !== false });
9606
+ if (!project || !pm) return null;
9607
+ const frame = (children, hints) => /* @__PURE__ */ jsxs(Box, {
9608
+ flexDirection: "column",
9609
+ width,
9610
+ height,
9611
+ borderStyle: "round",
9612
+ borderColor: theme.border,
9613
+ paddingX: 1,
9614
+ children: [
9615
+ /* @__PURE__ */ jsxs(Text, {
9616
+ wrap: "truncate-end",
9617
+ children: [
9618
+ /* @__PURE__ */ jsx(Text, {
9619
+ color: theme.coral,
9620
+ children: "Pre-mortem"
9621
+ }),
9622
+ " ",
9623
+ /* @__PURE__ */ jsx(Text, {
9624
+ bold: true,
9625
+ children: project.name
9626
+ })
9627
+ ]
9628
+ }),
9629
+ /* @__PURE__ */ jsx(Text, { children: " " }),
9630
+ children,
9631
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
9632
+ /* @__PURE__ */ jsx(Text, {
9633
+ color: theme.muted,
9634
+ wrap: "truncate-end",
9635
+ children: hints
9636
+ })
9637
+ ]
9638
+ });
9639
+ if (pm.phase === "none") {
9640
+ const ends = project.endDate ? `after ${shortDay(project.endDate, today())}` : "three months from now";
9641
+ return frame(/* @__PURE__ */ jsxs(Fragment, { children: [
9642
+ /* @__PURE__ */ jsxs(Text, {
9643
+ wrap: "wrap",
9644
+ children: [
9645
+ "Imagine it's ",
9646
+ ends,
9647
+ " and this project failed. Everyone writes what went wrong, alone, for five minutes. Then the answers show together, and each one can go straight into the register."
9648
+ ]
9649
+ }),
9650
+ /* @__PURE__ */ jsx(Text, { children: " " }),
9651
+ /* @__PURE__ */ jsx(Text, {
9652
+ color: theme.muted,
9653
+ children: viewOnly ? "You can view this harbor, so you can't start one." : "s starts it now."
9654
+ })
9655
+ ] }), viewOnly ? "Esc close" : "s start · Esc close");
9656
+ }
9657
+ const selected = row?.answer.body ?? "";
9658
+ const fullRows = Math.min(4, Math.max(1, Math.ceil(selected.length / Math.max(20, width - 4))));
9659
+ const listRows = Math.max(3, height - 2 - 2 - 2 - 2 - fullRows - 2);
9660
+ const view = windowOf(rows, Math.min(at, rows.length - 1), listRows);
9661
+ const hints = pm.phase === "answering" ? [
9662
+ "↑↓ answer",
9663
+ viewOnly ? null : "a answer",
9664
+ viewOnly || !row?.mine ? null : "t take back",
9665
+ viewOnly ? null : "S show them now",
9666
+ "Esc close"
9667
+ ] : [
9668
+ "↑↓ answer",
9669
+ row?.risk ? "Enter open the risk" : null,
9670
+ viewOnly || !row || row.answer.raidId || row.answer.skipped ? null : "r add as risk",
9671
+ viewOnly || !row?.same || row.answer.raidId || row.answer.skipped ? null : `m same as “${cut(row.same.title, 24)}”`,
9672
+ viewOnly || !row || row.answer.raidId ? null : row.answer.skipped ? "l put back" : "l leave out",
9673
+ viewOnly ? null : "a another answer",
9674
+ "Esc close"
9675
+ ];
9676
+ return frame(/* @__PURE__ */ jsxs(Fragment, { children: [
9677
+ /* @__PURE__ */ jsx(Text, {
9678
+ bold: true,
9679
+ wrap: "truncate-end",
9680
+ children: pm.question
9681
+ }),
9682
+ /* @__PURE__ */ jsx(Text, {
9683
+ color: theme.muted,
9684
+ wrap: "truncate-end",
9685
+ children: pm.phase === "answering" ? `Answers show together in ${clockText(pm.left)}. Until then you see only yours.` : `${pm.total} answer${pm.total === 1 ? "" : "s"}${pm.outcome ? ` · ${pm.outcome}` : ""}`
9686
+ }),
9687
+ /* @__PURE__ */ jsx(Text, { children: " " }),
9688
+ rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
9689
+ color: theme.muted,
9690
+ children: pm.phase === "answering" ? viewOnly ? "No answers of yours. The crew's show when the time is up." : "No answers of yours yet. a writes one: a sentence or two for each thing that could sink it." : "Nobody answered."
9691
+ }) : null,
9692
+ view.items.map((r, n) => {
9693
+ const on = view.start + n === Math.min(at, rows.length - 1);
9694
+ return /* @__PURE__ */ jsxs(Text, {
9695
+ wrap: "truncate-end",
9696
+ color: on ? theme.selected : r.answer.skipped && !r.answer.raidId ? theme.muted : void 0,
9697
+ bold: on,
9698
+ children: [
9699
+ on ? "› " : " ",
9700
+ r.tag ? /* @__PURE__ */ jsxs(Text, {
9701
+ color: r.answer.raidId ? theme.coral : theme.muted,
9702
+ children: [r.tag, " "]
9703
+ }) : null,
9704
+ r.answer.body.replace(/\s+/g, " "),
9705
+ r.mine ? /* @__PURE__ */ jsx(Text, {
9706
+ color: theme.muted,
9707
+ children: " · yours"
9708
+ }) : null,
9709
+ r.same ? /* @__PURE__ */ jsxs(Text, {
9710
+ color: theme.warn,
9711
+ children: [
9712
+ " · like “",
9713
+ cut(r.same.title, 30),
9714
+ "”"
9715
+ ]
9716
+ }) : null
9717
+ ]
9718
+ }, r.answer.id);
9719
+ }),
9720
+ row ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: " " }), /* @__PURE__ */ jsx(Box, {
9721
+ height: fullRows,
9722
+ overflow: "hidden",
9723
+ children: /* @__PURE__ */ jsx(Text, {
9724
+ wrap: "wrap",
9725
+ children: selected
9726
+ })
9727
+ })] }) : null
9728
+ ] }), hints.filter(Boolean).join(" · "));
9729
+ }
9730
+
8909
9731
  //#endregion
8910
9732
  //#region src/tui/views/bands.tsx
8911
9733
  /**
@@ -9650,6 +10472,7 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
9650
10472
  init_swatches();
9651
10473
  init_format();
9652
10474
  init_hill();
10475
+ init_clock();
9653
10476
  /** One project: its board (columns side by side) or its list, and the keys to work it. */
9654
10477
  function ProjectView({ projectId, active, width, height, go, onBack }) {
9655
10478
  const { store, today } = useDeps();
@@ -9659,6 +10482,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
9659
10482
  const boardConfigs = useHarbor((s) => s.boardConfigs);
9660
10483
  const reflections = useHarbor((s) => s.reflections);
9661
10484
  const milestones = useHarbor((s) => s.milestones);
10485
+ const raidItems = useHarbor((s) => s.raidItems);
9662
10486
  const project = projects.find((p) => p.id === projectId);
9663
10487
  const data = useMemo(() => ({
9664
10488
  tasks,
@@ -9718,6 +10542,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
9718
10542
  if (input === "e") return go.openPanel("project", projectId);
9719
10543
  if (input === "b") return go.openPanel("board", projectId);
9720
10544
  if (input === "R") return go.create("raid", { projectId });
10545
+ if (input === "P") return go.openPanel("premortem", projectId);
9721
10546
  if (input === "f" && mode !== "budget") return go.pick("Show cards assigned to", [
9722
10547
  {
9723
10548
  label: "Anyone",
@@ -9796,6 +10621,10 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
9796
10621
  const tide = tideLine(data, project, day);
9797
10622
  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);
9798
10623
  const lead = people.find((p) => p.id === project.ownerId);
10624
+ const pmNote = premortemNote({
10625
+ reflections,
10626
+ raidItems
10627
+ }, project, clockNow(), day);
9799
10628
  const opened = freshlyOpened({
9800
10629
  reflections,
9801
10630
  milestones
@@ -9864,17 +10693,24 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
9864
10693
  }),
9865
10694
  /* @__PURE__ */ jsxs(Text, {
9866
10695
  wrap: "truncate-end",
9867
- children: [/* @__PURE__ */ jsx(Text, {
9868
- color: tide?.late ? theme.warn : theme.muted,
9869
- children: tide ? `${tide.text} · ` : ""
9870
- }), /* @__PURE__ */ jsxs(Text, {
9871
- color: isStill(project, day) ? theme.warn : theme.muted,
9872
- children: [
9873
- project.hillAt == null ? "Not on the hill · } puts it on" : `Hill: ${hillText(project).toLowerCase()}`,
9874
- isStill(project, day) ? `, still ${stillFor(project, day)} days` : "",
9875
- project.hillAt == null ? "" : " · { } move it"
9876
- ]
9877
- })]
10696
+ children: [
10697
+ /* @__PURE__ */ jsx(Text, {
10698
+ color: tide?.late ? theme.warn : theme.muted,
10699
+ children: tide ? `${tide.text} · ` : ""
10700
+ }),
10701
+ /* @__PURE__ */ jsxs(Text, {
10702
+ color: isStill(project, day) ? theme.warn : theme.muted,
10703
+ children: [
10704
+ project.hillAt == null ? "Not on the hill · } puts it on" : `Hill: ${hillText(project).toLowerCase()}`,
10705
+ isStill(project, day) ? `, still ${stillFor(project, day)} days` : "",
10706
+ project.hillAt == null ? "" : " · { } move it"
10707
+ ]
10708
+ }),
10709
+ pmNote ? /* @__PURE__ */ jsxs(Text, {
10710
+ color: theme.muted,
10711
+ children: [" · ", pmNote]
10712
+ }) : null
10713
+ ]
9878
10714
  }),
9879
10715
  opened && /* @__PURE__ */ jsxs(Text, {
9880
10716
  wrap: "truncate-end",
@@ -11499,8 +12335,14 @@ const NAV = [
11499
12335
  {
11500
12336
  screen: "brief",
11501
12337
  label: "Brief"
12338
+ },
12339
+ {
12340
+ screen: "crew",
12341
+ label: "Crew"
11502
12342
  }
11503
12343
  ];
12344
+ /** The key that opens a sidebar screen: 1 to 9, 0 for the tenth, C for the crew. */
12345
+ const navKey = (i) => i < 10 ? String((i + 1) % 10) : "C";
11504
12346
  /** The sidebar's width: its longest line ("9 Calendar", ": Anything") with room either side. */
11505
12347
  const NAV_WIDTH = 15;
11506
12348
  /** What the corner says about saving, and in which of the theme's colours. */
@@ -11688,6 +12530,7 @@ function App({ ui }) {
11688
12530
  if (input === ":") return setOverlay({ kind: "palette" });
11689
12531
  if (input === "?") return setOverlay({ kind: "help" });
11690
12532
  if (input === "H") return setOverlay({ kind: "harbors" });
12533
+ if (input === "C") return goScreen("crew");
11691
12534
  if (input === "r") {
11692
12535
  sync.retryNow();
11693
12536
  return notify("Saving now.");
@@ -11887,6 +12730,10 @@ function App({ ui }) {
11887
12730
  id: panel.id,
11888
12731
  ...panelProps
11889
12732
  });
12733
+ else if (panel?.kind === "premortem") panelNode = /* @__PURE__ */ jsx(PremortemPanel, {
12734
+ id: panel.id,
12735
+ ...panelProps
12736
+ });
11890
12737
  const hints = panel ? [["Esc", "back"], ["u", "undo"]] : page?.kind === "project" ? [
11891
12738
  ["←→↑↓", "move"],
11892
12739
  ["Enter", "open"],
@@ -11897,6 +12744,7 @@ function App({ ui }) {
11897
12744
  ["v", "board/list/budget"],
11898
12745
  ["f p", "filter"],
11899
12746
  ["e", "details"],
12747
+ ["P", "pre-mortem"],
11900
12748
  ["Esc", "back"]
11901
12749
  ] : page ? [
11902
12750
  ["↑↓", "move"],
@@ -11941,6 +12789,7 @@ function App({ ui }) {
11941
12789
  else if (screen === "roadmap") screenNode = /* @__PURE__ */ jsx(RoadmapView, { ...viewProps });
11942
12790
  else if (screen === "calendar") screenNode = /* @__PURE__ */ jsx(CalendarView, { ...viewProps });
11943
12791
  else if (screen === "brief") screenNode = /* @__PURE__ */ jsx(BriefView, { ...viewProps });
12792
+ else if (screen === "crew") screenNode = /* @__PURE__ */ jsx(CrewView, { ...viewProps });
11944
12793
  else screenNode = /* @__PURE__ */ jsx(HistoryView, { ...viewProps });
11945
12794
  return /* @__PURE__ */ jsxs(Box, {
11946
12795
  flexDirection: "column",
@@ -12002,7 +12851,7 @@ function App({ ui }) {
12002
12851
  wrap: "truncate-end",
12003
12852
  children: [/* @__PURE__ */ jsxs(Text, {
12004
12853
  color: theme.muted,
12005
- children: [(i + 1) % 10, " "]
12854
+ children: [navKey(i), " "]
12006
12855
  }), x.label]
12007
12856
  }, x.screen);
12008
12857
  }),
@@ -13659,54 +14508,6 @@ var init_tasks = __esmMin((() => {
13659
14508
  init_assignment();
13660
14509
  }));
13661
14510
 
13662
- //#endregion
13663
- //#region src/lib/premortem.ts
13664
- /** A new pre-mortem started now: answers show together five minutes on. */
13665
- function newPremortem(startedById, now) {
13666
- return {
13667
- startedAt: now.toISOString(),
13668
- startedById,
13669
- revealAt: new Date(now.getTime() + 5 * 6e4).toISOString()
13670
- };
13671
- }
13672
- /**
13673
- * A risk's title from an answer: its first sentence, kept under 120
13674
- * characters at a word boundary, without a closing full stop.
13675
- */
13676
- function riskTitle(answer) {
13677
- const flat = answer.replace(/\s+/g, " ").trim();
13678
- const first = /^(.+?[.!?])(\s|$)/.exec(flat)?.[1] ?? flat;
13679
- let title = first.length <= 120 ? first : `${first.slice(0, 117).replace(/\s+\S*$/, "")}…`;
13680
- title = title.replace(/\.$/, "");
13681
- return title;
13682
- }
13683
- /**
13684
- * The risk an answer becomes: on its project, open, unrated, with the whole
13685
- * answer as what could happen. Scoped to the project alone, like a risk
13686
- * logged on the project page; the program and portfolio roll it up from there.
13687
- */
13688
- function riskFromAnswer(answer, project) {
13689
- return {
13690
- kind: "risk",
13691
- title: riskTitle(answer.body),
13692
- description: `${answer.body.trim()}\n\nRaised in the pre-mortem.`,
13693
- projectId: project.id,
13694
- status: "open",
13695
- ownerId: null,
13696
- dueDate: null
13697
- };
13698
- }
13699
- /** The risk's description with another answer folded in as the same worry. */
13700
- function withFoldedAnswer(description, answer) {
13701
- const base = description.trim();
13702
- return `${base}${base ? "\n\n" : ""}Also raised: ${answer.trim()}`;
13703
- }
13704
- var PREMORTEM_MINUTES, STOP;
13705
- var init_premortem = __esmMin((() => {
13706
- PREMORTEM_MINUTES = 5;
13707
- STOP = new Set("the a an and or but of to in on for with at by from into onto was were be been being is are it its this that these those we our us they them their he she his her i my me you your had has have did do does not no so too very just than then there here what which who because about after before over under again more most some any all each other only own same can could would should will got get project failed happened went".split(" "));
13708
- }));
13709
-
13710
14511
  //#endregion
13711
14512
  //#region src/lib/raid-history.ts
13712
14513
  /**
@@ -14830,7 +15631,9 @@ function plainTransport(t) {
14830
15631
  save: async (body) => plainDeep(await t.save(body)),
14831
15632
  wait: async (workspaceId, since) => plainDeep(await t.wait(workspaceId, since)),
14832
15633
  history: async (query) => plainDeep(await t.history(query)),
14833
- create: async (name) => plainDeep(await t.create(name))
15634
+ create: async (name) => plainDeep(await t.create(name)),
15635
+ crew: async (workspaceId, act) => plainDeep(await t.crew(workspaceId, act)),
15636
+ invite: async (workspaceId, email, role) => plainDeep(await t.invite(workspaceId, email, role))
14834
15637
  };
14835
15638
  }
14836
15639
  function httpTransport(source, fetcher = fetch) {
@@ -14883,6 +15686,17 @@ function httpTransport(source, fetcher = fetch) {
14883
15686
  create: (name) => post({
14884
15687
  op: "create",
14885
15688
  name
15689
+ }),
15690
+ crew: (workspaceId, act) => post({
15691
+ op: "crew",
15692
+ workspaceId,
15693
+ do: act
15694
+ }),
15695
+ invite: (workspaceId, email, role) => post({
15696
+ op: "invite",
15697
+ harbor: workspaceId,
15698
+ email,
15699
+ role
14886
15700
  })
14887
15701
  };
14888
15702
  }
@@ -15587,6 +16401,184 @@ var init_folder = __esmMin((() => {
15587
16401
  PLAN_DIR = ".harbor";
15588
16402
  }));
15589
16403
 
16404
+ //#endregion
16405
+ //#region src/tui/engine/memory-crew.ts
16406
+ function memoryCrew(account, harbor) {
16407
+ let n = 0;
16408
+ const token = () => `t${++n}`;
16409
+ const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
16410
+ const members = [{
16411
+ userId: account.id,
16412
+ name: account.name,
16413
+ email: account.email,
16414
+ role: "owner"
16415
+ }, {
16416
+ userId: "acct-2",
16417
+ name: "Mara Quinn",
16418
+ email: "mara@example.com",
16419
+ role: "member"
16420
+ }];
16421
+ const invites = [];
16422
+ const shares = [];
16423
+ const forms = [];
16424
+ const apps = [{
16425
+ clientId: "harbor-cli",
16426
+ name: "Harbor CLI",
16427
+ returnsTo: "127.0.0.1",
16428
+ connectedAt: stamp(),
16429
+ active: true,
16430
+ canWrite: true,
16431
+ fullAccess: true
16432
+ }, {
16433
+ clientId: "claude",
16434
+ name: "Claude",
16435
+ returnsTo: "claude.ai",
16436
+ connectedAt: stamp(),
16437
+ active: true,
16438
+ canWrite: false,
16439
+ fullAccess: false
16440
+ }];
16441
+ const email = {
16442
+ weeklyDigest: true,
16443
+ assignments: true
16444
+ };
16445
+ const keep = {
16446
+ kept: false,
16447
+ interval: null,
16448
+ stripe: true,
16449
+ keptAt: null,
16450
+ trialEndsAt: new Date(Date.now() + 1728e6).toISOString(),
16451
+ paused: false
16452
+ };
16453
+ const refuse = (message, status = 404) => {
16454
+ throw new TransportError(message, status);
16455
+ };
16456
+ const view = (said = {}) => structuredClone({
16457
+ ...said,
16458
+ pretend: true,
16459
+ harbor: {
16460
+ ...harbor,
16461
+ role: "owner"
16462
+ },
16463
+ you: account.id,
16464
+ verified: true,
16465
+ members,
16466
+ invites: invites.map((i) => ({
16467
+ ...i,
16468
+ url: `${ORIGIN}/join/${i.token}`
16469
+ })),
16470
+ shares: shares.map((s) => ({
16471
+ ...s,
16472
+ url: `${ORIGIN}/share/${s.token}`
16473
+ })),
16474
+ forms: forms.map((f) => ({
16475
+ ...f,
16476
+ url: `${ORIGIN}/intake/${f.token}`
16477
+ })),
16478
+ apps: apps.map((a) => ({
16479
+ ...a,
16480
+ thisApp: a.clientId === "harbor-cli"
16481
+ })),
16482
+ email,
16483
+ keep,
16484
+ keepUrl: `${ORIGIN}/keep`
16485
+ });
16486
+ return {
16487
+ async crew(_id, act) {
16488
+ if (!act) return view();
16489
+ switch (act.act) {
16490
+ case "role": {
16491
+ const m = members.find((x) => x.userId === act.userId && x.role !== "owner");
16492
+ if (!m) return refuse("That isn't someone whose role can change.");
16493
+ m.role = act.role;
16494
+ return view();
16495
+ }
16496
+ case "withdraw-invite":
16497
+ invites.splice(0, invites.length, ...invites.filter((i) => i.token !== act.token));
16498
+ return view();
16499
+ case "share": {
16500
+ const made = {
16501
+ token: token(),
16502
+ portfolioId: act.portfolioId,
16503
+ createdAt: stamp(),
16504
+ subscribers: []
16505
+ };
16506
+ shares.unshift(made);
16507
+ return view({ url: `${ORIGIN}/share/${made.token}` });
16508
+ }
16509
+ case "withdraw-share":
16510
+ shares.splice(0, shares.length, ...shares.filter((s) => s.token !== act.token));
16511
+ return view();
16512
+ case "subscribe": {
16513
+ const share = shares.find((s) => s.token === act.token) ?? refuse("That share link isn't live any more.");
16514
+ const asked = act.emails.split(/[\s,;]+/).filter(Boolean);
16515
+ const ok = asked.filter((e) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)).map((e) => e.toLowerCase());
16516
+ const fresh = ok.filter((e) => !share.subscribers.some((s) => s.email === e));
16517
+ share.subscribers.push(...fresh.map((e) => ({
16518
+ email: e,
16519
+ stopped: false
16520
+ })));
16521
+ return view({
16522
+ added: fresh,
16523
+ invalid: asked.filter((e) => !ok.includes(e.toLowerCase())),
16524
+ alreadyThere: ok.length - fresh.length,
16525
+ overLimit: 0
16526
+ });
16527
+ }
16528
+ case "unsubscribe": {
16529
+ const share = shares.find((s) => s.token === act.token) ?? refuse("That share link isn't live any more.");
16530
+ share.subscribers = share.subscribers.filter((s) => s.stopped || s.email !== act.email);
16531
+ return view();
16532
+ }
16533
+ case "intake": {
16534
+ const made = {
16535
+ token: token(),
16536
+ projectId: act.projectId,
16537
+ createdAt: stamp()
16538
+ };
16539
+ forms.unshift(made);
16540
+ return view({ url: `${ORIGIN}/intake/${made.token}` });
16541
+ }
16542
+ case "withdraw-intake":
16543
+ forms.splice(0, forms.length, ...forms.filter((f) => f.token !== act.token));
16544
+ return view();
16545
+ case "disconnect":
16546
+ apps.splice(0, apps.length, ...apps.filter((a) => a.clientId !== act.clientId));
16547
+ return view();
16548
+ case "email":
16549
+ if (act.weeklyDigest !== void 0) email.weeklyDigest = act.weeklyDigest;
16550
+ if (act.assignments !== void 0) email.assignments = act.assignments;
16551
+ return view();
16552
+ }
16553
+ },
16554
+ async invite(_id, address, role) {
16555
+ const lower = address.trim().toLowerCase();
16556
+ const again = invites.some((i) => i.email === lower);
16557
+ invites.splice(0, invites.length, ...invites.filter((i) => i.email !== lower));
16558
+ invites.unshift({
16559
+ token: token(),
16560
+ email: lower,
16561
+ name: null,
16562
+ role,
16563
+ createdAt: stamp(),
16564
+ url: ""
16565
+ });
16566
+ return {
16567
+ harbor: harbor.name,
16568
+ email: lower,
16569
+ role,
16570
+ sent: true,
16571
+ again
16572
+ };
16573
+ }
16574
+ };
16575
+ }
16576
+ var ORIGIN;
16577
+ var init_memory_crew = __esmMin((() => {
16578
+ init_transport();
16579
+ ORIGIN = "https://harborppm.com";
16580
+ }));
16581
+
15590
16582
  //#endregion
15591
16583
  //#region src/tui/engine/memory.ts
15592
16584
  function memoryHarbor(start, opts) {
@@ -15632,8 +16624,14 @@ function memoryHarbor(start, opts) {
15632
16624
  const unreachable = () => {
15633
16625
  if (offline || closed) throw new TransportError("Can't reach Harbor.", 0, true);
15634
16626
  };
16627
+ const crew = memoryCrew(opts.account, {
16628
+ id,
16629
+ name
16630
+ });
15635
16631
  return {
15636
16632
  transport: {
16633
+ crew: opts.noCrew ? async () => Promise.reject(new TransportError(opts.noCrew, 404)) : crew.crew,
16634
+ invite: opts.noCrew ? async () => Promise.reject(new TransportError(opts.noCrew, 404)) : crew.invite,
15637
16635
  async harbors() {
15638
16636
  return {
15639
16637
  account: opts.account,
@@ -15745,6 +16743,7 @@ var init_memory = __esmMin((() => {
15745
16743
  init_row_changes();
15746
16744
  init_task_keys();
15747
16745
  init_indexes();
16746
+ init_memory_crew();
15748
16747
  init_transport();
15749
16748
  }));
15750
16749
 
@@ -16218,7 +17217,8 @@ function localTransport(folder, data, config) {
16218
17217
  id: "local",
16219
17218
  name: config.project.name
16220
17219
  },
16221
- waitMs: 6e4
17220
+ waitMs: 6e4,
17221
+ noCrew: "A plan kept in this folder has no crew. harbor init PROJECT links the folder to a project in a harbor, where the crew lives."
16222
17222
  });
16223
17223
  let writing = Promise.resolve();
16224
17224
  return {
@@ -16480,7 +17480,8 @@ async function startApp(deps, options = {}) {
16480
17480
  account,
16481
17481
  harbors,
16482
17482
  today,
16483
- remember: deps.remember
17483
+ remember: deps.remember,
17484
+ browse: deps.browse
16484
17485
  },
16485
17486
  children: /* @__PURE__ */ jsx(App, { ui })
16486
17487
  }));
@@ -16559,7 +17560,8 @@ async function runApp(opts) {
16559
17560
  splashMs: SPLASH_MS,
16560
17561
  theme,
16561
17562
  firstRun,
16562
- remember: opts.remember
17563
+ remember: opts.remember,
17564
+ browse: opts.browse
16563
17565
  }, { alternateScreen: true });
16564
17566
  await instance.waitUntilExit();
16565
17567
  }