@harborppm/cli 0.96.0 → 0.98.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
@@ -2688,13 +2688,13 @@ var init_holidays = __esmMin((() => {
2688
2688
  // src/lib/tide.ts
2689
2689
  /** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
2690
2690
  function weeklyFinished(tasks, startDate, now, weeks = 6) {
2691
- const today = noon$3(todayISO(now));
2692
- const age = Math.floor(differenceInCalendarDays(today, noon$3(startDate)) / 7) + 1;
2691
+ const today = noon$4(todayISO(now));
2692
+ const age = Math.floor(differenceInCalendarDays(today, noon$4(startDate)) / 7) + 1;
2693
2693
  const span = Math.max(1, Math.min(weeks, age));
2694
2694
  const counts = new Array(span).fill(0);
2695
2695
  for (const t of tasks) {
2696
2696
  if (t.status !== "done") continue;
2697
- const at = t.completedAt ? noon$3(t.completedAt) : today;
2697
+ const at = t.completedAt ? noon$4(t.completedAt) : today;
2698
2698
  const back = Math.max(0, differenceInCalendarDays(today, at));
2699
2699
  const week = Math.floor(back / 7);
2700
2700
  if (week < span) counts[span - 1 - week] += 1;
@@ -2872,7 +2872,7 @@ function tideOptions(data) {
2872
2872
  function tideForecast(project, tasks, now, opts = {}) {
2873
2873
  if (tasks.length === 0) return { kind: "empty" };
2874
2874
  const remaining = tasks.filter((t) => t.status !== "done").length;
2875
- if (remaining === 0) return { kind: "done" };
2875
+ if (remaining === 0 || project.health === "done") return { kind: "done" };
2876
2876
  if (project.health === "paused") return {
2877
2877
  kind: "paused",
2878
2878
  remaining
@@ -2885,7 +2885,7 @@ function tideForecast(project, tasks, now, opts = {}) {
2885
2885
  finished
2886
2886
  };
2887
2887
  const today = todayISO(now);
2888
- const base = noon$3(today);
2888
+ const base = noon$4(today);
2889
2889
  const { shares, crew } = weekShares(tasks, base, opts.calendar);
2890
2890
  const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1), shares);
2891
2891
  const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
@@ -2894,12 +2894,12 @@ function tideForecast(project, tasks, now, opts = {}) {
2894
2894
  if (t.status === "done" || !t.dueDate) continue;
2895
2895
  if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
2896
2896
  }
2897
- const floor = lastDue ? differenceInCalendarDays(noon$3(lastDue.dueDate), base) : 0;
2897
+ const floor = lastDue ? differenceInCalendarDays(noon$4(lastDue.dueDate), base) : 0;
2898
2898
  const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`), opts.calendar);
2899
2899
  const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
2900
2900
  const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
2901
2901
  const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
2902
- const toEnd = differenceInCalendarDays(noon$3(project.endDate), base);
2902
+ const toEnd = differenceInCalendarDays(noon$4(project.endDate), base);
2903
2903
  const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
2904
2904
  const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
2905
2905
  const curve = [];
@@ -2952,7 +2952,7 @@ function tideQuiet(tide) {
2952
2952
  default: return null;
2953
2953
  }
2954
2954
  }
2955
- var TIDE_WEEKS, TRIALS, MAX_WEEKS, ROUGH_BELOW, TIDE_MIN_FINISHED, noon$3;
2955
+ var TIDE_WEEKS, TRIALS, MAX_WEEKS, ROUGH_BELOW, TIDE_MIN_FINISHED, noon$4;
2956
2956
  var init_tide = __esmMin((() => {
2957
2957
  init_date_fns();
2958
2958
  init_format();
@@ -2963,7 +2963,7 @@ var init_tide = __esmMin((() => {
2963
2963
  MAX_WEEKS = 104;
2964
2964
  ROUGH_BELOW = 5;
2965
2965
  TIDE_MIN_FINISHED = 3;
2966
- noon$3 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
2966
+ noon$4 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
2967
2967
  }));
2968
2968
 
2969
2969
  // src/lib/barnacles.ts
@@ -2980,7 +2980,7 @@ function planSetAt(project, tasks) {
2980
2980
  for (const t of tasks) if (!first || t.createdAt < first) first = t.createdAt;
2981
2981
  first = (first ?? project.startDate).slice(0, 10);
2982
2982
  return {
2983
- since: `${isoDay$3(addDays(noon$2(first), 7))}T00:00:00`,
2983
+ since: `${isoDay$3(addDays(noon$3(first), 7))}T00:00:00`,
2984
2984
  from: "start"
2985
2985
  };
2986
2986
  }
@@ -2992,20 +2992,20 @@ function planSetAt(project, tasks) {
2992
2992
  function barnaclesOf(project, tasks, people, now, slip = null) {
2993
2993
  const { since, from } = planSetAt(project, tasks);
2994
2994
  const today = todayISO(now);
2995
- const weekAgo = isoDay$3(addDays(noon$2(today), -7));
2995
+ const weekAgo = isoDay$3(addDays(noon$3(today), -7));
2996
2996
  let planned = 0;
2997
2997
  const added = [];
2998
2998
  for (const t of tasks) if (t.createdAt > since) added.push(t);
2999
2999
  else planned += 1;
3000
- const first = startOfWeek(noon$2(since), { weekStartsOn: 1 });
3001
- const span = Math.max(0, Math.floor(differenceInCalendarDays(noon$2(today), first) / 7));
3000
+ const first = startOfWeek(noon$3(since), { weekStartsOn: 1 });
3001
+ const span = Math.max(0, Math.floor(differenceInCalendarDays(noon$3(today), first) / 7));
3002
3002
  const weeks = Array.from({ length: Math.min(span + 1, 104) }, (_, i) => ({
3003
3003
  week: isoDay$3(addDays(first, i * 7)),
3004
3004
  added: 0
3005
3005
  }));
3006
3006
  const counts = /* @__PURE__ */ new Map();
3007
3007
  for (const t of added) {
3008
- const i = Math.floor(differenceInCalendarDays(noon$2(t.createdAt), first) / 7);
3008
+ const i = Math.floor(differenceInCalendarDays(noon$3(t.createdAt), first) / 7);
3009
3009
  const slot = weeks[Math.min(Math.max(0, i), weeks.length - 1)];
3010
3010
  if (slot) slot.added += 1;
3011
3011
  const who = t.createdById ?? null;
@@ -3023,7 +3023,7 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
3023
3023
  const byDay = /* @__PURE__ */ new Map();
3024
3024
  for (const t of added) byDay.set(t.createdAt.slice(0, 10), (byDay.get(t.createdAt.slice(0, 10)) ?? 0) + 1);
3025
3025
  const [bulkDay, bulk] = [...byDay].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
3026
- const age = differenceInCalendarDays(noon$2(today), addDays(noon$2(since), -7)) / 7;
3026
+ const age = differenceInCalendarDays(noon$3(today), addDays(noon$3(since), -7)) / 7;
3027
3027
  let caveat = null;
3028
3028
  if (added.length && bulkDay && bulk >= 5 && bulk >= added.length * .5) caveat = `${bulk} of the ${added.length} came in on ${shortDate(bulkDay)}, which looks like the plan being loaded rather than growth, so treat this as rough. Setting a baseline counts from then on.`;
3029
3029
  else if (added.length && from === "start" && (planned < 5 || age < 3)) caveat = "There's no baseline yet and the plan is young, so its first weeks may be the plan being written: treat this as rough. Setting a baseline counts from then on.";
@@ -3050,8 +3050,8 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
3050
3050
  */
3051
3051
  function slipAgainstPlan(project, tasks, now, tide = tideForecast(project, tasks, now)) {
3052
3052
  const plannedEnd = (project.baselineEnd ?? project.endDate).slice(0, 10);
3053
- const likely = tide.kind === "forecast" && tide.likely ? differenceInCalendarDays(noon$2(tide.likely), noon$2(plannedEnd)) : 0;
3054
- const moved = project.baselineEnd ? differenceInCalendarDays(noon$2(project.endDate), noon$2(project.baselineEnd)) : 0;
3053
+ const likely = tide.kind === "forecast" && tide.likely ? differenceInCalendarDays(noon$3(tide.likely), noon$3(plannedEnd)) : 0;
3054
+ const moved = project.baselineEnd ? differenceInCalendarDays(noon$3(project.endDate), noon$3(project.baselineEnd)) : 0;
3055
3055
  const slip = Math.max(likely, moved);
3056
3056
  return slip > 0 ? slip : null;
3057
3057
  }
@@ -3084,7 +3084,7 @@ function barnaclesShort(b) {
3084
3084
  function briefWorthy(b) {
3085
3085
  return b.from === "baseline" && !b.caveat && b.added >= 3 && (b.share == null || b.share >= .15);
3086
3086
  }
3087
- var SETTLE_DAYS, THIN_PLANNED, THIN_WEEKS, BULK_SHARE, BULK_MIN, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$7;
3087
+ var SETTLE_DAYS, THIN_PLANNED, THIN_WEEKS, BULK_SHARE, BULK_MIN, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$3, isoDay$3, plural$7;
3088
3088
  var init_barnacles = __esmMin((() => {
3089
3089
  init_date_fns();
3090
3090
  init_format();
@@ -3096,7 +3096,7 @@ var init_barnacles = __esmMin((() => {
3096
3096
  BULK_MIN = 5;
3097
3097
  BRIEF_MIN_ADDED = 3;
3098
3098
  BRIEF_MIN_SHARE = .15;
3099
- noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
3099
+ noon$3 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
3100
3100
  isoDay$3 = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
3101
3101
  plural$7 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
3102
3102
  }));
@@ -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;
@@ -5942,18 +5948,18 @@ init_tide();
5942
5948
  const BURN_DAYS = 56;
5943
5949
  /** A window shorter than a week says too little to read a rate from. */
5944
5950
  const MIN_DAYS = 7;
5945
- const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5951
+ const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5946
5952
  /**
5947
5953
  * Spend a day over the last BURN_DAYS (or the project's life, when it's
5948
5954
  * younger), from the ledger; null when nothing was spent in that time.
5949
5955
  */
5950
5956
  function burnPerDay(entries, startDate, now) {
5951
- const today = noon$1(todayISO(now));
5952
- const age = differenceInCalendarDays(today, noon$1(startDate)) + 1;
5957
+ const today = noon$2(todayISO(now));
5958
+ const age = differenceInCalendarDays(today, noon$2(startDate)) + 1;
5953
5959
  const span = Math.max(MIN_DAYS, Math.min(56, age));
5954
5960
  let sum = 0;
5955
5961
  for (const e of entries) {
5956
- const back = differenceInCalendarDays(today, noon$1(e.date));
5962
+ const back = differenceInCalendarDays(today, noon$2(e.date));
5957
5963
  if (back >= 0 && back < span) sum += e.amount;
5958
5964
  }
5959
5965
  return sum > 0 ? sum / span : null;
@@ -5975,8 +5981,8 @@ function slipCost(project, tide, entries, now) {
5975
5981
  const from = project.endDate > today ? project.endDate : today;
5976
5982
  const point = (date) => {
5977
5983
  if (!date) return null;
5978
- const daysLate = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(from)));
5979
- const ahead = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(today)));
5984
+ const daysLate = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(from)));
5985
+ const ahead = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(today)));
5980
5986
  return {
5981
5987
  date,
5982
5988
  daysLate,
@@ -10136,11 +10142,11 @@ function hillText(project) {
10136
10142
  if (at === 100) return "Over the hill: done";
10137
10143
  return `${HILL_PHASE_LABEL[hillPhase(at)]} (${at} of 100)`;
10138
10144
  }
10139
- var HILL_TOP, STILL_DAYS, HILL_PHASE_LABEL;
10145
+ var HILL_TOP, STILL_DAYS$1, HILL_PHASE_LABEL;
10140
10146
  var init_hill = __esmMin((() => {
10141
10147
  init_date_fns();
10142
10148
  HILL_TOP = 50;
10143
- STILL_DAYS = 14;
10149
+ STILL_DAYS$1 = 14;
10144
10150
  HILL_PHASE_LABEL = {
10145
10151
  figuring: "Figuring it out",
10146
10152
  doing: "Getting it done"
@@ -10560,6 +10566,126 @@ 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
10690
  /** This project's messages, oldest first. */
10565
10691
  function messagesOf(reflections, projectId) {
@@ -10682,6 +10808,7 @@ const KIND_RANK = {
10682
10808
  waiting: 0,
10683
10809
  approval: 1,
10684
10810
  review: 2,
10811
+ landfall: 2.5,
10685
10812
  sounding: 3,
10686
10813
  assigned: 4,
10687
10814
  idle: 5,
@@ -10694,6 +10821,7 @@ const PING_KIND_LABEL = {
10694
10821
  waiting: "Waiting on you",
10695
10822
  approval: "Sign-offs",
10696
10823
  review: "Reviews due",
10824
+ landfall: "Landfall",
10697
10825
  sounding: "Soundings",
10698
10826
  assigned: "Assigned to you",
10699
10827
  idle: "Quiet blockers",
@@ -10827,6 +10955,41 @@ function inboxOf(data, personId, now = clockNow()) {
10827
10955
  personId: null
10828
10956
  });
10829
10957
  }
10958
+ const askSince = todayISO(subDays(now, 14));
10959
+ for (const project of data.projects) {
10960
+ if (project.health !== "done" && !project.landfall?.result) continue;
10961
+ if (landfallAskee(data, project)?.id !== personId) continue;
10962
+ const tasks = tasksOf(data.tasks, project.id);
10963
+ const state = landfallState(project, tasks, today);
10964
+ if (state === "unset") {
10965
+ const finished = finishedOn(project, tasks);
10966
+ if (finished < askSince) continue;
10967
+ pings.push({
10968
+ id: `landfall-set:${project.id}:${finished}`,
10969
+ kind: "landfall",
10970
+ title: `${project.name} is done. What should landfall look like?`,
10971
+ detail: "Say what should be true in 30, 90 or 180 days, and Harbor asks you then.",
10972
+ at: finished,
10973
+ projectId: project.id,
10974
+ landfall: "set",
10975
+ personId: null,
10976
+ href: `/projects/${project.id}?view=log`
10977
+ });
10978
+ } else if (state === "due") {
10979
+ const on = landfallCheckOn(project, tasks);
10980
+ pings.push({
10981
+ id: `landfall:${project.id}:${on}`,
10982
+ kind: "landfall",
10983
+ title: `Did ${project.name} reach landfall?`,
10984
+ detail: `“${project.landfall.expect}”`,
10985
+ at: on,
10986
+ projectId: project.id,
10987
+ landfall: "check",
10988
+ personId: null,
10989
+ href: `/projects/${project.id}?view=log`
10990
+ });
10991
+ }
10992
+ }
10830
10993
  for (const constraint of constraintsOwnedBy(data, personId)) {
10831
10994
  if (!constraintIdle(data, constraint)) continue;
10832
10995
  const program = data.programs.find((p) => p.id === constraint.programId);
@@ -14807,6 +14970,12 @@ function taskMinutes(task) {
14807
14970
  function entriesNewestFirst(task) {
14808
14971
  return [...task.time ?? []].sort((a, b) => (b.on + b.at).localeCompare(a.on + a.at));
14809
14972
  }
14973
+ /** The ISO day `n` days before `iso`. */
14974
+ function daysBefore(iso, n) {
14975
+ const d = /* @__PURE__ */ new Date(`${iso}T12:00:00Z`);
14976
+ d.setUTCDate(d.getUTCDate() - n);
14977
+ return d.toISOString().slice(0, 10);
14978
+ }
14810
14979
 
14811
14980
  // src/lib/goals.ts
14812
14981
  init_indexes();
@@ -20783,8 +20952,8 @@ init_date_fns();
20783
20952
  init_blockers();
20784
20953
  init_format();
20785
20954
  init_indexes();
20786
- const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
20787
- const days$2 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
20955
+ const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
20956
+ const days$2 = (from, to) => differenceInCalendarDays(noon$1(to), noon$1(from));
20788
20957
  const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
20789
20958
  const open = (t) => t.status !== "done";
20790
20959
  /** True when `a` finishes later than `b`, or as late behind a longer line. */
@@ -21815,8 +21984,349 @@ var init_fog = __esmMin((() => {
21815
21984
  fileCache = /* @__PURE__ */ new WeakMap();
21816
21985
  }));
21817
21986
 
21818
- // src/lib/task-approval.ts
21987
+ // src/lib/assignment.ts
21988
+ /**
21989
+ * Record who set a task's assignee and when. Only a change of assignee
21990
+ * stamps; clearing the assignee clears the stamp.
21991
+ */
21992
+ function stampAssignment(before, next, by, at) {
21993
+ if (before && before.assigneeId === next.assigneeId) return next;
21994
+ if (!next.assigneeId) return {
21995
+ ...next,
21996
+ assignedBy: null,
21997
+ assignedAt: null
21998
+ };
21999
+ return {
22000
+ ...next,
22001
+ assignedBy: by,
22002
+ assignedAt: at
22003
+ };
22004
+ }
22005
+ var init_assignment = __esmMin((() => {}));
22006
+
22007
+ // src/lib/scuttle-rows.ts
22008
+ /** Whether it's scuttled: closed through Scuttle or sail and not raised since. */
22009
+ function isScuttled(project) {
22010
+ return Boolean(project.scuttled) && project.healthOverride === "done";
22011
+ }
22012
+ /**
22013
+ * Scuttle a project: Done by hand with the reason as its note, its open
22014
+ * tasks unassigned and undated (kept in `scuttled.freed`), and a decision on
22015
+ * the register. Null for a project that's already scuttled or gone, or a
22016
+ * blank why.
22017
+ */
22018
+ function scuttleRows(data, projectId, why, byId, at, decisionId, context = "Closed through Scuttle or sail.") {
22019
+ const project = data.projects.find((p) => p.id === projectId);
22020
+ const reason = why.trim().slice(0, 600);
22021
+ if (!project || !reason || isScuttled(project)) return null;
22022
+ const freed = [];
22023
+ const tasks = [];
22024
+ for (const t of tasksOf(data.tasks, projectId)) {
22025
+ if (t.status === "done" || !t.assigneeId && !t.dueDate) continue;
22026
+ freed.push({
22027
+ taskId: t.id,
22028
+ assigneeId: t.assigneeId,
22029
+ dueDate: t.dueDate
22030
+ });
22031
+ tasks.push(stampAssignment(t, {
22032
+ ...t,
22033
+ assigneeId: null,
22034
+ dueDate: null,
22035
+ touchedAt: at
22036
+ }, byId, at));
22037
+ }
22038
+ const decision = normalizeRaidItem({
22039
+ id: decisionId,
22040
+ kind: "decision",
22041
+ projectId,
22042
+ title: `Scuttle ${project.name}`,
22043
+ description: context,
22044
+ status: "decided",
22045
+ severity: "low",
22046
+ ownerId: byId,
22047
+ dueDate: null,
22048
+ resolution: "Stop the project. Its open tasks come off people’s lists.",
22049
+ rationale: reason,
22050
+ decidedById: byId,
22051
+ decidedAt: at,
22052
+ createdById: byId,
22053
+ createdAt: at,
22054
+ updatedAt: at,
22055
+ updates: []
22056
+ });
22057
+ return {
22058
+ project: {
22059
+ ...project,
22060
+ health: "done",
22061
+ healthOverride: "done",
22062
+ healthNote: `Scuttled: ${reason}`,
22063
+ scuttled: {
22064
+ at,
22065
+ byId,
22066
+ why: reason,
22067
+ decisionId,
22068
+ freed
22069
+ }
22070
+ },
22071
+ tasks,
22072
+ decision
22073
+ };
22074
+ }
22075
+ /**
22076
+ * Raise a scuttled project: the scuttle cleared (the caller clears its Done), and each
22077
+ * freed task given back its person and date, unless someone has since given
22078
+ * it others, it's done, or the person has left the crew. Null when it isn't
22079
+ * scuttled.
22080
+ */
22081
+ function raiseRows(data, projectId, at, byId) {
22082
+ const project = data.projects.find((p) => p.id === projectId);
22083
+ if (!project?.scuttled) return null;
22084
+ const people = new Set(data.people.map((p) => p.id));
22085
+ const byTask = new Map(project.scuttled.freed.map((f) => [f.taskId, f]));
22086
+ const tasks = [];
22087
+ for (const t of tasksOf(data.tasks, projectId)) {
22088
+ const f = byTask.get(t.id);
22089
+ if (!f || t.status === "done") continue;
22090
+ const assigneeId = !t.assigneeId && f.assigneeId && people.has(f.assigneeId) ? f.assigneeId : t.assigneeId;
22091
+ const dueDate = t.dueDate ?? f.dueDate;
22092
+ if (assigneeId === t.assigneeId && dueDate === t.dueDate) continue;
22093
+ tasks.push(stampAssignment(t, {
22094
+ ...t,
22095
+ assigneeId,
22096
+ dueDate,
22097
+ touchedAt: at
22098
+ }, byId, at));
22099
+ }
22100
+ return {
22101
+ project: {
22102
+ ...project,
22103
+ scuttled: null,
22104
+ voyageCall: {
22105
+ call: "sail",
22106
+ at,
22107
+ byId
22108
+ }
22109
+ },
22110
+ tasks
22111
+ };
22112
+ }
22113
+ var WHY_MAX;
22114
+ var init_scuttle_rows = __esmMin((() => {
22115
+ init_assignment();
22116
+ init_indexes();
22117
+ init_raid();
22118
+ WHY_MAX = 600;
22119
+ }));
22120
+
22121
+ // src/lib/scuttle.ts
22122
+ init_date_fns();
21819
22123
  init_fog();
22124
+ init_indexes();
22125
+ init_scuttle_rows();
22126
+ init_spend();
22127
+ init_format();
22128
+ /** Days with nothing moving before a project reads as still. */
22129
+ const STILL_DAYS = 28;
22130
+ /** A project younger than this is left alone: it's still being set up. */
22131
+ const YOUNG_DAYS = 21;
22132
+ /** Sailing on or a refit keeps it out of the line-up this long. */
22133
+ const QUIET_DAYS = 90;
22134
+ /** Slips are counted over this window. */
22135
+ const SLIP_WINDOW_DAYS = 90;
22136
+ /** This many falls into trouble in the window reads as slipping again and again. */
22137
+ const SLIP_TIMES = 3;
22138
+ /** Its end date this far past the baseline reads as drifting. */
22139
+ const DRIFT_DAYS = 21;
22140
+ /** This far past its end date with work open. */
22141
+ const OVERDUE_DAYS = 14;
22142
+ /** Paused this long. */
22143
+ const PAUSED_DAYS = 30;
22144
+ const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
22145
+ const daysSince = (iso, today) => differenceInCalendarDays(noon(today), noon(iso));
22146
+ const TRACK = {
22147
+ "on-track": 0,
22148
+ "at-risk": 1,
22149
+ "off-track": 2
22150
+ };
22151
+ /** When anything last moved on the project: its tasks (as fog reads them) and its hill dot. */
22152
+ function lastMoved(project, data) {
22153
+ let last = project.hillMovedAt ?? null;
22154
+ for (const t of tasksOf(data.tasks, project.id)) {
22155
+ const at = lastTouch(t, data);
22156
+ if (!last || at > last) last = at;
22157
+ }
22158
+ return last;
22159
+ }
22160
+ /** Times its health fell (on track to at risk or off track, at risk to off track) since `since`. */
22161
+ function slipsSince(project, since) {
22162
+ let times = 0;
22163
+ let prev;
22164
+ for (const line of project.healthHistory ?? []) {
22165
+ const rank = TRACK[line.health];
22166
+ if (rank === void 0) {
22167
+ prev = void 0;
22168
+ continue;
22169
+ }
22170
+ if (prev !== void 0 && rank > prev && line.at.slice(0, 10) >= since) times += 1;
22171
+ prev = rank;
22172
+ }
22173
+ return times;
22174
+ }
22175
+ /** Days it has been paused, from the history's latest line saying so; null when it isn't, or nobody knows since when. */
22176
+ function pausedDays(project, today) {
22177
+ if (project.health !== "paused") return null;
22178
+ const history = project.healthHistory ?? [];
22179
+ let since = null;
22180
+ for (let i = history.length - 1; i >= 0; i--) {
22181
+ if (history[i].health !== "paused") break;
22182
+ since = history[i].at;
22183
+ }
22184
+ return since ? Math.max(0, daysSince(since, today)) : null;
22185
+ }
22186
+ /** A goal's work key result names it or its program. Only asked when its portfolio has goals at all. */
22187
+ function aimless(project, data) {
22188
+ const program = data.programs.find((p) => p.id === project.programId);
22189
+ const goals = (program && data.portfolios.find((p) => p.id === program.portfolioId))?.goals ?? [];
22190
+ if (goals.length === 0) return false;
22191
+ return !goals.some((g) => g.keyResults.some((kr) => kr.kind === "work" && (kr.linkIds ?? []).some((id) => id === project.id || id === project.programId)));
22192
+ }
22193
+ const STRENGTH = {
22194
+ overdue: 0,
22195
+ still: 1,
22196
+ empty: 1,
22197
+ paused: 2,
22198
+ slipping: 3,
22199
+ drifted: 4,
22200
+ aimless: 5
22201
+ };
22202
+ /** The signs that put a project in the line-up today, strongest first; empty when it's sailing fine. */
22203
+ function zombieSigns(project, data, today) {
22204
+ const signs = [];
22205
+ const tasks = tasksOf(data.tasks, project.id);
22206
+ const open = tasks.filter((t) => t.status !== "done").length;
22207
+ const age = daysSince(project.startDate, today);
22208
+ if (tasks.length === 0) {
22209
+ if (age >= 28) signs.push({
22210
+ kind: "empty",
22211
+ days: age
22212
+ });
22213
+ } else if (project.health !== "paused") {
22214
+ const moved = lastMoved(project, data);
22215
+ const still = moved ? daysSince(moved, today) : age;
22216
+ if (still >= 28) signs.push({
22217
+ kind: "still",
22218
+ days: still
22219
+ });
22220
+ }
22221
+ const paused = pausedDays(project, today);
22222
+ if (paused !== null && paused >= 30) signs.push({
22223
+ kind: "paused",
22224
+ days: paused
22225
+ });
22226
+ const late = daysSince(project.endDate, today);
22227
+ if (open > 0 && late >= 14 && project.health !== "paused") signs.push({
22228
+ kind: "overdue",
22229
+ days: late,
22230
+ open
22231
+ });
22232
+ const slips = slipsSince(project, daysBefore(today, 90));
22233
+ if (slips >= 3) signs.push({
22234
+ kind: "slipping",
22235
+ times: slips
22236
+ });
22237
+ if (project.baselineEnd) {
22238
+ const drift = differenceInCalendarDays(noon(project.endDate), noon(project.baselineEnd));
22239
+ if (drift >= 21) signs.push({
22240
+ kind: "drifted",
22241
+ days: drift
22242
+ });
22243
+ }
22244
+ if (signs.length > 0 && aimless(project, data)) signs.push({ kind: "aimless" });
22245
+ return signs.sort((a, b) => STRENGTH[a.kind] - STRENGTH[b.kind]);
22246
+ }
22247
+ /** Left out of the line-up: finished, scuttled, too young, or answered within the quarter. */
22248
+ function restingFromReview(project, today) {
22249
+ if (project.health === "done" || isScuttled(project)) return true;
22250
+ if (project.startDate > today || daysSince(project.startDate, today) < 21) return true;
22251
+ const call = project.voyageCall;
22252
+ return Boolean(call && daysSince(call.at, today) < 90);
22253
+ }
22254
+ /** What scuttling it would free: open tasks by person, logged hours and spend a week. */
22255
+ function whatItHolds(project, data, now) {
22256
+ const today = todayISO(now);
22257
+ const tasks = tasksOf(data.tasks, project.id);
22258
+ const by = /* @__PURE__ */ new Map();
22259
+ let openTasks = 0;
22260
+ let minutes = 0;
22261
+ const from = daysBefore(today, 27);
22262
+ for (const t of tasks) {
22263
+ for (const e of t.time ?? []) if (e.on >= from && e.on <= today) minutes += e.minutes;
22264
+ if (t.status === "done") continue;
22265
+ openTasks += 1;
22266
+ if (t.assigneeId) by.set(t.assigneeId, (by.get(t.assigneeId) ?? 0) + 1);
22267
+ }
22268
+ const crew = [...by].map(([personId, open]) => ({
22269
+ personId,
22270
+ open
22271
+ })).sort((a, b) => b.open - a.open);
22272
+ const burn = project.budget > 0 ? burnPerDay(spendEntriesOf({ spendEntries: data.spendEntries ?? [] }, project.id), project.startDate, now) : null;
22273
+ return {
22274
+ openTasks,
22275
+ crew,
22276
+ hoursPerWeek: minutes > 0 ? Math.round(minutes / 60 / 4 * 10) / 10 : null,
22277
+ spendPerWeek: burn ? Math.round(burn * 7) : null
22278
+ };
22279
+ }
22280
+ /**
22281
+ * The line-up: projects showing a zombie sign that aren't resting, the ones
22282
+ * with the most signs first, then the stillest. `projectIds` narrows it to a
22283
+ * portfolio's or program's; leave it out for the whole harbor.
22284
+ */
22285
+ function scuttleLineUp(data, now, projectIds) {
22286
+ const today = todayISO(now);
22287
+ const out = [];
22288
+ for (const project of data.projects) {
22289
+ if (projectIds && !projectIds.has(project.id)) continue;
22290
+ if (restingFromReview(project, today)) continue;
22291
+ const signs = zombieSigns(project, data, today);
22292
+ if (signs.length === 0) continue;
22293
+ out.push({
22294
+ project,
22295
+ signs,
22296
+ lastMoved: lastMoved(project, data),
22297
+ ...whatItHolds(project, data, now)
22298
+ });
22299
+ }
22300
+ const still = (z) => {
22301
+ const s = z.signs.find((x) => x.kind === "still" || x.kind === "empty");
22302
+ return s && "days" in s ? s.days : 0;
22303
+ };
22304
+ return out.sort((a, b) => b.signs.length - a.signs.length || still(b) - still(a) || a.project.name.localeCompare(b.project.name));
22305
+ }
22306
+ /** The projects in a portfolio or a program, for narrowing the line-up. */
22307
+ function projectIdsUnder(data, scope) {
22308
+ const programs = new Set(scope.programId ? [scope.programId] : data.programs.filter((p) => p.portfolioId === scope.portfolioId).map((p) => p.id));
22309
+ return new Set(data.projects.filter((p) => programs.has(p.programId)).map((p) => p.id));
22310
+ }
22311
+ /** "6 weeks", "10 days". */
22312
+ function spanText(days) {
22313
+ if (days >= 14) return `${Math.floor(days / 7)} weeks`;
22314
+ return `${days} day${days === 1 ? "" : "s"}`;
22315
+ }
22316
+ /** One sign in a short sentence. */
22317
+ function signText(sign) {
22318
+ switch (sign.kind) {
22319
+ case "still": return `Nothing has moved in ${spanText(sign.days)}`;
22320
+ case "empty": return `No tasks, ${spanText(sign.days)} after it started`;
22321
+ case "slipping": return `Slipped into trouble ${sign.times} times in three months`;
22322
+ case "drifted": return `Its end date has moved ${spanText(sign.days)} past the plan`;
22323
+ case "overdue": return `${spanText(sign.days)} past its end date, ${sign.open} task${sign.open === 1 ? "" : "s"} still open`;
22324
+ case "paused": return `Paused for ${spanText(sign.days)}`;
22325
+ case "aimless": return "No goal behind it";
22326
+ }
22327
+ }
22328
+
22329
+ // src/lib/task-approval.ts
21820
22330
  /** Waits on its approver now: asked, not yet answered, and not done. */
21821
22331
  function awaitingApproval(task) {
21822
22332
  return Boolean(task?.approval && task.approval.status === "pending" && task.status !== "done");
@@ -21853,6 +22363,7 @@ init_handover();
21853
22363
  init_agents();
21854
22364
  init_work();
21855
22365
  init_task_people();
22366
+ init_fog();
21856
22367
  init_passage();
21857
22368
  init_board();
21858
22369
  init_holidays();
@@ -22480,7 +22991,7 @@ const HARBOR_TOOLS = [
22480
22991
  tool({
22481
22992
  name: "get_project",
22482
22993
  title: "Project details",
22483
- 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,
22994
+ 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,
22484
22995
  input: object({
22485
22996
  harbor: harborArg,
22486
22997
  project: string().min(1).max(200).describe("Project id or name.")
@@ -22544,6 +23055,7 @@ const HARBOR_TOOLS = [
22544
23055
  ...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
22545
23056
  finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
22546
23057
  ...charterOf(p, tasks),
23058
+ landfall: landfallJson(p, tasks, today, data.people),
22547
23059
  ...notesOf(ctx, p),
22548
23060
  ...hillOf(p, today),
22549
23061
  ...webLink(ctx, { project: p.id })
@@ -23693,6 +24205,53 @@ const HARBOR_TOOLS = [
23693
24205
  };
23694
24206
  }
23695
24207
  }),
24208
+ tool({
24209
+ name: "scuttle_or_sail",
24210
+ title: "Projects that look adrift",
24211
+ description: "Scuttle or sail: running projects that look like zombies (nothing moved for four weeks, no tasks a month in, two weeks past the end with work open, paused for a month, slipped into trouble three times in three months, or the end date moved three weeks past the baseline; \"no goal behind it\" is added when the portfolio keeps goals), the most signs first. Each says what stopping it would free: open tasks by person, logged hours a week, spend a week. Projects someone kept or refitted in the last three months, finished ones and ones under three weeks old are left out. Walk the person through them one at a time; answer_scuttle_or_sail records sail on, refit or scuttle, only on their word." + DATA_NOTE,
24212
+ input: object({
24213
+ harbor: harborArg,
24214
+ portfolio: string().max(200).optional().describe("Portfolio id or name, for its projects only."),
24215
+ program: string().max(200).optional().describe("Program id or name, for its projects only."),
24216
+ limit: limitArg(100, 25),
24217
+ offset: offsetArg
24218
+ }),
24219
+ run: (ctx, input) => {
24220
+ const { data } = ctx;
24221
+ const program = input.program ? findOne(data.programs, input.program, "program") : null;
24222
+ const portfolio = !program && input.portfolio ? findOne(data.portfolios, input.portfolio, "portfolio") : null;
24223
+ const ids = program || portfolio ? projectIdsUnder(data, {
24224
+ programId: program?.id,
24225
+ portfolioId: portfolio?.id
24226
+ }) : void 0;
24227
+ const lineUp = scuttleLineUp(data, ctx.now, ids);
24228
+ const people = byId(data.people);
24229
+ return {
24230
+ ...program ? { program: program.name } : portfolio ? { portfolio: portfolio.name } : {},
24231
+ adrift: lineUp.length,
24232
+ projects: lineUp.slice(input.offset, input.offset + input.limit).map((z) => ({
24233
+ project: z.project.name,
24234
+ ...z.project.key ? { key: z.project.key } : {},
24235
+ health: z.project.health,
24236
+ endDate: z.project.endDate,
24237
+ owner: people.get(z.project.ownerId)?.name ?? null,
24238
+ signs: z.signs.map((s) => ({
24239
+ kind: s.kind,
24240
+ text: signText(s)
24241
+ })),
24242
+ lastMoved: dayOf(z.lastMoved),
24243
+ openTasks: z.openTasks,
24244
+ crew: z.crew.map((c) => ({
24245
+ person: people.get(c.personId)?.name ?? null,
24246
+ openTasks: c.open
24247
+ })),
24248
+ hoursPerWeek: z.hoursPerWeek,
24249
+ spendPerWeek: z.spendPerWeek
24250
+ })),
24251
+ more: lineUp.length > input.offset + input.limit
24252
+ };
24253
+ }
24254
+ }),
23696
24255
  tool({
23697
24256
  name: "lifeboats",
23698
24257
  title: "Work only one person knows",
@@ -24487,26 +25046,6 @@ var init_session = __esmMin((() => {
24487
25046
  init_format();
24488
25047
  }));
24489
25048
 
24490
- // src/lib/assignment.ts
24491
- /**
24492
- * Record who set a task's assignee and when. Only a change of assignee
24493
- * stamps; clearing the assignee clears the stamp.
24494
- */
24495
- function stampAssignment(before, next, by, at) {
24496
- if (before && before.assigneeId === next.assigneeId) return next;
24497
- if (!next.assigneeId) return {
24498
- ...next,
24499
- assignedBy: null,
24500
- assignedAt: null
24501
- };
24502
- return {
24503
- ...next,
24504
- assignedBy: by,
24505
- assignedAt: at
24506
- };
24507
- }
24508
- var init_assignment = __esmMin((() => {}));
24509
-
24510
25049
  // src/lib/first-week.ts
24511
25050
  /** The program launch named for you, when nobody has renamed it yet. */
24512
25051
  function starterProgram(data) {
@@ -24518,6 +25057,33 @@ var init_first_week = __esmMin((() => {
24518
25057
 
24519
25058
  // src/lib/slices/hierarchy.ts
24520
25059
  function hierarchySlice({ get, write }) {
25060
+ /**
25061
+ * A scuttled project being reopened (its Done cleared or changed): its
25062
+ * freed tasks get their people and dates back, and its decision says so.
25063
+ * Null when it isn't scuttled.
25064
+ */
25065
+ const raise = (projectId, at) => {
25066
+ const s = get();
25067
+ const rows = raiseRows(s, projectId, at, s.currentUserId);
25068
+ if (!rows) return null;
25069
+ const changed = new Map(rows.tasks.map((t) => [t.id, t]));
25070
+ const decisionId = s.projects.find((p) => p.id === projectId)?.scuttled?.decisionId;
25071
+ const note = {
25072
+ id: uid("ru"),
25073
+ authorId: s.currentUserId,
25074
+ body: "Raised again: the project is back, and its tasks went back to their people.",
25075
+ createdAt: at
25076
+ };
25077
+ return {
25078
+ projects: s.projects.map((p) => p.id === projectId ? rows.project : p),
25079
+ tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
25080
+ raidItems: s.raidItems.map((r) => r.id === decisionId ? {
25081
+ ...r,
25082
+ updates: [...r.updates, note],
25083
+ updatedAt: at
25084
+ } : r)
25085
+ };
25086
+ };
24521
25087
  return {
24522
25088
  addPortfolio: (p) => {
24523
25089
  const id = uid("pf");
@@ -24666,6 +25232,20 @@ function hierarchySlice({ get, write }) {
24666
25232
  charter: withCharter(p.charter, patch, by, stamp())
24667
25233
  } : p) });
24668
25234
  },
25235
+ setLandfall: (projectId, input) => {
25236
+ const by = get().currentUserId;
25237
+ write({ projects: get().projects.map((p) => p.id === projectId ? {
25238
+ ...p,
25239
+ landfall: input ? withLandfall(p.landfall, input, by, stamp()) : null
25240
+ } : p) });
25241
+ },
25242
+ answerLandfall: (projectId, answer) => {
25243
+ const by = get().currentUserId;
25244
+ write({ projects: get().projects.map((p) => p.id === projectId && p.landfall ? {
25245
+ ...p,
25246
+ landfall: withLandfallAnswer(p.landfall, answer, by, stamp())
25247
+ } : p) });
25248
+ },
24669
25249
  addCustomField: (projectId, def) => {
24670
25250
  const id = uid("cf");
24671
25251
  write({ projects: get().projects.map((p) => p.id === projectId ? {
@@ -24762,11 +25342,24 @@ function hierarchySlice({ get, write }) {
24762
25342
  programs: get().programs.map((p) => p.id === id ? mark(p) : p),
24763
25343
  projects: parked ? get().projects.map((p) => p.programId === id && (!p.healthOverride || p.healthOverride === "paused" || p.healthOverride === "done") ? mark(p) : p) : get().projects
24764
25344
  });
24765
- } else write({ projects: get().projects.map((p) => p.id === id ? mark(p) : p) });
25345
+ } else {
25346
+ const raised = health === "done" ? null : raise(id, at);
25347
+ write({
25348
+ projects: (raised?.projects ?? get().projects).map((p) => p.id === id ? mark(p) : p),
25349
+ ...raised ? {
25350
+ tasks: raised.tasks,
25351
+ raidItems: raised.raidItems
25352
+ } : {}
25353
+ });
25354
+ }
24766
25355
  },
24767
25356
  clearHealthOverride: (kind, id) => {
24768
25357
  const at = stamp();
24769
- const s = get();
25358
+ const raised = kind === "project" ? raise(id, at) : null;
25359
+ const s = raised ? {
25360
+ ...get(),
25361
+ ...raised
25362
+ } : get();
24770
25363
  const cleared = {
24771
25364
  portfolio: /* @__PURE__ */ new Set(),
24772
25365
  program: /* @__PURE__ */ new Set(),
@@ -24801,9 +25394,59 @@ function hierarchySlice({ get, write }) {
24801
25394
  write({
24802
25395
  portfolios: next.portfolios.map(byRules("portfolio")),
24803
25396
  programs: next.programs.map(byRules("program")),
24804
- projects: next.projects.map(byRules("project"))
25397
+ projects: next.projects.map(byRules("project")),
25398
+ ...raised ? {
25399
+ tasks: raised.tasks,
25400
+ raidItems: raised.raidItems
25401
+ } : {}
24805
25402
  });
24806
25403
  },
25404
+ scuttleProject: (projectId, why, context) => {
25405
+ const s = get();
25406
+ const at = stamp();
25407
+ const decisionId = uid("raid");
25408
+ const rows = scuttleRows(s, projectId, why, s.currentUserId, at, decisionId, context);
25409
+ if (!rows) return null;
25410
+ const before = s.projects.find((p) => p.id === projectId);
25411
+ const changed = new Map(rows.tasks.map((t) => [t.id, t]));
25412
+ write({
25413
+ projects: s.projects.map((p) => p.id === projectId ? {
25414
+ ...rows.project,
25415
+ healthHistory: recordHealth(p.healthHistory, "done", at, true, rows.project.healthNote)
25416
+ } : p),
25417
+ tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
25418
+ raidItems: [...s.raidItems, rows.decision]
25419
+ });
25420
+ get().log(`scuttled ${before.name}`, `/projects/${projectId}`);
25421
+ return {
25422
+ project: before,
25423
+ tasks: s.tasks.filter((t) => changed.has(t.id)),
25424
+ decisionId
25425
+ };
25426
+ },
25427
+ undoScuttle: (undo) => {
25428
+ const s = get();
25429
+ const back = new Map(undo.tasks.map((t) => [t.id, t]));
25430
+ write({
25431
+ projects: s.projects.map((p) => p.id === undo.project.id ? undo.project : p),
25432
+ tasks: s.tasks.map((t) => back.get(t.id) ?? t),
25433
+ raidItems: s.raidItems.filter((r) => r.id !== undo.decisionId)
25434
+ });
25435
+ },
25436
+ answerVoyage: (projectId, call, note) => {
25437
+ const at = stamp();
25438
+ const byId = get().currentUserId;
25439
+ const text = note?.trim().slice(0, 300);
25440
+ write({ projects: get().projects.map((p) => p.id === projectId ? {
25441
+ ...p,
25442
+ voyageCall: {
25443
+ call,
25444
+ at,
25445
+ byId,
25446
+ ...text ? { note: text } : {}
25447
+ }
25448
+ } : p) });
25449
+ },
24807
25450
  snapshotBaseline: (programId, reset = false) => {
24808
25451
  const inProgram = new Set(get().projects.filter((p) => p.programId === programId).map((p) => p.id));
24809
25452
  const stamped = withBaseline(get().projects.filter((p) => inProgram.has(p.id)), reset, stamp());
@@ -24961,8 +25604,10 @@ var init_hierarchy = __esmMin((() => {
24961
25604
  init_derived_health();
24962
25605
  init_shared();
24963
25606
  init_assignment();
25607
+ init_scuttle_rows();
24964
25608
  init_hill();
24965
25609
  init_charter_lines();
25610
+ init_landfall_lines();
24966
25611
  init_format();
24967
25612
  init_empty();
24968
25613
  init_first_week();
package/lib/cli.mjs CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  notesText,
36
36
  messagesText,
37
37
  charterText,
38
+ landfallText,
38
39
  daysText,
39
40
  soundingText,
40
41
  lifeboatsText,
@@ -54,6 +55,8 @@ import {
54
55
  catchupText,
55
56
  handoverText,
56
57
  fogText,
58
+ scuttleText,
59
+ scuttleCallText,
57
60
  agentWorkText,
58
61
  raidDetail,
59
62
  raidList,
@@ -261,6 +264,11 @@ export const HELP = `harbor: Harbor from the terminal
261
264
  a project's five-line charter
262
265
  (why, in, out, done when, who
263
266
  makes the calls): read or set
267
+ harbor landfall PROJECT [--expect T] [--after 30|90|180] [--measure T]
268
+ [--answer yes|partly|no] [--value N] [--note T]
269
+ did a Done project pay off? say
270
+ what should be true, and answer
271
+ on the day
264
272
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
265
273
  a note for later: sealed until
266
274
  its day, milestone, or Off track
@@ -295,6 +303,13 @@ export const HELP = `harbor: Harbor from the terminal
295
303
  harbor fog --sweep [--project P] one at a time: still needed, a
296
304
  new date, or drop it
297
305
  harbor fog keep TASK it's still needed: out of the fog
306
+ harbor scuttle [--program P|--portfolio P] projects that look adrift: still
307
+ for weeks, slipping, long late
308
+ harbor scuttle sail|refit PROJECT [--end DATE] [--note TEXT]
309
+ keep it, or refit it, for three
310
+ months out of the line-up
311
+ harbor scuttle PROJECT --why "TEXT" stop it on purpose: Done, on the
312
+ register, its tasks set free
298
313
  harbor watch [--project P] [--bell] a live ticker of the harbor's
299
314
  changes; yours bright, q stops
300
315
  harbor watch --once [--since TIME] [--json] the latest changes (or all since
@@ -1016,6 +1031,24 @@ const DETAIL = {
1016
1031
  " --no-people. A task already in its project is left alone: by its key",
1017
1032
  " (a Key column, as harbor export writes) or by title.",
1018
1033
  ],
1034
+ landfall: [
1035
+ " harbor landfall PROJECT reads its landfall: what should be",
1036
+ " true some days after it's Done,",
1037
+ " when Harbor asks, and the answer",
1038
+ " Say what landfall looks like:",
1039
+ ' --expect "TEXT" what should be true by then',
1040
+ " --after 30|90|180 days after the project is marked Done to look",
1041
+ " (90 unless you say)",
1042
+ ' --measure "TEXT" what the number counts, if there is one',
1043
+ " Answer it (on its day, or sooner):",
1044
+ " --answer yes|partly|no",
1045
+ " --value N the number on the day",
1046
+ ' --note "TEXT" what you found',
1047
+ " --clear takes the landfall off (with --answer, only the answer), so it",
1048
+ " asks again. The owner is asked in their Inbox on the day; the portfolio",
1049
+ " counts the voyages that paid off. PROJECT is a name, a key like RR, or",
1050
+ " an id.",
1051
+ ],
1019
1052
  charter: [
1020
1053
  " harbor charter PROJECT reads its charter, and any open task",
1021
1054
  " that names something it leaves out",
@@ -1149,6 +1182,24 @@ const DETAIL = {
1149
1182
  " The sweep needs a terminal and full access (harbor login without",
1150
1183
  " --read-only), since dropping a task deletes it.",
1151
1184
  ],
1185
+ scuttle: [
1186
+ " harbor scuttle [--program P] reads: running projects that look",
1187
+ " [--portfolio P] adrift (nothing moved for four",
1188
+ " weeks, slipping again and again, long",
1189
+ " past the end, paused for a month),",
1190
+ " and what stopping each would free",
1191
+ " harbor scuttle sail PROJECT writes: keep it sailing; it leaves",
1192
+ " the line-up for three months",
1193
+ " harbor scuttle refit PROJECT writes: a new end date and/or a line",
1194
+ ' [--end DATE] [--note "TEXT"] on what changes',
1195
+ ' harbor scuttle PROJECT --why "TEXT" writes: stop it. It closes as Done',
1196
+ " with the why as its note, a decision",
1197
+ " goes on the register, and its open",
1198
+ " tasks come off people's lists",
1199
+ "",
1200
+ " Nothing is deleted: harbor health auto --project P raises a scuttled project",
1201
+ " again and gives its tasks back.",
1202
+ ],
1152
1203
  watch: [
1153
1204
  " harbor watch reads, live: each change to the harbor",
1154
1205
  " as it lands, one line with its time",
@@ -3259,6 +3310,70 @@ export async function run(argv, io = {}) {
3259
3310
  : `Cleared ${r.project}'s charter.`,
3260
3311
  );
3261
3312
  }
3313
+ case "landfall": {
3314
+ const { positionals, values, harbor } = parse(
3315
+ spec(["clear"], ["expect", "after", "measure", "answer", "value", "note"]),
3316
+ );
3317
+ const project = one(positionals, 'project: harbor landfall "NAME"');
3318
+ const answering =
3319
+ values.answer !== undefined || values.value !== undefined || values.note !== undefined;
3320
+ const setting =
3321
+ values.expect !== undefined || values.after !== undefined || values.measure !== undefined;
3322
+ if (answering && setting) {
3323
+ throw new HarborError("Set the landfall or answer it, one at a time.");
3324
+ }
3325
+ const show = (r) => landfallText(r.project ?? r.name, r.landfall);
3326
+ const json = (r) => ({ project: r.project ?? r.name, landfall: r.landfall ?? null });
3327
+ if (answering || (values.clear && values.answer !== undefined)) {
3328
+ if (values.clear) {
3329
+ return call("answer_landfall", { harbor, project, clear: true }, show, json);
3330
+ }
3331
+ const result = String(values.answer ?? "").toLowerCase();
3332
+ if (!["yes", "partly", "no"].includes(result)) {
3333
+ throw new HarborError("Say --answer yes, partly or no.");
3334
+ }
3335
+ let value;
3336
+ if (values.value !== undefined) {
3337
+ value = Number(String(values.value).replace(/,/g, ""));
3338
+ if (!Number.isFinite(value)) throw new HarborError("--value takes a number, like 120.");
3339
+ }
3340
+ return call(
3341
+ "answer_landfall",
3342
+ {
3343
+ harbor,
3344
+ project,
3345
+ result,
3346
+ ...(value !== undefined ? { value } : {}),
3347
+ ...(values.note !== undefined ? { note: values.note } : {}),
3348
+ },
3349
+ show,
3350
+ json,
3351
+ );
3352
+ }
3353
+ if (values.clear) return call("set_landfall", { harbor, project, clear: true }, show, json);
3354
+ if (setting) {
3355
+ let afterDays;
3356
+ if (values.after !== undefined) {
3357
+ afterDays = Number(String(values.after).replace(/\s*days?$/i, ""));
3358
+ if (![30, 90, 180].includes(afterDays)) {
3359
+ throw new HarborError("--after takes 30, 90 or 180 (days after the project is Done).");
3360
+ }
3361
+ }
3362
+ return call(
3363
+ "set_landfall",
3364
+ {
3365
+ harbor,
3366
+ project,
3367
+ ...(values.expect !== undefined ? { expect: values.expect } : {}),
3368
+ ...(afterDays !== undefined ? { afterDays } : {}),
3369
+ ...(values.measure !== undefined ? { measure: values.measure } : {}),
3370
+ },
3371
+ show,
3372
+ json,
3373
+ );
3374
+ }
3375
+ return call("get_project", { harbor, project }, show, json);
3376
+ }
3262
3377
  case "sound": {
3263
3378
  const { positionals, values, harbor } = parse(spec([], ["answer", "why", "ask", "settle"]));
3264
3379
  const [task, ...extra] = positionals;
@@ -3579,6 +3694,49 @@ export async function run(argv, io = {}) {
3579
3694
  }
3580
3695
  return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
3581
3696
  }
3697
+ case "scuttle": {
3698
+ const { positionals, values, harbor } = parse(
3699
+ spec(["list"], ["program", "portfolio", "end", "note", "why"]),
3700
+ );
3701
+ const [first, ...rest] = positionals;
3702
+ if (first === "sail" || first === "refit") {
3703
+ if (values.why !== undefined)
3704
+ throw new HarborError(`--why goes with scuttling: harbor scuttle PROJECT --why "TEXT".`);
3705
+ const project = one(rest, "project");
3706
+ if (first === "sail" && (values.end !== undefined || values.note !== undefined))
3707
+ throw new HarborError("--end and --note go with a refit: harbor scuttle refit PROJECT --end DATE.");
3708
+ if (first === "refit" && values.end === undefined && values.note === undefined)
3709
+ throw new HarborError('A refit needs --end DATE or --note "what changes".');
3710
+ return call(
3711
+ "answer_scuttle_or_sail",
3712
+ {
3713
+ harbor,
3714
+ project,
3715
+ call: first,
3716
+ ...(values.end !== undefined ? { endDate: dueDate(values.end) } : {}),
3717
+ ...(values.note !== undefined ? { note: values.note } : {}),
3718
+ },
3719
+ scuttleCallText,
3720
+ );
3721
+ }
3722
+ if (first !== undefined) {
3723
+ const project = one(positionals, "project");
3724
+ if (values.why === undefined || !values.why.trim())
3725
+ throw new HarborError(
3726
+ `Say why it stops: harbor scuttle ${project} --why "TEXT". To keep it: harbor scuttle sail ${project}.`,
3727
+ );
3728
+ return call(
3729
+ "answer_scuttle_or_sail",
3730
+ { harbor, project, call: "scuttle", why: values.why },
3731
+ scuttleCallText,
3732
+ );
3733
+ }
3734
+ return call(
3735
+ "scuttle_or_sail",
3736
+ { harbor, program: values.program, portfolio: values.portfolio, limit: 100 },
3737
+ scuttleText,
3738
+ );
3739
+ }
3582
3740
  case "bridge": {
3583
3741
  const { positionals, values, harbor } = parse(spec(["list"], ["program"]));
3584
3742
  none(positionals);
package/lib/format.mjs CHANGED
@@ -815,6 +815,34 @@ export function charterText(r) {
815
815
  return lines.join("\n");
816
816
  }
817
817
 
818
+ const LANDFALL_RESULT = { yes: "Paid off", partly: "Partly", no: "Didn't" };
819
+
820
+ /** A project's landfall as get_project, set_landfall or answer_landfall gives it. */
821
+ export function landfallText(project, lf) {
822
+ if (!lf) {
823
+ return `${project} has no landfall yet. Say what should be true some days after it's Done:\n harbor landfall "${project}" --expect "…" --after 90 [--measure "weekly sign-ups"]`;
824
+ }
825
+ const lines = [`Landfall · ${project}`, ` Looks like ${lf.expect}`];
826
+ if (lf.measure) lines.push(` Counting ${lf.measure}`);
827
+ if (lf.result) {
828
+ const value =
829
+ lf.value != null ? ` · ${Number(lf.value).toLocaleString("en-US")}${lf.measure ? ` ${lf.measure}` : ""}` : "";
830
+ lines.push(` Answer ${LANDFALL_RESULT[lf.result] ?? lf.result}${value}`);
831
+ if (lf.note) lines.push(` ${lf.note}`);
832
+ if (lf.answeredOn)
833
+ lines.push(` said by ${lf.answeredBy ?? "someone"} on ${lf.answeredOn}`);
834
+ } else if (lf.state === "due") {
835
+ lines.push(
836
+ ` Due now its day was ${lf.checkOn}. Answer: harbor landfall "${project}" --answer yes|partly|no [--value N]`,
837
+ );
838
+ } else if (lf.state === "waiting") {
839
+ lines.push(` Check on ${lf.checkOn} (${lf.afterDays} days after Done)`);
840
+ } else {
841
+ lines.push(` Check ${lf.afterDays} days after the project is marked Done`);
842
+ }
843
+ return lines.join("\n");
844
+ }
845
+
818
846
  export function notesText(project, notes) {
819
847
  if (!notes?.length)
820
848
  return `No notes for later on ${project}. Leave one: harbor note "${project}" "NOTE" --opens +30`;
@@ -1124,6 +1152,45 @@ export function fogText(r, { sweepHint = true } = {}) {
1124
1152
  return lines.join("\n");
1125
1153
  }
1126
1154
 
1155
+ /** harbor scuttle: projects that look adrift, with what stopping each would free. */
1156
+ export function scuttleText(r) {
1157
+ const where = r.program ? ` in ${r.program}` : r.portfolio ? ` in ${r.portfolio}` : "";
1158
+ if (!r.adrift)
1159
+ return `Nothing looks adrift${where}: every running project has moved lately, and none keeps slipping.`;
1160
+ const lines = [
1161
+ `${r.adrift} ${r.adrift === 1 ? "project looks" : "projects look"} adrift${where}. Keep each sailing, refit it, or stop it on purpose.`,
1162
+ "",
1163
+ ];
1164
+ for (const p of r.projects) {
1165
+ lines.push(
1166
+ ` ${(p.key ?? "").padEnd(5)} ${p.project}${p.owner ? ` · ${p.owner}` : ""} · ends ${p.endDate}`,
1167
+ );
1168
+ for (const s of p.signs) lines.push(` ${s.text}`);
1169
+ const frees = [
1170
+ `${p.openTasks} open task${p.openTasks === 1 ? "" : "s"}`,
1171
+ p.crew.length ? `with ${p.crew.map((c) => c.person).filter(Boolean).join(", ")}` : null,
1172
+ p.hoursPerWeek ? `${p.hoursPerWeek}h a week logged` : null,
1173
+ p.spendPerWeek ? `$${p.spendPerWeek.toLocaleString("en-US")} a week of spend` : null,
1174
+ ];
1175
+ lines.push(` holds ${frees.filter(Boolean).join(" · ")}`);
1176
+ }
1177
+ if (r.more) lines.push("", `And ${r.adrift - r.projects.length} more.`);
1178
+ lines.push(
1179
+ "",
1180
+ 'harbor scuttle sail PROJECT keeps one, harbor scuttle refit PROJECT --end DATE refits it, harbor scuttle PROJECT --why "TEXT" stops it.',
1181
+ );
1182
+ return lines.join("\n");
1183
+ }
1184
+
1185
+ /** What harbor scuttle sail, refit or PROJECT --why did. */
1186
+ export function scuttleCallText(r) {
1187
+ if (r.call === "scuttle")
1188
+ return `Scuttled ${r.project}. ${r.sendOff} The why is on the register as a decision; harbor health auto --project "${r.project}" raises it again.`;
1189
+ if (r.call === "refit")
1190
+ return `Refitted ${r.project}: ${r.note ?? `ends ${r.endDate}`}. It's out of the line-up until ${r.quietUntil}.`;
1191
+ return `${r.project} sails on. It's out of the line-up until ${r.quietUntil}.`;
1192
+ }
1193
+
1127
1194
  /** harbor lifeboats: work only one person knows, numbered for harbor lifeboats N. */
1128
1195
  export function lifeboatsText(r) {
1129
1196
  const where = r.program ? ` in ${r.program}` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.96.0",
3
+ "version": "0.98.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",