@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 +7 -2
- package/dist/app.mjs +246 -14
- package/lib/cli.mjs +56 -14
- package/lib/format.mjs +13 -4
- package/package.json +1 -1
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]
|
|
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`
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
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]
|
|
227
|
-
weeks,
|
|
228
|
-
|
|
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]
|
|
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
|
-
"
|
|
784
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
2564
|
-
if (
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
"
|
|
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).",
|