@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 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",