@harborppm/cli 0.51.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
package/dist/app.mjs CHANGED
@@ -3158,16 +3158,16 @@ const TRIALS = 1e3;
3158
3158
  const MAX_WEEKS = 104;
3159
3159
  /** Fewer finished tasks than this and the forecast says it's rough. */
3160
3160
  const ROUGH_BELOW = 5;
3161
- 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`);
3162
3162
  /** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
3163
3163
  function weeklyFinished(tasks, startDate, now, weeks = 6) {
3164
- const today = noon$1(todayISO(now));
3165
- 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;
3166
3166
  const span = Math.max(1, Math.min(weeks, age));
3167
3167
  const counts = new Array(span).fill(0);
3168
3168
  for (const t of tasks) {
3169
3169
  if (t.status !== "done") continue;
3170
- const at = t.completedAt ? noon$1(t.completedAt) : today;
3170
+ const at = t.completedAt ? noon$2(t.completedAt) : today;
3171
3171
  const back = Math.max(0, differenceInCalendarDays(today, at));
3172
3172
  const week = Math.floor(back / 7);
3173
3173
  if (week < span) counts[span - 1 - week] += 1;
@@ -3273,7 +3273,7 @@ function tideForecast(project, tasks, now, opts = {}) {
3273
3273
  remaining
3274
3274
  };
3275
3275
  const today = todayISO(now);
3276
- const base = noon$1(today);
3276
+ const base = noon$2(today);
3277
3277
  const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1));
3278
3278
  const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
3279
3279
  let lastDue = null;
@@ -3281,12 +3281,12 @@ function tideForecast(project, tasks, now, opts = {}) {
3281
3281
  if (t.status === "done" || !t.dueDate) continue;
3282
3282
  if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
3283
3283
  }
3284
- const floor = lastDue ? differenceInCalendarDays(noon$1(lastDue.dueDate), base) : 0;
3284
+ const floor = lastDue ? differenceInCalendarDays(noon$2(lastDue.dueDate), base) : 0;
3285
3285
  const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`));
3286
3286
  const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
3287
3287
  const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
3288
3288
  const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
3289
- const toEnd = differenceInCalendarDays(noon$1(project.endDate), base);
3289
+ const toEnd = differenceInCalendarDays(noon$2(project.endDate), base);
3290
3290
  const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
3291
3291
  const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
3292
3292
  const curve = [];
@@ -16966,7 +16966,7 @@ function dueFrom(value, now) {
16966
16966
  * The CLI saves through the sync loop afterwards.
16967
16967
  */
16968
16968
  init_fog();
16969
- 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}`;
16970
16970
  async function sweepFog(opts) {
16971
16971
  const { store, ask, say, today } = opts;
16972
16972
  const start = store.getState();
@@ -16982,7 +16982,7 @@ async function sweepFog(opts) {
16982
16982
  say("No fog: every open task was touched in the last three weeks.");
16983
16983
  return tally;
16984
16984
  }
16985
- 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.`);
16986
16986
  say("k still needed d new date x drop it s skip q stop\n");
16987
16987
  let stopped = false;
16988
16988
  for (let i = 0; i < queue.length && !stopped; i++) {
@@ -18281,9 +18281,9 @@ var commands_exports = /* @__PURE__ */ __exportAll({
18281
18281
  /** The one line a sync ends with; a dry run's says what it would have done. */
18282
18282
  function syncLine(report, dryRun = false) {
18283
18283
  const parts = [
18284
- report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural$1(report.sent.length, "change")} to Harbor` : "",
18285
- report.added.length ? `${dryRun ? "would add" : "added"} ${plural$1(report.added.length, "task")}` : "",
18286
- 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` : ""
18287
18287
  ].filter(Boolean);
18288
18288
  if (dryRun) return `⚓ Dry run: ${parts.length ? parts.join(", ") : "already in step with Harbor"}. Nothing was written or sent.`;
18289
18289
  return parts.length ? `⚓ ${parts.join(", ")}.` : "⚓ Already in step with Harbor.";
@@ -18338,13 +18338,13 @@ async function initHere(opts) {
18338
18338
  dryRun: opts.dryRun
18339
18339
  });
18340
18340
  const { project, harbor } = made.config;
18341
- 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"}`;
18342
18342
  if (opts.dryRun) {
18343
- 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}.`);
18344
18344
  opts.say("Dry run: nothing was written or sent. Run it again without --dry-run to do it.");
18345
18345
  return made.config;
18346
18346
  }
18347
- 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.`);
18348
18348
  opts.say("Edit a file, or add one, then run harbor sync. Changes on the website come back the same way.");
18349
18349
  if (opts.hook) opts.say(await addHook(home));
18350
18350
  return made.config;
@@ -18405,12 +18405,12 @@ async function openLocalPlan(dir, say) {
18405
18405
  }]
18406
18406
  };
18407
18407
  }
