@harborppm/cli 0.15.0 → 0.16.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 -0
- package/dist/app.mjs +143 -6
- package/lib/cli.mjs +37 -0
- package/lib/format.mjs +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -116,8 +116,11 @@ 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]
|
|
119
120
|
```
|
|
120
121
|
|
|
122
|
+
`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.
|
|
123
|
+
|
|
121
124
|
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
125
|
|
|
123
126
|
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
|
@@ -1434,7 +1434,8 @@ function cascadeDelete(data, kind, id) {
|
|
|
1434
1434
|
...r,
|
|
1435
1435
|
taskIds: r.taskIds.filter((t) => !taskIds.has(t))
|
|
1436
1436
|
})),
|
|
1437
|
-
spendEntries: (data.spendEntries ?? []).filter((e) => !projectIds.has(e.projectId))
|
|
1437
|
+
spendEntries: (data.spendEntries ?? []).filter((e) => !projectIds.has(e.projectId)),
|
|
1438
|
+
reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId))
|
|
1438
1439
|
},
|
|
1439
1440
|
counts: {
|
|
1440
1441
|
programs: programIds.size,
|
|
@@ -7668,6 +7669,7 @@ function pickData(s) {
|
|
|
7668
7669
|
ropes: s.ropes ?? NONE,
|
|
7669
7670
|
checkIns: s.checkIns ?? NONE,
|
|
7670
7671
|
spendEntries: s.spendEntries ?? NONE,
|
|
7672
|
+
reflections: s.reflections ?? NONE,
|
|
7671
7673
|
priorityRules: s.priorityRules ?? null
|
|
7672
7674
|
};
|
|
7673
7675
|
}
|
|
@@ -7878,7 +7880,8 @@ const UNDO_KEYS = [
|
|
|
7878
7880
|
"comments",
|
|
7879
7881
|
"attachments",
|
|
7880
7882
|
"checkIns",
|
|
7881
|
-
"spendEntries"
|
|
7883
|
+
"spendEntries",
|
|
7884
|
+
"reflections"
|
|
7882
7885
|
];
|
|
7883
7886
|
/** Edits to the same fields of the same things this close together are one change. */
|
|
7884
7887
|
const FOLD_MS = 5e3;
|
|
@@ -7893,7 +7896,7 @@ function reversalOf(before, after) {
|
|
|
7893
7896
|
const forward = [];
|
|
7894
7897
|
for (const key of UNDO_KEYS) {
|
|
7895
7898
|
if (before[key] === after[key]) continue;
|
|
7896
|
-
const back = rowChanges(after[key], before[key]);
|
|
7899
|
+
const back = rowChanges(after[key] ?? [], before[key] ?? []);
|
|
7897
7900
|
if (back.length === 0) continue;
|
|
7898
7901
|
reversal[key] = back;
|
|
7899
7902
|
for (const change of back) {
|
|
@@ -7926,7 +7929,7 @@ function labelOf(before, after, forward) {
|
|
|
7926
7929
|
const lead = LEAD.map((key) => forward.filter((f) => f.key === key)).find((l) => l.length > 0);
|
|
7927
7930
|
if (!lead || lead.length !== 1) return "your last change";
|
|
7928
7931
|
const { key, change } = lead[0];
|
|
7929
|
-
const find = (doc, id) => doc[key].find((r) => r.id === id);
|
|
7932
|
+
const find = (doc, id) => (doc[key] ?? []).find((r) => r.id === id);
|
|
7930
7933
|
if (change.kind === "add") {
|
|
7931
7934
|
const name = nameOf(change.row);
|
|
7932
7935
|
return name ? `deleting ${name}` : "your last change";
|
|
@@ -7964,7 +7967,7 @@ function undoStep(doc, step) {
|
|
|
7964
7967
|
for (const reversal of [...step.reversals].reverse()) for (const key of UNDO_KEYS) {
|
|
7965
7968
|
const changes = reversal[key];
|
|
7966
7969
|
if (!changes?.length) continue;
|
|
7967
|
-
const rows = next[key] ?? doc[key];
|
|
7970
|
+
const rows = next[key] ?? doc[key] ?? [];
|
|
7968
7971
|
const result = applyRowChanges(rows, changes, { keepTheirs: true });
|
|
7969
7972
|
next[key] = result.rows;
|
|
7970
7973
|
for (const clash of result.clashes) kept.add(`${key}:${clash.id}:${clash.field}`);
|
|
@@ -8596,6 +8599,7 @@ function hierarchySlice({ get, write }) {
|
|
|
8596
8599
|
comments: back(state.comments, rows.comments),
|
|
8597
8600
|
attachments: back(state.attachments, rows.attachments),
|
|
8598
8601
|
spendEntries: back(state.spendEntries, rows.spendEntries),
|
|
8602
|
+
reflections: back(state.reflections, rows.reflections),
|
|
8599
8603
|
boardConfigs: {
|
|
8600
8604
|
...rows.boardConfigs ?? {},
|
|
8601
8605
|
...state.boardConfigs ?? {}
|
|
@@ -9062,6 +9066,50 @@ function tasksSlice({ set, get, write }) {
|
|
|
9062
9066
|
};
|
|
9063
9067
|
}
|
|
9064
9068
|
|
|
9069
|
+
//#endregion
|
|
9070
|
+
//#region src/lib/premortem.ts
|
|
9071
|
+
/** How long everyone answers alone before the answers show together. */
|
|
9072
|
+
const PREMORTEM_MINUTES = 5;
|
|
9073
|
+
/** A new pre-mortem started now: answers show together five minutes on. */
|
|
9074
|
+
function newPremortem(startedById, now) {
|
|
9075
|
+
return {
|
|
9076
|
+
startedAt: now.toISOString(),
|
|
9077
|
+
startedById,
|
|
9078
|
+
revealAt: new Date(now.getTime() + 5 * 6e4).toISOString()
|
|
9079
|
+
};
|
|
9080
|
+
}
|
|
9081
|
+
/**
|
|
9082
|
+
* A risk's title from an answer: its first sentence, kept under 120
|
|
9083
|
+
* characters at a word boundary, without a closing full stop.
|
|
9084
|
+
*/
|
|
9085
|
+
function riskTitle(answer) {
|
|
9086
|
+
const flat = answer.replace(/\s+/g, " ").trim();
|
|
9087
|
+
const first = /^(.+?[.!?])(\s|$)/.exec(flat)?.[1] ?? flat;
|
|
9088
|
+
let title = first.length <= 120 ? first : `${first.slice(0, 117).replace(/\s+\S*$/, "")}…`;
|
|
9089
|
+
title = title.replace(/\.$/, "");
|
|
9090
|
+
return title;
|
|
9091
|
+
}
|
|
9092
|
+
/** The risk an answer becomes: on its project, open, unrated, with the whole answer as what could happen. */
|
|
9093
|
+
function riskFromAnswer(answer, project, portfolioId) {
|
|
9094
|
+
return {
|
|
9095
|
+
kind: "risk",
|
|
9096
|
+
title: riskTitle(answer.body),
|
|
9097
|
+
description: `${answer.body.trim()}\n\nRaised in the pre-mortem.`,
|
|
9098
|
+
projectId: project.id,
|
|
9099
|
+
programId: project.programId,
|
|
9100
|
+
portfolioId,
|
|
9101
|
+
status: "open",
|
|
9102
|
+
ownerId: null,
|
|
9103
|
+
dueDate: null
|
|
9104
|
+
};
|
|
9105
|
+
}
|
|
9106
|
+
/** The risk's description with another answer folded in as the same worry. */
|
|
9107
|
+
function withFoldedAnswer(description, answer) {
|
|
9108
|
+
const base = description.trim();
|
|
9109
|
+
return `${base}${base ? "\n\n" : ""}Also raised: ${answer.trim()}`;
|
|
9110
|
+
}
|
|
9111
|
+
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(" "));
|
|
9112
|
+
|
|
9065
9113
|
//#endregion
|
|
9066
9114
|
//#region src/lib/raid-history.ts
|
|
9067
9115
|
/**
|
|
@@ -9201,6 +9249,93 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
9201
9249
|
updates: [...r.updates, update],
|
|
9202
9250
|
updatedAt: stamp()
|
|
9203
9251
|
} : r) });
|
|
9252
|
+
},
|
|
9253
|
+
startPremortem: (projectId) => {
|
|
9254
|
+
const project = get().projects.find((p) => p.id === projectId);
|
|
9255
|
+
if (!project || project.premortem) return;
|
|
9256
|
+
const premortem = newPremortem(get().currentUserId, clockNow());
|
|
9257
|
+
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
|
9258
|
+
...p,
|
|
9259
|
+
premortem
|
|
9260
|
+
} : p) });
|
|
9261
|
+
get().log(`started a pre-mortem on ${project.name}`, `/projects/${projectId}?view=risks`);
|
|
9262
|
+
},
|
|
9263
|
+
showPremortemAnswers: (projectId) => {
|
|
9264
|
+
const now = clockNow().toISOString();
|
|
9265
|
+
write({ projects: get().projects.map((p) => p.id === projectId && p.premortem && p.premortem.revealAt > now ? {
|
|
9266
|
+
...p,
|
|
9267
|
+
premortem: {
|
|
9268
|
+
...p.premortem,
|
|
9269
|
+
revealAt: now
|
|
9270
|
+
}
|
|
9271
|
+
} : p) });
|
|
9272
|
+
},
|
|
9273
|
+
answerPremortem: (projectId, body) => {
|
|
9274
|
+
const text = body.trim().slice(0, 600);
|
|
9275
|
+
const project = get().projects.find((p) => p.id === projectId);
|
|
9276
|
+
if (!text || !project?.premortem) return null;
|
|
9277
|
+
const row = {
|
|
9278
|
+
id: uid("rf"),
|
|
9279
|
+
projectId,
|
|
9280
|
+
kind: "premortem",
|
|
9281
|
+
authorId: get().currentUserId,
|
|
9282
|
+
body: text,
|
|
9283
|
+
createdAt: stamp()
|
|
9284
|
+
};
|
|
9285
|
+
write({ reflections: [...get().reflections ?? [], row] });
|
|
9286
|
+
return row.id;
|
|
9287
|
+
},
|
|
9288
|
+
editReflection: (id, body) => {
|
|
9289
|
+
const text = body.trim().slice(0, 600);
|
|
9290
|
+
if (!text) return;
|
|
9291
|
+
const me = get().currentUserId;
|
|
9292
|
+
write({ reflections: (get().reflections ?? []).map((r) => r.id === id && r.authorId === me ? {
|
|
9293
|
+
...r,
|
|
9294
|
+
body: text
|
|
9295
|
+
} : r) });
|
|
9296
|
+
},
|
|
9297
|
+
deleteReflection: (id) => {
|
|
9298
|
+
const me = get().currentUserId;
|
|
9299
|
+
write({ reflections: (get().reflections ?? []).filter((r) => !(r.id === id && r.authorId === me)) });
|
|
9300
|
+
},
|
|
9301
|
+
raisePremortemAnswer: (id) => {
|
|
9302
|
+
const answer = (get().reflections ?? []).find((r) => r.id === id);
|
|
9303
|
+
if (!answer || answer.raidId) return answer?.raidId ?? null;
|
|
9304
|
+
const project = get().projects.find((p) => p.id === answer.projectId);
|
|
9305
|
+
if (!project) return null;
|
|
9306
|
+
const program = get().programs.find((p) => p.id === project.programId);
|
|
9307
|
+
const raidId = get().addRaidItem(riskFromAnswer(answer, project, program?.portfolioId ?? null));
|
|
9308
|
+
write({ reflections: (get().reflections ?? []).map((r) => r.id === id ? {
|
|
9309
|
+
...r,
|
|
9310
|
+
raidId,
|
|
9311
|
+
merged: false,
|
|
9312
|
+
skipped: false
|
|
9313
|
+
} : r) });
|
|
9314
|
+
return raidId;
|
|
9315
|
+
},
|
|
9316
|
+
foldPremortemAnswer: (id, raidId) => {
|
|
9317
|
+
const answer = (get().reflections ?? []).find((r) => r.id === id);
|
|
9318
|
+
const risk = get().raidItems.find((r) => r.id === raidId);
|
|
9319
|
+
if (!answer || answer.raidId || !risk) return;
|
|
9320
|
+
write({
|
|
9321
|
+
raidItems: get().raidItems.map((r) => r.id === raidId ? {
|
|
9322
|
+
...r,
|
|
9323
|
+
description: withFoldedAnswer(r.description, answer.body),
|
|
9324
|
+
updatedAt: stamp()
|
|
9325
|
+
} : r),
|
|
9326
|
+
reflections: (get().reflections ?? []).map((r) => r.id === id ? {
|
|
9327
|
+
...r,
|
|
9328
|
+
raidId,
|
|
9329
|
+
merged: true,
|
|
9330
|
+
skipped: false
|
|
9331
|
+
} : r)
|
|
9332
|
+
});
|
|
9333
|
+
},
|
|
9334
|
+
skipPremortemAnswer: (id, skipped) => {
|
|
9335
|
+
write({ reflections: (get().reflections ?? []).map((r) => r.id === id && !r.raidId ? {
|
|
9336
|
+
...r,
|
|
9337
|
+
skipped
|
|
9338
|
+
} : r) });
|
|
9204
9339
|
}
|
|
9205
9340
|
};
|
|
9206
9341
|
}
|
|
@@ -9443,6 +9578,7 @@ const COLLECTIONS = [
|
|
|
9443
9578
|
"ropes",
|
|
9444
9579
|
"checkIns",
|
|
9445
9580
|
"spendEntries",
|
|
9581
|
+
"reflections",
|
|
9446
9582
|
"priorityRules"
|
|
9447
9583
|
];
|
|
9448
9584
|
/** Keys whose value differs between the last saved document and the current one. */
|
|
@@ -9475,6 +9611,7 @@ const ROW_COLLECTIONS = [
|
|
|
9475
9611
|
"activity",
|
|
9476
9612
|
"checkIns",
|
|
9477
9613
|
"spendEntries",
|
|
9614
|
+
"reflections",
|
|
9478
9615
|
"boardConfigs",
|
|
9479
9616
|
"ropes",
|
|
9480
9617
|
"readPingIds",
|
|
@@ -9517,7 +9654,7 @@ const KEYED = {
|
|
|
9517
9654
|
}
|
|
9518
9655
|
};
|
|
9519
9656
|
const isKeyed = (key) => key in KEYED;
|
|
9520
|
-
/** A collection as rows; `spendEntries` and `priorityRules` may be missing from older documents. */
|
|
9657
|
+
/** A collection as rows; `spendEntries`, `reflections` and `priorityRules` may be missing from older documents. */
|
|
9521
9658
|
function rowsOf(doc, key) {
|
|
9522
9659
|
if (isKeyed(key)) return KEYED[key].rows(doc[key]);
|
|
9523
9660
|
return doc[key] ?? [];
|
package/lib/cli.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import {
|
|
20
20
|
harbors,
|
|
21
21
|
myWork,
|
|
22
|
+
premortemText,
|
|
22
23
|
raidList,
|
|
23
24
|
programTree,
|
|
24
25
|
projectList,
|
|
@@ -89,6 +90,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
89
90
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
90
91
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
91
92
|
the register, worst first
|
|
93
|
+
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
94
|
+
a project's pre-mortem: start,
|
|
95
|
+
answer or read it
|
|
92
96
|
|
|
93
97
|
Every command takes --json, and --harbor NAME when you're in more than one
|
|
94
98
|
harbor (or set one with harbor use). harbor help COMMAND shows just that one.
|
|
@@ -978,6 +982,39 @@ export async function run(argv, io = {}) {
|
|
|
978
982
|
(r) => raidList(r, positionals.join(" ")),
|
|
979
983
|
);
|
|
980
984
|
}
|
|
985
|
+
case "premortem": {
|
|
986
|
+
const { positionals, values, harbor } = parse(spec(["start"]));
|
|
987
|
+
const [project, ...words] = positionals;
|
|
988
|
+
if (!project)
|
|
989
|
+
throw new HarborError(
|
|
990
|
+
'Give a project, then your answer: harbor premortem "NAME" "WHAT WENT WRONG".',
|
|
991
|
+
);
|
|
992
|
+
const answer = words.join(" ").trim();
|
|
993
|
+
if (values.start) {
|
|
994
|
+
const started = await client.call("start_premortem", { harbor, project }).catch((err) => {
|
|
995
|
+
if (err instanceof HarborError) throw new HarborError(hint(err.message));
|
|
996
|
+
throw err;
|
|
997
|
+
});
|
|
998
|
+
if (!answer) {
|
|
999
|
+
out(
|
|
1000
|
+
asJson
|
|
1001
|
+
? jsonText(started)
|
|
1002
|
+
: plain(
|
|
1003
|
+
`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.`,
|
|
1004
|
+
),
|
|
1005
|
+
);
|
|
1006
|
+
return 0;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (answer) {
|
|
1010
|
+
return call("answer_premortem", { harbor, project, answer }, (r) =>
|
|
1011
|
+
r.answersPrivate
|
|
1012
|
+
? `Added your answer (${r.answered.yourAnswers} so far). Nobody sees it until the answers show.`
|
|
1013
|
+
: `Added your answer (${r.answered.yourAnswers} so far).`,
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
return call("get_premortem", { harbor, project }, premortemText);
|
|
1017
|
+
}
|
|
981
1018
|
case "risk":
|
|
982
1019
|
case "issue":
|
|
983
1020
|
case "decision": {
|
package/lib/format.mjs
CHANGED
|
@@ -183,3 +183,31 @@ 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
|
+
}
|