@harborppm/cli 0.50.0 → 0.52.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
@@ -126,6 +126,8 @@ harbor try PROJECT --drop RL-4 --borrow Sam=3
126
126
  dry dock: the forecast with changes tried
127
127
  on a copy (--apply makes them, --save keeps
128
128
  them to show someone; harbor help try)
129
+ harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of task
130
+ waits that sets its date, slack per link
129
131
  harbor hill [--program P] where each project is on its hill
130
132
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
131
133
  past 50 is getting it done
@@ -155,9 +157,11 @@ harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
155
157
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
156
158
  [--due DATE]
157
159
  harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
160
+ [--turned-down T] [--look-again DATE] [--supersedes "OLD"]
158
161
  harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
159
162
  harbor raid "TITLE" [--status S] [--owner me|none|NAME] [--impact 1-5] [--probability 1-5]
160
- [--due DATE|none] [--update TEXT] [--mitigation T] [--resolution T] …
163
+ [--due DATE|none] [--update TEXT] [--mitigation T] [--resolution T]
164
+ [--turned-down T] [--look-again DATE|none] …
161
165
  one risk, issue or decision in full, or change it
162
166
  harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
163
167
  the review: programs worst first, with next moves
package/dist/app.mjs CHANGED
@@ -2069,7 +2069,8 @@ function normalizeRaidItem(r) {
2069
2069
  updates: Array.isArray(r.updates) ? r.updates : [],
2070
2070
  scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0,
2071
2071
  ...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {},
2072
- ...kind === "decision" ? decisionFields(r) : {}
2072
+ ...kind === "decision" ? decisionFields(r) : {},
2073
+ ...ids$1(r.followerIds).length ? { followerIds: ids$1(r.followerIds) } : {}
2073
2074
  };
2074
2075
  }
2075
2076
  /** A revisit condition that still says something, or null. */
@@ -3157,16 +3158,16 @@ const TRIALS = 1e3;
3157
3158
  const MAX_WEEKS = 104;
3158
3159
  /** Fewer finished tasks than this and the forecast says it's rough. */
3159
3160
  const ROUGH_BELOW = 5;
3160
- const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
3161
+ const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
3161
3162
  /** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
3162
3163
  function weeklyFinished(tasks, startDate, now, weeks = 6) {
3163
- const today = noon$1(todayISO(now));
3164
- const age = Math.floor(differenceInCalendarDays(today, noon$1(startDate)) / 7) + 1;
3164
+ const today = noon$2(todayISO(now));
3165
+ const age = Math.floor(differenceInCalendarDays(today, noon$2(startDate)) / 7) + 1;
3165
3166
  const span = Math.max(1, Math.min(weeks, age));
3166
3167
  const counts = new Array(span).fill(0);
3167
3168
  for (const t of tasks) {
3168
3169
  if (t.status !== "done") continue;
3169
- const at = t.completedAt ? noon$1(t.completedAt) : today;
3170
+ const at = t.completedAt ? noon$2(t.completedAt) : today;
3170
3171
  const back = Math.max(0, differenceInCalendarDays(today, at));
3171
3172
  const week = Math.floor(back / 7);
3172
3173
  if (week < span) counts[span - 1 - week] += 1;
@@ -3272,7 +3273,7 @@ function tideForecast(project, tasks, now, opts = {}) {
3272
3273
  remaining
3273
3274
  };
3274
3275
  const today = todayISO(now);
3275
- const base = noon$1(today);
3276
+ const base = noon$2(today);
3276
3277
  const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1));
3277
3278
  const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
3278
3279
  let lastDue = null;
@@ -3280,12 +3281,12 @@ function tideForecast(project, tasks, now, opts = {}) {
3280
3281
  if (t.status === "done" || !t.dueDate) continue;
3281
3282
  if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
3282
3283
  }
3283
- const floor = lastDue ? differenceInCalendarDays(noon$1(lastDue.dueDate), base) : 0;
3284
+ const floor = lastDue ? differenceInCalendarDays(noon$2(lastDue.dueDate), base) : 0;
3284
3285
  const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`));
3285
3286
  const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
3286
3287
  const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
3287
3288
  const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
3288
- const toEnd = differenceInCalendarDays(noon$1(project.endDate), base);
3289
+ const toEnd = differenceInCalendarDays(noon$2(project.endDate), base);
3289
3290
  const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
3290
3291
  const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
3291
3292
  const curve = [];
@@ -14719,6 +14720,24 @@ var init_assignment = __esmMin((() => {}));
14719
14720
  //#region src/lib/slices/tasks.ts
14720
14721
  function tasksSlice({ set, get, write }) {
14721
14722
  return {
14723
+ followWork: (personId, taskIds, raidIds = []) => {
14724
+ const tasks = new Set(taskIds);
14725
+ const raid = new Set(raidIds);
14726
+ const add = (ids) => ids?.includes(personId) ? ids : [...ids ?? [], personId];
14727
+ write({
14728
+ tasks: get().tasks.map((t) => tasks.has(t.id) && t.assigneeId !== personId ? {
14729
+ ...t,
14730
+ followerIds: add(t.followerIds)
14731
+ } : t),
14732
+ raidItems: get().raidItems.map((r) => raid.has(r.id) && r.ownerId !== personId ? {
14733
+ ...r,
14734
+ followerIds: add(r.followerIds),
14735
+ updatedAt: stamp()
14736
+ } : r)
14737
+ });
14738
+ const who = get().people.find((p) => p.id === personId);
14739
+ get().log(`added ${who?.name ?? "someone"} as a second pair of hands on ${taskIds.length + raidIds.length} item${taskIds.length + raidIds.length === 1 ? "" : "s"}`, "/people");
14740
+ },
14722
14741
  addTask: (t, opts) => {
14723
14742
  if (!opts?.allowOverCap) {
14724
14743
  if (columnWip(get(), t.projectId, t.status, 1)) return "";
@@ -16947,7 +16966,7 @@ function dueFrom(value, now) {
16947
16966
  * The CLI saves through the sync loop afterwards.
16948
16967
  */
