@harborppm/cli 0.46.0 → 0.47.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.
Files changed (2) hide show
  1. package/dist/app.mjs +271 -32
  2. package/package.json +1 -1
package/dist/app.mjs CHANGED
@@ -2068,9 +2068,43 @@ function normalizeRaidItem(r) {
2068
2068
  updatedAt: r.updatedAt ?? r.createdAt ?? now,
2069
2069
  updates: Array.isArray(r.updates) ? r.updates : [],
2070
2070
  scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0,
2071
- ...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {}
2071
+ ...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {},
2072
+ ...kind === "decision" ? decisionFields(r) : {}
2072
2073
  };
2073
2074
  }
2075
+ /** A revisit condition that still says something, or null. */
2076
+ function cleanRevisit(v) {
2077
+ if (!v || typeof v !== "object") return null;
2078
+ const r = v;
2079
+ const str = (x) => typeof x === "string" && x ? x : null;
2080
+ if (r.kind === "date" && str(r.on)) return {
2081
+ kind: "date",
2082
+ on: str(r.on).slice(0, 10)
2083
+ };
2084
+ if (r.kind === "risk" && str(r.riskId)) return {
2085
+ kind: "risk",
2086
+ riskId: str(r.riskId)
2087
+ };
2088
+ if (r.kind === "milestone" && str(r.milestoneId) && str(r.due)) return {
2089
+ kind: "milestone",
2090
+ milestoneId: str(r.milestoneId),
2091
+ due: str(r.due).slice(0, 10)
2092
+ };
2093
+ return null;
2094
+ }
2095
+ /** A decision's own fields, kept only where they say something so older rows stay as they were. */
2096
+ function decisionFields(r) {
2097
+ const out = {};
2098
+ if (r.turnedDown?.trim()) out.turnedDown = r.turnedDown;
2099
+ const tasks = ids$1(r.taskIds);
2100
+ if (tasks.length) out.taskIds = tasks;
2101
+ const risks = ids$1(r.riskIds);
2102
+ if (risks.length) out.riskIds = risks;
2103
+ const revisit = cleanRevisit(r.revisitIf);
2104
+ if (revisit) out.revisitIf = revisit;
2105
+ if (r.supersedesId) out.supersedesId = r.supersedesId;
2106
+ return out;
2107
+ }
2074
2108
  /** Active first, then by score, then most recently touched. */
