@harborppm/cli 0.60.0 → 0.61.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 +6 -1
- package/dist/app.mjs +489 -200
- package/lib/cli.mjs +109 -12
- package/lib/format.mjs +53 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -177,9 +177,13 @@ harbor raid "TITLE" [--status S] [--owner me|none|NAME] [--impact 1-5] [--probab
|
|
|
177
177
|
harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
|
|
178
178
|
the review: programs worst first, with next moves
|
|
179
179
|
(reads only; harbor submit moves a task to In review)
|
|
180
|
+
harbor thanks [TASK "LINE"] [--to NAME] a one-line thanks for a task (--milestone, --decision);
|
|
181
|
+
on its own, the thanks you were given this month
|
|
180
182
|
harbor bridge run the review as a meeting: one project a screen,
|
|
181
183
|
what changed since the last one, log decisions,
|
|
182
184
|
actions and risks live, post the summary to chat
|
|
185
|
+
harbor bridge --list [--program P] what changed since each program's last review, every
|
|
186
|
+
item, as text (--json for agents); doesn't hold the meeting
|
|
183
187
|
harbor next-move PROGRAM "TEXT" write a program's next move
|
|
184
188
|
harbor health --program P|--project P|--portfolio P STATE [--note WHY]
|
|
185
189
|
set its health by hand (auto hands it back)
|
|
@@ -207,6 +211,7 @@ harbor fog [--project P] tasks nobody has touched for three w
|
|
|
207
211
|
harbor fog --sweep [--project P] the same, one at a time: still needed, a new date, or drop it
|
|
208
212
|
harbor fog keep TASK it's still needed: out of the fog
|
|
209
213
|
harbor watch [--project P] [--bell] a live ticker of the harbor's changes
|
|
214
|
+
harbor watch --once [--since TIME] [--json] the latest changes (or all since TIME), once, for scripts
|
|
210
215
|
harbor lifeboats [--program P] work only one person knows, each with a second pair of hands
|
|
211
216
|
harbor lifeboats N [--person NAME] adds that second pair of hands
|
|
212
217
|
harbor follow TASK|"RISK" --person NAME someone follows it too
|
|
@@ -218,7 +223,7 @@ harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
|
218
223
|
|
|
219
224
|
`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.
|
|
220
225
|
|
|
221
|
-
`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.
|
|
226
|
+
`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. It starts with the latest few changes and the time it began listening; `--since 14:30` (or a date) starts from then instead. `q` stops; piped or with `--json` (one JSON object a line) it runs until it's killed, and `--once` prints what's there and returns, for scripts.
|
|
222
227
|
|
|
223
228
|
`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.
|
|
224
229
|
|
package/dist/app.mjs
CHANGED
|
@@ -3315,6 +3315,252 @@ var init_barnacles = __esmMin((() => {
|
|
|
3315
3315
|
plural$5 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
3316
3316
|
}));
|
|
3317
3317
|
|
|
3318
|
+
//#endregion
|
|
3319
|
+
//#region src/lib/task-keys.ts
|
|
3320
|
+
/** "Ridge Runner" → "RR", "Website" → "WEB", "Q4 launch plan" → "QLP". */
|
|
3321
|
+
function baseKey(name) {
|
|
3322
|
+
const words = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9 ]+/g, " ").split(/\s+/).filter((w) => /[A-Za-z]/.test(w));
|
|
3323
|
+
if (words.length === 0) return "P";
|
|
3324
|
+
return (words.length === 1 ? words[0].replace(/[^A-Za-z]/g, "").slice(0, 3) : words.map((w) => w.replace(/[^A-Za-z]/g, "")[0] ?? "").join("").slice(0, MAX_KEY)).toUpperCase() || "P";
|
|
3325
|
+
}
|
|
3326
|
+
/** A key for `name` that none of `taken` has: "RR", then "RR2", "RR3"… */
|
|
3327
|
+
function projectKeyFor(name, taken) {
|
|
3328
|
+
const base = baseKey(name);
|
|
3329
|
+
if (!taken.has(base)) return base;
|
|
3330
|
+
for (let n = 2;; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`;
|
|
3331
|
+
}
|
|
3332
|
+
/** Whether `a` has the better claim to the number both hold. */
|
|
3333
|
+
function first$2(a, b, stored) {
|
|
3334
|
+
const ka = kept(a, stored);
|
|
3335
|
+
if (ka !== kept(b, stored)) return ka;
|
|
3336
|
+
return made(a, b) <= 0;
|
|
3337
|
+
}
|
|
3338
|
+
/**
|
|
3339
|
+
* Every project keyed, every task numbered, no number given twice in a
|
|
3340
|
+
* project. When two tasks hold one number, a task already stored with it
|
|
3341
|
+
* (`stored`: the document before this write) keeps it, since people may have
|
|
3342
|
+
* used its key already; otherwise the task made first does. Returns the same
|
|
3343
|
+
* object when nothing needed doing.
|
|
3344
|
+
*/
|
|
3345
|
+
function settleKeys(data, stored) {
|
|
3346
|
+
let projects = data.projects;
|
|
3347
|
+
const taken = /* @__PURE__ */ new Set();
|
|
3348
|
+
const keeps = projects.map((p) => {
|
|
3349
|
+
const ok = !!p.key && KEY.test(p.key) && !taken.has(p.key);
|
|
3350
|
+
if (ok) taken.add(p.key);
|
|
3351
|
+
return ok;
|
|
3352
|
+
});
|
|
3353
|
+
if (keeps.includes(false)) projects = projects.map((p, i) => {
|
|
3354
|
+
if (keeps[i]) return p;
|
|
3355
|
+
const key = projectKeyFor(p.name, taken);
|
|
3356
|
+
taken.add(key);
|
|
3357
|
+
return {
|
|
3358
|
+
...p,
|
|
3359
|
+
key
|
|
3360
|
+
};
|
|
3361
|
+
});
|
|
3362
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
3363
|
+
for (const t of data.tasks) {
|
|
3364
|
+
const list = byProject.get(t.projectId);
|
|
3365
|
+
if (list) list.push(t);
|
|
3366
|
+
else byProject.set(t.projectId, [t]);
|
|
3367
|
+
}
|
|
3368
|
+
const renumber = /* @__PURE__ */ new Map();
|
|
3369
|
+
for (const list of byProject.values()) {
|
|
3370
|
+
let max = 0;
|
|
3371
|
+
const holder = /* @__PURE__ */ new Map();
|
|
3372
|
+
const loose = [];
|
|
3373
|
+
for (const t of list) {
|
|
3374
|
+
const n = t.number;
|
|
3375
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
3376
|
+
loose.push(t);
|
|
3377
|
+
continue;
|
|
3378
|
+
}
|
|
3379
|
+
const other = holder.get(n);
|
|
3380
|
+
if (other) {
|
|
3381
|
+
const [keep, move] = first$2(other, t, stored) ? [other, t] : [t, other];
|
|
3382
|
+
holder.set(n, keep);
|
|
3383
|
+
loose.push(move);
|
|
3384
|
+
} else holder.set(n, t);
|
|
3385
|
+
if (n > max) max = n;
|
|
3386
|
+
}
|
|
3387
|
+
if (loose.length === 0) continue;
|
|
3388
|
+
loose.sort(made);
|
|
3389
|
+
for (const t of loose) renumber.set(t.id, ++max);
|
|
3390
|
+
}
|
|
3391
|
+
if (projects === data.projects && renumber.size === 0) return data;
|
|
3392
|
+
const tasks = renumber.size === 0 ? data.tasks : data.tasks.map((t) => renumber.has(t.id) ? {
|
|
3393
|
+
...t,
|
|
3394
|
+
number: renumber.get(t.id)
|
|
3395
|
+
} : t);
|
|
3396
|
+
return {
|
|
3397
|
+
...data,
|
|
3398
|
+
projects,
|
|
3399
|
+
tasks
|
|
3400
|
+
};
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* The browser's half: number the tasks this change adds, and the ones it
|
|
3404
|
+
* moves to another project, after the highest number in their project. Old
|
|
3405
|
+
* tasks without a number are left for the server. Same object when nothing
|
|
3406
|
+
* was added or moved.
|
|
3407
|
+
*/
|
|
3408
|
+
function numberNewTasks(before, after) {
|
|
3409
|
+
if (after.tasks === before.tasks && after.projects === before.projects) return after;
|
|
3410
|
+
let projects = after.projects;
|
|
3411
|
+
if (projects !== before.projects) {
|
|
3412
|
+
const known = new Set(before.projects.map((p) => p.id));
|
|
3413
|
+
if (projects.some((p) => !known.has(p.id) && !p.key)) {
|
|
3414
|
+
const taken = new Set(projects.flatMap((p) => p.key ? [p.key] : []));
|
|
3415
|
+
projects = projects.map((p) => {
|
|
3416
|
+
if (known.has(p.id) || p.key) return p;
|
|
3417
|
+
const key = projectKeyFor(p.name, taken);
|
|
3418
|
+
taken.add(key);
|
|
3419
|
+
return {
|
|
3420
|
+
...p,
|
|
3421
|
+
key
|
|
3422
|
+
};
|
|
3423
|
+
});
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
if (after.tasks === before.tasks) return projects === after.projects ? after : {
|
|
3427
|
+
...after,
|
|
3428
|
+
projects
|
|
3429
|
+
};
|
|
3430
|
+
const was = byId(before.tasks);
|
|
3431
|
+
const holders = /* @__PURE__ */ new Map();
|
|
3432
|
+
for (const t of after.tasks) {
|
|
3433
|
+
if (!Number.isInteger(t.number)) continue;
|
|
3434
|
+
const k = `${t.projectId}:${t.number}`;
|
|
3435
|
+
holders.set(k, (holders.get(k) ?? 0) + 1);
|
|
3436
|
+
}
|
|
3437
|
+
const keepsNumber = (t) => Number.isInteger(t.number) && holders.get(`${t.projectId}:${t.number}`) === 1;
|
|
3438
|
+
const fresh = (t) => {
|
|
3439
|
+
const old = was.get(t.id);
|
|
3440
|
+
if (!old) return !keepsNumber(t);
|
|
3441
|
+
return old.projectId !== t.projectId && old.number === t.number;
|
|
3442
|
+
};
|
|
3443
|
+
if (!after.tasks.some(fresh)) return projects === after.projects ? after : {
|
|
3444
|
+
...after,
|
|
3445
|
+
projects
|
|
3446
|
+
};
|
|
3447
|
+
const max = /* @__PURE__ */ new Map();
|
|
3448
|
+
for (const t of after.tasks) {
|
|
3449
|
+
if (fresh(t) || !Number.isInteger(t.number)) continue;
|
|
3450
|
+
if (t.number > (max.get(t.projectId) ?? 0)) max.set(t.projectId, t.number);
|
|
3451
|
+
}
|
|
3452
|
+
const tasks = after.tasks.map((t) => {
|
|
3453
|
+
if (!fresh(t)) return t;
|
|
3454
|
+
const n = (max.get(t.projectId) ?? 0) + 1;
|
|
3455
|
+
max.set(t.projectId, n);
|
|
3456
|
+
return {
|
|
3457
|
+
...t,
|
|
3458
|
+
number: n
|
|
3459
|
+
};
|
|
3460
|
+
});
|
|
3461
|
+
return {
|
|
3462
|
+
...after,
|
|
3463
|
+
projects,
|
|
3464
|
+
tasks
|
|
3465
|
+
};
|
|
3466
|
+
}
|
|
3467
|
+
/** "RR-42", or null for a task without a number yet. */
|
|
3468
|
+
function taskKey(task, project) {
|
|
3469
|
+
return project?.key && task.number ? `${project.key}-${task.number}` : null;
|
|
3470
|
+
}
|
|
3471
|
+
/** The task a key names ("rr-42" works too), or null. */
|
|
3472
|
+
function findByKey(data, text) {
|
|
3473
|
+
const m = KEY_TEXT.exec(text.trim());
|
|
3474
|
+
if (!m) return null;
|
|
3475
|
+
const project = data.projects.find((p) => p.key === m[1].toUpperCase());
|
|
3476
|
+
if (!project) return null;
|
|
3477
|
+
const n = Number(m[2]);
|
|
3478
|
+
return data.tasks.find((t) => t.projectId === project.id && t.number === n) ?? null;
|
|
3479
|
+
}
|
|
3480
|
+
var MAX_KEY, KEY, made, kept, KEY_TEXT;
|
|
3481
|
+
var init_task_keys = __esmMin((() => {
|
|
3482
|
+
init_indexes();
|
|
3483
|
+
MAX_KEY = 5;
|
|
3484
|
+
KEY = /^[A-Z][A-Z0-9]{0,9}$/;
|
|
3485
|
+
made = (a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
3486
|
+
kept = (t, stored) => {
|
|
3487
|
+
const was = stored?.get(t.id);
|
|
3488
|
+
return !!was && was.number === t.number && was.projectId === t.projectId;
|
|
3489
|
+
};
|
|
3490
|
+
KEY_TEXT = /^([A-Za-z][A-Za-z0-9]{0,9})-(\d{1,7})$/;
|
|
3491
|
+
}));
|
|
3492
|
+
|
|
3493
|
+
//#endregion
|
|
3494
|
+
//#region src/lib/thanks.ts
|
|
3495
|
+
/** What a thanks is for, read from the harbor; null once that's gone. */
|
|
3496
|
+
function thanksTarget(doc, on) {
|
|
3497
|
+
if (on.kind === "task") {
|
|
3498
|
+
const t = doc.tasks.find((x) => x.id === on.id);
|
|
3499
|
+
if (!t) return null;
|
|
3500
|
+
const project = byId(doc.projects).get(t.projectId);
|
|
3501
|
+
const key = taskKey(t, project);
|
|
3502
|
+
return {
|
|
3503
|
+
title: `${key ? `${key} ` : ""}${t.title}`,
|
|
3504
|
+
projectId: t.projectId,
|
|
3505
|
+
programId: project?.programId ?? null,
|
|
3506
|
+
suggested: t.assigneeId
|
|
3507
|
+
};
|
|
3508
|
+
}
|
|
3509
|
+
if (on.kind === "milestone") {
|
|
3510
|
+
const m = doc.milestones.find((x) => x.id === on.id);
|
|
3511
|
+
if (!m) return null;
|
|
3512
|
+
return {
|
|
3513
|
+
title: m.name,
|
|
3514
|
+
projectId: "",
|
|
3515
|
+
programId: m.programId,
|
|
3516
|
+
suggested: m.approverId ?? null
|
|
3517
|
+
};
|
|
3518
|
+
}
|
|
3519
|
+
const d = doc.raidItems.find((x) => x.id === on.id && x.kind === "decision");
|
|
3520
|
+
if (!d) return null;
|
|
3521
|
+
const project = d.projectId ? byId(doc.projects).get(d.projectId) : void 0;
|
|
3522
|
+
return {
|
|
3523
|
+
title: d.title,
|
|
3524
|
+
projectId: d.projectId ?? "",
|
|
3525
|
+
programId: project?.programId ?? d.programId ?? null,
|
|
3526
|
+
suggested: d.decidedById ?? d.ownerId ?? null
|
|
3527
|
+
};
|
|
3528
|
+
}
|
|
3529
|
+
/** Why a thanks can't be given, or null when it can. */
|
|
3530
|
+
function thanksProblem(body, toId, fromId) {
|
|
3531
|
+
if (!body.trim()) return "Say what you're thanking them for.";
|
|
3532
|
+
if (!toId) return "Say who it's for.";
|
|
3533
|
+
if (toId === fromId) return "Thanks go to someone else.";
|
|
3534
|
+
return null;
|
|
3535
|
+
}
|
|
3536
|
+
/** Thanks given since `since`, newest first, with names and what each was for; `keep` narrows them. */
|
|
3537
|
+
function thanksSince(doc, since, keep = () => true) {
|
|
3538
|
+
const out = [];
|
|
3539
|
+
for (const r of doc.reflections ?? []) {
|
|
3540
|
+
if (!isThanks(r) || r.createdAt < since) continue;
|
|
3541
|
+
const target = thanksTarget(doc, r.thanksFor);
|
|
3542
|
+
if (!target || !keep(target)) continue;
|
|
3543
|
+
out.push({
|
|
3544
|
+
thanks: r,
|
|
3545
|
+
from: first$1(doc, r.authorId),
|
|
3546
|
+
to: first$1(doc, r.toId),
|
|
3547
|
+
for: target.title
|
|
3548
|
+
});
|
|
3549
|
+
}
|
|
3550
|
+
return out.sort((a, b) => b.thanks.createdAt.localeCompare(a.thanks.createdAt));
|
|
3551
|
+
}
|
|
3552
|
+
/** "Aya to Sam for RR-4 Draft the copy: “Saved the launch.”" */
|
|
3553
|
+
function thanksText(line) {
|
|
3554
|
+
return `${line.from} to ${line.to} for ${line.for}: “${line.thanks.body.trim()}”`;
|
|
3555
|
+
}
|
|
3556
|
+
var isThanks, first$1;
|
|
3557
|
+
var init_thanks = __esmMin((() => {
|
|
3558
|
+
init_indexes();
|
|
3559
|
+
init_task_keys();
|
|
3560
|
+
isThanks = (r) => r.kind === "thanks" && !!r.thanksFor && !!r.toId;
|
|
3561
|
+
first$1 = (doc, id) => doc.people.find((p) => p.id === id)?.name.split(" ")[0] ?? "Someone";
|
|
3562
|
+
}));
|
|
3563
|
+
|
|
3318
3564
|
//#endregion
|
|
3319
3565
|
//#region src/lib/charter-lines.ts
|
|
3320
3566
|
/** Nothing written on any of the five lines. */
|
|
@@ -3353,8 +3599,8 @@ var init_charter_lines = __esmMin((() => {
|
|
|
3353
3599
|
|
|
3354
3600
|
//#endregion
|
|
3355
3601
|
//#region src/lib/charter.ts
|
|
3356
|
-
init_indexes();
|
|
3357
3602
|
init_barnacles();
|
|
3603
|
+
init_thanks();
|
|
3358
3604
|
init_date_fns();
|
|
3359
3605
|
init_date_text();
|
|
3360
3606
|
init_premortem();
|
|
@@ -3521,6 +3767,7 @@ function voyageLog(data, projectId) {
|
|
|
3521
3767
|
closed
|
|
3522
3768
|
})).filter((c) => c.person).sort((a, b) => b.closed - a.closed || a.person.name.localeCompare(b.person.name));
|
|
3523
3769
|
const lines = (data.reflections ?? []).filter((r) => r.projectId === projectId && r.kind === "voyage").sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
3770
|
+
const thanks = (data.reflections ?? []).filter((r) => r.projectId === projectId && isThanks(r)).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
3524
3771
|
return {
|
|
3525
3772
|
project,
|
|
3526
3773
|
done: project.health === "done",
|
|
@@ -3540,7 +3787,8 @@ function voyageLog(data, projectId) {
|
|
|
3540
3787
|
carriers,
|
|
3541
3788
|
budget: project.budget,
|
|
3542
3789
|
spent: project.spent,
|
|
3543
|
-
lines
|
|
3790
|
+
lines,
|
|
3791
|
+
thanks
|
|
3544
3792
|
};
|
|
3545
3793
|
}
|
|
3546
3794
|
const plural$4 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
@@ -3612,6 +3860,7 @@ function voyageStory(log) {
|
|
|
3612
3860
|
init_date_fns();
|
|
3613
3861
|
init_date_text();
|
|
3614
3862
|
init_format();
|
|
3863
|
+
init_indexes();
|
|
3615
3864
|
init_premortem();
|
|
3616
3865
|
const DAY = (iso) => parseIso(iso.slice(0, 10));
|
|
3617
3866
|
const span$1 = (a, b) => differenceInCalendarDays(DAY(b), DAY(a));
|
|
@@ -4582,6 +4831,7 @@ function cascadeDelete(data, kind, id) {
|
|
|
4582
4831
|
const projectIds = new Set(kind === "project" ? [id] : data.projects.filter((p) => programIds.has(p.programId)).map((p) => p.id));
|
|
4583
4832
|
const taskIds = new Set(data.tasks.filter((t) => projectIds.has(t.projectId)).map((t) => t.id));
|
|
4584
4833
|
const milestones = data.milestones.filter((m) => !programIds.has(m.programId)).map((m) => withoutBlockers(m, projectIds));
|
|
4834
|
+
const goneMilestones = new Set(data.milestones.filter((m) => programIds.has(m.programId)).map((m) => m.id));
|
|
4585
4835
|
const constraints = (data.constraints ?? []).filter((c) => !programIds.has(c.programId));
|
|
4586
4836
|
const raidItems = data.raidItems.filter((r) => !(r.portfolioId && portfolioIds.has(r.portfolioId)) && !(r.programId && programIds.has(r.programId)) && !(r.projectId && projectIds.has(r.projectId)));
|
|
4587
4837
|
const boardConfigs = Object.fromEntries(Object.entries(data.boardConfigs ?? {}).filter(([k]) => !projectIds.has(k)));
|
|
@@ -4602,7 +4852,7 @@ function cascadeDelete(data, kind, id) {
|
|
|
4602
4852
|
taskIds: r.taskIds.filter((t) => !taskIds.has(t))
|
|
4603
4853
|
})),
|
|
4604
4854
|
spendEntries: (data.spendEntries ?? []).filter((e) => !projectIds.has(e.projectId)),
|
|
4605
|
-
reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId))
|
|
4855
|
+
reflections: (data.reflections ?? []).filter((r) => !projectIds.has(r.projectId) && !(r.thanksFor?.kind === "milestone" && goneMilestones.has(r.thanksFor.id)))
|
|
4606
4856
|
},
|
|
4607
4857
|
counts: {
|
|
4608
4858
|
programs: programIds.size,
|
|
@@ -5085,181 +5335,6 @@ function roadmapRows(data, portfolioId, filter, collapsed, today) {
|
|
|
5085
5335
|
return out;
|
|
5086
5336
|
}
|
|
5087
5337
|
|
|
5088
|
-
//#endregion
|
|
5089
|
-
//#region src/lib/task-keys.ts
|
|
5090
|
-
/** "Ridge Runner" → "RR", "Website" → "WEB", "Q4 launch plan" → "QLP". */
|
|
5091
|
-
function baseKey(name) {
|
|
5092
|
-
const words = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9 ]+/g, " ").split(/\s+/).filter((w) => /[A-Za-z]/.test(w));
|
|
5093
|
-
if (words.length === 0) return "P";
|
|
5094
|
-
return (words.length === 1 ? words[0].replace(/[^A-Za-z]/g, "").slice(0, 3) : words.map((w) => w.replace(/[^A-Za-z]/g, "")[0] ?? "").join("").slice(0, MAX_KEY)).toUpperCase() || "P";
|
|
5095
|
-
}
|
|
5096
|
-
/** A key for `name` that none of `taken` has: "RR", then "RR2", "RR3"… */
|
|
5097
|
-
function projectKeyFor(name, taken) {
|
|
5098
|
-
const base = baseKey(name);
|
|
5099
|
-
if (!taken.has(base)) return base;
|
|
5100
|
-
for (let n = 2;; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`;
|
|
5101
|
-
}
|
|
5102
|
-
/** Whether `a` has the better claim to the number both hold. */
|
|
5103
|
-
function first$1(a, b, stored) {
|
|
5104
|
-
const ka = kept(a, stored);
|
|
5105
|
-
if (ka !== kept(b, stored)) return ka;
|
|
5106
|
-
return made(a, b) <= 0;
|
|
5107
|
-
}
|
|
5108
|
-
/**
|
|
5109
|
-
* Every project keyed, every task numbered, no number given twice in a
|
|
5110
|
-
* project. When two tasks hold one number, a task already stored with it
|
|
5111
|
-
* (`stored`: the document before this write) keeps it, since people may have
|
|
5112
|
-
* used its key already; otherwise the task made first does. Returns the same
|
|
5113
|
-
* object when nothing needed doing.
|
|
5114
|
-
*/
|
|
5115
|
-
function settleKeys(data, stored) {
|
|
5116
|
-
let projects = data.projects;
|
|
5117
|
-
const taken = /* @__PURE__ */ new Set();
|
|
5118
|
-
const keeps = projects.map((p) => {
|
|
5119
|
-
const ok = !!p.key && KEY.test(p.key) && !taken.has(p.key);
|
|
5120
|
-
if (ok) taken.add(p.key);
|
|
5121
|
-
return ok;
|
|
5122
|
-
});
|
|
5123
|
-
if (keeps.includes(false)) projects = projects.map((p, i) => {
|
|
5124
|
-
if (keeps[i]) return p;
|
|
5125
|
-
const key = projectKeyFor(p.name, taken);
|
|
5126
|
-
taken.add(key);
|
|
5127
|
-
return {
|
|
5128
|
-
...p,
|
|
5129
|
-
key
|
|
5130
|
-
};
|
|
5131
|
-
});
|
|
5132
|
-
const byProject = /* @__PURE__ */ new Map();
|
|
5133
|
-
for (const t of data.tasks) {
|
|
5134
|
-
const list = byProject.get(t.projectId);
|
|
5135
|
-
if (list) list.push(t);
|
|
5136
|
-
else byProject.set(t.projectId, [t]);
|
|
5137
|
-
}
|
|
5138
|
-
const renumber = /* @__PURE__ */ new Map();
|
|
5139
|
-
for (const list of byProject.values()) {
|
|
5140
|
-
let max = 0;
|
|
5141
|
-
const holder = /* @__PURE__ */ new Map();
|
|
5142
|
-
const loose = [];
|
|
5143
|
-
for (const t of list) {
|
|
5144
|
-
const n = t.number;
|
|
5145
|
-
if (!Number.isInteger(n) || n < 1) {
|
|
5146
|
-
loose.push(t);
|
|
5147
|
-
continue;
|
|
5148
|
-
}
|
|
5149
|
-
const other = holder.get(n);
|
|
5150
|
-
if (other) {
|
|
5151
|
-
const [keep, move] = first$1(other, t, stored) ? [other, t] : [t, other];
|
|
5152
|
-
holder.set(n, keep);
|
|
5153
|
-
loose.push(move);
|
|
5154
|
-
} else holder.set(n, t);
|
|
5155
|
-
if (n > max) max = n;
|
|
5156
|
-
}
|
|
5157
|
-
if (loose.length === 0) continue;
|
|
5158
|
-
loose.sort(made);
|
|
5159
|
-
for (const t of loose) renumber.set(t.id, ++max);
|
|
5160
|
-
}
|
|
5161
|
-
if (projects === data.projects && renumber.size === 0) return data;
|
|
5162
|
-
const tasks = renumber.size === 0 ? data.tasks : data.tasks.map((t) => renumber.has(t.id) ? {
|
|
5163
|
-
...t,
|
|
5164
|
-
number: renumber.get(t.id)
|
|
5165
|
-
} : t);
|
|
5166
|
-
return {
|
|
5167
|
-
...data,
|
|
5168
|
-
projects,
|
|
5169
|
-
tasks
|
|
5170
|
-
};
|
|
5171
|
-
}
|
|
5172
|
-
/**
|
|
5173
|
-
* The browser's half: number the tasks this change adds, and the ones it
|
|
5174
|
-
* moves to another project, after the highest number in their project. Old
|
|
5175
|
-
* tasks without a number are left for the server. Same object when nothing
|
|
5176
|
-
* was added or moved.
|
|
5177
|
-
*/
|
|
5178
|
-
function numberNewTasks(before, after) {
|
|
5179
|
-
if (after.tasks === before.tasks && after.projects === before.projects) return after;
|
|
5180
|
-
let projects = after.projects;
|
|
5181
|
-
if (projects !== before.projects) {
|
|
5182
|
-
const known = new Set(before.projects.map((p) => p.id));
|
|
5183
|
-
if (projects.some((p) => !known.has(p.id) && !p.key)) {
|
|
5184
|
-
const taken = new Set(projects.flatMap((p) => p.key ? [p.key] : []));
|
|
5185
|
-
projects = projects.map((p) => {
|
|
5186
|
-
if (known.has(p.id) || p.key) return p;
|
|
5187
|
-
const key = projectKeyFor(p.name, taken);
|
|
5188
|
-
taken.add(key);
|
|
5189
|
-
return {
|
|
5190
|
-
...p,
|
|
5191
|
-
key
|
|
5192
|
-
};
|
|
5193
|
-
});
|
|
5194
|
-
}
|
|
5195
|
-
}
|
|
5196
|
-
if (after.tasks === before.tasks) return projects === after.projects ? after : {
|
|
5197
|
-
...after,
|
|
5198
|
-
projects
|
|
5199
|
-
};
|
|
5200
|
-
const was = byId(before.tasks);
|
|
5201
|
-
const holders = /* @__PURE__ */ new Map();
|
|
5202
|
-
for (const t of after.tasks) {
|
|
5203
|
-
if (!Number.isInteger(t.number)) continue;
|
|
5204
|
-
const k = `${t.projectId}:${t.number}`;
|
|
5205
|
-
holders.set(k, (holders.get(k) ?? 0) + 1);
|
|
5206
|
-
}
|
|
5207
|
-
const keepsNumber = (t) => Number.isInteger(t.number) && holders.get(`${t.projectId}:${t.number}`) === 1;
|
|
5208
|
-
const fresh = (t) => {
|
|
5209
|
-
const old = was.get(t.id);
|
|
5210
|
-
if (!old) return !keepsNumber(t);
|
|
5211
|
-
return old.projectId !== t.projectId && old.number === t.number;
|
|
5212
|
-
};
|
|
5213
|
-
if (!after.tasks.some(fresh)) return projects === after.projects ? after : {
|
|
5214
|
-
...after,
|
|
5215
|
-
projects
|
|
5216
|
-
};
|
|
5217
|
-
const max = /* @__PURE__ */ new Map();
|
|
5218
|
-
for (const t of after.tasks) {
|
|
5219
|
-
if (fresh(t) || !Number.isInteger(t.number)) continue;
|
|
5220
|
-
if (t.number > (max.get(t.projectId) ?? 0)) max.set(t.projectId, t.number);
|
|
5221
|
-
}
|
|
5222
|
-
const tasks = after.tasks.map((t) => {
|
|
5223
|
-
if (!fresh(t)) return t;
|
|
5224
|
-
const n = (max.get(t.projectId) ?? 0) + 1;
|
|
5225
|
-
max.set(t.projectId, n);
|
|
5226
|
-
return {
|
|
5227
|
-
...t,
|
|
5228
|
-
number: n
|
|
5229
|
-
};
|
|
5230
|
-
});
|
|
5231
|
-
return {
|
|
5232
|
-
...after,
|
|
5233
|
-
projects,
|
|
5234
|
-
tasks
|
|
5235
|
-
};
|
|
5236
|
-
}
|
|
5237
|
-
/** "RR-42", or null for a task without a number yet. */
|
|
5238
|
-
function taskKey(task, project) {
|
|
5239
|
-
return project?.key && task.number ? `${project.key}-${task.number}` : null;
|
|
5240
|
-
}
|
|
5241
|
-
/** The task a key names ("rr-42" works too), or null. */
|
|
5242
|
-
function findByKey(data, text) {
|
|
5243
|
-
const m = KEY_TEXT.exec(text.trim());
|
|
5244
|
-
if (!m) return null;
|
|
5245
|
-
const project = data.projects.find((p) => p.key === m[1].toUpperCase());
|
|
5246
|
-
if (!project) return null;
|
|
5247
|
-
const n = Number(m[2]);
|
|
5248
|
-
return data.tasks.find((t) => t.projectId === project.id && t.number === n) ?? null;
|
|
5249
|
-
}
|
|
5250
|
-
var MAX_KEY, KEY, made, kept, KEY_TEXT;
|
|
5251
|
-
var init_task_keys = __esmMin((() => {
|
|
5252
|
-
init_indexes();
|
|
5253
|
-
MAX_KEY = 5;
|
|
5254
|
-
KEY = /^[A-Z][A-Z0-9]{0,9}$/;
|
|
5255
|
-
made = (a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
5256
|
-
kept = (t, stored) => {
|
|
5257
|
-
const was = stored?.get(t.id);
|
|
5258
|
-
return !!was && was.number === t.number && was.projectId === t.projectId;
|
|
5259
|
-
};
|
|
5260
|
-
KEY_TEXT = /^([A-Za-z][A-Za-z0-9]{0,9})-(\d{1,7})$/;
|
|
5261
|
-
}));
|
|
5262
|
-
|
|
5263
5338
|
//#endregion
|
|
5264
5339
|
//#region src/lib/search.ts
|
|
5265
5340
|
function tokensOf(query) {
|
|
@@ -7171,10 +7246,17 @@ function composeBriefBody(opts) {
|
|
|
7171
7246
|
for (const s of opts.said) lines.push(`• ${s.name}, ${format(/* @__PURE__ */ new Date(`${s.date}T12:00:00`), "EEE")}: ${s.body.trim()}`);
|
|
7172
7247
|
lines.push("");
|
|
7173
7248
|
};
|
|
7249
|
+
const thanks = () => {
|
|
7250
|
+
if (!opts.thanks?.length) return;
|
|
7251
|
+
lines.push("Fair winds");
|
|
7252
|
+
for (const t of opts.thanks) lines.push(`• ${t}`);
|
|
7253
|
+
lines.push("");
|
|
7254
|
+
};
|
|
7174
7255
|
if (quiet) {
|
|
7175
7256
|
lines.push(BRIEF_QUIET, "");
|
|
7176
7257
|
done();
|
|
7177
7258
|
said();
|
|
7259
|
+
thanks();
|
|
7178
7260
|
return lines.join("\n").trim();
|
|
7179
7261
|
}
|
|
7180
7262
|
if (opts.offTrack.length) {
|
|
@@ -7218,6 +7300,7 @@ function composeBriefBody(opts) {
|
|
|
7218
7300
|
}
|
|
7219
7301
|
done();
|
|
7220
7302
|
said();
|
|
7303
|
+
thanks();
|
|
7221
7304
|
if (opts.onTrack.length) {
|
|
7222
7305
|
lines.push("On track");
|
|
7223
7306
|
lines.push(opts.onTrack.map((p) => p.name).join(", "));
|
|
@@ -7407,6 +7490,7 @@ function briefOf(data, opts = {}) {
|
|
|
7407
7490
|
projectIds,
|
|
7408
7491
|
portfolioId
|
|
7409
7492
|
}), todayISO(now)),
|
|
7493
|
+
thanks: thanksSince(data, weekAgo, (t) => t.projectId ? projectIds.has(t.projectId) : !!t.programId && programIds.has(t.programId)),
|
|
7410
7494
|
barnacles: barnacleLinesOf(data, projects, now),
|
|
7411
7495
|
talk,
|
|
7412
7496
|
milestonesSoon,
|
|
@@ -7465,6 +7549,7 @@ function composeBriefBodyFrom(brief, data, workspace, all = false) {
|
|
|
7465
7549
|
reason: d.reason,
|
|
7466
7550
|
where: d.where
|
|
7467
7551
|
})),
|
|
7552
|
+
thanks: brief.thanks.map(thanksText),
|
|
7468
7553
|
barnacles: brief.barnacles.map((b) => ({
|
|
7469
7554
|
project: b.project.name,
|
|
7470
7555
|
line: barnaclesShort(b.barnacles)
|
|
@@ -7481,6 +7566,7 @@ function briefHidden(brief) {
|
|
|
7481
7566
|
}
|
|
7482
7567
|
var BRIEF_BARNACLES_SHOWN, BRIEF_QUIET, BRIEF_LATE_SHOWN;
|
|
7483
7568
|
var init_brief = __esmMin((() => {
|
|
7569
|
+
init_thanks();
|
|
7484
7570
|
init_date_fns();
|
|
7485
7571
|
init_date_text();
|
|
7486
7572
|
init_derived_health();
|
|
@@ -7879,9 +7965,12 @@ function listed(prefix, items) {
|
|
|
7879
7965
|
const rest = items.length - shown.length;
|
|
7880
7966
|
return {
|
|
7881
7967
|
text: `${prefix}${shown.join("; ")}${rest > 0 ? `; and ${rest} more` : ""}`,
|
|
7882
|
-
more: rest
|
|
7968
|
+
more: rest,
|
|
7969
|
+
items
|
|
7883
7970
|
};
|
|
7884
7971
|
}
|
|
7972
|
+
/** A line's heading for its full list: "Finished: …" reads "Finished". */
|
|
7973
|
+
const changeHeading = (c) => c.text.split(":")[0];
|
|
7885
7974
|
const highRated = (r) => r.kind !== "decision" && isActive(r) && (r.severity === "high" || (r.impact ?? 0) >= 4);
|
|
7886
7975
|
/** What changed on one project since `since`: health, work finished, late or added, the register, the hill. */
|
|
7887
7976
|
function projectChanges(doc, project, since, now) {
|
|
@@ -8073,9 +8162,11 @@ const WORD = {
|
|
|
8073
8162
|
/**
|
|
8074
8163
|
* Bridge mode, as on the website's review: the meeting one project at a
|
|
8075
8164
|
* time with only what changed since the program was last reviewed (←→ or
|
|
8076
|
-
* j k), d a decision, a an action, r a risk, m the program's next move,
|
|
8077
|
-
*
|
|
8078
|
-
*
|
|
8165
|
+
* j k), d a decision, a an action, r a risk, m the program's next move, v
|
|
8166
|
+
* every item behind an "and N more" (↑↓ scroll), e ends it. Esc or q leaves
|
|
8167
|
+
* without ending, so the next meeting still counts from the last one. The
|
|
8168
|
+
* summary lists what was logged; p posts it to each program's chat channel
|
|
8169
|
+
* where one is set, y copies it.
|
|
8079
8170
|
*/
|
|
8080
8171
|
function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
8081
8172
|
const { store, transport, today } = useDeps();
|
|
@@ -8110,6 +8201,12 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8110
8201
|
return bridgeSlides(store.getState(), picked, started);
|
|
8111
8202
|
});
|
|
8112
8203
|
const [index, setIndex] = useState(0);
|
|
8204
|
+
const [all, setAll] = useState(false);
|
|
8205
|
+
const [scroll, setScroll] = useState(0);
|
|
8206
|
+
const move = (to) => {
|
|
8207
|
+
setIndex(to);
|
|
8208
|
+
setScroll(0);
|
|
8209
|
+
};
|
|
8113
8210
|
const [logged, setLogged] = useState([]);
|
|
8114
8211
|
const [ended, setEnded] = useState(slides.length === 0 ? started : null);
|
|
8115
8212
|
const [channels, setChannels] = useState({});
|
|
@@ -8231,9 +8328,19 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8231
8328
|
if (input === "p" && !viewOnly) return void post();
|
|
8232
8329
|
return;
|
|
8233
8330
|
}
|
|
8234
|
-
if (
|
|
8235
|
-
if (key.
|
|
8236
|
-
|
|
8331
|
+
if (input === "e") return end();
|
|
8332
|
+
if (key.escape || input === "q") {
|
|
8333
|
+
go.notify("Left the meeting without ending it; the next one still counts from the last.");
|
|
8334
|
+
return onBack();
|
|
8335
|
+
}
|
|
8336
|
+
if (key.rightArrow || input === "j" || input === " ") return move((i) => Math.min(slides.length - 1, i + 1));
|
|
8337
|
+
if (key.leftArrow || input === "k") return move((i) => Math.max(0, i - 1));
|
|
8338
|
+
if (input === "v") {
|
|
8339
|
+
setScroll(0);
|
|
8340
|
+
return setAll((a) => !a);
|
|
8341
|
+
}
|
|
8342
|
+
if (key.downArrow) return setScroll((n) => n + 1);
|
|
8343
|
+
if (key.upArrow) return setScroll((n) => Math.max(0, n - 1));
|
|
8237
8344
|
if (viewOnly) return;
|
|
8238
8345
|
if (input === "d") return logDecision();
|
|
8239
8346
|
if (input === "a") return logAction();
|
|
@@ -8280,7 +8387,7 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8280
8387
|
slide.milestone ? `Next milestone: ${slide.milestone.name}, ${shortDay(slide.milestone.dueDate, today())}` : "",
|
|
8281
8388
|
"",
|
|
8282
8389
|
`Since ${program.reviewedAt ? "the last review" : "last week"}, ${timeAgo(new Date(slide.since), started)}:`,
|
|
8283
|
-
...slide.changes.
|
|
8390
|
+
...slide.changes.flatMap((c) => all && c.more > 0 && c.items ? [`• ${changeHeading(c)} (${c.items.length}):`, ...c.items.map((i) => ` ${i}`)] : [`• ${c.text}`]),
|
|
8284
8391
|
...here.length ? [
|
|
8285
8392
|
"",
|
|
8286
8393
|
"Logged in this meeting:",
|
|
@@ -8289,7 +8396,11 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8289
8396
|
"",
|
|
8290
8397
|
`Next move: ${program.nextMove?.trim() || "none yet"}`
|
|
8291
8398
|
].filter((l, i, all) => l !== "" || all[i - 1] !== "").join("\n");
|
|
8292
|
-
const
|
|
8399
|
+
const room = Math.max(1, height - 3);
|
|
8400
|
+
const wrapped = wrapLines(text, w);
|
|
8401
|
+
const top = Math.min(scroll, Math.max(0, wrapped.length - room));
|
|
8402
|
+
const lines = wrapped.slice(top, top + room);
|
|
8403
|
+
const hasMore = slide.changes.some((c) => c.more > 0);
|
|
8293
8404
|
return /* @__PURE__ */ jsxs(Box, {
|
|
8294
8405
|
flexDirection: "column",
|
|
8295
8406
|
paddingX: 1,
|
|
@@ -8312,7 +8423,8 @@ function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
|
8312
8423
|
children: [
|
|
8313
8424
|
"←→ projects",
|
|
8314
8425
|
viewOnly ? "" : " · d decision · a action · r risk · m next move",
|
|
8315
|
-
" ·
|
|
8426
|
+
hasMore ? all ? " · v fewer · ↑↓ scroll" : " · v show all" : "",
|
|
8427
|
+
" · e end · q leave"
|
|
8316
8428
|
]
|
|
8317
8429
|
})
|
|
8318
8430
|
]
|
|
@@ -18921,6 +19033,7 @@ var init_passage = __esmMin((() => {
|
|
|
18921
19033
|
|
|
18922
19034
|
//#endregion
|
|
18923
19035
|
//#region src/lib/mcp/tools.ts
|
|
19036
|
+
init_thanks();
|
|
18924
19037
|
init_task_keys();
|
|
18925
19038
|
init_brief();
|
|
18926
19039
|
init_indexes();
|
|
@@ -19924,6 +20037,51 @@ const HARBOR_TOOLS = [
|
|
|
19924
20037
|
};
|
|
19925
20038
|
}
|
|
19926
20039
|
}),
|
|
20040
|
+
tool({
|
|
20041
|
+
name: "review_meeting",
|
|
20042
|
+
title: "What changed since the last review",
|
|
20043
|
+
description: "The review meeting's agenda (bridge mode): for each open program, the open projects where something moved since the program was last reviewed (a week back if never): health moves, work finished, newly late or added, decisions, risks raised or updated, hill moves. Each change has its line and, where the line says \"and N more\", every item. Projects where nothing moved are listed as quiet. Reading this doesn't count as holding the meeting." + DATA_NOTE,
|
|
20044
|
+
input: object({
|
|
20045
|
+
harbor: harborArg,
|
|
20046
|
+
program: string().min(1).max(200).optional().describe("Program id or name; every open program when left out.")
|
|
20047
|
+
}),
|
|
20048
|
+
run: (ctx, input) => {
|
|
20049
|
+
const { data } = ctx;
|
|
20050
|
+
const programs = input.program ? [findOne(data.programs, input.program, "program")] : reviewPrograms(data).filter((p) => p.health !== "done");
|
|
20051
|
+
const { slides, quiet } = bridgeSlides(data, programs, ctx.now);
|
|
20052
|
+
const people = byId(data.people);
|
|
20053
|
+
return {
|
|
20054
|
+
programs: programs.filter((p) => p.health !== "done").map((p) => ({
|
|
20055
|
+
name: p.name,
|
|
20056
|
+
since: bridgeSince(p, ctx.now),
|
|
20057
|
+
lastReviewed: p.reviewedAt ?? null
|
|
20058
|
+
})),
|
|
20059
|
+
projects: slides.map((s) => ({
|
|
20060
|
+
program: s.program.name,
|
|
20061
|
+
project: s.project.name,
|
|
20062
|
+
health: s.project.health,
|
|
20063
|
+
lead: nameOf$2(people, s.project.ownerId),
|
|
20064
|
+
ends: dayOf(s.project.endDate),
|
|
20065
|
+
late: s.late,
|
|
20066
|
+
open: s.open,
|
|
20067
|
+
highRisks: s.highRisks,
|
|
20068
|
+
nextMilestone: s.milestone ? {
|
|
20069
|
+
name: s.milestone.name,
|
|
20070
|
+
due: dayOf(s.milestone.dueDate)
|
|
20071
|
+
} : null,
|
|
20072
|
+
changes: s.changes.map((c) => ({
|
|
20073
|
+
kind: c.kind,
|
|
20074
|
+
text: c.text,
|
|
20075
|
+
...c.more > 0 && c.items ? { items: c.items } : {}
|
|
20076
|
+
}))
|
|
20077
|
+
})),
|
|
20078
|
+
quiet: quiet.map((q) => ({
|
|
20079
|
+
program: q.program.name,
|
|
20080
|
+
project: q.project.name
|
|
20081
|
+
}))
|
|
20082
|
+
};
|
|
20083
|
+
}
|
|
20084
|
+
}),
|
|
19927
20085
|
tool({
|
|
19928
20086
|
name: "get_premortem",
|
|
19929
20087
|
title: "A project's pre-mortem",
|
|
@@ -20025,6 +20183,11 @@ const HARBOR_TOOLS = [
|
|
|
20025
20183
|
who: c.person.name,
|
|
20026
20184
|
tasks: c.closed
|
|
20027
20185
|
})),
|
|
20186
|
+
thanks: log.thanks.map((t) => ({
|
|
20187
|
+
from: nameOf$2(people, t.authorId),
|
|
20188
|
+
to: nameOf$2(people, t.toId ?? null),
|
|
20189
|
+
line: t.body
|
|
20190
|
+
})),
|
|
20028
20191
|
crew: log.lines.map((l) => ({
|
|
20029
20192
|
who: nameOf$2(people, l.authorId),
|
|
20030
20193
|
line: l.body
|
|
@@ -20177,6 +20340,41 @@ const HARBOR_TOOLS = [
|
|
|
20177
20340
|
return answer("project", project.name, projectPole(project, data.tasks, ctx.now), "the end date");
|
|
20178
20341
|
}
|
|
20179
20342
|
}),
|
|
20343
|
+
tool({
|
|
20344
|
+
name: "list_thanks",
|
|
20345
|
+
title: "Thanks people were given",
|
|
20346
|
+
description: "Fair winds: the one-line thanks given for tasks, milestones and decisions, newest first, each with who gave it, who it was for and what for. By default the ones you were given in the last 30 days; person narrows to someone else's (\"all\" for everyone's), given: true lists the ones they gave instead. There are no counts or rankings, by design." + DATA_NOTE,
|
|
20347
|
+
input: object({
|
|
20348
|
+
harbor: harborArg,
|
|
20349
|
+
person: string().max(200).optional().describe("\"me\" (the default), \"all\", or a person's id or name."),
|
|
20350
|
+
given: boolean().default(false).describe("The thanks they gave, not the ones they got."),
|
|
20351
|
+
days: number().int().min(1).max(365).default(30)
|
|
20352
|
+
}),
|
|
20353
|
+
run: (ctx, input) => {
|
|
20354
|
+
const { data } = ctx;
|
|
20355
|
+
const key = (input.person ?? "me").trim().toLowerCase();
|
|
20356
|
+
const who = key === "all" ? null : key === "me" ? me(ctx) : findOne(data.people, input.person, "person");
|
|
20357
|
+
if (key === "me" && !who) return {
|
|
20358
|
+
thanks: [],
|
|
20359
|
+
note: "You aren't one of this harbor's people yet, so nobody has thanked you here."
|
|
20360
|
+
};
|
|
20361
|
+
const since = (/* @__PURE__ */ new Date(ctx.now.getTime() - input.days * 864e5)).toISOString();
|
|
20362
|
+
const lines = thanksSince(data, since).filter((l) => !who || (input.given ? l.thanks.authorId : l.thanks.toId) === who.id);
|
|
20363
|
+
const people = byId(data.people);
|
|
20364
|
+
return {
|
|
20365
|
+
...who ? { person: who.name } : {},
|
|
20366
|
+
days: input.days,
|
|
20367
|
+
thanks: lines.slice(0, 100).map((l) => ({
|
|
20368
|
+
from: nameOf$2(people, l.thanks.authorId),
|
|
20369
|
+
to: nameOf$2(people, l.thanks.toId ?? null),
|
|
20370
|
+
for: l.for,
|
|
20371
|
+
kind: l.thanks.thanksFor.kind,
|
|
20372
|
+
line: l.thanks.body,
|
|
20373
|
+
at: l.thanks.createdAt
|
|
20374
|
+
}))
|
|
20375
|
+
};
|
|
20376
|
+
}
|
|
20377
|
+
}),
|
|
20180
20378
|
tool({
|
|
20181
20379
|
name: "scope_growth",
|
|
20182
20380
|
title: "Barnacles: work added since the plan was set",
|
|
@@ -22449,7 +22647,8 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
22449
22647
|
const me = get().currentUserId;
|
|
22450
22648
|
write({ reflections: (get().reflections ?? []).map((r) => {
|
|
22451
22649
|
if (r.id !== id || r.authorId !== me) return r;
|
|
22452
|
-
const
|
|
22650
|
+
const max = r.kind === "bottle" ? BOTTLE_MAX : r.kind === "thanks" ? 200 : 600;
|
|
22651
|
+
const text = body.trim().slice(0, max);
|
|
22453
22652
|
return text ? {
|
|
22454
22653
|
...r,
|
|
22455
22654
|
body: text
|
|
@@ -22519,6 +22718,29 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
22519
22718
|
skipped
|
|
22520
22719
|
} : r) });
|
|
22521
22720
|
},
|
|
22721
|
+
giveThanks: (on, toId, body) => {
|
|
22722
|
+
const s = get();
|
|
22723
|
+
const me = s.currentUserId;
|
|
22724
|
+
const text = body.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
22725
|
+
const target = thanksTarget(s, on);
|
|
22726
|
+
if (!target || thanksProblem(text, toId, me)) return null;
|
|
22727
|
+
if (!s.people.some((p) => p.id === toId)) return null;
|
|
22728
|
+
const row = {
|
|
22729
|
+
id: uid("rf"),
|
|
22730
|
+
projectId: target.projectId,
|
|
22731
|
+
kind: "thanks",
|
|
22732
|
+
authorId: me,
|
|
22733
|
+
body: text,
|
|
22734
|
+
createdAt: stamp(),
|
|
22735
|
+
toId,
|
|
22736
|
+
thanksFor: {
|
|
22737
|
+
kind: on.kind,
|
|
22738
|
+
id: on.id
|
|
22739
|
+
}
|
|
22740
|
+
};
|
|
22741
|
+
write({ reflections: [...s.reflections ?? [], row] });
|
|
22742
|
+
return row.id;
|
|
22743
|
+
},
|
|
22522
22744
|
writeVoyageLine: (projectId, body) => {
|
|
22523
22745
|
const text = body.trim().slice(0, 600);
|
|
22524
22746
|
const me = get().currentUserId;
|
|
@@ -22603,6 +22825,7 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
22603
22825
|
};
|
|
22604
22826
|
}
|
|
22605
22827
|
var init_milestones_raid = __esmMin((() => {
|
|
22828
|
+
init_thanks();
|
|
22606
22829
|
init_bottle_rules();
|
|
22607
22830
|
init_clock();
|
|
22608
22831
|
init_premortem();
|
|
@@ -28772,7 +28995,7 @@ function App({ ui, startIn }) {
|
|
|
28772
28995
|
const covered = overlay !== null || blockPending !== null;
|
|
28773
28996
|
const page = stack.at(-1);
|
|
28774
28997
|
useInput((input, key) => {
|
|
28775
|
-
if (key.ctrl && input === "c" || input === "q") return void quit();
|
|
28998
|
+
if (key.ctrl && input === "c" || input === "q" && page?.kind !== "bridge") return void quit();
|
|
28776
28999
|
if (key.tab) return setFocus((f) => f === "nav" ? "main" : "nav");
|
|
28777
29000
|
if (input === "/") return setOverlay({ kind: "search" });
|
|
28778
29001
|
if (input === ":") return setOverlay({ kind: "palette" });
|
|
@@ -28998,7 +29221,7 @@ function App({ ui, startIn }) {
|
|
|
28998
29221
|
["←→", "projects"],
|
|
28999
29222
|
["d a r", "log"],
|
|
29000
29223
|
["e", "end"],
|
|
29001
|
-
["
|
|
29224
|
+
["q", "leave"]
|
|
29002
29225
|
] : page?.kind === "project" ? [
|
|
29003
29226
|
["←→↑↓", "move"],
|
|
29004
29227
|
["Enter", "open"],
|
|
@@ -29368,6 +29591,7 @@ function fogSummary(t) {
|
|
|
29368
29591
|
|
|
29369
29592
|
//#endregion
|
|
29370
29593
|
//#region src/tui/watch.ts
|
|
29594
|
+
init_date_text();
|
|
29371
29595
|
init_indexes();
|
|
29372
29596
|
init_swatches();
|
|
29373
29597
|
/** Most lines one change prints; a whole reload says how many more there were. */
|
|
@@ -29484,6 +29708,43 @@ function watchLines(prev, next, meId) {
|
|
|
29484
29708
|
}
|
|
29485
29709
|
return out;
|
|
29486
29710
|
}
|
|
29711
|
+
/** How many of the latest changes `harbor watch` starts with. */
|
|
29712
|
+
const WATCH_RECENT = 5;
|
|
29713
|
+
/**
|
|
29714
|
+
* The harbor's latest changes from its activity log, oldest first, so a
|
|
29715
|
+
* watch doesn't start on a blank screen: the last few, or every one since
|
|
29716
|
+
* `since` (an ISO moment). With a project, only lines about it.
|
|
29717
|
+
*/
|
|
29718
|
+
function recentLines(doc, meId, opts = {}) {
|
|
29719
|
+
const people = byId(doc.people);
|
|
29720
|
+
const from = opts.since ? Date.parse(opts.since) : Number.NEGATIVE_INFINITY;
|
|
29721
|
+
const rows = doc.activity.filter((a) => Date.parse(a.at) > from).filter((a) => !opts.projectId || a.href === `/projects/${opts.projectId}`).sort((a, b) => a.at.localeCompare(b.at));
|
|
29722
|
+
return (opts.since ? rows : rows.slice(-(opts.limit ?? 5))).map((a) => ({
|
|
29723
|
+
at: a.at,
|
|
29724
|
+
line: {
|
|
29725
|
+
text: `${a.personId === meId ? "You" : firstName(people.get(a.personId))} ${a.text}`,
|
|
29726
|
+
mine: false,
|
|
29727
|
+
tone: "plain"
|
|
29728
|
+
}
|
|
29729
|
+
}));
|
|
29730
|
+
}
|
|
29731
|
+
/**
|
|
29732
|
+
* `--since`: "14:30" (today, on this computer's clock) or a date or moment
|
|
29733
|
+
* ("2026-10-21", "2026-10-21T14:30"), as an ISO moment; null when it isn't one.
|
|
29734
|
+
*/
|
|
29735
|
+
function watchSince(text, now) {
|
|
29736
|
+
const clock = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
|
|
29737
|
+
if (clock) {
|
|
29738
|
+
const [h, m] = [Number(clock[1]), Number(clock[2])];
|
|
29739
|
+
if (h > 23 || m > 59) return null;
|
|
29740
|
+
const at = new Date(now);
|
|
29741
|
+
at.setHours(h, m, 0, 0);
|
|
29742
|
+
return at.toISOString();
|
|
29743
|
+
}
|
|
29744
|
+
if (!/^\d{4}-\d{2}-\d{2}/.test(text.trim())) return null;
|
|
29745
|
+
const at = parseIso(text);
|
|
29746
|
+
return Number.isNaN(at.getTime()) ? null : at.toISOString();
|
|
29747
|
+
}
|
|
29487
29748
|
|
|
29488
29749
|
//#endregion
|
|
29489
29750
|
//#region src/tui/watch-run.ts
|
|
@@ -29528,7 +29789,33 @@ async function watchOn(opts) {
|
|
|
29528
29789
|
try {
|
|
29529
29790
|
const project = opts.project ? projectNamed(store.getState().projects, opts.project) : null;
|
|
29530
29791
|
const dim = (s) => opts.colour ? `${ANSI.dim}${s}${ANSI.reset}` : s;
|
|
29531
|
-
|
|
29792
|
+
const started = opts.now?.() ?? /* @__PURE__ */ new Date();
|
|
29793
|
+
const print = (line, at, earlier = false) => out(opts.json ? JSON.stringify({
|
|
29794
|
+
at: at.toISOString(),
|
|
29795
|
+
text: plain(line.text),
|
|
29796
|
+
...line.quote ? { quote: plain(line.quote) } : {},
|
|
29797
|
+
mine: line.mine,
|
|
29798
|
+
tone: line.tone,
|
|
29799
|
+
...earlier ? { earlier: true } : {}
|
|
29800
|
+
}) : watchText({
|
|
29801
|
+
...line,
|
|
29802
|
+
text: plain(line.text)
|
|
29803
|
+
}, at, {
|
|
29804
|
+
colour: opts.colour,
|
|
29805
|
+
width: opts.width?.()
|
|
29806
|
+
}));
|
|
29807
|
+
const earlier = recentLines(store.getState(), store.getState().currentUserId, {
|
|
29808
|
+
since: opts.since,
|
|
29809
|
+
projectId: project?.id ?? null
|
|
29810
|
+
});
|
|
29811
|
+
if (!opts.json) {
|
|
29812
|
+
out(dim(opts.once ? `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""}` : `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""} · on watch since ${wallClock(started).slice(11, 16)}${opts.bell ? " · bell on" : ""} · q to stop`));
|
|
29813
|
+
if (earlier.length === 0) out(dim(opts.since ? "Nothing changed since then." : "Nothing logged yet."));
|
|
29814
|
+
else out(dim(opts.since ? "Since then:" : "Lately:"));
|
|
29815
|
+
}
|
|
29816
|
+
for (const e of earlier) print(e.line, new Date(e.at), true);
|
|
29817
|
+
if (opts.once) return;
|
|
29818
|
+
if (!opts.json && earlier.length > 0) out(dim("New changes print below as they land."));
|
|
29532
29819
|
const narrow = (d) => project ? {
|
|
29533
29820
|
...d,
|
|
29534
29821
|
tasks: d.tasks.filter((t) => t.projectId === project.id),
|
|
@@ -29539,10 +29826,7 @@ async function watchOn(opts) {
|
|
|
29539
29826
|
if (s.tasks === prev.tasks && s.comments === prev.comments && s.projects === prev.projects && s.programs === prev.programs) return;
|
|
29540
29827
|
const lines = watchLines(narrow(prev), narrow(s), s.currentUserId);
|
|
29541
29828
|
const at = opts.now?.() ?? /* @__PURE__ */ new Date();
|
|
29542
|
-
for (const line of lines)
|
|
29543
|
-
colour: opts.colour,
|
|
29544
|
-
width: opts.width?.()
|
|
29545
|
-
}));
|
|
29829
|
+
for (const line of lines) print(line, at);
|
|
29546
29830
|
if (opts.bell && lines.some((l) => l.mine)) opts.write("\x07");
|
|
29547
29831
|
});
|
|
29548
29832
|
await opts.stopped;
|
|
@@ -29979,14 +30263,16 @@ async function fogHere(opts) {
|
|
|
29979
30263
|
if (sync.state !== "saved") throw new HarborError("Harbor didn't take those answers. Run harbor fog --sweep again.");
|
|
29980
30264
|
}
|
|
29981
30265
|
/**
|
|
29982
|
-
* `harbor watch`: the harbor's changes
|
|
29983
|
-
* or Ctrl+C
|
|
30266
|
+
* `harbor watch`: the harbor's latest changes, then each new one as it
|
|
30267
|
+
* lands, a line each, until q or Ctrl+C (or the process is stopped, when
|
|
30268
|
+
* piped). `once` prints what's there and returns. Nothing is changed from
|
|
30269
|
+
* here; it only listens.
|
|
29984
30270
|
*/
|
|
29985
30271
|
async function watchHere(opts) {
|
|
29986
30272
|
const transport = plainTransport(httpTransport(opts.source, opts.fetcher));
|
|
29987
30273
|
const answer = await transport.harbors();
|
|
29988
30274
|
const harbor = harborFor(answer.harbors, opts.harbor, opts.saved);
|
|
29989
|
-
const stopped = new Promise((resolve) => {
|
|
30275
|
+
const stopped = opts.once ? Promise.resolve() : new Promise((resolve) => {
|
|
29990
30276
|
const input = process.stdin;
|
|
29991
30277
|
const stop = () => {
|
|
29992
30278
|
input.off("data", onKey);
|
|
@@ -30011,6 +30297,9 @@ async function watchHere(opts) {
|
|
|
30011
30297
|
project: opts.project,
|
|
30012
30298
|
bell: opts.bell,
|
|
30013
30299
|
colour: opts.colour,
|
|
30300
|
+
since: opts.since,
|
|
30301
|
+
once: opts.once,
|
|
30302
|
+
json: opts.json,
|
|
30014
30303
|
width: () => process.stdout.columns,
|
|
30015
30304
|
write: (text) => process.stdout.write(text),
|
|
30016
30305
|
stopped
|
|
@@ -30057,4 +30346,4 @@ function planTransport(source, fetcher) {
|
|
|
30057
30346
|
}
|
|
30058
30347
|
|
|
30059
30348
|
//#endregion
|
|
30060
|
-
export { SPLASH_MS, TransportError, firstHarbor, fogHere, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere, watchHere };
|
|
30349
|
+
export { SPLASH_MS, TransportError, firstHarbor, fogHere, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere, watchHere, watchSince };
|
package/lib/cli.mjs
CHANGED
|
@@ -39,9 +39,11 @@ import {
|
|
|
39
39
|
lifeboatsText,
|
|
40
40
|
followedText,
|
|
41
41
|
soundingsText,
|
|
42
|
+
thanksText,
|
|
42
43
|
ashoreText,
|
|
43
44
|
voyageText,
|
|
44
45
|
whyText,
|
|
46
|
+
bridgeText,
|
|
45
47
|
barnaclesText,
|
|
46
48
|
handoverText,
|
|
47
49
|
fogText,
|
|
@@ -188,10 +190,17 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
188
190
|
the review: programs worst first,
|
|
189
191
|
each with its next move (reads
|
|
190
192
|
only; harbor submit moves a task)
|
|
193
|
+
harbor thanks [--all] [--given] thanks you were given this month
|
|
194
|
+
harbor thanks TASK "LINE" [--to NAME] a one-line thanks for a task
|
|
195
|
+
harbor thanks --milestone NAME|--decision TITLE "LINE" [--to NAME]
|
|
196
|
+
the same for a milestone or a
|
|
197
|
+
decision
|
|
191
198
|
harbor bridge [--harbor H] run the review as a meeting: one
|
|
192
199
|
project a screen, what changed
|
|
193
200
|
since the last one, decisions,
|
|
194
201
|
actions and risks logged live
|
|
202
|
+
harbor bridge --list [--program P] what changed since the last
|
|
203
|
+
review, as text (or --json)
|
|
195
204
|
harbor next-move PROGRAM "TEXT" write a program's next move
|
|
196
205
|
harbor health --program P|--project P|--portfolio P STATE [--note WHY]
|
|
197
206
|
set its health by hand, or auto
|
|
@@ -239,6 +248,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
239
248
|
harbor fog keep TASK it's still needed: out of the fog
|
|
240
249
|
harbor watch [--project P] [--bell] a live ticker of the harbor's
|
|
241
250
|
changes; yours bright, q stops
|
|
251
|
+
harbor watch --once [--since TIME] [--json] the latest changes (or all since
|
|
252
|
+
TIME), printed once, for scripts
|
|
242
253
|
harbor lifeboats [--program P] work only one person knows, each
|
|
243
254
|
with a second pair of hands
|
|
244
255
|
harbor lifeboats N [--person NAME] adds that second pair of hands
|
|
@@ -817,10 +828,18 @@ const DETAIL = {
|
|
|
817
828
|
" cards moving, comments, health changes",
|
|
818
829
|
" --project P one project's changes only",
|
|
819
830
|
" --bell ring the terminal's bell when something of yours changes",
|
|
831
|
+
" --since TIME start with every change since TIME (14:30 today, or",
|
|
832
|
+
" 2026-10-21, or 2026-10-21T14:30) instead of the last 5",
|
|
833
|
+
" --once print those and stop instead of listening",
|
|
834
|
+
" --json one JSON object a line (at, text, quote, mine, tone;",
|
|
835
|
+
" earlier: true for the ones from before it started)",
|
|
820
836
|
"",
|
|
821
|
-
"
|
|
822
|
-
"
|
|
823
|
-
"
|
|
837
|
+
" It starts with the latest few changes and a \"since HH:MM\" line, then",
|
|
838
|
+
" prints each new one as it lands. Lines about your own work (your tasks,",
|
|
839
|
+
" mentions of you, what you lead) are bright; the rest are dim. It runs",
|
|
840
|
+
" until q or Ctrl+C (or until it's killed, when piped or with --json);",
|
|
841
|
+
" --once returns at once. Made for a narrow split or a tmux pane; it needs",
|
|
842
|
+
" full access (harbor login without --read-only).",
|
|
824
843
|
],
|
|
825
844
|
lifeboats: [
|
|
826
845
|
" harbor lifeboats [--program P] reads: work only one person has",
|
|
@@ -927,19 +946,40 @@ const DETAIL = {
|
|
|
927
946
|
" enough of it to pick one out; harbor risks lists them. Changes several",
|
|
928
947
|
" at once, as one change.",
|
|
929
948
|
],
|
|
949
|
+
thanks: [
|
|
950
|
+
" harbor thanks reads the thanks you were given in",
|
|
951
|
+
" the last 30 days, newest first",
|
|
952
|
+
" harbor thanks --all everyone's; --given: the ones you gave",
|
|
953
|
+
' harbor thanks RR-42 "LINE" writes: thanks whoever has the task',
|
|
954
|
+
' harbor thanks --milestone NAME "LINE" writes: thanks for a milestone (its',
|
|
955
|
+
" sign-off person unless --to says)",
|
|
956
|
+
' harbor thanks --decision TITLE "LINE" writes: thanks whoever made the call',
|
|
957
|
+
" --to NAME someone else instead",
|
|
958
|
+
"",
|
|
959
|
+
" Fair winds: a thanks shows on the work, on the person's card on People,",
|
|
960
|
+
" in the next brief and in the project's voyage log. Nobody can thank",
|
|
961
|
+
" themselves, and nothing counts or ranks them.",
|
|
962
|
+
],
|
|
930
963
|
bridge: [
|
|
931
964
|
" harbor bridge opens the full-screen app on the",
|
|
932
965
|
" review meeting (b on the Programs",
|
|
933
966
|
" screen, or : Run the review meeting)",
|
|
967
|
+
" harbor bridge --list [--program P] prints the agenda instead: what",
|
|
968
|
+
" changed on each project since its",
|
|
969
|
+
" program's last review, every item",
|
|
970
|
+
" (--json for scripts and agents; the",
|
|
971
|
+
" same when piped). Reading it doesn't",
|
|
972
|
+
" count as holding the meeting",
|
|
934
973
|
"",
|
|
935
974
|
" One open project a screen, with only what changed since its program was",
|
|
936
975
|
" last reviewed (a week back the first time): health moves, work finished,",
|
|
937
976
|
" newly late or added, risks raised, decisions made. ←→ move between",
|
|
938
977
|
" projects; d logs a decision, a an action (who takes it, due in a week),",
|
|
939
|
-
" r a risk, m the program's next move
|
|
940
|
-
"
|
|
941
|
-
"
|
|
942
|
-
"
|
|
978
|
+
" r a risk, m the program's next move, v shows every item behind an",
|
|
979
|
+
" \"and N more\" (↑↓ scroll); e ends it, Esc or q leaves without ending",
|
|
980
|
+
" it. The summary lists what was logged; p posts it to each program's",
|
|
981
|
+
" chat channel where one is set (Ship to shore, set on the website), y",
|
|
982
|
+
" copies it. The next meeting counts from when this one ended.",
|
|
943
983
|
],
|
|
944
984
|
review: [
|
|
945
985
|
" harbor review reads the review: every program,",
|
|
@@ -1164,8 +1204,14 @@ export async function run(argv, io = {}) {
|
|
|
1164
1204
|
}
|
|
1165
1205
|
|
|
1166
1206
|
// On its own in a terminal: the full-screen app. Piped or scripted: the help.
|
|
1167
|
-
|
|
1168
|
-
|
|
1207
|
+
const inTerminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
1208
|
+
// harbor bridge --list (or --json, or piped) prints the agenda through the commands below.
|
|
1209
|
+
const bridgeApp =
|
|
1210
|
+
command === "bridge" &&
|
|
1211
|
+
inTerminal &&
|
|
1212
|
+
!rest.some((a) => a === "--list" || a === "--json" || a.startsWith("--program"));
|
|
1213
|
+
if (!command || command === "app" || bridgeApp) {
|
|
1214
|
+
const terminal = inTerminal;
|
|
1169
1215
|
if (!command && !terminal) {
|
|
1170
1216
|
out(HELP);
|
|
1171
1217
|
return 0;
|
|
@@ -2663,13 +2709,18 @@ export async function run(argv, io = {}) {
|
|
|
2663
2709
|
return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
|
|
2664
2710
|
}
|
|
2665
2711
|
case "watch": {
|
|
2666
|
-
const { positionals, values, harbor } = parse(spec(["bell"], ["project"]));
|
|
2712
|
+
const { positionals, values, harbor } = parse(spec(["bell", "once"], ["project", "since"]));
|
|
2667
2713
|
none(positionals);
|
|
2668
2714
|
if (!hasFullAccess(creds))
|
|
2669
2715
|
throw new HarborError(
|
|
2670
2716
|
"Watching needs full access, as the full-screen app does. Run harbor login (without --read-only), then try again.",
|
|
2671
2717
|
);
|
|
2672
|
-
const { watchHere } = await loadApp();
|
|
2718
|
+
const { watchHere, watchSince } = await loadApp();
|
|
2719
|
+
const since = values.since ? watchSince(values.since, new Date()) : null;
|
|
2720
|
+
if (values.since && !since)
|
|
2721
|
+
throw new HarborError(
|
|
2722
|
+
`--since takes a time today (14:30) or a date (2026-10-21, or 2026-10-21T14:30), not "${values.since}".`,
|
|
2723
|
+
);
|
|
2673
2724
|
await watchHere({
|
|
2674
2725
|
source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
|
|
2675
2726
|
fetcher,
|
|
@@ -2677,7 +2728,10 @@ export async function run(argv, io = {}) {
|
|
|
2677
2728
|
saved: creds.harbor ?? null,
|
|
2678
2729
|
project: values.project,
|
|
2679
2730
|
bell: Boolean(values.bell),
|
|
2680
|
-
colour: colourDepth(io.stdout ?? process.stdout, env) !== "none",
|
|
2731
|
+
colour: !asJson && colourDepth(io.stdout ?? process.stdout, env) !== "none",
|
|
2732
|
+
since,
|
|
2733
|
+
once: Boolean(values.once),
|
|
2734
|
+
json: asJson,
|
|
2681
2735
|
});
|
|
2682
2736
|
return 0;
|
|
2683
2737
|
}
|
|
@@ -2716,6 +2770,49 @@ export async function run(argv, io = {}) {
|
|
|
2716
2770
|
}
|
|
2717
2771
|
return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
|
|
2718
2772
|
}
|
|
2773
|
+
case "bridge": {
|
|
2774
|
+
const { positionals, values, harbor } = parse(spec(["list"], ["program"]));
|
|
2775
|
+
none(positionals);
|
|
2776
|
+
return call("review_meeting", { harbor, program: values.program }, bridgeText);
|
|
2777
|
+
}
|
|
2778
|
+
case "thanks": {
|
|
2779
|
+
const { positionals, values, harbor } = parse(
|
|
2780
|
+
spec(["all", "given"], ["to", "milestone", "decision"]),
|
|
2781
|
+
);
|
|
2782
|
+
const forWork = values.milestone || values.decision;
|
|
2783
|
+
if (!forWork && positionals.length === 0) {
|
|
2784
|
+
return call(
|
|
2785
|
+
"list_thanks",
|
|
2786
|
+
{
|
|
2787
|
+
harbor,
|
|
2788
|
+
person: values.all ? "all" : "me",
|
|
2789
|
+
given: Boolean(values.given),
|
|
2790
|
+
},
|
|
2791
|
+
thanksText,
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
const [first, ...rest] = positionals;
|
|
2795
|
+
const task = forWork ? undefined : first;
|
|
2796
|
+
const line = (forWork ? positionals : rest).join(" ").trim();
|
|
2797
|
+
if (task && !TASK_KEY.test(task) && !line) {
|
|
2798
|
+
throw new HarborError(
|
|
2799
|
+
'Say what for and the line: harbor thanks RR-42 "Caught it before launch" (or --milestone NAME, --decision TITLE).',
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
if (!line) throw new HarborError('Add the line of thanks in quotes: harbor thanks RR-42 "Thank you".');
|
|
2803
|
+
return call(
|
|
2804
|
+
"give_thanks",
|
|
2805
|
+
{
|
|
2806
|
+
harbor,
|
|
2807
|
+
task,
|
|
2808
|
+
milestone: values.milestone,
|
|
2809
|
+
decision: values.decision,
|
|
2810
|
+
to: values.to,
|
|
2811
|
+
line,
|
|
2812
|
+
},
|
|
2813
|
+
(r) => `Fair winds: thanked ${r.thanked.to ?? "them"} for ${r.thanked.for}.`,
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2719
2816
|
case "handover": {
|
|
2720
2817
|
const { positionals, values, harbor } = parse(
|
|
2721
2818
|
spec(["confirm"], ["to", "until", "note", "keep"]),
|
package/lib/format.mjs
CHANGED
|
@@ -526,15 +526,66 @@ export function premortemText(r, project = r.project) {
|
|
|
526
526
|
}
|
|
527
527
|
|
|
528
528
|
/** A project's voyage log, as get_voyage_log answers it. */
|
|
529
|
+
/** `harbor thanks`: each thanks on two lines, the words first, no counts. */
|
|
530
|
+
export function thanksText(r) {
|
|
531
|
+
if (r.note) return r.note;
|
|
532
|
+
if (!r.thanks.length) {
|
|
533
|
+
return r.person
|
|
534
|
+
? `No thanks in the last ${r.days} days yet. harbor thanks TASK "LINE" gives one.`
|
|
535
|
+
: `Nobody has thanked anyone in the last ${r.days} days. harbor thanks TASK "LINE" gives one.`;
|
|
536
|
+
}
|
|
537
|
+
return r.thanks
|
|
538
|
+
.map((t) => `“${t.line}”\n ${t.from} to ${t.to}, for ${t.for} · ${t.at.slice(0, 10)}`)
|
|
539
|
+
.join("\n");
|
|
540
|
+
}
|
|
541
|
+
|
|
529
542
|
export function voyageText(r) {
|
|
530
543
|
const head = [`Voyage log · ${r.project}${r.done ? ` · Done ${r.finished}` : " · not Done yet"}`];
|
|
531
544
|
if (r.note) head.push(r.note);
|
|
532
545
|
head.push((r.stats ?? []).join(" · "));
|
|
533
546
|
const story = (r.story ?? []).map((s) => ` - ${s}`);
|
|
534
547
|
const crew = (r.crew ?? []).map((c) => ` - ${c.who ?? "Someone"}: ${c.line}`);
|
|
535
|
-
|
|
536
|
-
"
|
|
548
|
+
const thanks = (r.thanks ?? []).map(
|
|
549
|
+
(t) => ` - ${t.from ?? "Someone"} to ${t.to ?? "someone"}: “${t.line}”`,
|
|
550
|
+
);
|
|
551
|
+
return [
|
|
552
|
+
...head,
|
|
553
|
+
"",
|
|
554
|
+
...story,
|
|
555
|
+
...(thanks.length ? ["", "Fair winds along the way:", ...thanks] : []),
|
|
556
|
+
...(crew.length ? ["", "From the crew:", ...crew] : []),
|
|
557
|
+
].join("\n");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The review meeting's agenda, as review_meeting answers it: each project that moved, with every item. */
|
|
561
|
+
export function bridgeText(r) {
|
|
562
|
+
const programs = r.programs ?? [];
|
|
563
|
+
if (programs.length === 0) return "No open programs to review.";
|
|
564
|
+
const out = programs.map(
|
|
565
|
+
(p) =>
|
|
566
|
+
`${p.name}: changes since ${p.lastReviewed ? "the last review" : "last week"}, ${p.since.slice(0, 10)}`,
|
|
537
567
|
);
|
|
568
|
+
for (const p of r.projects ?? []) {
|
|
569
|
+
const facts = [
|
|
570
|
+
p.health,
|
|
571
|
+
p.lead,
|
|
572
|
+
p.ends ? `ends ${p.ends}` : null,
|
|
573
|
+
`${p.late} late of ${p.open} open`,
|
|
574
|
+
p.highRisks ? `${p.highRisks} high risk${p.highRisks === 1 ? "" : "s"}` : null,
|
|
575
|
+
p.nextMilestone ? `next milestone ${p.nextMilestone.name}, ${p.nextMilestone.due}` : null,
|
|
576
|
+
].filter(Boolean);
|
|
577
|
+
out.push("", `${p.project} (${p.program})`, ` ${facts.join(" · ")}`);
|
|
578
|
+
for (const c of p.changes) {
|
|
579
|
+
if (c.items) {
|
|
580
|
+
out.push(` - ${c.text.split(":")[0]} (${c.items.length}):`);
|
|
581
|
+
for (const item of c.items) out.push(` ${item}`);
|
|
582
|
+
} else out.push(` - ${c.text}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if ((r.projects ?? []).length === 0) out.push("", "Nothing moved since the last review.");
|
|
586
|
+
const quiet = (r.quiet ?? []).map((q) => q.project);
|
|
587
|
+
if (quiet.length) out.push("", `No change on ${quiet.join(", ")}.`);
|
|
588
|
+
return out.join("\n");
|
|
538
589
|
}
|
|
539
590
|
|
|
540
591
|
/** What's holding something up, as long_pole answers it: the headline, then the line of waits, first blocker at the top. */
|