@harborppm/cli 0.54.0 → 0.55.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 +8 -1
- package/dist/app.mjs +260 -11
- package/lib/cli.mjs +103 -9
- package/lib/format.mjs +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -149,7 +149,8 @@ harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
|
149
149
|
--from: dead reckoning, its tasks due when
|
|
150
150
|
they really finished in that project
|
|
151
151
|
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
|
|
152
|
-
[--priority low|medium|high|urgent] [--status S] [--
|
|
152
|
+
[--priority low|medium|high|urgent] [--status S] [--comment TEXT]
|
|
153
|
+
[--over-limit] no TITLE: one title a line from stdin
|
|
153
154
|
harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
|
|
154
155
|
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
155
156
|
[--priority P] [--status S] [--notes TEXT]
|
|
@@ -189,6 +190,8 @@ harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
|
189
190
|
harbor sound TASK [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
|
|
190
191
|
harbor soundings [PROJECT]
|
|
191
192
|
harbor limit PROJECT [COLUMN N|none]
|
|
193
|
+
harbor week [--next|--last|--week DATE] [--review]
|
|
194
|
+
harbor week TASK DAY [--hours N] | week TASK --off [DAY] | week --day-hours N
|
|
192
195
|
harbor init [PROJECT] [--hook] [--dry-run] a project's tasks as files in this repository
|
|
193
196
|
harbor init --local [NAME] a plan in this folder alone, no account
|
|
194
197
|
harbor sync [--quiet] [--dry-run] the files and the website, in step both ways
|
|
@@ -211,6 +214,10 @@ harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
|
211
214
|
|
|
212
215
|
Agents on the crew: put an AI app on People with Add an agent and assign it tasks like anyone else. An agent working through the CLI (Claude Code, say) runs `harbor agent` for its tasks, in progress first, with their notes, checklists and latest comments. When it's done, `harbor agent report RR-4 "Drafted 12 questions; two need Dana"` adds its note as the agent's comment and moves the task to In review for a person to check. An agent never marks its own work Done.
|
|
213
216
|
|
|
217
|
+
`harbor week` is your own week, and only you see it: Monday to Friday with the tasks you put on each day, the hours each should take against the hours you work (`--day-hours 8`; 7 unless you say), a warning on a day that's too full, and your open tasks not on a day yet. `harbor week RR-42 wed --hours 3` puts a task on Wednesday (`--next` for next week), `harbor week RR-42 --off` takes it off the week, and from Friday (or with `--review`) it says what you planned against what you finished. The website's Your week is the same plan, with drag and drop.
|
|
218
|
+
|
|
219
|
+
`harbor add --project Trail --comment "From Monday's call" < list.txt` adds one task per line of the file, each with the comment.
|
|
220
|
+
|
|
214
221
|
`harbor limit RR` prints how many tasks each of a board's columns takes at once; `harbor limit RR doing 8` changes one (1 to 20, or `none` for no limit), if you own the harbor. `harbor projects --json` carries each board's `limits` too.
|
|
215
222
|
|
|
216
223
|
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first.
|
package/dist/app.mjs
CHANGED
|
@@ -4126,7 +4126,7 @@ function roadmapWindow(zoom, today, shift = 0) {
|
|
|
4126
4126
|
};
|
|
4127
4127
|
}
|
|
4128
4128
|
/** Where a day falls in the window, 0–1, or null outside it. */
|
|
4129
|
-
function at(iso, w) {
|
|
4129
|
+
function at$1(iso, w) {
|
|
4130
4130
|
if (!iso) return null;
|
|
4131
4131
|
if (iso < w.start || iso >= w.end) return null;
|
|
4132
4132
|
return differenceInCalendarDays(day$2(iso), day$2(w.start)) / w.days;
|
|
@@ -4765,7 +4765,7 @@ function roadmapCells(start, end, milestones, w, width) {
|
|
|
4765
4765
|
if (s.clippedEnd) cells[b - 1] = "›";
|
|
4766
4766
|
}
|
|
4767
4767
|
for (const m of milestones) {
|
|
4768
|
-
const pos = at(m.due, w);
|
|
4768
|
+
const pos = at$1(m.due, w);
|
|
4769
4769
|
if (pos == null) continue;
|
|
4770
4770
|
cells[Math.min(width - 1, Math.floor(pos * width))] = m.done ? "◇" : "◆";
|
|
4771
4771
|
}
|
|
@@ -5726,7 +5726,7 @@ function programIsBlocked(data, program) {
|
|
|
5726
5726
|
}
|
|
5727
5727
|
function daysOverdue(dueDate, now) {
|
|
5728
5728
|
if (!dueDate) return 0;
|
|
5729
|
-
const today = iso$
|
|
5729
|
+
const today = iso$3(now);
|
|
5730
5730
|
if (today !== overdueDay) {
|
|
5731
5731
|
overdueDay = today;
|
|
5732
5732
|
overdueByDue.clear();
|
|
@@ -5745,7 +5745,7 @@ function daysOverdue(dueDate, now) {
|
|
|
5745
5745
|
}
|
|
5746
5746
|
function daysUntil$1(date, now) {
|
|
5747
5747
|
if (!date) return null;
|
|
5748
|
-
const today = iso$
|
|
5748
|
+
const today = iso$3(now);
|
|
5749
5749
|
if (today !== untilDay) {
|
|
5750
5750
|
untilDay = today;
|
|
5751
5751
|
untilByDate.clear();
|
|
@@ -5810,7 +5810,7 @@ function effectiveProjectHealth(project, data, now) {
|
|
|
5810
5810
|
}
|
|
5811
5811
|
/** Milestones past their date and still open. */
|
|
5812
5812
|
function lateMilestonesOf(milestones, now) {
|
|
5813
|
-
const today = iso$
|
|
5813
|
+
const today = iso$3(now);
|
|
5814
5814
|
return milestones.filter((m) => !m.done && m.dueDate < today);
|
|
5815
5815
|
}
|
|
5816
5816
|
/**
|
|
@@ -5915,7 +5915,7 @@ function programLateCount(programId, data, now) {
|
|
|
5915
5915
|
for (const p of data.projects) if (p.programId === programId) late += lateTasksOf([...tasksOf(data.tasks, p.id)], now).length;
|
|
5916
5916
|
return late;
|
|
5917
5917
|
}
|
|
5918
|
-
var iso$
|
|
5918
|
+
var iso$3, overdueDay, overdueByDue, untilDay, untilByDate;
|
|
5919
5919
|
var init_derived_health = __esmMin((() => {
|
|
5920
5920
|
init_date_fns();
|
|
5921
5921
|
init_date_text();
|
|
@@ -5923,7 +5923,7 @@ var init_derived_health = __esmMin((() => {
|
|
|
5923
5923
|
init_indexes();
|
|
5924
5924
|
init_health_history();
|
|
5925
5925
|
init_clock();
|
|
5926
|
-
iso$
|
|
5926
|
+
iso$3 = (d) => {
|
|
5927
5927
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
5928
5928
|
};
|
|
5929
5929
|
overdueDay = "";
|
|
@@ -8647,10 +8647,10 @@ function InboxView({ active, width, height, go }) {
|
|
|
8647
8647
|
/** Open tasks for one person, bucketed by the week they are due, plus what is already late or has no date. */
|
|
8648
8648
|
function weeklyLoad(tasks, personId, now, weeks = 6) {
|
|
8649
8649
|
const mine = tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
|
|
8650
|
-
const today = iso$
|
|
8650
|
+
const today = iso$2(now);
|
|
8651
8651
|
const origin = startOfWeek(now, { weekStartsOn: 1 });
|
|
8652
8652
|
const buckets = Array.from({ length: weeks }, (_, i) => ({
|
|
8653
|
-
start: iso$
|
|
8653
|
+
start: iso$2(addDays(origin, i * 7)),
|
|
8654
8654
|
due: 0
|
|
8655
8655
|
}));
|
|
8656
8656
|
let overdue = 0;
|
|
@@ -8674,10 +8674,10 @@ function weeklyLoad(tasks, personId, now, weeks = 6) {
|
|
|
8674
8674
|
weeks: buckets
|
|
8675
8675
|
};
|
|
8676
8676
|
}
|
|
8677
|
-
var iso$
|
|
8677
|
+
var iso$2;
|
|
8678
8678
|
var init_load = __esmMin((() => {
|
|
8679
8679
|
init_date_fns();
|
|
8680
|
-
iso$
|
|
8680
|
+
iso$2 = (d) => {
|
|
8681
8681
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
8682
8682
|
};
|
|
8683
8683
|
}));
|
|
@@ -11722,6 +11722,7 @@ init_format();
|
|
|
11722
11722
|
const PAGES = {
|
|
11723
11723
|
today: "/today",
|
|
11724
11724
|
inbox: "/inbox",
|
|
11725
|
+
week: "/week",
|
|
11725
11726
|
brief: "/brief",
|
|
11726
11727
|
review: "/review",
|
|
11727
11728
|
portfolios: "/portfolios",
|
|
@@ -17803,6 +17804,142 @@ var init_fog = __esmMin((() => {
|
|
|
17803
17804
|
fileCache = /* @__PURE__ */ new WeakMap();
|
|
17804
17805
|
}));
|
|
17805
17806
|
|
|
17807
|
+
//#endregion
|
|
17808
|
+
//#region src/lib/passage.ts
|
|
17809
|
+
/** Hours as a person would say them: "2h", "1.5h", "30m". */
|
|
17810
|
+
function hoursText(h) {
|
|
17811
|
+
if (h > 0 && h < 1) return `${Math.round(h * 60)}m`;
|
|
17812
|
+
return `${Math.round(h * 10) / 10}h`;
|
|
17813
|
+
}
|
|
17814
|
+
/** Hours kept to the half hour, within what one day can take. */
|
|
17815
|
+
function clampHours(h) {
|
|
17816
|
+
if (!Number.isFinite(h)) return 2;
|
|
17817
|
+
return Math.min(12, Math.max(MIN_HOURS, Math.round(h * 2) / 2));
|
|
17818
|
+
}
|
|
17819
|
+
function dayHoursOf(person) {
|
|
17820
|
+
const h = person?.dayHours;
|
|
17821
|
+
return typeof h === "number" && h > 0 ? Math.min(16, h) : 7;
|
|
17822
|
+
}
|
|
17823
|
+
/** The Monday of the week a day falls in (a Saturday or Sunday looks ahead to the next week). */
|
|
17824
|
+
function weekOf(day = todayISO()) {
|
|
17825
|
+
const d = at(day);
|
|
17826
|
+
const wd = d.getDay();
|
|
17827
|
+
const back = wd === 0 ? -1 : wd === 6 ? -2 : wd - 1;
|
|
17828
|
+
return iso$1(addDays(d, -back));
|
|
17829
|
+
}
|
|
17830
|
+
/** Monday to Friday of the week starting `monday`. */
|
|
17831
|
+
function weekDays(monday) {
|
|
17832
|
+
const d = at(monday);
|
|
17833
|
+
return [
|
|
17834
|
+
0,
|
|
17835
|
+
1,
|
|
17836
|
+
2,
|
|
17837
|
+
3,
|
|
17838
|
+
4
|
|
17839
|
+
].map((i) => iso$1(addDays(d, i)));
|
|
17840
|
+
}
|
|
17841
|
+
function shiftWeek(monday, weeks) {
|
|
17842
|
+
return iso$1(addDays(at(monday), weeks * 7));
|
|
17843
|
+
}
|
|
17844
|
+
/** One person's placements, every week. */
|
|
17845
|
+
function placementsOf(data, personId) {
|
|
17846
|
+
return (data.reflections ?? []).filter((r) => isPassage(r) && r.authorId === personId);
|
|
17847
|
+
}
|
|
17848
|
+
/** The week as its owner plans it. Placements whose task is gone are left out. */
|
|
17849
|
+
function weekPlan(data, personId, monday) {
|
|
17850
|
+
const tasks = byId(data.tasks);
|
|
17851
|
+
const dates = weekDays(monday);
|
|
17852
|
+
const dayHours = dayHoursOf(data.people.find((p) => p.id === personId));
|
|
17853
|
+
const inWeek = placementsOf(data, personId).filter((r) => r.date && dates.includes(r.date) && r.taskId && tasks.has(r.taskId));
|
|
17854
|
+
const days = dates.map((date) => {
|
|
17855
|
+
const items = inWeek.filter((r) => r.date === date).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((r) => ({
|
|
17856
|
+
id: r.id,
|
|
17857
|
+
task: tasks.get(r.taskId),
|
|
17858
|
+
date,
|
|
17859
|
+
hours: clampHours(r.hours ?? 2)
|
|
17860
|
+
}));
|
|
17861
|
+
const hours = items.reduce((sum, i) => sum + i.hours, 0);
|
|
17862
|
+
return {
|
|
17863
|
+
date,
|
|
17864
|
+
items,
|
|
17865
|
+
hours,
|
|
17866
|
+
over: hours > dayHours
|
|
17867
|
+
};
|
|
17868
|
+
});
|
|
17869
|
+
const placed = new Set(inWeek.map((r) => r.taskId));
|
|
17870
|
+
const unplanned = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done" && !placed.has(t.id)).sort(byDue);
|
|
17871
|
+
return {
|
|
17872
|
+
monday,
|
|
17873
|
+
days,
|
|
17874
|
+
dayHours,
|
|
17875
|
+
planned: days.reduce((sum, d) => sum + d.hours, 0),
|
|
17876
|
+
capacity: dayHours * 5,
|
|
17877
|
+
unplanned
|
|
17878
|
+
};
|
|
17879
|
+
}
|
|
17880
|
+
/** Planned against done for a week: what was on the plan, what got finished, and what came in besides. */
|
|
17881
|
+
function weekReview(data, personId, monday) {
|
|
17882
|
+
const plan = weekPlan(data, personId, monday);
|
|
17883
|
+
const first = plan.days[0].date;
|
|
17884
|
+
const sunday = iso$1(addDays(at(monday), 6));
|
|
17885
|
+
const doneInWeek = (t) => t.status === "done" && Boolean(t.completedAt) && todayISO(new Date(t.completedAt)) >= first && todayISO(new Date(t.completedAt)) <= sunday;
|
|
17886
|
+
const hoursBy = /* @__PURE__ */ new Map();
|
|
17887
|
+
for (const d of plan.days) for (const i of d.items) {
|
|
17888
|
+
const had = hoursBy.get(i.task.id);
|
|
17889
|
+
hoursBy.set(i.task.id, {
|
|
17890
|
+
task: i.task,
|
|
17891
|
+
hours: (had?.hours ?? 0) + i.hours
|
|
17892
|
+
});
|
|
17893
|
+
}
|
|
17894
|
+
const planned = [...hoursBy.values()].map(({ task, hours }) => ({
|
|
17895
|
+
task,
|
|
17896
|
+
hours,
|
|
17897
|
+
done: task.status === "done" && (!task.completedAt || todayISO(new Date(task.completedAt)) <= sunday)
|
|
17898
|
+
}));
|
|
17899
|
+
const alsoDone = data.tasks.filter((t) => t.assigneeId === personId && doneInWeek(t) && !hoursBy.has(t.id)).sort((a, b) => (a.completedAt ?? "").localeCompare(b.completedAt ?? ""));
|
|
17900
|
+
return {
|
|
17901
|
+
monday,
|
|
17902
|
+
planned,
|
|
17903
|
+
doneCount: planned.filter((p) => p.done).length,
|
|
17904
|
+
alsoDone,
|
|
17905
|
+
plannedHours: plan.planned,
|
|
17906
|
+
doneHours: planned.filter((p) => p.done).reduce((sum, p) => sum + p.hours, 0)
|
|
17907
|
+
};
|
|
17908
|
+
}
|
|
17909
|
+
/** The one line the Friday view leads with. */
|
|
17910
|
+
function reviewText(r) {
|
|
17911
|
+
if (r.planned.length === 0) return r.alsoDone.length ? `Nothing was planned, and you finished ${r.alsoDone.length}.` : "Nothing was planned this week.";
|
|
17912
|
+
const also = r.alsoDone.length ? `, and ${r.alsoDone.length} more besides` : "";
|
|
17913
|
+
return `You finished ${r.doneCount} of the ${r.planned.length} you planned${also}.`;
|
|
17914
|
+
}
|
|
17915
|
+
/** Placements of this person's from before the weeks kept, which a new plan clears. */
|
|
17916
|
+
function stalePlacements(data, personId, today = todayISO()) {
|
|
17917
|
+
const cutoff = shiftWeek(weekOf(today), -12);
|
|
17918
|
+
return new Set(placementsOf(data, personId).filter((r) => (r.date ?? "") < cutoff).map((r) => r.id));
|
|
17919
|
+
}
|
|
17920
|
+
var DAY_HOURS, DEFAULT_HOURS, MIN_HOURS, MAX_HOURS, isPassage, iso$1, at, byDue, DAY_WORDS;
|
|
17921
|
+
var init_passage = __esmMin((() => {
|
|
17922
|
+
init_date_fns();
|
|
17923
|
+
init_date_text();
|
|
17924
|
+
init_format();
|
|
17925
|
+
init_indexes();
|
|
17926
|
+
DAY_HOURS = 7;
|
|
17927
|
+
DEFAULT_HOURS = 2;
|
|
17928
|
+
MIN_HOURS = .5;
|
|
17929
|
+
MAX_HOURS = 12;
|
|
17930
|
+
isPassage = (r) => r.kind === "passage";
|
|
17931
|
+
iso$1 = (d) => todayISO(d);
|
|
17932
|
+
at = (day) => parseIso(`${day}T12:00:00`);
|
|
17933
|
+
byDue = (a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999") || a.title.localeCompare(b.title);
|
|
17934
|
+
DAY_WORDS = [
|
|
17935
|
+
"mon",
|
|
17936
|
+
"tue",
|
|
17937
|
+
"wed",
|
|
17938
|
+
"thu",
|
|
17939
|
+
"fri"
|
|
17940
|
+
];
|
|
17941
|
+
}));
|
|
17942
|
+
|
|
17806
17943
|
//#endregion
|
|
17807
17944
|
//#region src/lib/mcp/tools.ts
|
|
17808
17945
|
init_task_keys();
|
|
@@ -17823,6 +17960,7 @@ init_shipping_forecast();
|
|
|
17823
17960
|
init_handover();
|
|
17824
17961
|
init_work();
|
|
17825
17962
|
init_fog();
|
|
17963
|
+
init_passage();
|
|
17826
17964
|
init_board();
|
|
17827
17965
|
init_soundings();
|
|
17828
17966
|
/**
|
|
@@ -18037,6 +18175,44 @@ function taskLine(t, people, projects, all) {
|
|
|
18037
18175
|
...waitingOn ? { waitingOn } : {}
|
|
18038
18176
|
};
|
|
18039
18177
|
}
|
|
18178
|
+
/** One person's week plan as the agent tools give it (get_week, plan_week). */
|
|
18179
|
+
function weekOut(data, personId, monday) {
|
|
18180
|
+
const people = byId(data.people);
|
|
18181
|
+
const projects = byId(data.projects);
|
|
18182
|
+
const plan = weekPlan(data, personId, monday);
|
|
18183
|
+
const review = weekReview(data, personId, monday);
|
|
18184
|
+
return {
|
|
18185
|
+
week: monday,
|
|
18186
|
+
dayHours: plan.dayHours,
|
|
18187
|
+
planned: plan.planned,
|
|
18188
|
+
capacity: plan.capacity,
|
|
18189
|
+
days: plan.days.map((d, i) => ({
|
|
18190
|
+
date: d.date,
|
|
18191
|
+
day: DAY_WORDS[i],
|
|
18192
|
+
hours: d.hours,
|
|
18193
|
+
...d.over ? {
|
|
18194
|
+
over: true,
|
|
18195
|
+
overBy: hoursText(d.hours - plan.dayHours)
|
|
18196
|
+
} : {},
|
|
18197
|
+
tasks: d.items.map((item) => ({
|
|
18198
|
+
placement: item.id,
|
|
18199
|
+
hours: item.hours,
|
|
18200
|
+
...taskLine(item.task, people, projects, data.tasks)
|
|
18201
|
+
}))
|
|
18202
|
+
})),
|
|
18203
|
+
unplanned: plan.unplanned.slice(0, 25).map((t) => taskLine(t, people, projects, data.tasks)),
|
|
18204
|
+
unplannedCount: plan.unplanned.length,
|
|
18205
|
+
review: {
|
|
18206
|
+
summary: reviewText(review),
|
|
18207
|
+
planned: review.planned.length,
|
|
18208
|
+
done: review.doneCount,
|
|
18209
|
+
plannedHours: review.plannedHours,
|
|
18210
|
+
doneHours: review.doneHours,
|
|
18211
|
+
stillOpen: review.planned.filter((p) => !p.done).map((p) => taskLine(p.task, people, projects, data.tasks)),
|
|
18212
|
+
alsoDone: review.alsoDone.map((t) => taskLine(t, people, projects, data.tasks))
|
|
18213
|
+
}
|
|
18214
|
+
};
|
|
18215
|
+
}
|
|
18040
18216
|
function raidScore(r) {
|
|
18041
18217
|
if (r.kind === "risk") return (r.probability ?? 3) * (r.impact ?? 3);
|
|
18042
18218
|
return (r.impact ?? 3) * 3;
|
|
@@ -18516,6 +18692,23 @@ const HARBOR_TOOLS = [
|
|
|
18516
18692
|
};
|
|
18517
18693
|
}
|
|
18518
18694
|
}),
|
|
18695
|
+
tool({
|
|
18696
|
+
name: "get_week",
|
|
18697
|
+
title: "My week's plan",
|
|
18698
|
+
description: "The person's own week plan (passage plan): their tasks laid on Monday to Friday with the hours each should take, against the hours they work a day (over: the day is fuller than that), the open tasks of theirs not yet on a day, and how the week went (planned against done, and what else they finished). Only ever the signed-in person's own; nobody else's plan can be read. week is any day in the week; it defaults to this one (a weekend looks at the next)." + DATA_NOTE,
|
|
18699
|
+
input: object({
|
|
18700
|
+
harbor: harborArg,
|
|
18701
|
+
week: string().regex(/^\d{4}-\d{2}-\d{2}$/, "a date as YYYY-MM-DD").optional().describe("Any day in the week, YYYY-MM-DD.")
|
|
18702
|
+
}),
|
|
18703
|
+
run: (ctx, input) => {
|
|
18704
|
+
const mine = me(ctx);
|
|
18705
|
+
if (!mine) return {
|
|
18706
|
+
you: null,
|
|
18707
|
+
note: "You aren't one of this harbor's people yet, so there's no week to plan."
|
|
18708
|
+
};
|
|
18709
|
+
return weekOut(ctx.data, mine.id, weekOf(input.week ?? todayISO(ctx.now)));
|
|
18710
|
+
}
|
|
18711
|
+
}),
|
|
18519
18712
|
tool({
|
|
18520
18713
|
name: "weekly_brief",
|
|
18521
18714
|
title: "Weekly brief",
|
|
@@ -21228,6 +21421,60 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
21228
21421
|
createdAt: stamp()
|
|
21229
21422
|
};
|
|
21230
21423
|
write({ reflections: [...all, row] });
|
|
21424
|
+
},
|
|
21425
|
+
planTask: (taskId, date, hours) => {
|
|
21426
|
+
const s = get();
|
|
21427
|
+
const me = s.currentUserId;
|
|
21428
|
+
const task = s.tasks.find((t) => t.id === taskId);
|
|
21429
|
+
if (!task || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
|
21430
|
+
const all = s.reflections ?? [];
|
|
21431
|
+
const same = all.find((r) => isPassage(r) && r.authorId === me && r.taskId === taskId && r.date === date);
|
|
21432
|
+
if (same) {
|
|
21433
|
+
if (hours !== void 0) get().setPlacementHours(same.id, hours);
|
|
21434
|
+
return same.id;
|
|
21435
|
+
}
|
|
21436
|
+
const stale = stalePlacements(s, me, date < todayISO() ? date : todayISO());
|
|
21437
|
+
const row = {
|
|
21438
|
+
id: uid("rf"),
|
|
21439
|
+
projectId: task.projectId,
|
|
21440
|
+
kind: "passage",
|
|
21441
|
+
authorId: me,
|
|
21442
|
+
body: "",
|
|
21443
|
+
createdAt: stamp(),
|
|
21444
|
+
taskId,
|
|
21445
|
+
date,
|
|
21446
|
+
hours: clampHours(hours ?? 2)
|
|
21447
|
+
};
|
|
21448
|
+
write({ reflections: [...all.filter((r) => !stale.has(r.id)), row] });
|
|
21449
|
+
return row.id;
|
|
21450
|
+
},
|
|
21451
|
+
movePlacement: (id, date) => {
|
|
21452
|
+
const me = get().currentUserId;
|
|
21453
|
+
const all = get().reflections ?? [];
|
|
21454
|
+
const row = all.find((r) => r.id === id && isPassage(r) && r.authorId === me);
|
|
21455
|
+
if (!row || row.date === date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return;
|
|
21456
|
+
const there = all.find((r) => isPassage(r) && r.authorId === me && r.taskId === row.taskId && r.date === date);
|
|
21457
|
+
write({ reflections: there ? all.filter((r) => r.id !== id).map((r) => r.id === there.id ? {
|
|
21458
|
+
...r,
|
|
21459
|
+
hours: clampHours((r.hours ?? 0) + (row.hours ?? 0))
|
|
21460
|
+
} : r) : all.map((r) => r.id === id ? {
|
|
21461
|
+
...r,
|
|
21462
|
+
date
|
|
21463
|
+
} : r) });
|
|
21464
|
+
},
|
|
21465
|
+
setPlacementHours: (id, hours) => {
|
|
21466
|
+
const me = get().currentUserId;
|
|
21467
|
+
const h = clampHours(hours);
|
|
21468
|
+
write({ reflections: (get().reflections ?? []).map((r) => r.id === id && isPassage(r) && r.authorId === me && r.hours !== h ? {
|
|
21469
|
+
...r,
|
|
21470
|
+
hours: h
|
|
21471
|
+
} : r) });
|
|
21472
|
+
},
|
|
21473
|
+
unplan: (id) => {
|
|
21474
|
+
const me = get().currentUserId;
|
|
21475
|
+
const all = get().reflections ?? [];
|
|
21476
|
+
if (!all.some((r) => r.id === id && isPassage(r) && r.authorId === me)) return;
|
|
21477
|
+
write({ reflections: all.filter((r) => r.id !== id) });
|
|
21231
21478
|
}
|
|
21232
21479
|
};
|
|
21233
21480
|
}
|
|
@@ -21238,6 +21485,8 @@ var init_milestones_raid = __esmMin((() => {
|
|
|
21238
21485
|
init_raid();
|
|
21239
21486
|
init_decisions();
|
|
21240
21487
|
init_raid_history();
|
|
21488
|
+
init_passage();
|
|
21489
|
+
init_format();
|
|
21241
21490
|
init_utils();
|
|
21242
21491
|
init_shared();
|
|
21243
21492
|
}));
|
package/lib/cli.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
taskLine,
|
|
52
52
|
taskList,
|
|
53
53
|
todayLocal,
|
|
54
|
+
weekText,
|
|
54
55
|
} from "./format.mjs";
|
|
55
56
|
import { colourDepth, logoLines } from "./logo.mjs";
|
|
56
57
|
import { HarborError, createClient } from "./mcp.mjs";
|
|
@@ -136,7 +137,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
136
137
|
project as that one really ran
|
|
137
138
|
harbor add "TITLE" --project P [--assignee me|none|NAME] [--due DATE]
|
|
138
139
|
[--priority low|medium|high|urgent] [--status S]
|
|
139
|
-
[--over-limit]
|
|
140
|
+
[--comment TEXT] [--over-limit]
|
|
141
|
+
with no TITLE: one title a line
|
|
142
|
+
from stdin
|
|
140
143
|
harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
|
|
141
144
|
move a task to In play, In review
|
|
142
145
|
or Done
|
|
@@ -146,6 +149,13 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
146
149
|
[--waits-on TASK,TASK|none] [--confirm] [--over-limit]
|
|
147
150
|
harbor limit PROJECT [COLUMN N|none] how many tasks a board column
|
|
148
151
|
takes at once; the owner sets it
|
|
152
|
+
harbor week [--next|--last|--week DATE] [--review]
|
|
153
|
+
your own week: tasks on days
|
|
154
|
+
against your hours, only for
|
|
155
|
+
you; --review: planned vs done
|
|
156
|
+
harbor week TASK DAY [--hours N] put a task on a day: mon to fri
|
|
157
|
+
harbor week TASK --off [DAY] take it off that day, or the week
|
|
158
|
+
harbor week --day-hours N the hours you work a day
|
|
149
159
|
harbor comment TASK "TEXT"
|
|
150
160
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
151
161
|
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
@@ -333,6 +343,13 @@ export function localMinute(now = new Date()) {
|
|
|
333
343
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
334
344
|
}
|
|
335
345
|
|
|
346
|
+
/** All of a stream's text: titles piped to harbor add. */
|
|
347
|
+
async function readAll(stream) {
|
|
348
|
+
let text = "";
|
|
349
|
+
for await (const chunk of stream) text += chunk;
|
|
350
|
+
return text;
|
|
351
|
+
}
|
|
352
|
+
|
|
336
353
|
const one = (positionals, what) => {
|
|
337
354
|
if (positionals.length !== 1) throw new HarborError(`Give one ${what}.`);
|
|
338
355
|
return positionals[0];
|
|
@@ -1951,24 +1968,61 @@ export async function run(argv, io = {}) {
|
|
|
1951
1968
|
}
|
|
1952
1969
|
case "add": {
|
|
1953
1970
|
const { positionals, values, harbor } = parse(
|
|
1954
|
-
spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
|
|
1971
|
+
spec(["over-limit"], ["project", "assignee", "due", "priority", "status", "comment"]),
|
|
1955
1972
|
);
|
|
1956
1973
|
if (!values.project) throw new HarborError("Say which project: --project NAME.");
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1974
|
+
// Several at once: one title a line on stdin, with no title (or -) after add.
|
|
1975
|
+
const piped = positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
|
|
1976
|
+
const stdin = io.stdin ?? process.stdin;
|
|
1977
|
+
const titles =
|
|
1978
|
+
piped && (io.stdin || !process.stdin.isTTY)
|
|
1979
|
+
? (await readAll(stdin))
|
|
1980
|
+
.split(/\r?\n/)
|
|
1981
|
+
.map((l) => l.trim())
|
|
1982
|
+
.filter(Boolean)
|
|
1983
|
+
: [text(positionals, "the task")];
|
|
1984
|
+
if (titles.length === 0) throw new HarborError("Say the task, or give one title a line on stdin.");
|
|
1985
|
+
if (titles.length > 100) throw new HarborError("That's more than 100 tasks; harbor import FILE takes a spreadsheet.");
|
|
1986
|
+
const added = [];
|
|
1987
|
+
for (const title of titles) {
|
|
1988
|
+
const r = await client.call("create_task", {
|
|
1960
1989
|
harbor,
|
|
1961
1990
|
project: values.project,
|
|
1962
|
-
title
|
|
1991
|
+
title,
|
|
1963
1992
|
// Yours unless you say otherwise, as with n in the app.
|
|
1964
1993
|
assignee: values.assignee ?? "me",
|
|
1965
1994
|
due: dueDate(values.due),
|
|
1966
1995
|
priority: values.priority,
|
|
1967
1996
|
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
1968
1997
|
overLimit: values["over-limit"] || undefined,
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
|
|
1998
|
+
}).catch((err) => {
|
|
1999
|
+
if (err instanceof HarborError) {
|
|
2000
|
+
const done = added.length ? ` (added ${added.length} before it)` : "";
|
|
2001
|
+
throw new HarborError(`${hint(err.message, values.project)}${done}`);
|
|
2002
|
+
}
|
|
2003
|
+
throw err;
|
|
2004
|
+
});
|
|
2005
|
+
if (values.comment)
|
|
2006
|
+
r.comment = (
|
|
2007
|
+
await client.call("add_comment", {
|
|
2008
|
+
harbor,
|
|
2009
|
+
task: r.created.key ?? r.created.id,
|
|
2010
|
+
body: values.comment,
|
|
2011
|
+
})
|
|
2012
|
+
).commented;
|
|
2013
|
+
added.push(r);
|
|
2014
|
+
}
|
|
2015
|
+
await stalePromptCache(env);
|
|
2016
|
+
if (asJson) out(jsonText(added.length === 1 ? added[0] : { added }));
|
|
2017
|
+
else
|
|
2018
|
+
out(
|
|
2019
|
+
plain(
|
|
2020
|
+
added
|
|
2021
|
+
.map((r) => `Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`)
|
|
2022
|
+
.join("\n"),
|
|
2023
|
+
),
|
|
2024
|
+
);
|
|
2025
|
+
return 0;
|
|
1972
2026
|
}
|
|
1973
2027
|
case "start":
|
|
1974
2028
|
case "submit":
|
|
@@ -2072,6 +2126,46 @@ export async function run(argv, io = {}) {
|
|
|
2072
2126
|
.join("\n"),
|
|
2073
2127
|
);
|
|
2074
2128
|
}
|
|
2129
|
+
case "week": {
|
|
2130
|
+
const { positionals, values, harbor } = parse(
|
|
2131
|
+
spec(["next", "last", "review", "off"], ["week", "hours", "day-hours"]),
|
|
2132
|
+
);
|
|
2133
|
+
if ([values.next, values.last, values.week].filter(Boolean).length > 1)
|
|
2134
|
+
throw new HarborError("Say one of --next, --last or --week DATE.");
|
|
2135
|
+
const base = values.week ? dueDate(values.week) : opts.today;
|
|
2136
|
+
const shift = values.next ? 7 : values.last ? -7 : 0;
|
|
2137
|
+
const at = new Date(`${base}T12:00:00`);
|
|
2138
|
+
const week = todayLocal(new Date(at.getFullYear(), at.getMonth(), at.getDate() + shift));
|
|
2139
|
+
const number = (v, name) => {
|
|
2140
|
+
if (v === undefined) return undefined;
|
|
2141
|
+
const n = Number(v);
|
|
2142
|
+
if (!Number.isFinite(n) || n <= 0) throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
|
|
2143
|
+
return n;
|
|
2144
|
+
};
|
|
2145
|
+
const [task, day, ...extra] = positionals;
|
|
2146
|
+
if (extra.length) throw new HarborError("Give a task key and a day: harbor week RR-42 wed.");
|
|
2147
|
+
const dayHours = number(values["day-hours"], "day-hours");
|
|
2148
|
+
const show = (r) => [r.done, weekText(r, { ...opts, review: Boolean(values.review) })].filter(Boolean).join("\n\n");
|
|
2149
|
+
if (!task && dayHours === undefined) {
|
|
2150
|
+
if (values.off || values.hours) throw new HarborError("Say which task: harbor week RR-42 wed --hours 3.");
|
|
2151
|
+
return call("get_week", { harbor, week }, show);
|
|
2152
|
+
}
|
|
2153
|
+
if (task && !day && !values.off)
|
|
2154
|
+
throw new HarborError(`Say which day for ${task}: harbor week ${task} wed, or --off to take it off the week.`);
|
|
2155
|
+
return call(
|
|
2156
|
+
"plan_week",
|
|
2157
|
+
{
|
|
2158
|
+
harbor,
|
|
2159
|
+
week,
|
|
2160
|
+
...(task ? { task } : {}),
|
|
2161
|
+
...(day ? { day } : {}),
|
|
2162
|
+
...(values.hours ? { hours: number(values.hours, "hours") } : {}),
|
|
2163
|
+
...(values.off ? { remove: true } : {}),
|
|
2164
|
+
...(dayHours !== undefined ? { dayHours } : {}),
|
|
2165
|
+
},
|
|
2166
|
+
show,
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2075
2169
|
case "comment": {
|
|
2076
2170
|
const { positionals, harbor } = parse(spec());
|
|
2077
2171
|
const [task, ...words] = positionals;
|
package/lib/format.mjs
CHANGED
|
@@ -856,6 +856,56 @@ export function exportData(r) {
|
|
|
856
856
|
};
|
|
857
857
|
}
|
|
858
858
|
|
|
859
|
+
const DAY_NAME = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri" };
|
|
860
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
861
|
+
const hours = (h) => (h > 0 && h < 1 ? `${Math.round(h * 60)}m` : `${Math.round(h * 10) / 10}h`);
|
|
862
|
+
const monthDay = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${Number(iso.slice(8, 10))}`;
|
|
863
|
+
|
|
864
|
+
/** get_week as the terminal shows it: the five days with their hours, what's not on a day yet, and how it went. */
|
|
865
|
+
export function weekText(w, { today, review = false, width } = {}) {
|
|
866
|
+
if (!w.days) return w.note ?? "No week to show.";
|
|
867
|
+
const first = w.days[0].date;
|
|
868
|
+
const last = w.days[4].date;
|
|
869
|
+
const out = [
|
|
870
|
+
`Your week · ${monthDay(first)} to ${monthDay(last)} · ${hours(w.planned)} of ${hours(w.capacity)} planned (only you see it)`,
|
|
871
|
+
];
|
|
872
|
+
const reviewing = review || (today && today >= last);
|
|
873
|
+
if (!review) {
|
|
874
|
+
for (const d of w.days) {
|
|
875
|
+
const head = `${DAY_NAME[d.day]} ${Number(d.date.slice(8, 10))}`;
|
|
876
|
+
const load = d.tasks.length ? `${hours(d.hours)} of ${hours(w.dayHours)}` : "nothing planned";
|
|
877
|
+
out.push(
|
|
878
|
+
"",
|
|
879
|
+
`${head} ${load}${d.over ? ` · too full by ${d.overBy}` : ""}${d.date === today ? " · today" : ""}`,
|
|
880
|
+
);
|
|
881
|
+
for (const t of d.tasks) {
|
|
882
|
+
const tail = ` ${hours(t.hours)}${t.status === "done" ? " · done" : ""}`;
|
|
883
|
+
const key = ` ${t.key ?? t.id} `;
|
|
884
|
+
out.push(
|
|
885
|
+
key + fitTitle(t.title, width ? width - 1 - key.length - tail.length : Infinity) + tail,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (w.unplannedCount) {
|
|
890
|
+
out.push("", `Not on a day yet: ${w.unplannedCount}. harbor week KEY DAY puts one there.`);
|
|
891
|
+
for (const t of w.unplanned.slice(0, 5))
|
|
892
|
+
out.push(` ${taskLine(t, { today, mine: true, width: width ? width - 2 : undefined })}`);
|
|
893
|
+
if (w.unplannedCount > 5) out.push(` … and ${w.unplannedCount - 5} more (--json lists 25)`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (reviewing) {
|
|
897
|
+
const r = w.review;
|
|
898
|
+
out.push("", `How it went: ${r.summary}`);
|
|
899
|
+
if (r.planned)
|
|
900
|
+
out.push(` ${hours(r.doneHours)} of the ${hours(r.plannedHours)} planned is done.`);
|
|
901
|
+
if (review) {
|
|
902
|
+
for (const t of r.stillOpen) out.push(` open ${taskLine(t, { today, mine: true })}`);
|
|
903
|
+
for (const t of r.alsoDone) out.push(` also ${taskLine(t, { today, mine: true })}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return out.join("\n");
|
|
907
|
+
}
|
|
908
|
+
|
|
859
909
|
/** fog: the open tasks nobody has touched for three weeks, longest first. */
|
|
860
910
|
export function fogText(r, { sweepHint = true } = {}) {
|
|
861
911
|
const where = r.project ? ` in ${r.project}` : "";
|