@harborppm/cli 0.57.0 → 0.58.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
@@ -81,6 +81,7 @@ due: 2026-11-07
81
81
  priority: high
82
82
  waits: [CS-2]
83
83
  ---
84
+
84
85
  Old endpoints go away on Nov 20.
85
86
  ```
86
87
 
@@ -199,8 +200,10 @@ harbor init --local [NAME] a plan in this folder alone, no acco
199
200
  harbor sync [--quiet] [--dry-run] the files and the website, in step both ways
200
201
  harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT] [--keep ID,ID] [--confirm]
201
202
  harbor handback PERSON
202
- harbor fog [--project P] [--list] tasks nobody has touched for three weeks, one at a time
203
+ harbor fog [--project P] tasks nobody has touched for three weeks, longest first
204
+ harbor fog --sweep [--project P] the same, one at a time: still needed, a new date, or drop it
203
205
  harbor fog keep TASK it's still needed: out of the fog
206
+ harbor watch [--project P] [--bell] a live ticker of the harbor's changes
204
207
  harbor lifeboats [--program P] work only one person knows, each with a second pair of hands
205
208
  harbor lifeboats N [--person NAME] adds that second pair of hands
206
209
  harbor follow TASK|"RISK" --person NAME someone follows it too
@@ -210,7 +213,9 @@ harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
210
213
 
211
214
  `harbor handover Priya` lists everything Priya carries (open tasks, sign-offs waiting on her, risks and issues she owns, projects and programs she leads) and who should take each, from who has room this week. Nothing moves until you add `--confirm`. `--until DATE` is for time away: Priya shows as away on People, and `harbor handback Priya` gives back whatever is still with whoever took it. `--note` goes with each task and risk as a comment.
212
215
 
213
- `harbor fog` walks through the open tasks nobody has opened, moved, edited or commented on for three weeks, one at a time: `k` still needed (clears the fog), `d` a new date, `x` drop it, `s` skip, `q` stop. Piped or with `--list` it just lists them. On the board, in the app and on the website, those cards show dimmed.
216
+ `harbor fog` lists the open tasks nobody has opened, moved, edited or commented on for three weeks, longest first. `harbor fog --sweep` walks through them one at a time in a terminal: `k` still needed (clears the fog), `d` a new date, `x` drop it, `s` skip, `q` stop. On the board, in the app and on the website, those cards show dimmed.
217
+
218
+ `harbor watch` turns a narrow split or a tmux pane into a live ticker of your harbor: cards moving, comments, health changes, a line each as they happen, over the same live updates the website uses. Lines about your own work (your tasks, mentions of you, what you lead) are bright and the rest dim; `--bell` rings the terminal's bell when something of yours changes, and `--project` keeps to one project. `q` stops.
214
219
 
215
220
  `harbor lifeboats` lists the work only one person has touched (a project, their corner of one, a task others wait on, their serious risks), numbered, each with someone who has room this week and already works nearby. `harbor lifeboats 2` puts that person on line 2 as a second pair of hands (`--person NAME` for someone else); `harbor follow RR-4 --person Sam` does it for one task. Following doesn't take the work over. `harbor raid "DECISION" --still-stands "WHY"` says a decision that came back for a look still stands.
216
221
 
package/dist/app.mjs CHANGED
@@ -34,12 +34,17 @@ var __exportAll = (all, no_symbols) => {
34
34
 
35
35
  //#endregion
36
36
  //#region src/lib/clock.ts
37
+ /** A date's local wall-clock time, with no zone: "2026-10-05T23:10:00". */
38
+ function wallClock(d = /* @__PURE__ */ new Date()) {
39
+ return `${d.getFullYear()}-${pad$1(d.getMonth() + 1)}-${pad$1(d.getDate())}T${pad$1(d.getHours())}:${pad$1(d.getMinutes())}:${pad$1(d.getSeconds())}`;
40
+ }
37
41
  function clockNow() {
38
42
  return frozen ? new Date(frozen) : /* @__PURE__ */ new Date();
39
43
  }
40
- var frozen;
44
+ var frozen, pad$1;
41
45
  var init_clock = __esmMin((() => {
42
46
  frozen = null;
47
+ pad$1 = (n) => String(n).padStart(2, "0");
43
48
  }));
44
49
 
45
50
  //#endregion
@@ -6486,7 +6491,7 @@ function composeBriefBody(opts) {
6486
6491
  }
6487
6492
  return lines.join("\n").trim();
6488
6493
  }
6489
- function firstName$1(person) {
6494
+ function firstName$2(person) {
6490
6495
  return person?.name.split(/\s+/)[0] ?? "Someone";
6491
6496
  }
6492
6497
  function similar(a, b) {
@@ -6524,7 +6529,7 @@ function headlineOf(drums, flow) {
6524
6529
  }
6525
6530
  if (idle.length === 1) {
6526
6531
  const d = idle[0];
6527
- return d.owner ? `${firstName$1(d.owner)} is in play elsewhere. ${d.constraint.title} is starving.` : `${d.constraint.title} is idle. Protect it or retire it.`;
6532
+ return d.owner ? `${firstName$2(d.owner)} is in play elsewhere. ${d.constraint.title} is starving.` : `${d.constraint.title} is idle. Protect it or retire it.`;
6528
6533
  }
6529
6534
  if (idle.length > 1) return `${idle.length} constraints are idle. The work is not at the constraint.`;
6530
6535
  if (system && flow.impliedDays != null && flow.impliedDays > 21) return `At this load, ${formatWait(flow.impliedDays)}. ${system.constraint.title}.`;
@@ -6588,7 +6593,7 @@ function briefOf(data, opts = {}) {
6588
6593
  for (const d of drums.filter((x) => x.idle)) push({
6589
6594
  id: `idle-${d.constraint.id}`,
6590
6595
  title: `${d.constraint.title} is idle`,
6591
- detail: d.owner ? `${firstName$1(d.owner)} is in play elsewhere. ${d.program.name} is waiting.` : `${d.program.name} has a named constraint and nobody protecting it.`,
6596
+ detail: d.owner ? `${firstName$2(d.owner)} is in play elsewhere. ${d.program.name} is waiting.` : `${d.program.name} has a named constraint and nobody protecting it.`,
6592
6597
  href: `/programs/${d.program.id}`
6593
6598
  });
6594
6599
  for (const d of drums.filter((x) => x.constraint.kind === "collision" && !x.idle)) push({
@@ -8579,7 +8584,7 @@ const PING_KIND_LABEL = {
8579
8584
  idle: "Quiet blockers",
8580
8585
  mention: "Mentions"
8581
8586
  };
8582
- function firstName(name) {
8587
+ function firstName$1(name) {
8583
8588
  return name.split(/\s+/)[0] ?? name;
8584
8589
  }
8585
8590
  /** FNV-1a over the text, as eight hex digits: a short, stable name for a set of ids. */
@@ -8618,7 +8623,7 @@ function inboxOf(data, personId, now = clockNow()) {
8618
8623
  pings.push({
8619
8624
  id: `assigned:${task.id}:${task.assignedAt}`,
8620
8625
  kind: "assigned",
8621
- title: by ? `${firstName(by.name)} gave you a task` : "You have a new task",
8626
+ title: by ? `${firstName$1(by.name)} gave you a task` : "You have a new task",
8622
8627
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""}`,
8623
8628
  at: task.assignedAt,
8624
8629
  taskId: task.id,
@@ -8632,7 +8637,7 @@ function inboxOf(data, personId, now = clockNow()) {
8632
8637
  const project = projectOf(data, blocker);
8633
8638
  const names = [...new Set(waiting.map((t) => {
8634
8639
  const who = data.people.find((p) => p.id === t.assigneeId);
8635
- return who ? firstName(who.name) : "Someone";
8640
+ return who ? firstName$1(who.name) : "Someone";
8636
8641
  }))];
8637
8642
  const blocked = waiting.length === 1 ? `“${waiting[0].title}”` : `${waiting.length} tasks`;
8638
8643
  const upstream = openBlockerNames(blocker, data.tasks);
@@ -8667,7 +8672,7 @@ function inboxOf(data, personId, now = clockNow()) {
8667
8672
  pings.push({
8668
8673
  id: `changes:${m.id}:${shortHash(note)}`,
8669
8674
  kind: "approval",
8670
- title: `${approver ? firstName(approver.name) : "The approver"} asked for changes to ${m.name}`,
8675
+ title: `${approver ? firstName$1(approver.name) : "The approver"} asked for changes to ${m.name}`,
8671
8676
  detail: note ? `${where}${excerpt(note)}` : program?.name ?? "",
8672
8677
  at: m.dueDate,
8673
8678
  programId: m.programId,
@@ -8713,7 +8718,7 @@ function inboxOf(data, personId, now = clockNow()) {
8713
8718
  pings.push({
8714
8719
  id: `sounding:${task.id}:${s.askedAt}`,
8715
8720
  kind: "sounding",
8716
- title: `${by ? firstName(by.name) : "Someone"} asked how long a task will take`,
8721
+ title: `${by ? firstName$1(by.name) : "Someone"} asked how long a task will take`,
8717
8722
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""} · answer alone; the guesses show together`,
8718
8723
  at: s.askedAt,
8719
8724
  taskId: task.id,
@@ -8730,7 +8735,7 @@ function inboxOf(data, personId, now = clockNow()) {
8730
8735
  pings.push({
8731
8736
  id: `mention:${comment.id}`,
8732
8737
  kind: "mention",
8733
- title: author ? `${firstName(author.name)} mentioned you` : "You were mentioned",
8738
+ title: author ? `${firstName$1(author.name)} mentioned you` : "You were mentioned",
8734
8739
  detail: snippet ? `on “${task.title}”${project ? ` · ${project.name}` : ""} — ${snippet}` : `on “${task.title}”${project ? ` · ${project.name}` : ""}`,
8735
8740
  at: comment.createdAt,
8736
8741
  taskId: task.id,
@@ -18306,6 +18311,7 @@ function me(ctx) {
18306
18311
  return ctx.data.people.find((p) => p.accountId === ctx.accountId) ?? null;
18307
18312
  }
18308
18313
  const nameOf$2 = (people, id) => id && people.get(id)?.name || null;
18314
+ const LIFEBOATS_ALONE = "Nobody else on the crew yet; harbor invite EMAIL adds someone.";
18309
18315
  /** One line of "only one person knows these" (src/lib/lifeboats.ts), for an agent. */
18310
18316
  function lifeboatOut(data, line, people) {
18311
18317
  return {
@@ -19464,8 +19470,10 @@ const HARBOR_TOOLS = [
19464
19470
  const program = input.program ? findOne(data.programs, input.program, "program") : null;
19465
19471
  const lines = lifeboats(data, ctx.now, { programIds: program ? /* @__PURE__ */ new Set([program.id]) : null });
19466
19472
  const people = byId(data.people);
19473
+ const alone = data.people.filter((p) => !isAgent(p)).length <= 1;
19467
19474
  return {
19468
19475
  ...program ? { program: program.name } : {},
19476
+ ...alone && lines.length > 0 ? { hint: LIFEBOATS_ALONE } : {},
19469
19477
  count: lines.length,
19470
19478
  lines: lines.slice(input.offset, input.offset + input.limit).map((l) => lifeboatOut(data, l, people)),
19471
19479
  away: awayNote(data, lines, todayISO(ctx.now)),
@@ -28376,7 +28384,7 @@ async function pickHarbor(deps) {
28376
28384
  //#endregion
28377
28385
  //#region src/tui/fog-sweep.ts
28378
28386
  /**
28379
- * `harbor fog` in a terminal: the tasks nobody has touched for three weeks
28387
+ * `harbor fog --sweep` in a terminal: the tasks nobody has touched for three weeks
28380
28388
  * (lib/fog.ts), one at a time, on the store's own actions. k keeps it
28381
28389
  * (clears the fog), d asks for a new date, x drops it, s skips, q stops.
28382
28390
  * The CLI saves through the sync loop afterwards.
@@ -28463,6 +28471,192 @@ function fogSummary(t) {
28463
28471
  return parts.length ? `${parts.join(", ")}.` : "Nothing changed.";
28464
28472
  }
28465
28473
 
28474
+ //#endregion
28475
+ //#region src/tui/watch.ts
28476
+ init_indexes();
28477
+ init_swatches();
28478
+ /** Most lines one change prints; a whole reload says how many more there were. */
28479
+ const WATCH_MAX = 12;
28480
+ const QUOTE_MAX = 90;
28481
+ function firstWords(body) {
28482
+ const one = body.replace(/\s+/g, " ").trim();
28483
+ return one.length > QUOTE_MAX ? `${one.slice(0, 89)}…` : one;
28484
+ }
28485
+ function firstName(p) {
28486
+ return p?.name.split(" ")[0] || "Someone";
28487
+ }
28488
+ function watchLines(prev, next, meId) {
28489
+ const out = [];
28490
+ const people = byId(next.people);
28491
+ const before = byId(prev.tasks);
28492
+ const label = (t) => `${keyOf$1(next, t)} ${t.title}`;
28493
+ const seen = new Set(prev.activity.map((a) => a.id));
28494
+ const fresh = next.activity.filter((a) => !seen.has(a.id));
28495
+ const who = (t) => {
28496
+ const hit = fresh.find((a) => a.text.includes(`“${t.title}”`));
28497
+ if (!hit) return null;
28498
+ return hit.personId === meId ? "You" : firstName(people.get(hit.personId));
28499
+ };
28500
+ const said = (t, phrase) => {
28501
+ const name = who(t);
28502
+ return name ? `${name} ${phrase}` : phrase.charAt(0).toUpperCase() + phrase.slice(1);
28503
+ };
28504
+ for (const t of next.tasks) {
28505
+ const was = before.get(t.id);
28506
+ const mine = t.assigneeId === meId;
28507
+ if (!was) {
28508
+ const name = who(t);
28509
+ out.push({
28510
+ text: mine && name !== "You" ? `${name ?? "Someone"} added ${label(t)} for you` : `${name ?? "Someone"} added ${label(t)}`,
28511
+ mine,
28512
+ tone: "plain"
28513
+ });
28514
+ continue;
28515
+ }
28516
+ if (t.assigneeId !== was.assigneeId && mine) {
28517
+ const from = t.assignedBy && t.assignedBy !== meId ? firstName(people.get(t.assignedBy)) : null;
28518
+ out.push({
28519
+ text: `You were handed ${label(t)}${from ? ` by ${from}` : ""}`,
28520
+ mine: true,
28521
+ tone: "good"
28522
+ });
28523
+ } else if (t.assigneeId !== was.assigneeId && was.assigneeId === meId) {
28524
+ const to = t.assigneeId ? firstName(people.get(t.assigneeId)) : "nobody";
28525
+ out.push({
28526
+ text: `Your task ${label(t)} went to ${to}`,
28527
+ mine: true,
28528
+ tone: "warn"
28529
+ });
28530
+ }
28531
+ if (t.status !== was.status) {
28532
+ const done = t.status === "done";
28533
+ out.push({
28534
+ text: done ? said(t, `finished ${label(t)}`) : said(t, `moved ${label(t)} to ${TASK_STATUS_LABEL[t.status]}`),
28535
+ mine,
28536
+ tone: done ? "good" : "plain"
28537
+ });
28538
+ } else if (t.dueDate !== was.dueDate && mine) out.push({
28539
+ text: t.dueDate ? `${label(t)} is due ${t.dueDate} now` : `${label(t)} has no due date now`,
28540
+ mine: true,
28541
+ tone: "plain"
28542
+ });
28543
+ }
28544
+ for (const t of prev.tasks) {
28545
+ if (next.tasks.some((x) => x.id === t.id)) continue;
28546
+ out.push({
28547
+ text: `${label(t)} was removed`,
28548
+ mine: t.assigneeId === meId,
28549
+ tone: "warn"
28550
+ });
28551
+ }
28552
+ const oldComments = new Set(prev.comments.map((c) => c.id));
28553
+ const tasks = byId(next.tasks);
28554
+ for (const c of next.comments) {
28555
+ if (oldComments.has(c.id)) continue;
28556
+ const t = tasks.get(c.taskId);
28557
+ if (!t) continue;
28558
+ const yours = t.assigneeId === meId;
28559
+ const mentioned = c.mentionedIds.includes(meId);
28560
+ const name = c.authorId === meId ? "You" : firstName(people.get(c.authorId));
28561
+ out.push({
28562
+ text: mentioned ? `${name} mentioned you on ${label(t)}` : `${name} commented on ${yours && name !== "You" ? "your task " : ""}${label(t)}`,
28563
+ quote: firstWords(c.body),
28564
+ mine: (yours || mentioned) && c.authorId !== meId,
28565
+ tone: mentioned ? "warn" : "plain"
28566
+ });
28567
+ }
28568
+ const health = (kind) => {
28569
+ const was = byId(prev[kind]);
28570
+ for (const item of next[kind]) {
28571
+ const old = was.get(item.id);
28572
+ if (!old || old.health === item.health) continue;
28573
+ out.push({
28574
+ text: `${item.name}: ${HEALTH_LABEL$1[old.health]} → ${HEALTH_LABEL$1[item.health]}`,
28575
+ mine: item.ownerId === meId,
28576
+ tone: item.health === "off-track" ? "bad" : item.health === "at-risk" ? "warn" : item.health === "on-track" || item.health === "done" ? "good" : "plain"
28577
+ });
28578
+ }
28579
+ };
28580
+ health("programs");
28581
+ health("projects");
28582
+ if (out.length > 12) {
28583
+ const more = out.length - 11;
28584
+ return [...out.slice(0, 11), {
28585
+ text: `and ${more} more changes`,
28586
+ mine: false,
28587
+ tone: "plain"
28588
+ }];
28589
+ }
28590
+ return out;
28591
+ }
28592
+
28593
+ //#endregion
28594
+ //#region src/tui/watch-run.ts
28595
+ init_clock();
28596
+ init_plain();
28597
+ init_repo_sync();
28598
+ init_store();
28599
+ init_sync();
28600
+ const ANSI = {
28601
+ dim: "\x1B[2m",
28602
+ bold: "\x1B[1m",
28603
+ reset: "\x1B[0m",
28604
+ good: "\x1B[36m",
28605
+ warn: "\x1B[33m",
28606
+ bad: "\x1B[31m"
28607
+ };
28608
+ /** One ticker line for the terminal: the time dim, the rest bright when it's yours. */
28609
+ function watchText(line, at, opts) {
28610
+ const time = wallClock(at).slice(11, 16);
28611
+ const room = (opts.width ?? 0) - 6;
28612
+ const fit = (s, n) => opts.width && s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
28613
+ const text = fit(line.text, room);
28614
+ const quote = line.quote ? fit(`“${line.quote}”`, room - 2) : null;
28615
+ if (!opts.colour) return [`${time} ${text}`, ...quote ? [` ${quote}`] : []].join("\n");
28616
+ const tone = line.tone === "plain" ? "" : ANSI[line.tone];
28617
+ const body = line.mine ? `${ANSI.bold}${tone}${text}${ANSI.reset}` : `${tone || ANSI.dim}${text}${ANSI.reset}`;
28618
+ return [`${ANSI.dim}${time}${ANSI.reset} ${body}`, ...quote ? [`${ANSI.dim} ${quote}${ANSI.reset}`] : []].join("\n");
28619
+ }
28620
+ async function watchOn(opts) {
28621
+ const out = (text) => opts.write(`${text}\n`);
28622
+ const store = createAppStore();
28623
+ const sync = createSync({
28624
+ store,
28625
+ transport: opts.transport,
28626
+ account: {
28627
+ id: opts.account.id,
28628
+ name: opts.account.name || opts.account.email
28629
+ },
28630
+ onNotice: (n) => out(plain(n))
28631
+ });
28632
+ await sync.open(opts.harbor.id);
28633
+ try {
28634
+ const project = opts.project ? projectNamed(store.getState().projects, opts.project) : null;
28635
+ const dim = (s) => opts.colour ? `${ANSI.dim}${s}${ANSI.reset}` : s;
28636
+ out(dim(`⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""} · on watch${opts.bell ? " · bell on" : ""} · q to stop`));
28637
+ const narrow = (d) => project ? {
28638
+ ...d,
28639
+ tasks: d.tasks.filter((t) => t.projectId === project.id),
28640
+ projects: d.projects.filter((p) => p.id === project.id),
28641
+ programs: []
28642
+ } : d;
28643
+ const unsubscribe = store.subscribe((s, prev) => {
28644
+ if (s.tasks === prev.tasks && s.comments === prev.comments && s.projects === prev.projects && s.programs === prev.programs) return;
28645
+ const lines = watchLines(narrow(prev), narrow(s), s.currentUserId);
28646
+ const at = opts.now?.() ?? /* @__PURE__ */ new Date();
28647
+ for (const line of lines) out(watchText(line, at, {
28648
+ colour: opts.colour,
28649
+ width: opts.width?.()
28650
+ }));
28651
+ if (opts.bell && lines.some((l) => l.mine)) opts.write("\x07");
28652
+ });
28653
+ await opts.stopped;
28654
+ unsubscribe();
28655
+ } finally {
28656
+ sync.close();
28657
+ }
28658
+ }
28659
+
28466
28660
  //#endregion
28467
28661
  //#region src/repo/folder.ts
28468
28662
  /**
@@ -28851,7 +29045,7 @@ function harborFor(harbors, named, saved) {
28851
29045
  if (harbors.length === 1) return harbors[0];
28852
29046
  throw new HarborError(`You're in ${harbors.length} harbors (${harbors.map((h) => h.name).join(", ")}). Add --harbor NAME, or run harbor use NAME first.`);
28853
29047
  }
28854
- /** `harbor fog` in a terminal: the sweep, one task at a time, then one save. */
29048
+ /** `harbor fog --sweep`: the sweep, one task at a time, then one save. */
28855
29049
  async function fogHere(opts) {
28856
29050
  const transport = plainTransport(httpTransport(opts.source, opts.fetcher));
28857
29051
  const answer = await transport.harbors();
@@ -28887,7 +29081,45 @@ async function fogHere(opts) {
28887
29081
  await sync.flush();
28888
29082
  sync.close();
28889
29083
  }
28890
- if (sync.state !== "saved") throw new HarborError("Harbor didn't take those answers. Run harbor fog again.");
29084
+ if (sync.state !== "saved") throw new HarborError("Harbor didn't take those answers. Run harbor fog --sweep again.");
29085
+ }
29086
+ /**
29087
+ * `harbor watch`: the harbor's changes as they land, a line each, until q
29088
+ * or Ctrl+C. Nothing is changed from here; it only listens.
29089
+ */
29090
+ async function watchHere(opts) {
29091
+ const transport = plainTransport(httpTransport(opts.source, opts.fetcher));
29092
+ const answer = await transport.harbors();
29093
+ const harbor = harborFor(answer.harbors, opts.harbor, opts.saved);
29094
+ const stopped = new Promise((resolve) => {
29095
+ const input = process.stdin;
29096
+ const stop = () => {
29097
+ input.off("data", onKey);
29098
+ process.off("SIGINT", stop);
29099
+ if (input.isTTY) input.setRawMode(false);
29100
+ input.pause();
29101
+ resolve();
29102
+ };
29103
+ const onKey = (chunk) => {
29104
+ const key = chunk.toString();
29105
+ if (key === "q" || key === "Q" || key === "") stop();
29106
+ };
29107
+ if (input.isTTY) input.setRawMode(true);
29108
+ input.on("data", onKey);
29109
+ input.resume();
29110
+ process.on("SIGINT", stop);
29111
+ });
29112
+ await watchOn({
29113
+ transport,
29114
+ account: answer.account,
29115
+ harbor,
29116
+ project: opts.project,
29117
+ bell: opts.bell,
29118
+ colour: opts.colour,
29119
+ width: () => process.stdout.columns,
29120
+ write: (text) => process.stdout.write(text),
29121
+ stopped
29122
+ });
28891
29123
  }
28892
29124
  /** Questions asked on the plain terminal, before the app takes it over. Ctrl+C stops. */
28893
29125
  function terminalAsk(stoppedText = "Stopped. Nothing was made.") {
@@ -28930,4 +29162,4 @@ function planTransport(source, fetcher) {
28930
29162
  }
28931
29163
 
28932
29164
  //#endregion
28933
- export { SPLASH_MS, TransportError, firstHarbor, fogHere, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere };
29165
+ export { SPLASH_MS, TransportError, firstHarbor, fogHere, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere, watchHere };
package/lib/cli.mjs CHANGED
@@ -223,10 +223,13 @@ export const HELP = `harbor: Harbor from the terminal
223
223
  [--keep ID,ID] [--confirm]
224
224
  hand someone's work to the crew
225
225
  harbor handback PERSON give it back after time away
226
- harbor fog [--project P] [--list] tasks nobody has touched for three
227
- weeks, one at a time: still
228
- needed, a new date, or drop it
226
+ harbor fog [--project P] tasks nobody has touched for three
227
+ weeks, longest first
228
+ harbor fog --sweep [--project P] one at a time: still needed, a
229
+ new date, or drop it
229
230
  harbor fog keep TASK it's still needed: out of the fog
231
+ harbor watch [--project P] [--bell] a live ticker of the harbor's
232
+ changes; yours bright, q stops
230
233
  harbor lifeboats [--program P] work only one person knows, each
231
234
  with a second pair of hands
232
235
  harbor lifeboats N [--person NAME] adds that second pair of hands
@@ -244,7 +247,7 @@ export const HELP = `harbor: Harbor from the terminal
244
247
  DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
245
248
  Changes show in Harbor's History as yours, "via Harbor CLI". These commands
246
249
  never delete; the full-screen app can, as the website can, and asks first,
247
- and so can harbor fog's x (drop it), one task at a time.
250
+ and so can harbor fog --sweep's x (drop it), one task at a time.
248
251
  `;
249
252
 
250
253
  export const VERSION = JSON.parse(
@@ -770,18 +773,29 @@ const DETAIL = {
770
773
  ' PERSON is a name, an id, or "me".',
771
774
  ],
772
775
  fog: [
773
- " harbor fog [--project P] in a terminal: the tasks in fog, one",
776
+ " harbor fog [--project P] reads them: open tasks nobody has",
777
+ " opened, moved, edited or commented on",
778
+ " for three weeks, longest first",
779
+ " harbor fog --sweep [--project P] in a terminal: the tasks in fog, one",
774
780
  " at a time. k still needed (clears the",
775
781
  " fog), d a new date, x drop it, s skip,",
776
782
  " q stop; then it saves what you chose",
777
- " harbor fog --list reads them: open tasks nobody has",
778
- " opened, moved, edited or commented on",
779
- " for three weeks, longest first",
780
783
  " harbor fog keep TASK writes: it's still needed, so it",
781
784
  " leaves the fog",
782
785
  "",
783
- " Piped, or with --json, it lists. The one-at-a-time sweep needs full access",
784
- " (harbor login without --read-only), since dropping a task deletes it.",
786
+ " The sweep needs a terminal and full access (harbor login without",
787
+ " --read-only), since dropping a task deletes it.",
788
+ ],
789
+ watch: [
790
+ " harbor watch reads, live: each change to the harbor",
791
+ " as it lands, one line with the time:",
792
+ " cards moving, comments, health changes",
793
+ " --project P one project's changes only",
794
+ " --bell ring the terminal's bell when something of yours changes",
795
+ "",
796
+ " Lines about your own work (your tasks, mentions of you, what you lead) are",
797
+ " bright; the rest are dim. q or Ctrl+C stops. Made for a narrow split or a",
798
+ " tmux pane; it needs full access (harbor login without --read-only).",
785
799
  ],
786
800
  lifeboats: [
787
801
  " harbor lifeboats [--program P] reads: work only one person has",
@@ -863,7 +877,7 @@ const DETAIL = {
863
877
  ' harbor raid "TITLE" --update "TEXT" writes: adds a line to its updates',
864
878
  ' harbor raid "TITLE" --still-stands "WHY"',
865
879
  " writes: a decision looked at again",
866
- " still stands (\"\" for no reason);",
880
+ ' still stands ("" for no reason);',
867
881
  " clears its look again",
868
882
  "",
869
883
  " Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
@@ -2549,8 +2563,27 @@ export async function run(argv, io = {}) {
2549
2563
  }
2550
2564
  return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
2551
2565
  }
2566
+ case "watch": {
2567
+ const { positionals, values, harbor } = parse(spec(["bell"], ["project"]));
2568
+ none(positionals);
2569
+ if (!hasFullAccess(creds))
2570
+ throw new HarborError(
2571
+ "Watching needs full access, as the full-screen app does. Run harbor login (without --read-only), then try again.",
2572
+ );
2573
+ const { watchHere } = await loadApp();
2574
+ await watchHere({
2575
+ source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
2576
+ fetcher,
2577
+ harbor,
2578
+ saved: creds.harbor ?? null,
2579
+ project: values.project,
2580
+ bell: Boolean(values.bell),
2581
+ colour: colourDepth(io.stdout ?? process.stdout, env) !== "none",
2582
+ });
2583
+ return 0;
2584
+ }
2552
2585
  case "fog": {
2553
- const { positionals, values, harbor } = parse(spec(["list"], ["project"]));
2586
+ const { positionals, values, harbor } = parse(spec(["list", "sweep"], ["project"]));
2554
2587
  if (positionals[0] === "keep") {
2555
2588
  const task = one(positionals.slice(1), "task");
2556
2589
  return call(
@@ -2560,8 +2593,17 @@ export async function run(argv, io = {}) {
2560
2593
  );
2561
2594
  }
2562
2595
  none(positionals);
2563
- const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
2564
- if (terminal && !values.list && !asJson && hasFullAccess(creds)) {
2596
+ // Listing is the default; the sweep, where x deletes, only when asked for.
2597
+ if (values.sweep) {
2598
+ const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
2599
+ if (!terminal || asJson) {
2600
+ throw new HarborError("harbor fog --sweep asks about each task, so it needs a terminal.");
2601
+ }
2602
+ if (!hasFullAccess(creds)) {
2603
+ throw new HarborError(
2604
+ "harbor fog --sweep can drop tasks, so it needs full access: harbor login (without --read-only).",
2605
+ );
2606
+ }
2565
2607
  const { fogHere } = await loadApp();
2566
2608
  await fogHere({
2567
2609
  source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
package/lib/format.mjs CHANGED
@@ -539,7 +539,10 @@ export function voyageText(r) {
539
539
 
540
540
  /** What's holding something up, as long_pole answers it: the headline, then the line of waits, first blocker at the top. */
541
541
  export function whyText(r) {
542
- const what = r.of.kind === "task" ? r.of.name : `${r.of.name}${r.of.kind === "milestone" ? " (milestone)" : ""}`;
542
+ const what =
543
+ r.of.kind === "task"
544
+ ? r.of.name
545
+ : `${r.of.name}${r.of.kind === "milestone" ? " (milestone)" : ""}`;
543
546
  if (r.none) return `What's holding up ${what}: ${r.none.replace(/^\w/, (c) => c.toLowerCase())}`;
544
547
  const head = [`What's holding up ${what}`];
545
548
  if (r.headline) head.push(r.headline);
@@ -552,14 +555,19 @@ export function whyText(r) {
552
555
  ` ${(l.key ?? "").padEnd(width)}${width ? " " : ""}${l.title}`,
553
556
  ` ${" ".repeat(width)}${width ? " " : ""}${l.assignee ?? "nobody yet"} · ${due}${finish} · ${l.slackText}`,
554
557
  ];
555
- return i < chain.length - 1 ? [...row, ` ${" ".repeat(width ? Math.floor(width / 2) : 0)}│`] : row;
558
+ return i < chain.length - 1
559
+ ? [...row, ` ${" ".repeat(width ? Math.floor(width / 2) : 0)}│`]
560
+ : row;
556
561
  });
557
562
  const via = r.via ? [`It waits longest on ${r.via.project}; inside it:`] : [];
558
563
  const others = (r.others ?? []).length
559
564
  ? [
560
565
  "",
561
566
  `Also waits on ${r.others
562
- .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`})`)
567
+ .map(
568
+ (o) =>
569
+ `${o.project} (finishes ${o.finish}, ${o.slack >= 0 ? `${o.slack} day${o.slack === 1 ? "" : "s"} spare` : `${-o.slack} day${o.slack === -1 ? "" : "s"} over`})`,
570
+ )
563
571
  .join(", ")}.`,
564
572
  ]
565
573
  : [];
@@ -961,7 +969,7 @@ export function fogText(r, { sweepHint = true } = {}) {
961
969
  if (sweepHint)
962
970
  lines.push(
963
971
  "",
964
- "In a terminal, harbor fog walks through them one at a time: k still needed, d new date, x drop it. harbor fog keep TASK keeps one.",
972
+ "harbor fog --sweep walks through them one at a time: k still needed, d new date, x drop it. harbor fog keep TASK keeps one.",
965
973
  );
966
974
  return lines.join("\n");
967
975
  }
@@ -983,6 +991,7 @@ export function lifeboatsText(r) {
983
991
  });
984
992
  if (r.more) lines.push("", `And ${r.count - r.lines.length} more.`);
985
993
  for (const a of r.away ?? []) lines.push("", a);
994
+ if (r.hint) return [...lines, "", r.hint].join("\n");
986
995
  lines.push(
987
996
  "",
988
997
  "harbor lifeboats N adds the suggested second pair of hands (--person NAME for someone else).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.57.0",
3
+ "version": "0.58.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",