@harborppm/cli 0.46.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
@@ -2068,9 +2068,43 @@ function normalizeRaidItem(r) {
2068
2068
  updatedAt: r.updatedAt ?? r.createdAt ?? now,
2069
2069
  updates: Array.isArray(r.updates) ? r.updates : [],
2070
2070
  scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0,
2071
- ...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {}
2071
+ ...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {},
2072
+ ...kind === "decision" ? decisionFields(r) : {}
2072
2073
  };
2073
2074
  }
2075
+ /** A revisit condition that still says something, or null. */
2076
+ function cleanRevisit(v) {
2077
+ if (!v || typeof v !== "object") return null;
2078
+ const r = v;
2079
+ const str = (x) => typeof x === "string" && x ? x : null;
2080
+ if (r.kind === "date" && str(r.on)) return {
2081
+ kind: "date",
2082
+ on: str(r.on).slice(0, 10)
2083
+ };
2084
+ if (r.kind === "risk" && str(r.riskId)) return {
2085
+ kind: "risk",
2086
+ riskId: str(r.riskId)
2087
+ };
2088
+ if (r.kind === "milestone" && str(r.milestoneId) && str(r.due)) return {
2089
+ kind: "milestone",
2090
+ milestoneId: str(r.milestoneId),
2091
+ due: str(r.due).slice(0, 10)
2092
+ };
2093
+ return null;
2094
+ }
2095
+ /** A decision's own fields, kept only where they say something so older rows stay as they were. */
2096
+ function decisionFields(r) {
2097
+ const out = {};
2098
+ if (r.turnedDown?.trim()) out.turnedDown = r.turnedDown;
2099
+ const tasks = ids$1(r.taskIds);
2100
+ if (tasks.length) out.taskIds = tasks;
2101
+ const risks = ids$1(r.riskIds);
2102
+ if (risks.length) out.riskIds = risks;
2103
+ const revisit = cleanRevisit(r.revisitIf);
2104
+ if (revisit) out.revisitIf = revisit;
2105
+ if (r.supersedesId) out.supersedesId = r.supersedesId;
2106
+ return out;
2107
+ }
2074
2108
  /** Active first, then by score, then most recently touched. */
