@harborppm/cli 0.96.0 → 0.97.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
  }));
@@ -5942,18 +5942,18 @@ init_tide();
5942
5942
  const BURN_DAYS = 56;
5943
5943
  /** A window shorter than a week says too little to read a rate from. */
5944
5944
  const MIN_DAYS = 7;
5945
- const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5945
+ const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5946
5946
  /**
5947
5947
  * Spend a day over the last BURN_DAYS (or the project's life, when it's
5948
5948
  * younger), from the ledger; null when nothing was spent in that time.
5949
5949
  */
5950
5950
  function burnPerDay(entries, startDate, now) {
5951
- const today = noon$1(todayISO(now));
5952
- const age = differenceInCalendarDays(today, noon$1(startDate)) + 1;
5951
+ const today = noon$2(todayISO(now));
5952
+ const age = differenceInCalendarDays(today, noon$2(startDate)) + 1;
5953
5953
  const span = Math.max(MIN_DAYS, Math.min(56, age));
5954
5954
  let sum = 0;
5955
5955
  for (const e of entries) {
5956
- const back = differenceInCalendarDays(today, noon$1(e.date));
5956
+ const back = differenceInCalendarDays(today, noon$2(e.date));
5957
5957
  if (back >= 0 && back < span) sum += e.amount;
5958
5958
  }
5959
5959
  return sum > 0 ? sum / span : null;
@@ -5975,8 +5975,8 @@ function slipCost(project, tide, entries, now) {
5975
5975
  const from = project.endDate > today ? project.endDate : today;
5976
5976
  const point = (date) => {
5977
5977
  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)));
