@harborppm/cli 0.15.0 → 0.18.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 +4 -0
- package/dist/app.mjs +172 -7
- package/lib/cli.mjs +54 -0
- package/lib/format.mjs +40 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -116,8 +116,12 @@ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
|
116
116
|
[--due DATE]
|
|
117
117
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
118
118
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
119
|
+
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
120
|
+
harbor voyage PROJECT ["YOUR LINE"]
|
|
119
121
|
```
|
|
120
122
|
|
|
123
|
+
`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.
|
|
124
|
+
|
|
121
125
|
Every command takes `--json` for the raw result (a refusal then comes out as `{"error": "…"}` on standard output, still with exit code 1), and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
|
|
122
126
|
|
|
123
127
|
When you belong to more than one harbor, say which: `--harbor NAME` on the command, or `harbor use NAME` once (it's saved with your sign-in; `harbor use none` forgets it). With neither, the commands ask rather than guess, so nothing lands in the wrong harbor. The full-screen app shares the same choice: it opens the harbor `harbor use` set, asks which one the first time if none is set, and a harbor you switch to in the app becomes the one the commands use.
|
package/dist/app.mjs
CHANGED
|
@@ -855,7 +855,8 @@ function normalizeRaidItem(r) {
|
|
|
855
855
|
createdAt: r.createdAt ?? now,
|
|
856
856
|
updatedAt: r.updatedAt ?? r.createdAt ?? now,
|
|
857
857
|
updates: Array.isArray(r.updates) ? r.updates : [],
|
|
858
|
-
scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0
|
|
858
|
+
scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0,
|
|
859
|
+
...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {}
|
|
859
860
|
};
|
|
860
861
|
}
|
|
861
862
|
/** Active first, then by score, then most recently touched. */
|
|
@@ -1434,7 +1435,8 @@ function cascadeDelete(data, kind, id) {
|
|
|
1434
1435
|
...r,
|
|
1435
1436
|
taskIds: r.taskIds.filter((t) => !taskIds.has(t))
|
|
1436
1437
|
})),
|
|
1437
|
-
spendEntries: (data.spendEntries ?? []).filter((e) => !projectIds.has(e.projectId))
|
|
1438
|
+
spendEntries: (data.spendEntries ?? []).filter((e) => !projectIds.has(e.projectId)),
|
|
1439
|
+
reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId))
|
|
1438
1440
|
},
|
|
1439
1441
|
counts: {
|
|
1440
1442
|
programs: programIds.size,
|
|
@@ -7668,6 +7670,7 @@ function pickData(s) {
|
|
|
7668
7670
|
ropes: s.ropes ?? NONE,
|
|
7669
7671
|
checkIns: s.checkIns ?? NONE,
|
|
7670
7672
|
spendEntries: s.spendEntries ?? NONE,
|
|
7673
|
+
reflections: s.reflections ?? NONE,
|
|
7671
7674
|
priorityRules: s.priorityRules ?? null
|
|
7672
7675
|
};
|
|
7673
7676
|
}
|
|
@@ -7878,7 +7881,8 @@ const UNDO_KEYS = [
|
|
|
7878
7881
|
"comments",
|
|
7879
7882
|
"attachments",
|
|
7880
7883
|
"checkIns",
|
|
7881
|
-
"spendEntries"
|
|
7884
|
+
"spendEntries",
|
|
7885
|
+
"reflections"
|
|
7882
7886
|
];
|
|
7883
7887
|
/** Edits to the same fields of the same things this close together are one change. */
|
|
7884
7888
|
const FOLD_MS = 5e3;
|
|
@@ -7893,7 +7897,7 @@ function reversalOf(before, after) {
|
|
|
7893
7897
|
const forward = [];
|
|
7894
7898
|
for (const key of UNDO_KEYS) {
|
|
7895
7899
|
if (before[key] === after[key]) continue;
|
|
7896
|
-
const back = rowChanges(after[key], before[key]);
|
|
7900
|
+
const back = rowChanges(after[key] ?? [], before[key] ?? []);
|
|
7897
7901
|
if (back.length === 0) continue;
|
|
7898
7902
|
reversal[key] = back;
|
|
7899
7903
|
for (const change of back) {
|
|
@@ -7926,7 +7930,7 @@ function labelOf(before, after, forward) {
|
|
|
7926
7930
|
const lead = LEAD.map((key) => forward.filter((f) => f.key === key)).find((l) => l.length > 0);
|
|
7927
7931
|
if (!lead || lead.length !== 1) return "your last change";
|
|
7928
7932
|
const { key, change } = lead[0];
|
|
7929
|
-
const find = (doc, id) => doc[key].find((r) => r.id === id);
|
|
7933
|
+
const find = (doc, id) => (doc[key] ?? []).find((r) => r.id === id);
|
|
7930
7934
|
if (change.kind === "add") {
|
|
7931
7935
|
const name = nameOf(change.row);
|
|
7932
7936
|
return name ? `deleting ${name}` : "your last change";
|
|
@@ -7964,7 +7968,7 @@ function undoStep(doc, step) {
|
|
|
7964
7968
|
for (const reversal of [...step.reversals].reverse()) for (const key of UNDO_KEYS) {
|
|
7965
7969
|
const changes = reversal[key];
|
|
7966
7970
|
if (!changes?.length) continue;
|
|
7967
|
-
const rows = next[key] ?? doc[key];
|
|
7971
|
+
const rows = next[key] ?? doc[key] ?? [];
|
|
7968
7972
|
const result = applyRowChanges(rows, changes, { keepTheirs: true });
|
|
7969
7973
|
next[key] = result.rows;
|
|
7970
7974
|
for (const clash of result.clashes) kept.add(`${key}:${clash.id}:${clash.field}`);
|
|
@@ -8596,6 +8600,7 @@ function hierarchySlice({ get, write }) {
|
|
|
8596
8600
|
comments: back(state.comments, rows.comments),
|
|
8597
8601
|
attachments: back(state.attachments, rows.attachments),
|
|
8598
8602
|
spendEntries: back(state.spendEntries, rows.spendEntries),
|
|
8603
|
+
reflections: back(state.reflections, rows.reflections),
|
|
8599
8604
|
boardConfigs: {
|
|
8600
8605
|
...rows.boardConfigs ?? {},
|
|
8601
8606
|
...state.boardConfigs ?? {}
|
|
@@ -9062,6 +9067,50 @@ function tasksSlice({ set, get, write }) {
|
|
|
9062
9067
|
};
|
|
9063
9068
|
}
|
|
9064
9069
|
|
|
9070
|
+
//#endregion
|
|
9071
|
+
//#region src/lib/premortem.ts
|
|
9072
|
+
/** How long everyone answers alone before the answers show together. */
|
|
9073
|
+
const PREMORTEM_MINUTES = 5;
|
|
9074
|
+
/** A new pre-mortem started now: answers show together five minutes on. */
|
|
9075
|
+
function newPremortem(startedById, now) {
|
|
9076
|
+
return {
|
|
9077
|
+
startedAt: now.toISOString(),
|
|
9078
|
+
startedById,
|
|
9079
|
+
revealAt: new Date(now.getTime() + 5 * 6e4).toISOString()
|
|
9080
|
+
};
|
|
9081
|
+
}
|
|
9082
|
+
/**
|
|
9083
|
+
* A risk's title from an answer: its first sentence, kept under 120
|
|
9084
|
+
* characters at a word boundary, without a closing full stop.
|
|
9085
|
+
*/
|
|
9086
|
+
function riskTitle(answer) {
|
|
9087
|
+
const flat = answer.replace(/\s+/g, " ").trim();
|
|
9088
|
+
const first = /^(.+?[.!?])(\s|$)/.exec(flat)?.[1] ?? flat;
|
|
9089
|
+
let title = first.length <= 120 ? first : `${first.slice(0, 117).replace(/\s+\S*$/, "")}…`;
|
|
9090
|
+
title = title.replace(/\.$/, "");
|
|
9091
|
+
return title;
|
|
9092
|
+
}
|
|
9093
|
+
/** The risk an answer becomes: on its project, open, unrated, with the whole answer as what could happen. */
|
|
9094
|
+
function riskFromAnswer(answer, project, portfolioId) {
|
|
9095
|
+
return {
|
|
9096
|
+
kind: "risk",
|
|
9097
|
+
title: riskTitle(answer.body),
|
|
9098
|
+
description: `${answer.body.trim()}\n\nRaised in the pre-mortem.`,
|
|
9099
|
+
projectId: project.id,
|
|
9100
|
+
programId: project.programId,
|
|
9101
|
+
portfolioId,
|
|
9102
|
+
status: "open",
|
|
9103
|
+
ownerId: null,
|
|
9104
|
+
dueDate: null
|
|
9105
|
+
};
|
|
9106
|
+
}
|
|
9107
|
+
/** The risk's description with another answer folded in as the same worry. */
|
|
9108
|
+
function withFoldedAnswer(description, answer) {
|
|
9109
|
+
const base = description.trim();
|
|
9110
|
+
return `${base}${base ? "\n\n" : ""}Also raised: ${answer.trim()}`;
|
|
9111
|
+
}
|
|
9112
|
+
const STOP = new Set("the a an and or but of to in on for with at by from into onto was were be been being is are it its this that these those we our us they them their he she his her i my me you your had has have did do does not no so too very just than then there here what which who because about after before over under again more most some any all each other only own same can could would should will got get project failed happened went".split(" "));
|
|
9113
|
+
|
|
9065
9114
|
//#endregion
|
|
9066
9115
|
//#region src/lib/raid-history.ts
|
|
9067
9116
|
/**
|
|
@@ -9201,6 +9250,120 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
9201
9250
|
updates: [...r.updates, update],
|
|
9202
9251
|
updatedAt: stamp()
|
|
9203
9252
|
} : r) });
|
|
9253
|
+
},
|
|
9254
|
+
startPremortem: (projectId) => {
|
|
9255
|
+
const project = get().projects.find((p) => p.id === projectId);
|
|
9256
|
+
if (!project || project.premortem) return;
|
|
9257
|
+
const premortem = newPremortem(get().currentUserId, clockNow());
|
|
9258
|
+
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
|
9259
|
+
...p,
|
|
9260
|
+
premortem
|
|
9261
|
+
} : p) });
|
|
9262
|
+
get().log(`started a pre-mortem on ${project.name}`, `/projects/${projectId}?view=risks`);
|
|
9263
|
+
},
|
|
9264
|
+
showPremortemAnswers: (projectId) => {
|
|
9265
|
+
const now = clockNow().toISOString();
|
|
9266
|
+
write({ projects: get().projects.map((p) => p.id === projectId && p.premortem && p.premortem.revealAt > now ? {
|
|
9267
|
+
...p,
|
|
9268
|
+
premortem: {
|
|
9269
|
+
...p.premortem,
|
|
9270
|
+
revealAt: now
|
|
9271
|
+
}
|
|
9272
|
+
} : p) });
|
|
9273
|
+
},
|
|
9274
|
+
answerPremortem: (projectId, body) => {
|
|
9275
|
+
const text = body.trim().slice(0, 600);
|
|
9276
|
+
const project = get().projects.find((p) => p.id === projectId);
|
|
9277
|
+
if (!text || !project?.premortem) return null;
|
|
9278
|
+
const row = {
|
|
9279
|
+
id: uid("rf"),
|
|
9280
|
+
projectId,
|
|
9281
|
+
kind: "premortem",
|
|
9282
|
+
authorId: get().currentUserId,
|
|
9283
|
+
body: text,
|
|
9284
|
+
createdAt: stamp()
|
|
9285
|
+
};
|
|
9286
|
+
write({ reflections: [...get().reflections ?? [], row] });
|
|
9287
|
+
return row.id;
|
|
9288
|
+
},
|
|
9289
|
+
editReflection: (id, body) => {
|
|
9290
|
+
const text = body.trim().slice(0, 600);
|
|
9291
|
+
if (!text) return;
|
|
9292
|
+
const me = get().currentUserId;
|
|
9293
|
+
write({ reflections: (get().reflections ?? []).map((r) => r.id === id && r.authorId === me ? {
|
|
9294
|
+
...r,
|
|
9295
|
+
body: text
|
|
9296
|
+
} : r) });
|
|
9297
|
+
},
|
|
9298
|
+
deleteReflection: (id) => {
|
|
9299
|
+
const me = get().currentUserId;
|
|
9300
|
+
write({ reflections: (get().reflections ?? []).filter((r) => !(r.id === id && r.authorId === me)) });
|
|
9301
|
+
},
|
|
9302
|
+
raisePremortemAnswer: (id) => {
|
|
9303
|
+
const answer = (get().reflections ?? []).find((r) => r.id === id);
|
|
9304
|
+
if (!answer || answer.raidId) return answer?.raidId ?? null;
|
|
9305
|
+
const project = get().projects.find((p) => p.id === answer.projectId);
|
|
9306
|
+
if (!project) return null;
|
|
9307
|
+
const program = get().programs.find((p) => p.id === project.programId);
|
|
9308
|
+
const raidId = get().addRaidItem(riskFromAnswer(answer, project, program?.portfolioId ?? null));
|
|
9309
|
+
write({ reflections: (get().reflections ?? []).map((r) => r.id === id ? {
|
|
9310
|
+
...r,
|
|
9311
|
+
raidId,
|
|
9312
|
+
merged: false,
|
|
9313
|
+
skipped: false
|
|
9314
|
+
} : r) });
|
|
9315
|
+
return raidId;
|
|
9316
|
+
},
|
|
9317
|
+
foldPremortemAnswer: (id, raidId) => {
|
|
9318
|
+
const answer = (get().reflections ?? []).find((r) => r.id === id);
|
|
9319
|
+
const risk = get().raidItems.find((r) => r.id === raidId);
|
|
9320
|
+
if (!answer || answer.raidId || !risk) return;
|
|
9321
|
+
write({
|
|
9322
|
+
raidItems: get().raidItems.map((r) => r.id === raidId ? {
|
|
9323
|
+
...r,
|
|
9324
|
+
description: withFoldedAnswer(r.description, answer.body),
|
|
9325
|
+
updatedAt: stamp()
|
|
9326
|
+
} : r),
|
|
9327
|
+
reflections: (get().reflections ?? []).map((r) => r.id === id ? {
|
|
9328
|
+
...r,
|
|
9329
|
+
raidId,
|
|
9330
|
+
merged: true,
|
|
9331
|
+
skipped: false
|
|
9332
|
+
} : r)
|
|
9333
|
+
});
|
|
9334
|
+
},
|
|
9335
|
+
skipPremortemAnswer: (id, skipped) => {
|
|
9336
|
+
write({ reflections: (get().reflections ?? []).map((r) => r.id === id && !r.raidId ? {
|
|
9337
|
+
...r,
|
|
9338
|
+
skipped
|
|
9339
|
+
} : r) });
|
|
9340
|
+
},
|
|
9341
|
+
writeVoyageLine: (projectId, body) => {
|
|
9342
|
+
const text = body.trim().slice(0, 600);
|
|
9343
|
+
const me = get().currentUserId;
|
|
9344
|
+
const all = get().reflections ?? [];
|
|
9345
|
+
const mine = all.find((r) => r.projectId === projectId && r.kind === "voyage" && r.authorId === me);
|
|
9346
|
+
if (!text) {
|
|
9347
|
+
if (mine) write({ reflections: all.filter((r) => r.id !== mine.id) });
|
|
9348
|
+
return;
|
|
9349
|
+
}
|
|
9350
|
+
if (mine) {
|
|
9351
|
+
if (mine.body !== text) write({ reflections: all.map((r) => r.id === mine.id ? {
|
|
9352
|
+
...r,
|
|
9353
|
+
body: text
|
|
9354
|
+
} : r) });
|
|
9355
|
+
return;
|
|
9356
|
+
}
|
|
9357
|
+
if (!get().projects.some((p) => p.id === projectId)) return;
|
|
9358
|
+
const row = {
|
|
9359
|
+
id: uid("rf"),
|
|
9360
|
+
projectId,
|
|
9361
|
+
kind: "voyage",
|
|
9362
|
+
authorId: me,
|
|
9363
|
+
body: text,
|
|
9364
|
+
createdAt: stamp()
|
|
9365
|
+
};
|
|
9366
|
+
write({ reflections: [...all, row] });
|
|
9204
9367
|
}
|
|
9205
9368
|
};
|
|
9206
9369
|
}
|
|
@@ -9443,6 +9606,7 @@ const COLLECTIONS = [
|
|
|
9443
9606
|
"ropes",
|
|
9444
9607
|
"checkIns",
|
|
9445
9608
|
"spendEntries",
|
|
9609
|
+
"reflections",
|
|
9446
9610
|
"priorityRules"
|
|
9447
9611
|
];
|
|
9448
9612
|
/** Keys whose value differs between the last saved document and the current one. */
|
|
@@ -9475,6 +9639,7 @@ const ROW_COLLECTIONS = [
|
|
|
9475
9639
|
"activity",
|
|
9476
9640
|
"checkIns",
|
|
9477
9641
|
"spendEntries",
|
|
9642
|
+
"reflections",
|
|
9478
9643
|
"boardConfigs",
|
|
9479
9644
|
"ropes",
|
|
9480
9645
|
"readPingIds",
|
|
@@ -9517,7 +9682,7 @@ const KEYED = {
|
|
|
9517
9682
|
}
|
|
9518
9683
|
};
|
|
9519
9684
|
const isKeyed = (key) => key in KEYED;
|
|
9520
|
-
/** A collection as rows; `spendEntries` and `priorityRules` may be missing from older documents. */
|
|
9685
|
+
/** A collection as rows; `spendEntries`, `reflections` and `priorityRules` may be missing from older documents. */
|
|
9521
9686
|
function rowsOf(doc, key) {
|
|
9522
9687
|
if (isKeyed(key)) return KEYED[key].rows(doc[key]);
|
|
9523
9688
|
return doc[key] ?? [];
|
package/lib/cli.mjs
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
import {
|
|
20
20
|
harbors,
|
|
21
21
|
myWork,
|
|
22
|
+
premortemText,
|
|
23
|
+
voyageText,
|
|
22
24
|
raidList,
|
|
23
25
|
programTree,
|
|
24
26
|
projectList,
|
|
@@ -89,6 +91,11 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
89
91
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
90
92
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
91
93
|
the register, worst first
|
|
94
|
+
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
95
|
+
a project's pre-mortem: start,
|
|
96
|
+
answer or read it
|
|
97
|
+
harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
|
|
98
|
+
your line in it
|
|
92
99
|
|
|
93
100
|
Every command takes --json, and --harbor NAME when you're in more than one
|
|
94
101
|
harbor (or set one with harbor use). harbor help COMMAND shows just that one.
|
|
@@ -978,6 +985,53 @@ export async function run(argv, io = {}) {
|
|
|
978
985
|
(r) => raidList(r, positionals.join(" ")),
|
|
979
986
|
);
|
|
980
987
|
}
|
|
988
|
+
case "premortem": {
|
|
989
|
+
const { positionals, values, harbor } = parse(spec(["start"]));
|
|
990
|
+
const [project, ...words] = positionals;
|
|
991
|
+
if (!project)
|
|
992
|
+
throw new HarborError(
|
|
993
|
+
'Give a project, then your answer: harbor premortem "NAME" "WHAT WENT WRONG".',
|
|
994
|
+
);
|
|
995
|
+
const answer = words.join(" ").trim();
|
|
996
|
+
if (values.start) {
|
|
997
|
+
const started = await client.call("start_premortem", { harbor, project }).catch((err) => {
|
|
998
|
+
if (err instanceof HarborError) throw new HarborError(hint(err.message));
|
|
999
|
+
throw err;
|
|
1000
|
+
});
|
|
1001
|
+
if (!answer) {
|
|
1002
|
+
out(
|
|
1003
|
+
asJson
|
|
1004
|
+
? jsonText(started)
|
|
1005
|
+
: plain(
|
|
1006
|
+
`Started the pre-mortem on ${started.started.project}.\n${started.started.question}\nEveryone answers alone for five minutes; then the answers show together on the project's Risks & issues tab.`,
|
|
1007
|
+
),
|
|
1008
|
+
);
|
|
1009
|
+
return 0;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (answer) {
|
|
1013
|
+
return call("answer_premortem", { harbor, project, answer }, (r) =>
|
|
1014
|
+
r.answersPrivate
|
|
1015
|
+
? `Added your answer (${r.answered.yourAnswers} so far). Nobody sees it until the answers show.`
|
|
1016
|
+
: `Added your answer (${r.answered.yourAnswers} so far).`,
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
return call("get_premortem", { harbor, project }, premortemText);
|
|
1020
|
+
}
|
|
1021
|
+
case "voyage": {
|
|
1022
|
+
const { positionals, harbor } = parse(spec());
|
|
1023
|
+
const [project, ...words] = positionals;
|
|
1024
|
+
if (!project) throw new HarborError("Give a project, and your line if you're adding one.");
|
|
1025
|
+
const line = words.join(" ").trim();
|
|
1026
|
+
if (line) {
|
|
1027
|
+
return call(
|
|
1028
|
+
"add_voyage_line",
|
|
1029
|
+
{ harbor, project, line },
|
|
1030
|
+
(r) => `Your line is in ${r.added.project}'s voyage log.`,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
return call("get_voyage_log", { harbor, project }, voyageText);
|
|
1034
|
+
}
|
|
981
1035
|
case "risk":
|
|
982
1036
|
case "issue":
|
|
983
1037
|
case "decision": {
|
package/lib/format.mjs
CHANGED
|
@@ -183,3 +183,43 @@ export function raidList(r, words = "") {
|
|
|
183
183
|
: "";
|
|
184
184
|
return lines.join("\n") + more;
|
|
185
185
|
}
|
|
186
|
+
|
|
187
|
+
/** A project's pre-mortem, as get_premortem answers it. */
|
|
188
|
+
export function premortemText(r) {
|
|
189
|
+
if (!r.running) {
|
|
190
|
+
return `No pre-mortem on ${r.project} yet. Start one with harbor premortem "${r.project}" --start.`;
|
|
191
|
+
}
|
|
192
|
+
const count = `${r.answerCount} answer${r.answerCount === 1 ? "" : "s"}`;
|
|
193
|
+
const head = [`Pre-mortem · ${r.project} · ${count}`, r.question];
|
|
194
|
+
if (r.answersPrivate) {
|
|
195
|
+
const s = r.secondsUntilShown ?? 0;
|
|
196
|
+
head.push(
|
|
197
|
+
`Answers show together in ${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}. Until then you see only yours.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const lines = (r.answers ?? []).map((a) => {
|
|
201
|
+
const after = a.risk
|
|
202
|
+
? a.merged
|
|
203
|
+
? ` (merged into the risk “${a.risk}”)`
|
|
204
|
+
: ` (risk: ${a.risk})`
|
|
205
|
+
: a.leftOut
|
|
206
|
+
? " (left out)"
|
|
207
|
+
: "";
|
|
208
|
+
return ` ${a.yours ? "*" : "-"} ${a.answer}${after}`;
|
|
209
|
+
});
|
|
210
|
+
if (!lines.length)
|
|
211
|
+
lines.push(` Nothing yet. Add one: harbor premortem "${r.project}" "WHAT WENT WRONG"`);
|
|
212
|
+
return [...head, "", ...lines].join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A project's voyage log, as get_voyage_log answers it. */
|
|
216
|
+
export function voyageText(r) {
|
|
217
|
+
const head = [`Voyage log · ${r.project}${r.done ? ` · Done ${r.finished}` : ""}`];
|
|
218
|
+
if (r.note) head.push(r.note);
|
|
219
|
+
head.push((r.stats ?? []).join(" · "));
|
|
220
|
+
const story = (r.story ?? []).map((s) => ` - ${s}`);
|
|
221
|
+
const crew = (r.crew ?? []).map((c) => ` - ${c.who ?? "Someone"}: ${c.line}`);
|
|
222
|
+
return [...head, "", ...story, ...(crew.length ? ["", "From the crew:", ...crew] : [])].join(
|
|
223
|
+
"\n",
|
|
224
|
+
);
|
|
225
|
+
}
|