@harborppm/cli 0.47.0 → 0.48.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
@@ -10698,12 +10698,103 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
10698
10698
  });
10699
10699
  }
10700
10700
 
10701
+ //#endregion
10702
+ //#region src/lib/fog.ts
10703
+ function latestBy(cache, items, key, at) {
10704
+ const hit = cache.get(items);
10705
+ if (hit && hit.size === items.length) return hit.value;
10706
+ const value = /* @__PURE__ */ new Map();
10707
+ for (const item of items) {
10708
+ const when = at(item);
10709
+ if (!when) continue;
10710
+ const k = key(item);
10711
+ const had = value.get(k);
10712
+ if (!had || when > had) value.set(k, when);
10713
+ }
10714
+ cache.set(items, {
10715
+ size: items.length,
10716
+ value
10717
+ });
10718
+ return value;
10719
+ }
10720
+ /** Each task's newest comment, built once per comments list. */
10721
+ function latestComments(comments) {
10722
+ return latestBy(commentCache, comments, (c) => c.taskId, (c) => c.editedAt && c.editedAt > c.createdAt ? c.editedAt : c.createdAt);
10723
+ }
10724
+ function latestFiles(files) {
10725
+ return latestBy(fileCache, files, (a) => a.taskId, (a) => a.addedAt);
10726
+ }
10727
+ /** When anyone last did anything with this task (an ISO time). */
10728
+ function lastTouch(task, data) {
10729
+ let last = task.createdAt;
10730
+ const bump = (at) => {
10731
+ if (at && at > last) last = at;
10732
+ };
10733
+ bump(task.touchedAt);
10734
+ bump(task.assignedAt);
10735
+ bump(task.completedAt);
10736
+ bump(task.github?.[0]?.at);
10737
+ bump(latestComments(data.comments).get(task.id));
10738
+ bump(latestFiles(data.attachments ?? []).get(task.id));
10739
+ return last;
10740
+ }
10741
+ /** Whole days since the task was last touched, as of `today` (yyyy-MM-dd). */
10742
+ function daysUntouched(task, data, today) {
10743
+ const last = parseIso(lastTouch(task, data));
10744
+ if (Number.isNaN(last.getTime())) return 0;
10745
+ return Math.max(0, differenceInCalendarDays(parseIso(today), last));
10746
+ }
10747
+ function fogLevel(days) {
10748
+ if (days >= 35) return "thick";
10749
+ if (days >= 21) return "fog";
10750
+ return "clear";
10751
+ }
10752
+ /** "untouched 5 weeks", "untouched 3 weeks". */
10753
+ function untouchedText(days) {
10754
+ const weeks = Math.floor(days / 7);
10755
+ if (weeks >= 1) return `untouched ${weeks} week${weeks === 1 ? "" : "s"}`;
10756
+ return `untouched ${days} day${days === 1 ? "" : "s"}`;
10757
+ }
10758
+ /**
10759
+ * The open tasks in fog, longest untouched first. `projectIds` narrows it
10760
+ * to some projects; leave it out for the whole harbor.
10761
+ */
10762
+ function foggyTasks(data, today, projectIds) {
10763
+ const out = [];
10764
+ for (const task of data.tasks) {
10765
+ if (task.status === "done") continue;
10766
+ if (projectIds && !projectIds.has(task.projectId)) continue;
10767
+ const days = daysUntouched(task, data, today);
10768
+ const level = fogLevel(days);
10769
+ if (level !== "clear") out.push({
10770
+ task,
10771
+ days,
10772
+ level
10773
+ });
10774
+ }
10775
+ return out.sort((a, b) => b.days - a.days);
10776
+ }
10777
+ /** Whether opening this task should count as touching it: only when it is in fog. */
10778
+ function isFoggy(task, data, today) {
10779
+ return task.status !== "done" && daysUntouched(task, data, today) >= 21;
10780
+ }
10781
+ var FOG_DAYS, THICK_FOG_DAYS, commentCache, fileCache;
10782
+ var init_fog = __esmMin((() => {
10783
+ init_date_fns();
10784
+ init_date_text();
10785
+ FOG_DAYS = 21;
10786
+ THICK_FOG_DAYS = 35;
10787
+ commentCache = /* @__PURE__ */ new WeakMap();
10788
+ fileCache = /* @__PURE__ */ new WeakMap();
10789
+ }));
10790
+
10701
10791
  //#endregion
10702
10792
  //#region src/tui/views/project.tsx
10703
10793
  init_swatches();
10704
10794
  init_format();
10705
10795
  init_hill();
10706
10796
  init_clock();
10797
+ init_fog();
10707
10798
  /** One project: its board (columns side by side) or its list, and the keys to work it. */
10708
10799
  function ProjectView({ projectId, active, width, height, go, onBack }) {
10709
10800
  const { store, today } = useDeps();
@@ -10714,6 +10805,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
10714
10805
  const reflections = useHarbor((s) => s.reflections);
10715
10806
  const milestones = useHarbor((s) => s.milestones);
10716
10807
  const raidItems = useHarbor((s) => s.raidItems);
10808
+ const comments = useHarbor((s) => s.comments);
10809
+ const attachments = useHarbor((s) => s.attachments);
10717
10810
  const project = projects.find((p) => p.id === projectId);
10718
10811
  const data = useMemo(() => ({
10719
10812
  tasks,
@@ -10748,6 +10841,14 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
10748
10841
  const [rowByCol, setRowByCol] = useState({});
10749
10842
  const [listAt, setListAt] = useState(0);
10750
10843
  const day = today();
10844
+ const fogWeeks = (t) => {
10845
+ if (t.status === "done") return null;
10846
+ const days = daysUntouched(t, {
10847
+ comments,
10848
+ attachments
10849
+ }, day);
10850
+ return days >= 21 ? Math.floor(days / 7) : null;
10851
+ };
10751
10852
  const column = columns[Math.min(col, columns.length - 1)];
10752
10853
  const row = Math.min(rowByCol[col] ?? 0, Math.max(0, (column?.tasks.length ?? 1) - 1));
10753
10854
  const selected = mode === "board" ? column?.tasks[row] : list[Math.min(listAt, list.length - 1)];
@@ -11042,7 +11143,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
11042
11143
  late: isLate(t, day),
11043
11144
  waits: blocked(data, t),
11044
11145
  priority: t.priority,
11045
- selected: on && view.start + i === r
11146
+ selected: on && view.start + i === r,
11147
+ fog: fogWeeks(t)
11046
11148
  }, t.id)),
11047
11149
  view.start + view.items.length < c.tasks.length ? /* @__PURE__ */ jsxs(Text, {
11048
11150
  color: theme.muted,
@@ -11083,7 +11185,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
11083
11185
  });
11084
11186
  }
