@harborppm/cli 0.47.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/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;
@@ -10698,12 +10868,103 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
10698
10868
  });
10699
10869
  }
10700
10870
 
10871
+ //#endregion
10872
+ //#region src/lib/fog.ts
10873
+ function latestBy(cache, items, key, at) {
10874
+ const hit = cache.get(items);
10875
+ if (hit && hit.size === items.length) return hit.value;
10876
+ const value = /* @__PURE__ */ new Map();
10877
+ for (const item of items) {
10878
+ const when = at(item);
10879
+ if (!when) continue;
10880
+ const k = key(item);
10881
+ const had = value.get(k);
10882
+ if (!had || when > had) value.set(k, when);
10883
+ }
10884
+ cache.set(items, {
10885
+ size: items.length,
10886
+ value
10887
+ });
10888
+ return value;
10889
+ }
10890
+ /** Each task's newest comment, built once per comments list. */
10891
+ function latestComments(comments) {
10892
+ return latestBy(commentCache, comments, (c) => c.taskId, (c) => c.editedAt && c.editedAt > c.createdAt ? c.editedAt : c.createdAt);
10893
+ }
10894
+ function latestFiles(files) {
10895
+ return latestBy(fileCache, files, (a) => a.taskId, (a) => a.addedAt);
10896
+ }
10897
+ /** When anyone last did anything with this task (an ISO time). */
10898
+ function lastTouch(task, data) {
10899
+ let last = task.createdAt;
10900
+ const bump = (at) => {
10901
+ if (at && at > last) last = at;
10902
+ };
10903
+ bump(task.touchedAt);
10904
+ bump(task.assignedAt);
10905
+ bump(task.completedAt);
10906
+ bump(task.github?.[0]?.at);
10907
+ bump(latestComments(data.comments).get(task.id));
10908
+ bump(latestFiles(data.attachments ?? []).get(task.id));
10909
+ return last;
10910
+ }
10911
+ /** Whole days since the task was last touched, as of `today` (yyyy-MM-dd). */
10912
+ function daysUntouched(task, data, today) {
10913
+ const last = parseIso(lastTouch(task, data));
10914
+ if (Number.isNaN(last.getTime())) return 0;
10915
+ return Math.max(0, differenceInCalendarDays(parseIso(today), last));
10916
+ }
10917
+ function fogLevel(days) {
10918
+ if (days >= 35) return "thick";
10919
+ if (days >= 21) return "fog";
10920
+ return "clear";
10921
+ }
10922
+ /** "untouched 5 weeks", "untouched 3 weeks". */
10923
+ function untouchedText(days) {
10924
+ const weeks = Math.floor(days / 7);
10925
+ if (weeks >= 1) return `untouched ${weeks} week${weeks === 1 ? "" : "s"}`;
10926
+ return `untouched ${days} day${days === 1 ? "" : "s"}`;
10927
+ }
10928
+ /**
10929
+ * The open tasks in fog, longest untouched first. `projectIds` narrows it
10930
+ * to some projects; leave it out for the whole harbor.
10931
+ */
10932
+ function foggyTasks(data, today, projectIds) {
10933
+ const out = [];
10934
+ for (const task of data.tasks) {
10935
+ if (task.status === "done") continue;
10936
+ if (projectIds && !projectIds.has(task.projectId)) continue;
10937
+ const days = daysUntouched(task, data, today);
10938
+ const level = fogLevel(days);
10939
+ if (level !== "clear") out.push({
10940
+ task,
10941
+ days,
10942
+ level
10943
+ });
10944
+ }
10945
+ return out.sort((a, b) => b.days - a.days);
10946
+ }
10947
+ /** Whether opening this task should count as touching it: only when it is in fog. */
10948
+ function isFoggy(task, data, today) {
10949
+ return task.status !== "done" && daysUntouched(task, data, today) >= 21;
10950
+ }
10951
+ var FOG_DAYS, THICK_FOG_DAYS, commentCache, fileCache;
10952
+ var init_fog = __esmMin((() => {
10953
+ init_date_fns();
10954
+ init_date_text();
10955
+ FOG_DAYS = 21;
10956
+ THICK_FOG_DAYS = 35;
10957
+ commentCache = /* @__PURE__ */ new WeakMap();
10958
+ fileCache = /* @__PURE__ */ new WeakMap();
10959
+ }));
10960
+
10701
10961
  //#endregion
10702
10962
  //#region src/tui/views/project.tsx
10703
10963
  init_swatches();
10704
10964
  init_format();
10705
10965
  init_hill();
10706
10966
  init_clock();
10967
+ init_fog();
10707
10968
  /** One project: its board (columns side by side) or its list, and the keys to work it. */
10708
10969
  function ProjectView({ projectId, active, width, height, go, onBack }) {
10709
10970
  const { store, today } = useDeps();
@@ -10714,6 +10975,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
10714
10975
  const reflections = useHarbor((s) => s.reflections);
10715
10976
  const milestones = useHarbor((s) => s.milestones);
10716
10977
  const raidItems = useHarbor((s) => s.raidItems);
10978
+ const comments = useHarbor((s) => s.comments);
10979
+ const attachments = useHarbor((s) => s.attachments);
10717
10980
  const project = projects.find((p) => p.id === projectId);
10718
10981
  const data = useMemo(() => ({
10719
10982
  tasks,
@@ -10748,6 +11011,14 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
10748
11011
  const [rowByCol, setRowByCol] = useState({});
10749
11012
  const [listAt, setListAt] = useState(0);
10750
11013
  const day = today();
11014
+ const fogWeeks = (t) => {
11015
+ if (t.status === "done") return null;
11016
+ const days = daysUntouched(t, {
11017
+ comments,
11018
+ attachments
11019
+ }, day);
11020
+ return days >= 21 ? Math.floor(days / 7) : null;
11021
+ };
10751
11022
  const column = columns[Math.min(col, columns.length - 1)];
10752
11023
  const row = Math.min(rowByCol[col] ?? 0, Math.max(0, (column?.tasks.length ?? 1) - 1));
10753
11024
  const selected = mode === "board" ? column?.tasks[row] : list[Math.min(listAt, list.length - 1)];
@@ -11042,7 +11313,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
11042
11313
  late: isLate(t, day),
11043
11314
  waits: blocked(data, t),
11044
11315
  priority: t.priority,
11045
- selected: on && view.start + i === r
11316
+ selected: on && view.start + i === r,
11317
+ fog: fogWeeks(t)
11046
11318
  }, t.id)),
11047
11319
  view.start + view.items.length < c.tasks.length ? /* @__PURE__ */ jsxs(Text, {
11048
11320
  color: theme.muted,
@@ -11083,7 +11355,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
11083
11355
  });
11084
11356
  }
11085
11357
  /** One card on the board: its key, due date and owner, then the title on up to two lines. */
11086
- function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
11358
+ function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog = null }) {
11087
11359
  return /* @__PURE__ */ jsxs(Box, {
11088
11360
  flexDirection: "column",
11089
11361
  height: 3,
@@ -11109,6 +11381,14 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
11109
11381
  owner ? /* @__PURE__ */ jsxs(Text, {
11110
11382
  color: selected ? void 0 : theme.muted,
11111
11383
  children: [" ", owner]
11384
+ }) : null,
11385
+ fog != null ? /* @__PURE__ */ jsxs(Text, {
11386
+ color: selected ? void 0 : theme.muted,
11387
+ children: [
11388
+ " ░ ",
11389
+ fog,
11390
+ "w"
11391
+ ]
11112
11392
  }) : null
11113
11393
  ]
11114
11394
  }), /* @__PURE__ */ jsx(Box, {
@@ -11116,7 +11396,8 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
11116
11396
  children: /* @__PURE__ */ jsx(Text, {
11117
11397
  wrap: "wrap",
11118
11398
  bold: selected,
11119
- color: selected ? theme.selected : void 0,
11399
+ dimColor: fog != null && !selected,
11400
+ color: selected ? theme.selected : fog != null ? theme.muted : void 0,
11120
11401
  children: title
11121
11402
  })
11122
11403
  })]
@@ -12615,6 +12896,7 @@ function App({ ui }) {
12615
12896
  };
