@harborppm/cli 0.97.0 → 0.99.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
@@ -3376,7 +3376,7 @@ var init_thanks = __esmMin((() => {
3376
3376
  // src/lib/charter-lines.ts
3377
3377
  /** Nothing written on any of the five lines. */
3378
3378
  function charterEmpty(charter) {
3379
- return !charter || CHARTER_FIELDS.every((f) => !clean(charter[f]));
3379
+ return !charter || CHARTER_FIELDS.every((f) => !clean$1(charter[f]));
3380
3380
  }
3381
3381
  /**
3382
3382
  * The charter after a change to some of its lines: trimmed, and null once
@@ -3396,7 +3396,7 @@ function withCharter(current, patch, by, at) {
3396
3396
  }
3397
3397
  return charterEmpty(next) ? null : next;
3398
3398
  }
3399
- var CHARTER_FIELDS, clean;
3399
+ var CHARTER_FIELDS, clean$1;
3400
3400
  var init_charter_lines = __esmMin((() => {
3401
3401
  CHARTER_FIELDS = [
3402
3402
  "why",
@@ -3405,7 +3405,7 @@ var init_charter_lines = __esmMin((() => {
3405
3405
  "doneWhen",
3406
3406
  "callsBy"
3407
3407
  ];
3408
- clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
3408
+ clean$1 = (s) => (s ?? "").replace(/\s+/g, " ").trim();
3409
3409
  }));
3410
3410
 
3411
3411
  // src/lib/charter.ts
@@ -3434,7 +3434,7 @@ function charterLines(charter) {
3434
3434
  return CHARTER_FIELDS.map((field) => ({
3435
3435
  field,
3436
3436
  label: CHARTER_LABEL[field],
3437
- text: clean(charter[field])
3437
+ text: clean$1(charter[field])
3438
3438
  })).filter((l) => l.text);
3439
3439
  }
3440
3440
  function charterFilled(charter) {
@@ -3502,7 +3502,7 @@ function charterCheck(charter, tasks) {
3502
3502
  };
3503
3503
  });
3504
3504
  return {
3505
- doneWhen: clean(charter.doneWhen) || null,
3505
+ doneWhen: clean$1(charter.doneWhen) || null,
3506
3506
  delivered,
3507
3507
  crept: outOfScope(charter, tasks),
3508
3508
  outItems: charterItems(charter.outScope).length
@@ -3524,6 +3524,18 @@ function charterSentence(check) {
3524
3524
  return `Against the charter: ${bits.join("; ")}.`;
3525
3525
  }
3526
3526
 
3527
+ // src/lib/finished-on.ts
3528
+ /**
3529
+ * When the project was marked Done: its last move to Done in its health
3530
+ * history, else its last finished task, else its end date. Kept on its own so
3531
+ * the inbox can ask it without the voyage log's code.
3532
+ */
3533
+ function finishedOn(project, tasks) {
3534
+ const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
3535
+ if (marked) return marked.at.slice(0, 10);
3536
+ return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
3537
+ }
3538
+
3527
3539
  // src/lib/voyage-log.ts
3528
3540
  init_date_fns();
3529
3541
  init_blockers();
@@ -3531,12 +3543,6 @@ init_date_text();
3531
3543
  init_premortem();
3532
3544
  const day$8 = (iso) => parseIso(localDay(iso));
3533
3545
  const between = (a, b) => differenceInCalendarDays(day$8(b), day$8(a));
3534
- /** When the project was marked Done: its last move to Done in its health history. */
3535
- function finishedOn(project, tasks) {
3536
- const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
3537
- if (marked) return marked.at.slice(0, 10);
3538
- return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
3539
- }
3540
3546
  function voyageLog(data, projectId) {
3541
3547
  const project = data.projects.find((p) => p.id === projectId);
3542
3548
  if (!project) return null;
@@ -5321,7 +5327,7 @@ function cascadeDelete(data, kind, id) {
5321
5327
  taskIds: r.taskIds.filter((t) => !taskIds.has(t))
5322
5328
  })),
5323
5329
  spendEntries: (data.spendEntries ?? []).filter((e) => !projectIds.has(e.projectId)),
5324
- reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId) && !(r.thanksFor?.kind === "milestone" && goneMilestones.has(r.thanksFor.id)))
5330
+ reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId) && !(r.programId && programIds.has(r.programId)) && !(r.thanksFor?.kind === "milestone" && goneMilestones.has(r.thanksFor.id)))
5325
5331
  },
5326
5332
  counts: {
5327
5333
  programs: programIds.size,
@@ -10560,10 +10566,144 @@ function HistoryView({ active, width, height, go }) {
10560
10566
  });
10561
10567
  }
10562
10568
 
