@harborppm/cli 0.54.0 → 0.56.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 +12 -3
- package/dist/app.mjs +260 -11
- package/lib/cli.mjs +137 -14
- package/lib/format.mjs +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -148,10 +148,13 @@ harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
|
148
148
|
[--ends DATE] [--budget N] [--from "DONE PROJECT"]
|
|
149
149
|
--from: dead reckoning, its tasks due when
|
|
150
150
|
they really finished in that project
|
|
151
|
-
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
|
|
152
|
-
[--priority low|medium|high|urgent] [--status S] [--
|
|
151
|
+
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE|pilot]
|
|
152
|
+
[--priority low|medium|high|urgent] [--status S] [--comment TEXT]
|
|
153
|
+
[--over-limit] no TITLE: one title a line from stdin
|
|
154
|
+
--due pilot takes the date the owner's queue suggests;
|
|
155
|
+
a date that won't hold says so
|
|
153
156
|
harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
|
|
154
|
-
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
157
|
+
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|pilot|none]
|
|
155
158
|
[--priority P] [--status S] [--notes TEXT]
|
|
156
159
|
[--repeat weekly|biweekly|monthly|never]
|
|
157
160
|
[--waits-on TASK,TASK|none] [--confirm] [--over-limit]
|
|
@@ -189,6 +192,8 @@ harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
|
189
192
|
harbor sound TASK [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
|
|
190
193
|
harbor soundings [PROJECT]
|
|
191
194
|
harbor limit PROJECT [COLUMN N|none]
|
|
195
|
+
harbor week [--next|--last|--week DATE] [--review]
|
|
196
|
+
harbor week TASK DAY [--hours N] | week TASK --off [DAY] | week --day-hours N
|
|
192
197
|
harbor init [PROJECT] [--hook] [--dry-run] a project's tasks as files in this repository
|
|
193
198
|
harbor init --local [NAME] a plan in this folder alone, no account
|
|
194
199
|
harbor sync [--quiet] [--dry-run] the files and the website, in step both ways
|
|
@@ -211,6 +216,10 @@ harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
|
211
216
|
|
|
212
217
|
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
218
|
|
|
219
|
+
`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.
|
|
220
|
+
|
|
221
|
+
`harbor add --project Trail --comment "From Monday's call" < list.txt` adds one task per line of the file, each with the comment.
|
|
222
|
+
|
|
214
223
|
`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
224
|
|
|
216
225
|
`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";
|
|
@@ -134,18 +135,29 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
134
135
|
[--ends DATE] [--budget N] [--from "DONE PROJECT"]
|
|
135
136
|
--from: plan it from a finished
|
|
136
137
|
project as that one really ran
|
|
137
|
-
harbor add "TITLE" --project P [--assignee me|none|NAME]
|
|
138
|
-
[--priority low|medium|high|urgent]
|
|
139
|
-
[--over-limit]
|
|
138
|
+
harbor add "TITLE" --project P [--assignee me|none|NAME]
|
|
139
|
+
[--due DATE|pilot] [--priority low|medium|high|urgent]
|
|
140
|
+
[--status S] [--comment TEXT] [--over-limit]
|
|
141
|
+
with no TITLE: one title a line
|
|
142
|
+
from stdin; --due pilot takes the
|
|
143
|
+
date the owner's queue suggests
|
|
140
144
|
harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
|
|
141
145
|
move a task to In play, In review
|
|
142
146
|
or Done
|
|
143
|
-
harbor edit TASK [--title T] [--assignee me|none|NAME]
|
|
147
|
+
harbor edit TASK [--title T] [--assignee me|none|NAME]
|
|
148
|
+
[--due DATE|pilot|none]
|
|
144
149
|
[--priority P] [--status S] [--notes TEXT]
|
|
145
150
|
[--repeat weekly|biweekly|monthly|never]
|
|
146
151
|
[--waits-on TASK,TASK|none] [--confirm] [--over-limit]
|
|
147
152
|
harbor limit PROJECT [COLUMN N|none] how many tasks a board column
|
|
148
153
|
takes at once; the owner sets it
|
|
154
|
+
harbor week [--next|--last|--week DATE] [--review]
|
|
155
|
+
your own week: tasks on days
|
|
156
|
+
against your hours, only for
|
|
157
|
+
you; --review: planned vs done
|
|
158
|
+
harbor week TASK DAY [--hours N] put a task on a day: mon to fri
|
|
159
|
+
harbor week TASK --off [DAY] take it off that day, or the week
|
|
160
|
+
harbor week --day-hours N the hours you work a day
|
|
149
161
|
harbor comment TASK "TEXT"
|
|
150
162
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
151
163
|
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
@@ -333,6 +345,13 @@ export function localMinute(now = new Date()) {
|
|
|
333
345
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
334
346
|
}
|
|
335
347
|
|
|
348
|
+
/** All of a stream's text: titles piped to harbor add. */
|
|
349
|
+
async function readAll(stream) {
|
|
350
|
+
let text = "";
|
|
351
|
+
for await (const chunk of stream) text += chunk;
|
|
352
|
+
return text;
|
|
353
|
+
}
|
|
354
|
+
|
|
336
355
|
const one = (positionals, what) => {
|
|
337
356
|
if (positionals.length !== 1) throw new HarborError(`Give one ${what}.`);
|
|
338
357
|
return positionals[0];
|
|
@@ -361,6 +380,21 @@ export function dueDate(v, today = new Date()) {
|
|
|
361
380
|
return todayLocal(d);
|
|
362
381
|
}
|
|
363
382
|
|
|
383
|
+
/** --due pilot: the date the owner's queue suggests, set by Harbor. */
|
|
384
|
+
const PILOT = new Set(["pilot", "suggested"]);
|
|
385
|
+
const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
|
|
386
|
+
|
|
387
|
+
/** Pilot's word when the date given won't hold: what the queue says, why, and how to take it. */
|
|
388
|
+
function pilotLines(r, task) {
|
|
389
|
+
const p = r.pilot;
|
|
390
|
+
if (!p) return [];
|
|
391
|
+
const ref = task.key ?? task.id;
|
|
392
|
+
return [
|
|
393
|
+
`${p.says}: ${p.why.map((w, i) => (i ? w.charAt(0).toLowerCase() + w.slice(1) : w)).join("; ")}.`,
|
|
394
|
+
`harbor edit ${ref} --due pilot takes ${p.earliest}.`,
|
|
395
|
+
];
|
|
396
|
+
}
|
|
397
|
+
|
|
364
398
|
/** Harbor's tool fields, as the CLI's flags. */
|
|
365
399
|
const FLAG = {
|
|
366
400
|
due: "--due",
|
|
@@ -1951,24 +1985,68 @@ export async function run(argv, io = {}) {
|
|
|
1951
1985
|
}
|
|
1952
1986
|
case "add": {
|
|
1953
1987
|
const { positionals, values, harbor } = parse(
|
|
1954
|
-
spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
|
|
1988
|
+
spec(["over-limit"], ["project", "assignee", "due", "priority", "status", "comment"]),
|
|
1955
1989
|
);
|
|
1956
1990
|
if (!values.project) throw new HarborError("Say which project: --project NAME.");
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1991
|
+
// Several at once: one title a line on stdin, with no title (or -) after add.
|
|
1992
|
+
const piped = positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
|
|
1993
|
+
const stdin = io.stdin ?? process.stdin;
|
|
1994
|
+
const titles =
|
|
1995
|
+
piped && (io.stdin || !process.stdin.isTTY)
|
|
1996
|
+
? (await readAll(stdin))
|
|
1997
|
+
.split(/\r?\n/)
|
|
1998
|
+
.map((l) => l.trim())
|
|
1999
|
+
.filter(Boolean)
|
|
2000
|
+
: [text(positionals, "the task")];
|
|
2001
|
+
if (titles.length === 0) throw new HarborError("Say the task, or give one title a line on stdin.");
|
|
2002
|
+
if (titles.length > 100) throw new HarborError("That's more than 100 tasks; harbor import FILE takes a spreadsheet.");
|
|
2003
|
+
const added = [];
|
|
2004
|
+
for (const title of titles) {
|
|
2005
|
+
const r = await client.call("create_task", {
|
|
1960
2006
|
harbor,
|
|
1961
2007
|
project: values.project,
|
|
1962
|
-
title
|
|
2008
|
+
title,
|
|
1963
2009
|
// Yours unless you say otherwise, as with n in the app.
|
|
1964
2010
|
assignee: values.assignee ?? "me",
|
|
1965
|
-
due: dueDate(values.due),
|
|
2011
|
+
due: duePilot(values.due) ? undefined : dueDate(values.due),
|
|
2012
|
+
suggestedDue: duePilot(values.due),
|
|
1966
2013
|
priority: values.priority,
|
|
1967
2014
|
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
1968
2015
|
overLimit: values["over-limit"] || undefined,
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
|
|
2016
|
+
}).catch((err) => {
|
|
2017
|
+
if (err instanceof HarborError) {
|
|
2018
|
+
const done = added.length ? ` (added ${added.length} before it)` : "";
|
|
2019
|
+
throw new HarborError(`${hint(err.message, values.project)}${done}`);
|
|
2020
|
+
}
|
|
2021
|
+
throw err;
|
|
2022
|
+
});
|
|
2023
|
+
if (values.comment)
|
|
2024
|
+
r.comment = (
|
|
2025
|
+
await client.call("add_comment", {
|
|
2026
|
+
harbor,
|
|
2027
|
+
task: r.created.key ?? r.created.id,
|
|
2028
|
+
body: values.comment,
|
|
2029
|
+
})
|
|
2030
|
+
).commented;
|
|
2031
|
+
added.push(r);
|
|
2032
|
+
}
|
|
2033
|
+
await stalePromptCache(env);
|
|
2034
|
+
if (asJson) out(jsonText(added.length === 1 ? added[0] : { added }));
|
|
2035
|
+
else
|
|
2036
|
+
out(
|
|
2037
|
+
plain(
|
|
2038
|
+
added
|
|
2039
|
+
.map((r) =>
|
|
2040
|
+
[
|
|
2041
|
+
`Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
|
|
2042
|
+
// Pilot speaks up only when a date was given that the queue says won't hold.
|
|
2043
|
+
...(values.due ? pilotLines(r, r.created) : []),
|
|
2044
|
+
].join("\n"),
|
|
2045
|
+
)
|
|
2046
|
+
.join("\n"),
|
|
2047
|
+
),
|
|
2048
|
+
);
|
|
2049
|
+
return 0;
|
|
1972
2050
|
}
|
|
1973
2051
|
case "start":
|
|
1974
2052
|
case "submit":
|
|
@@ -2045,7 +2123,11 @@ export async function run(argv, io = {}) {
|
|
|
2045
2123
|
task: one(positionals, "task key, like RR-42"),
|
|
2046
2124
|
title: values.title,
|
|
2047
2125
|
assignee: values.assignee,
|
|
2048
|
-
due:
|
|
2126
|
+
due:
|
|
2127
|
+
values.due === undefined || duePilot(values.due)
|
|
2128
|
+
? undefined
|
|
2129
|
+
: nullable(dueDate(values.due)),
|
|
2130
|
+
suggestedDue: duePilot(values.due),
|
|
2049
2131
|
priority: values.priority,
|
|
2050
2132
|
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
2051
2133
|
description: values.notes,
|
|
@@ -2067,11 +2149,52 @@ export async function run(argv, io = {}) {
|
|
|
2067
2149
|
`Updated: ${taskLine(r.updated, opts)}`,
|
|
2068
2150
|
r.updated.waitsOn ? `Waits on ${r.updated.waitsOn.join(", ")}.` : null,
|
|
2069
2151
|
r.updated.repeat ? `Repeats ${REPEAT_WORD[r.updated.repeat]}.` : null,
|
|
2152
|
+
...(values.due && !duePilot(values.due) ? pilotLines(r, r.updated) : []),
|
|
2070
2153
|
]
|
|
2071
2154
|
.filter(Boolean)
|
|
2072
2155
|
.join("\n"),
|
|
2073
2156
|
);
|
|
2074
2157
|
}
|
|
2158
|
+
case "week": {
|
|
2159
|
+
const { positionals, values, harbor } = parse(
|
|
2160
|
+
spec(["next", "last", "review", "off"], ["week", "hours", "day-hours"]),
|
|
2161
|
+
);
|
|
2162
|
+
if ([values.next, values.last, values.week].filter(Boolean).length > 1)
|
|
2163
|
+
throw new HarborError("Say one of --next, --last or --week DATE.");
|
|
2164
|
+
const base = values.week ? dueDate(values.week) : opts.today;
|
|
2165
|
+
const shift = values.next ? 7 : values.last ? -7 : 0;
|
|
2166
|
+
const at = new Date(`${base}T12:00:00`);
|
|
2167
|
+
const week = todayLocal(new Date(at.getFullYear(), at.getMonth(), at.getDate() + shift));
|
|
2168
|
+
const number = (v, name) => {
|
|
2169
|
+
if (v === undefined) return undefined;
|
|
2170
|
+
const n = Number(v);
|
|
2171
|
+
if (!Number.isFinite(n) || n <= 0) throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
|
|
2172
|
+
return n;
|
|
2173
|
+
};
|
|
2174
|
+
const [task, day, ...extra] = positionals;
|
|
2175
|
+
if (extra.length) throw new HarborError("Give a task key and a day: harbor week RR-42 wed.");
|
|
2176
|
+
const dayHours = number(values["day-hours"], "day-hours");
|
|
2177
|
+
const show = (r) => [r.done, weekText(r, { ...opts, review: Boolean(values.review) })].filter(Boolean).join("\n\n");
|
|
2178
|
+
if (!task && dayHours === undefined) {
|
|
2179
|
+
if (values.off || values.hours) throw new HarborError("Say which task: harbor week RR-42 wed --hours 3.");
|
|
2180
|
+
return call("get_week", { harbor, week }, show);
|
|
2181
|
+
}
|
|
2182
|
+
if (task && !day && !values.off)
|
|
2183
|
+
throw new HarborError(`Say which day for ${task}: harbor week ${task} wed, or --off to take it off the week.`);
|
|
2184
|
+
return call(
|
|
2185
|
+
"plan_week",
|
|
2186
|
+
{
|
|
2187
|
+
harbor,
|
|
2188
|
+
week,
|
|
2189
|
+
...(task ? { task } : {}),
|
|
2190
|
+
...(day ? { day } : {}),
|
|
2191
|
+
...(values.hours ? { hours: number(values.hours, "hours") } : {}),
|
|
2192
|
+
...(values.off ? { remove: true } : {}),
|
|
2193
|
+
...(dayHours !== undefined ? { dayHours } : {}),
|
|
2194
|
+
},
|
|
2195
|
+
show,
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2075
2198
|
case "comment": {
|
|
2076
2199
|
const { positionals, harbor } = parse(spec());
|
|
2077
2200
|
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}` : "";
|