2075
2109
  function sortRaid(items) {
2076
2110
  return [...items].sort((a, b) => {
@@ -2112,7 +2146,7 @@ function heatGrid(items) {
2112
2146
  function inHeatCell(item, probability, impact) {
2113
2147
  return item.kind === "risk" && item.probability === probability && item.impact === impact;
2114
2148
  }
2115
- var KIND_LABEL, STATUS_LABEL$1, STATUSES$1, PROBABILITY_LABEL, IMPACT_LABEL, FIELD_COPY;
2149
+ var KIND_LABEL, STATUS_LABEL$1, STATUSES$1, PROBABILITY_LABEL, IMPACT_LABEL, FIELD_COPY, ids$1;
2116
2150
  var init_raid = __esmMin((() => {
2117
2151
  KIND_LABEL = {
2118
2152
  risk: "Risk",
@@ -2182,6 +2216,7 @@ var init_raid = __esmMin((() => {
2182
2216
  dueDate: "Decide by"
2183
2217
  }
2184
2218
  };
2219
+ ids$1 = (v) => Array.isArray(v) ? [...new Set(v.filter((x) => typeof x === "string" && !!x))] : [];
2185
2220
  }));
2186
2221
 
2187
2222
  //#endregion
@@ -5435,6 +5470,85 @@ function freshlyOpened(data, projects, now) {
5435
5470
  return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
5436
5471
  }
5437
5472
 
5473
+ //#endregion
5474
+ //#region src/lib/decisions.ts
5475
+ /** A risk counts as come true when someone said so, or when it was turned into an issue. */
5476
+ function cameTrue(risk) {
5477
+ return risk.happened === true || risk.kind === "issue";
5478
+ }
5479
+ /** "the risk “Vendor misses window” comes true", for "Revisit if …". */
5480
+ function revisitText(revisit, data) {
5481
+ if (revisit.kind === "date") return `it's ${md$1(revisit.on)}`;
5482
+ if (revisit.kind === "risk") {
5483
+ const risk = byId(data.raidItems).get(revisit.riskId);
5484
+ return risk ? `“${risk.title}” comes true` : "a risk that's since been removed comes true";
5485
+ }
5486
+ const m = byId(data.milestones).get(revisit.milestoneId);
5487
+ return m ? `“${m.name}” slips past ${md$1(revisit.due)}` : "a milestone that's since been removed slips";
5488
+ }
5489
+ /** Why a decided decision is due another look today, in a sentence, or null while it isn't. */
5490
+ function revisitReason(item, data, today) {
5491
+ const revisit = item.revisitIf;
5492
+ if (item.kind !== "decision" || item.status !== "decided" || !revisit) return null;
5493
+ if (revisit.kind === "date") return today >= revisit.on ? `${md$1(revisit.on)} came, the day set to look again.` : null;
5494
+ if (revisit.kind === "risk") {
5495
+ const risk = byId(data.raidItems).get(revisit.riskId);
5496
+ return risk && cameTrue(risk) ? `The risk “${risk.title}” came true.` : null;
5497
+ }
5498
+ const m = byId(data.milestones).get(revisit.milestoneId);
5499
+ if (!m || m.done) return null;
5500
+ const due = m.dueDate.slice(0, 10);
5501
+ if (due > revisit.due) return `“${m.name}” moved from ${md$1(revisit.due)} to ${md$1(due)}.`;
5502
+ if (due < today) return `“${m.name}” is past its date, ${md$1(due)}.`;
5503
+ return null;
5504
+ }
5505
+ /** The decision that replaced this one, if any. */
5506
+ function supersededBy(data, id) {
5507
+ return data.raidItems.find((r) => r.kind === "decision" && r.supersedesId === id) ?? null;
5508
+ }
5509
+ /** Decided decisions in scope whose "revisit if" has happened, oldest decision first. */
5510
+ function decisionsToRevisit(data, inScope, today) {
5511
+ const out = [];
5512
+ for (const item of data.raidItems) {
5513
+ if (item.kind !== "decision" || !item.revisitIf || !inScope(item)) continue;
5514
+ const reason = revisitReason(item, data, today);
5515
+ if (!reason) continue;
5516
+ const where = item.projectId && byId(data.projects).get(item.projectId)?.name || item.programId && byId(data.programs).get(item.programId)?.name || item.portfolioId && byId(data.portfolios).get(item.portfolioId)?.name || null;
5517
+ out.push({
5518
+ item,
5519
+ reason,
5520
+ where
5521
+ });
5522
+ }
5523
+ return out.sort((a, b) => (a.item.decidedAt ?? a.item.createdAt).localeCompare(b.item.decidedAt ?? b.item.createdAt));
5524
+ }
5525
+ /** The comment a "still stands" leaves, so the thread says it was looked at. */
5526
+ function stillStandsNote(reason) {
5527
+ return reason ? `Looked at again (${reason.replace(/\.$/, "")}): it still stands.` : "Looked at again: it still stands.";
5528
+ }
5529
+ /** Fields a new decision copies from the one it replaces: where it sits, what it touches. */
5530
+ function successorOf(old) {
5531
+ return {
5532
+ kind: "decision",
5533
+ title: old.title,
5534
+ description: old.description,
5535
+ portfolioId: old.portfolioId ?? null,
5536
+ programId: old.programId ?? null,
5537
+ projectId: old.projectId ?? null,
5538
+ ownerId: old.ownerId,
5539
+ taskIds: old.taskIds,
5540
+ riskIds: old.riskIds,
5541
+ supersedesId: old.id,
5542
+ status: "proposed"
5543
+ };
5544
+ }
5545
+ var md$1;
5546
+ var init_decisions = __esmMin((() => {
5547
+ init_indexes();
5548
+ init_date_text();
5549
+ md$1 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
5550
+ }));
5551
+
5438
5552
  //#endregion
5439
5553
  //#region src/lib/brief.ts
5440
5554
  init_date_fns();
@@ -5445,6 +5559,7 @@ init_work();
5445
5559
  init_indexes();
5446
5560
  init_blockers();
5447
5561
  init_clock();
5562
+ init_decisions();
5448
5563
  const BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
5449
5564
  /** Late tasks worst first, each with what it holds up, so the letter says what to chase. */
5450
5565
  function lateLinesOf(data, tasks, now) {
@@ -5503,14 +5618,18 @@ function isHighRisk(r) {
5503
5618
  function isMajorIssue(r) {
5504
5619
  return r.kind === "issue" && (r.impact != null ? r.impact >= 4 : r.severity === "high");
5505
5620
  }
5506
- /** The risks and issues worth a line in the brief: live, and either a high-scored risk or a major issue. */
5507
- function watchListOf(data, scope, limit = 5) {
5508
- const inScope = (r) => {
5621
+ /** Whether a register item sits inside the brief's portfolio (or anywhere, for all of them). */
5622
+ function inBriefScope(scope) {
5623
+ return (r) => {
5509
5624
  if (r.projectId) return scope.projectIds.has(r.projectId);
5510
5625
  if (r.programId) return scope.programIds.has(r.programId);
5511
5626
  if (r.portfolioId) return scope.portfolioId == null || r.portfolioId === scope.portfolioId;
5512
5627
  return scope.portfolioId == null;
5513
5628
  };
5629
+ }
5630
+ /** The risks and issues worth a line in the brief: live, and either a high-scored risk or a major issue. */
5631
+ function watchListOf(data, scope, limit = 5) {
5632
+ const inScope = inBriefScope(scope);
5514
5633
  const live = data.raidItems.filter((r) => r.kind !== "decision" && isActive(r) && inScope(r) && (r.kind === "risk" && isHighRisk(r) || isMajorIssue(r)));
5515
5634
  return sortRaid(live).slice(0, limit).map((item) => ({
5516
5635
  item,
@@ -5546,6 +5665,11 @@ function composeBriefBody(opts) {
5546
5665
  }
5547
5666
  lines.push("");
5548
5667
  }
5668
+ if (opts.revisit?.length) {
5669
+ lines.push("Decisions to look at again");
5670
+ for (const d of opts.revisit) lines.push(`• ${d.title}${d.where ? ` (${d.where})` : ""}: ${d.reason}`);
5671
+ lines.push("");
5672
+ }
5549
5673
  const done = () => {
5550
5674
  if (!opts.shipped?.length) return;
5551
5675
  lines.push("Done this week");
@@ -5790,6 +5914,11 @@ function briefOf(data, opts = {}) {
5790
5914
  said: saidThisWeek(data, now, portfolioId ? new Set(tasks.flatMap((t) => t.assigneeId ? [t.assigneeId] : [])) : null),
5791
5915
  late: lateLinesOf(data, tasks, now),
5792
5916
  opened: freshlyOpened(data, projects, now),
5917
+ revisit: decisionsToRevisit(data, inBriefScope({
5918
+ programIds,
5919
+ projectIds,
5920
+ portfolioId
5921
+ }), todayISO(now)),
5793
5922
  talk,
5794
5923
  milestonesSoon,
5795
5924
  milestonesLate,
@@ -5841,6 +5970,11 @@ function composeBriefBodyFrom(brief, data, workspace, all = false) {
5841
5970
  project: b.project.name,
5842
5971
  who: data.people.find((p) => p.id === b.note.authorId)?.name ?? null,
5843
5972
  body: b.note.body
5973
+ })),
5974
+ revisit: brief.revisit.map((d) => ({
5975
+ title: d.item.title,
5976
+ reason: d.reason,
5977
+ where: d.where
5844
5978
  }))
5845
5979
  });
5846
5980
  }
@@ -7685,6 +7819,7 @@ init_baseline();
7685
7819
  init_derived_health();
7686
7820
  init_date_text();
7687
7821
  init_format();
7822
+ init_decisions();
7688
7823
  init_swatches();
7689
7824
  init_raid();
7690
7825
  /** The people of this harbor to pick from, with "Nobody" first when that's allowed. */
@@ -8511,30 +8646,54 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
8511
8646
  label: "Score",
8512
8647
  value: score != null ? String(score) : ""
8513
8648
  }] : [],
8514
- ...item.kind === "decision" ? [{
8515
- label: "Decided by",
8516
- value: person(item.decidedById) ?? "Not yet",
8517
- edit: {
8518
- kind: "pick",
8519
- choices: [{
8520
- label: "Not yet",
8521
- value: null
8522
- }, ...people.map((p) => ({
8523
- label: p.name,
8524
- value: p.id
8525
- }))],
8526
- initial: Math.max(0, people.findIndex((p) => p.id === item.decidedById) + 1),
8527
- onPick: (v) => up({ decidedById: v })
8528
- }
8529
- }, {
8530
- label: "Decided on",
8531
- value: item.decidedAt ? shortDay(item.decidedAt, today()) : "Not yet",
8532
- edit: {
8533
- kind: "date",
8534
- initial: item.decidedAt ?? null,
8535
- onSave: (v) => up({ decidedAt: v })
8649
+ ...item.kind === "decision" ? [
8650
+ {
8651
+ label: "Decided by",
8652
+ value: person(item.decidedById) ?? "Not yet",
8653
+ edit: {
8654
+ kind: "pick",
8655
+ choices: [{
8656
+ label: "Not yet",
8657
+ value: null
8658
+ }, ...people.map((p) => ({
8659
+ label: p.name,
8660
+ value: p.id
8661
+ }))],
8662
+ initial: Math.max(0, people.findIndex((p) => p.id === item.decidedById) + 1),
8663
+ onPick: (v) => up({ decidedById: v })
8664
+ }
8665
+ },
8666
+ {
8667
+ label: "Decided on",
8668
+ value: item.decidedAt ? shortDay(item.decidedAt, today()) : "Not yet",
8669
+ edit: {
8670
+ kind: "date",
8671
+ initial: item.decidedAt ?? null,
8672
+ onSave: (v) => up({ decidedAt: v })
8673
+ }
8674
+ },
8675
+ {
8676
+ label: "Turned down",
8677
+ value: (item.turnedDown ?? "").replace(/\s+/g, " "),
8678
+ edit: {
8679
+ kind: "text",
8680
+ initial: item.turnedDown ?? "",
8681
+ onSave: (v) => up({ turnedDown: v })
8682
+ }
8683
+ },
8684
+ {
8685
+ label: "Look again",
8686
+ value: item.revisitIf ? revisitText(item.revisitIf, store.getState()) : "",
8687
+ edit: {
8688
+ kind: "date",
8689
+ initial: item.revisitIf?.kind === "date" ? item.revisitIf.on : null,
8690
+ onSave: (v) => up({ revisitIf: v ? {
8691
+ kind: "date",
8692
+ on: v
8693
+ } : null })
8694
+ }
8536
8695
  }
8537
- }] : [],
8696
+ ] : [],
8538
8697
  {
8539
8698
  label: copy.dueDate,
8540
8699
  value: shortDay(item.dueDate, today()),
@@ -14721,6 +14880,42 @@ function milestonesRaidSlice({ set, get, write }) {
14721
14880
  updatedAt: stamp()
14722
14881
  } : r) });
14723
14882
  },
14883
+ supersedeDecision: (id) => {
14884
+ const old = get().raidItems.find((r) => r.id === id && r.kind === "decision");
14885
+ if (!old) return null;
14886
+ const now = stamp();
14887
+ const row = normalizeRaidItem({
14888
+ ...successorOf(old),
14889
+ id: uid("raid"),
14890
+ createdById: get().currentUserId,
14891
+ createdAt: now,
14892
+ updatedAt: now
14893
+ });
14894
+ write({ raidItems: [...get().raidItems.map((r) => r.id === id ? {
14895
+ ...r,
14896
+ status: "superseded",
14897
+ updatedAt: now
14898
+ } : r), row] });
14899
+ get().log(`changed a decision — ${old.title}`);
14900
+ return row.id;
14901
+ },
14902
+ decisionStillStands: (id, reason) => {
14903
+ const item = get().raidItems.find((r) => r.id === id);
14904
+ if (!item || item.kind !== "decision") return;
14905
+ const now = stamp();
14906
+ const update = {
14907
+ id: uid("ru"),
14908
+ authorId: get().currentUserId,
14909
+ body: stillStandsNote(reason ?? null),
14910
+ createdAt: now
14911
+ };
14912
+ write({ raidItems: get().raidItems.map((r) => r.id === id ? normalizeRaidItem({
14913
+ ...r,
14914
+ revisitIf: null,
14915
+ updates: [...r.updates, update],
14916
+ updatedAt: now
14917
+ }) : r) });
14918
+ },
14724
14919
  startPremortem: (projectId) => {
14725
14920
  const project = get().projects.find((p) => p.id === projectId);
14726
14921
  if (!project || project.premortem) return;
@@ -14864,6 +15059,7 @@ var init_milestones_raid = __esmMin((() => {
14864
15059
  init_clock();
14865
15060
  init_premortem();
14866
15061
  init_raid();
15062
+ init_decisions();
14867
15063
  init_raid_history();
14868
15064
  init_utils();
14869
15065
  init_shared();
@@ -23121,6 +23317,7 @@ function voyageLog(data, projectId) {
23121
23317
  guessed: true
23122
23318
  };
23123
23319
  });
23320
+ const decisions = raid.filter((r) => r.kind === "decision" && r.status !== "proposed").sort((a, b) => (a.decidedAt ?? a.createdAt).localeCompare(b.decidedAt ?? b.createdAt));
23124
23321
  const counts = /* @__PURE__ */ new Map();
23125
23322
  for (const t of tasks) if (t.status === "done" && t.assigneeId) counts.set(t.assigneeId, (counts.get(t.assigneeId) ?? 0) + 1);
23126
23323
  const people = new Map(data.people.map((p) => [p.id, p]));
@@ -23142,6 +23339,7 @@ function voyageLog(data, projectId) {
23142
23339
  slips,
23143
23340
  risks,
23144
23341
  issues: issues.length,
23342
+ decisions,
23145
23343
  carriers,
23146
23344
  budget: project.budget,
23147
23345
  spent: project.spent,
@@ -23189,6 +23387,10 @@ function voyageStory(log) {
23189
23387
  out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md(s.waitedOn.completedAt)}.` : `${late}.`);
23190
23388
  }
23191
23389
  for (const r of log.risks.filter((x) => x.happened)) out.push(`The risk “${r.risk.title}” came true${r.guessed ? " (an issue names it)" : ""}.`);
23390
+ if (log.decisions.length) {
23391
+ const changed = log.decisions.filter((d) => d.status === "superseded").length;
23392
+ out.push(`${plural(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
23393
+ }
23192
23394
  const top = log.carriers[0];
23193
23395
  if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural(top.closed, "task")}, the most on the crew.`);
23194
23396
  else if (top) out.push(`${top.person.name} closed ${plural(top.closed, "task")}.`);
@@ -23233,6 +23435,7 @@ init_hill();
23233
23435
  init_raid();
23234
23436
  init_priority();
23235
23437
  init_premortem();
23438
+ init_decisions();
23236
23439
  init_search();
23237
23440
  init_derived_health();
23238
23441
  init_format();
@@ -23440,10 +23643,7 @@ function raidOut(data, r) {
23440
23643
  ...text("contingency"),
23441
23644
  ...text("resolution"),
23442
23645
  ...text("rationale"),
23443
- ...r.kind === "decision" ? {
23444
- decidedBy: nameOf(people, r.decidedById),
23445
- decidedOn: dayOf(r.decidedAt)
23446
- } : {},
23646
+ ...r.kind === "decision" ? decisionOut(data, r, people) : {},
23447
23647
  ...text("notes"),
23448
23648
  updates: r.updates.map((u) => ({
23449
23649
  by: nameOf(people, u.authorId) ?? "Someone",
@@ -23452,6 +23652,39 @@ function raidOut(data, r) {
23452
23652
  }))
23453
23653
  };
23454
23654
  }
23655
+ /** A decision's own fields: who and when, what was turned down, what it touches, when to look again. */
23656
+ function decisionOut(data, r, people) {
23657
+ const tasks = byId(data.tasks);
23658
+ const items = byId(data.raidItems);
23659
+ const touches = [...(r.taskIds ?? []).flatMap((id) => {
23660
+ const t = tasks.get(id);
23661
+ if (!t) return [];
23662
+ const key = taskKey(t, data.projects.find((p) => p.id === t.projectId));
23663
+ return [key ? `${key} ${t.title}` : t.title];
23664
+ }), ...(r.riskIds ?? []).flatMap((id) => {
23665
+ const x = items.get(id);
23666
+ return x ? [`${KIND_LABEL[x.kind]}: ${x.title}`] : [];
23667
+ })];
23668
+ const older = r.supersedesId ? items.get(r.supersedesId) : null;
23669
+ const newer = supersededBy(data, r.id);
23670
+ const due = revisitReason(r, data, todayISO());
23671
+ return {
23672
+ decidedBy: nameOf(people, r.decidedById),
23673
+ decidedOn: dayOf(r.decidedAt),
23674
+ ...r.turnedDown?.trim() ? { turnedDown: r.turnedDown.trim() } : {},
23675
+ ...touches.length ? { touches } : {},
23676
+ ...r.revisitIf ? { revisitIf: revisitText(r.revisitIf, data) } : {},
23677
+ ...due ? { lookAgainNow: due } : {},
23678
+ ...older ? { replaces: {
23679
+ id: older.id,
23680
+ title: older.title
23681
+ } } : {},
23682
+ ...newer ? { replacedBy: {
23683
+ id: newer.id,
23684
+ title: newer.title
23685
+ } } : {}
23686
+ };
23687
+ }
23455
23688
  const tool = (t) => t;
23456
23689
  /** A project's notes for later, as get_project gives them: sealed ones without their text unless they're the caller's. */
23457
23690
  function notesOf(ctx, p) {
@@ -24089,6 +24322,12 @@ const HARBOR_TOOLS = [
24089
24322
  cameTrue: r.happened,
24090
24323
  ...r.guessed ? { guessed: true } : {}
24091
24324
  })),
24325
+ decisions: log.decisions.map((d) => ({
24326
+ title: d.title,
24327
+ on: dayOf(d.decidedAt),
24328
+ ...d.rationale?.trim() ? { why: d.rationale.trim() } : {},
24329
+ ...d.status === "superseded" ? { changedLater: true } : {}
24330
+ })),
24092
24331
  closedBy: log.carriers.map((c) => ({
24093
24332
  who: c.person.name,
24094
24333
  tasks: c.closed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.46.0",
3
+ "version": "0.47.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",