10569
+ // src/lib/landfall-lines.ts
10570
+ function isLandfallDays(n) {
10571
+ return LANDFALL_DAYS.includes(n);
10572
+ }
10573
+ /** Why a landfall can't be saved as given, in words for the person; null when it can. */
10574
+ function landfallProblem(input) {
10575
+ const expect = clean(input.expect);
10576
+ if (!expect) return "Say what landfall should look like.";
10577
+ if (expect.length > 300) return `Keep it under ${300} characters.`;
10578
+ if (!isLandfallDays(input.afterDays)) return "Pick 30, 90 or 180 days.";
10579
+ if (clean(input.measure).length > 60) return `Name the number in under ${60} characters.`;
10580
+ return null;
10581
+ }
10582
+ /** Why an answer can't be saved as given; null when it can. */
10583
+ function landfallAnswerProblem(input) {
10584
+ if (!LANDFALL_RESULTS.includes(input.result)) return "Say yes, partly or no.";
10585
+ if (input.value != null && (typeof input.value !== "number" || !Number.isFinite(input.value))) return "The number has to be a number.";
10586
+ if (typeof input.value === "number" && Math.abs(input.value) > 0xe8d4a51000) return "That number is too big.";
10587
+ if (clean(input.note).length > 500) return `Keep the note under ${500} characters.`;
10588
+ return null;
10589
+ }
10590
+ /**
10591
+ * The landfall after someone sets or changes what it looks like. An answer
10592
+ * already given stays: changing the words after the fact doesn't unask it.
10593
+ * Throws on bad input, so callers check `landfallProblem` first.
10594
+ */
10595
+ function withLandfall(current, input, by, at) {
10596
+ const problem = landfallProblem(input);
10597
+ if (problem) throw new Error(problem);
10598
+ const measure = clean(input.measure);
10599
+ return {
10600
+ ...current ?? {},
10601
+ expect: clean(input.expect),
10602
+ afterDays: input.afterDays,
10603
+ measure: measure || null,
10604
+ setAt: current?.setAt ?? at,
10605
+ setById: current?.setById ?? by
10606
+ };
10607
+ }
10608
+ /** The landfall with an answer, or with the answer taken back (null) so it asks again. */
10609
+ function withLandfallAnswer(current, answer, by, at) {
10610
+ if (!answer) {
10611
+ const { result: _r, value: _v, note: _n, answeredAt: _a, answeredById: _b, ...rest } = current;
10612
+ return rest;
10613
+ }
10614
+ const problem = landfallAnswerProblem(answer);
10615
+ if (problem) throw new Error(problem);
10616
+ const note = clean(answer.note);
10617
+ return {
10618
+ ...current,
10619
+ result: answer.result,
10620
+ value: answer.value ?? null,
10621
+ note: note || null,
10622
+ answeredAt: at,
10623
+ answeredById: by
10624
+ };
10625
+ }
10626
+ var LANDFALL_DAYS, LANDFALL_EXPECT_MAX, LANDFALL_MEASURE_MAX, LANDFALL_NOTE_MAX, LANDFALL_RESULTS, clean;
10627
+ var init_landfall_lines = __esmMin((() => {
10628
+ LANDFALL_DAYS = [
10629
+ 30,
10630
+ 90,
10631
+ 180
10632
+ ];
10633
+ LANDFALL_EXPECT_MAX = 300;
10634
+ LANDFALL_MEASURE_MAX = 60;
10635
+ LANDFALL_NOTE_MAX = 500;
10636
+ LANDFALL_RESULTS = [
10637
+ "yes",
10638
+ "partly",
10639
+ "no"
10640
+ ];
10641
+ clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
10642
+ }));
10643
+
10644
+ // src/lib/landfall.ts
10645
+ init_date_fns();
10646
+ init_date_text();
10647
+ function plusDays(iso, days) {
10648
+ return addDays(parseIso(iso.slice(0, 10)), days).toISOString().slice(0, 10);
10649
+ }
10650
+ /** The day to look: that many days after the project was marked Done. */
10651
+ function landfallCheckOn(project, tasks) {
10652
+ if (!project.landfall) return null;
10653
+ return plusDays(finishedOn(project, tasks), project.landfall.afterDays);
10654
+ }
10655
+ function landfallState(project, tasks, today) {
10656
+ const lf = project.landfall;
10657
+ if (lf?.result) return "answered";
10658
+ if (project.health !== "done") return "sailing";
10659
+ if (!lf) return "unset";
10660
+ const on = landfallCheckOn(project, tasks);
10661
+ return on && on <= today ? "due" : "waiting";
10662
+ }
10663
+ /** Who is asked: the project's owner, else (their person gone) the program's. */
10664
+ function landfallAskee(data, project) {
10665
+ const here = (id) => id ? data.people.find((p) => p.id === id) : void 0;
10666
+ return here(project.ownerId) ?? here(data.programs.find((p) => p.id === project.programId)?.ownerId);
10667
+ }
10668
+ /** A project's landfall as agents and the CLI's --json read it; null while there's none. */
10669
+ function landfallJson(project, tasks, today, people) {
10670
+ const lf = project.landfall;
10671
+ if (!lf) return null;
10672
+ const answered = Boolean(lf.result);
10673
+ return {
10674
+ expect: lf.expect,
10675
+ afterDays: lf.afterDays,
10676
+ measure: lf.measure ?? null,
10677
+ state: landfallState(project, tasks, today),
10678
+ checkOn: project.health === "done" ? landfallCheckOn(project, tasks) : null,
10679
+ result: lf.result ?? null,
10680
+ ...answered ? {
10681
+ value: lf.value ?? null,
10682
+ note: lf.note ?? null,
10683
+ answeredOn: lf.answeredAt?.slice(0, 10) ?? null,
10684
+ answeredBy: people.find((p) => p.id === lf.answeredById)?.name ?? null
10685
+ } : {}
10686
+ };
10687
+ }
10688
+
10563
10689
  // src/lib/project-messages.ts