18408
- var plural$1, shownDir, HOOK;
18408
+ var plural$2, shownDir, HOOK;
18409
18409
  var init_commands = __esmMin((() => {
18410
18410
  init_errors();
18411
18411
  init_folder();
18412
18412
  init_repo_sync();
18413
- 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}`;
18414
18414
  shownDir = (cwd, dir) => `${relative(cwd, dir) || ".harbor"}/`;
18415
18415
  HOOK = "harbor sync --quiet || true";
18416
18416
  }));
@@ -21186,7 +21186,7 @@ function bucketFor(state, inst) {
21186
21186
  return bucket;
21187
21187
  }
21188
21188
  let handoff;
21189
- const open = [];
21189
+ const open$1 = [];
21190
21190
  const memo = {
21191
21191
  alloc(_inst, payload, empty) {
21192
21192
  const bucket = handoff;
@@ -21197,7 +21197,7 @@ const memo = {
21197
21197
  issues: null
21198
21198
  };
21199
21199
  bucket.set(payload.value, entry);
21200
- open.push(entry);
21200
+ open$1.push(entry);
21201
21201
  return empty;
21202
21202
  },
21203
21203
  guard(inst) {
@@ -21263,10 +21263,10 @@ const memo = {
21263
21263
  return payload;
21264
21264
  }
21265
21265
  handoff = bucket;
21266
- const depth = open.length;
21266
+ const depth = open$1.length;
21267
21267
  const result = base(payload, ctx);
21268
21268
  handoff = void 0;
21269
- const entry = open.length > depth ? open.pop() : void 0;
21269
+ const entry = open$1.length > depth ? open$1.pop() : void 0;
21270
21270
  if (result instanceof Promise) return result.then((r) => {
21271
21271
  if (entry) entry.issues = r.issues.length ? cloneIssues(r.issues) : NO_ISSUES;
21272
21272
  return r;
@@ -23638,18 +23638,18 @@ init_format();
23638
23638
  const BURN_DAYS = 56;
23639
23639
  /** A window shorter than a week says too little to read a rate from. */
23640
23640
  const MIN_DAYS = 7;
23641
- 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`);
23642
23642
  /**
23643
23643
  * Spend a day over the last BURN_DAYS (or the project's life, when it's
23644
23644
  * younger), from the ledger; null when nothing was spent in that time.
23645
23645
  */
23646
23646
  function burnPerDay(entries, startDate, now) {
23647
- const today = noon(todayISO(now));
23648
- const age = differenceInCalendarDays(today, noon(startDate)) + 1;
23647
+ const today = noon$1(todayISO(now));
23648
+ const age = differenceInCalendarDays(today, noon$1(startDate)) + 1;
23649
23649
  const span = Math.max(MIN_DAYS, Math.min(56, age));
23650
23650
  let sum = 0;
23651
23651
  for (const e of entries) {
23652
- const back = differenceInCalendarDays(today, noon(e.date));
23652
+ const back = differenceInCalendarDays(today, noon$1(e.date));
23653
23653
  if (back >= 0 && back < span) sum += e.amount;
23654
23654
  }
23655
23655
  return sum > 0 ? sum / span : null;
@@ -23666,8 +23666,8 @@ function slipCost(project, tide, entries, now) {
23666
23666
  const from = project.endDate > today ? project.endDate : today;
23667
23667
  const point = (date) => {
23668
23668
  if (!date) return null;
23669
- const daysLate = Math.max(0, differenceInCalendarDays(noon(date), noon(from)));
23670
- 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)));
23671
23671
  return {
23672
23672
  date,
23673
23673
  daysLate,
@@ -23682,6 +23682,209 @@ function slipCost(project, tide, entries, now) {
23682
23682
  };
23683
23683
  }
23684
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
+
23685
23888
  //#endregion
23686
23889
  //#region src/lib/csv.ts
23687
23890
  /**
@@ -24959,6 +25162,80 @@ const HARBOR_TOOLS = [
24959
25162
  };
24960
25163
  }
24961
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
+ }),
24962
25239
  tool({
24963
25240
  name: "shipping_forecast",
24964
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
@@ -721,6 +724,20 @@ const DETAIL = {
721
724
  " never marks its own work Done; a person does, on the website or in the",
722
725
  " terminal app.",
723
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
+ ],
724
741
  voyage: [
725
742
  " harbor voyage PROJECT reads a Done project's voyage log",
726
743
  ' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
@@ -2173,6 +2190,12 @@ export async function run(argv, io = {}) {
2173
2190
  soundingsText(r),
2174
2191
  );
2175
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
+ }
2176
2199
  case "voyage": {
2177
2200
  const { positionals, harbor } = parse(spec());
2178
2201
  const [project, ...words] = positionals;
package/lib/format.mjs CHANGED
@@ -487,6 +487,35 @@ export function voyageText(r) {
487
487
  );
488
488
  }
489
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
+
490
519
  /** A position on a 21-wide track, the top of the hill marked in the middle. */
491
520
  function hillTrack(at) {
492
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.51.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",