12616
12897
  const go = {
12617
12898
  openTask: (id) => {
12899
+ store.getState().selectTask(id);
12618
12900
  setPanel({
12619
12901
  kind: "task",
12620
12902
  id
@@ -13268,16 +13550,6 @@ function fieldChange(prev, row) {
13268
13550
  was
13269
13551
  };
13270
13552
  }
13271
- /**
13272
- * Apply changes to the rows stored now. A set writes only its own fields,
13273
- * so it keeps whatever someone else changed in the others. `clashIds` names
13274
- * rows where someone else had already changed one of the same fields or had
13275
- * removed the row (the change is dropped); `clashes` says which field and
13276
- * what each side had. By default this change wins a clashed field, as the
13277
- * later save; with `keepTheirs` the stored value stays (undo uses this, so
13278
- * it never takes back someone else's newer edit). Rows nothing touched keep
13279
- * their object.
13280
- */
13281
13553
  function applyRowChanges(rows, changes, options = {}) {
13282
13554
  if (changes.length === 0) return {
13283
13555
  rows: rows.slice(),
@@ -13309,6 +13581,11 @@ function applyRowChanges(rows, changes, options = {}) {
13309
13581
  const fields = {};
13310
13582
  let clashed = false;
13311
13583
  for (const [key, value] of Object.entries(change.fields)) {
13584
+ if (LATEST_WINS.has(key)) {
13585
+ const had = stored[key];
13586
+ if (typeof had !== "string" || typeof value === "string" && value > had) fields[key] = value;
13587
+ continue;
13588
+ }
13312
13589
  if (!deepEqual(stored[key], was[key]) && !deepEqual(stored[key], value)) {
13313
13590
  clashed = true;
13314
13591
  clashes.push({
@@ -13339,8 +13616,10 @@ function applyRowChanges(rows, changes, options = {}) {
13339
13616
  clashes
13340
13617
  };
13341
13618
  }
13619
+ var LATEST_WINS;
13342
13620
  var init_row_changes = __esmMin((() => {
13343
13621
  init_deep_equal();
13622
+ LATEST_WINS = /* @__PURE__ */ new Set(["touchedAt"]);
13344
13623
  }));
13345
13624
 
13346
13625
  //#endregion
@@ -13546,7 +13825,8 @@ function withStatus(task, status) {
13546
13825
  if (task.status === status) return task;
13547
13826
  const next = {
13548
13827
  ...task,
13549
- status
13828
+ status,
13829
+ touchedAt: stamp()
13550
13830
  };
13551
13831
  if (status === "done") next.completedAt = stamp();
13552
13832
  else if (task.status === "done") next.completedAt = null;
@@ -13659,6 +13939,14 @@ function sessionSlice({ set, get, write }) {
13659
13939
  selectTask: (id) => {
13660
13940
  if (get().selectedTaskId === id) return;
13661
13941
  set({ selectedTaskId: id });
13942
+ const task = id ? get().tasks.find((t) => t.id === id) : void 0;
13943
+ if (task && !readOnlyHarbor(get()) && isFoggy(task, get(), todayISO())) {
13944
+ const at = stamp();
13945
+ set({ tasks: get().tasks.map((t) => t.id === id ? {
13946
+ ...t,
13947
+ touchedAt: at
13948
+ } : t) });
13949
+ }
13662
13950
  },
13663
13951
  log: (text, href) => {
13664
13952
  if (readOnlyHarbor(get())) return;
@@ -13711,6 +13999,8 @@ var init_session = __esmMin((() => {
13711
13999
  init_shared();
13712
14000
  init_undo_state();
13713
14001
  init_view_only();
14002
+ init_fog();
14003
+ init_format();
13714
14004
  }));
13715
14005
 
13716
14006
  //#endregion
@@ -14486,7 +14776,8 @@ function tasksSlice({ set, get, write }) {
14486
14776
  if (row.id !== id) return row;
14487
14777
  const next = {
14488
14778
  ...row,
14489
- ...patch
14779
+ ...patch,
14780
+ touchedAt: stamp()
14490
14781
  };
14491
14782
  if (patch.status && patch.status !== row.status) {
14492
14783
  if (patch.status === "done" && !patch.completedAt) next.completedAt = stamp();
@@ -14637,6 +14928,7 @@ function tasksSlice({ set, get, write }) {
14637
14928
  if (!name) return;
14638
14929
  set({ tasks: get().tasks.map((t) => t.id === taskId ? {
14639
14930
  ...t,
14931
+ touchedAt: stamp(),
14640
14932
  checklist: [...t.checklist ?? [], {
14641
14933
  id: uid("ch"),
14642
14934
  title: name,
@@ -14647,6 +14939,7 @@ function tasksSlice({ set, get, write }) {
14647
14939
  toggleChecklistItem: (taskId, itemId) => {
14648
14940
  set({ tasks: get().tasks.map((t) => t.id === taskId ? {
14649
14941
  ...t,
14942
+ touchedAt: stamp(),
14650
14943
  checklist: (t.checklist ?? []).map((i) => i.id === itemId ? {
14651
14944
  ...i,
14652
14945
  done: !i.done
@@ -14656,6 +14949,7 @@ function tasksSlice({ set, get, write }) {
14656
14949
  removeChecklistItem: (taskId, itemId) => {
14657
14950
  set({ tasks: get().tasks.map((t) => t.id === taskId ? {
14658
14951
  ...t,
14952
+ touchedAt: stamp(),
14659
14953
  checklist: (t.checklist ?? []).filter((i) => i.id !== itemId)
14660
14954
  } : t) });
14661
14955
  },
@@ -14680,6 +14974,7 @@ function tasksSlice({ set, get, write }) {
14680
14974
  setTaskCustomFieldValue: (taskId, fieldId, value) => {
14681
14975
  write({ tasks: get().tasks.map((t) => t.id === taskId ? {
14682
14976
  ...t,
14977
+ touchedAt: stamp(),
14683
14978
  customFieldValues: {
14684
14979
  ...t.customFieldValues ?? {},
14685
14980
  [fieldId]: value
@@ -14711,6 +15006,17 @@ function tasksSlice({ set, get, write }) {
14711
15006
  write({ tasks: get().tasks.map((t) => tick.has(t.id) && t.status !== "done" ? withStatus(t, "done") : t) });
14712
15007
  for (const t of ticked) get().log(`ticked off “${t.title}” on First voyage`, `/projects/${t.projectId}`);
14713
15008
  },
15009
+ keepTasks: (ids) => {
15010
+ const keep = new Set(ids);
15011
+ const kept = get().tasks.filter((t) => keep.has(t.id));
15012
+ if (kept.length === 0) return;
15013
+ const at = stamp();
15014
+ write({ tasks: get().tasks.map((t) => keep.has(t.id) ? {
15015
+ ...t,
15016
+ touchedAt: at
15017
+ } : t) });
15018
+ get().log(kept.length === 1 ? `kept “${kept[0].title}” out of the fog` : `kept ${kept.length} tasks out of the fog`, kept.length === 1 ? `/projects/${kept[0].projectId}` : void 0);
15019
+ },
14714
15020
  toggleFocus: (taskId) => {
14715
15021
  const personId = get().currentUserId;
14716
15022
  const date = todayISO();
@@ -14722,8 +15028,83 @@ function tasksSlice({ set, get, write }) {
14722
15028
  if (ids.length >= 3) return "full";
14723
15029
  set({ ropes: upsertFocus(get().ropes, personId, date, [...ids, taskId]) });
14724
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
+ });
14725
15100
  }
14726
15101
  };
15102
+ function setSounding(taskId, sounding) {
15103
+ set({ tasks: get().tasks.map((t) => t.id === taskId ? {
15104
+ ...t,
15105
+ sounding
15106
+ } : t) });
15107
+ }
14727
15108
  }
14728
15109
  var init_tasks = __esmMin((() => {
14729
15110
  init_github_refs();
@@ -14731,6 +15112,8 @@ var init_tasks = __esmMin((() => {
14731
15112
  init_automations();
14732
15113
  init_focus();
14733
15114
  init_format();
15115
+ init_soundings();
15116
+ init_clock();
14734
15117
  init_utils();
14735
15118
  init_blockers();
14736
15119
  init_work();
@@ -16501,356 +16884,149 @@ async function pickHarbor(deps) {
16501
16884
  }
16502
16885
 
16503
16886
  //#endregion
16504
- //#region src/repo/plan-files.ts
16505
- /** "RR-42.md" → "RR-42"; a file named anything else is a new task. */
16506
- function keyOfFile(name) {
16507
- return KEY_NAME.exec(name)?.[1] ?? null;
16508
- }
16509
- /** A front-matter value written so it reads back the same. */
16510
- function scalar(value) {
16511
- return value === value.trim() && value !== "" && !/^["'[\]{}>|*&!%@#`,?:-]/.test(value) && !/: |\s#/.test(value) && !/^(null|~|true|false|yes|no|-?\d[\d.]*)$/i.test(value) ? value : JSON.stringify(value);
16512
- }
16513
- function unquote(raw) {
16514
- const v = raw.trim();
16515
- if (v.startsWith("\"") && v.endsWith("\"") && v.length > 1) try {
16516
- return String(JSON.parse(v));
16517
- } catch {
16518
- return v.slice(1, -1);
16887
+ //#region src/lib/command-line.ts
16888
+ init_date_fns();
16889
+ init_format();
16890
+ const PAGES = {
16891
+ today: "/today",
16892
+ inbox: "/inbox",
16893
+ brief: "/brief",
16894
+ review: "/review",
16895
+ portfolios: "/portfolios",
16896
+ programs: "/programs",
16897
+ projects: "/projects",
16898
+ risks: "/risks",
16899
+ calendar: "/calendar",
16900
+ people: "/people"
16901
+ };
16902
+ const HELP = [
16903
+ { text: "add \"TITLE\" [#project] [@person] [--due fri|+3|2026-11-02]" },
16904
+ { text: "mine what's on you, late first" },
16905
+ { text: "tasks [words] [--late] [#project] [@person]" },
16906
+ { text: "show RR-12 open a task" },
16907
+ { text: "start | review | done RR-12" },
16908
+ { text: "comment RR-12 \"TEXT\"" },
16909
+ { text: "checkin \"WHAT YOU'RE ON\" [--on-blocker]" },
16910
+ { text: `go ${Object.keys(PAGES).join("|")}` },
16911
+ {
16912
+ text: "undo · clear · help",
16913
+ tone: "muted"
16519
16914
  }
16520
- if (v.startsWith("'") && v.endsWith("'") && v.length > 1) return v.slice(1, -1).replace(/''/g, "'");
16521
- return v;
16522
- }
16523
- function list$1(raw) {
16524
- const v = raw.trim();
16525
- return (v.startsWith("[") && v.endsWith("]") ? v.slice(1, -1) : v).split(",").map((x) => unquote(x)).filter(Boolean);
16526
- }
16527
- /** A task's file, as `harbor sync` writes it. */
16528
- function renderTask(f) {
16529
- const lines = [
16530
- "---",
16531
- `title: ${scalar(f.title)}`,
16532
- `status: ${f.status}`,
16533
- `owner: ${f.owner ? scalar(f.owner) : ""}`.trimEnd(),
16534
- `due: ${f.due ?? ""}`.trimEnd(),
16535
- `priority: ${f.priority}`
16536
- ];
16537
- if (f.waits.length) lines.push(`waits: [${f.waits.join(", ")}]`);
16538
- if (f.repeat) lines.push(`repeat: ${f.repeat}`);
16539
- lines.push("---");
16540
- const notes = f.notes.trim();
16541
- return `${lines.join("\n")}\n${notes ? `${notes}\n` : ""}`;
16915
+ ];
16916
+ var CommandError = class extends Error {};
16917
+ const WEEKDAYS = [
16918
+ "sun",
16919
+ "mon",
16920
+ "tue",
16921
+ "wed",
16922
+ "thu",
16923
+ "fri",
16924
+ "sat"
16925
+ ];
16926
+ /** today, tomorrow, +N, a weekday (the next one, never today) or YYYY-MM-DD. */
16927
+ function dueFrom(value, now) {
16928
+ const v = value.trim().toLowerCase();
16929
+ if (v === "today") return todayISO(now);
16930
+ if (v === "tomorrow") return todayISO(addDays(now, 1));
16931
+ if (/^\+\d{1,3}$/.test(v)) return todayISO(addDays(now, Number(v.slice(1))));
16932
+ const day = WEEKDAYS.findIndex((d) => v.startsWith(d) && d.length >= 3 && v.length >= 3);
16933
+ if (day >= 0) {
16934
+ const ahead = (day - now.getDay() + 7) % 7 || 7;
16935
+ return todayISO(addDays(now, ahead));
16936
+ }
16937
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(parseDate$1(v).getTime())) return v;
16938
+ throw new CommandError(`Can't read the date "${value}". Try today, fri, +3 or 2026-11-02.`);
16542
16939
  }
16940
+
16941
+ //#endregion
16942
+ //#region src/tui/fog-sweep.ts
16543
16943
  /**
16544
- * A task's file read back. Missing fields take their empty value (no owner,
16545
- * no due date, medium priority), so deleting a line clears it. Anything it
16546
- * can't read is named in `problems`, and a file without a title isn't a task.
16944
+ * `harbor fog` in a terminal: the tasks nobody has touched for three weeks
16945
+ * (lib/fog.ts), one at a time, on the store's own actions. k keeps it
16946
+ * (clears the fog), d asks for a new date, x drops it, s skips, q stops.
16947
+ * The CLI saves through the sync loop afterwards.
16547
16948
  */
16548
- function parseTask(text) {
16549
- const problems = [];
16550
- const src = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
16551
- const fm = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/.exec(src);
16552
- if (!fm) return {
16553
- fields: null,
16554
- problems: ["it doesn't start with a --- block"]
16555
- };
16556
- const values = /* @__PURE__ */ new Map();
16557
- const lists = /* @__PURE__ */ new Map();
16558
- let open = null;
16559
- for (const line of fm[1].split("\n")) {
16560
- if (!line.trim() || line.trim().startsWith("#")) continue;
16561
- const item = /^\s+-\s*(.*)$/.exec(line);
16562
- if (item && open) {
16563
- lists.get(open).push(unquote(item[1]));
16564
- continue;
16565
- }
16566
- const kv = /^([A-Za-z][\w-]*)\s*:\s?(.*)$/.exec(line);
16567
- if (!kv) {
16568
- problems.push(`couldn't read "${line.trim()}"`);
16569
- continue;
16949
+ init_fog();
16950
+ const plural$2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
16951
+ async function sweepFog(opts) {
16952
+ const { store, ask, say, today } = opts;
16953
+ const start = store.getState();
16954
+ const queue = foggyTasks(start, today, opts.projectId ? /* @__PURE__ */ new Set([opts.projectId]) : void 0);
16955
+ const tally = {
16956
+ foggy: queue.length,
16957
+ kept: 0,
16958
+ dated: 0,
16959
+ dropped: 0,
16960
+ skipped: 0
16961
+ };
16962
+ if (queue.length === 0) {
16963
+ say("No fog: every open task was touched in the last three weeks.");
16964
+ return tally;
16965
+ }
16966
+ say(`Clear the fog · ${plural$2(queue.length, "task")} nobody has opened, moved or commented on for three weeks.`);
16967
+ say("k still needed d new date x drop it s skip q stop\n");
16968
+ let stopped = false;
16969
+ for (let i = 0; i < queue.length && !stopped; i++) {
16970
+ const { task, days } = queue[i];
16971
+ const s = store.getState();
16972
+ if (!s.tasks.some((t) => t.id === task.id)) continue;
16973
+ const project = s.projects.find((p) => p.id === task.projectId);
16974
+ const where = [
16975
+ keyOf$1(s, task),
16976
+ opts.projectId ? null : project?.name,
16977
+ untouchedText(days),
16978
+ task.dueDate ? `due ${shortDay(task.dueDate, today)}` : null
16979
+ ].filter(Boolean).join(" · ");
16980
+ say(`${i + 1} of ${queue.length} ${task.title}`);
16981
+ say(` ${where}`);
16982
+ for (;;) {
16983
+ const key = (await ask(" k, d, x, s or q: ")).trim().toLowerCase();
16984
+ if (key === "k") {
16985
+ store.getState().keepTasks([task.id]);
16986
+ tally.kept++;
16987
+ say(" Kept. The fog's off it.\n");
16988
+ } else if (key === "d") {
16989
+ let due = null;
16990
+ while (!due) {
16991
+ const typed = (await ask(" New due date (today, fri, +7, YYYY-MM-DD): ")).trim();
16992
+ if (!typed) break;
16993
+ try {
16994
+ due = dueFrom(typed, opts.now);
16995
+ } catch (err) {
16996
+ say(` ${err.message}`);
16997
+ }
16998
+ }
16999
+ if (!due) continue;
17000
+ store.getState().updateTask(task.id, { dueDate: due });
17001
+ tally.dated++;
17002
+ say(` Due ${shortDay(due, today)} now.\n`);
17003
+ } else if (key === "x") {
17004
+ store.getState().deleteTask(task.id);
17005
+ tally.dropped++;
17006
+ say(" Dropped.\n");
17007
+ } else if (key === "s" || key === "") {
17008
+ tally.skipped++;
17009
+ say(" Left for next time.\n");
17010
+ } else if (key === "q") stopped = true;
17011
+ else continue;
17012
+ break;
16570
17013
  }
16571
- const key = kv[1].toLowerCase();
16572
- values.set(key, kv[2]);
16573
- open = kv[2].trim() === "" ? key : null;
16574
- if (open) lists.set(open, []);
16575
- }
16576
- const value = (k) => {
16577
- const block = lists.get(k);
16578
- if (block && block.length) return block.join(", ");
16579
- return unquote(values.get(k) ?? "");
16580
- };
16581
- const title = value("title");
16582
- if (!title) return {
16583
- fields: null,
16584
- problems: [...problems, "it has no title"]
16585
- };
16586
- const statusText = value("status").toLowerCase();
16587
- const status = statusText ? STATUS_WORDS[statusText] : "todo";
16588
- if (!status) problems.push(`status "${statusText}" isn't one of backlog, todo, doing, review, done`);
16589
- const priorityText = value("priority").toLowerCase();
16590
- const priority = priorityText ? PRIORITIES.find((p) => p === priorityText) : "medium";
16591
- if (!priority) problems.push(`priority "${priorityText}" isn't low, medium, high or urgent`);
16592
- const due = value("due") || null;
16593
- const dueOk = !due || /^\d{4}-\d{2}-\d{2}$/.test(due) && !Number.isNaN(Date.parse(due));
16594
- if (!dueOk) problems.push(`due "${due}" isn't a date like 2026-11-07`);
16595
- const repeatText = value("repeat").toLowerCase();
16596
- const repeat = !repeatText || repeatText === "never" ? null : REPEATS.find((r) => r === repeatText) ?? null;
16597
- if (repeatText && repeatText !== "never" && !repeat) problems.push(`repeat "${repeatText}" isn't weekly, biweekly or monthly`);
16598
- const waits = (lists.get("waits")?.length ? lists.get("waits") : list$1(values.get("waits") ?? "")).map((k) => k.toUpperCase());
16599
- return {
16600
- fields: {
16601
- title,
16602
- status: status ?? "todo",
16603
- owner: value("owner") || null,
16604
- due: dueOk ? due : null,
16605
- priority: priority ?? "medium",
16606
- waits,
16607
- repeat,
16608
- notes: fm[2].trim()
16609
- },
16610
- problems
16611
- };
16612
- }
16613
- /** The key a task goes by in this harbor, like RR-42. */
16614
- function keyOf(data, task) {
16615
- return taskKey(task, data.projects.find((p) => p.id === task.projectId));
16616
- }
16617
- /** What a task's file says, read off the harbor. */
16618
- function fieldsOf(data, task) {
16619
- const people = byId(data.people);
16620
- const tasks = byId(data.tasks);
16621
- const waitIds = task.blockedByIds ?? (task.blockedById ? [task.blockedById] : []);
16622
- return {
16623
- title: task.title,
16624
- status: task.status,
16625
- owner: task.assigneeId ? people.get(task.assigneeId)?.name ?? null : null,
16626
- due: task.dueDate,
16627
- priority: task.priority,
16628
- waits: waitIds.map((id) => tasks.get(id)).map((t) => t ? keyOf(data, t) : null).filter((k) => Boolean(k)),
16629
- repeat: task.repeat ?? null,
16630
- notes: task.description.trim()
16631
- };
16632
- }
16633
- /** The person a file's owner names: their whole name, else a first name or initials only they have. */
16634
- function personNamed$1(people, name) {
16635
- const want = name.trim().toLowerCase();
16636
- if (!want) return null;
16637
- const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
16638
- if (exact.length === 1) return exact[0];
16639
- const first = people.filter((p) => p.name.trim().toLowerCase().split(/\s+/)[0] === want);
16640
- if (first.length === 1) return first[0];
16641
- const initials = people.filter((p) => p.initials.toLowerCase() === want);
16642
- return initials.length === 1 ? initials[0] : null;
16643
- }
16644
- /**
16645
- * The folder's changes to one task, worked out against the base: a field
16646
- * the file changed and the harbor didn't goes to the harbor; one both
16647
- * changed differently stays as the harbor has it, and is named.
16648
- */
16649
- function mergeTask(base, file, harbor) {
16650
- const patch = {};
16651
- const clashes = [];
16652
- for (const k of FIELDS) {
16653
- if (same(file[k], base[k]) || same(file[k], harbor[k])) continue;
16654
- if (same(harbor[k], base[k])) patch[k] = file[k];
16655
- else clashes.push({
16656
- field: k,
16657
- file: shown(file[k]),
16658
- harbor: shown(harbor[k])
16659
- });
16660
- }
16661
- return {
16662
- patch,
16663
- clashes
16664
- };
16665
- }
16666
- /** What a set of file fields means as a change to a task in this harbor. */
16667
- function taskPatch(data, fields, say, label) {
16668
- const patch = {};
16669
- if (fields.title !== void 0) patch.title = fields.title;
16670
- if (fields.status !== void 0) patch.status = fields.status;
16671
- if (fields.due !== void 0) patch.dueDate = fields.due;
16672
- if (fields.priority !== void 0) patch.priority = fields.priority;
16673
- if (fields.repeat !== void 0) patch.repeat = fields.repeat;
16674
- if (fields.notes !== void 0) patch.description = fields.notes;
16675
- if (fields.owner !== void 0) {
16676
- const person = fields.owner ? personNamed$1(data.people, fields.owner) : null;
16677
- if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
16678
- patch.assigneeId = person?.id ?? null;
16679
- }
16680
- if (fields.waits !== void 0) {
16681
- const ids = [];
16682
- for (const key of fields.waits) {
16683
- const t = findByKey(data, key);
16684
- if (t) ids.push(t.id);
16685
- else say(`${label}: there's no ${key} to wait on, so that was left out.`);
16686
- }
16687
- patch.blockedByIds = ids;
16688
- patch.blockedById = ids[0] ?? null;
16689
- }
16690
- return patch;
16691
- }
16692
- /** The base: what each of a project's tasks said when the folder last matched the harbor. */
16693
- function baseOf(data, projectId) {
16694
- const base = {};
16695
- for (const t of data.tasks) if (t.projectId === projectId) base[t.id] = fieldsOf(data, t);
16696
- return base;
16697
- }
16698
- /** A person a local plan adds for an owner it hasn't met. */
16699
- function localPerson(name, id) {
16700
- return {
16701
- id,
16702
- name,
16703
- role: "Crew",
16704
- initials: initialsOf(name),
16705
- swatch: "pine",
16706
- location: ""
16707
- };
16708
- }
16709
- /** `.harbor/README.md`: what the folder is, for whoever finds it in the repository. */
16710
- function readme(config) {
16711
- return `# Plan
16712
-
16713
- The tasks for this repository, one file per task in \`tasks/\`, kept by [Harbor](https://harborppm.com/ai). ${config.local ? "This plan lives only in this folder. Run `harbor` here to open it full screen; `harbor init PROJECT` later keeps it in step with a project on harborppm.com." : `It's kept in step with the ${config.project.name} project on harborppm.com: run \`harbor sync\` after editing, or after pulling, to bring both up to date.`}
16714
-
16715
- Each file has its fields between the \`---\` lines and its notes below:
16716
-
16717
- \`\`\`md
16718
- ---
16719
- title: Move checkout to the new API
16720
- status: doing
16721
- owner: Sam Rivera
16722
- due: 2026-11-07
16723
- priority: high
16724
- waits: [${config.project.key}-2]
16725
- ---
16726
- Old endpoints go away on Nov 20.
16727
- \`\`\`
16728
-
16729
- - status: backlog, todo, doing, review or done
16730
- - priority: low, medium, high or urgent
16731
- - repeat (optional): weekly, biweekly or monthly
16732
- - A new file with any name is a new task; Harbor gives it a key and renames it.
16733
- - Removing a file doesn't remove the task. Mark it done instead.
16734
- `;
16735
- }
16736
- var FIELDS, TASKS_DIR, CONFIG_FILE, BASE_FILE, LOCAL_FILE, STATUS_WORDS, PRIORITIES, REPEATS, KEY_NAME, same, shown;
16737
- var init_plan_files = __esmMin((() => {
16738
- init_shared();
16739
- init_indexes();
16740
- init_task_keys();
16741
- FIELDS = [
16742
- "title",
16743
- "status",
16744
- "owner",
16745
- "due",
16746
- "priority",
16747
- "waits",
16748
- "repeat",
16749
- "notes"
16750
- ];
16751
- TASKS_DIR = "tasks";
16752
- CONFIG_FILE = "harbor.json";
16753
- BASE_FILE = "state/base.json";
16754
- LOCAL_FILE = "plan.json";
16755
- STATUS_WORDS = {
16756
- backlog: "backlog",
16757
- todo: "todo",
16758
- "to-do": "todo",
16759
- "to do": "todo",
16760
- doing: "doing",
16761
- "in-progress": "doing",
16762
- "in progress": "doing",
16763
- started: "doing",
16764
- review: "review",
16765
- "in-review": "review",
16766
- "in review": "review",
16767
- done: "done",
16768
- finished: "done"
16769
- };
16770
- PRIORITIES = [
16771
- "low",
16772
- "medium",
16773
- "high",
16774
- "urgent"
16775
- ];
16776
- REPEATS = [
16777
- "weekly",
16778
- "biweekly",
16779
- "monthly"
16780
- ];
16781
- KEY_NAME = /^([A-Z][A-Z0-9]{0,9}-\d{1,7})\.md$/;
16782
- same = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.join(",") === b.join(",") : a === b;
16783
- shown = (v) => Array.isArray(v) ? v.join(", ") || "nothing" : v === null || v === "" ? "nothing" : String(v);
16784
- }));
16785
-
16786
- //#endregion
16787
- //#region src/repo/folder.ts
16788
- /**
16789
- * The `.harbor/` folder on disk, and finding it: like git, `harbor sync`
16790
- * works from anywhere inside the repository.
16791
- */
16792
- function diskFolder(root) {
16793
- return {
16794
- async read(path) {
16795
- try {
16796
- return await readFile(join(root, path), "utf8");
16797
- } catch (error) {
16798
- if (error.code === "ENOENT") return null;
16799
- throw error;
16800
- }
16801
- },
16802
- async write(path, text) {
16803
- const file = join(root, path);
16804
- await mkdir(dirname(file), { recursive: true });
16805
- const temp = `${file}.${process.pid}.tmp`;
16806
- await writeFile(temp, text);
16807
- await rename(temp, file);
16808
- },
16809
- async remove(path) {
16810
- await rm(join(root, path), { force: true });
16811
- },
16812
- async list(dir) {
16813
- try {
16814
- return (await readdir(join(root, dir), { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name);
16815
- } catch (error) {
16816
- if (error.code === "ENOENT") return [];
16817
- throw error;
16818
- }
16819
- }
16820
- };
16821
- }
16822
- /** The nearest `.harbor/` with a plan in it, from `start` up; null when there's none. */
16823
- async function findPlan(start) {
16824
- let dir = resolve(start);
16825
- for (;;) {
16826
- const here = join(dir, PLAN_DIR);
16827
- try {
16828
- await readFile(join(here, CONFIG_FILE));
16829
- return here;
16830
- } catch {}
16831
- const up = dirname(dir);
16832
- if (up === dir) return null;
16833
- dir = up;
16834
17014
  }
17015
+ say(fogSummary(tally));
17016
+ return tally;
16835
17017
  }
16836
- /** Where a new plan goes: the repository's top (the folder holding .git), else here. */
16837
- async function planHome(start) {
16838
- let dir = resolve(start);
16839
- for (;;) {
16840
- try {
16841
- await access(join(dir, ".git"));
16842
- return dir;
16843
- } catch {}
16844
- const up = dirname(dir);
16845
- if (up === dir) return resolve(start);
16846
- dir = up;
16847
- }
17018
+ /** "2 still needed, 1 given a new date, 1 dropped." */
17019
+ function fogSummary(t) {
17020
+ const parts = [
17021
+ t.kept && `${t.kept} still needed`,
17022
+ t.dated && `${t.dated} given a new date`,
17023
+ t.dropped && `${t.dropped} dropped`,
17024
+ t.skipped && `${t.skipped} left for next time`
17025
+ ].filter(Boolean);
17026
+ const left = t.foggy - t.kept - t.dated - t.dropped - t.skipped;
17027
+ if (left > 0) parts.push(`${left} not looked at`);
17028
+ return parts.length ? `${parts.join(", ")}.` : "Nothing changed.";
16848
17029
  }
16849
- var PLAN_DIR;
16850
- var init_folder = __esmMin((() => {
16851
- init_plan_files();
16852
- PLAN_DIR = ".harbor";
16853
- }));
16854
17030
 
16855
17031
  //#endregion
16856
17032
  //#region src/tui/engine/memory-crew.ts
@@ -17188,14 +17364,297 @@ function memoryHarbor(start, opts) {
17188
17364
  }
17189
17365
  };
17190
17366
  }
17191
- var init_memory = __esmMin((() => {
17192
- init_changes();
17193
- init_live_changes();
17194
- init_row_changes();
17195
- init_task_keys();
17367
+ var init_memory = __esmMin((() => {
17368
+ init_changes();
17369
+ init_live_changes();
17370
+ init_row_changes();
17371
+ init_task_keys();
17372
+ init_indexes();
17373
+ init_memory_crew();
17374
+ init_transport();
17375
+ }));
17376
+
17377
+ //#endregion
17378
+ //#region src/repo/plan-files.ts
17379
+ /** "RR-42.md" → "RR-42"; a file named anything else is a new task. */
17380
+ function keyOfFile(name) {
17381
+ return KEY_NAME.exec(name)?.[1] ?? null;
17382
+ }
17383
+ /** A front-matter value written so it reads back the same. */
17384
+ function scalar(value) {
17385
+ return value === value.trim() && value !== "" && !/^["'[\]{}>|*&!%@#`,?:-]/.test(value) && !/: |\s#/.test(value) && !/^(null|~|true|false|yes|no|-?\d[\d.]*)$/i.test(value) ? value : JSON.stringify(value);
17386
+ }
17387
+ function unquote(raw) {
17388
+ const v = raw.trim();
17389
+ if (v.startsWith("\"") && v.endsWith("\"") && v.length > 1) try {
17390
+ return String(JSON.parse(v));
17391
+ } catch {
17392
+ return v.slice(1, -1);
17393
+ }
17394
+ if (v.startsWith("'") && v.endsWith("'") && v.length > 1) return v.slice(1, -1).replace(/''/g, "'");
17395
+ return v;
17396
+ }
17397
+ function list$1(raw) {
17398
+ const v = raw.trim();
17399
+ return (v.startsWith("[") && v.endsWith("]") ? v.slice(1, -1) : v).split(",").map((x) => unquote(x)).filter(Boolean);
17400
+ }
17401
+ /** A task's file, as `harbor sync` writes it. */
17402
+ function renderTask(f) {
17403
+ const lines = [
17404
+ "---",
17405
+ `title: ${scalar(f.title)}`,
17406
+ `status: ${f.status}`,
17407
+ `owner: ${f.owner ? scalar(f.owner) : ""}`.trimEnd(),
17408
+ `due: ${f.due ?? ""}`.trimEnd(),
17409
+ `priority: ${f.priority}`
17410
+ ];
17411
+ if (f.waits.length) lines.push(`waits: [${f.waits.join(", ")}]`);
17412
+ if (f.repeat) lines.push(`repeat: ${f.repeat}`);
17413
+ lines.push("---");
17414
+ const notes = f.notes.trim();
17415
+ return `${lines.join("\n")}\n${notes ? `${notes}\n` : ""}`;
17416
+ }
17417
+ /**
17418
+ * A task's file read back. Missing fields take their empty value (no owner,
17419
+ * no due date, medium priority), so deleting a line clears it. Anything it
17420
+ * can't read is named in `problems`, and a file without a title isn't a task.
17421
+ */
17422
+ function parseTask(text) {
17423
+ const problems = [];
17424
+ const src = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
17425
+ const fm = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/.exec(src);
17426
+ if (!fm) return {
17427
+ fields: null,
17428
+ problems: ["it doesn't start with a --- block"]
17429
+ };
17430
+ const values = /* @__PURE__ */ new Map();
17431
+ const lists = /* @__PURE__ */ new Map();
17432
+ let open = null;
17433
+ for (const line of fm[1].split("\n")) {
17434
+ if (!line.trim() || line.trim().startsWith("#")) continue;
17435
+ const item = /^\s+-\s*(.*)$/.exec(line);
17436
+ if (item && open) {
17437
+ lists.get(open).push(unquote(item[1]));
17438
+ continue;
17439
+ }
17440
+ const kv = /^([A-Za-z][\w-]*)\s*:\s?(.*)$/.exec(line);
17441
+ if (!kv) {
17442
+ problems.push(`couldn't read "${line.trim()}"`);
17443
+ continue;
17444
+ }
17445
+ const key = kv[1].toLowerCase();
17446
+ values.set(key, kv[2]);
17447
+ open = kv[2].trim() === "" ? key : null;
17448
+ if (open) lists.set(open, []);
17449
+ }
17450
+ const value = (k) => {
17451
+ const block = lists.get(k);
17452
+ if (block && block.length) return block.join(", ");
17453
+ return unquote(values.get(k) ?? "");
17454
+ };
17455
+ const title = value("title");
17456
+ if (!title) return {
17457
+ fields: null,
17458
+ problems: [...problems, "it has no title"]
17459
+ };
17460
+ const statusText = value("status").toLowerCase();
17461
+ const status = statusText ? STATUS_WORDS[statusText] : "todo";
17462
+ if (!status) problems.push(`status "${statusText}" isn't one of backlog, todo, doing, review, done`);
17463
+ const priorityText = value("priority").toLowerCase();
17464
+ const priority = priorityText ? PRIORITIES.find((p) => p === priorityText) : "medium";
17465
+ if (!priority) problems.push(`priority "${priorityText}" isn't low, medium, high or urgent`);
17466
+ const due = value("due") || null;
17467
+ const dueOk = !due || /^\d{4}-\d{2}-\d{2}$/.test(due) && !Number.isNaN(Date.parse(due));
17468
+ if (!dueOk) problems.push(`due "${due}" isn't a date like 2026-11-07`);
17469
+ const repeatText = value("repeat").toLowerCase();
17470
+ const repeat = !repeatText || repeatText === "never" ? null : REPEATS.find((r) => r === repeatText) ?? null;
17471
+ if (repeatText && repeatText !== "never" && !repeat) problems.push(`repeat "${repeatText}" isn't weekly, biweekly or monthly`);
17472
+ const waits = (lists.get("waits")?.length ? lists.get("waits") : list$1(values.get("waits") ?? "")).map((k) => k.toUpperCase());
17473
+ return {
17474
+ fields: {
17475
+ title,
17476
+ status: status ?? "todo",
17477
+ owner: value("owner") || null,
17478
+ due: dueOk ? due : null,
17479
+ priority: priority ?? "medium",
17480
+ waits,
17481
+ repeat,
17482
+ notes: fm[2].trim()
17483
+ },
17484
+ problems
17485
+ };
17486
+ }
17487
+ /** The key a task goes by in this harbor, like RR-42. */
17488
+ function keyOf(data, task) {
17489
+ return taskKey(task, data.projects.find((p) => p.id === task.projectId));
17490
+ }
17491
+ /** What a task's file says, read off the harbor. */
17492
+ function fieldsOf(data, task) {
17493
+ const people = byId(data.people);
17494
+ const tasks = byId(data.tasks);
17495
+ const waitIds = task.blockedByIds ?? (task.blockedById ? [task.blockedById] : []);
17496
+ return {
17497
+ title: task.title,
17498
+ status: task.status,
17499
+ owner: task.assigneeId ? people.get(task.assigneeId)?.name ?? null : null,
17500
+ due: task.dueDate,
17501
+ priority: task.priority,
17502
+ waits: waitIds.map((id) => tasks.get(id)).map((t) => t ? keyOf(data, t) : null).filter((k) => Boolean(k)),
17503
+ repeat: task.repeat ?? null,
17504
+ notes: task.description.trim()
17505
+ };
17506
+ }
17507
+ /** The person a file's owner names: their whole name, else a first name or initials only they have. */
17508
+ function personNamed$1(people, name) {
17509
+ const want = name.trim().toLowerCase();
17510
+ if (!want) return null;
17511
+ const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
17512
+ if (exact.length === 1) return exact[0];
17513
+ const first = people.filter((p) => p.name.trim().toLowerCase().split(/\s+/)[0] === want);
17514
+ if (first.length === 1) return first[0];
17515
+ const initials = people.filter((p) => p.initials.toLowerCase() === want);
17516
+ return initials.length === 1 ? initials[0] : null;
17517
+ }
17518
+ /**
17519
+ * The folder's changes to one task, worked out against the base: a field
17520
+ * the file changed and the harbor didn't goes to the harbor; one both
17521
+ * changed differently stays as the harbor has it, and is named.
17522
+ */
17523
+ function mergeTask(base, file, harbor) {
17524
+ const patch = {};
17525
+ const clashes = [];
17526
+ for (const k of FIELDS) {
17527
+ if (same(file[k], base[k]) || same(file[k], harbor[k])) continue;
17528
+ if (same(harbor[k], base[k])) patch[k] = file[k];
17529
+ else clashes.push({
17530
+ field: k,
17531
+ file: shown(file[k]),
17532
+ harbor: shown(harbor[k])
17533
+ });
17534
+ }
17535
+ return {
17536
+ patch,
17537
+ clashes
17538
+ };
17539
+ }
17540
+ /** What a set of file fields means as a change to a task in this harbor. */
17541
+ function taskPatch(data, fields, say, label) {
17542
+ const patch = {};
17543
+ if (fields.title !== void 0) patch.title = fields.title;
17544
+ if (fields.status !== void 0) patch.status = fields.status;
17545
+ if (fields.due !== void 0) patch.dueDate = fields.due;
17546
+ if (fields.priority !== void 0) patch.priority = fields.priority;
17547
+ if (fields.repeat !== void 0) patch.repeat = fields.repeat;
17548
+ if (fields.notes !== void 0) patch.description = fields.notes;
17549
+ if (fields.owner !== void 0) {
17550
+ const person = fields.owner ? personNamed$1(data.people, fields.owner) : null;
17551
+ if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
17552
+ patch.assigneeId = person?.id ?? null;
17553
+ }
17554
+ if (fields.waits !== void 0) {
17555
+ const ids = [];
17556
+ for (const key of fields.waits) {
17557
+ const t = findByKey(data, key);
17558
+ if (t) ids.push(t.id);
17559
+ else say(`${label}: there's no ${key} to wait on, so that was left out.`);
17560
+ }
17561
+ patch.blockedByIds = ids;
17562
+ patch.blockedById = ids[0] ?? null;
17563
+ }
17564
+ return patch;
17565
+ }
17566
+ /** The base: what each of a project's tasks said when the folder last matched the harbor. */
17567
+ function baseOf(data, projectId) {
17568
+ const base = {};
17569
+ for (const t of data.tasks) if (t.projectId === projectId) base[t.id] = fieldsOf(data, t);
17570
+ return base;
17571
+ }
17572
+ /** A person a local plan adds for an owner it hasn't met. */
17573
+ function localPerson(name, id) {
17574
+ return {
17575
+ id,
17576
+ name,
17577
+ role: "Crew",
17578
+ initials: initialsOf(name),
17579
+ swatch: "pine",
17580
+ location: ""
17581
+ };
17582
+ }
17583
+ /** `.harbor/README.md`: what the folder is, for whoever finds it in the repository. */
17584
+ function readme(config) {
17585
+ return `# Plan
17586
+
17587
+ The tasks for this repository, one file per task in \`tasks/\`, kept by [Harbor](https://harborppm.com/ai). ${config.local ? "This plan lives only in this folder. Run `harbor` here to open it full screen; `harbor init PROJECT` later keeps it in step with a project on harborppm.com." : `It's kept in step with the ${config.project.name} project on harborppm.com: run \`harbor sync\` after editing, or after pulling, to bring both up to date.`}
17588
+
17589
+ Each file has its fields between the \`---\` lines and its notes below:
17590
+
17591
+ \`\`\`md
17592
+ ---
17593
+ title: Move checkout to the new API
17594
+ status: doing
17595
+ owner: Sam Rivera
17596
+ due: 2026-11-07
17597
+ priority: high
17598
+ waits: [${config.project.key}-2]
17599
+ ---
17600
+ Old endpoints go away on Nov 20.
17601
+ \`\`\`
17602
+
17603
+ - status: backlog, todo, doing, review or done
17604
+ - priority: low, medium, high or urgent
17605
+ - repeat (optional): weekly, biweekly or monthly
17606
+ - A new file with any name is a new task; Harbor gives it a key and renames it.
17607
+ - Removing a file doesn't remove the task. Mark it done instead.
17608
+ `;
17609
+ }
17610
+ var FIELDS, TASKS_DIR, CONFIG_FILE, BASE_FILE, LOCAL_FILE, STATUS_WORDS, PRIORITIES, REPEATS, KEY_NAME, same, shown;
17611
+ var init_plan_files = __esmMin((() => {
17612
+ init_shared();
17196
17613
  init_indexes();
17197
- init_memory_crew();
17198
- init_transport();
17614
+ init_task_keys();
17615
+ FIELDS = [
17616
+ "title",
17617
+ "status",
17618
+ "owner",
17619
+ "due",
17620
+ "priority",
17621
+ "waits",
17622
+ "repeat",
17623
+ "notes"
17624
+ ];
17625
+ TASKS_DIR = "tasks";
17626
+ CONFIG_FILE = "harbor.json";
17627
+ BASE_FILE = "state/base.json";
17628
+ LOCAL_FILE = "plan.json";
17629
+ STATUS_WORDS = {
17630
+ backlog: "backlog",
17631
+ todo: "todo",
17632
+ "to-do": "todo",
17633
+ "to do": "todo",
17634
+ doing: "doing",
17635
+ "in-progress": "doing",
17636
+ "in progress": "doing",
17637
+ started: "doing",
17638
+ review: "review",
17639
+ "in-review": "review",
17640
+ "in review": "review",
17641
+ done: "done",
17642
+ finished: "done"
17643
+ };
17644
+ PRIORITIES = [
17645
+ "low",
17646
+ "medium",
17647
+ "high",
17648
+ "urgent"
17649
+ ];
17650
+ REPEATS = [
17651
+ "weekly",
17652
+ "biweekly",
17653
+ "monthly"
17654
+ ];
17655
+ KEY_NAME = /^([A-Z][A-Z0-9]{0,9}-\d{1,7})\.md$/;
17656
+ same = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.join(",") === b.join(",") : a === b;
17657
+ shown = (v) => Array.isArray(v) ? v.join(", ") || "nothing" : v === null || v === "" ? "nothing" : String(v);
17199
17658
  }));
17200
17659
 
17201
17660
  //#endregion
@@ -17715,6 +18174,75 @@ var init_repo_sync = __esmMin((() => {
17715
18174
  };
17716
18175
  }));
17717
18176
 
18177
+ //#endregion
18178
+ //#region src/repo/folder.ts
18179
+ /**
18180
+ * The `.harbor/` folder on disk, and finding it: like git, `harbor sync`
18181
+ * works from anywhere inside the repository.
18182
+ */
18183
+ function diskFolder(root) {
18184
+ return {
18185
+ async read(path) {
18186
+ try {
18187
+ return await readFile(join(root, path), "utf8");
18188
+ } catch (error) {
18189
+ if (error.code === "ENOENT") return null;
18190
+ throw error;
18191
+ }
18192
+ },
18193
+ async write(path, text) {
18194
+ const file = join(root, path);
18195
+ await mkdir(dirname(file), { recursive: true });
18196
+ const temp = `${file}.${process.pid}.tmp`;
18197
+ await writeFile(temp, text);
18198
+ await rename(temp, file);
18199
+ },
18200
+ async remove(path) {
18201
+ await rm(join(root, path), { force: true });
18202
+ },
18203
+ async list(dir) {
18204
+ try {
18205
+ return (await readdir(join(root, dir), { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name);
18206
+ } catch (error) {
18207
+ if (error.code === "ENOENT") return [];
18208
+ throw error;
18209
+ }
18210
+ }
18211
+ };
18212
+ }
18213
+ /** The nearest `.harbor/` with a plan in it, from `start` up; null when there's none. */
18214
+ async function findPlan(start) {
18215
+ let dir = resolve(start);
18216
+ for (;;) {
18217
+ const here = join(dir, PLAN_DIR);
18218
+ try {
18219
+ await readFile(join(here, CONFIG_FILE));
18220
+ return here;
18221
+ } catch {}
18222
+ const up = dirname(dir);
18223
+ if (up === dir) return null;
18224
+ dir = up;
18225
+ }
18226
+ }
18227
+ /** Where a new plan goes: the repository's top (the folder holding .git), else here. */
18228
+ async function planHome(start) {
18229
+ let dir = resolve(start);
18230
+ for (;;) {
18231
+ try {
18232
+ await access(join(dir, ".git"));
18233
+ return dir;
18234
+ } catch {}
18235
+ const up = dirname(dir);
18236
+ if (up === dir) return resolve(start);
18237
+ dir = up;
18238
+ }
18239
+ }
18240
+ var PLAN_DIR;
18241
+ var init_folder = __esmMin((() => {
18242
+ init_plan_files();
18243
+ PLAN_DIR = ".harbor";
18244
+ }));
18245
+
17718
18246
  //#endregion
17719
18247
  //#region src/repo/commands.ts
17720
18248
  /**
@@ -23442,7 +23970,9 @@ init_format();
23442
23970
  init_date_text();
23443
23971
  init_handover();
23444
23972
  init_work();
23973
+ init_fog();
23445
23974
  init_board();
23975
+ init_soundings();
23446
23976
  /**
23447
23977
  * Where the website opens one task or project, switching to its harbor
23448
23978
  * first (src/routes/open.tsx). Nothing without an origin.
@@ -23494,8 +24024,8 @@ const STATUSES = [
23494
24024
  * for an agent: dates for half, 85% and 95% of the runs, or why there's none.
23495
24025
  * Given the spend ledger, also what each week late costs (src/lib/slip-cost.ts).
23496
24026
  */
23497
- function finishForecast(project, tasks, now, spend) {
23498
- const tide = tideForecast(project, tasks, now);
24027
+ function finishForecast(project, tasks, now, spend, reflections) {
24028
+ const tide = tideForecast(project, tasks, now, { reflections });
23499
24029
  if (tide.kind !== "forecast") return { none: tideQuiet(tide) };
23500
24030
  const lastDue = dueAfterFinish(tide);
23501
24031
  const dueLater = lastDue ? { lastDue: {
@@ -23521,6 +24051,7 @@ function finishForecast(project, tasks, now, spend) {
23521
24051
  finishedPerWeek: tide.perWeek,
23522
24052
  basedOn: `${tide.finished} task${tide.finished === 1 ? "" : "s"} finished in the last ${tide.weeks} week${tide.weeks === 1 ? "" : "s"}`,
23523
24053
  ...tide.rough ? { rough: true } : {},
24054
+ ...tide.sounded ? { soundedTasks: tide.sounded } : {},
23524
24055
  ...tide.weeks < 2 ? { shortHistory: true } : {},
23525
24056
  ...dueLater,
23526
24057
  ...slip
@@ -23580,6 +24111,39 @@ function me(ctx) {
23580
24111
  }
23581
24112
  const nameOf = (people, id) => id && people.get(id)?.name || null;
23582
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
+ }
23583
24147
  const dayOf = (iso) => iso ? iso.slice(0, 10) : null;
23584
24148
  function overdue(t, today) {
23585
24149
  const due = dayOf(t.dueDate);
@@ -23837,7 +24401,7 @@ const HARBOR_TOOLS = [
23837
24401
  done: c.done,
23838
24402
  late: c.late
23839
24403
  },
23840
- 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),
23841
24405
  ...limitsOf(data, p.id),
23842
24406
  ...hillOf(p, today),
23843
24407
  ...publicRoadmap(ctx, p.id)
@@ -23908,7 +24472,7 @@ const HARBOR_TOOLS = [
23908
24472
  waitsOn,
23909
24473
  milestones,
23910
24474
  openRaid: raid,
23911
- finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id)),
24475
+ finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data.reflections),
23912
24476
  ...notesOf(ctx, p),
23913
24477
  ...hillOf(p, today),
23914
24478
  ...webLink(ctx, { project: p.id })
@@ -24016,6 +24580,7 @@ const HARBOR_TOOLS = [
24016
24580
  })),
24017
24581
  waitingOn: blockersOf(t, data.tasks).map(brief),
24018
24582
  waitedOnBy: waiters.map(brief),
24583
+ ...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
24019
24584
  comments,
24020
24585
  ...webLink(ctx, { task: t.id })
24021
24586
  };
@@ -24064,7 +24629,13 @@ const HARBOR_TOOLS = [
24064
24629
  late: open.filter((t) => overdue(t, today)).length,
24065
24630
  tasks: open.slice(0, input.limit).map((t) => taskLine(t, people, projects, data.tasks)),
24066
24631
  waitingOnYou: waitingOnYou.slice(0, 20),
24067
- 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
+ }))
24068
24639
  };
24069
24640
  }
24070
24641
  }),
@@ -24340,6 +24911,35 @@ const HARBOR_TOOLS = [
24340
24911
  };
24341
24912
  }
24342
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
+ }),
24343
24943
  tool({
24344
24944
  name: "shipping_forecast",
24345
24945
  title: "The harbor forecast",
@@ -24431,6 +25031,38 @@ const HARBOR_TOOLS = [
24431
25031
  };
24432
25032
  }
24433
25033
  }),
25034
+ tool({
25035
+ name: "fog",
25036
+ title: "Tasks in fog",
25037
+ description: "Open tasks nobody has opened, moved, edited or commented on for three weeks (thick fog after five), longest untouched first, with how long and when they're due. Walk the person through them one at a time: still needed (keep_task clears it), a new date (update_task), or drop it (only on the website or in the terminal; nothing here deletes)." + DATA_NOTE,
25038
+ input: object({
25039
+ harbor: harborArg,
25040
+ project: string().max(200).optional().describe("Project id, key or name, for one only."),
25041
+ limit: limitArg(100, 25),
25042
+ offset: offsetArg
25043
+ }),
25044
+ run: (ctx, input) => {
25045
+ const { data } = ctx;
25046
+ const project = input.project ? findOne(data.projects, input.project, "project") : null;
25047
+ const today = todayISO(ctx.now);
25048
+ const fog = foggyTasks(data, today, project ? /* @__PURE__ */ new Set([project.id]) : void 0);
25049
+ const people = byId(data.people);
25050
+ const projects = byId(data.projects);
25051
+ return {
25052
+ ...project ? { project: project.name } : {},
25053
+ inFog: fog.length,
25054
+ fogAfterDays: 21,
25055
+ tasks: fog.slice(input.offset, input.offset + input.limit).map((f) => ({
25056
+ ...taskLine(f.task, people, projects, data.tasks),
25057
+ untouchedDays: f.days,
25058
+ untouched: untouchedText(f.days),
25059
+ thick: f.level === "thick",
25060
+ lastTouched: dayOf(lastTouch(f.task, data))
25061
+ })),
25062
+ more: fog.length > input.offset + input.limit
25063
+ };
25064
+ }
25065
+ }),
24434
25066
  tool({
24435
25067
  name: "handover_plan",
24436
25068
  title: "What handing someone's work over would move",
@@ -24490,61 +25122,6 @@ const LIST_HARBORS = {
24490
25122
  input: object({})
24491
25123
  };
24492
25124
 
24493
- //#endregion
24494
- //#region src/lib/command-line.ts
24495
- init_date_fns();
24496
- init_format();
24497
- const PAGES = {
24498
- today: "/today",
24499
- inbox: "/inbox",
24500
- brief: "/brief",
24501
- review: "/review",
24502
- portfolios: "/portfolios",
24503
- programs: "/programs",
24504
- projects: "/projects",
24505
- risks: "/risks",
24506
- calendar: "/calendar",
24507
- people: "/people"
24508
- };
24509
- const HELP = [
24510
- { text: "add \"TITLE\" [#project] [@person] [--due fri|+3|2026-11-02]" },
24511
- { text: "mine what's on you, late first" },
24512
- { text: "tasks [words] [--late] [#project] [@person]" },
24513
- { text: "show RR-12 open a task" },
24514
- { text: "start | review | done RR-12" },
24515
- { text: "comment RR-12 \"TEXT\"" },
24516
- { text: "checkin \"WHAT YOU'RE ON\" [--on-blocker]" },
24517
- { text: `go ${Object.keys(PAGES).join("|")}` },
24518
- {
24519
- text: "undo · clear · help",
24520
- tone: "muted"
24521
- }
24522
- ];
24523
- var CommandError = class extends Error {};
24524
- const WEEKDAYS = [
24525
- "sun",
24526
- "mon",
24527
- "tue",
24528
- "wed",
24529
- "thu",
24530
- "fri",
24531
- "sat"
24532
- ];
24533
- /** today, tomorrow, +N, a weekday (the next one, never today) or YYYY-MM-DD. */
24534
- function dueFrom(value, now) {
24535
- const v = value.trim().toLowerCase();
24536
- if (v === "today") return todayISO(now);
24537
- if (v === "tomorrow") return todayISO(addDays(now, 1));
24538
- if (/^\+\d{1,3}$/.test(v)) return todayISO(addDays(now, Number(v.slice(1))));
24539
- const day = WEEKDAYS.findIndex((d) => v.startsWith(d) && d.length >= 3 && v.length >= 3);
24540
- if (day >= 0) {
24541
- const ahead = (day - now.getDay() + 7) % 7 || 7;
24542
- return todayISO(addDays(now, ahead));
24543
- }
24544
- if (/^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(parseDate$1(v).getTime())) return v;
24545
- throw new CommandError(`Can't read the date "${value}". Try today, fri, +3 or 2026-11-02.`);
24546
- }
24547
-
24548
25125
  //#endregion
24549
25126
  //#region src/lib/dry-dock.ts
24550
25127
  init_date_fns();
@@ -25088,6 +25665,8 @@ init_transport();
25088
25665
  init_errors();
25089
25666
  init_plain();
25090
25667
  init_search();
25668
+ init_view_only();
25669
+ init_repo_sync();
25091
25670
  const today = () => format(/* @__PURE__ */ new Date(), "yyyy-MM-dd");
25092
25671
  /** How long `harbor` shows its splash at the least, however fast the harbor loads. */
25093
25672
  const SPLASH_MS = 1200;
@@ -25221,8 +25800,58 @@ async function runApp(opts) {
25221
25800
  }, { alternateScreen: true });
25222
25801
  await instance.waitUntilExit();
25223
25802
  }
25803
+ /**
25804
+ * The harbor a one-off run works in, without asking: --harbor, else the one
25805
+ * `harbor use` saved, else the only one.
25806
+ */
25807
+ function harborFor(harbors, named, saved) {
25808
+ if (harbors.length === 0) throw new HarborError("You're not in a harbor yet. Run harbor to make one first.");
25809
+ if (named) return harborNamed(harbors, named);
25810
+ const kept = saved ? harbors.find((h) => h.id === saved) : void 0;
25811
+ if (kept) return kept;
25812
+ if (harbors.length === 1) return harbors[0];
25813
+ throw new HarborError(`You're in ${harbors.length} harbors (${harbors.map((h) => h.name).join(", ")}). Add --harbor NAME, or run harbor use NAME first.`);
25814
+ }
25815
+ /** `harbor fog` in a terminal: the sweep, one task at a time, then one save. */
25816
+ async function fogHere(opts) {
25817
+ const transport = plainTransport(httpTransport(opts.source, opts.fetcher));
25818
+ const answer = await transport.harbors();
25819
+ const harbor = harborFor(answer.harbors, opts.harbor, opts.saved);
25820
+ const say = (line) => process.stdout.write(`${plain(line)}\n`);
25821
+ const store = createAppStore();
25822
+ const sync = createSync({
25823
+ store,
25824
+ transport,
25825
+ account: {
25826
+ id: answer.account.id,
25827
+ name: answer.account.name || answer.account.email
25828
+ },
25829
+ live: false,
25830
+ saveDelayMs: 0,
25831
+ onNotice: say
25832
+ });
25833
+ await sync.open(harbor.id);
25834
+ const terminal = terminalAsk("Stopped. What you'd answered is saved.");
25835
+ try {
25836
+ if (readOnlyHarbor(store.getState())) throw new HarborError(store.getState().keepPaused ? PAUSED_NOTICE : `You can only view ${harbor.name}, so there's no fog to clear from here.`);
25837
+ const project = opts.project ? projectNamed(store.getState().projects, opts.project) : null;
25838
+ await sweepFog({
25839
+ store,
25840
+ ask: terminal.ask,
25841
+ say,
25842
+ today: today(),
25843
+ now: /* @__PURE__ */ new Date(),
25844
+ projectId: project?.id ?? null
25845
+ });
25846
+ } finally {
25847
+ terminal.close();
25848
+ await sync.flush();
25849
+ sync.close();
25850
+ }
25851
+ if (sync.state !== "saved") throw new HarborError("Harbor didn't take those answers. Run harbor fog again.");
25852
+ }
25224
25853
  /** Questions asked on the plain terminal, before the app takes it over. Ctrl+C stops. */
25225
- function terminalAsk() {
25854
+ function terminalAsk(stoppedText = "Stopped. Nothing was made.") {
25226
25855
  const rl = createInterface({
25227
25856
  input: process.stdin,
25228
25857
  output: process.stdout
@@ -25230,7 +25859,7 @@ function terminalAsk() {
25230
25859
  let stop = () => {};
25231
25860
  const stopped = new Promise((_, reject) => stop = reject);
25232
25861
  stopped.catch(() => {});
25233
- const nothing = () => stop(new HarborError("Stopped. Nothing was made."));
25862
+ const nothing = () => stop(new HarborError(stoppedText));
25234
25863
  rl.on("SIGINT", nothing);
25235
25864
  rl.on("close", nothing);
25236
25865
  return {
@@ -25262,4 +25891,4 @@ function planTransport(source, fetcher) {
25262
25891
  }
25263
25892
 
25264
25893
  //#endregion
25265
- export { SPLASH_MS, TransportError, firstHarbor, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere };
25894
+ export { SPLASH_MS, TransportError, firstHarbor, fogHere, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere };