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