@harborppm/cli 0.14.1 → 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
@@ -55,7 +55,7 @@ Your harbor is the same one at [harborppm.com](https://harborppm.com/today?utm_s
55
55
  - **GitHub**: a pull request that names a task's key (RR-42) shows on the task and moves it along.
56
56
  - **Claude and other AI apps**, connected over MCP from [harborppm.com/ai](https://harborppm.com/ai?utm_source=npm&utm_medium=readme&utm_campaign=cli).
57
57
 
58
- `harbor open RR-42` opens a task on the website (in the right harbor, even when the browser had another one open), `harbor open Ridge` a project, and `harbor open` on its own opens Today. `harbor invite sam@example.com` sends Sam the same invite letter the People page does (`--viewer` to let them look but not change anything); only a harbor's owner can invite.
58
+ `harbor open RR-42` opens a task on the website (in the right harbor, even when the browser had another one open), `harbor open Ridge` a project, and `harbor open` on its own opens Today. `harbor invite sam@example.com` sends Sam the same invite letter the People page does (`--viewer` to let them look but not change anything); only a harbor's owner can invite. `harbor calendar` prints your private calendar link, to add to Google, Apple or Outlook calendar: your due dates, milestones and sign-offs beside your meetings, kept up to date (`--program NAME` for everything dated in one program, `--new` to swap the link for a fresh one, `--off` to turn it off).
59
59
 
60
60
  The website has the commands too: turn on **Command line at the bottom** in your menu, press `/`, and type `mine`, `add "Call the mill" #ridge @sam --due fri` or `done RR-12`.
61
61
 
@@ -95,6 +95,7 @@ harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status
95
95
  harbor show TASK one task in full, with its web link
96
96
  harbor open [TASK|PROJECT] open it on the website (or Today)
97
97
  harbor invite EMAIL [--viewer] invite someone to your harbor by email
98
+ harbor calendar [--program P] [--new|--off] your private calendar link (.ics)
98
99
 
99
100
  harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
100
101
  [--ends DATE]
@@ -115,8 +116,11 @@ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
115
116
  [--due DATE]
116
117
  harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
117
118
  harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
119
+ harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
118
120
  ```
119
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
+
120
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.
121
125
 
122
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,
@@ -66,6 +67,7 @@ export const HELP = `harbor: Harbor from the terminal
66
67
  shell prompt (never waits)
67
68
  harbor prompt --init SHELL how to add it: zsh, bash, fish
68
69
  or starship
70
+ harbor calendar [--program P] [--new|--off] your private calendar link (.ics)
69
71
 
70
72
  harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
71
73
  [--ends DATE]
@@ -88,6 +90,9 @@ export const HELP = `harbor: Harbor from the terminal
88
90
  harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
89
91
  harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
90
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
91
96
 
92
97
  Every command takes --json, and --harbor NAME when you're in more than one
93
98
  harbor (or set one with harbor use). harbor help COMMAND shows just that one.
@@ -821,6 +826,34 @@ export async function run(argv, io = {}) {
821
826
  );
822
827
  return 0;
823
828
  }
829
+ case "calendar": {
830
+ const { values, harbor } = parse(spec(["new", "off"], ["program"]));
831
+ if (values.new && values.off) throw new HarborError("Say --new or --off, not both.");
832
+ if (!hasFullAccess(creds)) {
833
+ throw new HarborError("Calendar links need full access. Run harbor login, then try again.");
834
+ }
835
+ const r = await client.app({
836
+ op: "calendar",
837
+ ...(harbor ? { harbor } : {}),
838
+ ...(values.program ? { program: values.program } : {}),
839
+ action: values.off ? "off" : values.new ? "fresh" : "show",
840
+ });
841
+ if (asJson) {
842
+ out(jsonText(r));
843
+ return 0;
844
+ }
845
+ const what = r.program ? `everything dated in ${r.program}` : `your work in ${r.harbor}`;
846
+ if (!r.url) {
847
+ out(plain(`Turned off the calendar link for ${what}. Calendars using it stop updating.`));
848
+ return 0;
849
+ }
850
+ out(
851
+ plain(
852
+ `${values.new ? "A new calendar link" : "Your calendar link"} for ${what}${values.new ? " (the old one has stopped working)" : ""}:\n\n ${r.url}\n\nAdd it to Google, Apple or Outlook calendar as a calendar from a URL. Anyone with the link can see these dates; harbor calendar --new swaps it for a fresh one.`,
853
+ ),
854
+ );
855
+ return 0;
856
+ }
824
857
  case "add": {
825
858
  const { positionals, values, harbor } = parse(
826
859
  spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
@@ -949,6 +982,39 @@ export async function run(argv, io = {}) {
949
982
  (r) => raidList(r, positionals.join(" ")),
950
983
  );
951
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
+ }
952
1018
  case "risk":
953
1019
  case "issue":
954
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.14.1",
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",