@harborppm/cli 0.48.0 → 0.49.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
@@ -174,6 +174,8 @@ harbor import FILE [--project P] [--portfolio P] [--no-people]
174
174
  harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
175
175
  harbor voyage PROJECT ["YOUR LINE"]
176
176
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
177
+ harbor sound TASK [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
178
+ harbor soundings [PROJECT]
177
179
  harbor limit PROJECT [COLUMN N|none]
178
180
  harbor init [PROJECT] [--hook] [--dry-run] a project's tasks as files in this repository
179
181
  harbor init --local [NAME] a plan in this folder alone, no account
@@ -194,7 +196,7 @@ Agents on the crew: put an AI app on People with Add an agent and assign it task
194
196
 
195
197
  `harbor limit RR` prints how many tasks each of a board's columns takes at once; `harbor limit RR doing 8` changes one (1 to 20, or `none` for no limit), if you own the harbor. `harbor projects --json` carries each board's `limits` too.
196
198
 
197
- `harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief.
199
+ `harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first.
198
200
 
199
201
  `harbor brief --say` turns the brief into the BBC's Shipping Forecast: each program an area, wind for late work, visibility for health, gale warnings for anything off track. It prints the forecast and speaks it with your computer's own voice (`say` on a Mac, Windows' speech, or `espeak-ng` on Linux, which you may need to install). With `--json` it prints the forecast and stays quiet.
200
202
 
package/dist/app.mjs CHANGED
@@ -3028,10 +3028,118 @@ function forecastRollup(items, budget, now) {
3028
3028
  };
3029
3029
  }
3030
3030
 
3031
+ //#endregion
3032
+ //#region src/lib/soundings.ts
3033
+ /** Why a number can't be an answer, or null when it can: half days up to SOUNDING_MAX_DAYS. */
3034
+ function daysProblem(days) {
3035
+ if (!Number.isFinite(days) || days <= 0) return "Give a number of days above 0, like 3 or 0.5.";
3036
+ if (days > 250) return `Keep it to ${250} working days or fewer.`;
3037
+ if (Math.round(days * 2) !== days * 2) return "Use whole or half days, like 3 or 2.5.";
3038
+ return null;
3039
+ }
3040
+ /** Each asked person's latest answer to the task's current sounding, oldest first. */
3041
+ function answersFor(reflections, task) {
3042
+ const s = task.sounding;
3043
+ if (!s) return [];
3044
+ const crew = new Set(s.crewIds);
3045
+ const latest = /* @__PURE__ */ new Map();
3046
+ for (const r of reflections ?? []) {
3047
+ if (r.kind !== "sounding" || r.taskId !== task.id || typeof r.days !== "number") continue;
3048
+ if (r.createdAt < s.askedAt || !crew.has(r.authorId)) continue;
3049
+ const had = latest.get(r.authorId);
3050
+ if (!had || r.createdAt >= had.createdAt) latest.set(r.authorId, r);
3051
+ }
3052
+ return [...latest.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3053
+ }
3054
+ /** Middle of the guesses, to the half day. */
3055
+ function middleOf(days) {
3056
+ if (days.length === 0) return null;
3057
+ const sorted = [...days].sort((a, b) => a - b);
3058
+ const mid = Math.floor(sorted.length / 2);
3059
+ const m = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
3060
+ return Math.round(m * 2) / 2;
3061
+ }
3062
+ /**
3063
+ * Guesses far enough apart to talk about first: the highest at least twice
3064
+ * the lowest and three or more days from it. 3 against 5 is agreement; 2
3065
+ * against 15, or 5 against 10, is two people seeing different work.
3066
+ */
3067
+ function isWide(days) {
3068
+ if (days.length < 2) return false;
3069
+ const low = Math.min(...days);
3070
+ const high = Math.max(...days);
3071
+ return high - low >= 3 && high >= low * 2;
3072
+ }
3073
+ /** Where a task's sounding stands, or null when nobody asked. */
3074
+ function readSounding(task, reflections, now) {
3075
+ const s = task.sounding;
3076
+ if (!s) return null;
3077
+ const answers = answersFor(reflections, task);
3078
+ const by = new Set(answers.map((a) => a.authorId));
3079
+ const waitingOn = s.crewIds.filter((id) => !by.has(id));
3080
+ const early = Boolean(s.shownAt && Date.parse(s.shownAt) <= now.getTime());
3081
+ const shown = answers.length > 0 && (waitingOn.length === 0 || early);
3082
+ const days = answers.map((a) => a.days);
3083
+ const wide = shown && isWide(days);
3084
+ const middle = middleOf(days);
3085
+ const settled = s.settledDays ?? null;
3086
+ return {
3087
+ sounding: s,
3088
+ crewIds: s.crewIds,
3089
+ answers,
3090
+ waitingOn,
3091
+ shown,
3092
+ low: days.length ? Math.min(...days) : null,
3093
+ high: days.length ? Math.max(...days) : null,
3094
+ middle,
3095
+ wide,
3096
+ estimate: settled ?? (shown && !wide ? middle : null)
3097
+ };
3098
+ }
3099
+ /** What one person may see: their own answer always; everyone's once shown. */
3100
+ function visibleAnswers$1(read, personId) {
3101
+ return read.shown ? read.answers : read.answers.filter((a) => a.authorId === personId);
3102
+ }
3103
+ /**
3104
+ * The answers the forecast draws from for an open task: the settled number
3105
+ * alone once there is one, otherwise every answer in so far (the spread is
3106
+ * the point, and the forecast names nobody's guess).
3107
+ */
3108
+ function soundingDraws(task, reflections) {
3109
+ const s = task.sounding;
3110
+ if (!s) return [];
3111
+ if (s.settledDays != null) return [s.settledDays];
3112
+ return answersFor(reflections, task).map((a) => a.days);
3113
+ }
3114
+ /** Calendar days from `from` until `days` working days (Monday to Friday) have passed. */
3115
+ function workingDaysAhead(days, from) {
3116
+ let left = Math.ceil(days);
3117
+ let d = 0;
3118
+ while (left > 0) {
3119
+ d += 1;
3120
+ const wd = addDays(from, d).getDay();
3121
+ if (wd !== 0 && wd !== 6) left -= 1;
3122
+ }
3123
+ return d;
3124
+ }
3125
+ /** Open tasks whose sounding waits on this person's answer. */
3126
+ function soundingsWaitingOn(data, personId, now) {
3127
+ return data.tasks.filter((t) => {
3128
+ if (t.status === "done" || !t.sounding?.crewIds.includes(personId)) return false;
3129
+ return readSounding(t, data.reflections, now).waitingOn.includes(personId);
3130
+ });
3131
+ }
3132
+ var SOUNDING_MAX_DAYS;
3133
+ var init_soundings = __esmMin((() => {
3134
+ init_date_fns();
3135
+ SOUNDING_MAX_DAYS = 250;
3136
+ }));
3137
+
3031
3138
  //#endregion
3032
3139
  //#region src/lib/tide.ts
3033
3140
  init_date_fns();
3034
3141
  init_format();
3142
+ init_soundings();
3035
3143
  /**
3036
3144
  * The tide forecast: when a project will likely finish, from how fast its
3037
3145
  * crew has actually closed tasks. Each of a thousand runs replays the coming
@@ -3106,6 +3214,47 @@ function simulate(remaining, weekly, seed, pace = 1) {
3106
3214
  return out.sort((a, b) => a - b);
3107
3215
  }
3108
3216
  /**
3217
+ * Per run, the fewest days the sounded tasks allow: each draws one of its
3218
+ * answers at random, so a wide spread widens the forecast. A task already
3219
+ * in play counts half its days; it's partly done.
3220
+ */
3221
+ function soundingFloors(tasks, reflections, base, seed) {
3222
+ const draws = [];
3223
+ for (const t of tasks) {
3224
+ if (t.status === "done") continue;
3225
+ const days = soundingDraws(t, reflections);
3226
+ if (days.length === 0) continue;
3227
+ draws.push({
3228
+ days,
3229
+ share: t.status === "doing" || t.status === "review" ? .5 : 1
3230
+ });
3231
+ }
3232
+ if (draws.length === 0) return {
3233
+ floors: null,
3234
+ sounded: 0
3235
+ };
3236
+ const next = random(seed);
3237
+ const memo = /* @__PURE__ */ new Map();
3238
+ const ahead = (d) => {
3239
+ let v = memo.get(d);
3240
+ if (v === void 0) memo.set(d, v = workingDaysAhead(d, base));
3241
+ return v;
3242
+ };
3243
+ const floors = [];
3244
+ for (let run = 0; run < TRIALS; run++) {
3245
+ let most = 0;
3246
+ for (const x of draws) {
3247
+ const d = x.days[Math.floor(next() * x.days.length)] * x.share;
3248
+ most = Math.max(most, ahead(d));
3249
+ }
3250
+ floors.push(most);
3251
+ }
3252
+ return {
3253
+ floors,
3254
+ sounded: draws.length
3255
+ };
3256
+ }
3257
+ /**
3109
3258
  * `pace` scales the crew's weekly counts (1 is as they are), for dry dock's
3110
3259
  * "what if Sam joined" (src/lib/dry-dock.ts).
3111
3260
  */
@@ -3132,7 +3281,8 @@ function tideForecast(project, tasks, now, opts = {}) {
3132
3281
  if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
3133
3282
  }
3134
3283
  const floor = lastDue ? differenceInCalendarDays(noon$1(lastDue.dueDate), base) : 0;
3135
- const runs = floor > 0 ? paced.map((d) => Math.max(d, floor)) : paced;
3284
+ const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`));
3285
+ const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
3136
3286
  const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
3137
3287
  const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
3138
3288
  const toEnd = differenceInCalendarDays(noon$1(project.endDate), base);
@@ -3160,7 +3310,8 @@ function tideForecast(project, tasks, now, opts = {}) {
3160
3310
  chanceByEnd: Math.round(byEnd * 100) / 100,
3161
3311
  curve,
3162
3312
  rough: finished < ROUGH_BELOW,
3163
- heldBy
3313
+ heldBy,
3314
+ sounded: sounded.sounded
3164
3315
  };
3165
3316
  }
3166
3317
  /** "15%", with "under 5%" and "over 95%" at the ends, where a thousand runs can't be that sure. */
@@ -4237,7 +4388,7 @@ function filterTasks(tasks, f) {
4237
4388
  */
4238
4389
  function tideLine(data, project, today) {
4239
4390
  const tasks = tasksOf(data.tasks, project.id);
4240
- const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`));
4391
+ const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`), { reflections: data.reflections });
4241
4392
  if (tide.kind === "no-pace") return {
4242
4393
  text: "No forecast yet: nothing finished in the last few weeks",
4243
4394
  late: false
@@ -7282,18 +7433,21 @@ init_format();
7282
7433
  init_raid();
7283
7434
  init_work();
7284
7435
  init_clock();
7436
+ init_soundings();
7285
7437
  const KIND_RANK = {
7286
7438
  waiting: 0,
7287
7439
  approval: 1,
7288
7440
  review: 2,
7289
- assigned: 3,
7290
- idle: 4,
7291
- mention: 5
7441
+ sounding: 3,
7442
+ assigned: 4,
7443
+ idle: 5,
7444
+ mention: 6
7292
7445
  };
7293
7446
  const PING_KIND_LABEL = {
7294
7447
  waiting: "Waiting on you",
7295
7448
  approval: "Sign-offs",
7296
7449
  review: "Reviews due",
7450
+ sounding: "Soundings",
7297
7451
  assigned: "Assigned to you",
7298
7452
  idle: "Quiet blockers",
7299
7453
  mention: "Mentions"
@@ -7424,6 +7578,22 @@ function inboxOf(data, personId, now = clockNow()) {
7424
7578
  personId
7425
7579
  });
7426
7580
  }
7581
+ for (const task of soundingsWaitingOn(data, personId, now)) {
7582
+ const s = task.sounding;
7583
+ if (s.askedById === personId) continue;
7584
+ const by = data.people.find((p) => p.id === s.askedById);
7585
+ const project = projectOf(data, task);
7586
+ pings.push({
7587
+ id: `sounding:${task.id}:${s.askedAt}`,
7588
+ kind: "sounding",
7589
+ title: `${by ? firstName(by.name) : "Someone"} asked how long a task will take`,
7590
+ detail: `“${task.title}”${project ? ` · ${project.name}` : ""} · answer alone; the guesses show together`,
7591
+ at: s.askedAt,
7592
+ taskId: task.id,
7593
+ personId: s.askedById,
7594
+ href: `/projects/${task.projectId}`
7595
+ });
7596
+ }
7427
7597
  for (const comment of mentionsFor(data.comments, personId)) {
7428
7598
  const task = data.tasks.find((t) => t.id === comment.taskId);
7429
7599
  if (!task || task.status === "done") continue;
@@ -14858,8 +15028,83 @@ function tasksSlice({ set, get, write }) {
14858
15028
  if (ids.length >= 3) return "full";
14859
15029
  set({ ropes: upsertFocus(get().ropes, personId, date, [...ids, taskId]) });
14860
15030
  return "added";
15031
+ },
15032
+ takeSounding: (taskId, crewIds) => {
15033
+ const s = get();
15034
+ const task = s.tasks.find((t) => t.id === taskId);
15035
+ const people = new Set(s.people.filter((p) => p.kind !== "agent").map((p) => p.id));
15036
+ const crew = [...new Set(crewIds)].filter((id) => people.has(id)).slice(0, 12);
15037
+ if (!task || task.status === "done" || crew.length === 0) return false;
15038
+ const sounding = {
15039
+ askedAt: clockNow().toISOString(),
15040
+ askedById: s.currentUserId,
15041
+ crewIds: crew
15042
+ };
15043
+ set({
15044
+ tasks: s.tasks.map((t) => t.id === taskId ? {
15045
+ ...t,
15046
+ sounding
15047
+ } : t),
15048
+ reflections: (s.reflections ?? []).filter((r) => !(r.kind === "sounding" && r.taskId === taskId))
15049
+ });
15050
+ return true;
15051
+ },
15052
+ answerSounding: (taskId, days, why = "") => {
15053
+ const s = get();
15054
+ const task = s.tasks.find((t) => t.id === taskId);
15055
+ if (!task?.sounding || !task.sounding.crewIds.includes(s.currentUserId)) return false;
15056
+ if (daysProblem(days)) return false;
15057
+ const me = s.currentUserId;
15058
+ const others = (s.reflections ?? []).filter((r) => !(r.kind === "sounding" && r.taskId === taskId && r.authorId === me));
15059
+ const row = {
15060
+ id: uid("rf"),
15061
+ projectId: task.projectId,
15062
+ kind: "sounding",
15063
+ authorId: me,
15064
+ body: why.trim().slice(0, 300),
15065
+ createdAt: clockNow().toISOString(),
15066
+ taskId,
15067
+ days
15068
+ };
15069
+ set({ reflections: [...others, row] });
15070
+ return true;
15071
+ },
15072
+ showSounding: (taskId) => {
15073
+ const task = get().tasks.find((t) => t.id === taskId);
15074
+ if (!task?.sounding || task.sounding.shownAt) return;
15075
+ setSounding(taskId, {
15076
+ ...task.sounding,
15077
+ shownAt: clockNow().toISOString()
15078
+ });
15079
+ },
15080
+ settleSounding: (taskId, days) => {
15081
+ const task = get().tasks.find((t) => t.id === taskId);
15082
+ if (!task?.sounding) return false;
15083
+ if (days !== null && daysProblem(days)) return false;
15084
+ setSounding(taskId, {
15085
+ ...task.sounding,
15086
+ settledDays: days
15087
+ });
15088
+ return true;
15089
+ },
15090
+ stopSounding: (taskId) => {
15091
+ const s = get();
15092
+ if (!s.tasks.some((t) => t.id === taskId && t.sounding)) return;
15093
+ set({
15094
+ tasks: s.tasks.map((t) => t.id === taskId ? {
15095
+ ...t,
15096
+ sounding: null
15097
+ } : t),
15098
+ reflections: (s.reflections ?? []).filter((r) => !(r.kind === "sounding" && r.taskId === taskId))
15099
+ });
14861
15100
  }
14862
15101
  };
15102
+ function setSounding(taskId, sounding) {
15103
+ set({ tasks: get().tasks.map((t) => t.id === taskId ? {
15104
+ ...t,
15105
+ sounding
15106
+ } : t) });
15107
+ }
14863
15108
  }
14864
15109
  var init_tasks = __esmMin((() => {
14865
15110
  init_github_refs();
@@ -14867,6 +15112,8 @@ var init_tasks = __esmMin((() => {
14867
15112
  init_automations();
14868
15113
  init_focus();
14869
15114
  init_format();
15115
+ init_soundings();
15116
+ init_clock();
14870
15117
  init_utils();
14871
15118
  init_blockers();
14872
15119
  init_work();
@@ -23725,6 +23972,7 @@ init_handover();
23725
23972
  init_work();
23726
23973
  init_fog();
23727
23974
  init_board();
23975
+ init_soundings();
23728
23976
  /**
23729
23977
  * Where the website opens one task or project, switching to its harbor
23730
23978
  * first (src/routes/open.tsx). Nothing without an origin.
@@ -23776,8 +24024,8 @@ const STATUSES = [
23776
24024
  * for an agent: dates for half, 85% and 95% of the runs, or why there's none.
23777
24025
  * Given the spend ledger, also what each week late costs (src/lib/slip-cost.ts).
23778
24026
  */
23779
- function finishForecast(project, tasks, now, spend) {
23780
- const tide = tideForecast(project, tasks, now);
24027
+ function finishForecast(project, tasks, now, spend, reflections) {
24028
+ const tide = tideForecast(project, tasks, now, { reflections });
23781
24029
  if (tide.kind !== "forecast") return { none: tideQuiet(tide) };
23782
24030
  const lastDue = dueAfterFinish(tide);
23783
24031
  const dueLater = lastDue ? { lastDue: {
@@ -23803,6 +24051,7 @@ function finishForecast(project, tasks, now, spend) {
23803
24051
  finishedPerWeek: tide.perWeek,
23804
24052
  basedOn: `${tide.finished} task${tide.finished === 1 ? "" : "s"} finished in the last ${tide.weeks} week${tide.weeks === 1 ? "" : "s"}`,
23805
24053
  ...tide.rough ? { rough: true } : {},
24054
+ ...tide.sounded ? { soundedTasks: tide.sounded } : {},
23806
24055
  ...tide.weeks < 2 ? { shortHistory: true } : {},
23807
24056
  ...dueLater,
23808
24057
  ...slip
@@ -23862,6 +24111,39 @@ function me(ctx) {
23862
24111
  }
23863
24112
  const nameOf = (people, id) => id && people.get(id)?.name || null;
23864
24113
  const isOpen = (t) => t.status !== "done";
24114
+ /**
24115
+ * A task's sounding as the caller may see it (src/lib/soundings.ts): who was
24116
+ * asked and who hasn't answered, their own answer, and everyone's with the
24117
+ * spread only once the answers show. Null when nobody asked.
24118
+ */
24119
+ function soundingOut(data, t, meId, now) {
24120
+ const read = readSounding(t, data.reflections, now);
24121
+ if (!read) return null;
24122
+ const people = byId(data.people);
24123
+ const seen = meId ? visibleAnswers$1(read, meId) : read.shown ? read.answers : [];
24124
+ return {
24125
+ askedBy: nameOf(people, read.sounding.askedById),
24126
+ asked: dayOf(read.sounding.askedAt),
24127
+ crew: read.crewIds.map((id) => nameOf(people, id) ?? "Someone"),
24128
+ answered: read.answers.length,
24129
+ waitingOn: read.waitingOn.map((id) => nameOf(people, id) ?? "Someone"),
24130
+ ...meId && read.crewIds.includes(meId) ? { youWereAsked: true } : {},
24131
+ shown: read.shown,
24132
+ answers: seen.map((a) => ({
24133
+ who: nameOf(people, a.authorId) ?? "Someone",
24134
+ days: a.days,
24135
+ ...a.body ? { why: a.body } : {}
24136
+ })),
24137
+ ...read.shown ? {
24138
+ low: read.low,
24139
+ high: read.high,
24140
+ middle: read.middle
24141
+ } : {},
24142
+ ...read.wide ? { talkFirst: true } : {},
24143
+ estimateDays: read.estimate,
24144
+ ...read.sounding.settledDays != null ? { settled: true } : {}
24145
+ };
24146
+ }
23865
24147
  const dayOf = (iso) => iso ? iso.slice(0, 10) : null;
23866
24148
  function overdue(t, today) {
23867
24149
  const due = dayOf(t.dueDate);
@@ -24119,7 +24401,7 @@ const HARBOR_TOOLS = [
24119
24401
  done: c.done,
24120
24402
  late: c.late
24121
24403
  },
24122
- finish: finishForecast(p, tasksOf(data.tasks, p.id), ctx.now),
24404
+ finish: finishForecast(p, tasksOf(data.tasks, p.id), ctx.now, void 0, data.reflections),
24123
24405
  ...limitsOf(data, p.id),
24124
24406
  ...hillOf(p, today),
24125
24407
  ...publicRoadmap(ctx, p.id)
@@ -24190,7 +24472,7 @@ const HARBOR_TOOLS = [
24190
24472
  waitsOn,
24191
24473
  milestones,
24192
24474
  openRaid: raid,
24193
- finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id)),
24475
+ finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data.reflections),
24194
24476
  ...notesOf(ctx, p),
24195
24477
  ...hillOf(p, today),
24196
24478
  ...webLink(ctx, { project: p.id })
@@ -24298,6 +24580,7 @@ const HARBOR_TOOLS = [
24298
24580
  })),
24299
24581
  waitingOn: blockersOf(t, data.tasks).map(brief),
24300
24582
  waitedOnBy: waiters.map(brief),
24583
+ ...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
24301
24584
  comments,
24302
24585
  ...webLink(ctx, { task: t.id })
24303
24586
  };
@@ -24346,7 +24629,13 @@ const HARBOR_TOOLS = [
24346
24629
  late: open.filter((t) => overdue(t, today)).length,
24347
24630
  tasks: open.slice(0, input.limit).map((t) => taskLine(t, people, projects, data.tasks)),
24348
24631
  waitingOnYou: waitingOnYou.slice(0, 20),
24349
- raid
24632
+ raid,
24633
+ soundingsToAnswer: soundingsWaitingOn(data, mine.id, ctx.now).map((t) => ({
24634
+ id: t.id,
24635
+ ...taskKey(t, projects.get(t.projectId)) ? { key: taskKey(t, projects.get(t.projectId)) } : {},
24636
+ title: t.title,
24637
+ askedBy: nameOf(people, t.sounding.askedById)
24638
+ }))
24350
24639
  };
24351
24640
  }
24352
24641
  }),
@@ -24622,6 +24911,35 @@ const HARBOR_TOOLS = [
24622
24911
  };
24623
24912
  }
24624
24913
  }),
24914
+ tool({
24915
+ name: "list_soundings",
24916
+ title: "Soundings",
24917
+ description: "Open tasks someone took a sounding on (asked a few people how many working days each will take), in one project or the whole harbor: who hasn't answered, and once the answers show, the spread, whether it's wide enough to talk through first (talkFirst), and the estimate. Answers show only once everyone has answered or someone shows them; until then you see only the person's own." + DATA_NOTE,
24918
+ input: object({
24919
+ harbor: harborArg,
24920
+ project: string().min(1).max(200).optional().describe("Project id or name; all projects when left out.")
24921
+ }),
24922
+ run: (ctx, input) => {
24923
+ const { data } = ctx;
24924
+ const project = input.project ? findOne(data.projects, input.project, "project") : null;
24925
+ const projects = byId(data.projects);
24926
+ const meId = me(ctx)?.id ?? null;
24927
+ const tasks = data.tasks.filter((t) => isOpen(t) && t.sounding && (!project || t.projectId === project.id));
24928
+ return {
24929
+ ...project ? { project: project.name } : {},
24930
+ soundings: tasks.map((t) => {
24931
+ const key = taskKey(t, projects.get(t.projectId));
24932
+ return {
24933
+ id: t.id,
24934
+ ...key ? { key } : {},
24935
+ title: t.title,
24936
+ project: projects.get(t.projectId)?.name ?? null,
24937
+ ...soundingOut(data, t, meId, ctx.now)
24938
+ };
24939
+ })
24940
+ };
24941
+ }
24942
+ }),
24625
24943
  tool({
24626
24944
  name: "shipping_forecast",
24627
24945
  title: "The harbor forecast",
package/lib/cli.mjs CHANGED
@@ -33,6 +33,9 @@ import {
33
33
  myWork,
34
34
  premortemText,
35
35
  notesText,
36
+ daysText,
37
+ soundingText,
38
+ soundingsText,
36
39
  voyageText,
37
40
  handoverText,
38
41
  fogText,
@@ -175,6 +178,12 @@ export const HELP = `harbor: Harbor from the terminal
175
178
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
176
179
  a note for later: sealed until
