@harborppm/cli 0.56.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
@@ -2274,9 +2279,158 @@ var init_premortem = __esmMin((() => {
2274
2279
  }));
2275
2280
 
2276
2281
  //#endregion
2277
- //#region src/lib/voyage-log.ts
2282
+ //#region src/lib/charter-lines.ts
2283
+ /** Nothing written on any of the five lines. */
2284
+ function charterEmpty(charter) {
2285
+ return !charter || CHARTER_FIELDS.every((f) => !clean(charter[f]));
2286
+ }
2287
+ /**
2288
+ * The charter after a change to some of its lines: trimmed, and null once
2289
+ * every line is empty so a cleared charter reads as never written.
2290
+ */
2291
+ function withCharter(current, patch, by, at) {
2292
+ const next = {
2293
+ ...current ?? {},
2294
+ updatedAt: at,
2295
+ updatedById: by
2296
+ };
2297
+ for (const field of CHARTER_FIELDS) {
2298
+ if (!(field in patch)) continue;
2299
+ const text = (patch[field] ?? "").trim();
2300
+ if (text) next[field] = text;
2301
+ else delete next[field];
2302
+ }
2303
+ return charterEmpty(next) ? null : next;
2304
+ }
2305
+ var CHARTER_FIELDS, clean;
2306
+ var init_charter_lines = __esmMin((() => {
2307
+ CHARTER_FIELDS = [
2308
+ "why",
2309
+ "inScope",
2310
+ "outScope",
2311
+ "doneWhen",
2312
+ "callsBy"
2313
+ ];
2314
+ clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
2315
+ }));
2316
+
2317
+ //#endregion
2318
+ //#region src/lib/charter.ts
2278
2319
  init_indexes();
2279
2320
  init_date_fns();
2321
+ init_date_text();
2322
+ init_premortem();
2323
+ init_charter_lines();
2324
+ const CHARTER_LABEL = {
2325
+ why: "Why",
2326
+ inScope: "In",
2327
+ outScope: "Out",
2328
+ doneWhen: "Done when",
2329
+ callsBy: "Calls made by"
2330
+ };
2331
+ /** Each line's name for agents and the CLI's --json: plain words, no "Scope". */
2332
+ const CHARTER_KEY = {
2333
+ why: "why",
2334
+ inScope: "in",
2335
+ outScope: "out",
2336
+ doneWhen: "doneWhen",
2337
+ callsBy: "callsBy"
2338
+ };
2339
+ /** The lines written so far, in order. */
2340
+ function charterLines(charter) {
2341
+ if (!charter) return [];
2342
+ return CHARTER_FIELDS.map((field) => ({
2343
+ field,
2344
+ label: CHARTER_LABEL[field],
2345
+ text: clean(charter[field])
2346
+ })).filter((l) => l.text);
2347
+ }
2348
+ function charterFilled(charter) {
2349
+ return charterLines(charter).length;
2350
+ }
2351
+ /** A week after the start, an empty charter is worth a nudge. */
2352
+ const CHARTER_GRACE_DAYS = 7;
2353
+ /**
2354
+ * A project whose charter is still blank a week after it started, while
2355
+ * it's still running. Done and paused ones are left alone.
2356
+ */
2357
+ function charterOverdue(project, today) {
2358
+ if (project.health === "done" || project.health === "paused") return null;
2359
+ if (!charterEmpty(project.charter)) return null;
2360
+ const days = differenceInCalendarDays(parseIso(today), parseIso(project.startDate.slice(0, 10)));
2361
+ return days >= 7 ? days : null;
2362
+ }
2363
+ /** The items of an In or Out line: split on commas, semicolons, "and" lists and new lines. */
2364
+ function charterItems(text) {
2365
+ return (text ?? "").split(/[\n,;•]+/).map((s) => s.replace(/^\s*(?:and|or|-|\*)\s+/i, "").trim()).filter((s) => contentWords(s).size > 0);
2366
+ }
2367
+ /** True when a task's title names an item: every meaningful word of a short item, or two of a longer one. */
2368
+ function names(item, title) {
2369
+ let shared = 0;
2370
+ for (const w of item) if (title.has(w)) shared += 1;
2371
+ return item.size <= 2 ? shared === item.size : shared >= 2;
2372
+ }
2373
+ /**
2374
+ * Tasks whose titles name something the charter put out of scope, newest
2375
+ * first: the "I thought that was in" list. Tasks made before the charter
2376
+ * was last written are left out when `since` is given, so writing an Out
2377
+ * line about work already on the board doesn't flag it.
2378
+ */
2379
+ function outOfScope(charter, tasks, opts = {}) {
2380
+ const items = charterItems(charter?.outScope).map((item) => ({
2381
+ item,
2382
+ words: contentWords(item)
2383
+ }));
2384
+ if (items.length === 0) return [];
2385
+ const out = [];
2386
+ for (const task of tasks) {
2387
+ if (opts.since && task.createdAt < opts.since) continue;
2388
+ const title = contentWords(task.title);
2389
+ const hit = items.find((i) => names(i.words, title));
2390
+ if (hit) out.push({
2391
+ task,
2392
+ item: hit.item
2393
+ });
2394
+ }
2395
+ return out.sort((a, b) => b.task.createdAt.localeCompare(a.task.createdAt));
2396
+ }
2397
+ /** The voyage log's look at the charter: did the In list get done, did the Out list stay out. */
2398
+ function charterCheck(charter, tasks) {
2399
+ if (charterEmpty(charter)) return null;
2400
+ const finished = tasks.filter((t) => t.status === "done").map((t) => contentWords(t.title));
2401
+ const delivered = charterItems(charter.inScope).map((item) => {
2402
+ const words = contentWords(item);
2403
+ return {
2404
+ item,
2405
+ done: finished.filter((title) => names(words, title)).length
2406
+ };
2407
+ });
2408
+ return {
2409
+ doneWhen: clean(charter.doneWhen) || null,
2410
+ delivered,
2411
+ crept: outOfScope(charter, tasks),
2412
+ outItems: charterItems(charter.outScope).length
2413
+ };
2414
+ }
2415
+ /** One sentence for the voyage log's story; null when there's no charter. */
2416
+ function charterSentence(check) {
2417
+ if (!check) return null;
2418
+ const bits = [];
2419
+ if (check.delivered.length) {
2420
+ const missing = check.delivered.filter((d) => d.done === 0).map((d) => d.item);
2421
+ bits.push(missing.length === 0 ? "every item the charter put in has finished work against it" : `${check.delivered.length - missing.length} of ${check.delivered.length} items the charter put in have finished work against them (not: ${missing.join(", ")})`);
2422
+ }
2423
+ if (check.crept.length) {
2424
+ const items = [...new Set(check.crept.map((c) => c.item))];
2425
+ bits.push(`${check.crept.length} task${check.crept.length === 1 ? "" : "s"} touched what it put out (${items.join(", ")})`);
2426
+ } else if (check.outItems > 0) bits.push("nothing it put out crept in");
2427
+ if (!bits.length) return null;
2428
+ return `Against the charter: ${bits.join("; ")}.`;
2429
+ }
2430
+
2431
+ //#endregion
2432
+ //#region src/lib/voyage-log.ts
2433
+ init_date_fns();
2280
2434
  init_blockers();