11085
11187
  /** 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 }) {
11188
+ function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog = null }) {
11087
11189
  return /* @__PURE__ */ jsxs(Box, {
11088
11190
  flexDirection: "column",
11089
11191
  height: 3,
@@ -11109,6 +11211,14 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
11109
11211
  owner ? /* @__PURE__ */ jsxs(Text, {
11110
11212
  color: selected ? void 0 : theme.muted,
11111
11213
  children: [" ", owner]
11214
+ }) : null,
11215
+ fog != null ? /* @__PURE__ */ jsxs(Text, {
11216
+ color: selected ? void 0 : theme.muted,
11217
+ children: [
11218
+ " ░ ",
11219
+ fog,
11220
+ "w"
11221
+ ]
11112
11222
  }) : null
11113
11223
  ]
11114
11224
  }), /* @__PURE__ */ jsx(Box, {
@@ -11116,7 +11226,8 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
11116
11226
  children: /* @__PURE__ */ jsx(Text, {
11117
11227
  wrap: "wrap",
11118
11228
  bold: selected,
11119
- color: selected ? theme.selected : void 0,
11229
+ dimColor: fog != null && !selected,
11230
+ color: selected ? theme.selected : fog != null ? theme.muted : void 0,
11120
11231
  children: title
11121
11232
  })
11122
11233
  })]
@@ -12615,6 +12726,7 @@ function App({ ui }) {
12615
12726
  };
