@harborppm/cli 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -117,7 +117,8 @@ Task titles, names and notes can come from an intake form, a GitHub pull request
117
117
 
118
118
  ```
119
119
  harbor harbors the harbors you belong to
120
- harbor whoami who you're signed in as, and where
120
+ harbor whoami who you're signed in as, where, and which
121
+ build of the site answers
121
122
  harbor use HARBOR|none the harbor to work in when you're in several
122
123
  harbor programs programs and their projects
123
124
  harbor projects [words] [--program P] projects with health and open work
@@ -131,10 +132,14 @@ harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of ta
131
132
  waits that sets its date, slack per link
132
133
  harbor barnacles PROJECT work added since the plan was set: by
133
134
  week, by who, and the slip it explains
135
+ harbor baseline PROJECT [--set|--reset] reads a project's baseline; --set commits
136
+ its dates now (--dry-run says what)
134
137
  harbor hill [--program P] where each project is on its hill
135
138
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
136
139
  past 50 is getting it done
137
140
  harbor mine what's on you, late first
141
+ harbor catchup [--since DATE] while you were away: handed to you,
142
+ decided without you, what moved
138
143
  harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
139
144
  who's working now; set your (or NAME's) time zone and hours
140
145
  harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S] [--all]
package/dist/app.mjs CHANGED
@@ -19110,6 +19110,169 @@ function pilotReasons(p, you = false) {
19110
19110
  return out;
19111
19111
  }
19112
19112
 
19113
+ //#endregion
19114
+ //#region src/lib/seen-mark.ts
19115
+ /** Days without opening Harbor before the catch-up shows. */
19116
+ const UNSEEN_DAYS = 3;
19117
+ /** Calendar days from one ISO day (or moment) to another, by their dates alone. */
19118
+ function dayGap(from, to) {
19119
+ const utc = (iso) => {
19120
+ const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
19121
+ return Date.UTC(y, m - 1, d);
19122
+ };
19123
+ return Math.round((utc(to) - utc(from)) / 864e5);
19124
+ }
19125
+ /**
19126
+ * Whether to catch someone up, and from when: back from time away they were
19127
+ * marked for (a handover with a return date that has come, until this device
19128
+ * has caught them up once since), or `UNSEEN_DAYS` or more since this device
19129
+ * last saw them. Null otherwise, and the first time a device sees them:
19130
+ * there's nothing to compare with.
19131
+ */
19132
+ function welcomeWhen(person, lastSeen, today) {
19133
+ const away = person?.away;
19134
+ if (away?.since && away.until && away.until.slice(0, 10) <= today) {
19135
+ if (!lastSeen || lastSeen.slice(0, 10) < away.until.slice(0, 10)) return {
19136
+ since: away.since,
19137
+ why: "away",
19138
+ days: dayGap(away.since, today)
19139
+ };
19140
+ }
19141
+ if (!lastSeen) return null;
19142
+ const days = dayGap(lastSeen, today);
19143
+ return days >= 3 ? {
19144
+ since: lastSeen,
19145
+ why: "unseen",
19146
+ days
19147
+ } : null;
19148
+ }
19149
+
19150
+ //#endregion
19151
+ //#region src/lib/welcome-back.ts
19152
+ init_date_text();
19153
+ init_indexes();
19154
+ init_task_keys();
19155
+ const day = (iso) => parseIso(iso.slice(0, 10));
19156
+ const daysBetween = dayGap;
19157
+ /** Their projects: ones they lead, in a program they lead, or with a task of theirs. */
19158
+ function projectsOf(data, personId) {
19159
+ const programs = new Set(data.programs.filter((p) => p.ownerId === personId).map((p) => p.id));
19160
+ return data.projects.filter((p) => p.ownerId === personId || programs.has(p.programId) || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId));
19161
+ }
19162
+ const first = (p) => p?.name.split(" ")[0] ?? null;
19163
+ /** "due Thu, Oct 23", "3 days late", "due today". */
19164
+ function dueText(task, today) {
19165
+ if (!task.dueDate) return "no due date";
19166
+ const d = daysBetween(today, task.dueDate);
19167
+ if (d === 0) return "due today";
19168
+ if (d < 0) return `${-d} day${d === -1 ? "" : "s"} late`;
19169
+ if (d === 1) return "due tomorrow";
19170
+ return `due ${format(day(task.dueDate), "EEE, MMM d")}`;
19171
+ }
19172
+ /** What a person needs to hear about since `when.since`, as of `today` (YYYY-MM-DD). */
19173
+ function welcomeBack(data, personId, when, today) {
19174
+ const since = when.since;
19175
+ const people = byId(data.people);
19176
+ const projects = projectsOf(data, personId);
19177
+ const projectIds = new Set(projects.map((p) => p.id));
19178
+ const programIds = new Set(projects.map((p) => p.programId));
19179
+ const projectById = byId(data.projects);
19180
+ const label = (t) => {
19181
+ const p = projectById.get(t.projectId);
19182
+ const key = p ? taskKey(t, p) : null;
19183
+ return key ? `${key} ${t.title}` : t.title;
19184
+ };
19185
+ const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
19186
+ const handed = mine.filter((t) => t.assignedAt && t.assignedAt >= since && t.assignedBy && t.assignedBy !== personId).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999")).map((task) => ({
19187
+ task,
19188
+ by: people.get(task.assignedBy) ?? null
19189
+ }));
19190
+ const decisions = data.raidItems.filter((r) => r.kind === "decision" && r.status !== "proposed" && Boolean(r.decidedAt) && r.decidedAt >= since && r.decidedById !== personId && (r.projectId ? projectIds.has(r.projectId) : Boolean(r.programId && programIds.has(r.programId)))).sort((a, b) => b.decidedAt.localeCompare(a.decidedAt)).map((item) => ({
19191
+ item,
19192
+ by: item.decidedById ? people.get(item.decidedById) ?? null : null
19193
+ }));
19194
+ const moved = [];
19195
+ for (const project of projects) {
19196
+ const tasks = tasksOf(data.tasks, project.id);
19197
+ const done = tasks.filter((t) => t.completedAt && t.completedAt >= since).length;
19198
+ const added = tasks.filter((t) => t.createdAt >= since).length;
19199
+ const history = project.healthHistory ?? [];
19200
+ const before = [...history].reverse().find((h) => h.at < since)?.health;
19201
+ const health = history.some((h) => h.at >= since) && before && before !== project.health ? {
19202
+ from: before,
19203
+ to: project.health
19204
+ } : null;
19205
+ if (done || added || health) moved.push({
19206
+ project,
19207
+ done,
19208
+ added,
19209
+ health
19210
+ });
19211
+ }
19212
+ moved.sort((a, b) => Number(Boolean(b.health)) - Number(Boolean(a.health)) || b.done - a.done);
19213
+ const look = [];
19214
+ const seen = /* @__PURE__ */ new Set();
19215
+ const push = (item) => {
19216
+ const id = item.taskId ?? item.raidId ?? item.text;
19217
+ if (look.length >= 3 || seen.has(id)) return;
19218
+ seen.add(id);
19219
+ look.push(item);
19220
+ };
19221
+ for (const h of handed) {
19222
+ const by = first(h.by);
19223
+ push({
19224
+ kind: "handed",
19225
+ taskId: h.task.id,
19226
+ text: `${label(h.task)}: handed to you${by ? ` by ${by}` : ""}, ${dueText(h.task, today)}`
19227
+ });
19228
+ }
19229
+ const dated = mine.filter((t) => t.dueDate).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
19230
+ for (const t of dated) if (t.dueDate.slice(0, 10) < today) push({
19231
+ kind: "late",
19232
+ taskId: t.id,
19233
+ text: `${label(t)}: ${dueText(t, today)}`
19234
+ });
19235
+ for (const t of dated) {
19236
+ const d = daysBetween(today, t.dueDate);
19237
+ if (d >= 0 && d <= 3) push({
19238
+ kind: "due",
19239
+ taskId: t.id,
19240
+ text: `${label(t)}: ${dueText(t, today)}`
19241
+ });
19242
+ }
19243
+ for (const d of decisions) {
19244
+ const by = first(d.by);
19245
+ push({
19246
+ kind: "decision",
19247
+ raidId: d.item.id,
19248
+ text: `Decided without you: ${d.item.title}${by ? ` (${by})` : ""}`
19249
+ });
19250
+ }
19251
+ return {
19252
+ when,
19253
+ handed,
19254
+ decisions,
19255
+ moved: moved.slice(0, 6),
19256
+ lookFirst: look,
19257
+ quiet: !handed.length && !decisions.length && !moved.length && !look.length
19258
+ };
19259
+ }
19260
+ /** "Welcome back, Priya · away Oct 3 to Oct 17", or "· 5 days since you last looked". */
19261
+ function welcomeTitle(w, name, today) {
19262
+ const hi = name ? `Welcome back, ${name}` : "Welcome back";
19263
+ if (w.when.why === "away") return `${hi} · away ${format(day(w.when.since), "MMM d")} to ${format(day(today), "MMM d")}`;
19264
+ return `${hi} · ${w.when.days} days since you last looked`;
19265
+ }
19266
+ /** "Checkout v3: 4 done, 2 added, now At risk (was On track)". */
19267
+ function movedText(m, healthLabel) {
19268
+ const bits = [
19269
+ m.done ? `${m.done} done` : null,
19270
+ m.added ? `${m.added} added` : null,
19271
+ m.health ? `now ${healthLabel(m.health.to)} (was ${healthLabel(m.health.from)})` : null
19272
+ ].filter(Boolean);
19273
+ return `${m.project.name}: ${bits.join(", ")}`;
19274
+ }
19275
+
19113
19276
  //#endregion
19114
19277
  //#region src/lib/agents.ts
19115
19278
  const isAgent = (p) => p?.kind === "agent";
@@ -19383,6 +19546,7 @@ init_search();
19383
19546
  init_derived_health();
19384
19547
  init_format();
19385
19548
  init_date_text();
19549
+ init_date_fns();
19386
19550
  init_shipping_forecast();
19387
19551
  init_handover();
19388
19552
  init_work();
@@ -19999,7 +20163,9 @@ const HARBOR_TOOLS = [
19999
20163
  } : {},
20000
20164
  starts: dayOf(p.startDate),
20001
20165
  ends: dayOf(p.endDate),
20166
+ ...p.baselineStart ? { baselineStarts: dayOf(p.baselineStart) } : {},
20002
20167
  ...p.baselineEnd ? { baselineEnds: dayOf(p.baselineEnd) } : {},
20168
+ ...p.baselineAt ? { baselineSetAt: p.baselineAt } : {},
20003
20169
  budget: p.budget,
20004
20170
  spent: p.spent,
20005
20171
  tasks: byStatus,
@@ -20201,6 +20367,67 @@ const HARBOR_TOOLS = [
20201
20367
  return weekOut(ctx.data, mine.id, weekOf(input.week ?? todayISO(ctx.now)));
20202
20368
  }
20203
20369
  }),
20370
+ tool({
20371
+ name: "catch_up",
20372
+ title: "While you were away",
20373
+ description: "The catch-up the website and the terminal app open on after time away or a few days unseen (while you were away): tasks handed to the person since then and by whom, decisions made on their projects without them, what moved on their projects (done, added, health changes), and the three to look at first. It counts from the start of time away they were marked for once its return date has come, else from since (when they last looked; any ISO date or moment), else the last 7 days. Reading it doesn't clear the catch-up anywhere." + DATA_NOTE,
20374
+ input: object({
20375
+ harbor: harborArg,
20376
+ since: string().regex(/^\d{4}-\d{2}-\d{2}/, "a date as YYYY-MM-DD, or an ISO moment").max(40).optional().describe("When the person last looked, YYYY-MM-DD or an ISO moment.")
20377
+ }),
20378
+ run: (ctx, input) => {
20379
+ const { data } = ctx;
20380
+ const mine = me(ctx);
20381
+ if (!mine) return {
20382
+ you: null,
20383
+ note: "You aren't one of this harbor's people yet, so there's nothing to catch up on."
20384
+ };
20385
+ const today = todayFor(mine, ctx.now, todayISO(ctx.now));
20386
+ const since = input.since ?? subDays(ctx.now, 7).toISOString();
20387
+ const away = welcomeWhen(mine, input.since ?? null, today);
20388
+ const when = away?.why === "away" ? away : {
20389
+ since,
20390
+ why: "unseen",
20391
+ days: dayGap(since, today)
20392
+ };
20393
+ const w = welcomeBack(data, mine.id, when, today);
20394
+ const projects = byId(data.projects);
20395
+ const keyOf = (t) => taskKey(t, projects.get(t.projectId));
20396
+ return {
20397
+ you: mine.name,
20398
+ from: when.why === "away" ? "time away" : input.since ? "when you last looked" : "the last 7 days",
20399
+ since: dayOf(when.since),
20400
+ days: when.days,
20401
+ quiet: w.quiet,
20402
+ lookFirst: w.lookFirst.map((l) => ({
20403
+ kind: l.kind,
20404
+ text: l.text,
20405
+ ...l.taskId ? { taskId: l.taskId } : {},
20406
+ ...l.raidId ? { raidId: l.raidId } : {}
20407
+ })),
20408
+ handed: w.handed.map(({ task, by }) => ({
20409
+ id: task.id,
20410
+ ...keyOf(task) ? { key: keyOf(task) } : {},
20411
+ title: task.title,
20412
+ due: dayOf(task.dueDate),
20413
+ by: by?.name ?? null
20414
+ })),
20415
+ decisions: w.decisions.map(({ item, by }) => ({
20416
+ id: item.id,
20417
+ title: item.title,
20418
+ decidedAt: dayOf(item.decidedAt),
20419
+ by: by?.name ?? null
20420
+ })),
20421
+ moved: w.moved.map((m) => ({
20422
+ project: m.project.name,
20423
+ ...m.project.key ? { key: m.project.key } : {},
20424
+ done: m.done,
20425
+ added: m.added,
20426
+ health: m.health
20427
+ }))
20428
+ };
20429
+ }
20430
+ }),
20204
20431
  tool({
20205
20432
  name: "weekly_brief",
20206
20433
  title: "Weekly brief",
@@ -26622,169 +26849,6 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
26622
26849
  });
26623
26850
  }
26624
26851
 
26625
- //#endregion
26626
- //#region src/lib/seen-mark.ts
26627
- /** Days without opening Harbor before the catch-up shows. */
26628
- const UNSEEN_DAYS = 3;
26629
- /** Calendar days from one ISO day (or moment) to another, by their dates alone. */
26630
- function dayGap(from, to) {
26631
- const utc = (iso) => {
26632
- const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
26633
- return Date.UTC(y, m - 1, d);
26634
- };
26635
- return Math.round((utc(to) - utc(from)) / 864e5);
26636
- }
26637
- /**
26638
- * Whether to catch someone up, and from when: back from time away they were
26639
- * marked for (a handover with a return date that has come, until this device
26640
- * has caught them up once since), or `UNSEEN_DAYS` or more since this device
26641
- * last saw them. Null otherwise, and the first time a device sees them:
26642
- * there's nothing to compare with.
26643
- */
26644
- function welcomeWhen(person, lastSeen, today) {
26645
- const away = person?.away;
26646
- if (away?.since && away.until && away.until.slice(0, 10) <= today) {
26647
- if (!lastSeen || lastSeen.slice(0, 10) < away.until.slice(0, 10)) return {
26648
- since: away.since,
26649
- why: "away",
26650
- days: dayGap(away.since, today)
26651
- };
26652
- }
26653
- if (!lastSeen) return null;
26654
- const days = dayGap(lastSeen, today);
26655
- return days >= 3 ? {
26656
- since: lastSeen,
26657
- why: "unseen",
26658
- days
26659
- } : null;
26660
- }
26661
-
26662
- //#endregion
26663
- //#region src/lib/welcome-back.ts
26664
- init_date_text();
26665
- init_indexes();
26666
- init_task_keys();
26667
- const day = (iso) => parseIso(iso.slice(0, 10));
26668
- const daysBetween = dayGap;
26669
- /** Their projects: ones they lead, in a program they lead, or with a task of theirs. */
26670
- function projectsOf(data, personId) {
26671
- const programs = new Set(data.programs.filter((p) => p.ownerId === personId).map((p) => p.id));
26672
- return data.projects.filter((p) => p.ownerId === personId || programs.has(p.programId) || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId));
26673
- }
26674
- const first = (p) => p?.name.split(" ")[0] ?? null;
26675
- /** "due Thu, Oct 23", "3 days late", "due today". */
26676
- function dueText(task, today) {
26677
- if (!task.dueDate) return "no due date";
26678
- const d = daysBetween(today, task.dueDate);
26679
- if (d === 0) return "due today";
26680
- if (d < 0) return `${-d} day${d === -1 ? "" : "s"} late`;
26681
- if (d === 1) return "due tomorrow";
26682
- return `due ${format(day(task.dueDate), "EEE, MMM d")}`;
26683
- }
26684
- /** What a person needs to hear about since `when.since`, as of `today` (YYYY-MM-DD). */
26685
- function welcomeBack(data, personId, when, today) {
26686
- const since = when.since;
26687
- const people = byId(data.people);
26688
- const projects = projectsOf(data, personId);
26689
- const projectIds = new Set(projects.map((p) => p.id));
26690
- const programIds = new Set(projects.map((p) => p.programId));
26691
- const projectById = byId(data.projects);
26692
- const label = (t) => {
26693
- const p = projectById.get(t.projectId);
26694
- const key = p ? taskKey(t, p) : null;
26695
- return key ? `${key} ${t.title}` : t.title;
26696
- };
26697
- const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
26698
- const handed = mine.filter((t) => t.assignedAt && t.assignedAt >= since && t.assignedBy && t.assignedBy !== personId).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999")).map((task) => ({
26699
- task,
26700
- by: people.get(task.assignedBy) ?? null
26701
- }));
26702
- const decisions = data.raidItems.filter((r) => r.kind === "decision" && r.status !== "proposed" && Boolean(r.decidedAt) && r.decidedAt >= since && r.decidedById !== personId && (r.projectId ? projectIds.has(r.projectId) : Boolean(r.programId && programIds.has(r.programId)))).sort((a, b) => b.decidedAt.localeCompare(a.decidedAt)).map((item) => ({
26703
- item,
26704
- by: item.decidedById ? people.get(item.decidedById) ?? null : null
26705
- }));
26706
- const moved = [];
26707
- for (const project of projects) {
26708
- const tasks = tasksOf(data.tasks, project.id);
26709
- const done = tasks.filter((t) => t.completedAt && t.completedAt >= since).length;
26710
- const added = tasks.filter((t) => t.createdAt >= since).length;
26711
- const history = project.healthHistory ?? [];
26712
- const before = [...history].reverse().find((h) => h.at < since)?.health;
26713
- const health = history.some((h) => h.at >= since) && before && before !== project.health ? {
26714
- from: before,
26715
- to: project.health
26716
- } : null;
26717
- if (done || added || health) moved.push({
26718
- project,
26719
- done,
26720
- added,
26721
- health
26722
- });
26723
- }
26724
- moved.sort((a, b) => Number(Boolean(b.health)) - Number(Boolean(a.health)) || b.done - a.done);
26725
- const look = [];
26726
- const seen = /* @__PURE__ */ new Set();
26727
- const push = (item) => {
26728
- const id = item.taskId ?? item.raidId ?? item.text;
26729
- if (look.length >= 3 || seen.has(id)) return;
26730
- seen.add(id);
26731
- look.push(item);
26732
- };
26733
- for (const h of handed) {
26734
- const by = first(h.by);
26735
- push({
26736
- kind: "handed",
26737
- taskId: h.task.id,
26738
- text: `${label(h.task)}: handed to you${by ? ` by ${by}` : ""}, ${dueText(h.task, today)}`
26739
- });
26740
- }
26741
- const dated = mine.filter((t) => t.dueDate).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
26742
- for (const t of dated) if (t.dueDate.slice(0, 10) < today) push({
26743
- kind: "late",
26744
- taskId: t.id,
26745
- text: `${label(t)}: ${dueText(t, today)}`
26746
- });
26747
- for (const t of dated) {
26748
- const d = daysBetween(today, t.dueDate);
26749
- if (d >= 0 && d <= 3) push({
26750
- kind: "due",
26751
- taskId: t.id,
26752
- text: `${label(t)}: ${dueText(t, today)}`
26753
- });
26754
- }
26755
- for (const d of decisions) {
26756
- const by = first(d.by);
26757
- push({
26758
- kind: "decision",
26759
- raidId: d.item.id,
26760
- text: `Decided without you: ${d.item.title}${by ? ` (${by})` : ""}`
26761
- });
26762
- }
26763
- return {
26764
- when,
26765
- handed,
26766
- decisions,
26767
- moved: moved.slice(0, 6),
26768
- lookFirst: look,
26769
- quiet: !handed.length && !decisions.length && !moved.length && !look.length
26770
- };
26771
- }
26772
- /** "Welcome back, Priya · away Oct 3 to Oct 17", or "· 5 days since you last looked". */
26773
- function welcomeTitle(w, name, today) {
26774
- const hi = name ? `Welcome back, ${name}` : "Welcome back";
26775
- if (w.when.why === "away") return `${hi} · away ${format(day(w.when.since), "MMM d")} to ${format(day(today), "MMM d")}`;
26776
- return `${hi} · ${w.when.days} days since you last looked`;
26777
- }
26778
- /** "Checkout v3: 4 done, 2 added, now At risk (was On track)". */
26779
- function movedText(m, healthLabel) {
26780
- const bits = [
26781
- m.done ? `${m.done} done` : null,
26782
- m.added ? `${m.added} added` : null,
26783
- m.health ? `now ${healthLabel(m.health.to)} (was ${healthLabel(m.health.from)})` : null
26784
- ].filter(Boolean);
26785
- return `${m.project.name}: ${bits.join(", ")}`;
26786
- }
26787
-
26788
26852
  //#endregion
26789
26853
  //#region src/tui/views/welcome.tsx
26790
26854
  init_swatches();
package/lib/cli.mjs CHANGED
@@ -45,6 +45,9 @@ import {
45
45
  whyText,
46
46
  bridgeText,
47
47
  barnaclesText,
48
+ baselineData,
49
+ baselineText,
50
+ catchupText,
48
51
  handoverText,
49
52
  fogText,
50
53
  agentWorkText,
@@ -103,13 +106,15 @@ export const HELP = `harbor: Harbor from the terminal
103
106
  harbor barnacles PROJECT work added since the plan was set:
104
107
  by week, by who, and the days of
105
108
  slip it explains
106
- harbor baseline PROJECT [--reset] sets a project's baseline: its
107
- dates now, and growth counted
108
- from now
109
+ harbor baseline PROJECT reads a project's baseline
110
+ harbor baseline PROJECT --set|--reset sets it: its dates now, and
111
+ [--dry-run] growth counted from now
109
112
  harbor hill [--program P] where each project is on its hill
110
113
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
111
114
  out, past 50 is getting it done
112
115
  harbor mine what's on you, late first
116
+ harbor catchup [--since DATE] while you were away: handed to
117
+ you, decided, what moved
113
118
  harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
114
119
  who's working now, or set your
115
120
  (or NAME's) time zone and hours
@@ -972,14 +977,27 @@ const DETAIL = {
972
977
  " don't keep hours.",
973
978
  ],
974
979
  baseline: [
975
- " harbor baseline PROJECT writes: commits its start and end",
980
+ " harbor baseline PROJECT reads its baseline and when it was set",
981
+ " harbor baseline PROJECT --set writes: commits its start and end",
976
982
  " dates as its baseline",
977
983
  " harbor baseline PROJECT --reset writes: replaces one already set",
984
+ " harbor baseline PROJECT --set --dry-run",
985
+ " reads: says what it would set",
978
986
  "",
979
987
  " The timeline then shows slips against those dates, and harbor barnacles",
980
988
  " counts tasks added from now on as growth. On the website a program's",
981
989
  " Set baseline does the same for all its projects.",
982
990
  ],
991
+ catchup: [
992
+ " harbor catchup reads the catch-up the app opens on",
993
+ " harbor catchup --since DATE reads it from a day (YYYY-MM-DD)",
994
+ "",
995
+ " What was handed to you and by whom, the calls made on your projects",
996
+ " without you, what moved on them, and the three to look at first. It",
997
+ " counts from your time away once you're back, else from when the app on",
998
+ " this machine last saw you, else the last 7 days. Reading it here",
999
+ " doesn't clear the app's or the website's catch-up.",
1000
+ ],
983
1001
  barnacles: [
984
1002
  " harbor barnacles PROJECT reads how much the plan has grown",
985
1003
  "",
@@ -2873,6 +2891,15 @@ export async function run(argv, io = {}) {
2873
2891
  if (!of) throw new HarborError("Say what: a project, a task key like RR-42, or a milestone.");
2874
2892
  return call("long_pole", { harbor, of }, whyText);
2875
2893
  }
2894
+ case "catchup": {
2895
+ const { positionals, values, harbor } = parse(spec([], ["since"]));
2896
+ none(positionals);
2897
+ if (values.since && !/^\d{4}-\d{2}-\d{2}$/.test(values.since))
2898
+ throw new HarborError("--since takes a date like 2026-10-14.");
2899
+ // When the app on this machine last saw you in this harbor (it keeps the mark by id).
2900
+ const seen = values.since ?? (harbor ? (await readSettings(env)).seen?.[harbor] : null);
2901
+ return call("catch_up", { harbor, ...(seen ? { since: seen } : {}) }, catchupText);
2902
+ }
2876
2903
  case "barnacles": {
2877
2904
  const { positionals, harbor } = parse(spec());
2878
2905
  const project = positionals.join(" ").trim();
@@ -2880,9 +2907,16 @@ export async function run(argv, io = {}) {
2880
2907
  return call("scope_growth", { harbor, project }, barnaclesText);
2881
2908
  }
2882
2909
  case "baseline": {
2883
- const { positionals, values, harbor } = parse(spec(["reset"]));
2910
+ const { positionals, values, harbor } = parse(spec(["set", "reset", "dry-run"]));
2884
2911
  const project = positionals.join(" ").trim();
2885
2912
  if (!project) throw new HarborError("Say which project: harbor baseline PROJECT.");
2913
+ const dryRun = Boolean(values["dry-run"]);
2914
+ // Reading is the default: only --set or --reset writes.
2915
+ if (dryRun || !(values.set || values.reset)) {
2916
+ return call("get_project", { harbor, project }, baselineText, (r) =>
2917
+ baselineData(r, { dryRun, reset: Boolean(values.reset) }),
2918
+ );
2919
+ }
2886
2920
  return call(
2887
2921
  "set_baseline",
2888
2922
  { harbor, project, ...(values.reset ? { reset: true } : {}) },
package/lib/format.mjs CHANGED
@@ -205,9 +205,21 @@ export function whoami(r, creds) {
205
205
  else if (list.length)
206
206
  lines.push("No harbor set: say --harbor NAME, or set one with harbor use NAME.");
207
207
  else lines.push("You aren't in any harbor yet. Run harbor to make one.");
208
+ // Shown from whoamiData (build), or straight from list_harbors (server).
209
+ lines.push(serverLine(r.build ?? (typeof r.server === "object" ? r.server : null)));
208
210
  return lines.join("\n");
209
211
  }
210
212
 
213
+ /**
214
+ * "Server: built Oct 22 03:14 UTC from 5e1c6d4." A site from before the stamp
215
+ * says nothing about itself, which means it's older than this CLI's whoami.
216
+ */
217
+ export function serverLine(server) {
218
+ if (!server?.builtAt) return "Server: an older build that doesn't say which; some commands may not be there yet.";
219
+ const at = `${monthDay(server.builtAt)} ${server.builtAt.slice(11, 16)} UTC`;
220
+ return `Server: built ${at}${server.commit ? ` from ${server.commit.slice(0, 7)}` : ""}.`;
221
+ }
222
+
211
223
  /** whoami --json: the account and its harbors, with where it signed in and the harbor commands use. */
212
224
  export function whoamiData(r, creds) {
213
225
  const list = r.harbors ?? [];
@@ -224,6 +236,8 @@ export function whoamiData(r, creds) {
224
236
  server: creds.server,
225
237
  readOnly: !creds.scope?.split(" ").includes("harbor:write"),
226
238
  harbor: using,
239
+ // The site's build (commit and when), or null from a site too old to say.
240
+ build: r.server ?? null,
227
241
  };
228
242
  }
229
243
 
@@ -644,7 +658,7 @@ export function barnaclesText(r) {
644
658
  const from =
645
659
  r.measuredFrom === "when the baseline was set"
646
660
  ? `Counted from when the baseline was set, ${r.since.slice(0, 10)}.`
647
- : `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; harbor baseline "${r.project}" counts from then instead.`;
661
+ : `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; harbor baseline "${r.project}" --set counts from then instead.`;
648
662
  return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
649
663
  }
650
664
 
@@ -1110,3 +1124,84 @@ export function followedText(r) {
1110
1124
  const f = r.followed;
1111
1125
  return `${f.person} is a second pair of hands on ${f.what}: they follow it now, and it stays with whoever has it.`;
1112
1126
  }
1127
+
1128
+ /** A project's baseline from get_project, as harbor baseline reads it (or would set it, with --dry-run). */
1129
+ export function baselineData(p, { dryRun = false, reset = false } = {}) {
1130
+ const has = Boolean(p.baselineEnds);
1131
+ return {
1132
+ project: p.name,
1133
+ ...(p.key ? { key: p.key } : {}),
1134
+ baselineStart: p.baselineStarts ?? null,
1135
+ baselineEnd: p.baselineEnds ?? null,
1136
+ setAt: p.baselineSetAt ?? null,
1137
+ starts: p.starts ?? null,
1138
+ ends: p.ends ?? null,
1139
+ ...(dryRun
1140
+ ? {
1141
+ dryRun: true,
1142
+ wouldSet: !has || reset ? { baselineStart: p.starts ?? null, baselineEnd: p.ends ?? null } : null,
1143
+ }
1144
+ : {}),
1145
+ };
1146
+ }
1147
+
1148
+ const span = (from, to) => (from ? `${monthDay(from)} – ${monthDay(to)}` : `ending ${monthDay(to)}`);
1149
+
1150
+ export function baselineText(b) {
1151
+ const name = b.project;
1152
+ const now = b.starts && b.ends ? span(b.starts, b.ends) : null;
1153
+ const had = b.baselineEnd
1154
+ ? `${b.setAt ? `Baseline set ${monthDay(b.setAt)}` : "Baseline"}: ${span(b.baselineStart, b.baselineEnd)}.`
1155
+ : null;
1156
+ if (b.dryRun) {
1157
+ if (!b.wouldSet) {
1158
+ return `${name} already has a baseline (${span(b.baselineStart, b.baselineEnd)}); --reset would replace it${now ? ` with ${now}` : ""}. Nothing changed.`;
1159
+ }
1160
+ if (!now) return `${name} has no start and end dates yet, so there's nothing to set as its baseline.`;
1161
+ return `Would set ${name}'s baseline: ${now}${had ? `, replacing ${span(b.baselineStart, b.baselineEnd)}` : ""}. Nothing changed; leave out --dry-run to set it.`;
1162
+ }
1163
+ if (!had) {
1164
+ return now
1165
+ ? `${name}: no baseline yet. harbor baseline "${name}" --set commits its dates now (${now}).`
1166
+ : `${name}: no baseline yet, and no start and end dates to set one from.`;
1167
+ }
1168
+ const moved = now && (b.starts !== b.baselineStart || b.ends !== b.baselineEnd) && b.baselineStart;
1169
+ return [`${name}: ${had}`, ...(moved ? [`Its dates now: ${now}. --reset makes those the baseline.`] : [])].join("\n");
1170
+ }
1171
+
1172
+
1173
+ /** catch_up as the terminal shows it: the three to look at first, the calls made without you, what moved. */
1174
+ export function catchupText(r) {
1175
+ if (!r.you) return r.note ?? "Nothing to catch up on.";
1176
+ const first = r.you.split(" ")[0];
1177
+ const head =
1178
+ r.from === "time away"
1179
+ ? `While you were away, ${first} · since ${monthDay(r.since)}`
1180
+ : r.from === "when you last looked"
1181
+ ? `Since you last looked, ${first} · ${monthDay(r.since)}, ${r.days} day${r.days === 1 ? "" : "s"} ago`
1182
+ : `The last 7 days, ${first} · since ${monthDay(r.since)}`;
1183
+ if (r.quiet) return `${head}\n\nNothing moved on your projects since then.`;
1184
+ const out = [head];
1185
+ if (r.lookFirst.length) {
1186
+ out.push("", "Look at first:");
1187
+ for (const l of r.lookFirst) out.push(` ${l.text}`);
1188
+ }
1189
+ if (r.decisions.length) {
1190
+ out.push("", "Decided without you:");
1191
+ for (const d of r.decisions) out.push(` ${d.title}${d.by ? ` · ${d.by.split(" ")[0]}` : ""} (${monthDay(d.decidedAt)})`);
1192
+ }
1193
+ if (r.moved.length) {
1194
+ out.push("", "What moved:");
1195
+ for (const m of r.moved) {
1196
+ const bits = [
1197
+ m.done ? `${m.done} done` : null,
1198
+ m.added ? `${m.added} added` : null,
1199
+ m.health ? `now ${HEALTH[m.health.to] ?? m.health.to} (was ${HEALTH[m.health.from] ?? m.health.from})` : null,
1200
+ ].filter(Boolean);
1201
+ out.push(` ${m.project}: ${bits.join(", ")}`);
1202
+ }
1203
+ }
1204
+ const more = r.handed.length > r.lookFirst.filter((l) => l.kind === "handed").length;
1205
+ if (more) out.push("", `${r.handed.length} handed to you in all; harbor catchup --json lists them.`);
1206
+ return out.join("\n");
1207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.64.0",
3
+ "version": "0.65.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",