2075
2109
  function sortRaid(items) {
2076
2110
  return [...items].sort((a, b) => {
@@ -2112,7 +2146,7 @@ function heatGrid(items) {
2112
2146
  function inHeatCell(item, probability, impact) {
2113
2147
  return item.kind === "risk" && item.probability === probability && item.impact === impact;
2114
2148
  }
2115
- var KIND_LABEL, STATUS_LABEL$1, STATUSES$1, PROBABILITY_LABEL, IMPACT_LABEL, FIELD_COPY;
2149
+ var KIND_LABEL, STATUS_LABEL$1, STATUSES$1, PROBABILITY_LABEL, IMPACT_LABEL, FIELD_COPY, ids$1;
2116
2150
  var init_raid = __esmMin((() => {
2117
2151
  KIND_LABEL = {
2118
2152
  risk: "Risk",
@@ -2182,6 +2216,7 @@ var init_raid = __esmMin((() => {
2182
2216
  dueDate: "Decide by"
2183
2217
  }
2184
2218
  };
2219
+ ids$1 = (v) => Array.isArray(v) ? [...new Set(v.filter((x) => typeof x === "string" && !!x))] : [];
2185
2220
  }));
2186
2221
 
2187
2222
  //#endregion
@@ -5435,6 +5470,85 @@ function freshlyOpened(data, projects, now) {
5435
5470
  return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
5436
5471
  }
5437
5472
 
5473
+ //#endregion
5474
+ //#region src/lib/decisions.ts
5475
+ /** A risk counts as come true when someone said so, or when it was turned into an issue. */
5476
+ function cameTrue(risk) {
5477
+ return risk.happened === true || risk.kind === "issue";
5478
+ }
5479
+ /** "the risk “Vendor misses window” comes true", for "Revisit if …". */
5480
+ function revisitText(revisit, data) {
5481
+ if (revisit.kind === "date") return `it's ${md$1(revisit.on)}`;
5482
+ if (revisit.kind === "risk") {
5483
+ const risk = byId(data.raidItems).get(revisit.riskId);
5484
+ return risk ? `“${risk.title}” comes true` : "a risk that's since been removed comes true";
5485
+ }
5486
+ const m = byId(data.milestones).get(revisit.milestoneId);
5487
+ return m ? `“${m.name}” slips past ${md$1(revisit.due)}` : "a milestone that's since been removed slips";
5488
+ }
5489
+ /** Why a decided decision is due another look today, in a sentence, or null while it isn't. */
5490
+ function revisitReason(item, data, today) {
5491
+ const revisit = item.revisitIf;
5492
+ if (item.kind !== "decision" || item.status !== "decided" || !revisit) return null;
5493
+ if (revisit.kind === "date") return today >= revisit.on ? `${md$1(revisit.on)} came, the day set to look again.` : null;
5494
+ if (revisit.kind === "risk") {
5495
+ const risk = byId(data.raidItems).get(revisit.riskId);
5496
+ return risk && cameTrue(risk) ? `The risk “${risk.title}” came true.` : null;
5497
+ }
5498
+ const m = byId(data.milestones).get(revisit.milestoneId);
5499
+ if (!m || m.done) return null;
5500
+ const due = m.dueDate.slice(0, 10);
5501
+ if (due > revisit.due) return `“${m.name}” moved from ${md$1(revisit.due)} to ${md$1(due)}.`;
5502
+ if (due < today) return `“${m.name}” is past its date, ${md$1(due)}.`;
5503
+ return null;
5504
+ }
5505
+ /** The decision that replaced this one, if any. */
5506
+ function supersededBy(data, id) {
5507
+ return data.raidItems.find((r) => r.kind === "decision" && r.supersedesId === id) ?? null;
5508
+ }
5509
+ /** Decided decisions in scope whose "revisit if" has happened, oldest decision first. */
5510
+ function decisionsToRevisit(data, inScope, today) {
5511
+ const out = [];
5512
+ for (const item of data.raidItems) {
5513
+ if (item.kind !== "decision" || !item.revisitIf || !inScope(item)) continue;
5514
+ const reason = revisitReason(item, data, today);
5515
+ if (!reason) continue;
5516
+ const where = item.projectId && byId(data.projects).get(item.projectId)?.name || item.programId && byId(data.programs).get(item.programId)?.name || item.portfolioId && byId(data.portfolios).get(item.portfolioId)?.name || null;
5517
+ out.push({
5518
+ item,
5519
+ reason,
5520
+ where
5521
+ });
5522
+ }
5523
+ return out.sort((a, b) => (a.item.decidedAt ?? a.item.createdAt).localeCompare(b.item.decidedAt ?? b.item.createdAt));
5524
+ }
5525
+ /** The comment a "still stands" leaves, so the thread says it was looked at. */
5526
+ function stillStandsNote(reason) {
5527
+ return reason ? `Looked at again (${reason.replace(/\.$/, "")}): it still stands.` : "Looked at again: it still stands.";
5528
+ }
5529
+ /** Fields a new decision copies from the one it replaces: where it sits, what it touches. */
5530
+ function successorOf(old) {
5531
+ return {
5532
+ kind: "decision",
5533
+ title: old.title,
5534
+ description: old.description,
5535
+ portfolioId: old.portfolioId ?? null,
5536
+ programId: old.programId ?? null,
5537
+ projectId: old.projectId ?? null,
5538
+ ownerId: old.ownerId,
5539
+ taskIds: old.taskIds,
5540
+ riskIds: old.riskIds,
5541
+ supersedesId: old.id,
5542
+ status: "proposed"
5543
+ };
5544
+ }
5545
+ var md$1;
5546
+ var init_decisions = __esmMin((() => {
5547
+ init_indexes();
5548
+ init_date_text();
5549
+ md$1 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
5550
+ }));
5551
+
5438
5552
  //#endregion
5439
5553
  //#region src/lib/brief.ts
5440
5554
  init_date_fns();
@@ -5445,6 +5559,7 @@ init_work();
5445
5559
  init_indexes();
5446
5560
  init_blockers();
5447
5561
  init_clock();
5562
+ init_decisions();
5448
5563
  const BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
5449
5564
  /** Late tasks worst first, each with what it holds up, so the letter says what to chase. */
5450
5565
  function lateLinesOf(data, tasks, now) {
@@ -5503,14 +5618,18 @@ function isHighRisk(r) {
5503
5618
  function isMajorIssue(r) {
5504
5619
  return r.kind === "issue" && (r.impact != null ? r.impact >= 4 : r.severity === "high");
5505
5620
  }
5506
- /** The risks and issues worth a line in the brief: live, and either a high-scored risk or a major issue. */
5507
- function watchListOf(data, scope, limit = 5) {
5508
- const inScope = (r) => {
5621
+ /** Whether a register item sits inside the brief's portfolio (or anywhere, for all of them). */
5622
+ function inBriefScope(scope) {
5623
+ return (r) => {
5509
5624
  if (r.projectId) return scope.projectIds.has(r.projectId);
5510
5625
  if (r.programId) return scope.programIds.has(r.programId);
5511
5626
  if (r.portfolioId) return scope.portfolioId == null || r.portfolioId === scope.portfolioId;
5512
5627
  return scope.portfolioId == null;
5513
5628
  };
5629
+ }
5630
+ /** The risks and issues worth a line in the brief: live, and either a high-scored risk or a major issue. */
5631
+ function watchListOf(data, scope, limit = 5) {
5632
+ const inScope = inBriefScope(scope);
5514
5633
  const live = data.raidItems.filter((r) => r.kind !== "decision" && isActive(r) && inScope(r) && (r.kind === "risk" && isHighRisk(r) || isMajorIssue(r)));
5515
5634
  return sortRaid(live).slice(0, limit).map((item) => ({
5516
5635
  item,
@@ -5546,6 +5665,11 @@ function composeBriefBody(opts) {
5546
5665
  }
5547
5666
  lines.push("");
5548
5667
  }
5668
+ if (opts.revisit?.length) {
5669
+ lines.push("Decisions to look at again");
5670
+ for (const d of opts.revisit) lines.push(`• ${d.title}${d.where ? ` (${d.where})` : ""}: ${d.reason}`);
5671
+ lines.push("");
5672
+ }
5549
5673
  const done = () => {
5550
5674
  if (!opts.shipped?.length) return;
5551
5675
  lines.push("Done this week");
@@ -5790,6 +5914,11 @@ function briefOf(data, opts = {}) {
5790
5914
  said: saidThisWeek(data, now, portfolioId ? new Set(tasks.flatMap((t) => t.assigneeId ? [t.assigneeId] : [])) : null),
5791
5915
  late: lateLinesOf(data, tasks, now),
5792
5916
  opened: freshlyOpened(data, projects, now),
5917
+ revisit: decisionsToRevisit(data, inBriefScope({
5918
+ programIds,
5919
+ projectIds,
5920
+ portfolioId
5921
+ }), todayISO(now)),
5793
5922
  talk,
5794
5923
  milestonesSoon,
5795
5924
  milestonesLate,
@@ -5841,6 +5970,11 @@ function composeBriefBodyFrom(brief, data, workspace, all = false) {
5841
5970
  project: b.project.name,
5842
5971
  who: data.people.find((p) => p.id === b.note.authorId)?.name ?? null,
5843
5972
  body: b.note.body
5973
+ })),
5974
+ revisit: brief.revisit.map((d) => ({
5975
+ title: d.item.title,
5976
+ reason: d.reason,
5977
+ where: d.where
5844
5978
  }))
5845
5979
  });
5846
5980
  }
@@ -7685,6 +7819,7 @@ init_baseline();
7685
7819
  init_derived_health();
7686
7820
  init_date_text();
7687
7821
  init_format();
7822
+ init_decisions();
7688
7823
  init_swatches();
7689
7824
  init_raid();
7690
7825
  /** The people of this harbor to pick from, with "Nobody" first when that's allowed. */
@@ -8511,30 +8646,54 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
8511
8646
  label: "Score",
8512
8647
  value: score != null ? String(score) : ""
8513
8648
  }] : [],
8514
- ...item.kind === "decision" ? [{
8515
- label: "Decided by",
8516
- value: person(item.decidedById) ?? "Not yet",
8517
- edit: {
8518
- kind: "pick",
8519
- choices: [{
8520
- label: "Not yet",
8521
- value: null
8522
- }, ...people.map((p) => ({
8523
- label: p.name,
8524
- value: p.id
8525
- }))],
8526
- initial: Math.max(0, people.findIndex((p) => p.id === item.decidedById) + 1),
8527
- onPick: (v) => up({ decidedById: v })
8528
- }
8529
- }, {
8530
- label: "Decided on",
8531
- value: item.decidedAt ? shortDay(item.decidedAt, today()) : "Not yet",
8532
- edit: {
8533
- kind: "date",
8534
- initial: item.decidedAt ?? null,
8535
- onSave: (v) => up({ decidedAt: v })
8649
+ ...item.kind === "decision" ? [
8650
+ {
8651
+ label: "Decided by",
8652
+ value: person(item.decidedById) ?? "Not yet",
8653
+ edit: {
8654
+ kind: "pick",
8655
+ choices: [{
8656
+ label: "Not yet",
8657
+ value: null
8658
+ }, ...people.map((p) => ({
8659
+ label: p.name,
8660
+ value: p.id
8661
+ }))],
8662
+ initial: Math.max(0, people.findIndex((p) => p.id === item.decidedById) + 1),
8663
+ onPick: (v) => up({ decidedById: v })
8664
+ }
8665
+ },
8666
+ {
8667
+ label: "Decided on",
8668
+ value: item.decidedAt ? shortDay(item.decidedAt, today()) : "Not yet",
8669
+ edit: {
8670
+ kind: "date",
8671
+ initial: item.decidedAt ?? null,
8672
+ onSave: (v) => up({ decidedAt: v })
8673
+ }
8674
+ },
8675
+ {
8676
+ label: "Turned down",
8677
+ value: (item.turnedDown ?? "").replace(/\s+/g, " "),
8678
+ edit: {
8679
+ kind: "text",
8680
+ initial: item.turnedDown ?? "",
8681
+ onSave: (v) => up({ turnedDown: v })
8682
+ }
8683
+ },
8684
+ {
8685
+ label: "Look again",
8686
+ value: item.revisitIf ? revisitText(item.revisitIf, store.getState()) : "",
8687
+ edit: {
8688
+ kind: "date",
8689
+ initial: item.revisitIf?.kind === "date" ? item.revisitIf.on : null,
8690
+ onSave: (v) => up({ revisitIf: v ? {
8691
+ kind: "date",
8692
+ on: v
8693
+ } : null })
8694
+ }
8536
8695
  }
8537
- }] : [],
8696
+ ] : [],
8538
8697
  {
8539
8698
  label: copy.dueDate,
8540
8699
  value: shortDay(item.dueDate, today()),
@@ -10539,12 +10698,103 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
10539
10698
  });
10540
10699
  }
10541
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
+
10542
10791
  //#endregion
10543
10792
  //#region src/tui/views/project.tsx
10544
10793
  init_swatches();
10545
10794
  init_format();
10546
10795
  init_hill();
10547
10796
  init_clock();
10797
+ init_fog();
10548
10798
  /** One project: its board (columns side by side) or its list, and the keys to work it. */
10549
10799
  function ProjectView({ projectId, active, width, height, go, onBack }) {
10550
10800
  const { store, today } = useDeps();
@@ -10555,6 +10805,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
10555
10805
  const reflections = useHarbor((s) => s.reflections);
10556
10806
  const milestones = useHarbor((s) => s.milestones);
10557
10807
  const raidItems = useHarbor((s) => s.raidItems);
10808
+ const comments = useHarbor((s) => s.comments);
10809
+ const attachments = useHarbor((s) => s.attachments);
10558
10810
  const project = projects.find((p) => p.id === projectId);
10559
10811
  const data = useMemo(() => ({
10560
10812
  tasks,
@@ -10589,6 +10841,14 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
10589
10841
  const [rowByCol, setRowByCol] = useState({});
10590
10842
  const [listAt, setListAt] = useState(0);
10591
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
+ };
10592
10852
  const column = columns[Math.min(col, columns.length - 1)];
10593
10853
  const row = Math.min(rowByCol[col] ?? 0, Math.max(0, (column?.tasks.length ?? 1) - 1));
10594
10854
  const selected = mode === "board" ? column?.tasks[row] : list[Math.min(listAt, list.length - 1)];
@@ -10883,7 +11143,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
10883
11143
  late: isLate(t, day),
10884
11144
  waits: blocked(data, t),
10885
11145
  priority: t.priority,
10886
- selected: on && view.start + i === r
11146
+ selected: on && view.start + i === r,
11147
+ fog: fogWeeks(t)
10887
11148
  }, t.id)),
10888
11149
  view.start + view.items.length < c.tasks.length ? /* @__PURE__ */ jsxs(Text, {
10889
11150
  color: theme.muted,
@@ -10924,7 +11185,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
10924
11185
  });
10925
11186
  }
10926
11187
  /** One card on the board: its key, due date and owner, then the title on up to two lines. */
10927
- function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
11188
+ function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog = null }) {
10928
11189
  return /* @__PURE__ */ jsxs(Box, {
10929
11190
  flexDirection: "column",
10930
11191
  height: 3,
@@ -10950,6 +11211,14 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
10950
11211
  owner ? /* @__PURE__ */ jsxs(Text, {
10951
11212
  color: selected ? void 0 : theme.muted,
10952
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
+ ]
10953
11222
  }) : null
10954
11223
  ]
10955
11224
  }), /* @__PURE__ */ jsx(Box, {
@@ -10957,7 +11226,8 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
10957
11226
  children: /* @__PURE__ */ jsx(Text, {
10958
11227
  wrap: "wrap",
10959
11228
  bold: selected,
10960
- color: selected ? theme.selected : void 0,
11229
+ dimColor: fog != null && !selected,
11230
+ color: selected ? theme.selected : fog != null ? theme.muted : void 0,
10961
11231
  children: title
10962
11232
  })
10963
11233
  })]
@@ -12456,6 +12726,7 @@ function App({ ui }) {
12456
12726
  };
12457
12727
  const go = {
12458
12728
  openTask: (id) => {
12729
+ store.getState().selectTask(id);
12459
12730
  setPanel({
12460
12731
  kind: "task",
12461
12732
  id
@@ -13109,16 +13380,6 @@ function fieldChange(prev, row) {
13109
13380
  was
13110
13381
  };
13111
13382
  }
13112
- /**
13113
- * Apply changes to the rows stored now. A set writes only its own fields,
13114
- * so it keeps whatever someone else changed in the others. `clashIds` names
13115
- * rows where someone else had already changed one of the same fields or had
13116
- * removed the row (the change is dropped); `clashes` says which field and
13117
- * what each side had. By default this change wins a clashed field, as the
13118
- * later save; with `keepTheirs` the stored value stays (undo uses this, so
13119
- * it never takes back someone else's newer edit). Rows nothing touched keep
13120
- * their object.
13121
- */
13122
13383
  function applyRowChanges(rows, changes, options = {}) {
13123
13384
  if (changes.length === 0) return {
13124
13385
  rows: rows.slice(),
@@ -13150,6 +13411,11 @@ function applyRowChanges(rows, changes, options = {}) {
13150
13411
  const fields = {};
13151
13412
  let clashed = false;
13152
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
+ }
13153
13419
  if (!deepEqual(stored[key], was[key]) && !deepEqual(stored[key], value)) {
13154
13420
  clashed = true;
13155
13421
  clashes.push({
@@ -13180,8 +13446,10 @@ function applyRowChanges(rows, changes, options = {}) {
13180
13446
  clashes
13181
13447
  };
13182
13448
  }
13449
+ var LATEST_WINS;
13183
13450
  var init_row_changes = __esmMin((() => {
13184
13451
  init_deep_equal();
13452
+ LATEST_WINS = /* @__PURE__ */ new Set(["touchedAt"]);
13185
13453
  }));
13186
13454
 
13187
13455
  //#endregion
@@ -13387,7 +13655,8 @@ function withStatus(task, status) {
13387
13655
  if (task.status === status) return task;
13388
13656
  const next = {
13389
13657
  ...task,
13390
- status
13658
+ status,
13659
+ touchedAt: stamp()
13391
13660
  };
13392
13661
  if (status === "done") next.completedAt = stamp();
13393
13662
  else if (task.status === "done") next.completedAt = null;
@@ -13500,6 +13769,14 @@ function sessionSlice({ set, get, write }) {
13500
13769
  selectTask: (id) => {
13501
13770
  if (get().selectedTaskId === id) return;
13502
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
+ }
13503
13780
  },
13504
13781
  log: (text, href) => {
13505
13782
  if (readOnlyHarbor(get())) return;
@@ -13552,6 +13829,8 @@ var init_session = __esmMin((() => {
13552
13829
  init_shared();
13553
13830
  init_undo_state();
13554
13831
  init_view_only();
13832
+ init_fog();
13833
+ init_format();
13555
13834
  }));
13556
13835
 
13557
13836
  //#endregion
@@ -14327,7 +14606,8 @@ function tasksSlice({ set, get, write }) {
14327
14606
  if (row.id !== id) return row;
14328
14607
  const next = {
14329
14608
  ...row,
14330
- ...patch
14609
+ ...patch,
14610
+ touchedAt: stamp()
14331
14611
  };
14332
14612
  if (patch.status && patch.status !== row.status) {
14333
14613
  if (patch.status === "done" && !patch.completedAt) next.completedAt = stamp();
@@ -14478,6 +14758,7 @@ function tasksSlice({ set, get, write }) {
14478
14758
  if (!name) return;
14479
14759
  set({ tasks: get().tasks.map((t) => t.id === taskId ? {
14480
14760
  ...t,
14761
+ touchedAt: stamp(),
14481
14762
  checklist: [...t.checklist ?? [], {
14482
14763
  id: uid("ch"),
14483
14764
  title: name,
@@ -14488,6 +14769,7 @@ function tasksSlice({ set, get, write }) {
14488
14769
  toggleChecklistItem: (taskId, itemId) => {
14489
14770
  set({ tasks: get().tasks.map((t) => t.id === taskId ? {
14490
14771
  ...t,
14772
+ touchedAt: stamp(),
14491
14773
  checklist: (t.checklist ?? []).map((i) => i.id === itemId ? {
14492
14774
  ...i,
14493
14775
  done: !i.done
@@ -14497,6 +14779,7 @@ function tasksSlice({ set, get, write }) {
14497
14779
  removeChecklistItem: (taskId, itemId) => {
14498
14780
  set({ tasks: get().tasks.map((t) => t.id === taskId ? {
14499
14781
  ...t,
14782
+ touchedAt: stamp(),
14500
14783
  checklist: (t.checklist ?? []).filter((i) => i.id !== itemId)
14501
14784
  } : t) });
14502
14785
  },
@@ -14521,6 +14804,7 @@ function tasksSlice({ set, get, write }) {
14521
14804
  setTaskCustomFieldValue: (taskId, fieldId, value) => {
14522
14805
  write({ tasks: get().tasks.map((t) => t.id === taskId ? {
14523
14806
  ...t,
14807
+ touchedAt: stamp(),
14524
14808
  customFieldValues: {
14525
14809
  ...t.customFieldValues ?? {},
14526
14810
  [fieldId]: value
@@ -14552,6 +14836,17 @@ function tasksSlice({ set, get, write }) {
14552
14836
  write({ tasks: get().tasks.map((t) => tick.has(t.id) && t.status !== "done" ? withStatus(t, "done") : t) });
14553
14837
  for (const t of ticked) get().log(`ticked off “${t.title}” on First voyage`, `/projects/${t.projectId}`);
14554
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
+ },
14555
14850
  toggleFocus: (taskId) => {
14556
14851
  const personId = get().currentUserId;
14557
14852
  const date = todayISO();
@@ -14721,6 +15016,42 @@ function milestonesRaidSlice({ set, get, write }) {
14721
15016
  updatedAt: stamp()
14722
15017
  } : r) });
14723
15018
  },
15019
+ supersedeDecision: (id) => {
15020
+ const old = get().raidItems.find((r) => r.id === id && r.kind === "decision");
15021
+ if (!old) return null;
15022
+ const now = stamp();
15023
+ const row = normalizeRaidItem({
15024
+ ...successorOf(old),
15025
+ id: uid("raid"),
15026
+ createdById: get().currentUserId,
15027
+ createdAt: now,
15028
+ updatedAt: now
15029
+ });
15030
+ write({ raidItems: [...get().raidItems.map((r) => r.id === id ? {
15031
+ ...r,
15032
+ status: "superseded",
15033
+ updatedAt: now
15034
+ } : r), row] });
15035
+ get().log(`changed a decision — ${old.title}`);
15036
+ return row.id;
15037
+ },
15038
+ decisionStillStands: (id, reason) => {
15039
+ const item = get().raidItems.find((r) => r.id === id);
15040
+ if (!item || item.kind !== "decision") return;
15041
+ const now = stamp();
15042
+ const update = {
15043
+ id: uid("ru"),
15044
+ authorId: get().currentUserId,
15045
+ body: stillStandsNote(reason ?? null),
15046
+ createdAt: now
15047
+ };
15048
+ write({ raidItems: get().raidItems.map((r) => r.id === id ? normalizeRaidItem({
15049
+ ...r,
15050
+ revisitIf: null,
15051
+ updates: [...r.updates, update],
15052
+ updatedAt: now
15053
+ }) : r) });
15054
+ },
14724
15055
  startPremortem: (projectId) => {
14725
15056
  const project = get().projects.find((p) => p.id === projectId);
14726
15057
  if (!project || project.premortem) return;
@@ -14864,6 +15195,7 @@ var init_milestones_raid = __esmMin((() => {
14864
15195
  init_clock();
14865
15196
  init_premortem();
14866
15197
  init_raid();
15198
+ init_decisions();
14867
15199
  init_raid_history();
14868
15200
  init_utils();
14869
15201
  init_shared();
@@ -16305,356 +16637,149 @@ async function pickHarbor(deps) {
16305
16637
  }
16306
16638
 
16307
16639
  //#endregion
16308
- //#region src/repo/plan-files.ts
16309
- /** "RR-42.md" → "RR-42"; a file named anything else is a new task. */
16310
- function keyOfFile(name) {
16311
- return KEY_NAME.exec(name)?.[1] ?? null;
16312
- }
16313
- /** A front-matter value written so it reads back the same. */
16314
- function scalar(value) {
16315
- return value === value.trim() && value !== "" && !/^["'[\]{}>|*&!%@#`,?:-]/.test(value) && !/: |\s#/.test(value) && !/^(null|~|true|false|yes|no|-?\d[\d.]*)$/i.test(value) ? value : JSON.stringify(value);
16316
- }
16317
- function unquote(raw) {
16318
- const v = raw.trim();
16319
- if (v.startsWith("\"") && v.endsWith("\"") && v.length > 1) try {
16320
- return String(JSON.parse(v));
16321
- } catch {
16322
- 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"
16323
16667
  }
16324
- if (v.startsWith("'") && v.endsWith("'") && v.length > 1) return v.slice(1, -1).replace(/''/g, "'");
16325
- return v;
16326
- }
16327
- function list$1(raw) {
16328
- const v = raw.trim();
16329
- return (v.startsWith("[") && v.endsWith("]") ? v.slice(1, -1) : v).split(",").map((x) => unquote(x)).filter(Boolean);
16330
- }
16331
- /** A task's file, as `harbor sync` writes it. */
16332
- function renderTask(f) {
16333
- const lines = [
16334
- "---",
16335
- `title: ${scalar(f.title)}`,
16336
- `status: ${f.status}`,
16337
- `owner: ${f.owner ? scalar(f.owner) : ""}`.trimEnd(),
16338
- `due: ${f.due ?? ""}`.trimEnd(),
16339
- `priority: ${f.priority}`
16340
- ];
16341
- if (f.waits.length) lines.push(`waits: [${f.waits.join(", ")}]`);
16342
- if (f.repeat) lines.push(`repeat: ${f.repeat}`);
16343
- lines.push("---");
16344
- const notes = f.notes.trim();
16345
- 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.`);
16346
16692
  }
16693
+
16694
+ //#endregion
16695
+ //#region src/tui/fog-sweep.ts
16347
16696
  /**
16348
- * A task's file read back. Missing fields take their empty value (no owner,
16349
- * no due date, medium priority), so deleting a line clears it. Anything it
16350
- * 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.
16351
16701
  */
16352
- function parseTask(text) {
16353
- const problems = [];
16354
- const src = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
16355
- const fm = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/.exec(src);
16356
- if (!fm) return {
16357
- fields: null,
16358
- problems: ["it doesn't start with a --- block"]
16359
- };
16360
- const values = /* @__PURE__ */ new Map();
16361
- const lists = /* @__PURE__ */ new Map();
16362
- let open = null;
16363
- for (const line of fm[1].split("\n")) {
16364
- if (!line.trim() || line.trim().startsWith("#")) continue;
16365
- const item = /^\s+-\s*(.*)$/.exec(line);
16366
- if (item && open) {
16367
- lists.get(open).push(unquote(item[1]));
16368
- continue;
16369
- }
16370
- const kv = /^([A-Za-z][\w-]*)\s*:\s?(.*)$/.exec(line);
16371
- if (!kv) {
16372
- problems.push(`couldn't read "${line.trim()}"`);
16373
- 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;
16374
16766
  }
16375
- const key = kv[1].toLowerCase();
16376
- values.set(key, kv[2]);
16377
- open = kv[2].trim() === "" ? key : null;
16378
- if (open) lists.set(open, []);
16379
16767
  }
16380
- const value = (k) => {
16381
- const block = lists.get(k);
16382
- if (block && block.length) return block.join(", ");
16383
- return unquote(values.get(k) ?? "");
16384
- };
16385
- const title = value("title");
16386
- if (!title) return {
16387
- fields: null,
16388
- problems: [...problems, "it has no title"]
16389
- };
16390
- const statusText = value("status").toLowerCase();
16391
- const status = statusText ? STATUS_WORDS[statusText] : "todo";
16392
- if (!status) problems.push(`status "${statusText}" isn't one of backlog, todo, doing, review, done`);
16393
- const priorityText = value("priority").toLowerCase();
16394
- const priority = priorityText ? PRIORITIES.find((p) => p === priorityText) : "medium";
16395
- if (!priority) problems.push(`priority "${priorityText}" isn't low, medium, high or urgent`);
16396
- const due = value("due") || null;
16397
- const dueOk = !due || /^\d{4}-\d{2}-\d{2}$/.test(due) && !Number.isNaN(Date.parse(due));
16398
- if (!dueOk) problems.push(`due "${due}" isn't a date like 2026-11-07`);
16399
- const repeatText = value("repeat").toLowerCase();
16400
- const repeat = !repeatText || repeatText === "never" ? null : REPEATS.find((r) => r === repeatText) ?? null;
16401
- if (repeatText && repeatText !== "never" && !repeat) problems.push(`repeat "${repeatText}" isn't weekly, biweekly or monthly`);
16402
- const waits = (lists.get("waits")?.length ? lists.get("waits") : list$1(values.get("waits") ?? "")).map((k) => k.toUpperCase());
16403
- return {
16404
- fields: {
16405
- title,
16406
- status: status ?? "todo",
16407
- owner: value("owner") || null,
16408
- due: dueOk ? due : null,
16409
- priority: priority ?? "medium",
16410
- waits,
16411
- repeat,
16412
- notes: fm[2].trim()
16413
- },
16414
- problems
16415
- };
16416
- }
16417
- /** The key a task goes by in this harbor, like RR-42. */
16418
- function keyOf(data, task) {
16419
- return taskKey(task, data.projects.find((p) => p.id === task.projectId));
16420
- }
16421
- /** What a task's file says, read off the harbor. */
16422
- function fieldsOf(data, task) {
16423
- const people = byId(data.people);
16424
- const tasks = byId(data.tasks);
16425
- const waitIds = task.blockedByIds ?? (task.blockedById ? [task.blockedById] : []);
16426
- return {
16427
- title: task.title,
16428
- status: task.status,
16429
- owner: task.assigneeId ? people.get(task.assigneeId)?.name ?? null : null,
16430
- due: task.dueDate,
16431
- priority: task.priority,
16432
- waits: waitIds.map((id) => tasks.get(id)).map((t) => t ? keyOf(data, t) : null).filter((k) => Boolean(k)),
16433
- repeat: task.repeat ?? null,
16434
- notes: task.description.trim()
16435
- };
16436
- }
16437
- /** The person a file's owner names: their whole name, else a first name or initials only they have. */
16438
- function personNamed$1(people, name) {
16439
- const want = name.trim().toLowerCase();
16440
- if (!want) return null;
16441
- const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
16442
- if (exact.length === 1) return exact[0];
16443
- const first = people.filter((p) => p.name.trim().toLowerCase().split(/\s+/)[0] === want);
16444
- if (first.length === 1) return first[0];
16445
- const initials = people.filter((p) => p.initials.toLowerCase() === want);
16446
- return initials.length === 1 ? initials[0] : null;
16768
+ say(fogSummary(tally));
16769
+ return tally;
16447
16770
  }
16448
- /**
16449
- * The folder's changes to one task, worked out against the base: a field
16450
- * the file changed and the harbor didn't goes to the harbor; one both
16451
- * changed differently stays as the harbor has it, and is named.
16452
- */
16453
- function mergeTask(base, file, harbor) {
16454
- const patch = {};
16455
- const clashes = [];
16456
- for (const k of FIELDS) {
16457
- if (same(file[k], base[k]) || same(file[k], harbor[k])) continue;
16458
- if (same(harbor[k], base[k])) patch[k] = file[k];
16459
- else clashes.push({
16460
- field: k,
16461
- file: shown(file[k]),
16462
- harbor: shown(harbor[k])
16463
- });
16464
- }
16465
- return {
16466
- patch,
16467
- clashes
16468
- };
16469
- }
16470
- /** What a set of file fields means as a change to a task in this harbor. */
16471
- function taskPatch(data, fields, say, label) {
16472
- const patch = {};
16473
- if (fields.title !== void 0) patch.title = fields.title;
16474
- if (fields.status !== void 0) patch.status = fields.status;
16475
- if (fields.due !== void 0) patch.dueDate = fields.due;
16476
- if (fields.priority !== void 0) patch.priority = fields.priority;
16477
- if (fields.repeat !== void 0) patch.repeat = fields.repeat;
16478
- if (fields.notes !== void 0) patch.description = fields.notes;
16479
- if (fields.owner !== void 0) {
16480
- const person = fields.owner ? personNamed$1(data.people, fields.owner) : null;
16481
- if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
16482
- patch.assigneeId = person?.id ?? null;
16483
- }
16484
- if (fields.waits !== void 0) {
16485
- const ids = [];
16486
- for (const key of fields.waits) {
16487
- const t = findByKey(data, key);
16488
- if (t) ids.push(t.id);
16489
- else say(`${label}: there's no ${key} to wait on, so that was left out.`);
16490
- }
16491
- patch.blockedByIds = ids;
16492
- patch.blockedById = ids[0] ?? null;
16493
- }
16494
- return patch;
16495
- }
16496
- /** The base: what each of a project's tasks said when the folder last matched the harbor. */
16497
- function baseOf(data, projectId) {
16498
- const base = {};
16499
- for (const t of data.tasks) if (t.projectId === projectId) base[t.id] = fieldsOf(data, t);
16500
- return base;
16501
- }
16502
- /** A person a local plan adds for an owner it hasn't met. */
16503
- function localPerson(name, id) {
16504
- return {
16505
- id,
16506
- name,
16507
- role: "Crew",
16508
- initials: initialsOf(name),
16509
- swatch: "pine",
16510
- location: ""
16511
- };
16512
- }
16513
- /** `.harbor/README.md`: what the folder is, for whoever finds it in the repository. */
16514
- function readme(config) {
16515
- return `# Plan
16516
-
16517
- 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.`}
16518
-
16519
- Each file has its fields between the \`---\` lines and its notes below:
16520
-
16521
- \`\`\`md
16522
- ---
16523
- title: Move checkout to the new API
16524
- status: doing
16525
- owner: Sam Rivera
16526
- due: 2026-11-07
16527
- priority: high
16528
- waits: [${config.project.key}-2]
16529
- ---
16530
- Old endpoints go away on Nov 20.
16531
- \`\`\`
16532
-
16533
- - status: backlog, todo, doing, review or done
16534
- - priority: low, medium, high or urgent
16535
- - repeat (optional): weekly, biweekly or monthly
16536
- - A new file with any name is a new task; Harbor gives it a key and renames it.
16537
- - Removing a file doesn't remove the task. Mark it done instead.
16538
- `;
16539
- }
16540
- var FIELDS, TASKS_DIR, CONFIG_FILE, BASE_FILE, LOCAL_FILE, STATUS_WORDS, PRIORITIES, REPEATS, KEY_NAME, same, shown;
16541
- var init_plan_files = __esmMin((() => {
16542
- init_shared();
16543
- init_indexes();
16544
- init_task_keys();
16545
- FIELDS = [
16546
- "title",
16547
- "status",
16548
- "owner",
16549
- "due",
16550
- "priority",
16551
- "waits",
16552
- "repeat",
16553
- "notes"
16554
- ];
16555
- TASKS_DIR = "tasks";
16556
- CONFIG_FILE = "harbor.json";
16557
- BASE_FILE = "state/base.json";
16558
- LOCAL_FILE = "plan.json";
16559
- STATUS_WORDS = {
16560
- backlog: "backlog",
16561
- todo: "todo",
16562
- "to-do": "todo",
16563
- "to do": "todo",
16564
- doing: "doing",
16565
- "in-progress": "doing",
16566
- "in progress": "doing",
16567
- started: "doing",
16568
- review: "review",
16569
- "in-review": "review",
16570
- "in review": "review",
16571
- done: "done",
16572
- finished: "done"
16573
- };
16574
- PRIORITIES = [
16575
- "low",
16576
- "medium",
16577
- "high",
16578
- "urgent"
16579
- ];
16580
- REPEATS = [
16581
- "weekly",
16582
- "biweekly",
16583
- "monthly"
16584
- ];
16585
- KEY_NAME = /^([A-Z][A-Z0-9]{0,9}-\d{1,7})\.md$/;
16586
- same = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.join(",") === b.join(",") : a === b;
16587
- shown = (v) => Array.isArray(v) ? v.join(", ") || "nothing" : v === null || v === "" ? "nothing" : String(v);
16588
- }));
16589
-
16590
- //#endregion
16591
- //#region src/repo/folder.ts
16592
- /**
16593
- * The `.harbor/` folder on disk, and finding it: like git, `harbor sync`
16594
- * works from anywhere inside the repository.
16595
- */
16596
- function diskFolder(root) {
16597
- return {
16598
- async read(path) {
16599
- try {
16600
- return await readFile(join(root, path), "utf8");
16601
- } catch (error) {
16602
- if (error.code === "ENOENT") return null;
16603
- throw error;
16604
- }
16605
- },
16606
- async write(path, text) {
16607
- const file = join(root, path);
16608
- await mkdir(dirname(file), { recursive: true });
16609
- const temp = `${file}.${process.pid}.tmp`;
16610
- await writeFile(temp, text);
16611
- await rename(temp, file);
16612
- },
16613
- async remove(path) {
16614
- await rm(join(root, path), { force: true });
16615
- },
16616
- async list(dir) {
16617
- try {
16618
- return (await readdir(join(root, dir), { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name);
16619
- } catch (error) {
16620
- if (error.code === "ENOENT") return [];
16621
- throw error;
16622
- }
16623
- }
16624
- };
16625
- }
16626
- /** The nearest `.harbor/` with a plan in it, from `start` up; null when there's none. */
16627
- async function findPlan(start) {
16628
- let dir = resolve(start);
16629
- for (;;) {
16630
- const here = join(dir, PLAN_DIR);
16631
- try {
16632
- await readFile(join(here, CONFIG_FILE));
16633
- return here;
16634
- } catch {}
16635
- const up = dirname(dir);
16636
- if (up === dir) return null;
16637
- dir = up;
16638
- }
16639
- }
16640
- /** Where a new plan goes: the repository's top (the folder holding .git), else here. */
16641
- async function planHome(start) {
16642
- let dir = resolve(start);
16643
- for (;;) {
16644
- try {
16645
- await access(join(dir, ".git"));
16646
- return dir;
16647
- } catch {}
16648
- const up = dirname(dir);
16649
- if (up === dir) return resolve(start);
16650
- dir = up;
16651
- }
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.";
16652
16782
  }
16653
- var PLAN_DIR;
16654
- var init_folder = __esmMin((() => {
16655
- init_plan_files();
16656
- PLAN_DIR = ".harbor";
16657
- }));
16658
16783
 
16659
16784
  //#endregion
16660
16785
  //#region src/tui/engine/memory-crew.ts
@@ -16992,14 +17117,297 @@ function memoryHarbor(start, opts) {
16992
17117
  }
16993
17118
  };
16994
17119
  }
16995
- var init_memory = __esmMin((() => {
16996
- init_changes();
16997
- init_live_changes();
16998
- init_row_changes();
16999
- 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();
17000
17366
  init_indexes();
17001
- init_memory_crew();
17002
- 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);
17003
17411
  }));
17004
17412
 
17005
17413
  //#endregion
@@ -17519,6 +17927,75 @@ var init_repo_sync = __esmMin((() => {
17519
17927
  };
17520
17928
  }));
17521
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
+
17522
17999
  //#endregion
17523
18000
  //#region src/repo/commands.ts
17524
18001
  /**
@@ -23121,6 +23598,7 @@ function voyageLog(data, projectId) {
23121
23598
  guessed: true
23122
23599
  };
23123
23600
  });
23601
+ const decisions = raid.filter((r) => r.kind === "decision" && r.status !== "proposed").sort((a, b) => (a.decidedAt ?? a.createdAt).localeCompare(b.decidedAt ?? b.createdAt));
23124
23602
  const counts = /* @__PURE__ */ new Map();
23125
23603
  for (const t of tasks) if (t.status === "done" && t.assigneeId) counts.set(t.assigneeId, (counts.get(t.assigneeId) ?? 0) + 1);
23126
23604
  const people = new Map(data.people.map((p) => [p.id, p]));
@@ -23142,6 +23620,7 @@ function voyageLog(data, projectId) {
23142
23620
  slips,
23143
23621
  risks,
23144
23622
  issues: issues.length,
23623
+ decisions,
23145
23624
  carriers,
23146
23625
  budget: project.budget,
23147
23626
  spent: project.spent,
@@ -23189,6 +23668,10 @@ function voyageStory(log) {
23189
23668
  out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md(s.waitedOn.completedAt)}.` : `${late}.`);
23190
23669
  }
23191
23670
  for (const r of log.risks.filter((x) => x.happened)) out.push(`The risk “${r.risk.title}” came true${r.guessed ? " (an issue names it)" : ""}.`);
23671
+ if (log.decisions.length) {
23672
+ const changed = log.decisions.filter((d) => d.status === "superseded").length;
23673
+ out.push(`${plural(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
23674
+ }
23192
23675
  const top = log.carriers[0];
23193
23676
  if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural(top.closed, "task")}, the most on the crew.`);
23194
23677
  else if (top) out.push(`${top.person.name} closed ${plural(top.closed, "task")}.`);
@@ -23233,12 +23716,14 @@ init_hill();
23233
23716
  init_raid();
23234
23717
  init_priority();
23235
23718
  init_premortem();
23719
+ init_decisions();
23236
23720
  init_search();
23237
23721
  init_derived_health();
23238
23722
  init_format();
23239
23723
  init_date_text();
23240
23724
  init_handover();
23241
23725
  init_work();
23726
+ init_fog();
23242
23727
  init_board();
23243
23728
  /**
23244
23729
  * Where the website opens one task or project, switching to its harbor
@@ -23440,10 +23925,7 @@ function raidOut(data, r) {
23440
23925
  ...text("contingency"),
23441
23926
  ...text("resolution"),
23442
23927
  ...text("rationale"),
23443
- ...r.kind === "decision" ? {
23444
- decidedBy: nameOf(people, r.decidedById),
23445
- decidedOn: dayOf(r.decidedAt)
23446
- } : {},
23928
+ ...r.kind === "decision" ? decisionOut(data, r, people) : {},
23447
23929
  ...text("notes"),
23448
23930
  updates: r.updates.map((u) => ({
23449
23931
  by: nameOf(people, u.authorId) ?? "Someone",
@@ -23452,6 +23934,39 @@ function raidOut(data, r) {
23452
23934
  }))
23453
23935
  };
23454
23936
  }
23937
+ /** A decision's own fields: who and when, what was turned down, what it touches, when to look again. */
23938
+ function decisionOut(data, r, people) {
23939
+ const tasks = byId(data.tasks);
23940
+ const items = byId(data.raidItems);
23941
+ const touches = [...(r.taskIds ?? []).flatMap((id) => {
23942
+ const t = tasks.get(id);
23943
+ if (!t) return [];
23944
+ const key = taskKey(t, data.projects.find((p) => p.id === t.projectId));
23945
+ return [key ? `${key} ${t.title}` : t.title];
23946
+ }), ...(r.riskIds ?? []).flatMap((id) => {
23947
+ const x = items.get(id);
23948
+ return x ? [`${KIND_LABEL[x.kind]}: ${x.title}`] : [];
23949
+ })];
23950
+ const older = r.supersedesId ? items.get(r.supersedesId) : null;
23951
+ const newer = supersededBy(data, r.id);
23952
+ const due = revisitReason(r, data, todayISO());
23953
+ return {
23954
+ decidedBy: nameOf(people, r.decidedById),
23955
+ decidedOn: dayOf(r.decidedAt),
23956
+ ...r.turnedDown?.trim() ? { turnedDown: r.turnedDown.trim() } : {},
23957
+ ...touches.length ? { touches } : {},
23958
+ ...r.revisitIf ? { revisitIf: revisitText(r.revisitIf, data) } : {},
23959
+ ...due ? { lookAgainNow: due } : {},
23960
+ ...older ? { replaces: {
23961
+ id: older.id,
23962
+ title: older.title
23963
+ } } : {},
23964
+ ...newer ? { replacedBy: {
23965
+ id: newer.id,
23966
+ title: newer.title
23967
+ } } : {}
23968
+ };
23969
+ }
23455
23970
  const tool = (t) => t;
23456
23971
  /** A project's notes for later, as get_project gives them: sealed ones without their text unless they're the caller's. */
23457
23972
  function notesOf(ctx, p) {
@@ -24089,6 +24604,12 @@ const HARBOR_TOOLS = [
24089
24604
  cameTrue: r.happened,
24090
24605
  ...r.guessed ? { guessed: true } : {}
24091
24606
  })),
24607
+ decisions: log.decisions.map((d) => ({
24608
+ title: d.title,
24609
+ on: dayOf(d.decidedAt),
24610
+ ...d.rationale?.trim() ? { why: d.rationale.trim() } : {},
24611
+ ...d.status === "superseded" ? { changedLater: true } : {}
24612
+ })),
24092
24613
  closedBy: log.carriers.map((c) => ({
24093
24614
  who: c.person.name,
24094
24615
  tasks: c.closed
@@ -24192,6 +24713,38 @@ const HARBOR_TOOLS = [
24192
24713
  };
24193
24714
  }
24194
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
+ }),
24195
24748
  tool({
24196
24749
  name: "handover_plan",
24197
24750
  title: "What handing someone's work over would move",
@@ -24251,61 +24804,6 @@ const LIST_HARBORS = {
24251
24804
  input: object({})
24252
24805
  };
24253
24806
 
24254
- //#endregion
24255
- //#region src/lib/command-line.ts
24256
- init_date_fns();
24257
- init_format();
24258
- const PAGES = {
24259
- today: "/today",
24260
- inbox: "/inbox",
24261
- brief: "/brief",
24262
- review: "/review",
24263
- portfolios: "/portfolios",
24264
- programs: "/programs",
24265
- projects: "/projects",
24266
- risks: "/risks",
24267
- calendar: "/calendar",
24268
- people: "/people"
24269
- };
24270
- const HELP = [
24271
- { text: "add \"TITLE\" [#project] [@person] [--due fri|+3|2026-11-02]" },
24272
- { text: "mine what's on you, late first" },
24273
- { text: "tasks [words] [--late] [#project] [@person]" },
24274
- { text: "show RR-12 open a task" },
24275
- { text: "start | review | done RR-12" },
24276
- { text: "comment RR-12 \"TEXT\"" },
24277
- { text: "checkin \"WHAT YOU'RE ON\" [--on-blocker]" },
24278
- { text: `go ${Object.keys(PAGES).join("|")}` },
24279
- {
24280
- text: "undo · clear · help",
24281
- tone: "muted"
24282
- }
24283
- ];
24284
- var CommandError = class extends Error {};
24285
- const WEEKDAYS = [
24286
- "sun",
24287
- "mon",
24288
- "tue",
24289
- "wed",
24290
- "thu",
24291
- "fri",
24292
- "sat"
24293
- ];
24294
- /** today, tomorrow, +N, a weekday (the next one, never today) or YYYY-MM-DD. */
24295
- function dueFrom(value, now) {
24296
- const v = value.trim().toLowerCase();
24297
- if (v === "today") return todayISO(now);
24298
- if (v === "tomorrow") return todayISO(addDays(now, 1));
24299
- if (/^\+\d{1,3}$/.test(v)) return todayISO(addDays(now, Number(v.slice(1))));
24300
- const day = WEEKDAYS.findIndex((d) => v.startsWith(d) && d.length >= 3 && v.length >= 3);
24301
- if (day >= 0) {
24302
- const ahead = (day - now.getDay() + 7) % 7 || 7;
24303
- return todayISO(addDays(now, ahead));
24304
- }
24305
- if (/^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(parseDate$1(v).getTime())) return v;
24306
- throw new CommandError(`Can't read the date "${value}". Try today, fri, +3 or 2026-11-02.`);
24307
- }
24308
-
24309
24807
  //#endregion
24310
24808
  //#region src/lib/dry-dock.ts
24311
24809
  init_date_fns();
@@ -24849,6 +25347,8 @@ init_transport();
24849
25347
  init_errors();
24850
25348
  init_plain();
24851
25349
  init_search();
25350
+ init_view_only();
25351
+ init_repo_sync();
24852
25352
  const today = () => format(/* @__PURE__ */ new Date(), "yyyy-MM-dd");
24853
25353
  /** How long `harbor` shows its splash at the least, however fast the harbor loads. */
24854
25354
  const SPLASH_MS = 1200;
@@ -24982,8 +25482,58 @@ async function runApp(opts) {
24982
25482
  }, { alternateScreen: true });
24983
25483
  await instance.waitUntilExit();
24984
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
+ }
24985
25535
  /** Questions asked on the plain terminal, before the app takes it over. Ctrl+C stops. */
24986
- function terminalAsk() {
25536
+ function terminalAsk(stoppedText = "Stopped. Nothing was made.") {
24987
25537
  const rl = createInterface({
24988
25538
  input: process.stdin,
24989
25539
  output: process.stdout
@@ -24991,7 +25541,7 @@ function terminalAsk() {
24991
25541
  let stop = () => {};
24992
25542
  const stopped = new Promise((_, reject) => stop = reject);
24993
25543
  stopped.catch(() => {});
24994
- const nothing = () => stop(new HarborError("Stopped. Nothing was made."));
25544
+ const nothing = () => stop(new HarborError(stoppedText));
24995
25545
  rl.on("SIGINT", nothing);
24996
25546
  rl.on("close", nothing);
24997
25547
  return {
@@ -25023,4 +25573,4 @@ function planTransport(source, fetcher) {
25023
25573
  }
25024
25574
 
25025
25575
  //#endregion
25026
- 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 };