5978
+ const daysLate = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(from)));
5979
+ const ahead = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(today)));
5980
5980
  return {
5981
5981
  date,
5982
5982
  daysLate,
@@ -10136,11 +10136,11 @@ function hillText(project) {
10136
10136
  if (at === 100) return "Over the hill: done";
10137
10137
  return `${HILL_PHASE_LABEL[hillPhase(at)]} (${at} of 100)`;
10138
10138
  }
10139
- var HILL_TOP, STILL_DAYS, HILL_PHASE_LABEL;
10139
+ var HILL_TOP, STILL_DAYS$1, HILL_PHASE_LABEL;
10140
10140
  var init_hill = __esmMin((() => {
10141
10141
  init_date_fns();
10142
10142
  HILL_TOP = 50;
10143
- STILL_DAYS = 14;
10143
+ STILL_DAYS$1 = 14;
10144
10144
  HILL_PHASE_LABEL = {
10145
10145
  figuring: "Figuring it out",
10146
10146
  doing: "Getting it done"
@@ -14807,6 +14807,12 @@ function taskMinutes(task) {
14807
14807
  function entriesNewestFirst(task) {
14808
14808
  return [...task.time ?? []].sort((a, b) => (b.on + b.at).localeCompare(a.on + a.at));
14809
14809
  }
14810
+ /** The ISO day `n` days before `iso`. */
14811
+ function daysBefore(iso, n) {
14812
+ const d = /* @__PURE__ */ new Date(`${iso}T12:00:00Z`);
14813
+ d.setUTCDate(d.getUTCDate() - n);
14814
+ return d.toISOString().slice(0, 10);
14815
+ }
14810
14816
 
14811
14817
  // src/lib/goals.ts
14812
14818
  init_indexes();
@@ -20783,8 +20789,8 @@ init_date_fns();
20783
20789
  init_blockers();
20784
20790
  init_format();
20785
20791
  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));
20792
+ const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
20793
+ const days$2 = (from, to) => differenceInCalendarDays(noon$1(to), noon$1(from));
20788
20794
  const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
20789
20795
  const open = (t) => t.status !== "done";
20790
20796
  /** True when `a` finishes later than `b`, or as late behind a longer line. */
@@ -21815,8 +21821,349 @@ var init_fog = __esmMin((() => {
21815
21821
  fileCache = /* @__PURE__ */ new WeakMap();
21816
21822
  }));
21817
21823
 
21818
- // src/lib/task-approval.ts
21824
+ // src/lib/assignment.ts
21825
+ /**
21826
+ * Record who set a task's assignee and when. Only a change of assignee
21827
+ * stamps; clearing the assignee clears the stamp.
21828
+ */
21829
+ function stampAssignment(before, next, by, at) {
21830
+ if (before && before.assigneeId === next.assigneeId) return next;
21831
+ if (!next.assigneeId) return {
21832
+ ...next,
21833
+ assignedBy: null,
21834
+ assignedAt: null
21835
+ };
21836
+ return {
21837
+ ...next,
21838
+ assignedBy: by,
21839
+ assignedAt: at
21840
+ };
21841
+ }
21842
+ var init_assignment = __esmMin((() => {}));
21843
+
21844
+ // src/lib/scuttle-rows.ts
21845
+ /** Whether it's scuttled: closed through Scuttle or sail and not raised since. */
21846
+ function isScuttled(project) {
21847
+ return Boolean(project.scuttled) && project.healthOverride === "done";
21848
+ }
21849
+ /**
21850
+ * Scuttle a project: Done by hand with the reason as its note, its open
21851
+ * tasks unassigned and undated (kept in `scuttled.freed`), and a decision on
21852
+ * the register. Null for a project that's already scuttled or gone, or a
21853
+ * blank why.
21854
+ */
21855
+ function scuttleRows(data, projectId, why, byId, at, decisionId, context = "Closed through Scuttle or sail.") {
21856
+ const project = data.projects.find((p) => p.id === projectId);
21857
+ const reason = why.trim().slice(0, 600);
21858
+ if (!project || !reason || isScuttled(project)) return null;
21859
+ const freed = [];
21860
+ const tasks = [];
21861
+ for (const t of tasksOf(data.tasks, projectId)) {
21862
+ if (t.status === "done" || !t.assigneeId && !t.dueDate) continue;
21863
+ freed.push({
21864
+ taskId: t.id,
21865
+ assigneeId: t.assigneeId,
21866
+ dueDate: t.dueDate
21867
+ });
21868
+ tasks.push(stampAssignment(t, {
21869
+ ...t,
21870
+ assigneeId: null,
21871
+ dueDate: null,
21872
+ touchedAt: at
21873
+ }, byId, at));
21874
+ }
21875
+ const decision = normalizeRaidItem({
21876
+ id: decisionId,
21877
+ kind: "decision",
21878
+ projectId,
21879
+ title: `Scuttle ${project.name}`,
21880
+ description: context,
21881
+ status: "decided",
21882
+ severity: "low",
21883
+ ownerId: byId,
21884
+ dueDate: null,
21885
+ resolution: "Stop the project. Its open tasks come off people’s lists.",
21886
+ rationale: reason,
21887
+ decidedById: byId,
21888
+ decidedAt: at,
21889
+ createdById: byId,
21890
+ createdAt: at,
21891
+ updatedAt: at,
21892
+ updates: []
21893
+ });
21894
+ return {
21895
+ project: {
21896
+ ...project,
21897
+ health: "done",
21898
+ healthOverride: "done",
21899
+ healthNote: `Scuttled: ${reason}`,
21900
+ scuttled: {
21901
+ at,
21902
+ byId,
21903
+ why: reason,
21904
+ decisionId,
21905
+ freed
21906
+ }
21907
+ },
21908
+ tasks,
21909
+ decision
21910
+ };
21911
+ }
21912
+ /**
21913
+ * Raise a scuttled project: the scuttle cleared (the caller clears its Done), and each
21914
+ * freed task given back its person and date, unless someone has since given
21915
+ * it others, it's done, or the person has left the crew. Null when it isn't
21916
+ * scuttled.
21917
+ */
21918
+ function raiseRows(data, projectId, at, byId) {
21919
+ const project = data.projects.find((p) => p.id === projectId);
21920
+ if (!project?.scuttled) return null;
21921
+ const people = new Set(data.people.map((p) => p.id));
21922
+ const byTask = new Map(project.scuttled.freed.map((f) => [f.taskId, f]));
21923
+ const tasks = [];
21924
+ for (const t of tasksOf(data.tasks, projectId)) {
21925
+ const f = byTask.get(t.id);
21926
+ if (!f || t.status === "done") continue;
21927
+ const assigneeId = !t.assigneeId && f.assigneeId && people.has(f.assigneeId) ? f.assigneeId : t.assigneeId;
21928
+ const dueDate = t.dueDate ?? f.dueDate;
21929
+ if (assigneeId === t.assigneeId && dueDate === t.dueDate) continue;
21930
+ tasks.push(stampAssignment(t, {
21931
+ ...t,
21932
+ assigneeId,
21933
+ dueDate,
21934
+ touchedAt: at
21935
+ }, byId, at));
21936
+ }
21937
+ return {
21938
+ project: {
21939
+ ...project,
21940
+ scuttled: null,
21941
+ voyageCall: {
21942
+ call: "sail",
21943
+ at,
21944
+ byId
21945
+ }
21946
+ },
21947
+ tasks
21948
+ };
21949
+ }
21950
+ var WHY_MAX;
21951
+ var init_scuttle_rows = __esmMin((() => {
21952
+ init_assignment();
21953
+ init_indexes();
21954
+ init_raid();
21955
+ WHY_MAX = 600;
21956
+ }));
21957
+
21958
+ // src/lib/scuttle.ts
21959
+ init_date_fns();
21819
21960
  init_fog();
21961
+ init_indexes();
21962
+ init_scuttle_rows();
21963
+ init_spend();
21964
+ init_format();
21965
+ /** Days with nothing moving before a project reads as still. */
21966
+ const STILL_DAYS = 28;
21967
+ /** A project younger than this is left alone: it's still being set up. */
21968
+ const YOUNG_DAYS = 21;
21969
+ /** Sailing on or a refit keeps it out of the line-up this long. */
21970
+ const QUIET_DAYS = 90;
21971
+ /** Slips are counted over this window. */
21972
+ const SLIP_WINDOW_DAYS = 90;
21973
+ /** This many falls into trouble in the window reads as slipping again and again. */
21974
+ const SLIP_TIMES = 3;
21975
+ /** Its end date this far past the baseline reads as drifting. */
21976
+ const DRIFT_DAYS = 21;
21977
+ /** This far past its end date with work open. */
21978
+ const OVERDUE_DAYS = 14;
21979
+ /** Paused this long. */
21980
+ const PAUSED_DAYS = 30;
21981
+ const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
21982
+ const daysSince = (iso, today) => differenceInCalendarDays(noon(today), noon(iso));
21983
+ const TRACK = {
21984
+ "on-track": 0,
21985
+ "at-risk": 1,
21986
+ "off-track": 2
21987
+ };
21988
+ /** When anything last moved on the project: its tasks (as fog reads them) and its hill dot. */
21989
+ function lastMoved(project, data) {
21990
+ let last = project.hillMovedAt ?? null;
21991
+ for (const t of tasksOf(data.tasks, project.id)) {
21992
+ const at = lastTouch(t, data);
21993
+ if (!last || at > last) last = at;
21994
+ }
21995
+ return last;
21996
+ }
21997
+ /** Times its health fell (on track to at risk or off track, at risk to off track) since `since`. */
21998
+ function slipsSince(project, since) {
21999
+ let times = 0;
22000
+ let prev;
22001
+ for (const line of project.healthHistory ?? []) {
22002
+ const rank = TRACK[line.health];
22003
+ if (rank === void 0) {
22004
+ prev = void 0;
22005
+ continue;
22006
+ }
22007
+ if (prev !== void 0 && rank > prev && line.at.slice(0, 10) >= since) times += 1;
22008
+ prev = rank;
22009
+ }
22010
+ return times;
22011
+ }
22012
+ /** Days it has been paused, from the history's latest line saying so; null when it isn't, or nobody knows since when. */
22013
+ function pausedDays(project, today) {
22014
+ if (project.health !== "paused") return null;
22015
+ const history = project.healthHistory ?? [];
22016
+ let since = null;
22017
+ for (let i = history.length - 1; i >= 0; i--) {
22018
+ if (history[i].health !== "paused") break;
22019
+ since = history[i].at;
22020
+ }
22021
+ return since ? Math.max(0, daysSince(since, today)) : null;
22022
+ }
22023
+ /** A goal's work key result names it or its program. Only asked when its portfolio has goals at all. */
22024
+ function aimless(project, data) {
22025
+ const program = data.programs.find((p) => p.id === project.programId);
22026
+ const goals = (program && data.portfolios.find((p) => p.id === program.portfolioId))?.goals ?? [];
22027
+ if (goals.length === 0) return false;
22028
+ return !goals.some((g) => g.keyResults.some((kr) => kr.kind === "work" && (kr.linkIds ?? []).some((id) => id === project.id || id === project.programId)));
22029
+ }
22030
+ const STRENGTH = {
22031
+ overdue: 0,
22032
+ still: 1,
22033
+ empty: 1,
22034
+ paused: 2,
22035
+ slipping: 3,
22036
+ drifted: 4,
22037
+ aimless: 5
22038
+ };
22039
+ /** The signs that put a project in the line-up today, strongest first; empty when it's sailing fine. */
22040
+ function zombieSigns(project, data, today) {
22041
+ const signs = [];
22042
+ const tasks = tasksOf(data.tasks, project.id);
22043
+ const open = tasks.filter((t) => t.status !== "done").length;
22044
+ const age = daysSince(project.startDate, today);
22045
+ if (tasks.length === 0) {
22046
+ if (age >= 28) signs.push({
22047
+ kind: "empty",
22048
+ days: age
22049
+ });
22050
+ } else if (project.health !== "paused") {
22051
+ const moved = lastMoved(project, data);
22052
+ const still = moved ? daysSince(moved, today) : age;
22053
+ if (still >= 28) signs.push({
22054
+ kind: "still",
22055
+ days: still
22056
+ });
22057
+ }
22058
+ const paused = pausedDays(project, today);
22059
+ if (paused !== null && paused >= 30) signs.push({
22060
+ kind: "paused",
22061
+ days: paused
22062
+ });
22063
+ const late = daysSince(project.endDate, today);
22064
+ if (open > 0 && late >= 14 && project.health !== "paused") signs.push({
22065
+ kind: "overdue",
22066
+ days: late,
22067
+ open
22068
+ });
22069
+ const slips = slipsSince(project, daysBefore(today, 90));
22070
+ if (slips >= 3) signs.push({
22071
+ kind: "slipping",
22072
+ times: slips
22073
+ });
22074
+ if (project.baselineEnd) {
22075
+ const drift = differenceInCalendarDays(noon(project.endDate), noon(project.baselineEnd));
22076
+ if (drift >= 21) signs.push({
22077
+ kind: "drifted",
22078
+ days: drift
22079
+ });
22080
+ }
22081
+ if (signs.length > 0 && aimless(project, data)) signs.push({ kind: "aimless" });
22082
+ return signs.sort((a, b) => STRENGTH[a.kind] - STRENGTH[b.kind]);
22083
+ }
22084
+ /** Left out of the line-up: finished, scuttled, too young, or answered within the quarter. */
22085
+ function restingFromReview(project, today) {
22086
+ if (project.health === "done" || isScuttled(project)) return true;
22087
+ if (project.startDate > today || daysSince(project.startDate, today) < 21) return true;
22088
+ const call = project.voyageCall;
22089
+ return Boolean(call && daysSince(call.at, today) < 90);
22090
+ }
22091
+ /** What scuttling it would free: open tasks by person, logged hours and spend a week. */
22092
+ function whatItHolds(project, data, now) {
22093
+ const today = todayISO(now);
22094
+ const tasks = tasksOf(data.tasks, project.id);
22095
+ const by = /* @__PURE__ */ new Map();
22096
+ let openTasks = 0;
22097
+ let minutes = 0;
22098
+ const from = daysBefore(today, 27);
22099
+ for (const t of tasks) {
22100
+ for (const e of t.time ?? []) if (e.on >= from && e.on <= today) minutes += e.minutes;
22101
+ if (t.status === "done") continue;
22102
+ openTasks += 1;
22103
+ if (t.assigneeId) by.set(t.assigneeId, (by.get(t.assigneeId) ?? 0) + 1);
22104
+ }
22105
+ const crew = [...by].map(([personId, open]) => ({
22106
+ personId,
22107
+ open
22108
+ })).sort((a, b) => b.open - a.open);
22109
+ const burn = project.budget > 0 ? burnPerDay(spendEntriesOf({ spendEntries: data.spendEntries ?? [] }, project.id), project.startDate, now) : null;
22110
+ return {
22111
+ openTasks,
22112
+ crew,
22113
+ hoursPerWeek: minutes > 0 ? Math.round(minutes / 60 / 4 * 10) / 10 : null,
22114
+ spendPerWeek: burn ? Math.round(burn * 7) : null
22115
+ };
22116
+ }
22117
+ /**
22118
+ * The line-up: projects showing a zombie sign that aren't resting, the ones
22119
+ * with the most signs first, then the stillest. `projectIds` narrows it to a
22120
+ * portfolio's or program's; leave it out for the whole harbor.
22121
+ */
22122
+ function scuttleLineUp(data, now, projectIds) {
22123
+ const today = todayISO(now);
22124
+ const out = [];
22125
+ for (const project of data.projects) {
22126
+ if (projectIds && !projectIds.has(project.id)) continue;
22127
+ if (restingFromReview(project, today)) continue;
22128
+ const signs = zombieSigns(project, data, today);
22129
+ if (signs.length === 0) continue;
22130
+ out.push({
22131
+ project,
22132
+ signs,
22133
+ lastMoved: lastMoved(project, data),
22134
+ ...whatItHolds(project, data, now)
22135
+ });
22136
+ }
22137
+ const still = (z) => {
22138
+ const s = z.signs.find((x) => x.kind === "still" || x.kind === "empty");
22139
+ return s && "days" in s ? s.days : 0;
22140
+ };
22141
+ return out.sort((a, b) => b.signs.length - a.signs.length || still(b) - still(a) || a.project.name.localeCompare(b.project.name));
22142
+ }
22143
+ /** The projects in a portfolio or a program, for narrowing the line-up. */
22144
+ function projectIdsUnder(data, scope) {
22145
+ const programs = new Set(scope.programId ? [scope.programId] : data.programs.filter((p) => p.portfolioId === scope.portfolioId).map((p) => p.id));
22146
+ return new Set(data.projects.filter((p) => programs.has(p.programId)).map((p) => p.id));
22147
+ }
22148
+ /** "6 weeks", "10 days". */
22149
+ function spanText(days) {
22150
+ if (days >= 14) return `${Math.floor(days / 7)} weeks`;
22151
+ return `${days} day${days === 1 ? "" : "s"}`;
22152
+ }
22153
+ /** One sign in a short sentence. */
22154
+ function signText(sign) {
22155
+ switch (sign.kind) {
22156
+ case "still": return `Nothing has moved in ${spanText(sign.days)}`;
22157
+ case "empty": return `No tasks, ${spanText(sign.days)} after it started`;
22158
+ case "slipping": return `Slipped into trouble ${sign.times} times in three months`;
22159
+ case "drifted": return `Its end date has moved ${spanText(sign.days)} past the plan`;
22160
+ case "overdue": return `${spanText(sign.days)} past its end date, ${sign.open} task${sign.open === 1 ? "" : "s"} still open`;
22161
+ case "paused": return `Paused for ${spanText(sign.days)}`;
22162
+ case "aimless": return "No goal behind it";
22163
+ }
22164
+ }
22165
+
22166
+ // src/lib/task-approval.ts
21820
22167
  /** Waits on its approver now: asked, not yet answered, and not done. */
21821
22168
  function awaitingApproval(task) {
21822
22169
  return Boolean(task?.approval && task.approval.status === "pending" && task.status !== "done");
@@ -21853,6 +22200,7 @@ init_handover();
21853
22200
  init_agents();
21854
22201
  init_work();
21855
22202
  init_task_people();
22203
+ init_fog();
21856
22204
  init_passage();
21857
22205
  init_board();
21858
22206
  init_holidays();
@@ -23693,6 +24041,53 @@ const HARBOR_TOOLS = [
23693
24041
  };
23694
24042
  }
23695
24043
  }),
24044
+ tool({
24045
+ name: "scuttle_or_sail",
24046
+ title: "Projects that look adrift",
24047
+ 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,
24048
+ input: object({
24049
+ harbor: harborArg,
24050
+ portfolio: string().max(200).optional().describe("Portfolio id or name, for its projects only."),
24051
+ program: string().max(200).optional().describe("Program id or name, for its projects only."),
24052
+ limit: limitArg(100, 25),
24053
+ offset: offsetArg
24054
+ }),
24055
+ run: (ctx, input) => {
24056
+ const { data } = ctx;
24057
+ const program = input.program ? findOne(data.programs, input.program, "program") : null;
24058
+ const portfolio = !program && input.portfolio ? findOne(data.portfolios, input.portfolio, "portfolio") : null;
24059
+ const ids = program || portfolio ? projectIdsUnder(data, {
24060
+ programId: program?.id,
24061
+ portfolioId: portfolio?.id
24062
+ }) : void 0;
24063
+ const lineUp = scuttleLineUp(data, ctx.now, ids);
24064
+ const people = byId(data.people);
24065
+ return {
24066
+ ...program ? { program: program.name } : portfolio ? { portfolio: portfolio.name } : {},
24067
+ adrift: lineUp.length,
24068
+ projects: lineUp.slice(input.offset, input.offset + input.limit).map((z) => ({
24069
+ project: z.project.name,
24070
+ ...z.project.key ? { key: z.project.key } : {},
24071
+ health: z.project.health,
24072
+ endDate: z.project.endDate,
24073
+ owner: people.get(z.project.ownerId)?.name ?? null,
24074
+ signs: z.signs.map((s) => ({
24075
+ kind: s.kind,
24076
+ text: signText(s)
24077
+ })),
24078
+ lastMoved: dayOf(z.lastMoved),
24079
+ openTasks: z.openTasks,
24080
+ crew: z.crew.map((c) => ({
24081
+ person: people.get(c.personId)?.name ?? null,
24082
+ openTasks: c.open
24083
+ })),
24084
+ hoursPerWeek: z.hoursPerWeek,
24085
+ spendPerWeek: z.spendPerWeek
24086
+ })),
24087
+ more: lineUp.length > input.offset + input.limit
24088
+ };
24089
+ }
24090
+ }),
23696
24091
  tool({
23697
24092
  name: "lifeboats",
23698
24093
  title: "Work only one person knows",
@@ -24487,26 +24882,6 @@ var init_session = __esmMin((() => {
24487
24882
  init_format();
24488
24883
  }));
24489
24884
 
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
24885
  // src/lib/first-week.ts
24511
24886
  /** The program launch named for you, when nobody has renamed it yet. */
24512
24887
  function starterProgram(data) {
@@ -24518,6 +24893,33 @@ var init_first_week = __esmMin((() => {
24518
24893
 
24519
24894
  // src/lib/slices/hierarchy.ts
24520
24895
  function hierarchySlice({ get, write }) {
24896
+ /**
24897
+ * A scuttled project being reopened (its Done cleared or changed): its
24898
+ * freed tasks get their people and dates back, and its decision says so.
24899
+ * Null when it isn't scuttled.
24900
+ */
24901
+ const raise = (projectId, at) => {
24902
+ const s = get();
24903
+ const rows = raiseRows(s, projectId, at, s.currentUserId);
24904
+ if (!rows) return null;
24905
+ const changed = new Map(rows.tasks.map((t) => [t.id, t]));
24906
+ const decisionId = s.projects.find((p) => p.id === projectId)?.scuttled?.decisionId;
24907
+ const note = {
24908
+ id: uid("ru"),
24909
+ authorId: s.currentUserId,
24910
+ body: "Raised again: the project is back, and its tasks went back to their people.",
24911
+ createdAt: at
24912
+ };
24913
+ return {
24914
+ projects: s.projects.map((p) => p.id === projectId ? rows.project : p),
24915
+ tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
24916
+ raidItems: s.raidItems.map((r) => r.id === decisionId ? {
24917
+ ...r,
24918
+ updates: [...r.updates, note],
24919
+ updatedAt: at
24920
+ } : r)
24921
+ };
24922
+ };
24521
24923
  return {
24522
24924
  addPortfolio: (p) => {
24523
24925
  const id = uid("pf");
@@ -24762,11 +25164,24 @@ function hierarchySlice({ get, write }) {
24762
25164
  programs: get().programs.map((p) => p.id === id ? mark(p) : p),
24763
25165
  projects: parked ? get().projects.map((p) => p.programId === id && (!p.healthOverride || p.healthOverride === "paused" || p.healthOverride === "done") ? mark(p) : p) : get().projects
24764
25166
  });
24765
- } else write({ projects: get().projects.map((p) => p.id === id ? mark(p) : p) });
25167
+ } else {
25168
+ const raised = health === "done" ? null : raise(id, at);
25169
+ write({
25170
+ projects: (raised?.projects ?? get().projects).map((p) => p.id === id ? mark(p) : p),
25171
+ ...raised ? {
25172
+ tasks: raised.tasks,
25173
+ raidItems: raised.raidItems
25174
+ } : {}
25175
+ });
25176
+ }
24766
25177
  },
24767
25178
  clearHealthOverride: (kind, id) => {
24768
25179
  const at = stamp();
24769
- const s = get();
25180
+ const raised = kind === "project" ? raise(id, at) : null;
25181
+ const s = raised ? {
25182
+ ...get(),
25183
+ ...raised
25184
+ } : get();
24770
25185
  const cleared = {
24771
25186
  portfolio: /* @__PURE__ */ new Set(),
24772
25187
  program: /* @__PURE__ */ new Set(),
@@ -24801,8 +25216,58 @@ function hierarchySlice({ get, write }) {
24801
25216
  write({
24802
25217
  portfolios: next.portfolios.map(byRules("portfolio")),
24803
25218
  programs: next.programs.map(byRules("program")),
24804
- projects: next.projects.map(byRules("project"))
25219
+ projects: next.projects.map(byRules("project")),
25220
+ ...raised ? {
25221
+ tasks: raised.tasks,
25222
+ raidItems: raised.raidItems
25223
+ } : {}
25224
+ });
25225
+ },
25226
+ scuttleProject: (projectId, why, context) => {
25227
+ const s = get();
25228
+ const at = stamp();
25229
+ const decisionId = uid("raid");
25230
+ const rows = scuttleRows(s, projectId, why, s.currentUserId, at, decisionId, context);
25231
+ if (!rows) return null;
25232
+ const before = s.projects.find((p) => p.id === projectId);
25233
+ const changed = new Map(rows.tasks.map((t) => [t.id, t]));
25234
+ write({
25235
+ projects: s.projects.map((p) => p.id === projectId ? {
25236
+ ...rows.project,
25237
+ healthHistory: recordHealth(p.healthHistory, "done", at, true, rows.project.healthNote)
25238
+ } : p),
25239
+ tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
25240
+ raidItems: [...s.raidItems, rows.decision]
24805
25241
  });
25242
+ get().log(`scuttled ${before.name}`, `/projects/${projectId}`);
25243
+ return {
25244
+ project: before,
25245
+ tasks: s.tasks.filter((t) => changed.has(t.id)),
25246
+ decisionId
25247
+ };
25248
+ },
25249
+ undoScuttle: (undo) => {
25250
+ const s = get();
25251
+ const back = new Map(undo.tasks.map((t) => [t.id, t]));
25252
+ write({
25253
+ projects: s.projects.map((p) => p.id === undo.project.id ? undo.project : p),
25254
+ tasks: s.tasks.map((t) => back.get(t.id) ?? t),
25255
+ raidItems: s.raidItems.filter((r) => r.id !== undo.decisionId)
25256
+ });
25257
+ },
25258
+ answerVoyage: (projectId, call, note) => {
25259
+ const at = stamp();
25260
+ const byId = get().currentUserId;
25261
+ const text = note?.trim().slice(0, 300);
25262
+ write({ projects: get().projects.map((p) => p.id === projectId ? {
25263
+ ...p,
25264
+ voyageCall: {
25265
+ call,
25266
+ at,
25267
+ byId,
25268
+ ...text ? { note: text } : {}
25269
+ }
25270
+ } : p) });
24806
25271
  },
24807
25272
  snapshotBaseline: (programId, reset = false) => {
24808
25273
  const inProgram = new Set(get().projects.filter((p) => p.programId === programId).map((p) => p.id));
@@ -24961,6 +25426,7 @@ var init_hierarchy = __esmMin((() => {
24961
25426
  init_derived_health();
24962
25427
  init_shared();
24963
25428
  init_assignment();
25429
+ init_scuttle_rows();
24964
25430
  init_hill();
24965
25431
  init_charter_lines();
24966
25432
  init_format();
package/lib/cli.mjs CHANGED
@@ -54,6 +54,8 @@ import {
54
54
  catchupText,
55
55
  handoverText,
56
56
  fogText,
57
+ scuttleText,
58
+ scuttleCallText,
57
59
  agentWorkText,
58
60
  raidDetail,
59
61
  raidList,
@@ -295,6 +297,13 @@ export const HELP = `harbor: Harbor from the terminal
295
297
  harbor fog --sweep [--project P] one at a time: still needed, a
296
298
  new date, or drop it
297
299
  harbor fog keep TASK it's still needed: out of the fog
300
+ harbor scuttle [--program P|--portfolio P] projects that look adrift: still
301
+ for weeks, slipping, long late
302
+ harbor scuttle sail|refit PROJECT [--end DATE] [--note TEXT]
303
+ keep it, or refit it, for three
304
+ months out of the line-up
305
+ harbor scuttle PROJECT --why "TEXT" stop it on purpose: Done, on the
306
+ register, its tasks set free
298
307
  harbor watch [--project P] [--bell] a live ticker of the harbor's
299
308
  changes; yours bright, q stops
300
309
  harbor watch --once [--since TIME] [--json] the latest changes (or all since
@@ -1149,6 +1158,24 @@ const DETAIL = {
1149
1158
  " The sweep needs a terminal and full access (harbor login without",
1150
1159
  " --read-only), since dropping a task deletes it.",
1151
1160
  ],
1161
+ scuttle: [
1162
+ " harbor scuttle [--program P] reads: running projects that look",
1163
+ " [--portfolio P] adrift (nothing moved for four",
1164
+ " weeks, slipping again and again, long",
1165
+ " past the end, paused for a month),",
1166
+ " and what stopping each would free",
1167
+ " harbor scuttle sail PROJECT writes: keep it sailing; it leaves",
1168
+ " the line-up for three months",
1169
+ " harbor scuttle refit PROJECT writes: a new end date and/or a line",
1170
+ ' [--end DATE] [--note "TEXT"] on what changes',
1171
+ ' harbor scuttle PROJECT --why "TEXT" writes: stop it. It closes as Done',
1172
+ " with the why as its note, a decision",
1173
+ " goes on the register, and its open",
1174
+ " tasks come off people's lists",
1175
+ "",
1176
+ " Nothing is deleted: harbor health auto --project P raises a scuttled project",
1177
+ " again and gives its tasks back.",
1178
+ ],
1152
1179
  watch: [
1153
1180
  " harbor watch reads, live: each change to the harbor",
1154
1181
  " as it lands, one line with its time",
@@ -3579,6 +3606,49 @@ export async function run(argv, io = {}) {
3579
3606
  }
3580
3607
  return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
3581
3608
  }
3609
+ case "scuttle": {
3610
+ const { positionals, values, harbor } = parse(
3611
+ spec(["list"], ["program", "portfolio", "end", "note", "why"]),
3612
+ );
3613
+ const [first, ...rest] = positionals;
3614
+ if (first === "sail" || first === "refit") {
3615
+ if (values.why !== undefined)
3616
+ throw new HarborError(`--why goes with scuttling: harbor scuttle PROJECT --why "TEXT".`);
3617
+ const project = one(rest, "project");
3618
+ if (first === "sail" && (values.end !== undefined || values.note !== undefined))
3619
+ throw new HarborError("--end and --note go with a refit: harbor scuttle refit PROJECT --end DATE.");
3620
+ if (first === "refit" && values.end === undefined && values.note === undefined)
3621
+ throw new HarborError('A refit needs --end DATE or --note "what changes".');
3622
+ return call(
3623
+ "answer_scuttle_or_sail",
3624
+ {
3625
+ harbor,
3626
+ project,
3627
+ call: first,
3628
+ ...(values.end !== undefined ? { endDate: dueDate(values.end) } : {}),
3629
+ ...(values.note !== undefined ? { note: values.note } : {}),
3630
+ },
3631
+ scuttleCallText,
3632
+ );
3633
+ }
3634
+ if (first !== undefined) {
3635
+ const project = one(positionals, "project");
3636
+ if (values.why === undefined || !values.why.trim())
3637
+ throw new HarborError(
3638
+ `Say why it stops: harbor scuttle ${project} --why "TEXT". To keep it: harbor scuttle sail ${project}.`,
3639
+ );
3640
+ return call(
3641
+ "answer_scuttle_or_sail",
3642
+ { harbor, project, call: "scuttle", why: values.why },
3643
+ scuttleCallText,
3644
+ );
3645
+ }
3646
+ return call(
3647
+ "scuttle_or_sail",
3648
+ { harbor, program: values.program, portfolio: values.portfolio, limit: 100 },
3649
+ scuttleText,
3650
+ );
3651
+ }
3582
3652
  case "bridge": {
3583
3653
  const { positionals, values, harbor } = parse(spec(["list"], ["program"]));
3584
3654
  none(positionals);
package/lib/format.mjs CHANGED
@@ -1124,6 +1124,45 @@ export function fogText(r, { sweepHint = true } = {}) {
1124
1124
  return lines.join("\n");
1125
1125
  }
1126
1126
 
1127
+ /** harbor scuttle: projects that look adrift, with what stopping each would free. */
1128
+ export function scuttleText(r) {
1129
+ const where = r.program ? ` in ${r.program}` : r.portfolio ? ` in ${r.portfolio}` : "";
1130
+ if (!r.adrift)
1131
+ return `Nothing looks adrift${where}: every running project has moved lately, and none keeps slipping.`;
1132
+ const lines = [
1133
+ `${r.adrift} ${r.adrift === 1 ? "project looks" : "projects look"} adrift${where}. Keep each sailing, refit it, or stop it on purpose.`,
1134
+ "",
1135
+ ];
1136
+ for (const p of r.projects) {
1137
+ lines.push(
1138
+ ` ${(p.key ?? "").padEnd(5)} ${p.project}${p.owner ? ` · ${p.owner}` : ""} · ends ${p.endDate}`,
1139
+ );
1140
+ for (const s of p.signs) lines.push(` ${s.text}`);
1141
+ const frees = [
1142
+ `${p.openTasks} open task${p.openTasks === 1 ? "" : "s"}`,
1143
+ p.crew.length ? `with ${p.crew.map((c) => c.person).filter(Boolean).join(", ")}` : null,
1144
+ p.hoursPerWeek ? `${p.hoursPerWeek}h a week logged` : null,
1145
+ p.spendPerWeek ? `$${p.spendPerWeek.toLocaleString("en-US")} a week of spend` : null,
1146
+ ];
1147
+ lines.push(` holds ${frees.filter(Boolean).join(" · ")}`);
1148
+ }
1149
+ if (r.more) lines.push("", `And ${r.adrift - r.projects.length} more.`);
1150
+ lines.push(
1151
+ "",
1152
+ 'harbor scuttle sail PROJECT keeps one, harbor scuttle refit PROJECT --end DATE refits it, harbor scuttle PROJECT --why "TEXT" stops it.',
1153
+ );
1154
+ return lines.join("\n");
1155
+ }
1156
+
1157
+ /** What harbor scuttle sail, refit or PROJECT --why did. */
1158
+ export function scuttleCallText(r) {
1159
+ if (r.call === "scuttle")
1160
+ return `Scuttled ${r.project}. ${r.sendOff} The why is on the register as a decision; harbor health auto --project "${r.project}" raises it again.`;
1161
+ if (r.call === "refit")
1162
+ return `Refitted ${r.project}: ${r.note ?? `ends ${r.endDate}`}. It's out of the line-up until ${r.quietUntil}.`;
1163
+ return `${r.project} sails on. It's out of the line-up until ${r.quietUntil}.`;
1164
+ }
1165
+
1127
1166
  /** harbor lifeboats: work only one person knows, numbered for harbor lifeboats N. */
1128
1167
  export function lifeboatsText(r) {
1129
1168
  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.97.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",