@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 +7 -2
- package/dist/app.mjs +505 -19
- package/lib/cli.mjs +122 -15
- package/lib/format.mjs +51 -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
|
|
@@ -2274,9 +2279,158 @@ var init_premortem = __esmMin((() => {
|
|
|
2274
2279
|
}));
|
|
2275
2280
|
|
|
2276
2281
|
//#endregion
|
|
2277
|
-
//#region src/lib/
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
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]
|
|
222
|
-
weeks,
|
|
223
|
-
|
|
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]
|
|
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
|
-
"
|
|
763
|
-
"
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
2499
|
-
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
|
+
}
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
"
|
|
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).",
|