12616
12727
  const go = {
12617
12728
  openTask: (id) => {
12729
+ store.getState().selectTask(id);
12618
12730
  setPanel({
12619
12731
  kind: "task",
12620
12732
  id
@@ -13268,16 +13380,6 @@ function fieldChange(prev, row) {
13268
13380
  was
13269
13381
  };
13270
13382
  }
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
13383
  function applyRowChanges(rows, changes, options = {}) {
13282
13384
  if (changes.length === 0) return {
13283
13385
  rows: rows.slice(),
@@ -13309,6 +13411,11 @@ function applyRowChanges(rows, changes, options = {}) {
13309
13411
  const fields = {};
13310
13412
  let clashed = false;
13311
13413
  for (const [key, value] of Object.entries(change.fields)) {
13414
+ if (LATEST_WINS.has(key)) {
13415
+ const had = stored[key];
13416
+ if (typeof had !== "string" || typeof value === "string" && value > had) fields[key] = value;
13417
+ continue;
13418
+ }
13312
13419
  if (!deepEqual(stored[key], was[key]) && !deepEqual(stored[key], value)) {
13313
13420
  clashed = true;
13314
13421
  clashes.push({
@@ -13339,8 +13446,10 @@ function applyRowChanges(rows, changes, options = {}) {
13339
13446
  clashes
13340
13447
  };
13341
13448
  }
13449
+ var LATEST_WINS;
13342
13450
  var init_row_changes = __esmMin((() => {
13343
13451
  init_deep_equal();
13452
+ LATEST_WINS = /* @__PURE__ */ new Set(["touchedAt"]);
13344
13453
  }));
13345
13454
 
13346
13455
  //#endregion
@@ -13546,7 +13655,8 @@ function withStatus(task, status) {
13546
13655
  if (task.status === status) return task;
13547
13656
  const next = {
13548
13657
  ...task,
13549
- status
13658
+ status,
13659
+ touchedAt: stamp()
13550
13660
  };
13551
13661
  if (status === "done") next.completedAt = stamp();
13552
13662
  else if (task.status === "done") next.completedAt = null;
@@ -13659,6 +13769,14 @@ function sessionSlice({ set, get, write }) {
13659
13769
  selectTask: (id) => {
13660
13770
  if (get().selectedTaskId === id) return;
13661
13771
  set({ selectedTaskId: id });
13772
+ const task = id ? get().tasks.find((t) => t.id === id) : void 0;
13773
+ if (task && !readOnlyHarbor(get()) && isFoggy(task, get(), todayISO())) {
13774
+ const at = stamp();
13775
+ set({ tasks: get().tasks.map((t) => t.id === id ? {
13776
+ ...t,
13777
+ touchedAt: at
13778
+ } : t) });
13779
+ }
13662
13780
  },
13663
13781
  log: (text, href) => {
13664
13782
  if (readOnlyHarbor(get())) return;
@@ -13711,6 +13829,8 @@ var init_session = __esmMin((() => {
13711
13829
  init_shared();
13712
13830
  init_undo_state();
13713
13831
  init_view_only();
13832
+ init_fog();
13833
+ init_format();
13714
13834
  }));
13715
13835
 
13716
13836
  //#endregion
@@ -14486,7 +14606,8 @@ function tasksSlice({ set, get, write }) {
14486
14606
  if (row.id !== id) return row;
14487
14607
  const next = {
14488
14608
  ...row,
14489
- ...patch
14609
+ ...patch,
14610
+ touchedAt: stamp()
14490
14611
  };
14491
14612
  if (patch.status && patch.status !== row.status) {
14492
14613
  if (patch.status === "done" && !patch.completedAt) next.completedAt = stamp();
@@ -14637,6 +14758,7 @@ function tasksSlice({ set, get, write }) {
14637
14758
  if (!name) return;
14638
14759
  set({ tasks: get().tasks.map((t) => t.id === taskId ? {
14639
14760
  ...t,
14761
+ touchedAt: stamp(),
14640
14762
  checklist: [...t.checklist ?? [], {
14641
14763
  id: uid("ch"),
14642
14764
  title: name,
@@ -14647,6 +14769,7 @@ function tasksSlice({ set, get, write }) {
14647
14769
  toggleChecklistItem: (taskId, itemId) => {
14648
14770
  set({ tasks: get().tasks.map((t) => t.id === taskId ? {
14649
14771
  ...t,
14772
+ touchedAt: stamp(),
14650
14773
  checklist: (t.checklist ?? []).map((i) => i.id === itemId ? {
14651
14774
  ...i,
14652
14775
  done: !i.done
@@ -14656,6 +14779,7 @@ function tasksSlice({ set, get, write }) {
14656
14779
  removeChecklistItem: (taskId, itemId) => {
14657
14780
  set({ tasks: get().tasks.map((t) => t.id === taskId ? {
14658
14781
  ...t,
14782
+ touchedAt: stamp(),
14659
14783
  checklist: (t.checklist ?? []).filter((i) => i.id !== itemId)
14660
14784
  } : t) });
14661
14785
  },
@@ -14680,6 +14804,7 @@ function tasksSlice({ set, get, write }) {
14680
14804
  setTaskCustomFieldValue: (taskId, fieldId, value) => {
14681
14805
  write({ tasks: get().tasks.map((t) => t.id === taskId ? {
14682
14806
  ...t,
14807
+ touchedAt: stamp(),
14683
14808
  customFieldValues: {
14684
14809
  ...t.customFieldValues ?? {},
14685
14810
  [fieldId]: value
@@ -14711,6 +14836,17 @@ function tasksSlice({ set, get, write }) {
14711
14836
  write({ tasks: get().tasks.map((t) => tick.has(t.id) && t.status !== "done" ? withStatus(t, "done") : t) });
14712
14837
  for (const t of ticked) get().log(`ticked off “${t.title}” on First voyage`, `/projects/${t.projectId}`);
14713
14838
  },
14839
+ keepTasks: (ids) => {
14840
+ const keep = new Set(ids);
14841
+ const kept = get().tasks.filter((t) => keep.has(t.id));
14842
+ if (kept.length === 0) return;
14843
+ const at = stamp();
14844
+ write({ tasks: get().tasks.map((t) => keep.has(t.id) ? {
14845
+ ...t,
14846
+ touchedAt: at
14847
+ } : t) });
14848
+ 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);
14849
+ },
14714
14850
  toggleFocus: (taskId) => {
14715
14851
  const personId = get().currentUserId;
14716
14852
  const date = todayISO();
@@ -16501,408 +16637,201 @@ async function pickHarbor(deps) {
16501
16637
  }
16502
16638
 
16503
16639
  //#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);
16640
+ //#region src/lib/command-line.ts
16641
+ init_date_fns();
16642
+ init_format();
16643
+ const PAGES = {
16644
+ today: "/today",
16645
+ inbox: "/inbox",
16646
+ brief: "/brief",
16647
+ review: "/review",
16648
+ portfolios: "/portfolios",
16649
+ programs: "/programs",
16650
+ projects: "/projects",
16651
+ risks: "/risks",
16652
+ calendar: "/calendar",
16653
+ people: "/people"
16654
+ };
16655
+ const HELP = [
16656
+ { text: "add \"TITLE\" [#project] [@person] [--due fri|+3|2026-11-02]" },
16657
+ { text: "mine what's on you, late first" },
16658
+ { text: "tasks [words] [--late] [#project] [@person]" },
16659
+ { text: "show RR-12 open a task" },
16660
+ { text: "start | review | done RR-12" },
16661
+ { text: "comment RR-12 \"TEXT\"" },
16662
+ { text: "checkin \"WHAT YOU'RE ON\" [--on-blocker]" },
16663
+ { text: `go ${Object.keys(PAGES).join("|")}` },
16664
+ {
16665
+ text: "undo · clear · help",
16666
+ tone: "muted"
16519
16667
  }
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` : ""}`;
16668
+ ];
16669
+ var CommandError = class extends Error {};
16670
+ const WEEKDAYS = [
16671
+ "sun",
16672
+ "mon",
16673
+ "tue",
16674
+ "wed",
16675
+ "thu",
16676
+ "fri",
16677
+ "sat"
16678
+ ];
16679
+ /** today, tomorrow, +N, a weekday (the next one, never today) or YYYY-MM-DD. */
16680
+ function dueFrom(value, now) {
16681
+ const v = value.trim().toLowerCase();
16682
+ if (v === "today") return todayISO(now);
16683
+ if (v === "tomorrow") return todayISO(addDays(now, 1));
16684
+ if (/^\+\d{1,3}$/.test(v)) return todayISO(addDays(now, Number(v.slice(1))));
16685
+ const day = WEEKDAYS.findIndex((d) => v.startsWith(d) && d.length >= 3 && v.length >= 3);
16686
+ if (day >= 0) {
16687
+ const ahead = (day - now.getDay() + 7) % 7 || 7;
16688
+ return todayISO(addDays(now, ahead));
16689
+ }
16690
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(parseDate$1(v).getTime())) return v;
16691
+ throw new CommandError(`Can't read the date "${value}". Try today, fri, +3 or 2026-11-02.`);
16542
16692
  }
16693
+
16694
+ //#endregion
16695
+ //#region src/tui/fog-sweep.ts
16543
16696
  /**
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.
16697
+ * `harbor fog` in a terminal: the tasks nobody has touched for three weeks
16698
+ * (lib/fog.ts), one at a time, on the store's own actions. k keeps it
16699
+ * (clears the fog), d asks for a new date, x drops it, s skips, q stops.
16700
+ * The CLI saves through the sync loop afterwards.
16547
16701
  */
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;
16702
+ init_fog();
16703
+ const plural$2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
16704
+ async function sweepFog(opts) {
16705
+ const { store, ask, say, today } = opts;
16706
+ const start = store.getState();
16707
+ const queue = foggyTasks(start, today, opts.projectId ? /* @__PURE__ */ new Set([opts.projectId]) : void 0);
16708
+ const tally = {
16709
+ foggy: queue.length,
16710
+ kept: 0,
16711
+ dated: 0,
16712
+ dropped: 0,
16713
+ skipped: 0
16714
+ };
16715
+ if (queue.length === 0) {
16716
+ say("No fog: every open task was touched in the last three weeks.");
16717
+ return tally;
16718
+ }
16719
+ say(`Clear the fog · ${plural$2(queue.length, "task")} nobody has opened, moved or commented on for three weeks.`);
16720
+ say("k still needed d new date x drop it s skip q stop\n");
16721
+ let stopped = false;
16722
+ for (let i = 0; i < queue.length && !stopped; i++) {
16723
+ const { task, days } = queue[i];
16724
+ const s = store.getState();
16725
+ if (!s.tasks.some((t) => t.id === task.id)) continue;
16726
+ const project = s.projects.find((p) => p.id === task.projectId);
16727
+ const where = [
16728
+ keyOf$1(s, task),
16729
+ opts.projectId ? null : project?.name,
16730
+ untouchedText(days),
16731
+ task.dueDate ? `due ${shortDay(task.dueDate, today)}` : null
16732
+ ].filter(Boolean).join(" · ");
16733
+ say(`${i + 1} of ${queue.length} ${task.title}`);
16734
+ say(` ${where}`);
16735
+ for (;;) {
16736
+ const key = (await ask(" k, d, x, s or q: ")).trim().toLowerCase();
16737
+ if (key === "k") {
16738
+ store.getState().keepTasks([task.id]);
16739
+ tally.kept++;
16740
+ say(" Kept. The fog's off it.\n");
16741
+ } else if (key === "d") {
16742
+ let due = null;
16743
+ while (!due) {
16744
+ const typed = (await ask(" New due date (today, fri, +7, YYYY-MM-DD): ")).trim();
16745
+ if (!typed) break;
16746
+ try {
16747
+ due = dueFrom(typed, opts.now);
16748
+ } catch (err) {
16749
+ say(` ${err.message}`);
16750
+ }
16751
+ }
16752
+ if (!due) continue;
16753
+ store.getState().updateTask(task.id, { dueDate: due });
16754
+ tally.dated++;
16755
+ say(` Due ${shortDay(due, today)} now.\n`);
16756
+ } else if (key === "x") {
16757
+ store.getState().deleteTask(task.id);
16758
+ tally.dropped++;
16759
+ say(" Dropped.\n");
16760
+ } else if (key === "s" || key === "") {
16761
+ tally.skipped++;
16762
+ say(" Left for next time.\n");
16763
+ } else if (key === "q") stopped = true;
16764
+ else continue;
16765
+ break;
16570
16766
  }
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
16767
  }
16576
- const value = (k) => {
16577
- const block = lists.get(k);
16578
- if (block && block.length) return block.join(", ");
16579
- return unquote(values.get(k) ?? "");
16768
+ say(fogSummary(tally));
16769
+ return tally;
16770
+ }
16771
+ /** "2 still needed, 1 given a new date, 1 dropped." */
16772
+ function fogSummary(t) {
16773
+ const parts = [
16774
+ t.kept && `${t.kept} still needed`,
16775
+ t.dated && `${t.dated} given a new date`,
16776
+ t.dropped && `${t.dropped} dropped`,
16777
+ t.skipped && `${t.skipped} left for next time`
16778
+ ].filter(Boolean);
16779
+ const left = t.foggy - t.kept - t.dated - t.dropped - t.skipped;
16780
+ if (left > 0) parts.push(`${left} not looked at`);
16781
+ return parts.length ? `${parts.join(", ")}.` : "Nothing changed.";
16782
+ }
16783
+
16784
+ //#endregion
16785
+ //#region src/tui/engine/memory-crew.ts
16786
+ function memoryCrew(account, harbor) {
16787
+ let n = 0;
16788
+ const token = () => `t${++n}`;
16789
+ const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
16790
+ const members = [{
16791
+ userId: account.id,
16792
+ name: account.name,
16793
+ email: account.email,
16794
+ role: "owner"
16795
+ }, {
16796
+ userId: "acct-2",
16797
+ name: "Mara Quinn",
16798
+ email: "mara@example.com",
16799
+ role: "member"
16800
+ }];
16801
+ const invites = [];
16802
+ const shares = [];
16803
+ const forms = [];
16804
+ const apps = [{
16805
+ clientId: "harbor-cli",
16806
+ name: "Harbor CLI",
16807
+ returnsTo: "127.0.0.1",
16808
+ connectedAt: stamp(),
16809
+ active: true,
16810
+ canWrite: true,
16811
+ fullAccess: true
16812
+ }, {
16813
+ clientId: "claude",
16814
+ name: "Claude",
16815
+ returnsTo: "claude.ai",
16816
+ connectedAt: stamp(),
16817
+ active: true,
16818
+ canWrite: false,
16819
+ fullAccess: false
16820
+ }];
16821
+ const email = {
16822
+ weeklyDigest: true,
16823
+ assignments: true
16580
16824
  };
16581
- const title = value("title");
16582
- if (!title) return {
16583
- fields: null,
16584
- problems: [...problems, "it has no title"]
16825
+ const keep = {
16826
+ kept: false,
16827
+ interval: null,
16828
+ stripe: true,
16829
+ keptAt: null,
16830
+ trialEndsAt: new Date(Date.now() + 1728e6).toISOString(),
16831
+ paused: false
16585
16832
  };
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
- }
16835
- }
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
- }
16848
- }
16849
- var PLAN_DIR;
16850
- var init_folder = __esmMin((() => {
16851
- init_plan_files();
16852
- PLAN_DIR = ".harbor";
16853
- }));
16854
-
16855
- //#endregion
16856
- //#region src/tui/engine/memory-crew.ts
16857
- function memoryCrew(account, harbor) {
16858
- let n = 0;
16859
- const token = () => `t${++n}`;
16860
- const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
16861
- const members = [{
16862
- userId: account.id,
16863
- name: account.name,
16864
- email: account.email,
16865
- role: "owner"
16866
- }, {
16867
- userId: "acct-2",
16868
- name: "Mara Quinn",
16869
- email: "mara@example.com",
16870
- role: "member"
16871
- }];
16872
- const invites = [];
16873
- const shares = [];
16874
- const forms = [];
16875
- const apps = [{
16876
- clientId: "harbor-cli",
16877
- name: "Harbor CLI",
16878
- returnsTo: "127.0.0.1",
16879
- connectedAt: stamp(),
16880
- active: true,
16881
- canWrite: true,
16882
- fullAccess: true
16883
- }, {
16884
- clientId: "claude",
16885
- name: "Claude",
16886
- returnsTo: "claude.ai",
16887
- connectedAt: stamp(),
16888
- active: true,
16889
- canWrite: false,
16890
- fullAccess: false
16891
- }];
16892
- const email = {
16893
- weeklyDigest: true,
16894
- assignments: true
16895
- };
16896
- const keep = {
16897
- kept: false,
16898
- interval: null,
16899
- stripe: true,
16900
- keptAt: null,
16901
- trialEndsAt: new Date(Date.now() + 1728e6).toISOString(),
16902
- paused: false
16903
- };
16904
- const refuse = (message, status = 404) => {
16905
- throw new TransportError(message, status);
16833
+ const refuse = (message, status = 404) => {
16834
+ throw new TransportError(message, status);
16906
16835
  };
16907
16836
  const view = (said = {}) => structuredClone({
16908
16837
  ...said,
@@ -17188,14 +17117,297 @@ function memoryHarbor(start, opts) {
17188
17117
  }
17189
17118
  };
17190
17119
  }
17191
- var init_memory = __esmMin((() => {
17192
- init_changes();
17193
- init_live_changes();
17194
- init_row_changes();
17195
- init_task_keys();
17120
+ var init_memory = __esmMin((() => {
17121
+ init_changes();
17122
+ init_live_changes();
17123
+ init_row_changes();
17124
+ init_task_keys();
17125
+ init_indexes();
17126
+ init_memory_crew();
17127
+ init_transport();
17128
+ }));
17129
+
17130
+ //#endregion
17131
+ //#region src/repo/plan-files.ts
17132
+ /** "RR-42.md" → "RR-42"; a file named anything else is a new task. */
17133
+ function keyOfFile(name) {
17134
+ return KEY_NAME.exec(name)?.[1] ?? null;
17135
+ }
17136
+ /** A front-matter value written so it reads back the same. */
17137
+ function scalar(value) {
17138
+ return value === value.trim() && value !== "" && !/^["'[\]{}>|*&!%@#`,?:-]/.test(value) && !/: |\s#/.test(value) && !/^(null|~|true|false|yes|no|-?\d[\d.]*)$/i.test(value) ? value : JSON.stringify(value);
17139
+ }
17140
+ function unquote(raw) {
17141
+ const v = raw.trim();
17142
+ if (v.startsWith("\"") && v.endsWith("\"") && v.length > 1) try {
17143
+ return String(JSON.parse(v));
17144
+ } catch {
17145
+ return v.slice(1, -1);
17146
+ }
17147
+ if (v.startsWith("'") && v.endsWith("'") && v.length > 1) return v.slice(1, -1).replace(/''/g, "'");
17148
+ return v;
17149
+ }
17150
+ function list$1(raw) {
17151
+ const v = raw.trim();
17152
+ return (v.startsWith("[") && v.endsWith("]") ? v.slice(1, -1) : v).split(",").map((x) => unquote(x)).filter(Boolean);
17153
+ }
17154
+ /** A task's file, as `harbor sync` writes it. */
17155
+ function renderTask(f) {
17156
+ const lines = [
17157
+ "---",
17158
+ `title: ${scalar(f.title)}`,
17159
+ `status: ${f.status}`,
17160
+ `owner: ${f.owner ? scalar(f.owner) : ""}`.trimEnd(),
17161
+ `due: ${f.due ?? ""}`.trimEnd(),
17162
+ `priority: ${f.priority}`
17163
+ ];
17164
+ if (f.waits.length) lines.push(`waits: [${f.waits.join(", ")}]`);
17165
+ if (f.repeat) lines.push(`repeat: ${f.repeat}`);
17166
+ lines.push("---");
17167
+ const notes = f.notes.trim();
17168
+ return `${lines.join("\n")}\n${notes ? `${notes}\n` : ""}`;
17169
+ }
17170
+ /**
17171
+ * A task's file read back. Missing fields take their empty value (no owner,
17172
+ * no due date, medium priority), so deleting a line clears it. Anything it
17173
+ * can't read is named in `problems`, and a file without a title isn't a task.
17174
+ */
17175
+ function parseTask(text) {
17176
+ const problems = [];
17177
+ const src = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
17178
+ const fm = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/.exec(src);
17179
+ if (!fm) return {
17180
+ fields: null,
17181
+ problems: ["it doesn't start with a --- block"]
17182
+ };
17183
+ const values = /* @__PURE__ */ new Map();
17184
+ const lists = /* @__PURE__ */ new Map();
17185
+ let open = null;
17186
+ for (const line of fm[1].split("\n")) {
17187
+ if (!line.trim() || line.trim().startsWith("#")) continue;
17188
+ const item = /^\s+-\s*(.*)$/.exec(line);
17189
+ if (item && open) {
17190
+ lists.get(open).push(unquote(item[1]));
17191
+ continue;
17192
+ }
17193
+ const kv = /^([A-Za-z][\w-]*)\s*:\s?(.*)$/.exec(line);
17194
+ if (!kv) {
17195
+ problems.push(`couldn't read "${line.trim()}"`);
17196
+ continue;
17197
+ }
17198
+ const key = kv[1].toLowerCase();
17199
+ values.set(key, kv[2]);
17200
+ open = kv[2].trim() === "" ? key : null;
17201
+ if (open) lists.set(open, []);
17202
+ }
17203
+ const value = (k) => {
17204
+ const block = lists.get(k);
17205
+ if (block && block.length) return block.join(", ");
17206
+ return unquote(values.get(k) ?? "");
17207
+ };
17208
+ const title = value("title");
17209
+ if (!title) return {
17210
+ fields: null,
17211
+ problems: [...problems, "it has no title"]
17212
+ };
17213
+ const statusText = value("status").toLowerCase();
17214
+ const status = statusText ? STATUS_WORDS[statusText] : "todo";
17215
+ if (!status) problems.push(`status "${statusText}" isn't one of backlog, todo, doing, review, done`);
17216
+ const priorityText = value("priority").toLowerCase();
17217
+ const priority = priorityText ? PRIORITIES.find((p) => p === priorityText) : "medium";
17218
+ if (!priority) problems.push(`priority "${priorityText}" isn't low, medium, high or urgent`);
17219
+ const due = value("due") || null;
17220
+ const dueOk = !due || /^\d{4}-\d{2}-\d{2}$/.test(due) && !Number.isNaN(Date.parse(due));
17221
+ if (!dueOk) problems.push(`due "${due}" isn't a date like 2026-11-07`);
17222
+ const repeatText = value("repeat").toLowerCase();
17223
+ const repeat = !repeatText || repeatText === "never" ? null : REPEATS.find((r) => r === repeatText) ?? null;
17224
+ if (repeatText && repeatText !== "never" && !repeat) problems.push(`repeat "${repeatText}" isn't weekly, biweekly or monthly`);
17225
+ const waits = (lists.get("waits")?.length ? lists.get("waits") : list$1(values.get("waits") ?? "")).map((k) => k.toUpperCase());
17226
+ return {
17227
+ fields: {
17228
+ title,
17229
+ status: status ?? "todo",
17230
+ owner: value("owner") || null,
17231
+ due: dueOk ? due : null,
17232
+ priority: priority ?? "medium",
17233
+ waits,
17234
+ repeat,
17235
+ notes: fm[2].trim()
17236
+ },
17237
+ problems
17238
+ };
17239
+ }
17240
+ /** The key a task goes by in this harbor, like RR-42. */
17241
+ function keyOf(data, task) {
17242
+ return taskKey(task, data.projects.find((p) => p.id === task.projectId));
17243
+ }
17244
+ /** What a task's file says, read off the harbor. */
17245
+ function fieldsOf(data, task) {
17246
+ const people = byId(data.people);
17247
+ const tasks = byId(data.tasks);
17248
+ const waitIds = task.blockedByIds ?? (task.blockedById ? [task.blockedById] : []);
17249
+ return {
17250
+ title: task.title,
17251
+ status: task.status,
17252
+ owner: task.assigneeId ? people.get(task.assigneeId)?.name ?? null : null,
17253
+ due: task.dueDate,
17254
+ priority: task.priority,
17255
+ waits: waitIds.map((id) => tasks.get(id)).map((t) => t ? keyOf(data, t) : null).filter((k) => Boolean(k)),
17256
+ repeat: task.repeat ?? null,
17257
+ notes: task.description.trim()
17258
+ };
17259
+ }
17260
+ /** The person a file's owner names: their whole name, else a first name or initials only they have. */
17261
+ function personNamed$1(people, name) {
17262
+ const want = name.trim().toLowerCase();
17263
+ if (!want) return null;
17264
+ const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
17265
+ if (exact.length === 1) return exact[0];
17266
+ const first = people.filter((p) => p.name.trim().toLowerCase().split(/\s+/)[0] === want);
17267
+ if (first.length === 1) return first[0];
17268
+ const initials = people.filter((p) => p.initials.toLowerCase() === want);
17269
+ return initials.length === 1 ? initials[0] : null;
17270
+ }
17271
+ /**
17272
+ * The folder's changes to one task, worked out against the base: a field
17273
+ * the file changed and the harbor didn't goes to the harbor; one both
17274
+ * changed differently stays as the harbor has it, and is named.
17275
+ */
17276
+ function mergeTask(base, file, harbor) {
17277
+ const patch = {};
17278
+ const clashes = [];
17279
+ for (const k of FIELDS) {
17280
+ if (same(file[k], base[k]) || same(file[k], harbor[k])) continue;
17281
+ if (same(harbor[k], base[k])) patch[k] = file[k];
17282
+ else clashes.push({
17283
+ field: k,
17284
+ file: shown(file[k]),
17285
+ harbor: shown(harbor[k])
17286
+ });
17287
+ }
17288
+ return {
17289
+ patch,
17290
+ clashes
17291
+ };
17292
+ }
17293
+ /** What a set of file fields means as a change to a task in this harbor. */
17294
+ function taskPatch(data, fields, say, label) {
17295
+ const patch = {};
17296
+ if (fields.title !== void 0) patch.title = fields.title;
17297
+ if (fields.status !== void 0) patch.status = fields.status;
17298
+ if (fields.due !== void 0) patch.dueDate = fields.due;
17299
+ if (fields.priority !== void 0) patch.priority = fields.priority;
17300
+ if (fields.repeat !== void 0) patch.repeat = fields.repeat;
17301
+ if (fields.notes !== void 0) patch.description = fields.notes;
17302
+ if (fields.owner !== void 0) {
17303
+ const person = fields.owner ? personNamed$1(data.people, fields.owner) : null;
17304
+ if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
17305
+ patch.assigneeId = person?.id ?? null;
17306
+ }
17307
+ if (fields.waits !== void 0) {
17308
+ const ids = [];
17309
+ for (const key of fields.waits) {
17310
+ const t = findByKey(data, key);
17311
+ if (t) ids.push(t.id);
17312
+ else say(`${label}: there's no ${key} to wait on, so that was left out.`);
17313
+ }
17314
+ patch.blockedByIds = ids;
17315
+ patch.blockedById = ids[0] ?? null;
17316
+ }
17317
+ return patch;
17318
+ }
17319
+ /** The base: what each of a project's tasks said when the folder last matched the harbor. */
17320
+ function baseOf(data, projectId) {
17321
+ const base = {};
17322
+ for (const t of data.tasks) if (t.projectId === projectId) base[t.id] = fieldsOf(data, t);
17323
+ return base;
17324
+ }
17325
+ /** A person a local plan adds for an owner it hasn't met. */
17326
+ function localPerson(name, id) {
17327
+ return {
17328
+ id,
17329
+ name,
17330
+ role: "Crew",
17331
+ initials: initialsOf(name),
17332
+ swatch: "pine",
17333
+ location: ""
17334
+ };
17335
+ }
17336
+ /** `.harbor/README.md`: what the folder is, for whoever finds it in the repository. */
17337
+ function readme(config) {
17338
+ return `# Plan
17339
+
17340
+ 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.`}
17341
+
17342
+ Each file has its fields between the \`---\` lines and its notes below:
17343
+
17344
+ \`\`\`md
17345
+ ---
17346
+ title: Move checkout to the new API
17347
+ status: doing
17348
+ owner: Sam Rivera
17349
+ due: 2026-11-07
17350
+ priority: high
17351
+ waits: [${config.project.key}-2]
17352
+ ---
17353
+ Old endpoints go away on Nov 20.
17354
+ \`\`\`
17355
+
17356
+ - status: backlog, todo, doing, review or done
17357
+ - priority: low, medium, high or urgent
17358
+ - repeat (optional): weekly, biweekly or monthly
17359
+ - A new file with any name is a new task; Harbor gives it a key and renames it.
17360
+ - Removing a file doesn't remove the task. Mark it done instead.
17361
+ `;
17362
+ }
17363
+ var FIELDS, TASKS_DIR, CONFIG_FILE, BASE_FILE, LOCAL_FILE, STATUS_WORDS, PRIORITIES, REPEATS, KEY_NAME, same, shown;
17364
+ var init_plan_files = __esmMin((() => {
17365
+ init_shared();
17196
17366
  init_indexes();
17197
- init_memory_crew();
17198
- init_transport();
17367
+ init_task_keys();
17368
+ FIELDS = [
17369
+ "title",
17370
+ "status",
17371
+ "owner",
17372
+ "due",
17373
+ "priority",
17374
+ "waits",
17375
+ "repeat",
17376
+ "notes"
17377
+ ];
17378
+ TASKS_DIR = "tasks";
17379
+ CONFIG_FILE = "harbor.json";
17380
+ BASE_FILE = "state/base.json";
17381
+ LOCAL_FILE = "plan.json";
17382
+ STATUS_WORDS = {
17383
+ backlog: "backlog",
17384
+ todo: "todo",
17385
+ "to-do": "todo",
17386
+ "to do": "todo",
17387
+ doing: "doing",
17388
+ "in-progress": "doing",
17389
+ "in progress": "doing",
17390
+ started: "doing",
17391
+ review: "review",
17392
+ "in-review": "review",
17393
+ "in review": "review",
17394
+ done: "done",
17395
+ finished: "done"
17396
+ };
17397
+ PRIORITIES = [
17398
+ "low",
17399
+ "medium",
17400
+ "high",
17401
+ "urgent"
17402
+ ];
17403
+ REPEATS = [
17404
+ "weekly",
17405
+ "biweekly",
17406
+ "monthly"
17407
+ ];
17408
+ KEY_NAME = /^([A-Z][A-Z0-9]{0,9}-\d{1,7})\.md$/;
17409
+ same = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.join(",") === b.join(",") : a === b;
17410
+ shown = (v) => Array.isArray(v) ? v.join(", ") || "nothing" : v === null || v === "" ? "nothing" : String(v);
17199
17411
  }));
17200
17412
 
17201
17413
  //#endregion
@@ -17715,6 +17927,75 @@ var init_repo_sync = __esmMin((() => {
17715
17927
  };
17716
17928
  }));
17717
17929
 
17930
+ //#endregion
17931
+ //#region src/repo/folder.ts
17932
+ /**
17933
+ * The `.harbor/` folder on disk, and finding it: like git, `harbor sync`
17934
+ * works from anywhere inside the repository.
17935
+ */
17936
+ function diskFolder(root) {
17937
+ return {
17938
+ async read(path) {
17939
+ try {
17940
+ return await readFile(join(root, path), "utf8");
17941
+ } catch (error) {
17942
+ if (error.code === "ENOENT") return null;
17943
+ throw error;
17944
+ }
17945
+ },
17946
+ async write(path, text) {
17947
+ const file = join(root, path);
17948
+ await mkdir(dirname(file), { recursive: true });
17949
+ const temp = `${file}.${process.pid}.tmp`;
17950
+ await writeFile(temp, text);
17951
+ await rename(temp, file);
17952
+ },
17953
+ async remove(path) {
17954
+ await rm(join(root, path), { force: true });
17955
+ },
17956
+ async list(dir) {
17957
+ try {
17958
+ return (await readdir(join(root, dir), { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name);
17959
+ } catch (error) {
17960
+ if (error.code === "ENOENT") return [];
17961
+ throw error;
17962
+ }
17963
+ }
17964
+ };
17965
+ }
17966
+ /** The nearest `.harbor/` with a plan in it, from `start` up; null when there's none. */
17967
+ async function findPlan(start) {
17968
+ let dir = resolve(start);
17969
+ for (;;) {
17970
+ const here = join(dir, PLAN_DIR);
17971
+ try {
17972
+ await readFile(join(here, CONFIG_FILE));
17973
+ return here;
17974
+ } catch {}
17975
+ const up = dirname(dir);
17976
+ if (up === dir) return null;
17977
+ dir = up;
17978
+ }
17979
+ }
17980
+ /** Where a new plan goes: the repository's top (the folder holding .git), else here. */
17981
+ async function planHome(start) {
17982
+ let dir = resolve(start);
17983
+ for (;;) {
17984
+ try {
17985
+ await access(join(dir, ".git"));
17986
+ return dir;
17987
+ } catch {}
17988
+ const up = dirname(dir);
17989
+ if (up === dir) return resolve(start);
17990
+ dir = up;
17991
+ }
17992
+ }
17993
+ var PLAN_DIR;
17994
+ var init_folder = __esmMin((() => {
17995
+ init_plan_files();
17996
+ PLAN_DIR = ".harbor";
17997
+ }));
17998
+
17718
17999
  //#endregion
17719
18000
  //#region src/repo/commands.ts
17720
18001
  /**
@@ -23442,6 +23723,7 @@ init_format();
23442
23723
  init_date_text();
23443
23724
  init_handover();
23444
23725
  init_work();
23726
+ init_fog();
23445
23727
  init_board();
23446
23728
  /**
23447
23729
  * Where the website opens one task or project, switching to its harbor
@@ -24431,6 +24713,38 @@ const HARBOR_TOOLS = [
24431
24713
  };
24432
24714
  }
24433
24715
  }),
24716
+ tool({
24717
+ name: "fog",
24718
+ title: "Tasks in fog",
24719
+ 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,
24720
+ input: object({
24721
+ harbor: harborArg,
24722
+ project: string().max(200).optional().describe("Project id, key or name, for one only."),
24723
+ limit: limitArg(100, 25),
24724
+ offset: offsetArg
24725
+ }),
24726
+ run: (ctx, input) => {
24727
+ const { data } = ctx;
24728
+ const project = input.project ? findOne(data.projects, input.project, "project") : null;
24729
+ const today = todayISO(ctx.now);
24730
+ const fog = foggyTasks(data, today, project ? /* @__PURE__ */ new Set([project.id]) : void 0);
24731
+ const people = byId(data.people);
24732
+ const projects = byId(data.projects);
24733
+ return {
24734
+ ...project ? { project: project.name } : {},
24735
+ inFog: fog.length,
24736
+ fogAfterDays: 21,
24737
+ tasks: fog.slice(input.offset, input.offset + input.limit).map((f) => ({
24738
+ ...taskLine(f.task, people, projects, data.tasks),
24739
+ untouchedDays: f.days,
24740
+ untouched: untouchedText(f.days),
24741
+ thick: f.level === "thick",
24742
+ lastTouched: dayOf(lastTouch(f.task, data))
24743
+ })),
24744
+ more: fog.length > input.offset + input.limit
24745
+ };
24746
+ }
24747
+ }),
24434
24748
  tool({
24435
24749
  name: "handover_plan",
24436
24750
  title: "What handing someone's work over would move",
@@ -24490,61 +24804,6 @@ const LIST_HARBORS = {
24490
24804
  input: object({})
24491
24805
  };
24492
24806
 
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
24807
  //#endregion
24549
24808
  //#region src/lib/dry-dock.ts
24550
24809
  init_date_fns();
@@ -25088,6 +25347,8 @@ init_transport();
25088
25347
  init_errors();
25089
25348
  init_plain();
25090
25349
  init_search();
25350
+ init_view_only();
25351
+ init_repo_sync();
25091
25352
  const today = () => format(/* @__PURE__ */ new Date(), "yyyy-MM-dd");
25092
25353
  /** How long `harbor` shows its splash at the least, however fast the harbor loads. */
25093
25354
  const SPLASH_MS = 1200;
@@ -25221,8 +25482,58 @@ async function runApp(opts) {
25221
25482
  }, { alternateScreen: true });
25222
25483
  await instance.waitUntilExit();
25223
25484
  }
25485
+ /**
25486
+ * The harbor a one-off run works in, without asking: --harbor, else the one
25487
+ * `harbor use` saved, else the only one.
25488
+ */
25489
+ function harborFor(harbors, named, saved) {
25490
+ if (harbors.length === 0) throw new HarborError("You're not in a harbor yet. Run harbor to make one first.");
25491
+ if (named) return harborNamed(harbors, named);
25492
+ const kept = saved ? harbors.find((h) => h.id === saved) : void 0;
25493
+ if (kept) return kept;
25494
+ if (harbors.length === 1) return harbors[0];
25495
+ throw new HarborError(`You're in ${harbors.length} harbors (${harbors.map((h) => h.name).join(", ")}). Add --harbor NAME, or run harbor use NAME first.`);
25496
+ }
25497
+ /** `harbor fog` in a terminal: the sweep, one task at a time, then one save. */
25498
+ async function fogHere(opts) {
25499
+ const transport = plainTransport(httpTransport(opts.source, opts.fetcher));
25500
+ const answer = await transport.harbors();
25501
+ const harbor = harborFor(answer.harbors, opts.harbor, opts.saved);
25502
+ const say = (line) => process.stdout.write(`${plain(line)}\n`);
25503
+ const store = createAppStore();
25504
+ const sync = createSync({
25505
+ store,
25506
+ transport,
25507
+ account: {
25508
+ id: answer.account.id,
25509
+ name: answer.account.name || answer.account.email
25510
+ },
25511
+ live: false,
25512
+ saveDelayMs: 0,
25513
+ onNotice: say
25514
+ });
25515
+ await sync.open(harbor.id);
25516
+ const terminal = terminalAsk("Stopped. What you'd answered is saved.");
25517
+ try {
25518
+ 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.`);
25519
+ const project = opts.project ? projectNamed(store.getState().projects, opts.project) : null;
25520
+ await sweepFog({
25521
+ store,
25522
+ ask: terminal.ask,
25523
+ say,
25524
+ today: today(),
25525
+ now: /* @__PURE__ */ new Date(),
25526
+ projectId: project?.id ?? null
25527
+ });
25528
+ } finally {
25529
+ terminal.close();
25530
+ await sync.flush();
25531
+ sync.close();
25532
+ }
25533
+ if (sync.state !== "saved") throw new HarborError("Harbor didn't take those answers. Run harbor fog again.");
25534
+ }
25224
25535
  /** Questions asked on the plain terminal, before the app takes it over. Ctrl+C stops. */
25225
- function terminalAsk() {
25536
+ function terminalAsk(stoppedText = "Stopped. Nothing was made.") {
25226
25537
  const rl = createInterface({
25227
25538
  input: process.stdin,
25228
25539
  output: process.stdout
@@ -25230,7 +25541,7 @@ function terminalAsk() {
25230
25541
  let stop = () => {};
25231
25542
  const stopped = new Promise((_, reject) => stop = reject);
25232
25543
  stopped.catch(() => {});
25233
- const nothing = () => stop(new HarborError("Stopped. Nothing was made."));
25544
+ const nothing = () => stop(new HarborError(stoppedText));
25234
25545
  rl.on("SIGINT", nothing);
25235
25546
  rl.on("close", nothing);
25236
25547
  return {
@@ -25262,4 +25573,4 @@ function planTransport(source, fetcher) {
25262
25573
  }
25263
25574
 
25264
25575
  //#endregion
25265
- export { SPLASH_MS, TransportError, firstHarbor, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere };
25576
+ export { SPLASH_MS, TransportError, firstHarbor, fogHere, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere };