@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 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.15.0",
3
+ "version": "0.18.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",