16949
16968
  init_fog();
16950
- const plural$2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
16969
+ const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
16951
16970
  async function sweepFog(opts) {
16952
16971
  const { store, ask, say, today } = opts;
16953
16972
  const start = store.getState();
@@ -16963,7 +16982,7 @@ async function sweepFog(opts) {
16963
16982
  say("No fog: every open task was touched in the last three weeks.");
16964
16983
  return tally;
16965
16984
  }
16966
- say(`Clear the fog · ${plural$2(queue.length, "task")} nobody has opened, moved or commented on for three weeks.`);
16985
+ say(`Clear the fog · ${plural$3(queue.length, "task")} nobody has opened, moved or commented on for three weeks.`);
16967
16986
  say("k still needed d new date x drop it s skip q stop\n");
16968
16987
  let stopped = false;
16969
16988
  for (let i = 0; i < queue.length && !stopped; i++) {
@@ -18262,9 +18281,9 @@ var commands_exports = /* @__PURE__ */ __exportAll({
18262
18281
  /** The one line a sync ends with; a dry run's says what it would have done. */
18263
18282
  function syncLine(report, dryRun = false) {
18264
18283
  const parts = [
18265
- report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural$1(report.sent.length, "change")} to Harbor` : "",
18266
- report.added.length ? `${dryRun ? "would add" : "added"} ${plural$1(report.added.length, "task")}` : "",
18267
- report.took.length ? `${dryRun ? "would bring in" : "brought in"} ${plural$1(report.took.length, "change")} from Harbor` : ""
18284
+ report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural$2(report.sent.length, "change")} to Harbor` : "",
18285
+ report.added.length ? `${dryRun ? "would add" : "added"} ${plural$2(report.added.length, "task")}` : "",
18286
+ report.took.length ? `${dryRun ? "would bring in" : "brought in"} ${plural$2(report.took.length, "change")} from Harbor` : ""
18268
18287
  ].filter(Boolean);
18269
18288
  if (dryRun) return `⚓ Dry run: ${parts.length ? parts.join(", ") : "already in step with Harbor"}. Nothing was written or sent.`;
18270
18289
  return parts.length ? `⚓ ${parts.join(", ")}.` : "⚓ Already in step with Harbor.";
@@ -18319,13 +18338,13 @@ async function initHere(opts) {
18319
18338
  dryRun: opts.dryRun
18320
18339
  });
18321
18340
  const { project, harbor } = made.config;
18322
- const counted = made.open === made.tasks ? plural$1(made.tasks, "task") : `${made.open} open and ${made.tasks - made.open} done ${made.tasks === 1 ? "task" : "tasks"}`;
18341
+ const counted = made.open === made.tasks ? plural$2(made.tasks, "task") : `${made.open} open and ${made.tasks - made.open} done ${made.tasks === 1 ? "task" : "tasks"}`;
18323
18342
  if (opts.dryRun) {
18324
- opts.say(made.moved ? `Would move this folder's ${plural$1(made.moved, "task")} into ${project.name} (${project.key}) in ${harbor.name}.` : `Would make ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with a file for each of its ${counted}.`);
18343
+ opts.say(made.moved ? `Would move this folder's ${plural$2(made.moved, "task")} into ${project.name} (${project.key}) in ${harbor.name}.` : `Would make ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with a file for each of its ${counted}.`);
18325
18344
  opts.say("Dry run: nothing was written or sent. Run it again without --dry-run to do it.");
18326
18345
  return made.config;
18327
18346
  }
18328
- opts.say(made.moved ? `Moved this folder's ${plural$1(made.moved, "task")} into ${project.name} in ${harbor.name}.` : `Made ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with a file for each of its ${counted}. Commit it like any code.`);
18347
+ opts.say(made.moved ? `Moved this folder's ${plural$2(made.moved, "task")} into ${project.name} in ${harbor.name}.` : `Made ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with a file for each of its ${counted}. Commit it like any code.`);
18329
18348
  opts.say("Edit a file, or add one, then run harbor sync. Changes on the website come back the same way.");
18330
18349
  if (opts.hook) opts.say(await addHook(home));
18331
18350
  return made.config;
@@ -18386,12 +18405,12 @@ async function openLocalPlan(dir, say) {
18386
18405
  }]
18387
18406
  };
18388
18407
  }