10564
- /** This project's messages, oldest first. */
10565
- function messagesOf(reflections, projectId) {
10566
- return (reflections ?? []).filter((r) => r.projectId === projectId && isMessage(r)).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
10690
+ /** One conversation's messages (answers too), oldest first. */
10691
+ function messagesIn(reflections, place) {
10692
+ return (reflections ?? []).filter((r) => inPlace(r, place)).sort(oldestFirst);
10693
+ }
10694
+ /**
10695
+ * The message an answer hangs under: the one asked for, or, for an answer
10696
+ * to an answer, that answer's own message. Null when it isn't a message in
10697
+ * the same conversation.
10698
+ */
10699
+ function replyRoot(reflections, place, replyToId) {
10700
+ if (!replyToId) return null;
10701
+ const all = reflections ?? [];
10702
+ const target = all.find((r) => r.id === replyToId && inPlace(r, place));
10703
+ if (!target) return null;
10704
+ if (!target.replyToId) return target.id;
10705
+ const root = all.find((r) => r.id === target.replyToId && inPlace(r, place));
10706
+ return root ? root.id : target.id;
10567
10707
  }
10568
10708
  /** Why a message can't be posted, or null. */
10569
10709
  function messageProblem(body) {
@@ -10582,25 +10722,30 @@ function mayChangeMessage(message, personId, role, action) {
10582
10722
  return action === "delete" && role === "owner";
10583
10723
  }
10584
10724
  /**
10585
- * What a person's Inbox says about project messages since `since`: each
10586
- * message that @mentions them, and for each project they've written in, the
10587
- * messages others posted after their last one (a mention among them is
10588
- * already its own line). A finished project's replies are left out, but a
10589
- * mention there still reaches you: a wrap-up "thanks @Dana" is meant to.
10725
+ * What a person's Inbox says about messages since `since`: each message that
10726
+ * @mentions them, and for each conversation they've written in, the messages
10727
+ * others posted after their last one (a mention among them is already its
10728
+ * own line). An answer to one of your messages reaches you as well, even in
10729
+ * a conversation where that's all you've written. A finished project's or
10730
+ * program's chatter is left out, but a mention or an answer to you there
10731
+ * still reaches you: a wrap-up "thanks @Dana" is meant to.
10590
10732
  */
10591
10733
  function messageLines(data, personId, since) {
10592
- const done = new Set(data.projects.filter((p) => p.health === "done").map((p) => p.id));
10593
- const byProject = /* @__PURE__ */ new Map();
10734
+ const done = /* @__PURE__ */ new Set([...data.projects.filter((p) => p.health === "done").map((p) => `project:${p.id}`), ...(data.programs ?? []).filter((p) => p.health === "done").map((p) => `program:${p.id}`)]);
10735
+ const byPlace = /* @__PURE__ */ new Map();
10736
+ const mine = /* @__PURE__ */ new Set();
10594
10737
  for (const r of data.reflections ?? []) {
10595
10738
  if (!isMessage(r)) continue;
10596
- const list = byProject.get(r.projectId);
10739
+ if (r.authorId === personId) mine.add(r.id);
10740
+ const key = placeKey(placeOf(r));
10741
+ const list = byPlace.get(key);
10597
10742
  if (list) list.push(r);
10598
- else byProject.set(r.projectId, [r]);
10743
+ else byPlace.set(key, [r]);
10599
10744
  }
10600
10745
  const out = [];
10601
- for (const [projectId, list] of byProject) {
10602
- const finished = done.has(projectId);
10603
- list.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
10746
+ for (const [key, list] of byPlace) {
10747
+ const finished = done.has(key);
10748
+ list.sort(oldestFirst);
10604
10749
  let joined = false;
10605
10750
  let replies = [];
10606
10751
  for (const m of list) {
@@ -10618,7 +10763,8 @@ function messageLines(data, personId, since) {
10618
10763
  });
10619
10764
  continue;
10620
10765
  }
10621
- if (joined && !finished) replies.push(m);
10766
+ const toMe = m.replyToId != null && mine.has(m.replyToId);
10767
+ if (joined && !finished || toMe) replies.push(m);
10622
10768
  }
10623
10769
  const latest = replies.at(-1);
10624
10770
  if (latest) out.push({
@@ -10629,11 +10775,26 @@ function messageLines(data, personId, since) {
10629
10775
  }
10630
10776
  return out;
10631
10777
  }
10632
- var MESSAGE_MAX, isMessage, messageText;
10778
+ var placeOf, placeKey, inPlace, placePath, MESSAGE_MAX, isMessage, oldestFirst, messagesOf, messageText;
10633
10779
  var init_project_messages = __esmMin((() => {
10634
10780
  init_work();
10781
+ placeOf = (r) => r.programId ? {
10782
+ kind: "program",
10783
+ id: r.programId
10784
+ } : {
10785
+ kind: "project",
10786
+ id: r.projectId
10787
+ };
10788
+ placeKey = (p) => `${p.kind}:${p.id}`;
10789
+ inPlace = (r, place) => isMessage(r) && (place.kind === "program" ? r.programId === place.id : !r.programId && r.projectId === place.id);
10790
+ placePath = (p) => p.kind === "program" ? `/programs/${p.id}?view=messages` : `/projects/${p.id}?view=messages`;
10635
10791
  MESSAGE_MAX = 4e3;
10636
10792
  isMessage = (r) => r.kind === "message";
10793
+ oldestFirst = (a, b) => a.createdAt.localeCompare(b.createdAt);
10794
+ messagesOf = (reflections, projectId) => messagesIn(reflections, {
10795
+ kind: "project",
10796
+ id: projectId
10797
+ });
10637
10798
  messageText = (body) => body.trim().slice(0, MESSAGE_MAX);
10638
10799
  }));
10639
10800
 
@@ -10682,6 +10843,7 @@ const KIND_RANK = {
10682
10843
  waiting: 0,
10683
10844
  approval: 1,
10684
10845
  review: 2,
10846
+ landfall: 2.5,
10685
10847
  sounding: 3,
10686
10848
  assigned: 4,
10687
10849
  idle: 5,
@@ -10694,6 +10856,7 @@ const PING_KIND_LABEL = {
10694
10856
  waiting: "Waiting on you",
10695
10857
  approval: "Sign-offs",
10696
10858
  review: "Reviews due",
10859
+ landfall: "Landfall",
10697
10860
  sounding: "Soundings",
10698
10861
  assigned: "Assigned to you",
10699
10862
  idle: "Quiet blockers",
@@ -10827,6 +10990,41 @@ function inboxOf(data, personId, now = clockNow()) {
10827
10990
  personId: null
10828
10991
  });
10829
10992
  }
10993
+ const askSince = todayISO(subDays(now, 14));
10994
+ for (const project of data.projects) {
10995
+ if (project.health !== "done" && !project.landfall?.result) continue;
10996
+ if (landfallAskee(data, project)?.id !== personId) continue;
10997
+ const tasks = tasksOf(data.tasks, project.id);
10998
+ const state = landfallState(project, tasks, today);
10999
+ if (state === "unset") {
11000
+ const finished = finishedOn(project, tasks);
11001
+ if (finished < askSince) continue;
11002
+ pings.push({
11003
+ id: `landfall-set:${project.id}:${finished}`,
11004
+ kind: "landfall",
11005
+ title: `${project.name} is done. What should landfall look like?`,
11006
+ detail: "Say what should be true in 30, 90 or 180 days, and Harbor asks you then.",
11007
+ at: finished,
11008
+ projectId: project.id,
11009
+ landfall: "set",
11010
+ personId: null,
11011
+ href: `/projects/${project.id}?view=log`
11012
+ });
11013
+ } else if (state === "due") {
11014
+ const on = landfallCheckOn(project, tasks);
11015
+ pings.push({
11016
+ id: `landfall:${project.id}:${on}`,
11017
+ kind: "landfall",
11018
+ title: `Did ${project.name} reach landfall?`,
11019
+ detail: `“${project.landfall.expect}”`,
11020
+ at: on,
11021
+ projectId: project.id,
11022
+ landfall: "check",
11023
+ personId: null,
11024
+ href: `/projects/${project.id}?view=log`
11025
+ });
11026
+ }
11027
+ }
10830
11028
  for (const constraint of constraintsOwnedBy(data, personId)) {
10831
11029
  if (!constraintIdle(data, constraint)) continue;
10832
11030
  const program = data.programs.find((p) => p.id === constraint.programId);
@@ -10949,14 +11147,15 @@ function inboxOf(data, personId, now = clockNow()) {
10949
11147
  });
10950
11148
  }
10951
11149
  for (const line of messageLines(data, personId, since)) {
10952
- const project = data.projects.find((p) => p.id === line.message.projectId);
10953
- if (!project) continue;
11150
+ const place = placeOf(line.message);
11151
+ const holder = place.kind === "program" ? data.programs.find((p) => p.id === place.id) : data.projects.find((p) => p.id === place.id);
11152
+ if (!holder) continue;
10954
11153
  const names = line.authorIds.map((id) => {
10955
11154
  const who = data.people.find((p) => p.id === id);
10956
11155
  return who ? firstName$3(who.name) : "Someone";
10957
11156
  });
10958
11157
  const snippet = excerpt(line.message.body);
10959
- const where = `in ${project.name}’s messages`;
11158
+ const where = `in ${holder.name}’s messages`;
10960
11159
  pings.push({
10961
11160
  id: `message-${line.kind}:${line.message.id}`,
10962
11161
  kind: line.kind,
@@ -10964,7 +11163,7 @@ function inboxOf(data, personId, now = clockNow()) {
10964
11163
  detail: snippet ? `${where} — ${snippet}` : where,
10965
11164
  at: line.message.createdAt,
10966
11165
  personId: line.message.authorId,
10967
- href: `/projects/${project.id}?view=messages`
11166
+ href: placePath(place)
10968
11167
  });
10969
11168
  }
10970
11169
  for (const task of data.tasks) {
@@ -22176,6 +22375,7 @@ function approvalsWaitingOn(tasks, personId) {
22176
22375
  // src/lib/mcp/tools.ts
22177
22376
  init_project_messages();
22178
22377
  init_subtasks();
22378
+ init_blockers();
22179
22379
  init_thanks();
22180
22380
  init_task_keys();
22181
22381
  init_brief();
@@ -22203,6 +22403,7 @@ init_task_people();
22203
22403
  init_fog();
22204
22404
  init_passage();
22205
22405
  init_board();
22406
+ init_swatches();
22206
22407
  init_holidays();
22207
22408
  init_soundings();
22208
22409
  /** A task's logged time for get_task: the total and the latest entries. */
@@ -22482,15 +22683,57 @@ function overdue(t, today) {
22482
22683
  const due = dayOf(t.dueDate);
22483
22684
  return isOpen(t) && due != null && due < today;
22484
22685
  }
22485
- function taskLine(t, people, projects, all) {
22686
+ /**
22687
+ * The board's own name for a task's column when its project renamed it
22688
+ * ("In progress" for doing), so a status reads as the board says it.
22689
+ */
22690
+ function columnName(data, t) {
22691
+ const label = boardConfigOf(data, t.projectId).columns.find((c) => c.status === t.status)?.label.trim();
22692
+ return label && label !== TASK_STATUS_LABEL[t.status] ? label : null;
22693
+ }
22694
+ /**
22695
+ * A milestone as agents see it: its program, due day, whether it's reached or
22696
+ * late, the tasks tied to it, its sign-off and the projects it waits on.
22697
+ */
22698
+ function milestoneOut(data, m, today, progress = milestoneProgress(data)) {
22699
+ const people = byId(data.people);
22700
+ const projects = byId(data.projects);
22701
+ const p = progress.get(m.id);
22702
+ const due = dayOf(m.dueDate);
22703
+ const waits = blockerIds(m).map((id) => projects.get(id)).filter((x) => Boolean(x));
22704
+ return {
22705
+ id: m.id,
22706
+ name: m.name,
22707
+ program: data.programs.find((x) => x.id === m.programId)?.name ?? null,
22708
+ due,
22709
+ reached: m.done,
22710
+ ...!m.done && due && due < today ? { late: true } : {},
22711
+ ...p ? { tasks: {
22712
+ done: p.done,
22713
+ total: p.total
22714
+ } } : {},
22715
+ ...m.approverId ? { signOff: {
22716
+ by: nameOf$2(people, m.approverId),
22717
+ status: m.approvalStatus ?? "pending",
22718
+ ...m.approvalNote ? { note: m.approvalNote } : {}
22719
+ } } : {},
22720
+ ...waits.length ? { waitsOn: waits.map((x) => ({
22721
+ project: x.name,
22722
+ done: x.health === "done"
22723
+ })) } : {}
22724
+ };
22725
+ }
22726
+ function taskLine(t, people, projects, all, data) {
22486
22727
  const waitingOn = blockersOf(t, all).filter(isOpen).length;
22487
22728
  const project = projects.get(t.projectId);
22488
22729
  const key = taskKey(t, project);
22730
+ const column = data ? columnName(data, t) : null;
22489
22731
  return {
22490
22732
  id: t.id,
22491
22733
  ...key ? { key } : {},
22492
22734
  title: t.title,
22493
22735
  status: t.status,
22736
+ ...column ? { column } : {},
22494
22737
  position: boardPositions(all).get(t.id) ?? null,
22495
22738
  priority: t.priority,
22496
22739
  due: dayOf(t.dueDate),
@@ -22540,10 +22783,10 @@ function weekOut(data, personId, monday) {
22540
22783
  tasks: d.items.map((item) => ({
22541
22784
  placement: item.id,
22542
22785
  hours: item.hours,
22543
- ...taskLine(item.task, people, projects, data.tasks)
22786
+ ...taskLine(item.task, people, projects, data.tasks, data)
22544
22787
  }))
22545
22788
  })),
22546
- unplanned: plan.unplanned.slice(0, 200).map((t) => taskLine(t, people, projects, data.tasks)),
22789
+ unplanned: plan.unplanned.slice(0, 200).map((t) => taskLine(t, people, projects, data.tasks, data)),
22547
22790
  unplannedCount: plan.unplanned.length,
22548
22791
  inReviewCount: plan.inReview.length,
22549
22792
  review: {
@@ -22552,8 +22795,8 @@ function weekOut(data, personId, monday) {
22552
22795
  done: review.doneCount,
22553
22796
  plannedHours: review.plannedHours,
22554
22797
  doneHours: review.doneHours,
22555
- stillOpen: review.planned.filter((p) => !p.done).map((p) => taskLine(p.task, people, projects, data.tasks)),
22556
- alsoDone: review.alsoDone.map((t) => taskLine(t, people, projects, data.tasks))
22798
+ stillOpen: review.planned.filter((p) => !p.done).map((p) => taskLine(p.task, people, projects, data.tasks, data)),
22799
+ alsoDone: review.alsoDone.map((t) => taskLine(t, people, projects, data.tasks, data))
22557
22800
  }
22558
22801
  };
22559
22802
  }
@@ -22728,11 +22971,13 @@ const HARBOR_TOOLS = [
22728
22971
  id: pg.id,
22729
22972
  name: pg.name,
22730
22973
  health: pg.health,
22974
+ starts: dayOf(pg.startDate),
22731
22975
  ends: dayOf(pg.endDate),
22732
22976
  projects: (projectsByProgram.get(pg.id) ?? []).map((pj) => ({
22733
22977
  id: pj.id,
22734
22978
  name: pj.name,
22735
22979
  health: pj.health,
22980
+ starts: dayOf(pj.startDate),
22736
22981
  ends: dayOf(pj.endDate),
22737
22982
  openTasks: openByProject.get(pj.id)?.open ?? 0,
22738
22983
  lateTasks: openByProject.get(pj.id)?.late ?? 0
@@ -22828,7 +23073,7 @@ const HARBOR_TOOLS = [
22828
23073
  tool({
22829
23074
  name: "get_project",
22830
23075
  title: "Project details",
22831
- description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
23076
+ description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), its landfall (what should be true some days after it's Done, when Harbor asks, and the answer: yes, partly or no with a number; null until someone sets one; state is sailing while not Done, unset, waiting, due or answered), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
22832
23077
  input: object({
22833
23078
  harbor: harborArg,
22834
23079
  project: string().min(1).max(200).describe("Project id or name.")
@@ -22844,7 +23089,7 @@ const HARBOR_TOOLS = [
22844
23089
  const tasks = data.tasks.filter((t) => t.projectId === p.id);
22845
23090
  const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0]));
22846
23091
  for (const t of tasks) byStatus[t.status] += 1;
22847
- const late = tasks.filter((t) => overdue(t, today)).sort((a, b) => (a.dueDate ?? "").localeCompare(b.dueDate ?? "")).slice(0, 10).map((t) => taskLine(t, people, projects, data.tasks));
23092
+ const late = tasks.filter((t) => overdue(t, today)).sort((a, b) => (a.dueDate ?? "").localeCompare(b.dueDate ?? "")).slice(0, 10).map((t) => taskLine(t, people, projects, data.tasks, data));
22848
23093
  const waitsOn = (p.blockedByIds ?? (p.blockedById ? [p.blockedById] : [])).map((id) => projects.get(id)).filter((x) => Boolean(x)).map((x) => ({
22849
23094
  id: x.id,
22850
23095
  name: x.name,
@@ -22892,6 +23137,7 @@ const HARBOR_TOOLS = [
22892
23137
  ...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
22893
23138
  finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
22894
23139
  ...charterOf(p, tasks),
23140
+ landfall: landfallJson(p, tasks, today, data.people),
22895
23141
  ...notesOf(ctx, p),
22896
23142
  ...hillOf(p, today),
22897
23143
  ...webLink(ctx, { project: p.id })
@@ -22945,7 +23191,7 @@ const HARBOR_TOOLS = [
22945
23191
  return {
22946
23192
  ...doneMatches ? { doneMatches } : {},
22947
23193
  total: list.length,
22948
- tasks: list.slice(input.offset, input.offset + input.limit).map((t) => taskLine(t, people, projects, data.tasks))
23194
+ tasks: list.slice(input.offset, input.offset + input.limit).map((t) => taskLine(t, people, projects, data.tasks, data))
22949
23195
  };
22950
23196
  }
22951
23197
  }),
@@ -22987,6 +23233,7 @@ const HARBOR_TOOLS = [
22987
23233
  title: t.title,
22988
23234
  description: t.description,
22989
23235
  status: t.status,
23236
+ ...((c) => c ? { column: c } : {})(columnName(data, t)),
22990
23237
  position: boardPositions(data.tasks).get(t.id) ?? null,
22991
23238
  priority: t.priority,
22992
23239
  due: dayOf(t.dueDate),
@@ -23111,8 +23358,8 @@ const HARBOR_TOOLS = [
23111
23358
  you: mine.name,
23112
23359
  openTasks: open.length,
23113
23360
  late: open.filter((t) => overdue(t, today)).length,
23114
- tasks: open.slice(0, input.limit).map((t) => taskLine(t, people, projects, data.tasks)),
23115
- alsoOn: tasksAlsoOn(data, mine.id).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999")).slice(0, input.limit).map((t) => taskLine(t, people, projects, data.tasks)),
23361
+ tasks: open.slice(0, input.limit).map((t) => taskLine(t, people, projects, data.tasks, data)),
23362
+ alsoOn: tasksAlsoOn(data, mine.id).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999")).slice(0, input.limit).map((t) => taskLine(t, people, projects, data.tasks, data)),
23116
23363
  waitingOnYou: waitingOnYou.slice(0, 20),
23117
23364
  raid,
23118
23365
  soundingsToAnswer: soundingsWaitingOn(data, mine.id, ctx.now).map((t) => ({
@@ -23577,6 +23824,30 @@ const HARBOR_TOOLS = [
23577
23824
  };
23578
23825
  }
23579
23826
  }),
23827
+ tool({
23828
+ name: "list_milestones",
23829
+ title: "Milestones",
23830
+ description: "A program's milestones (or every program's, or the program of one project), soonest first: each with its due day, whether it's reached or late, how many of the tasks tied to it are done, its sign-off (who signs it off, and pending, approved or changes_requested with their note) and the projects it waits on. Reached ones only with done. set_milestone adds or changes one." + DATA_NOTE,
23831
+ input: object({
23832
+ harbor: harborArg,
23833
+ program: string().min(1).max(200).optional().describe("Program id or name."),
23834
+ project: string().min(1).max(200).optional().describe("Project id or name: its program's milestones."),
23835
+ done: boolean().default(false).describe("Include milestones already reached.")
23836
+ }),
23837
+ run: (ctx, input) => {
23838
+ const { data } = ctx;
23839
+ const today = todayISO(ctx.now);
23840
+ const programId = input.project ? findOne(data.projects, input.project, "project").programId : input.program ? findOne(data.programs, input.program, "program").id : null;
23841
+ const all = data.milestones.filter((m) => !programId || m.programId === programId).sort((a, b) => (a.dueDate ?? "").localeCompare(b.dueDate ?? "") || a.name.localeCompare(b.name));
23842
+ const shown = input.done ? all : all.filter((m) => !m.done);
23843
+ const progress = milestoneProgress(data);
23844
+ return {
23845
+ ...programId ? { program: data.programs.find((p) => p.id === programId)?.name } : {},
23846
+ milestones: shown.map((m) => milestoneOut(data, m, today, progress)),
23847
+ ...shown.length < all.length ? { reachedHidden: all.length - shown.length } : {}
23848
+ };
23849
+ }
23850
+ }),
23580
23851
  tool({
23581
23852
  name: "list_messages",
23582
23853
  title: "A project's messages",
@@ -24031,7 +24302,7 @@ const HARBOR_TOOLS = [
24031
24302
  inFog: fog.length,
24032
24303
  fogAfterDays: 21,
24033
24304
  tasks: fog.slice(input.offset, input.offset + input.limit).map((f) => ({
24034
- ...taskLine(f.task, people, projects, data.tasks),
24305
+ ...taskLine(f.task, people, projects, data.tasks, data),
24035
24306
  untouchedDays: f.days,
24036
24307
  untouched: untouchedText(f.days),
24037
24308
  thick: f.level === "thick",
@@ -24869,6 +25140,7 @@ function sessionSlice({ set, get, write }) {
24869
25140
  }
24870
25141
  };
24871
25142
  }
25143
+ var ACTIVITY_KEPT;
24872
25144
  var init_session = __esmMin((() => {
24873
25145
  init_board();
24874
25146
  init_derived_health();
@@ -24880,6 +25152,7 @@ var init_session = __esmMin((() => {
24880
25152
  init_view_only();
24881
25153
  init_fog();
24882
25154
  init_format();
25155
+ ACTIVITY_KEPT = 40;
24883
25156
  }));
24884
25157
 
24885
25158
  // src/lib/first-week.ts
@@ -25068,6 +25341,20 @@ function hierarchySlice({ get, write }) {
25068
25341
  charter: withCharter(p.charter, patch, by, stamp())
25069
25342
  } : p) });
25070
25343
  },
25344
+ setLandfall: (projectId, input) => {
25345
+ const by = get().currentUserId;
25346
+ write({ projects: get().projects.map((p) => p.id === projectId ? {
25347
+ ...p,
25348
+ landfall: input ? withLandfall(p.landfall, input, by, stamp()) : null
25349
+ } : p) });
25350
+ },
25351
+ answerLandfall: (projectId, answer) => {
25352
+ const by = get().currentUserId;
25353
+ write({ projects: get().projects.map((p) => p.id === projectId && p.landfall ? {
25354
+ ...p,
25355
+ landfall: withLandfallAnswer(p.landfall, answer, by, stamp())
25356
+ } : p) });
25357
+ },
25071
25358
  addCustomField: (projectId, def) => {
25072
25359
  const id = uid("cf");
25073
25360
  write({ projects: get().projects.map((p) => p.id === projectId ? {
@@ -25429,6 +25716,7 @@ var init_hierarchy = __esmMin((() => {
25429
25716
  init_scuttle_rows();
25430
25717
  init_hill();
25431
25718
  init_charter_lines();
25719
+ init_landfall_lines();
25432
25720
  init_format();
25433
25721
  init_empty();
25434
25722
  init_first_week();
@@ -26327,6 +26615,28 @@ var init_raid_history = __esmMin((() => {}));
26327
26615
 
26328
26616
  // src/lib/slices/milestones-raid.ts
26329
26617
  function milestonesRaidSlice({ get, write }) {
26618
+ /** A new message in one conversation, or an answer under one of its messages. */
26619
+ const post = (place, name, body, replyToId) => {
26620
+ const s = get();
26621
+ if (messageProblem(body)) return null;
26622
+ const root = replyRoot(s.reflections, place, replyToId);
26623
+ const me = s.currentUserId;
26624
+ const text = messageText(body);
26625
+ const row = {
26626
+ id: uid("msg"),
26627
+ projectId: place.kind === "project" ? place.id : "",
26628
+ ...place.kind === "program" ? { programId: place.id } : {},
26629
+ kind: "message",
26630
+ authorId: me,
26631
+ body: text,
26632
+ createdAt: stamp(),
26633
+ mentionedIds: messageMentions(text, s.people, me),
26634
+ ...root ? { replyToId: root } : {}
26635
+ };
26636
+ write({ reflections: [...s.reflections ?? [], row] });
26637
+ get().log(`${root ? "replied" : "wrote"} in ${name}’s messages`, placePath(place));
26638
+ return row.id;
26639
+ };
26330
26640
  return {
26331
26641
  toggleMilestone: (id) => {
26332
26642
  if (get().milestones.find((m) => m.id === id)?.approverId) return;
@@ -26632,25 +26942,21 @@ function milestonesRaidSlice({ get, write }) {
26632
26942
  write({ reflections: [...s.reflections ?? [], row] });
26633
26943
  return row.id;
26634
26944
  },
26635
- postMessage: (projectId, body) => {
26636
- const s = get();
26637
- if (messageProblem(body)) return null;
26638
- const project = s.projects.find((p) => p.id === projectId);
26945
+ postMessage: (projectId, body, replyToId) => {
26946
+ const project = get().projects.find((p) => p.id === projectId);
26639
26947
  if (!project) return null;
26640
- const me = s.currentUserId;
26641
- const text = messageText(body);
26642
- const row = {
26643
- id: uid("msg"),
26644
- projectId,
26645
- kind: "message",
26646
- authorId: me,
26647
- body: text,
26648
- createdAt: stamp(),
26649
- mentionedIds: messageMentions(text, s.people, me)
26650
- };
26651
- write({ reflections: [...s.reflections ?? [], row] });
26652
- get().log(`wrote in ${project.name}’s messages`, `/projects/${projectId}?view=messages`);
26653
- return row.id;
26948
+ return post({
26949
+ kind: "project",
26950
+ id: projectId
26951
+ }, project.name, body, replyToId);
26952
+ },
26953
+ postProgramMessage: (programId, body, replyToId) => {
26954
+ const program = get().programs.find((p) => p.id === programId);
26955
+ if (!program) return null;
26956
+ return post({
26957
+ kind: "program",
26958
+ id: programId
26959
+ }, program.name, body, replyToId);
26654
26960
  },
26655
26961
  editMessage: (id, body) => {
26656
26962
  const s = get();
@@ -28133,6 +28439,11 @@ var init_shore = __esmMin((() => {
28133
28439
  kind: "notes",
28134
28440
  label: "Notes for later, when they open",
28135
28441
  hint: "A note someone left on one of its projects, posted the day it opens."
28442
+ },
28443
+ {
28444
+ kind: "messages",
28445
+ label: "Messages",
28446
+ hint: "Each new message and reply in the program’s messages and its projects’, with a link to answer in Harbor."
28136
28447
  }
28137
28448
  ];
28138
28449
  }));
@@ -28216,6 +28527,7 @@ function memoryHarbor(start, opts) {
28216
28527
  forecast: false,
28217
28528
  health: false,
28218
28529
  notes: false,
28530
+ messages: false,
28219
28531
  ...row,
28220
28532
  ...ask.url ? { url } : {}
28221
28533
  };
@@ -28230,6 +28542,7 @@ function memoryHarbor(start, opts) {
28230
28542
  forecast: row.forecast,
28231
28543
  health: row.health,
28232
28544
  notes: row.notes,
28545
+ messages: row.messages,
28233
28546
  failures: 0
28234
28547
  } : null;
28235
28548
  if (ask.test) {
@@ -34570,6 +34883,7 @@ function sinceText(iso, now) {
34570
34883
 
34571
34884
  // src/tui/watch-run.ts
34572
34885
  init_clock();
34886
+ init_session();
34573
34887
  init_plain();
34574
34888
  init_repo_sync();
34575
34889
  init_store();
@@ -34636,7 +34950,18 @@ async function watchOn(opts) {
34636
34950
  if (earlier.length === 0) out(dim(since ? `Nothing since ${since}.` : "Nothing logged yet."));
34637
34951
  else out(dim(since ? `Since ${since}:` : "Lately:"));
34638
34952
  }
34953
+ const log = store.getState().activity;
34954
+ const oldest = log.reduce((o, a) => !o || a.at < o ? a.at : o, null);
34955
+ const cut = oldest && log.length >= 40 && (!opts.since || opts.since < oldest) ? oldest : null;
34956
+ const capped = !opts.since && !project && log.length > earlier.length;
34957
+ if (opts.json) {
34958
+ if (cut) out(JSON.stringify({
34959
+ note: `Only the latest ${40} changes are kept, back to ${cut}; History on the website has every one.`,
34960
+ keptFrom: cut
34961
+ }));
34962
+ } else if (opts.since && cut) out(dim(`Only the latest ${40} changes are kept, back to ${wallClock(new Date(cut)).slice(0, 16).replace("T", " ")}; History on the website has every one.`));
34639
34963
  for (const e of earlier) print(e.line, new Date(e.at), true);
34964
+ if (opts.once && capped && !opts.json) out(dim(`The latest ${earlier.length} of ${log.length}; --since TIME shows every one since then.`));
34640
34965
  if (opts.once) return;
34641
34966
  if (!opts.json && earlier.length > 0) out(dim("New changes print below as they land."));
34642
34967
  const narrow = (d) => project ? {