@harborppm/cli 0.48.0 → 0.50.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 +3 -1
- package/dist/app.mjs +363 -12
- package/lib/cli.mjs +124 -14
- package/lib/format.mjs +72 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,6 +174,8 @@ harbor import FILE [--project P] [--portfolio P] [--no-people]
|
|
|
174
174
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
175
175
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
176
176
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
177
|
+
harbor sound TASK [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
|
|
178
|
+
harbor soundings [PROJECT]
|
|
177
179
|
harbor limit PROJECT [COLUMN N|none]
|
|
178
180
|
harbor init [PROJECT] [--hook] [--dry-run] a project's tasks as files in this repository
|
|
179
181
|
harbor init --local [NAME] a plan in this folder alone, no account
|
|
@@ -194,7 +196,7 @@ Agents on the crew: put an AI app on People with Add an agent and assign it task
|
|
|
194
196
|
|
|
195
197
|
`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.
|
|
196
198
|
|
|
197
|
-
`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.
|
|
199
|
+
`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.
|
|
198
200
|
|
|
199
201
|
`harbor brief --say` turns the brief into the BBC's Shipping Forecast: each program an area, wind for late work, visibility for health, gale warnings for anything off track. It prints the forecast and speaks it with your computer's own voice (`say` on a Mac, Windows' speech, or `espeak-ng` on Linux, which you may need to install). With `--json` it prints the forecast and stays quiet.
|
|
200
202
|
|
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;
|
|
@@ -14858,8 +15028,83 @@ function tasksSlice({ set, get, write }) {
|
|
|
14858
15028
|
if (ids.length >= 3) return "full";
|
|
14859
15029
|
set({ ropes: upsertFocus(get().ropes, personId, date, [...ids, taskId]) });
|
|
14860
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
|
+
});
|
|
14861
15100
|
}
|
|
14862
15101
|
};
|
|
15102
|
+
function setSounding(taskId, sounding) {
|
|
15103
|
+
set({ tasks: get().tasks.map((t) => t.id === taskId ? {
|
|
15104
|
+
...t,
|
|
15105
|
+
sounding
|
|
15106
|
+
} : t) });
|
|
15107
|
+
}
|
|
14863
15108
|
}
|
|
14864
15109
|
var init_tasks = __esmMin((() => {
|
|
14865
15110
|
init_github_refs();
|
|
@@ -14867,6 +15112,8 @@ var init_tasks = __esmMin((() => {
|
|
|
14867
15112
|
init_automations();
|
|
14868
15113
|
init_focus();
|
|
14869
15114
|
init_format();
|
|
15115
|
+
init_soundings();
|
|
15116
|
+
init_clock();
|
|
14870
15117
|
init_utils();
|
|
14871
15118
|
init_blockers();
|
|
14872
15119
|
init_work();
|
|
@@ -23725,6 +23972,7 @@ init_handover();
|
|
|
23725
23972
|
init_work();
|
|
23726
23973
|
init_fog();
|
|
23727
23974
|
init_board();
|
|
23975
|
+
init_soundings();
|
|
23728
23976
|
/**
|
|
23729
23977
|
* Where the website opens one task or project, switching to its harbor
|
|
23730
23978
|
* first (src/routes/open.tsx). Nothing without an origin.
|
|
@@ -23776,8 +24024,8 @@ const STATUSES = [
|
|
|
23776
24024
|
* for an agent: dates for half, 85% and 95% of the runs, or why there's none.
|
|
23777
24025
|
* Given the spend ledger, also what each week late costs (src/lib/slip-cost.ts).
|
|
23778
24026
|
*/
|
|
23779
|
-
function finishForecast(project, tasks, now, spend) {
|
|
23780
|
-
const tide = tideForecast(project, tasks, now);
|
|
24027
|
+
function finishForecast(project, tasks, now, spend, reflections) {
|
|
24028
|
+
const tide = tideForecast(project, tasks, now, { reflections });
|
|
23781
24029
|
if (tide.kind !== "forecast") return { none: tideQuiet(tide) };
|
|
23782
24030
|
const lastDue = dueAfterFinish(tide);
|
|
23783
24031
|
const dueLater = lastDue ? { lastDue: {
|
|
@@ -23803,6 +24051,7 @@ function finishForecast(project, tasks, now, spend) {
|
|
|
23803
24051
|
finishedPerWeek: tide.perWeek,
|
|
23804
24052
|
basedOn: `${tide.finished} task${tide.finished === 1 ? "" : "s"} finished in the last ${tide.weeks} week${tide.weeks === 1 ? "" : "s"}`,
|
|
23805
24053
|
...tide.rough ? { rough: true } : {},
|
|
24054
|
+
...tide.sounded ? { soundedTasks: tide.sounded } : {},
|
|
23806
24055
|
...tide.weeks < 2 ? { shortHistory: true } : {},
|
|
23807
24056
|
...dueLater,
|
|
23808
24057
|
...slip
|
|
@@ -23862,6 +24111,39 @@ function me(ctx) {
|
|
|
23862
24111
|
}
|
|
23863
24112
|
const nameOf = (people, id) => id && people.get(id)?.name || null;
|
|
23864
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
|
+
}
|
|
23865
24147
|
const dayOf = (iso) => iso ? iso.slice(0, 10) : null;
|
|
23866
24148
|
function overdue(t, today) {
|
|
23867
24149
|
const due = dayOf(t.dueDate);
|
|
@@ -24119,7 +24401,7 @@ const HARBOR_TOOLS = [
|
|
|
24119
24401
|
done: c.done,
|
|
24120
24402
|
late: c.late
|
|
24121
24403
|
},
|
|
24122
|
-
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),
|
|
24123
24405
|
...limitsOf(data, p.id),
|
|
24124
24406
|
...hillOf(p, today),
|
|
24125
24407
|
...publicRoadmap(ctx, p.id)
|
|
@@ -24190,7 +24472,7 @@ const HARBOR_TOOLS = [
|
|
|
24190
24472
|
waitsOn,
|
|
24191
24473
|
milestones,
|
|
24192
24474
|
openRaid: raid,
|
|
24193
|
-
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id)),
|
|
24475
|
+
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data.reflections),
|
|
24194
24476
|
...notesOf(ctx, p),
|
|
24195
24477
|
...hillOf(p, today),
|
|
24196
24478
|
...webLink(ctx, { project: p.id })
|
|
@@ -24298,6 +24580,7 @@ const HARBOR_TOOLS = [
|
|
|
24298
24580
|
})),
|
|
24299
24581
|
waitingOn: blockersOf(t, data.tasks).map(brief),
|
|
24300
24582
|
waitedOnBy: waiters.map(brief),
|
|
24583
|
+
...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
|
|
24301
24584
|
comments,
|
|
24302
24585
|
...webLink(ctx, { task: t.id })
|
|
24303
24586
|
};
|
|
@@ -24346,7 +24629,13 @@ const HARBOR_TOOLS = [
|
|
|
24346
24629
|
late: open.filter((t) => overdue(t, today)).length,
|
|
24347
24630
|
tasks: open.slice(0, input.limit).map((t) => taskLine(t, people, projects, data.tasks)),
|
|
24348
24631
|
waitingOnYou: waitingOnYou.slice(0, 20),
|
|
24349
|
-
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
|
+
}))
|
|
24350
24639
|
};
|
|
24351
24640
|
}
|
|
24352
24641
|
}),
|
|
@@ -24622,6 +24911,35 @@ const HARBOR_TOOLS = [
|
|
|
24622
24911
|
};
|
|
24623
24912
|
}
|
|
24624
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
|
+
}),
|
|
24625
24943
|
tool({
|
|
24626
24944
|
name: "shipping_forecast",
|
|
24627
24945
|
title: "The harbor forecast",
|
|
@@ -25059,9 +25377,30 @@ function dockCompare(data, projectId, changes, now) {
|
|
|
25059
25377
|
pace: plan.pace,
|
|
25060
25378
|
shift: a && b ? dayNumber(b) - dayNumber(a) : null,
|
|
25061
25379
|
load,
|
|
25062
|
-
stale: plan.stale
|
|
25380
|
+
stale: plan.stale,
|
|
25381
|
+
notes: dockNotes(plan.project, realTasks, dockTide, now)
|
|
25063
25382
|
};
|
|
25064
25383
|
}
|
|
25384
|
+
/**
|
|
25385
|
+
* What to know before trusting the shift: the pace is read from weeks of
|
|
25386
|
+
* finished work, so a short or lumpy record (work marked done in one go) or
|
|
25387
|
+
* a due date that holds the finish can leave the dates where they were.
|
|
25388
|
+
*/
|
|
25389
|
+
function dockNotes(project, realTasks, docked, now) {
|
|
25390
|
+
if (docked.kind !== "forecast") return [];
|
|
25391
|
+
const notes = [];
|
|
25392
|
+
const weekly = weeklyFinished(realTasks, project.startDate, now);
|
|
25393
|
+
const finished = weekly.reduce((a, b) => a + b, 0);
|
|
25394
|
+
const busiest = Math.max(0, ...weekly);
|
|
25395
|
+
if (weekly.length < 2) notes.push("The pace comes from under two weeks of finished work, so treat the dates as rough.");
|
|
25396
|
+
else if (docked.rough) notes.push(`Only ${finished} task${finished === 1 ? "" : "s"} finished in the last ${weekly.length} weeks, so treat the dates as rough.`);
|
|
25397
|
+
if (weekly.length >= 2 && finished >= 4 && busiest * 2 > finished) notes.push(`${busiest} of the ${finished} tasks finished lately were marked done in one week, so the pace jumps from week to week and a few tasks more or less may not move the dates.`);
|
|
25398
|
+
if (docked.heldBy?.dueDate) {
|
|
25399
|
+
const name = taskKey(docked.heldBy, project) ?? docked.heldBy.title;
|
|
25400
|
+
notes.push(`${name} is due ${shortDate(docked.heldBy.dueDate)}, after the pace would finish, so its due date sets the finish and work done before then doesn't move it.`);
|
|
25401
|
+
}
|
|
25402
|
+
return notes;
|
|
25403
|
+
}
|
|
25065
25404
|
/** "11 days sooner", "3 days later", "Same day", or null without both forecasts. */
|
|
25066
25405
|
function shiftText(shift) {
|
|
25067
25406
|
if (shift == null) return null;
|
|
@@ -25255,6 +25594,10 @@ function tryLines(data, project, changes, cmp, today) {
|
|
|
25255
25594
|
const end = docked.project.endDate;
|
|
25256
25595
|
if (end >= today && real.tide.kind === "forecast" && docked.tide.kind === "forecast") out.push(row(`Making ${shortDate(end)}`, end === real.project.endDate ? chanceText(real.tide.chanceByEnd) : "-", chanceText(docked.tide.chanceByEnd)));
|
|
25257
25596
|
if (cmp.pace !== 1) out.push(row("Crew pace", "100%", `${Math.round(cmp.pace * 100)}%`));
|
|
25597
|
+
if (changes.length && cmp.notes.length) {
|
|
25598
|
+
out.push("");
|
|
25599
|
+
for (const n of cmp.notes) out.push(` Note: ${n}`);
|
|
25600
|
+
}
|
|
25258
25601
|
if (cmp.load.length) {
|
|
25259
25602
|
out.push("", " Due in the next six weeks:");
|
|
25260
25603
|
for (const l of cmp.load) {
|
|
@@ -25303,6 +25646,14 @@ async function tryHere(opts) {
|
|
|
25303
25646
|
lines
|
|
25304
25647
|
};
|
|
25305
25648
|
if ((opts.apply || opts.save) && changes.length === 0) throw new HarborError("There are no changes to apply or save. Add some, like --drop RL-4.");
|
|
25649
|
+
if (opts.apply && !opts.confirm) {
|
|
25650
|
+
const gone = changes.flatMap((c) => {
|
|
25651
|
+
if (c.kind !== "drop") return [];
|
|
25652
|
+
const t = data.tasks.find((x) => x.id === c.taskId);
|
|
25653
|
+
return t ? [`${taskKey(t, project) ?? t.id} ${t.title}`] : [];
|
|
25654
|
+
});
|
|
25655
|
+
if (gone.length) throw new HarborError(`--apply removes ${gone.length === 1 ? "this task" : `these ${gone.length} tasks`} from the harbor:\n${gone.map((g) => ` ${g}`).join("\n")}\nTo remove ${gone.length === 1 ? "it" : "them"}, run it again with --confirm. Nothing was changed.`);
|
|
25656
|
+
}
|
|
25306
25657
|
if (opts.apply) {
|
|
25307
25658
|
const s = store.getState();
|
|
25308
25659
|
result.applied = applyDock({
|
package/lib/cli.mjs
CHANGED
|
@@ -33,6 +33,9 @@ import {
|
|
|
33
33
|
myWork,
|
|
34
34
|
premortemText,
|
|
35
35
|
notesText,
|
|
36
|
+
daysText,
|
|
37
|
+
soundingText,
|
|
38
|
+
soundingsText,
|
|
36
39
|
voyageText,
|
|
37
40
|
handoverText,
|
|
38
41
|
fogText,
|
|
@@ -82,7 +85,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
82
85
|
harbor try PROJECT [--drop KEYS] [--add "TITLE" [--count N]]
|
|
83
86
|
[--due KEY=DATE] [--give KEY=NAME] [--end DATE]
|
|
84
87
|
[--borrow NAME[=DAYS]] [--release NAME]
|
|
85
|
-
[--saved] [--apply|--save|--discard]
|
|
88
|
+
[--saved] [--apply [--confirm]|--save|--discard]
|
|
86
89
|
dry dock: the forecast with plan
|
|
87
90
|
changes tried on a copy; nothing
|
|
88
91
|
changes without --apply
|
|
@@ -175,6 +178,12 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
175
178
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
176
179
|
a note for later: sealed until
|
|
177
180
|
its day, milestone, or Off track
|
|
181
|
+
harbor sound TASK [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
|
|
182
|
+
a sounding: ask a few people how
|
|
183
|
+
many days a task will take, give
|
|
184
|
+
your answer, or read the spread
|
|
185
|
+
harbor soundings [PROJECT] open tasks with a sounding, and
|
|
186
|
+
the ones waiting on your answer
|
|
178
187
|
harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
|
|
179
188
|
[--keep ID,ID] [--confirm]
|
|
180
189
|
hand someone's work to the crew
|
|
@@ -369,6 +378,9 @@ const FLAG = {
|
|
|
369
378
|
to: "--to",
|
|
370
379
|
keep: "--keep",
|
|
371
380
|
person: "the person",
|
|
381
|
+
days: "the days",
|
|
382
|
+
why: "--why",
|
|
383
|
+
people: "--ask",
|
|
372
384
|
};
|
|
373
385
|
|
|
374
386
|
/** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
|
|
@@ -554,6 +566,8 @@ const DETAIL = {
|
|
|
554
566
|
" harbor try PROJECT [changes] reads the forecast with the changes",
|
|
555
567
|
" tried on a copy of the plan",
|
|
556
568
|
" harbor try PROJECT [changes] --apply writes: makes the changes for real",
|
|
569
|
+
" (--confirm too when it drops tasks:",
|
|
570
|
+
" they're removed from the harbor)",
|
|
557
571
|
" harbor try PROJECT [changes] --save writes: keeps them on the project to",
|
|
558
572
|
" show someone (Forecast tab, Open dry",
|
|
559
573
|
" dock)",
|
|
@@ -562,7 +576,8 @@ const DETAIL = {
|
|
|
562
576
|
" harbor try PROJECT --discard writes: throws the saved ones away",
|
|
563
577
|
"",
|
|
564
578
|
" Changes, each taking several with commas between:",
|
|
565
|
-
" --drop RL-4,RL-5 take tasks out of the plan (--apply
|
|
579
|
+
" --drop RL-4,RL-5 take tasks out of the plan (--apply --confirm",
|
|
580
|
+
" removes them)",
|
|
566
581
|
' --add "TITLE" --count N new work, N tasks called TITLE',
|
|
567
582
|
" --due RL-4=fri move a due date (none takes it off)",
|
|
568
583
|
" --give RL-4=Sam give a task to someone (none: nobody)",
|
|
@@ -576,6 +591,13 @@ const DETAIL = {
|
|
|
576
591
|
" date, the crew's pace, and the load of everyone the changes touch. A",
|
|
577
592
|
" borrowed person adds an average crew member's pace for each day a week",
|
|
578
593
|
" they give; a released one takes away their share of what was finished.",
|
|
594
|
+
" Notes under it say when the dates may not move: a short or lumpy record",
|
|
595
|
+
" of finished work, or a due date that sets the finish.",
|
|
596
|
+
"",
|
|
597
|
+
" --json gives each plan's endDate, open, likely, veryLikely, chanceByEnd",
|
|
598
|
+
" and tasksPerWeek (noForecast says why when there are no dates), the",
|
|
599
|
+
" shiftDays between them, crewPacePercent (100 is the crew as it is) and",
|
|
600
|
+
" the notes.",
|
|
579
601
|
],
|
|
580
602
|
report: [
|
|
581
603
|
" harbor report PROGRAM reads the program's status report",
|
|
@@ -606,6 +628,25 @@ const DETAIL = {
|
|
|
606
628
|
" --no-people. A task already in its project is left alone: by its key",
|
|
607
629
|
" (a Key column, as harbor export writes) or by title.",
|
|
608
630
|
],
|
|
631
|
+
sound: [
|
|
632
|
+
" harbor sound TASK reads its sounding: who hasn't answered,",
|
|
633
|
+
" and the spread once everyone has",
|
|
634
|
+
" harbor sound TASK --ask crew writes: asks you, its owner and whoever",
|
|
635
|
+
" has open work on its project",
|
|
636
|
+
' harbor sound TASK --ask "Sam,Priya" writes: asks those people',
|
|
637
|
+
' harbor sound TASK 4 [--why "TEXT"] writes: your answer, in working days',
|
|
638
|
+
" harbor sound TASK --settle 6 writes: the days you agreed on after",
|
|
639
|
+
" talking a wide spread through",
|
|
640
|
+
"",
|
|
641
|
+
" Each person answers alone, and nobody sees the others' numbers until",
|
|
642
|
+
" everyone has. Close guesses give the task an estimate; far-apart ones",
|
|
643
|
+
" say talk first. The forecast draws on every answer.",
|
|
644
|
+
],
|
|
645
|
+
soundings: [
|
|
646
|
+
" harbor soundings [PROJECT] reads: open tasks with a sounding",
|
|
647
|
+
" (all projects when left out), and the",
|
|
648
|
+
" ones waiting on your answer",
|
|
649
|
+
],
|
|
609
650
|
note: [
|
|
610
651
|
" harbor note PROJECT reads its notes for later: who left",
|
|
611
652
|
" each, what opens it, and the opened ones",
|
|
@@ -1138,7 +1179,7 @@ export async function run(argv, io = {}) {
|
|
|
1138
1179
|
|
|
1139
1180
|
if (command === "try") {
|
|
1140
1181
|
const { values, positionals } = parseArgs(rest, {
|
|
1141
|
-
flags: ["apply", "save", "discard", "saved", "json"],
|
|
1182
|
+
flags: ["apply", "confirm", "save", "discard", "saved", "json"],
|
|
1142
1183
|
options: ["harbor", "drop", "add", "count", "due", "give", "end", "borrow", "release"],
|
|
1143
1184
|
});
|
|
1144
1185
|
if (positionals.length !== 1)
|
|
@@ -1149,6 +1190,8 @@ export async function run(argv, io = {}) {
|
|
|
1149
1190
|
);
|
|
1150
1191
|
if (["apply", "save", "discard"].filter((k) => values[k]).length > 1)
|
|
1151
1192
|
throw new HarborError("Use one of --apply, --save or --discard.");
|
|
1193
|
+
if (values.confirm && !values.apply)
|
|
1194
|
+
throw new HarborError("--confirm goes with --apply, to remove the tasks it drops.");
|
|
1152
1195
|
const creds = await readCredentials(env);
|
|
1153
1196
|
if (!hasFullAccess(creds))
|
|
1154
1197
|
throw new HarborError(
|
|
@@ -1167,6 +1210,7 @@ export async function run(argv, io = {}) {
|
|
|
1167
1210
|
project: positionals[0],
|
|
1168
1211
|
flags: values,
|
|
1169
1212
|
apply: Boolean(values.apply),
|
|
1213
|
+
confirm: Boolean(values.confirm),
|
|
1170
1214
|
save: Boolean(values.save),
|
|
1171
1215
|
discard: Boolean(values.discard),
|
|
1172
1216
|
say,
|
|
@@ -1185,14 +1229,19 @@ export async function run(argv, io = {}) {
|
|
|
1185
1229
|
return 0;
|
|
1186
1230
|
}
|
|
1187
1231
|
if (values.json) {
|
|
1188
|
-
const side = (s) =>
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1232
|
+
const side = (s) => {
|
|
1233
|
+
const f = s.tide.kind === "forecast" ? s.tide : null;
|
|
1234
|
+
return {
|
|
1235
|
+
endDate: s.project.endDate,
|
|
1236
|
+
open: s.open,
|
|
1237
|
+
likely: f?.likely ?? null,
|
|
1238
|
+
veryLikely: f?.veryLikely ?? null,
|
|
1239
|
+
chanceByEnd: f?.chanceByEnd ?? null,
|
|
1240
|
+
tasksPerWeek: f?.perWeek ?? null,
|
|
1241
|
+
// Why there are no dates, when there aren't: no pace yet, paused, done.
|
|
1242
|
+
noForecast: f ? null : s.tide.kind,
|
|
1243
|
+
};
|
|
1244
|
+
};
|
|
1196
1245
|
const c = result.comparison;
|
|
1197
1246
|
out(
|
|
1198
1247
|
jsonText({
|
|
@@ -1201,10 +1250,10 @@ export async function run(argv, io = {}) {
|
|
|
1201
1250
|
real: side(c.real),
|
|
1202
1251
|
dryDock: side(c.docked),
|
|
1203
1252
|
shiftDays: c.shift,
|
|
1204
|
-
|
|
1253
|
+
crewPacePercent: Math.round(c.pace * 100),
|
|
1205
1254
|
...(result.applied !== undefined ? { applied: result.applied } : {}),
|
|
1206
1255
|
...(result.saved ? { saved: true } : {}),
|
|
1207
|
-
notes,
|
|
1256
|
+
notes: [...c.notes, ...notes],
|
|
1208
1257
|
}),
|
|
1209
1258
|
);
|
|
1210
1259
|
return 0;
|
|
@@ -1759,7 +1808,9 @@ export async function run(argv, io = {}) {
|
|
|
1759
1808
|
);
|
|
1760
1809
|
}
|
|
1761
1810
|
if (values.confirm || values["over-limit"]) {
|
|
1762
|
-
throw new HarborError(
|
|
1811
|
+
throw new HarborError(
|
|
1812
|
+
"--confirm and --over-limit are for moving a task: harbor submit TASK.",
|
|
1813
|
+
);
|
|
1763
1814
|
}
|
|
1764
1815
|
if (values.mine && values.owner) throw new HarborError("Say --mine or --owner, not both.");
|
|
1765
1816
|
return call(
|
|
@@ -2051,6 +2102,65 @@ export async function run(argv, io = {}) {
|
|
|
2051
2102
|
(r) => `Sealed your note on ${r.sealed.project}. It ${r.sealed.opens}.`,
|
|
2052
2103
|
);
|
|
2053
2104
|
}
|
|
2105
|
+
case "sound": {
|
|
2106
|
+
const { positionals, values, harbor } = parse(spec([], ["why", "ask", "settle"]));
|
|
2107
|
+
const [task, days, ...extra] = positionals;
|
|
2108
|
+
if (!task) throw new HarborError("Give a task's key, like harbor sound RR-4.");
|
|
2109
|
+
if (extra.length) throw new HarborError(`Unexpected "${extra.join(" ")}".`);
|
|
2110
|
+
const number = (text) => {
|
|
2111
|
+
const n = Number(String(text).replace(/d(ays?)?$/i, ""));
|
|
2112
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
2113
|
+
throw new HarborError(`Give days as a number, like 3 or 2.5 (not "${text}").`);
|
|
2114
|
+
return n;
|
|
2115
|
+
};
|
|
2116
|
+
const given = [days !== undefined, values.ask !== undefined, values.settle !== undefined];
|
|
2117
|
+
if (given.filter(Boolean).length > 1)
|
|
2118
|
+
throw new HarborError("Answer, ask or settle: one at a time.");
|
|
2119
|
+
if (values.why !== undefined && days === undefined)
|
|
2120
|
+
throw new HarborError('--why goes with your answer: harbor sound TASK 4 --why "TEXT".');
|
|
2121
|
+
if (values.ask !== undefined) {
|
|
2122
|
+
const crew = /^crew$/i.test(values.ask.trim())
|
|
2123
|
+
? undefined
|
|
2124
|
+
: values.ask
|
|
2125
|
+
.split(",")
|
|
2126
|
+
.map((x) => x.trim())
|
|
2127
|
+
.filter(Boolean);
|
|
2128
|
+
return call(
|
|
2129
|
+
"take_sounding",
|
|
2130
|
+
{ harbor, task, ...(crew ? { people: crew } : {}) },
|
|
2131
|
+
(r) =>
|
|
2132
|
+
`Asked ${r.asked.people.join(", ")} how many days ${r.asked.key ?? r.asked.title} will take. The answers show together once everyone's in.`,
|
|
2133
|
+
);
|
|
2134
|
+
}
|
|
2135
|
+
if (values.settle !== undefined) {
|
|
2136
|
+
return call(
|
|
2137
|
+
"settle_sounding",
|
|
2138
|
+
{ harbor, task, days: number(values.settle) },
|
|
2139
|
+
(r) => `Settled ${r.settled.key ?? r.settled.title} at ${daysText(r.settled.days)}.`,
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
if (days !== undefined) {
|
|
2143
|
+
return call(
|
|
2144
|
+
"answer_sounding",
|
|
2145
|
+
{ harbor, task, days: number(days), ...(values.why ? { why: values.why } : {}) },
|
|
2146
|
+
(r) => soundingText(r.answered, r.sounding, { answered: true }),
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2149
|
+
return call(
|
|
2150
|
+
"get_task",
|
|
2151
|
+
{ harbor, task },
|
|
2152
|
+
(r) => soundingText(r, r.sounding ?? null),
|
|
2153
|
+
(r) => ({ id: r.id, key: r.key ?? null, title: r.title, sounding: r.sounding ?? null }),
|
|
2154
|
+
);
|
|
2155
|
+
}
|
|
2156
|
+
case "soundings": {
|
|
2157
|
+
const { positionals, harbor } = parse(spec());
|
|
2158
|
+
const [project, ...extra] = positionals;
|
|
2159
|
+
if (extra.length) throw new HarborError(`Unexpected "${extra.join(" ")}".`);
|
|
2160
|
+
return call("list_soundings", { harbor, ...(project ? { project } : {}) }, (r) =>
|
|
2161
|
+
soundingsText(r),
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2054
2164
|
case "voyage": {
|
|
2055
2165
|
const { positionals, harbor } = parse(spec());
|
|
2056
2166
|
const [project, ...words] = positionals;
|
package/lib/format.mjs
CHANGED
|
@@ -600,6 +600,78 @@ export function notesText(project, notes) {
|
|
|
600
600
|
return lines.join("\n");
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
+
/** "half a day", "1 day", "2.5 days". */
|
|
604
|
+
export function daysText(days) {
|
|
605
|
+
if (days === 0.5) return "half a day";
|
|
606
|
+
return `${days} day${days === 1 ? "" : "s"}`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** The spread as a line of dots, 0 to the highest guess, like the website's. */
|
|
610
|
+
function spreadLine(answers, width = 30) {
|
|
611
|
+
const days = answers.map((a) => a.days);
|
|
612
|
+
const top = Math.max(5, ...days);
|
|
613
|
+
const cells = Array(width + 1).fill("·");
|
|
614
|
+
for (const d of days) cells[Math.round((d / top) * width)] = "●";
|
|
615
|
+
return `0 ${cells.join("")} ${daysText(top)}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** One task's sounding: who's left to answer, or the spread and what it means. */
|
|
619
|
+
export function soundingText(task, s, { answered = false } = {}) {
|
|
620
|
+
const name = task.key ? `${task.key} ${task.title}` : task.title;
|
|
621
|
+
if (!s)
|
|
622
|
+
return `No sounding on ${name}. Ask the crew: harbor sound ${task.key ?? task.id} --ask crew`;
|
|
623
|
+
const lines = [`Sounding · ${name} · ${s.answered} of ${s.crew.length} answered`];
|
|
624
|
+
if (answered) lines[0] = `Your answer is in. ${lines[0]}`;
|
|
625
|
+
if (!s.shown) {
|
|
626
|
+
if (s.waitingOn.length) lines.push(` Waiting on ${s.waitingOn.join(", ")}.`);
|
|
627
|
+
lines.push(" The answers show together once everyone's in.");
|
|
628
|
+
for (const a of s.answers)
|
|
629
|
+
lines.push(` You said ${daysText(a.days)}${a.why ? ` · "${a.why}"` : ""}`);
|
|
630
|
+
if (s.youWereAsked && !s.answers.length)
|
|
631
|
+
lines.push(` Give yours: harbor sound ${task.key ?? task.id} DAYS`);
|
|
632
|
+
return lines.join("\n");
|
|
633
|
+
}
|
|
634
|
+
lines.push(` ${spreadLine(s.answers)}`);
|
|
635
|
+
for (const a of s.answers)
|
|
636
|
+
lines.push(` ${a.who}: ${daysText(a.days)}${a.why ? ` · "${a.why}"` : ""}`);
|
|
637
|
+
if (s.settled) lines.push(` Settled on ${daysText(s.estimateDays)}.`);
|
|
638
|
+
else if (s.talkFirst)
|
|
639
|
+
lines.push(
|
|
640
|
+
` Talk about this first: ${daysText(s.low)} to ${daysText(s.high)} means someone sees work the others don't.`,
|
|
641
|
+
` Once you've talked: harbor sound ${task.key ?? task.id} --settle DAYS`,
|
|
642
|
+
);
|
|
643
|
+
else lines.push(` Close enough: about ${daysText(s.estimateDays)}.`);
|
|
644
|
+
return lines.join("\n");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** list_soundings: the ones waiting on you first, then the rest, wide ones flagged. */
|
|
648
|
+
export function soundingsText(r) {
|
|
649
|
+
const all = r.soundings ?? [];
|
|
650
|
+
const where = r.project ? ` on ${r.project}` : "";
|
|
651
|
+
if (!all.length)
|
|
652
|
+
return `No soundings${where}. Ask how long a task will take: harbor sound TASK --ask crew`;
|
|
653
|
+
const name = (t) => (t.key ? `${t.key} ${t.title}` : t.title);
|
|
654
|
+
const yours = all.filter((t) => t.youWereAsked && !t.answers.length && !t.shown);
|
|
655
|
+
const lines = [];
|
|
656
|
+
if (yours.length) {
|
|
657
|
+
lines.push("Waiting on your answer");
|
|
658
|
+
for (const t of yours) lines.push(` ${name(t)} · asked by ${t.askedBy ?? "someone"}`);
|
|
659
|
+
lines.push("");
|
|
660
|
+
}
|
|
661
|
+
lines.push(`Soundings${where}`);
|
|
662
|
+
for (const t of all) {
|
|
663
|
+
const state = t.settled
|
|
664
|
+
? `settled · ${daysText(t.estimateDays)}`
|
|
665
|
+
: t.talkFirst
|
|
666
|
+
? `talk first · ${daysText(t.low)} to ${daysText(t.high)}`
|
|
667
|
+
: t.shown
|
|
668
|
+
? `about ${daysText(t.estimateDays)}`
|
|
669
|
+
: `${t.answered} of ${t.crew.length} answered`;
|
|
670
|
+
lines.push(` ${name(t)} · ${state}`);
|
|
671
|
+
}
|
|
672
|
+
return lines.join("\n");
|
|
673
|
+
}
|
|
674
|
+
|
|
603
675
|
/** handover_plan as a list grouped like the website's dialog, each line with who takes it. */
|
|
604
676
|
export function handoverText(r, { confirmHint = true } = {}) {
|
|
605
677
|
const items = r.items ?? [];
|