18389
- var plural$1, shownDir, HOOK;
18408
+ var plural$2, shownDir, HOOK;
18390
18409
  var init_commands = __esmMin((() => {
18391
18410
  init_errors();
18392
18411
  init_folder();
18393
18412
  init_repo_sync();
18394
- plural$1 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
18413
+ plural$2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
18395
18414
  shownDir = (cwd, dir) => `${relative(cwd, dir) || ".harbor"}/`;
18396
18415
  HOOK = "harbor sync --quiet || true";
18397
18416
  }));
@@ -21167,7 +21186,7 @@ function bucketFor(state, inst) {
21167
21186
  return bucket;
21168
21187
  }
21169
21188
  let handoff;
21170
- const open = [];
21189
+ const open$1 = [];
21171
21190
  const memo = {
21172
21191
  alloc(_inst, payload, empty) {
21173
21192
  const bucket = handoff;
@@ -21178,7 +21197,7 @@ const memo = {
21178
21197
  issues: null
21179
21198
  };
21180
21199
  bucket.set(payload.value, entry);
21181
- open.push(entry);
21200
+ open$1.push(entry);
21182
21201
  return empty;
21183
21202
  },
21184
21203
  guard(inst) {
@@ -21244,10 +21263,10 @@ const memo = {
21244
21263
  return payload;
21245
21264
  }
21246
21265
  handoff = bucket;
21247
- const depth = open.length;
21266
+ const depth = open$1.length;
21248
21267
  const result = base(payload, ctx);
21249
21268
  handoff = void 0;
21250
- const entry = open.length > depth ? open.pop() : void 0;
21269
+ const entry = open$1.length > depth ? open$1.pop() : void 0;
21251
21270
  if (result instanceof Promise) return result.then((r) => {
21252
21271
  if (entry) entry.issues = r.issues.length ? cloneIssues(r.issues) : NO_ISSUES;
21253
21272
  return r;
@@ -23619,18 +23638,18 @@ init_format();
23619
23638
  const BURN_DAYS = 56;
23620
23639
  /** A window shorter than a week says too little to read a rate from. */
23621
23640
  const MIN_DAYS = 7;
23622
- const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
23641
+ const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
23623
23642
  /**
23624
23643
  * Spend a day over the last BURN_DAYS (or the project's life, when it's
23625
23644
  * younger), from the ledger; null when nothing was spent in that time.
23626
23645
  */
23627
23646
  function burnPerDay(entries, startDate, now) {
23628
- const today = noon(todayISO(now));
23629
- const age = differenceInCalendarDays(today, noon(startDate)) + 1;
23647
+ const today = noon$1(todayISO(now));
23648
+ const age = differenceInCalendarDays(today, noon$1(startDate)) + 1;
23630
23649
  const span = Math.max(MIN_DAYS, Math.min(56, age));
23631
23650
  let sum = 0;
23632
23651
  for (const e of entries) {
23633
- const back = differenceInCalendarDays(today, noon(e.date));
23652
+ const back = differenceInCalendarDays(today, noon$1(e.date));
23634
23653
  if (back >= 0 && back < span) sum += e.amount;
23635
23654
  }
23636
23655
  return sum > 0 ? sum / span : null;
@@ -23647,8 +23666,8 @@ function slipCost(project, tide, entries, now) {
23647
23666
  const from = project.endDate > today ? project.endDate : today;
23648
23667
  const point = (date) => {
23649
23668
  if (!date) return null;
23650
- const daysLate = Math.max(0, differenceInCalendarDays(noon(date), noon(from)));
23651
- const ahead = Math.max(0, differenceInCalendarDays(noon(date), noon(today)));
23669
+ const daysLate = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(from)));
23670
+ const ahead = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(today)));
23652
23671
  return {
23653
23672
  date,
23654
23673
  daysLate,
@@ -23663,6 +23682,209 @@ function slipCost(project, tide, entries, now) {
23663
23682
  };
23664
23683
  }
23665
23684
 
23685
+ //#endregion
23686
+ //#region src/lib/long-pole.ts
23687
+ init_date_fns();
23688
+ init_blockers();
23689
+ init_format();
23690
+ init_indexes();
23691
+ const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
23692
+ const days = (from, to) => differenceInCalendarDays(noon(to), noon(from));
23693
+ const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
23694
+ const open = (t) => t.status !== "done";
23695
+ /** True when `a` finishes later than `b`, or as late behind a longer line. */
23696
+ const beats = (a, b) => !b || a.finish > b.finish || a.finish === b.finish && a.depth > b.depth;
23697
+ /**
23698
+ * Soonest finish for open tasks, and how many open links lie behind each
23699
+ * (itself included), worked out once per task however many wait on it. A
23700
+ * loop is cut where it closes. Undated tasks with nothing dated behind them
23701
+ * have no finish.
23702
+ */
23703
+ function finishes(all, today) {
23704
+ const memo = /* @__PURE__ */ new Map();
23705
+ const onPath = /* @__PURE__ */ new Set();
23706
+ const walk = (t) => {
23707
+ const known = memo.get(t.id);
23708
+ if (known) return known;
23709
+ onPath.add(t.id);
23710
+ let behind = null;
23711
+ for (const b of blockersIn(t, all)) {
23712
+ if (!open(b) || onPath.has(b.id)) continue;
23713
+ const w = walk(b);
23714
+ if (w.finish && beats(w, behind)) behind = w;
23715
+ }
23716
+ const own = t.dueDate ? later(t.dueDate.slice(0, 10), today) : null;
23717
+ onPath.delete(t.id);
23718
+ const out = {
23719
+ finish: later(own, behind?.finish ?? null),
23720
+ depth: (behind?.depth ?? 0) + 1
23721
+ };
23722
+ memo.set(t.id, out);
23723
+ return out;
23724
+ };
23725
+ return { walk };
23726
+ }
23727
+ /** The line of open tasks ending at `last`, through the blocker that finishes last at each step. */
23728
+ function lineTo(last, all, walk) {
23729
+ const line = [last];
23730
+ const seen = /* @__PURE__ */ new Set([last.id]);
23731
+ let cursor = last;
23732
+ for (;;) {
23733
+ let next = null;
23734
+ let best = null;
23735
+ for (const b of blockersIn(cursor, all)) {
23736
+ if (!open(b) || seen.has(b.id)) continue;
23737
+ const w = walk(b);
23738
+ if (w.finish && beats(w, best)) {
23739
+ best = w;
23740
+ next = b;
23741
+ }
23742
+ }
23743
+ if (!next) break;
23744
+ seen.add(next.id);
23745
+ line.unshift(next);
23746
+ cursor = next;
23747
+ }
23748
+ return line;
23749
+ }
23750
+ function linksOf(line, walk, target, today) {
23751
+ return line.map((task, i) => {
23752
+ const finish = walk(task).finish;
23753
+ const next = line[i + 1];
23754
+ const against = next ? next.dueDate?.slice(0, 10) ?? null : target;
23755
+ return {
23756
+ task,
23757
+ finish,
23758
+ late: Boolean(task.dueDate && task.dueDate.slice(0, 10) < today),
23759
+ slack: against ? days(finish, against) : null
23760
+ };
23761
+ });
23762
+ }
23763
+ function chainOf(line, walk, target, today) {
23764
+ const links = linksOf(line, walk, target, today);
23765
+ const finish = links[links.length - 1].finish;
23766
+ return {
23767
+ kind: "chain",
23768
+ links,
23769
+ finish,
23770
+ target,
23771
+ over: target ? days(target, finish) : null
23772
+ };
23773
+ }
23774
+ /** What sets a project's date: the line behind its open task that finishes last. */
23775
+ function projectPole(project, all, now) {
23776
+ const today = todayISO(now);
23777
+ const mine = tasksOf(all, project.id).filter(open);
23778
+ if (mine.length === 0) return {
23779
+ kind: "none",
23780
+ reason: "Nothing is open, so nothing holds it up."
23781
+ };
23782
+ const { walk } = finishes(all, today);
23783
+ let last = null;
23784
+ let best = null;
23785
+ for (const t of mine) {
23786
+ const w = walk(t);
23787
+ if (w.finish && beats(w, best)) {
23788
+ best = w;
23789
+ last = t;
23790
+ }
23791
+ }
23792
+ if (!last) return {
23793
+ kind: "none",
23794
+ reason: "None of its open tasks has a due date, so there's no date to trace."
23795
+ };
23796
+ return chainOf(lineTo(last, all, walk), walk, project.endDate.slice(0, 10), today);
23797
+ }
23798
+ /** What holds one task up: the line of open tasks it waits on, ending at it. */
23799
+ function taskPole(task, all, now) {
23800
+ const today = todayISO(now);
23801
+ if (!open(task)) return {
23802
+ kind: "none",
23803
+ reason: "It's done, so nothing holds it up."
23804
+ };
23805
+ const { walk } = finishes(all, today);
23806
+ if (!walk(task).finish) return {
23807
+ kind: "none",
23808
+ reason: "Neither it nor anything it waits on has a due date, so there's no date to trace."
23809
+ };
23810
+ return chainOf(lineTo(task, all, walk), walk, task.dueDate?.slice(0, 10) ?? null, today);
23811
+ }
23812
+ /**
23813
+ * What holds a milestone up: of the projects it waits on that still have
23814
+ * open work, the one finishing last (its end date, or later when its tasks
23815
+ * run past it), and that project's own long pole.
23816
+ */
23817
+ function milestonePole(milestone, data, now) {
23818
+ if (milestone.done) return {
23819
+ kind: "none",
23820
+ reason: "It's done, so nothing holds it up."
23821
+ };
23822
+ const waited = blockersIn(milestone, data.projects);
23823
+ if (waited.length === 0) return {
23824
+ kind: "none",
23825
+ reason: "It doesn't wait on any project. Link the projects it needs to trace what sets it."
23826
+ };
23827
+ const due = milestone.dueDate.slice(0, 10);
23828
+ const runs = waited.flatMap((project) => {
23829
+ if (project.health === "done" || !tasksOf(data.tasks, project.id).some(open)) return [];
23830
+ const pole = projectPole(project, data.tasks, now);
23831
+ const end = project.endDate.slice(0, 10);
23832
+ return [{
23833
+ project,
23834
+ pole,
23835
+ finish: pole.kind === "chain" ? later(pole.finish, end) ?? end : end
23836
+ }];
23837
+ });
23838
+ if (runs.length === 0) return {
23839
+ kind: "none",
23840
+ reason: "Every project it waits on is finished."
23841
+ };
23842
+ runs.sort((a, b) => b.finish.localeCompare(a.finish));
23843
+ const first = runs[0];
23844
+ const others = runs.slice(1).map((r) => ({
23845
+ project: r.project,
23846
+ finish: r.finish,
23847
+ slack: days(r.finish, due)
23848
+ }));
23849
+ return {
23850
+ kind: "chain",
23851
+ links: first.pole.kind === "chain" ? first.pole.links.map((l, i, all) => i === all.length - 1 ? {
23852
+ ...l,
23853
+ slack: days(l.finish, due)
23854
+ } : l) : [],
23855
+ finish: first.finish,
23856
+ target: due,
23857
+ over: days(due, first.finish),
23858
+ via: {
23859
+ project: first.project,
23860
+ finish: first.finish
23861
+ },
23862
+ others
23863
+ };
23864
+ }
23865
+ const plural$1 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
23866
+ /** A link's slack in a few words: "4 days spare", "no slack", "3 days late for what's next". */
23867
+ function slackText(slack, last = false) {
23868
+ if (slack == null) return last ? "no date to hold" : "next has no due date";
23869
+ if (slack > 0) return `${plural$1(slack, "day")} spare`;
23870
+ if (slack === 0) return "no slack";
23871
+ return last ? `runs ${plural$1(-slack, "day")} over` : `pushes the next ${plural$1(-slack, "day")}`;
23872
+ }
23873
+ /**
23874
+ * The sentence above the line: what sets the date and how it compares with
23875
+ * the date being held. `name` turns a task into what to call it (its key).
23876
+ */
23877
+ function poleHeadline(pole, name, held) {
23878
+ if (pole.kind !== "chain") return null;
23879
+ const last = pole.links[pole.links.length - 1];
23880
+ const lead = pole.via ? `${pole.via.project.name} finishes ${shortDate(pole.finish)}` : `${last ? name(last.task) : "It"} can finish ${shortDate(pole.finish)}`;
23881
+ const tail = pole.via && last && last.finish < pole.finish ? ` Its tasks can all be done by ${shortDate(last.finish)}; its end date sets it.` : "";
23882
+ if (pole.over == null || !pole.target) return `${lead}.${tail}`;
23883
+ if (pole.over > 0) return `${lead}, ${plural$1(pole.over, "day")} after ${held} (${shortDate(pole.target)}).${tail}`;
23884
+ if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
23885
+ return `${lead}, ${plural$1(-pole.over, "day")} before ${held}.${tail}`;
23886
+ }
23887
+
23666
23888
  //#endregion
23667
23889
  //#region src/lib/csv.ts
23668
23890
  /**
@@ -24940,6 +25162,80 @@ const HARBOR_TOOLS = [
24940
25162
  };
24941
25163
  }
24942
25164
  }),
25165
+ tool({
25166
+ name: "long_pole",
25167
+ title: "What's holding this up",
25168
+ description: "The long pole behind a project, a task or a milestone: of all the task waits behind it, the one line that sets its date, first blocker to last. Each link has its due date, the soonest it can finish (its due date, later when what it waits on finishes after, today at the earliest for late work) and slack: days it can slip before it moves the next link, or the end; negative when it already does. over is how many days the line finishes past the date held (a project's end, a task's or milestone's due date). For a milestone, via is the project it waits on that finishes last, and others are the rest with their slack." + DATA_NOTE,
25169
+ input: object({
25170
+ harbor: harborArg,
25171
+ of: string().min(1).max(200).describe("A project (id, name or key like \"RR\"), a task key (\"RR-42\") or a milestone (id or name).")
25172
+ }),
25173
+ run: (ctx, input) => {
25174
+ const { data } = ctx;
25175
+ const people = byId(data.people);
25176
+ const projects = byId(data.projects);
25177
+ const keyOf = (t) => taskKey(t, projects.get(t.projectId));
25178
+ const name = (t) => keyOf(t) ?? `“${t.title}”`;
25179
+ const answer = (kind, what, pole, held) => {
25180
+ if (pole.kind !== "chain") return {
25181
+ of: {
25182
+ kind,
25183
+ name: what
25184
+ },
25185
+ none: pole.reason
25186
+ };
25187
+ return {
25188
+ of: {
25189
+ kind,
25190
+ name: what
25191
+ },
25192
+ headline: poleHeadline(pole, name, held),
25193
+ finish: pole.finish,
25194
+ held: pole.target,
25195
+ over: pole.over,
25196
+ ...pole.via ? { via: {
25197
+ project: pole.via.project.name,
25198
+ finish: pole.via.finish
25199
+ } } : {},
25200
+ chain: pole.links.map((l, i) => ({
25201
+ id: l.task.id,
25202
+ ...keyOf(l.task) ? { key: keyOf(l.task) } : {},
25203
+ title: l.task.title,
25204
+ status: l.task.status,
25205
+ assignee: nameOf(people, l.task.assigneeId),
25206
+ due: dayOf(l.task.dueDate),
25207
+ canFinish: l.finish,
25208
+ late: l.late,
25209
+ slack: l.slack,
25210
+ slackText: slackText(l.slack, i === pole.links.length - 1)
25211
+ })),
25212
+ ...pole.others ? { others: pole.others.map((o) => ({
25213
+ project: o.project.name,
25214
+ finish: o.finish,
25215
+ slack: o.slack
25216
+ })) } : {}
25217
+ };
25218
+ };
25219
+ const task = data.tasks.find((x) => x.id === input.of.trim()) ?? findByKey(data, input.of);
25220
+ if (task) return answer("task", name(task), taskPole(task, data.tasks, ctx.now), "its due date");
25221
+ let project = null;
25222
+ try {
25223
+ project = findOne(data.projects, input.of, "project");
25224
+ } catch (err) {
25225
+ if (!(err instanceof ToolError) || !/^No project matches/.test(err.message)) throw err;
25226
+ const milestones = data.milestones.filter((m) => !m.done);
25227
+ let milestone;
25228
+ try {
25229
+ milestone = findOne(milestones.length ? milestones : data.milestones, input.of, "milestone");
25230
+ } catch (inner) {
25231
+ if (inner instanceof ToolError && /^No milestone matches/.test(inner.message)) throw new ToolError(`No project, task key or open milestone matches "${input.of.trim()}".`);
25232
+ throw inner;
25233
+ }
25234
+ return answer("milestone", milestone.name, milestonePole(milestone, data, ctx.now), "the milestone");
25235
+ }
25236
+ return answer("project", project.name, projectPole(project, data.tasks, ctx.now), "the end date");
25237
+ }
25238
+ }),
24943
25239
  tool({
24944
25240
  name: "shipping_forecast",
24945
25241
  title: "The harbor forecast",
package/lib/cli.mjs CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  soundingText,
38
38
  soundingsText,
39
39
  voyageText,
40
+ whyText,
40
41
  handoverText,
41
42
  fogText,
42
43
  agentWorkText,
@@ -89,6 +90,8 @@ export const HELP = `harbor: Harbor from the terminal
89
90
  dry dock: the forecast with plan
90
91
  changes tried on a copy; nothing
91
92
  changes without --apply
93
+ harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of
94
+ task waits that sets its date
92
95
  harbor hill [--program P] where each project is on its hill
93
96
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
94
97
  out, past 50 is getting it done
@@ -142,6 +145,8 @@ export const HELP = `harbor: Harbor from the terminal
142
145
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
143
146
  [--due DATE]
144
147
  harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
148
+ [--turned-down T] [--look-again DATE]
149
+ [--supersedes "OLD DECISION"]
145
150
  harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
146
151
  the register, worst first
147
152
  (--closed adds closed ones)
@@ -149,7 +154,7 @@ export const HELP = `harbor: Harbor from the terminal
149
154
  [--probability 1-5] [--due DATE|none] [--update TEXT]
150
155
  [--title T] [--description T] [--mitigation T]
151
156
  [--contingency T] [--resolution T] [--rationale T]
152
- [--notes T]
157
+ [--notes T] [--turned-down T] [--look-again DATE|none]
153
158
  one risk, issue or decision in
154
159
  full, or change it
155
160
  harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
@@ -719,6 +724,20 @@ const DETAIL = {
719
724
  " never marks its own work Done; a person does, on the website or in the",
720
725
  " terminal app.",
721
726
  ],
727
+ why: [
728
+ " harbor why PROJECT the line of task waits that sets a",
729
+ " project's date, against its end",
730
+ " harbor why TASK the waits behind one task (a key",
731
+ " like RR-42), against its due date",
732
+ " harbor why MILESTONE the project a milestone waits on",
733
+ " that finishes last, and its line",
734
+ "",
735
+ " Tasks have due dates, not lengths, so a task can finish on its due date",
736
+ " at the soonest, never before what it waits on, and never in the past.",
737
+ " Each link's slack is the days it can slip before it moves the next one",
738
+ " (or the end); a negative one already does. PROJECT is a name, a key",
739
+ " like RR, or an id.",
740
+ ],
722
741
  voyage: [
723
742
  " harbor voyage PROJECT reads a Done project's voyage log",
724
743
  ' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
@@ -743,7 +762,8 @@ const DETAIL = {
743
762
  " Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
744
763
  " --due DATE|none (review by, or decide by), --title, --description,",
745
764
  " --mitigation and --contingency (risks), --resolution (issues and",
746
- " decisions), --rationale (decisions) and --notes. TITLE is its title or",
765
+ " decisions), --rationale, --turned-down and --look-again DATE|none",
766
+ " (decisions) and --notes. TITLE is its title or",
747
767
  " enough of it to pick one out; harbor risks lists them. Changes several",
748
768
  " at once, as one change.",
749
769
  ],
@@ -794,6 +814,12 @@ const DETAIL = {
794
814
  ' harbor decision "TITLE" --project P writes: logs a decision to make',
795
815
  ' harbor decision "TITLE" --program P writes: logs one on the program',
796
816
  " [--owner NAME] [--due DATE] (the date to decide by)",
817
+ " [--turned-down T] (what was turned down, and why not)",
818
+ " [--look-again DATE] (when it comes back to the top of the brief)",
819
+ ' harbor decision "TITLE" --supersedes "OLD"',
820
+ " writes: a decision that replaces an",
821
+ " earlier one, which reads as",
822
+ " superseded",
797
823
  ],
798
824
  };
799
825
 
@@ -850,6 +876,9 @@ function distance(a, b) {
850
876
  /** A mistyped command, with the one it was probably meant to be. */
851
877
  export function noCommand(name) {
852
878
  if (name.startsWith("-")) return `${name} goes after a command, like harbor mine ${name}.`;
879
+ // A plural kind reads the register, not the command that writes one.
880
+ if (["decisions", "issues"].includes(name))
881
+ return `No command "${name}". harbor risks --kind ${name.slice(0, -1)} lists them.`;
853
882
  const near = COMMANDS.filter((c) => distance(name, c) <= (c.length > 4 ? 2 : 1));
854
883
  const guess = near.length === 1 ? ` Did you mean harbor ${near[0]}?` : "";
855
884
  return `No command "${name}".${guess} Run harbor help.`;
@@ -1963,7 +1992,7 @@ export async function run(argv, io = {}) {
1963
1992
  includeClosed: Boolean(values.closed),
1964
1993
  limit: 50,
1965
1994
  },
1966
- (r) => raidList(r, positionals.join(" ")),
1995
+ (r) => raidList(r, positionals.join(" "), kind),
1967
1996
  );
1968
1997
  }
1969
1998
  case "report": {
@@ -2161,6 +2190,12 @@ export async function run(argv, io = {}) {
2161
2190
  soundingsText(r),
2162
2191
  );
2163
2192
  }
2193
+ case "why": {
2194
+ const { positionals, harbor } = parse(spec());
2195
+ const of = positionals.join(" ").trim();
2196
+ if (!of) throw new HarborError("Say what: a project, a task key like RR-42, or a milestone.");
2197
+ return call("long_pole", { harbor, of }, whyText);
2198
+ }
2164
2199
  case "voyage": {
2165
2200
  const { positionals, harbor } = parse(spec());
2166
2201
  const [project, ...words] = positionals;
@@ -2292,6 +2327,8 @@ export async function run(argv, io = {}) {
2292
2327
  "update",
2293
2328
  "decided-by",
2294
2329
  "decided-on",
2330
+ "turned-down",
2331
+ "look-again",
2295
2332
  ...fields,
2296
2333
  ],
2297
2334
  ),
@@ -2309,6 +2346,9 @@ export async function run(argv, io = {}) {
2309
2346
  decidedBy: values["decided-by"],
2310
2347
  decidedOn:
2311
2348
  values["decided-on"] === undefined ? undefined : nullable(dueDate(values["decided-on"])),
2349
+ turnedDown: values["turned-down"],
2350
+ revisitOn:
2351
+ values["look-again"] === undefined ? undefined : nullable(dueDate(values["look-again"])),
2312
2352
  update: values.update,
2313
2353
  ...Object.fromEntries(fields.map((f) => [f, values[f]])),
2314
2354
  };
@@ -2370,11 +2410,15 @@ export async function run(argv, io = {}) {
2370
2410
  spec(
2371
2411
  [],
2372
2412
  ["project", "program", "owner", "due"].concat(
2373
- command === "risk" ? ["impact", "probability"] : command === "issue" ? ["impact"] : [],
2413
+ command === "risk"
2414
+ ? ["impact", "probability"]
2415
+ : command === "issue"
2416
+ ? ["impact"]
2417
+ : ["supersedes", "turned-down", "look-again"],
2374
2418
  ),
2375
2419
  ),
2376
2420
  );
2377
- if (!values.project && !values.program) {
2421
+ if (!values.project && !values.program && !values.supersedes) {
2378
2422
  throw new HarborError("Say where it belongs: --project NAME or --program NAME.");
2379
2423
  }
2380
2424
  return call(
@@ -2389,8 +2433,14 @@ export async function run(argv, io = {}) {
2389
2433
  impact: level(values.impact, "impact"),
2390
2434
  probability: level(values.probability, "probability"),
2391
2435
  due: dueDate(values.due),
2436
+ supersedes: values.supersedes,
2437
+ turnedDown: values["turned-down"],
2438
+ revisitOn: values["look-again"] === undefined ? undefined : dueDate(values["look-again"]),
2392
2439
  },
2393
- (r) => `Logged the ${command} “${r.raised.title}”.`,
2440
+ (r) =>
2441
+ r.replaces
2442
+ ? `Logged the decision “${r.raised.title}”; it replaces “${r.replaces.title}”.`
2443
+ : `Logged the ${command} “${r.raised.title}”.`,
2394
2444
  );
2395
2445
  }
2396
2446
  default:
package/lib/format.mjs CHANGED
@@ -246,12 +246,19 @@ export function todayLocal(d = new Date()) {
246
246
  }
247
247
 
248
248
  /** The register, worst first: kind, title, then status, scores, owner and where it sits. */
249
- export function raidList(r, words = "") {
249
+ export function raidList(r, words = "", kinds = []) {
250
250
  const want = words.trim().toLowerCase();
251
251
  const items = (r.items ?? []).filter(
252
252
  (x) => !want || `${x.title} ${x.description ?? ""}`.toLowerCase().includes(want),
253
253
  );
254
- if (!items.length) return want ? "Nothing in the register matches." : "The register is clear.";
254
+ if (!items.length) {
255
+ if (want) return "Nothing in the register matches.";
256
+ const only =
257
+ kinds?.length === 1
258
+ ? { risk: "risks", issue: "issues", decision: "decisions" }[kinds[0]]
259
+ : null;
260
+ return only ? `No ${only} in the register.` : "The register is clear.";
261
+ }
255
262
  const lines = items.map((x) => {
256
263
  const bits = [
257
264
  x.status?.toLowerCase(),
@@ -480,6 +487,35 @@ export function voyageText(r) {
480
487
  );
481
488
  }
482
489
 
490
+ /** What's holding something up, as long_pole answers it: the headline, then the line of waits, first blocker at the top. */
491
+ export function whyText(r) {
492
+ const what = r.of.kind === "task" ? r.of.name : `${r.of.name}${r.of.kind === "milestone" ? " (milestone)" : ""}`;
493
+ if (r.none) return `What's holding up ${what}: ${r.none.replace(/^\w/, (c) => c.toLowerCase())}`;
494
+ const head = [`What's holding up ${what}`];
495
+ if (r.headline) head.push(r.headline);
496
+ const chain = r.chain ?? [];
497
+ const width = Math.min(12, Math.max(0, ...chain.map((l) => (l.key ?? "").length)));
498
+ const lines = chain.flatMap((l, i) => {
499
+ const due = l.due ? (l.late ? `was due ${l.due}` : `due ${l.due}`) : "no due date";
500
+ const finish = l.canFinish !== l.due ? ` · can finish ${l.canFinish}` : "";
501
+ const row = [
502
+ ` ${(l.key ?? "").padEnd(width)}${width ? " " : ""}${l.title}`,
503
+ ` ${" ".repeat(width)}${width ? " " : ""}${l.assignee ?? "nobody yet"} · ${due}${finish} · ${l.slackText}`,
504
+ ];
505
+ return i < chain.length - 1 ? [...row, ` ${" ".repeat(width ? Math.floor(width / 2) : 0)}│`] : row;
506
+ });
507
+ const via = r.via ? [`It waits longest on ${r.via.project}; inside it:`] : [];
508
+ const others = (r.others ?? []).length
509
+ ? [
510
+ "",
511
+ `Also waits on ${r.others
512
+ .map((o) => `${o.project} (finishes ${o.finish}, ${o.slack >= 0 ? `${o.slack} day${o.slack === 1 ? "" : "s"} spare` : `${-o.slack} day${o.slack === -1 ? "" : "s"} over`})`)
513
+ .join(", ")}.`,
514
+ ]
515
+ : [];
516
+ return [...head, "", ...via, ...lines, ...others].join("\n");
517
+ }
518
+
483
519
  /** A position on a 21-wide track, the top of the hill marked in the middle. */
484
520
  function hillTrack(at) {
485
521
  const cells = Array.from({ length: 21 }, (_, i) => (i === 10 ? "┼" : "─"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.50.0",
3
+ "version": "0.52.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",