@harborppm/cli 0.33.0 → 0.35.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 +2 -1
- package/dist/app.mjs +183 -7
- package/lib/cli.mjs +68 -7
- package/lib/format.mjs +29 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -128,6 +128,7 @@ harbor brief [--portfolio P] [--say] the weekly brief, ready to send
|
|
|
128
128
|
harbor report PROGRAM a program's status report
|
|
129
129
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
130
130
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
131
|
+
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
131
132
|
harbor limit PROJECT [COLUMN N|none]
|
|
132
133
|
harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT] [--keep ID,ID] [--confirm]
|
|
133
134
|
harbor handback PERSON
|
|
@@ -137,7 +138,7 @@ harbor handback PERSON
|
|
|
137
138
|
|
|
138
139
|
`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.
|
|
139
140
|
|
|
140
|
-
`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.
|
|
141
|
+
`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.
|
|
141
142
|
|
|
142
143
|
`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).
|
|
143
144
|
|
package/dist/app.mjs
CHANGED
|
@@ -3524,6 +3524,123 @@ function programLateCount(programId, data, now) {
|
|
|
3524
3524
|
return late;
|
|
3525
3525
|
}
|
|
3526
3526
|
|
|
3527
|
+
//#endregion
|
|
3528
|
+
//#region src/lib/bottle-rules.ts
|
|
3529
|
+
/**
|
|
3530
|
+
* What a note for later needs before it's sealed (the rest is in bottle.ts).
|
|
3531
|
+
* Apart so the store's action carries only this into every page's first load.
|
|
3532
|
+
*/
|
|
3533
|
+
/** Longest note kept. */
|
|
3534
|
+
const BOTTLE_MAX = 1e3;
|
|
3535
|
+
/**
|
|
3536
|
+
* Why a new note can't be sealed, or null when it can: nothing written, a
|
|
3537
|
+
* date that isn't after today (it would open at once), or a milestone that
|
|
3538
|
+
* isn't this project's program's or is already reached.
|
|
3539
|
+
*/
|
|
3540
|
+
function bottleProblem(body, opens, ctx) {
|
|
3541
|
+
if (!body.trim()) return "Write the note first.";
|
|
3542
|
+
if (opens.on === "date") {
|
|
3543
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(opens.date)) return "Pick the day it opens.";
|
|
3544
|
+
if (opens.date <= todayISO(ctx.now)) return "Pick a day after today.";
|
|
3545
|
+
}
|
|
3546
|
+
if (opens.on === "milestone") {
|
|
3547
|
+
const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
|
|
3548
|
+
if (!m || m.programId !== ctx.programId) return "Pick one of this program's milestones.";
|
|
3549
|
+
if (m.done) return `${m.name} is already reached.`;
|
|
3550
|
+
}
|
|
3551
|
+
return null;
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
//#endregion
|
|
3555
|
+
//#region src/lib/bottle.ts
|
|
3556
|
+
/**
|
|
3557
|
+
* Message in a bottle: a note someone leaves on a project now that opens
|
|
3558
|
+
* later, on a date, when one of the program's milestones is reached, or if
|
|
3559
|
+
* the project turns Off track. Until then the crew sees only that a sealed
|
|
3560
|
+
* note is there, who left it and what opens it; the writer can read their
|
|
3561
|
+
* own. Once open it sits at the top of the project and in the brief.
|
|
3562
|
+
*
|
|
3563
|
+
* Notes are `reflections` of kind "bottle". Whether one is open is worked
|
|
3564
|
+
* out from the project as it stands (nothing stamps it), so every screen,
|
|
3565
|
+
* the CLI and the agents agree. Like the pre-mortem's wait, the seal is a
|
|
3566
|
+
* courtesy the screens keep, not a secret from the server.
|
|
3567
|
+
*/
|
|
3568
|
+
/** An opened note stays at the top of its project this many days, then folds away. */
|
|
3569
|
+
const BOTTLE_FRESH_DAYS = 14;
|
|
3570
|
+
const isBottle = (r) => r.kind === "bottle";
|
|
3571
|
+
/**
|
|
3572
|
+
* Whether a note is open, and since when. A date opens it that morning; a
|
|
3573
|
+
* milestone when it's marked done (dated by its due date, or today if that's
|
|
3574
|
+
* still ahead); Off track the first time the project went Off track after
|
|
3575
|
+
* the note was left. A milestone since deleted opens it, so nothing is
|
|
3576
|
+
* sealed for good.
|
|
3577
|
+
*/
|
|
3578
|
+
function bottleState(note, ctx) {
|
|
3579
|
+
const today = todayISO(ctx.now);
|
|
3580
|
+
const opens = note.opens;
|
|
3581
|
+
if (!opens) return {
|
|
3582
|
+
open: true,
|
|
3583
|
+
openedOn: note.createdAt.slice(0, 10)
|
|
3584
|
+
};
|
|
3585
|
+
if (opens.on === "date") return today >= opens.date ? {
|
|
3586
|
+
open: true,
|
|
3587
|
+
openedOn: opens.date
|
|
3588
|
+
} : sealed;
|
|
3589
|
+
if (opens.on === "milestone") {
|
|
3590
|
+
const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
|
|
3591
|
+
if (!m) return {
|
|
3592
|
+
open: true,
|
|
3593
|
+
openedOn: today
|
|
3594
|
+
};
|
|
3595
|
+
if (!m.done) return sealed;
|
|
3596
|
+
return {
|
|
3597
|
+
open: true,
|
|
3598
|
+
openedOn: m.dueDate && m.dueDate < today ? m.dueDate : today
|
|
3599
|
+
};
|
|
3600
|
+
}
|
|
3601
|
+
const flip = (ctx.project.healthHistory ?? []).find((h) => h.health === "off-track" && h.at >= note.createdAt);
|
|
3602
|
+
if (flip) return {
|
|
3603
|
+
open: true,
|
|
3604
|
+
openedOn: flip.at.slice(0, 10)
|
|
3605
|
+
};
|
|
3606
|
+
if (ctx.project.health === "off-track") return {
|
|
3607
|
+
open: true,
|
|
3608
|
+
openedOn: today
|
|
3609
|
+
};
|
|
3610
|
+
return sealed;
|
|
3611
|
+
}
|
|
3612
|
+
const sealed = {
|
|
3613
|
+
open: false,
|
|
3614
|
+
openedOn: null
|
|
3615
|
+
};
|
|
3616
|
+
/** Opened in the last `days` days, so it still belongs at the top and in the brief. */
|
|
3617
|
+
function isFresh(state, now, days = 14) {
|
|
3618
|
+
if (!state.open || !state.openedOn) return false;
|
|
3619
|
+
return differenceInCalendarDays(now, parseIso(state.openedOn)) < days;
|
|
3620
|
+
}
|
|
3621
|
+
/** Notes opened in the last fortnight across these projects, newest first: the brief's. */
|
|
3622
|
+
function freshlyOpened(data, projects, now) {
|
|
3623
|
+
const notes = (data.reflections ?? []).filter(isBottle);
|
|
3624
|
+
if (notes.length === 0) return [];
|
|
3625
|
+
const byProject = new Map(projects.map((p) => [p.id, p]));
|
|
3626
|
+
const out = [];
|
|
3627
|
+
for (const note of notes) {
|
|
3628
|
+
const project = byProject.get(note.projectId);
|
|
3629
|
+
if (!project) continue;
|
|
3630
|
+
const state = bottleState(note, {
|
|
3631
|
+
project,
|
|
3632
|
+
milestones: data.milestones,
|
|
3633
|
+
now
|
|
3634
|
+
});
|
|
3635
|
+
if (isFresh(state, now)) out.push({
|
|
3636
|
+
note,
|
|
3637
|
+
project,
|
|
3638
|
+
state
|
|
3639
|
+
});
|
|
3640
|
+
}
|
|
3641
|
+
return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3527
3644
|
//#endregion
|
|
3528
3645
|
//#region src/lib/brief.ts
|
|
3529
3646
|
const BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
|
|
@@ -3618,6 +3735,14 @@ function composeBriefBody(opts) {
|
|
|
3618
3735
|
const lateMilestones = milestones.filter((m) => m.daysLate > 0);
|
|
3619
3736
|
const quiet = opts.offTrack.length === 0 && opts.atRisk.length === 0 && opts.late.length === 0 && watch.length === 0 && lateMilestones.length === 0;
|
|
3620
3737
|
const lines = [`Harbor brief — ${opts.workspace} — ${date}`, ""];
|
|
3738
|
+
if (opts.opened?.length) {
|
|
3739
|
+
lines.push("Notes that opened");
|
|
3740
|
+
for (const n of opts.opened) {
|
|
3741
|
+
const body = n.body.replace(/\s+/g, " ").trim();
|
|
3742
|
+
lines.push(`• ${n.project}${n.who ? `, from ${n.who}` : ""}: “${body}”`);
|
|
3743
|
+
}
|
|
3744
|
+
lines.push("");
|
|
3745
|
+
}
|
|
3621
3746
|
const done = () => {
|
|
3622
3747
|
if (!opts.shipped?.length) return;
|
|
3623
3748
|
lines.push("Done this week");
|
|
@@ -3861,6 +3986,7 @@ function briefOf(data, opts = {}) {
|
|
|
3861
3986
|
finished,
|
|
3862
3987
|
said: saidThisWeek(data, now, portfolioId ? new Set(tasks.flatMap((t) => t.assigneeId ? [t.assigneeId] : [])) : null),
|
|
3863
3988
|
late: lateLinesOf(data, tasks, now),
|
|
3989
|
+
opened: freshlyOpened(data, projects, now),
|
|
3864
3990
|
talk,
|
|
3865
3991
|
milestonesSoon,
|
|
3866
3992
|
milestonesLate,
|
|
@@ -3906,6 +4032,11 @@ function composeBriefBodyFrom(brief, data, workspace) {
|
|
|
3906
4032
|
program: data.programs.find((p) => p.id === m.programId)?.name ?? null,
|
|
3907
4033
|
dueDate: m.dueDate,
|
|
3908
4034
|
daysLate: -daysUntil(m.dueDate, brief.asOf)
|
|
4035
|
+
})),
|
|
4036
|
+
opened: brief.opened.map((b) => ({
|
|
4037
|
+
project: b.project.name,
|
|
4038
|
+
who: data.people.find((p) => p.id === b.note.authorId)?.name ?? null,
|
|
4039
|
+
body: b.note.body
|
|
3909
4040
|
}))
|
|
3910
4041
|
});
|
|
3911
4042
|
}
|
|
@@ -7757,6 +7888,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
7757
7888
|
const projects = useHarbor((s) => s.projects);
|
|
7758
7889
|
const people = useHarbor((s) => s.people);
|
|
7759
7890
|
const boardConfigs = useHarbor((s) => s.boardConfigs);
|
|
7891
|
+
const reflections = useHarbor((s) => s.reflections);
|
|
7892
|
+
const milestones = useHarbor((s) => s.milestones);
|
|
7760
7893
|
const project = projects.find((p) => p.id === projectId);
|
|
7761
7894
|
const data = useMemo(() => ({
|
|
7762
7895
|
tasks,
|
|
@@ -7893,7 +8026,12 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
7893
8026
|
const tide = tideLine(data, project, day);
|
|
7894
8027
|
const filtered = [filter.who === "none" ? "nobody's" : filter.who !== "any" ? `${people.find((p) => p.id === filter.who)?.name ?? "someone"}'s` : null, filter.priority !== "any" ? PRIORITY_LABEL[filter.priority].toLowerCase() : null].filter(Boolean);
|
|
7895
8028
|
const lead = people.find((p) => p.id === project.ownerId);
|
|
7896
|
-
const
|
|
8029
|
+
const opened = freshlyOpened({
|
|
8030
|
+
reflections,
|
|
8031
|
+
milestones
|
|
8032
|
+
}, [project], /* @__PURE__ */ new Date(`${day}T12:00:00`))[0];
|
|
8033
|
+
const openedBy = opened ? people.find((p) => p.id === opened.note.authorId)?.name : null;
|
|
8034
|
+
const body = height - 3 - (opened ? 1 : 0);
|
|
7897
8035
|
const fit = Math.max(1, Math.floor((width - 2) / 14));
|
|
7898
8036
|
const firstColumn = Math.min(Math.max(0, col - Math.floor(fit / 2)), Math.max(0, columns.length - fit));
|
|
7899
8037
|
const shownColumns = columns.slice(firstColumn, firstColumn + fit);
|
|
@@ -7968,6 +8106,21 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
7968
8106
|
]
|
|
7969
8107
|
})]
|
|
7970
8108
|
}),
|
|
8109
|
+
opened && /* @__PURE__ */ jsxs(Text, {
|
|
8110
|
+
wrap: "truncate-end",
|
|
8111
|
+
color: theme.warn,
|
|
8112
|
+
children: [
|
|
8113
|
+
"A note opened",
|
|
8114
|
+
openedBy ? `, from ${openedBy}` : "",
|
|
8115
|
+
":",
|
|
8116
|
+
" ",
|
|
8117
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
8118
|
+
"“",
|
|
8119
|
+
opened.note.body.replace(/\s+/g, " "),
|
|
8120
|
+
"”"
|
|
8121
|
+
] })
|
|
8122
|
+
]
|
|
8123
|
+
}),
|
|
7971
8124
|
mode === "budget" ? /* @__PURE__ */ jsx(BudgetPane, {
|
|
7972
8125
|
project,
|
|
7973
8126
|
spendEntries,
|
|
@@ -11322,13 +11475,36 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
11322
11475
|
return row.id;
|
|
11323
11476
|
},
|
|
11324
11477
|
editReflection: (id, body) => {
|
|
11325
|
-
const text = body.trim().slice(0, 600);
|
|
11326
|
-
if (!text) return;
|
|
11327
11478
|
const me = get().currentUserId;
|
|
11328
|
-
write({ reflections: (get().reflections ?? []).map((r) =>
|
|
11329
|
-
|
|
11330
|
-
body:
|
|
11331
|
-
|
|
11479
|
+
write({ reflections: (get().reflections ?? []).map((r) => {
|
|
11480
|
+
if (r.id !== id || r.authorId !== me) return r;
|
|
11481
|
+
const text = body.trim().slice(0, r.kind === "bottle" ? BOTTLE_MAX : 600);
|
|
11482
|
+
return text ? {
|
|
11483
|
+
...r,
|
|
11484
|
+
body: text
|
|
11485
|
+
} : r;
|
|
11486
|
+
}) });
|
|
11487
|
+
},
|
|
11488
|
+
leaveBottle: (projectId, body, opens) => {
|
|
11489
|
+
const s = get();
|
|
11490
|
+
const project = s.projects.find((p) => p.id === projectId);
|
|
11491
|
+
if (!project) return null;
|
|
11492
|
+
if (bottleProblem(body, opens, {
|
|
11493
|
+
programId: project.programId,
|
|
11494
|
+
milestones: s.milestones,
|
|
11495
|
+
now: clockNow()
|
|
11496
|
+
})) return null;
|
|
11497
|
+
const row = {
|
|
11498
|
+
id: uid("rf"),
|
|
11499
|
+
projectId,
|
|
11500
|
+
kind: "bottle",
|
|
11501
|
+
authorId: s.currentUserId,
|
|
11502
|
+
body: body.trim().slice(0, BOTTLE_MAX),
|
|
11503
|
+
createdAt: stamp(),
|
|
11504
|
+
opens
|
|
11505
|
+
};
|
|
11506
|
+
write({ reflections: [...s.reflections ?? [], row] });
|
|
11507
|
+
return row.id;
|
|
11332
11508
|
},
|
|
11333
11509
|
deleteReflection: (id) => {
|
|
11334
11510
|
const me = get().currentUserId;
|
package/lib/cli.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
hillList,
|
|
27
27
|
myWork,
|
|
28
28
|
premortemText,
|
|
29
|
+
notesText,
|
|
29
30
|
voyageText,
|
|
30
31
|
handoverText,
|
|
31
32
|
raidList,
|
|
@@ -117,6 +118,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
117
118
|
answer or read it
|
|
118
119
|
harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
|
|
119
120
|
your line in it
|
|
121
|
+
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
122
|
+
a note for later: sealed until
|
|
123
|
+
its day, milestone, or Off track
|
|
120
124
|
harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
|
|
121
125
|
[--keep ID,ID] [--confirm]
|
|
122
126
|
hand someone's work to the crew
|
|
@@ -289,6 +293,8 @@ const FLAG = {
|
|
|
289
293
|
column: "the column",
|
|
290
294
|
limit: "the limit",
|
|
291
295
|
at: "--at",
|
|
296
|
+
opensOn: "--opens",
|
|
297
|
+
milestone: "--milestone",
|
|
292
298
|
until: "--until",
|
|
293
299
|
to: "--to",
|
|
294
300
|
keep: "--keep",
|
|
@@ -296,7 +302,7 @@ const FLAG = {
|
|
|
296
302
|
};
|
|
297
303
|
|
|
298
304
|
/** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
|
|
299
|
-
export function hint(message) {
|
|
305
|
+
export function hint(message, project) {
|
|
300
306
|
const said = message.replace(/\s*Ask the person;.*$/s, "");
|
|
301
307
|
if (/confirmed: true/.test(message))
|
|
302
308
|
return `${said}\nTo finish it anyway, run it again with --confirm.`;
|
|
@@ -331,8 +337,13 @@ export function hint(message) {
|
|
|
331
337
|
.replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
|
|
332
338
|
.replace(
|
|
333
339
|
/^(.+) has no pre-mortem running\. Start one with start_premortem first\.$/s,
|
|
340
|
+
// What the person typed found it, so the command they're told to run uses that (the name may be shared).
|
|
334
341
|
(_, name) =>
|
|
335
|
-
`${name} has no pre-mortem running. Start one with harbor premortem "${name}" --start.`,
|
|
342
|
+
`${name} has no pre-mortem running. Start one with harbor premortem "${project ?? name}" --start.`,
|
|
343
|
+
)
|
|
344
|
+
.replace(
|
|
345
|
+
/^Say what opens the note: exactly one of opensOn, milestone or ifOffTrack\.$/,
|
|
346
|
+
"Say what opens it: --opens DATE, --milestone NAME or --off-track (one of them).",
|
|
336
347
|
)
|
|
337
348
|
.replace(
|
|
338
349
|
/^Nothing to change: pass a field to update\.$/,
|
|
@@ -353,11 +364,20 @@ export function hint(message) {
|
|
|
353
364
|
}
|
|
354
365
|
|
|
355
366
|
/** get_premortem's answer with its note naming the command, not the agent tool, since --json shows it. */
|
|
356
|
-
export function premortemForTerminal(r) {
|
|
367
|
+
export function premortemForTerminal(r, project = r.project) {
|
|
357
368
|
if (r.running || !r.note) return r;
|
|
358
369
|
return {
|
|
359
370
|
...r,
|
|
360
|
-
note: `No pre-mortem on ${r.project} yet. Start one with harbor premortem "${
|
|
371
|
+
note: `No pre-mortem on ${r.project} yet. Start one with harbor premortem "${project}" --start.`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** get_voyage_log's answer with its note on an empty crew naming the command, not the agent tool. */
|
|
376
|
+
export function voyageForTerminal(r, project = r.project) {
|
|
377
|
+
if (!r.crewNote) return r;
|
|
378
|
+
return {
|
|
379
|
+
...r,
|
|
380
|
+
crewNote: `crew is the lines people add to the log; nobody has yet. Add yours with harbor voyage "${project}" "YOUR LINE".`,
|
|
361
381
|
};
|
|
362
382
|
}
|
|
363
383
|
|
|
@@ -386,6 +406,19 @@ const DETAIL = {
|
|
|
386
406
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
387
407
|
" name, give the key.",
|
|
388
408
|
],
|
|
409
|
+
note: [
|
|
410
|
+
" harbor note PROJECT reads its notes for later: who left",
|
|
411
|
+
" each, what opens it, and the opened ones",
|
|
412
|
+
' harbor note PROJECT "NOTE" --opens DATE',
|
|
413
|
+
" writes: seals a note that opens that day",
|
|
414
|
+
' harbor note PROJECT "NOTE" --milestone NAME',
|
|
415
|
+
" writes: opens when that milestone is reached",
|
|
416
|
+
' harbor note PROJECT "NOTE" --off-track',
|
|
417
|
+
" writes: opens if the project turns Off track",
|
|
418
|
+
"",
|
|
419
|
+
" Until it opens, the crew sees only that a note is there and who left it.",
|
|
420
|
+
" DATE: YYYY-MM-DD, tomorrow, or +N for N days from today.",
|
|
421
|
+
],
|
|
389
422
|
handover: [
|
|
390
423
|
" harbor handover PERSON reads what would move, and to whom:",
|
|
391
424
|
" their open tasks, sign-offs, risks",
|
|
@@ -405,6 +438,7 @@ const DETAIL = {
|
|
|
405
438
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
406
439
|
' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
|
|
407
440
|
"",
|
|
441
|
+
" In --json, crew is the lines people added; closedBy is who closed tasks.",
|
|
408
442
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
409
443
|
" name, give the key.",
|
|
410
444
|
],
|
|
@@ -790,9 +824,10 @@ export async function run(argv, io = {}) {
|
|
|
790
824
|
const call = async (name, args, show, forTerminal = (r) => r) => {
|
|
791
825
|
const result = forTerminal(
|
|
792
826
|
await client.call(name, args).catch((err) => {
|
|
793
|
-
if (err instanceof HarborError) throw new HarborError(hint(err.message));
|
|
827
|
+
if (err instanceof HarborError) throw new HarborError(hint(err.message, args.project));
|
|
794
828
|
throw err;
|
|
795
829
|
}),
|
|
830
|
+
args.project,
|
|
796
831
|
);
|
|
797
832
|
// The prompt's counts are behind now; its next draw fetches new ones.
|
|
798
833
|
if (name === "create_task" || name === "update_task") await stalePromptCache(env);
|
|
@@ -1307,7 +1342,7 @@ export async function run(argv, io = {}) {
|
|
|
1307
1342
|
const answer = words.join(" ").trim();
|
|
1308
1343
|
if (values.start) {
|
|
1309
1344
|
const started = await client.call("start_premortem", { harbor, project }).catch((err) => {
|
|
1310
|
-
if (err instanceof HarborError) throw new HarborError(hint(err.message));
|
|
1345
|
+
if (err instanceof HarborError) throw new HarborError(hint(err.message, project));
|
|
1311
1346
|
throw err;
|
|
1312
1347
|
});
|
|
1313
1348
|
if (!answer) {
|
|
@@ -1350,6 +1385,32 @@ export async function run(argv, io = {}) {
|
|
|
1350
1385
|
limitsText(r.set.project, r.limits, r.tasks, r.hiddenColumns),
|
|
1351
1386
|
);
|
|
1352
1387
|
}
|
|
1388
|
+
case "note": {
|
|
1389
|
+
const { positionals, values, harbor } = parse(spec(["off-track"], ["opens", "milestone"]));
|
|
1390
|
+
const [project, ...words] = positionals;
|
|
1391
|
+
if (!project)
|
|
1392
|
+
throw new HarborError(
|
|
1393
|
+
'Give a project, then the note: harbor note "NAME" "NOTE" --opens +30.',
|
|
1394
|
+
);
|
|
1395
|
+
const note = words.join(" ").trim();
|
|
1396
|
+
if (!note) {
|
|
1397
|
+
if (values.opens || values.milestone || values["off-track"])
|
|
1398
|
+
throw new HarborError("Write the note too, in quotes after the project.");
|
|
1399
|
+
return call("get_project", { harbor, project }, (r) => notesText(r.name, r.notesForLater));
|
|
1400
|
+
}
|
|
1401
|
+
return call(
|
|
1402
|
+
"leave_note",
|
|
1403
|
+
{
|
|
1404
|
+
harbor,
|
|
1405
|
+
project,
|
|
1406
|
+
note,
|
|
1407
|
+
...(values.opens ? { opensOn: dueDate(values.opens) } : {}),
|
|
1408
|
+
...(values.milestone ? { milestone: values.milestone } : {}),
|
|
1409
|
+
...(values["off-track"] ? { ifOffTrack: true } : {}),
|
|
1410
|
+
},
|
|
1411
|
+
(r) => `Sealed your note on ${r.sealed.project}. It ${r.sealed.opens}.`,
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1353
1414
|
case "voyage": {
|
|
1354
1415
|
const { positionals, harbor } = parse(spec());
|
|
1355
1416
|
const [project, ...words] = positionals;
|
|
@@ -1362,7 +1423,7 @@ export async function run(argv, io = {}) {
|
|
|
1362
1423
|
(r) => `Your line is in ${r.added.project}'s voyage log.`,
|
|
1363
1424
|
);
|
|
1364
1425
|
}
|
|
1365
|
-
return call("get_voyage_log", { harbor, project }, voyageText);
|
|
1426
|
+
return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
|
|
1366
1427
|
}
|
|
1367
1428
|
case "handover": {
|
|
1368
1429
|
const { positionals, values, harbor } = parse(
|
package/lib/format.mjs
CHANGED
|
@@ -370,6 +370,35 @@ export function hillList(r) {
|
|
|
370
370
|
return blocks.join("\n\n");
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
/** A project's notes for later, as get_project lists them. */
|
|
374
|
+
export function notesText(project, notes) {
|
|
375
|
+
if (!notes?.length)
|
|
376
|
+
return `No notes for later on ${project}. Leave one: harbor note "${project}" "NOTE" --opens +30`;
|
|
377
|
+
const open = notes.filter((n) => n.open);
|
|
378
|
+
const sealed = notes.filter((n) => !n.open);
|
|
379
|
+
const lines = [`Notes for later · ${project}`];
|
|
380
|
+
for (const n of open) {
|
|
381
|
+
const when = n.openedOn ? `Opened ${n.openedOn}` : "Opened";
|
|
382
|
+
lines.push("", ` ${when} · left by ${n.yours ? "you" : (n.from ?? "someone")} on ${n.left}`);
|
|
383
|
+
lines.push(
|
|
384
|
+
...String(n.note ?? "")
|
|
385
|
+
.split("\n")
|
|
386
|
+
.map((l) => ` ${l}`),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (sealed.length) lines.push("");
|
|
390
|
+
for (const n of sealed) {
|
|
391
|
+
lines.push(` Sealed · ${n.yours ? "yours" : `from ${n.from ?? "someone"}`} · ${n.opens}`);
|
|
392
|
+
if (n.yours && n.note)
|
|
393
|
+
lines.push(
|
|
394
|
+
...String(n.note)
|
|
395
|
+
.split("\n")
|
|
396
|
+
.map((l) => ` ${l}`),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return lines.join("\n");
|
|
400
|
+
}
|
|
401
|
+
|
|
373
402
|
/** handover_plan as a list grouped like the website's dialog, each line with who takes it. */
|
|
374
403
|
export function handoverText(r, { confirmHint = true } = {}) {
|
|
375
404
|
const items = r.items ?? [];
|