@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/README.md +4 -0
- package/dist/app.mjs +785 -474
- package/lib/cli.mjs +51 -1
- package/lib/format.mjs +22 -0
- package/package.json +1 -1
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
|
-
|
|
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/
|
|
16505
|
-
|
|
16506
|
-
|
|
16507
|
-
|
|
16508
|
-
|
|
16509
|
-
|
|
16510
|
-
|
|
16511
|
-
|
|
16512
|
-
|
|
16513
|
-
|
|
16514
|
-
|
|
16515
|
-
|
|
16516
|
-
|
|
16517
|
-
|
|
16518
|
-
|
|
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
|
-
|
|
16521
|
-
|
|
16522
|
-
|
|
16523
|
-
|
|
16524
|
-
|
|
16525
|
-
|
|
16526
|
-
|
|
16527
|
-
|
|
16528
|
-
|
|
16529
|
-
|
|
16530
|
-
|
|
16531
|
-
|
|
16532
|
-
|
|
16533
|
-
|
|
16534
|
-
|
|
16535
|
-
|
|
16536
|
-
|
|
16537
|
-
|
|
16538
|
-
if (
|
|
16539
|
-
|
|
16540
|
-
|
|
16541
|
-
|
|
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
|
-
*
|
|
16545
|
-
*
|
|
16546
|
-
*
|
|
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
|
-
|
|
16549
|
-
|
|
16550
|
-
|
|
16551
|
-
const
|
|
16552
|
-
|
|
16553
|
-
|
|
16554
|
-
|
|
16555
|
-
|
|
16556
|
-
|
|
16557
|
-
|
|
16558
|
-
|
|
16559
|
-
|
|
16560
|
-
|
|
16561
|
-
|
|
16562
|
-
|
|
16563
|
-
|
|
16564
|
-
|
|
16565
|
-
|
|
16566
|
-
|
|
16567
|
-
|
|
16568
|
-
|
|
16569
|
-
|
|
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
|
-
|
|
16577
|
-
|
|
16578
|
-
|
|
16579
|
-
|
|
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
|
|
16582
|
-
|
|
16583
|
-
|
|
16584
|
-
|
|
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
|
|
16587
|
-
|
|
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
|
-
|
|
17198
|
-
|
|
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(
|
|
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 };
|