2281
2435
  init_date_text();
2282
2436
  init_premortem();
@@ -2342,6 +2496,7 @@ function voyageLog(data, projectId) {
2342
2496
  slips,
2343
2497
  risks,
2344
2498
  issues: issues.length,
2499
+ charter: charterCheck(project.charter, tasks),
2345
2500
  decisions,
2346
2501
  carriers,
2347
2502
  budget: project.budget,
@@ -2390,6 +2545,8 @@ function voyageStory(log) {
2390
2545
  out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md$2(s.waitedOn.completedAt)}.` : `${late}.`);
2391
2546
  }
2392
2547
  for (const r of log.risks.filter((x) => x.happened)) out.push(`The risk “${r.risk.title}” came true${r.guessed ? " (an issue names it)" : ""}.`);
2548
+ const against = charterSentence(log.charter);
2549
+ if (against) out.push(against);
2393
2550
  if (log.decisions.length) {
2394
2551
  const changed = log.decisions.filter((d) => d.status === "superseded").length;
2395
2552
  out.push(`${plural$4(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
@@ -5026,6 +5183,29 @@ function premortemNote(data, project, now, today) {
5026
5183
  const pending = answersOf(data.reflections, project.id).filter(isPending).length;
5027
5184
  return pending ? `pre-mortem: ${pending} to sort · P` : "pre-mortem · P";
5028
5185
  }
5186
+ /**
5187
+ * The charter in a few words for the project's header: how many of its five
5188
+ * lines are written and any open task that names what it leaves out, or a
5189
+ * nudge (warned once it's a week overdue). Null on a finished project with none.
5190
+ */
5191
+ function charterNote(project, tasks, today) {
5192
+ const filled = charterFilled(project.charter);
5193
+ if (filled === 0) {
5194
+ if (project.health === "done") return null;
5195
+ return {
5196
+ text: "no charter yet · e writes it",
5197
+ warn: charterOverdue(project, today) !== null
5198
+ };
5199
+ }
5200
+ const creep = outOfScope(project.charter, tasksOf(tasks, project.id).filter((t) => t.status !== "done")).length;
5201
+ return creep ? {
5202
+ text: `charter: ${creep} task${creep === 1 ? "" : "s"} look out of scope · e`,
5203
+ warn: true
5204
+ } : {
5205
+ text: `charter ${filled} of 5 · e`,
5206
+ warn: false
5207
+ };
5208
+ }
5029
5209
  const ROLE_LABEL = {
5030
5210
  owner: "Owner",
5031
5211
  member: "Can edit",
@@ -6311,7 +6491,7 @@ function composeBriefBody(opts) {
6311
6491
  }
6312
6492
  return lines.join("\n").trim();
6313
6493
  }
6314
- function firstName$1(person) {
6494
+ function firstName$2(person) {
6315
6495
  return person?.name.split(/\s+/)[0] ?? "Someone";
6316
6496
  }
6317
6497
  function similar(a, b) {
@@ -6349,7 +6529,7 @@ function headlineOf(drums, flow) {
6349
6529
  }
6350
6530
  if (idle.length === 1) {
6351
6531
  const d = idle[0];
6352
- 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.`;
6353
6533
  }
6354
6534
  if (idle.length > 1) return `${idle.length} constraints are idle. The work is not at the constraint.`;
6355
6535
  if (system && flow.impliedDays != null && flow.impliedDays > 21) return `At this load, ${formatWait(flow.impliedDays)}. ${system.constraint.title}.`;
@@ -6413,7 +6593,7 @@ function briefOf(data, opts = {}) {
6413
6593
  for (const d of drums.filter((x) => x.idle)) push({
6414
6594
  id: `idle-${d.constraint.id}`,
6415
6595
  title: `${d.constraint.title} is idle`,
6416
- 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.`,
6417
6597
  href: `/programs/${d.program.id}`
6418
6598
  });
6419
6599
  for (const d of drums.filter((x) => x.constraint.kind === "collision" && !x.idle)) push({
@@ -7416,6 +7596,24 @@ function reviewPrograms(data, opts = {}) {
7416
7596
  const { ownerId, portfolioId } = opts;
7417
7597
  return data.programs.filter((p) => (!portfolioId || p.portfolioId === portfolioId) && (!ownerId || programTouches(p, data, ownerId))).sort((a, b) => rankHealth(a.health) - rankHealth(b.health) || a.endDate.localeCompare(b.endDate));
7418
7598
  }
7599
+ /**
7600
+ * The projects in these programs whose charter is still blank a week after
7601
+ * they started (src/lib/charter.ts), longest-running first: the review's
7602
+ * nudge to write the five lines.
7603
+ */
7604
+ function blankCharters(data, programs, today) {
7605
+ const ids = new Set(programs.filter((p) => p.health !== "done").map((p) => p.id));
7606
+ const out = [];
7607
+ for (const project of data.projects) {
7608
+ if (!ids.has(project.programId)) continue;
7609
+ const days = charterOverdue(project, today);
7610
+ if (days !== null) out.push({
7611
+ project,
7612
+ days
7613
+ });
7614
+ }
7615
+ return out.sort((a, b) => b.days - a.days || a.project.name.localeCompare(b.project.name));
7616
+ }
7419
7617
 
7420
7618
  //#endregion
7421
7619
  //#region src/tui/moves.ts
@@ -8386,7 +8584,7 @@ const PING_KIND_LABEL = {
8386
8584
  idle: "Quiet blockers",
8387
8585
  mention: "Mentions"
8388
8586
  };
8389
- function firstName(name) {
8587
+ function firstName$1(name) {
8390
8588
  return name.split(/\s+/)[0] ?? name;
8391
8589
  }
8392
8590
  /** FNV-1a over the text, as eight hex digits: a short, stable name for a set of ids. */
@@ -8425,7 +8623,7 @@ function inboxOf(data, personId, now = clockNow()) {
8425
8623
  pings.push({
8426
8624
  id: `assigned:${task.id}:${task.assignedAt}`,
8427
8625
  kind: "assigned",
8428
- 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",
8429
8627
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""}`,
8430
8628
  at: task.assignedAt,
8431
8629
  taskId: task.id,
@@ -8439,7 +8637,7 @@ function inboxOf(data, personId, now = clockNow()) {
8439
8637
  const project = projectOf(data, blocker);
8440
8638
  const names = [...new Set(waiting.map((t) => {
8441
8639
  const who = data.people.find((p) => p.id === t.assigneeId);
8442
- return who ? firstName(who.name) : "Someone";
8640
+ return who ? firstName$1(who.name) : "Someone";
8443
8641
  }))];
8444
8642
  const blocked = waiting.length === 1 ? `“${waiting[0].title}”` : `${waiting.length} tasks`;
8445
8643
  const upstream = openBlockerNames(blocker, data.tasks);
@@ -8474,7 +8672,7 @@ function inboxOf(data, personId, now = clockNow()) {
8474
8672
  pings.push({
8475
8673
  id: `changes:${m.id}:${shortHash(note)}`,
8476
8674
  kind: "approval",
8477
- 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}`,
8478
8676
  detail: note ? `${where}${excerpt(note)}` : program?.name ?? "",
8479
8677
  at: m.dueDate,
8480
8678
  programId: m.programId,
@@ -8520,7 +8718,7 @@ function inboxOf(data, personId, now = clockNow()) {
8520
8718
  pings.push({
8521
8719
  id: `sounding:${task.id}:${s.askedAt}`,
8522
8720
  kind: "sounding",
8523
- 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`,
8524
8722
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""} · answer alone; the guesses show together`,
8525
8723
  at: s.askedAt,
8526
8724
  taskId: task.id,
@@ -8537,7 +8735,7 @@ function inboxOf(data, personId, now = clockNow()) {
8537
8735
  pings.push({
8538
8736
  id: `mention:${comment.id}`,
8539
8737
  kind: "mention",
8540
- title: author ? `${firstName(author.name)} mentioned you` : "You were mentioned",
8738
+ title: author ? `${firstName$1(author.name)} mentioned you` : "You were mentioned",
8541
8739
  detail: snippet ? `on “${task.title}”${project ? ` · ${project.name}` : ""} — ${snippet}` : `on “${task.title}”${project ? ` · ${project.name}` : ""}`,
8542
8740
  at: comment.createdAt,
8543
8741
  taskId: task.id,
@@ -10070,7 +10268,17 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
10070
10268
  initial: project.description,
10071
10269
  onSave: (v) => up({ description: v })
10072
10270
  }
10073
- }
10271
+ },
10272
+ ...CHARTER_FIELDS.map((f) => ({
10273
+ label: `Charter: ${CHARTER_LABEL[f].toLowerCase()}`,
10274
+ value: project.charter?.[f]?.replace(/\s+/g, " ") || "Not written",
10275
+ color: project.charter?.[f] ? void 0 : theme.muted,
10276
+ edit: {
10277
+ kind: "text",
10278
+ initial: project.charter?.[f] ?? "",
10279
+ onSave: (v) => store.getState().setCharter(id, { [f]: v })
10280
+ }
10281
+ }))
10074
10282
  ];
10075
10283
  return /* @__PURE__ */ jsx(DetailPanel, {
10076
10284
  title: /* @__PURE__ */ jsx(Text, {
@@ -10708,6 +10916,8 @@ function ProjectsView({ active, width, height, go }) {
10708
10916
  }
10709
10917
  });
10710
10918
  }
10919
+ /** Projects a week in without a charter, in a few words; null for none. */
10920
+ const charterGap = (n) => n ? `${n} blank charter${n === 1 ? "" : "s"}` : null;
10711
10921
  /**
10712
10922
  * The review, as on the website: programs worst first, then by end date,
10713
10923
  * each with its lead, how much is late, where it sits on the priority matrix
@@ -10773,7 +10983,8 @@ function ProgramsView({ active, width, height, go }) {
10773
10983
  late ? `${late} late` : null,
10774
10984
  slip >= 100 ? `slipping costs ${dollars$1(slip)}` : null,
10775
10985
  isRated(program) ? quadrantOf(program, maxBudget, rules) : null,
10776
- program.nextMove ? `next: ${program.nextMove}` : "no next move"
10986
+ program.nextMove ? `next: ${program.nextMove}` : "no next move",
10987
+ charterGap(blankCharters({ projects }, [program], day).length)
10777
10988
  ].filter(Boolean).join(" · ")
10778
10989
  };
10779
10990
  });
@@ -18100,6 +18311,7 @@ function me(ctx) {
18100
18311
  return ctx.data.people.find((p) => p.accountId === ctx.accountId) ?? null;
18101
18312
  }
18102
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.";
18103
18315
  /** One line of "only one person knows these" (src/lib/lifeboats.ts), for an agent. */
18104
18316
  function lifeboatOut(data, line, people) {
18105
18317
  return {
@@ -18297,6 +18509,24 @@ function decisionOut(data, r, people) {
18297
18509
  };
18298
18510
  }
18299
18511
  const tool = (t) => t;
18512
+ /** A project's charter, as get_project gives it: the lines written, and open tasks that name what it leaves out. */
18513
+ function charterOf(p, tasks) {
18514
+ const lines = charterLines(p.charter);
18515
+ if (lines.length === 0) return { charter: null };
18516
+ const creep = outOfScope(p.charter, tasks.filter((t) => t.status !== "done")).slice(0, 10);
18517
+ return {
18518
+ charter: {
18519
+ ...Object.fromEntries(lines.map((l) => [CHARTER_KEY[l.field], l.text])),
18520
+ ...p.charter?.updatedAt ? { updated: dayOf(p.charter.updatedAt) } : {}
18521
+ },
18522
+ ...creep.length ? { outOfScope: creep.map((c) => ({
18523
+ task: taskKey(c.task, p) ?? c.task.id,
18524
+ title: c.task.title,
18525
+ names: c.item,
18526
+ added: dayOf(c.task.createdAt)
18527
+ })) } : {}
18528
+ };
18529
+ }
18300
18530
  /** A project's notes for later, as get_project gives them: sealed ones without their text unless they're the caller's. */
18301
18531
  function notesOf(ctx, p) {
18302
18532
  const notes = bottlesOf(ctx.data.reflections, p.id);
@@ -18466,7 +18696,7 @@ const HARBOR_TOOLS = [
18466
18696
  tool({
18467
18697
  name: "get_project",
18468
18698
  title: "Project details",
18469
- description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), and its url on the website." + DATA_NOTE,
18699
+ description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), and its url on the website." + DATA_NOTE,
18470
18700
  input: object({
18471
18701
  harbor: harborArg,
18472
18702
  project: string().min(1).max(200).describe("Project id or name.")
@@ -18526,6 +18756,7 @@ const HARBOR_TOOLS = [
18526
18756
  milestones,
18527
18757
  openRaid: raid,
18528
18758
  finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data.reflections),
18759
+ ...charterOf(p, tasks),
18529
18760
  ...notesOf(ctx, p),
18530
18761
  ...hillOf(p, today),
18531
18762
  ...webLink(ctx, { project: p.id })
@@ -18844,7 +19075,7 @@ const HARBOR_TOOLS = [
18844
19075
  tool({
18845
19076
  name: "program_review",
18846
19077
  title: "The review",
18847
- description: "The website's Review: programs worst health first, then soonest end, each with its lead, end date, health (and the note when set by hand), late tasks, where it sits on the priority matrix and its next move. Done programs only with includeDone." + DATA_NOTE,
19078
+ description: "The website's Review: programs worst health first, then soonest end, each with its lead, end date, health (and the note when set by hand), late tasks, where it sits on the priority matrix and its next move. Done programs only with includeDone. blankCharters lists running projects in them whose charter is still empty a week after they started (set_charter writes one)." + DATA_NOTE,
18848
19079
  input: object({
18849
19080
  harbor: harborArg,
18850
19081
  owner: string().max(200).optional().describe("\"me\", or a person's id or name: programs they lead, lead a project in or hold a task in."),
@@ -18881,6 +19112,11 @@ const HARBOR_TOOLS = [
18881
19112
  nextMove: p.nextMove?.trim() || null
18882
19113
  })),
18883
19114
  done: all.length - listed.length,
19115
+ blankCharters: blankCharters(data, listed, todayISO(ctx.now)).map((b) => ({
19116
+ project: b.project.name,
19117
+ program: data.programs.find((x) => x.id === b.project.programId)?.name ?? null,
19118
+ startedDaysAgo: b.days
19119
+ })),
18884
19120
  onlyOneKnows: lifeboats(data, ctx.now, { programIds: new Set(listed.map((p) => p.id)) }).slice(0, 25).map((l) => lifeboatOut(data, l, people))
18885
19121
  };
18886
19122
  }
@@ -18964,6 +19200,17 @@ const HARBOR_TOOLS = [
18964
19200
  cameTrue: r.happened,
18965
19201
  ...r.guessed ? { guessed: true } : {}
18966
19202
  })),
19203
+ ...log.charter ? { charter: {
19204
+ doneWhen: log.charter.doneWhen,
19205
+ inItems: log.charter.delivered.map((d) => ({
19206
+ item: d.item,
19207
+ finishedTasks: d.done
19208
+ })),
19209
+ outOfScopeTasks: log.charter.crept.map((c) => ({
19210
+ title: c.task.title,
19211
+ names: c.item
19212
+ }))
19213
+ } } : {},
18967
19214
  decisions: log.decisions.map((d) => ({
18968
19215
  title: d.title,
18969
19216
  on: dayOf(d.decidedAt),
@@ -19223,8 +19470,10 @@ const HARBOR_TOOLS = [
19223
19470
  const program = input.program ? findOne(data.programs, input.program, "program") : null;
19224
19471
  const lines = lifeboats(data, ctx.now, { programIds: program ? /* @__PURE__ */ new Set([program.id]) : null });
19225
19472
  const people = byId(data.people);
19473
+ const alone = data.people.filter((p) => !isAgent(p)).length <= 1;
19226
19474
  return {
19227
19475
  ...program ? { program: program.name } : {},
19476
+ ...alone && lines.length > 0 ? { hint: LIFEBOATS_ALONE } : {},
19228
19477
  count: lines.length,
19229
19478
  lines: lines.slice(input.offset, input.offset + input.limit).map((l) => lifeboatOut(data, l, people)),
19230
19479
  away: awayNote(data, lines, todayISO(ctx.now)),
@@ -20226,6 +20475,13 @@ function hierarchySlice({ get, write }) {
20226
20475
  hillMovedAt: stamp()
20227
20476
  } : p) });
20228
20477
  },
20478
+ setCharter: (projectId, patch) => {
20479
+ const by = get().currentUserId;
20480
+ write({ projects: get().projects.map((p) => p.id === projectId ? {
20481
+ ...p,
20482
+ charter: withCharter(p.charter, patch, by, stamp())
20483
+ } : p) });
20484
+ },
20229
20485
  addCustomField: (projectId, def) => {
20230
20486
  const id = uid("cf");
20231
20487
  write({ projects: get().projects.map((p) => p.id === projectId ? {
@@ -20502,6 +20758,7 @@ var init_hierarchy = __esmMin((() => {
20502
20758
  init_derived_health();
20503
20759
  init_shared();
20504
20760
  init_hill();
20761
+ init_charter_lines();
20505
20762
  init_sample_project();
20506
20763
  init_format();
20507
20764
  init_empty();
@@ -25611,6 +25868,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
25611
25868
  reflections,
25612
25869
  raidItems
25613
25870
  }, project, clockNow(), day);
25871
+ const charter = charterNote(project, tasks, day);
25614
25872
  const opened = freshlyOpened({
25615
25873
  reflections,
25616
25874
  milestones
@@ -25699,6 +25957,10 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
25699
25957
  pmNote ? /* @__PURE__ */ jsxs(Text, {
25700
25958
  color: theme.muted,
25701
25959
  children: [" · ", pmNote]
25960
+ }) : null,
25961
+ charter ? /* @__PURE__ */ jsxs(Text, {
25962
+ color: charter.warn ? theme.warn : theme.muted,
25963
+ children: [" · ", charter.text]
25702
25964
  }) : null
25703
25965
  ]
25704
25966
  }),
@@ -28122,7 +28384,7 @@ async function pickHarbor(deps) {
28122
28384
  //#endregion
28123
28385
  //#region src/tui/fog-sweep.ts
28124
28386
  /**
28125
- * `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
28126
28388
  * (lib/fog.ts), one at a time, on the store's own actions. k keeps it
28127
28389
  * (clears the fog), d asks for a new date, x drops it, s skips, q stops.
28128
28390
  * The CLI saves through the sync loop afterwards.
@@ -28209,6 +28471,192 @@ function fogSummary(t) {
28209
28471
  return parts.length ? `${parts.join(", ")}.` : "Nothing changed.";
28210
28472
  }
28211
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
+
28212
28660
  //#endregion
28213
28661
  //#region src/repo/folder.ts
28214
28662
  /**
@@ -28597,7 +29045,7 @@ function harborFor(harbors, named, saved) {
28597
29045
  if (harbors.length === 1) return harbors[0];
28598
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.`);
28599
29047
  }
28600
- /** `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. */
28601
29049
  async function fogHere(opts) {
28602
29050
  const transport = plainTransport(httpTransport(opts.source, opts.fetcher));
28603
29051
  const answer = await transport.harbors();
@@ -28633,7 +29081,45 @@ async function fogHere(opts) {
28633
29081
  await sync.flush();
28634
29082
  sync.close();
28635
29083
  }
28636
- 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
+ });
28637
29123
  }
28638
29124
  /** Questions asked on the plain terminal, before the app takes it over. Ctrl+C stops. */
28639
29125
  function terminalAsk(stoppedText = "Stopped. Nothing was made.") {
@@ -28676,4 +29162,4 @@ function planTransport(source, fetcher) {
28676
29162
  }
28677
29163
 
28678
29164
  //#endregion
28679
- 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
@@ -33,6 +33,7 @@ import {
33
33
  myWork,
34
34
  premortemText,
35
35
  notesText,
36
+ charterText,
36
37
  daysText,
37
38
  soundingText,
38
39
  lifeboatsText,
@@ -205,6 +206,10 @@ export const HELP = `harbor: Harbor from the terminal
205
206
  answer or read it
206
207
  harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
207
208
  your line in it
209
+ harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
210
+ [--calls T]
211
+ a project's five-line charter:
212
+ read it, or write some lines
208
213
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
209
214
  a note for later: sealed until
210
215
  its day, milestone, or Off track
@@ -218,10 +223,13 @@ export const HELP = `harbor: Harbor from the terminal
218
223
  [--keep ID,ID] [--confirm]
219
224
  hand someone's work to the crew
220
225
  harbor handback PERSON give it back after time away
221
- harbor fog [--project P] [--list] tasks nobody has touched for three
222
- weeks, one at a time: still
223
- 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
224
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
225
233
  harbor lifeboats [--program P] work only one person knows, each
226
234
  with a second pair of hands
227
235
  harbor lifeboats N [--person NAME] adds that second pair of hands
@@ -239,7 +247,7 @@ export const HELP = `harbor: Harbor from the terminal
239
247
  DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
240
248
  Changes show in Harbor's History as yours, "via Harbor CLI". These commands
241
249
  never delete; the full-screen app can, as the website can, and asks first,
242
- 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.
243
251
  `;
244
252
 
245
253
  export const VERSION = JSON.parse(
@@ -423,6 +431,10 @@ const FLAG = {
423
431
  text: "the next move",
424
432
  waitsOn: "--waits-on",
425
433
  repeat: "--repeat",
434
+ in: "--in",
435
+ out: "--out",
436
+ doneWhen: "--done-when",
437
+ callsBy: "--calls",
426
438
  description: "--notes",
427
439
  body: "the comment",
428
440
  column: "the column",
@@ -701,6 +713,18 @@ const DETAIL = {
701
713
  " --no-people. A task already in its project is left alone: by its key",
702
714
  " (a Key column, as harbor export writes) or by title.",
703
715
  ],
716
+ charter: [
717
+ " harbor charter PROJECT reads its charter, and any open task",
718
+ " that names something it leaves out",
719
+ ' harbor charter PROJECT --why "TEXT" writes: that line; give any of',
720
+ " --why, --in, --out, --done-when and",
721
+ " --calls at once",
722
+ "",
723
+ " none clears a line: --out none. Separate items in --in and --out",
724
+ " with commas, so Harbor can spot a task that names one. The review lists",
725
+ " running projects still without a charter a week after they started.",
726
+ " PROJECT is a name, a key like RR, or an id.",
727
+ ],
704
728
  sound: [
705
729
  " harbor sound TASK reads its sounding: who hasn't answered,",
706
730
  " and the spread once everyone has",
@@ -749,18 +773,29 @@ const DETAIL = {
749
773
  ' PERSON is a name, an id, or "me".',
750
774
  ],
751
775
  fog: [
752
- " 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",
753
780
  " at a time. k still needed (clears the",
754
781
  " fog), d a new date, x drop it, s skip,",
755
782
  " q stop; then it saves what you chose",
756
- " harbor fog --list reads them: open tasks nobody has",
757
- " opened, moved, edited or commented on",
758
- " for three weeks, longest first",
759
783
  " harbor fog keep TASK writes: it's still needed, so it",
760
784
  " leaves the fog",
761
785
  "",
762
- " Piped, or with --json, it lists. The one-at-a-time sweep needs full access",
763
- " (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).",
764
799
  ],
765
800
  lifeboats: [
766
801
  " harbor lifeboats [--program P] reads: work only one person has",
@@ -842,7 +877,7 @@ const DETAIL = {
842
877
  ' harbor raid "TITLE" --update "TEXT" writes: adds a line to its updates',
843
878
  ' harbor raid "TITLE" --still-stands "WHY"',
844
879
  " writes: a decision looked at again",
845
- " still stands (\"\" for no reason);",
880
+ ' still stands ("" for no reason);',
846
881
  " clears its look again",
847
882
  "",
848
883
  " Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
@@ -2083,7 +2118,13 @@ export async function run(argv, io = {}) {
2083
2118
  words && r.programs.length === 0
2084
2119
  ? `No program in the review matches "${words}".`
2085
2120
  : reviewList(r),
2086
- (r) => (words ? { ...r, programs: r.programs.filter((p) => nameHas(p.name, words)) } : r),
2121
+ (r) => {
2122
+ if (!words) return r;
2123
+ const programs = r.programs.filter((p) => nameHas(p.name, words));
2124
+ const names = new Set(programs.map((p) => p.name));
2125
+ const blankCharters = (r.blankCharters ?? []).filter((b) => names.has(b.program));
2126
+ return { ...r, programs, blankCharters };
2127
+ },
2087
2128
  );
2088
2129
  }
2089
2130
  const status = { start: "doing", submit: "review", done: "done" }[command];
@@ -2405,6 +2446,44 @@ export async function run(argv, io = {}) {
2405
2446
  (r) => `Sealed your note on ${r.sealed.project}. It ${r.sealed.opens}.`,
2406
2447
  );
2407
2448
  }
2449
+ case "charter": {
2450
+ const { positionals, values, harbor } = parse(
2451
+ spec([], ["why", "in", "out", "done-when", "calls"]),
2452
+ );
2453
+ const project = one(positionals, 'project: harbor charter "NAME"');
2454
+ // none clears a line, as --due none does.
2455
+ const line = (v) => (v.trim().toLowerCase() === "none" ? "" : v);
2456
+ const lines = Object.fromEntries(
2457
+ [
2458
+ ["why", values.why],
2459
+ ["in", values.in],
2460
+ ["out", values.out],
2461
+ ["doneWhen", values["done-when"]],
2462
+ ["callsBy", values.calls],
2463
+ ]
2464
+ .filter(([, v]) => v !== undefined)
2465
+ .map(([k, v]) => [k, line(v)]),
2466
+ );
2467
+ if (Object.keys(lines).length === 0) {
2468
+ // --json: the project's charter (null until written) and any out-of-scope tasks.
2469
+ return call(
2470
+ "get_project",
2471
+ { harbor, project },
2472
+ (r) => charterText(r),
2473
+ (r) => ({
2474
+ id: r.id,
2475
+ project: r.name,
2476
+ charter: r.charter ?? null,
2477
+ ...(r.outOfScope ? { outOfScope: r.outOfScope } : {}),
2478
+ }),
2479
+ );
2480
+ }
2481
+ return call("set_charter", { harbor, project, ...lines }, (r) =>
2482
+ r.charter
2483
+ ? `Saved ${r.project}'s charter.\n\n${charterText({ project: r.project, charter: r.charter })}`
2484
+ : `Cleared ${r.project}'s charter.`,
2485
+ );
2486
+ }
2408
2487
  case "sound": {
2409
2488
  const { positionals, values, harbor } = parse(spec([], ["why", "ask", "settle"]));
2410
2489
  const [task, days, ...extra] = positionals;
@@ -2484,8 +2563,27 @@ export async function run(argv, io = {}) {
2484
2563
  }
2485
2564
  return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
2486
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
+ }
2487
2585
  case "fog": {
2488
- const { positionals, values, harbor } = parse(spec(["list"], ["project"]));
2586
+ const { positionals, values, harbor } = parse(spec(["list", "sweep"], ["project"]));
2489
2587
  if (positionals[0] === "keep") {
2490
2588
  const task = one(positionals.slice(1), "task");
2491
2589
  return call(
@@ -2495,8 +2593,17 @@ export async function run(argv, io = {}) {
2495
2593
  );
2496
2594
  }
2497
2595
  none(positionals);
2498
- const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
2499
- 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
+ }
2500
2607
  const { fogHere } = await loadApp();
2501
2608
  await fogHere({
2502
2609
  source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
package/lib/format.mjs CHANGED
@@ -344,6 +344,14 @@ export function reviewList(r) {
344
344
  out.push(`${p.name} (${bits.join(" · ")})`);
345
345
  out.push(` ${p.nextMove ? `next: ${p.nextMove}` : "no next move yet"}`);
346
346
  }
347
+ const blank = r.blankCharters ?? [];
348
+ if (blank.length) {
349
+ out.push("", "Charters still blank a week in:");
350
+ for (const b of blank.slice(0, 8))
351
+ out.push(` ${b.project} (started ${b.startedDaysAgo} days ago)`);
352
+ if (blank.length > 8) out.push(` and ${blank.length - 8} more`);
353
+ out.push(' harbor charter "PROJECT" --why "…" writes one.');
354
+ }
347
355
  return out.join("\n") + more;
348
356
  }
349
357
 
@@ -531,7 +539,10 @@ export function voyageText(r) {
531
539
 
532
540
  /** What's holding something up, as long_pole answers it: the headline, then the line of waits, first blocker at the top. */
533
541
  export function whyText(r) {
534
- 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)" : ""}`;
535
546
  if (r.none) return `What's holding up ${what}: ${r.none.replace(/^\w/, (c) => c.toLowerCase())}`;
536
547
  const head = [`What's holding up ${what}`];
537
548
  if (r.headline) head.push(r.headline);
@@ -544,14 +555,19 @@ export function whyText(r) {
544
555
  ` ${(l.key ?? "").padEnd(width)}${width ? " " : ""}${l.title}`,
545
556
  ` ${" ".repeat(width)}${width ? " " : ""}${l.assignee ?? "nobody yet"} · ${due}${finish} · ${l.slackText}`,
546
557
  ];
547
- 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;
548
561
  });
549
562
  const via = r.via ? [`It waits longest on ${r.via.project}; inside it:`] : [];
550
563
  const others = (r.others ?? []).length
551
564
  ? [
552
565
  "",
553
566
  `Also waits on ${r.others
554
- .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
+ )
555
571
  .join(", ")}.`,
556
572
  ]
557
573
  : [];
@@ -650,6 +666,36 @@ export function importText(r) {
650
666
  }
651
667
 
652
668
  /** A project's notes for later, as get_project lists them. */
669
+ const CHARTER_LINES = [
670
+ ["why", "Why"],
671
+ ["in", "In"],
672
+ ["out", "Out"],
673
+ ["doneWhen", "Done when"],
674
+ ["callsBy", "Calls made by"],
675
+ ];
676
+
677
+ /** A project's charter as get_project or set_charter gives it, with open tasks that name what it leaves out. */
678
+ export function charterText(r) {
679
+ const project = r.name ?? r.project;
680
+ if (!r.charter) {
681
+ return `${project} has no charter yet. Write one, a line at a time:\n harbor charter "${project}" --why "…" --in "…, …" --out "…, …" --done-when "…" --calls "…"`;
682
+ }
683
+ const lines = [`Charter · ${project}`];
684
+ const width = Math.max(...CHARTER_LINES.map(([, label]) => label.length));
685
+ for (const [key, label] of CHARTER_LINES) {
686
+ if (r.charter[key]) lines.push(` ${label.padEnd(width)} ${r.charter[key]}`);
687
+ }
688
+ if (r.outOfScope?.length) {
689
+ lines.push("", "Out of scope?");
690
+ for (const t of r.outOfScope) {
691
+ lines.push(
692
+ ` ${t.task} ${t.title} (added ${t.added}; the charter leaves out "${t.names}")`,
693
+ );
694
+ }
695
+ }
696
+ return lines.join("\n");
697
+ }
698
+
653
699
  export function notesText(project, notes) {
654
700
  if (!notes?.length)
655
701
  return `No notes for later on ${project}. Leave one: harbor note "${project}" "NOTE" --opens +30`;
@@ -923,7 +969,7 @@ export function fogText(r, { sweepHint = true } = {}) {
923
969
  if (sweepHint)
924
970
  lines.push(
925
971
  "",
926
- "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.",
927
973
  );
928
974
  return lines.join("\n");
929
975
  }
@@ -945,6 +991,7 @@ export function lifeboatsText(r) {
945
991
  });
946
992
  if (r.more) lines.push("", `And ${r.count - r.lines.length} more.`);
947
993
  for (const a of r.away ?? []) lines.push("", a);
994
+ if (r.hint) return [...lines, "", r.hint].join("\n");
948
995
  lines.push(
949
996
  "",
950
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.56.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",