177
180
  its day, milestone, or Off track
181
+ harbor sound TASK [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
182
+ a sounding: ask a few people how
183
+ many days a task will take, give
184
+ your answer, or read the spread
185
+ harbor soundings [PROJECT] open tasks with a sounding, and
186
+ the ones waiting on your answer
178
187
  harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
179
188
  [--keep ID,ID] [--confirm]
180
189
  hand someone's work to the crew
@@ -369,6 +378,9 @@ const FLAG = {
369
378
  to: "--to",
370
379
  keep: "--keep",
371
380
  person: "the person",
381
+ days: "the days",
382
+ why: "--why",
383
+ people: "--ask",
372
384
  };
373
385
 
374
386
  /** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
@@ -606,6 +618,25 @@ const DETAIL = {
606
618
  " --no-people. A task already in its project is left alone: by its key",
607
619
  " (a Key column, as harbor export writes) or by title.",
608
620
  ],
621
+ sound: [
622
+ " harbor sound TASK reads its sounding: who hasn't answered,",
623
+ " and the spread once everyone has",
624
+ " harbor sound TASK --ask crew writes: asks you, its owner and whoever",
625
+ " has open work on its project",
626
+ ' harbor sound TASK --ask "Sam,Priya" writes: asks those people',
627
+ ' harbor sound TASK 4 [--why "TEXT"] writes: your answer, in working days',
628
+ " harbor sound TASK --settle 6 writes: the days you agreed on after",
629
+ " talking a wide spread through",
630
+ "",
631
+ " Each person answers alone, and nobody sees the others' numbers until",
632
+ " everyone has. Close guesses give the task an estimate; far-apart ones",
633
+ " say talk first. The forecast draws on every answer.",
634
+ ],
635
+ soundings: [
636
+ " harbor soundings [PROJECT] reads: open tasks with a sounding",
637
+ " (all projects when left out), and the",
638
+ " ones waiting on your answer",
639
+ ],
609
640
  note: [
610
641
  " harbor note PROJECT reads its notes for later: who left",
611
642
  " each, what opens it, and the opened ones",
@@ -2051,6 +2082,65 @@ export async function run(argv, io = {}) {
2051
2082
  (r) => `Sealed your note on ${r.sealed.project}. It ${r.sealed.opens}.`,
2052
2083
  );
2053
2084
  }
2085
+ case "sound": {
2086
+ const { positionals, values, harbor } = parse(spec([], ["why", "ask", "settle"]));
2087
+ const [task, days, ...extra] = positionals;
2088
+ if (!task) throw new HarborError("Give a task's key, like harbor sound RR-4.");
2089
+ if (extra.length) throw new HarborError(`Unexpected "${extra.join(" ")}".`);
2090
+ const number = (text) => {
2091
+ const n = Number(String(text).replace(/d(ays?)?$/i, ""));
2092
+ if (!Number.isFinite(n) || n <= 0)
2093
+ throw new HarborError(`Give days as a number, like 3 or 2.5 (not "${text}").`);
2094
+ return n;
2095
+ };
2096
+ const given = [days !== undefined, values.ask !== undefined, values.settle !== undefined];
2097
+ if (given.filter(Boolean).length > 1)
2098
+ throw new HarborError("Answer, ask or settle: one at a time.");
2099
+ if (values.why !== undefined && days === undefined)
2100
+ throw new HarborError('--why goes with your answer: harbor sound TASK 4 --why "TEXT".');
2101
+ if (values.ask !== undefined) {
2102
+ const crew = /^crew$/i.test(values.ask.trim())
2103
+ ? undefined
2104
+ : values.ask
2105
+ .split(",")
2106
+ .map((x) => x.trim())
2107
+ .filter(Boolean);
2108
+ return call(
2109
+ "take_sounding",
2110
+ { harbor, task, ...(crew ? { people: crew } : {}) },
2111
+ (r) =>
2112
+ `Asked ${r.asked.people.join(", ")} how many days ${r.asked.key ?? r.asked.title} will take. The answers show together once everyone's in.`,
2113
+ );
2114
+ }
2115
+ if (values.settle !== undefined) {
2116
+ return call(
2117
+ "settle_sounding",
2118
+ { harbor, task, days: number(values.settle) },
2119
+ (r) => `Settled ${r.settled.key ?? r.settled.title} at ${daysText(r.settled.days)}.`,
2120
+ );
2121
+ }
2122
+ if (days !== undefined) {
2123
+ return call(
2124
+ "answer_sounding",
2125
+ { harbor, task, days: number(days), ...(values.why ? { why: values.why } : {}) },
2126
+ (r) => soundingText(r.answered, r.sounding, { answered: true }),
2127
+ );
2128
+ }
2129
+ return call(
2130
+ "get_task",
2131
+ { harbor, task },
2132
+ (r) => soundingText(r, r.sounding ?? null),
2133
+ (r) => ({ id: r.id, key: r.key ?? null, title: r.title, sounding: r.sounding ?? null }),
2134
+ );
2135
+ }
2136
+ case "soundings": {
2137
+ const { positionals, harbor } = parse(spec());
2138
+ const [project, ...extra] = positionals;
2139
+ if (extra.length) throw new HarborError(`Unexpected "${extra.join(" ")}".`);
2140
+ return call("list_soundings", { harbor, ...(project ? { project } : {}) }, (r) =>
2141
+ soundingsText(r),
2142
+ );
2143
+ }
2054
2144
  case "voyage": {
2055
2145
  const { positionals, harbor } = parse(spec());
2056
2146
  const [project, ...words] = positionals;
package/lib/format.mjs CHANGED
@@ -600,6 +600,78 @@ export function notesText(project, notes) {
600
600
  return lines.join("\n");
601
601
  }
602
602
 
603
+ /** "half a day", "1 day", "2.5 days". */
604
+ export function daysText(days) {
605
+ if (days === 0.5) return "half a day";
606
+ return `${days} day${days === 1 ? "" : "s"}`;
607
+ }
608
+
609
+ /** The spread as a line of dots, 0 to the highest guess, like the website's. */
610
+ function spreadLine(answers, width = 30) {
611
+ const days = answers.map((a) => a.days);
612
+ const top = Math.max(5, ...days);
613
+ const cells = Array(width + 1).fill("·");
614
+ for (const d of days) cells[Math.round((d / top) * width)] = "●";
615
+ return `0 ${cells.join("")} ${daysText(top)}`;
616
+ }
617
+
618
+ /** One task's sounding: who's left to answer, or the spread and what it means. */
619
+ export function soundingText(task, s, { answered = false } = {}) {
620
+ const name = task.key ? `${task.key} ${task.title}` : task.title;
621
+ if (!s)
622
+ return `No sounding on ${name}. Ask the crew: harbor sound ${task.key ?? task.id} --ask crew`;
623
+ const lines = [`Sounding · ${name} · ${s.answered} of ${s.crew.length} answered`];
624
+ if (answered) lines[0] = `Your answer is in. ${lines[0]}`;
625
+ if (!s.shown) {
626
+ if (s.waitingOn.length) lines.push(` Waiting on ${s.waitingOn.join(", ")}.`);
627
+ lines.push(" The answers show together once everyone's in.");
628
+ for (const a of s.answers)
629
+ lines.push(` You said ${daysText(a.days)}${a.why ? ` · "${a.why}"` : ""}`);
630
+ if (s.youWereAsked && !s.answers.length)
631
+ lines.push(` Give yours: harbor sound ${task.key ?? task.id} DAYS`);
632
+ return lines.join("\n");
633
+ }
634
+ lines.push(` ${spreadLine(s.answers)}`);
635
+ for (const a of s.answers)
636
+ lines.push(` ${a.who}: ${daysText(a.days)}${a.why ? ` · "${a.why}"` : ""}`);
637
+ if (s.settled) lines.push(` Settled on ${daysText(s.estimateDays)}.`);
638
+ else if (s.talkFirst)
639
+ lines.push(
640
+ ` Talk about this first: ${daysText(s.low)} to ${daysText(s.high)} means someone sees work the others don't.`,
641
+ ` Once you've talked: harbor sound ${task.key ?? task.id} --settle DAYS`,
642
+ );
643
+ else lines.push(` Close enough: about ${daysText(s.estimateDays)}.`);
644
+ return lines.join("\n");
645
+ }
646
+
647
+ /** list_soundings: the ones waiting on you first, then the rest, wide ones flagged. */
648
+ export function soundingsText(r) {
649
+ const all = r.soundings ?? [];
650
+ const where = r.project ? ` on ${r.project}` : "";
651
+ if (!all.length)
652
+ return `No soundings${where}. Ask how long a task will take: harbor sound TASK --ask crew`;
653
+ const name = (t) => (t.key ? `${t.key} ${t.title}` : t.title);
654
+ const yours = all.filter((t) => t.youWereAsked && !t.answers.length && !t.shown);
655
+ const lines = [];
656
+ if (yours.length) {
657
+ lines.push("Waiting on your answer");
658
+ for (const t of yours) lines.push(` ${name(t)} · asked by ${t.askedBy ?? "someone"}`);
659
+ lines.push("");
660
+ }
661
+ lines.push(`Soundings${where}`);
662
+ for (const t of all) {
663
+ const state = t.settled
664
+ ? `settled · ${daysText(t.estimateDays)}`
665
+ : t.talkFirst
666
+ ? `talk first · ${daysText(t.low)} to ${daysText(t.high)}`
667
+ : t.shown
668
+ ? `about ${daysText(t.estimateDays)}`
669
+ : `${t.answered} of ${t.crew.length} answered`;
670
+ lines.push(` ${name(t)} · ${state}`);
671
+ }
672
+ return lines.join("\n");
673
+ }
674
+
603
675
  /** handover_plan as a list grouped like the website's dialog, each line with who takes it. */
604
676
  export function handoverText(r, { confirmHint = true } = {}) {
605
677
  const items = r.items ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.48.0",
3
+ "version": "0.49.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",