@harborppm/cli 0.46.0 → 0.48.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 +4 -0
- package/dist/app.mjs +1004 -454
- package/lib/cli.mjs +51 -1
- package/lib/format.mjs +22 -0
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -2068,9 +2068,43 @@ function normalizeRaidItem(r) {
|
|
|
2068
2068
|
updatedAt: r.updatedAt ?? r.createdAt ?? now,
|
|
2069
2069
|
updates: Array.isArray(r.updates) ? r.updates : [],
|
|
2070
2070
|
scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0,
|
|
2071
|
-
...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {}
|
|
2071
|
+
...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {},
|
|
2072
|
+
...kind === "decision" ? decisionFields(r) : {}
|
|
2072
2073
|
};
|
|
2073
2074
|
}
|
|
2075
|
+
/** A revisit condition that still says something, or null. */
|
|
2076
|
+
function cleanRevisit(v) {
|
|
2077
|
+
if (!v || typeof v !== "object") return null;
|
|
2078
|
+
const r = v;
|
|
2079
|
+
const str = (x) => typeof x === "string" && x ? x : null;
|
|
2080
|
+
if (r.kind === "date" && str(r.on)) return {
|
|
2081
|
+
kind: "date",
|
|
2082
|
+
on: str(r.on).slice(0, 10)
|
|
2083
|
+
};
|
|
2084
|
+
if (r.kind === "risk" && str(r.riskId)) return {
|
|
2085
|
+
kind: "risk",
|
|
2086
|
+
riskId: str(r.riskId)
|
|
2087
|
+
};
|
|
2088
|
+
if (r.kind === "milestone" && str(r.milestoneId) && str(r.due)) return {
|
|
2089
|
+
kind: "milestone",
|
|
2090
|
+
milestoneId: str(r.milestoneId),
|
|
2091
|
+
due: str(r.due).slice(0, 10)
|
|
2092
|
+
};
|
|
2093
|
+
return null;
|
|
2094
|
+
}
|
|
2095
|
+
/** A decision's own fields, kept only where they say something so older rows stay as they were. */
|
|
2096
|
+
function decisionFields(r) {
|
|
2097
|
+
const out = {};
|
|
2098
|
+
if (r.turnedDown?.trim()) out.turnedDown = r.turnedDown;
|
|
2099
|
+
const tasks = ids$1(r.taskIds);
|
|
2100
|
+
if (tasks.length) out.taskIds = tasks;
|
|
2101
|
+
const risks = ids$1(r.riskIds);
|
|
2102
|
+
if (risks.length) out.riskIds = risks;
|
|
2103
|
+
const revisit = cleanRevisit(r.revisitIf);
|
|
2104
|
+
if (revisit) out.revisitIf = revisit;
|
|
2105
|
+
if (r.supersedesId) out.supersedesId = r.supersedesId;
|
|
2106
|
+
return out;
|
|
2107
|
+
}
|
|
2074
2108
|
/** Active first, then by score, then most recently touched. */
|
|
2075
2109
|
function sortRaid(items) {
|
|
2076
2110
|
return [...items].sort((a, b) => {
|
|
@@ -2112,7 +2146,7 @@ function heatGrid(items) {
|
|
|
2112
2146
|
function inHeatCell(item, probability, impact) {
|
|
2113
2147
|
return item.kind === "risk" && item.probability === probability && item.impact === impact;
|
|
2114
2148
|
}
|
|
2115
|
-
var KIND_LABEL, STATUS_LABEL$1, STATUSES$1, PROBABILITY_LABEL, IMPACT_LABEL, FIELD_COPY;
|
|
2149
|
+
var KIND_LABEL, STATUS_LABEL$1, STATUSES$1, PROBABILITY_LABEL, IMPACT_LABEL, FIELD_COPY, ids$1;
|
|
2116
2150
|
var init_raid = __esmMin((() => {
|
|
2117
2151
|
KIND_LABEL = {
|
|
2118
2152
|
risk: "Risk",
|
|
@@ -2182,6 +2216,7 @@ var init_raid = __esmMin((() => {
|
|
|
2182
2216
|
dueDate: "Decide by"
|
|
2183
2217
|
}
|
|
2184
2218
|
};
|
|
2219
|
+
ids$1 = (v) => Array.isArray(v) ? [...new Set(v.filter((x) => typeof x === "string" && !!x))] : [];
|
|
2185
2220
|
}));
|
|
2186
2221
|
|
|
2187
2222
|
//#endregion
|
|
@@ -5435,6 +5470,85 @@ function freshlyOpened(data, projects, now) {
|
|
|
5435
5470
|
return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
|
|
5436
5471
|
}
|
|
5437
5472
|
|
|
5473
|
+
//#endregion
|
|
5474
|
+
//#region src/lib/decisions.ts
|
|
5475
|
+
/** A risk counts as come true when someone said so, or when it was turned into an issue. */
|
|
5476
|
+
function cameTrue(risk) {
|
|
5477
|
+
return risk.happened === true || risk.kind === "issue";
|
|
5478
|
+
}
|
|
5479
|
+
/** "the risk “Vendor misses window” comes true", for "Revisit if …". */
|
|
5480
|
+
function revisitText(revisit, data) {
|
|
5481
|
+
if (revisit.kind === "date") return `it's ${md$1(revisit.on)}`;
|
|
5482
|
+
if (revisit.kind === "risk") {
|
|
5483
|
+
const risk = byId(data.raidItems).get(revisit.riskId);
|
|
5484
|
+
return risk ? `“${risk.title}” comes true` : "a risk that's since been removed comes true";
|
|
5485
|
+
}
|
|
5486
|
+
const m = byId(data.milestones).get(revisit.milestoneId);
|
|
5487
|
+
return m ? `“${m.name}” slips past ${md$1(revisit.due)}` : "a milestone that's since been removed slips";
|
|
5488
|
+
}
|
|
5489
|
+
/** Why a decided decision is due another look today, in a sentence, or null while it isn't. */
|
|
5490
|
+
function revisitReason(item, data, today) {
|
|
5491
|
+
const revisit = item.revisitIf;
|
|
5492
|
+
if (item.kind !== "decision" || item.status !== "decided" || !revisit) return null;
|
|
5493
|
+
if (revisit.kind === "date") return today >= revisit.on ? `${md$1(revisit.on)} came, the day set to look again.` : null;
|
|
5494
|
+
if (revisit.kind === "risk") {
|
|
5495
|
+
const risk = byId(data.raidItems).get(revisit.riskId);
|
|
5496
|
+
return risk && cameTrue(risk) ? `The risk “${risk.title}” came true.` : null;
|
|
5497
|
+
}
|
|
5498
|
+
const m = byId(data.milestones).get(revisit.milestoneId);
|
|
5499
|
+
if (!m || m.done) return null;
|
|
5500
|
+
const due = m.dueDate.slice(0, 10);
|
|
5501
|
+
if (due > revisit.due) return `“${m.name}” moved from ${md$1(revisit.due)} to ${md$1(due)}.`;
|
|
5502
|
+
if (due < today) return `“${m.name}” is past its date, ${md$1(due)}.`;
|
|
5503
|
+
return null;
|
|
5504
|
+
}
|
|
5505
|
+
/** The decision that replaced this one, if any. */
|
|
5506
|
+
function supersededBy(data, id) {
|
|
5507
|
+
return data.raidItems.find((r) => r.kind === "decision" && r.supersedesId === id) ?? null;
|
|
5508
|
+
}
|
|
5509
|
+
/** Decided decisions in scope whose "revisit if" has happened, oldest decision first. */
|
|
5510
|
+
function decisionsToRevisit(data, inScope, today) {
|
|
5511
|
+
const out = [];
|
|
5512
|
+
for (const item of data.raidItems) {
|
|
5513
|
+
if (item.kind !== "decision" || !item.revisitIf || !inScope(item)) continue;
|
|
5514
|
+
const reason = revisitReason(item, data, today);
|
|
5515
|
+
if (!reason) continue;
|
|
5516
|
+
const where = item.projectId && byId(data.projects).get(item.projectId)?.name || item.programId && byId(data.programs).get(item.programId)?.name || item.portfolioId && byId(data.portfolios).get(item.portfolioId)?.name || null;
|
|
5517
|
+
out.push({
|
|
5518
|
+
item,
|
|
5519
|
+
reason,
|
|
5520
|
+
where
|
|
5521
|
+
});
|
|
5522
|
+
}
|
|
5523
|
+
return out.sort((a, b) => (a.item.decidedAt ?? a.item.createdAt).localeCompare(b.item.decidedAt ?? b.item.createdAt));
|
|
5524
|
+
}
|
|
5525
|
+
/** The comment a "still stands" leaves, so the thread says it was looked at. */
|
|
5526
|
+
function stillStandsNote(reason) {
|
|
5527
|
+
return reason ? `Looked at again (${reason.replace(/\.$/, "")}): it still stands.` : "Looked at again: it still stands.";
|
|
5528
|
+
}
|
|
5529
|
+
/** Fields a new decision copies from the one it replaces: where it sits, what it touches. */
|
|
5530
|
+
function successorOf(old) {
|
|
5531
|
+
return {
|
|
5532
|
+
kind: "decision",
|
|
5533
|
+
title: old.title,
|
|
5534
|
+
description: old.description,
|
|
5535
|
+
portfolioId: old.portfolioId ?? null,
|
|
5536
|
+
programId: old.programId ?? null,
|
|
5537
|
+
projectId: old.projectId ?? null,
|
|
5538
|
+
ownerId: old.ownerId,
|
|
5539
|
+
taskIds: old.taskIds,
|
|
5540
|
+
riskIds: old.riskIds,
|
|
5541
|
+
supersedesId: old.id,
|
|
5542
|
+
status: "proposed"
|
|
5543
|
+
};
|
|
5544
|
+
}
|
|
5545
|
+
var md$1;
|
|
5546
|
+
var init_decisions = __esmMin((() => {
|
|
5547
|
+
init_indexes();
|
|
5548
|
+
init_date_text();
|
|
5549
|
+
md$1 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
|
|
5550
|
+
}));
|
|
5551
|
+
|
|
5438
5552
|
//#endregion
|
|
5439
5553
|
//#region src/lib/brief.ts
|
|
5440
5554
|
init_date_fns();
|
|
@@ -5445,6 +5559,7 @@ init_work();
|
|
|
5445
5559
|
init_indexes();
|
|
5446
5560
|
init_blockers();
|
|
5447
5561
|
init_clock();
|
|
5562
|
+
init_decisions();
|
|
5448
5563
|
const BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
|
|
5449
5564
|
/** Late tasks worst first, each with what it holds up, so the letter says what to chase. */
|
|
5450
5565
|
function lateLinesOf(data, tasks, now) {
|
|
@@ -5503,14 +5618,18 @@ function isHighRisk(r) {
|
|
|
5503
5618
|
function isMajorIssue(r) {
|
|
5504
5619
|
return r.kind === "issue" && (r.impact != null ? r.impact >= 4 : r.severity === "high");
|
|
5505
5620
|
}
|
|
5506
|
-
/**
|
|
5507
|
-
function
|
|
5508
|
-
|
|
5621
|
+
/** Whether a register item sits inside the brief's portfolio (or anywhere, for all of them). */
|
|
5622
|
+
function inBriefScope(scope) {
|
|
5623
|
+
return (r) => {
|
|
5509
5624
|
if (r.projectId) return scope.projectIds.has(r.projectId);
|
|
5510
5625
|
if (r.programId) return scope.programIds.has(r.programId);
|
|
5511
5626
|
if (r.portfolioId) return scope.portfolioId == null || r.portfolioId === scope.portfolioId;
|
|
5512
5627
|
return scope.portfolioId == null;
|
|
5513
5628
|
};
|
|
5629
|
+
}
|
|
5630
|
+
/** The risks and issues worth a line in the brief: live, and either a high-scored risk or a major issue. */
|
|
5631
|
+
function watchListOf(data, scope, limit = 5) {
|
|
5632
|
+
const inScope = inBriefScope(scope);
|
|
5514
5633
|
const live = data.raidItems.filter((r) => r.kind !== "decision" && isActive(r) && inScope(r) && (r.kind === "risk" && isHighRisk(r) || isMajorIssue(r)));
|
|
5515
5634
|
return sortRaid(live).slice(0, limit).map((item) => ({
|
|
5516
5635
|
item,
|
|
@@ -5546,6 +5665,11 @@ function composeBriefBody(opts) {
|
|
|
5546
5665
|
}
|
|
5547
5666
|
lines.push("");
|
|
5548
5667
|
}
|
|
5668
|
+
if (opts.revisit?.length) {
|
|
5669
|
+
lines.push("Decisions to look at again");
|
|
5670
|
+
for (const d of opts.revisit) lines.push(`• ${d.title}${d.where ? ` (${d.where})` : ""}: ${d.reason}`);
|
|
5671
|
+
lines.push("");
|
|
5672
|
+
}
|
|
5549
5673
|
const done = () => {
|
|
5550
5674
|
if (!opts.shipped?.length) return;
|
|
5551
5675
|
lines.push("Done this week");
|
|
@@ -5790,6 +5914,11 @@ function briefOf(data, opts = {}) {
|
|
|
5790
5914
|
said: saidThisWeek(data, now, portfolioId ? new Set(tasks.flatMap((t) => t.assigneeId ? [t.assigneeId] : [])) : null),
|
|
5791
5915
|
late: lateLinesOf(data, tasks, now),
|
|
5792
5916
|
opened: freshlyOpened(data, projects, now),
|
|
5917
|
+
revisit: decisionsToRevisit(data, inBriefScope({
|
|
5918
|
+
programIds,
|
|
5919
|
+
projectIds,
|
|
5920
|
+
portfolioId
|
|
5921
|
+
}), todayISO(now)),
|
|
5793
5922
|
talk,
|
|
5794
5923
|
milestonesSoon,
|
|
5795
5924
|
milestonesLate,
|
|
@@ -5841,6 +5970,11 @@ function composeBriefBodyFrom(brief, data, workspace, all = false) {
|
|
|
5841
5970
|
project: b.project.name,
|
|
5842
5971
|
who: data.people.find((p) => p.id === b.note.authorId)?.name ?? null,
|
|
5843
5972
|
body: b.note.body
|
|
5973
|
+
})),
|
|
5974
|
+
revisit: brief.revisit.map((d) => ({
|
|
5975
|
+
title: d.item.title,
|
|
5976
|
+
reason: d.reason,
|
|
5977
|
+
where: d.where
|
|
5844
5978
|
}))
|
|
5845
5979
|
});
|
|
5846
5980
|
}
|
|
@@ -7685,6 +7819,7 @@ init_baseline();
|
|
|
7685
7819
|
init_derived_health();
|
|
7686
7820
|
init_date_text();
|
|
7687
7821
|
init_format();
|
|
7822
|
+
init_decisions();
|
|
7688
7823
|
init_swatches();
|
|
7689
7824
|
init_raid();
|
|
7690
7825
|
/** The people of this harbor to pick from, with "Nobody" first when that's allowed. */
|
|
@@ -8511,30 +8646,54 @@ function RaidPanel({ id, width, height, go, active, onClose }) {
|
|
|
8511
8646
|
label: "Score",
|
|
8512
8647
|
value: score != null ? String(score) : ""
|
|
8513
8648
|
}] : [],
|
|
8514
|
-
...item.kind === "decision" ? [
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
|
|
8522
|
-
|
|
8523
|
-
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8649
|
+
...item.kind === "decision" ? [
|
|
8650
|
+
{
|
|
8651
|
+
label: "Decided by",
|
|
8652
|
+
value: person(item.decidedById) ?? "Not yet",
|
|
8653
|
+
edit: {
|
|
8654
|
+
kind: "pick",
|
|
8655
|
+
choices: [{
|
|
8656
|
+
label: "Not yet",
|
|
8657
|
+
value: null
|
|
8658
|
+
}, ...people.map((p) => ({
|
|
8659
|
+
label: p.name,
|
|
8660
|
+
value: p.id
|
|
8661
|
+
}))],
|
|
8662
|
+
initial: Math.max(0, people.findIndex((p) => p.id === item.decidedById) + 1),
|
|
8663
|
+
onPick: (v) => up({ decidedById: v })
|
|
8664
|
+
}
|
|
8665
|
+
},
|
|
8666
|
+
{
|
|
8667
|
+
label: "Decided on",
|
|
8668
|
+
value: item.decidedAt ? shortDay(item.decidedAt, today()) : "Not yet",
|
|
8669
|
+
edit: {
|
|
8670
|
+
kind: "date",
|
|
8671
|
+
initial: item.decidedAt ?? null,
|
|
8672
|
+
onSave: (v) => up({ decidedAt: v })
|
|
8673
|
+
}
|
|
8674
|
+
},
|
|
8675
|
+
{
|
|
8676
|
+
label: "Turned down",
|
|
8677
|
+
value: (item.turnedDown ?? "").replace(/\s+/g, " "),
|
|
8678
|
+
edit: {
|
|
8679
|
+
kind: "text",
|
|
8680
|
+
initial: item.turnedDown ?? "",
|
|
8681
|
+
onSave: (v) => up({ turnedDown: v })
|
|
8682
|
+
}
|
|
8683
|
+
},
|
|
8684
|
+
{
|
|
8685
|
+
label: "Look again",
|
|
8686
|
+
value: item.revisitIf ? revisitText(item.revisitIf, store.getState()) : "",
|
|
8687
|
+
edit: {
|
|
8688
|
+
kind: "date",
|
|
8689
|
+
initial: item.revisitIf?.kind === "date" ? item.revisitIf.on : null,
|
|
8690
|
+
onSave: (v) => up({ revisitIf: v ? {
|
|
8691
|
+
kind: "date",
|
|
8692
|
+
on: v
|
|
8693
|
+
} : null })
|
|
8694
|
+
}
|
|
8536
8695
|
}
|
|
8537
|
-
|
|
8696
|
+
] : [],
|
|
8538
8697
|
{
|
|
8539
8698
|
label: copy.dueDate,
|
|
8540
8699
|
value: shortDay(item.dueDate, today()),
|
|
@@ -10539,12 +10698,103 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
10539
10698
|
});
|
|
10540
10699
|
}
|
|
10541
10700
|
|
|
10701
|
+
//#endregion
|
|
10702
|
+
//#region src/lib/fog.ts
|
|
10703
|
+
function latestBy(cache, items, key, at) {
|
|
10704
|
+
const hit = cache.get(items);
|
|
10705
|
+
if (hit && hit.size === items.length) return hit.value;
|
|
10706
|
+
const value = /* @__PURE__ */ new Map();
|
|
10707
|
+
for (const item of items) {
|
|
10708
|
+
const when = at(item);
|
|
10709
|
+
if (!when) continue;
|
|
10710
|
+
const k = key(item);
|
|
10711
|
+
const had = value.get(k);
|
|
10712
|
+
if (!had || when > had) value.set(k, when);
|
|
10713
|
+
}
|
|
10714
|
+
cache.set(items, {
|
|
10715
|
+
size: items.length,
|
|
10716
|
+
value
|
|
10717
|
+
});
|
|
10718
|
+
return value;
|
|
10719
|
+
}
|
|
10720
|
+
/** Each task's newest comment, built once per comments list. */
|
|
10721
|
+
function latestComments(comments) {
|
|
10722
|
+
return latestBy(commentCache, comments, (c) => c.taskId, (c) => c.editedAt && c.editedAt > c.createdAt ? c.editedAt : c.createdAt);
|
|
10723
|
+
}
|
|
10724
|
+
function latestFiles(files) {
|
|
10725
|
+
return latestBy(fileCache, files, (a) => a.taskId, (a) => a.addedAt);
|
|
10726
|
+
}
|
|
10727
|
+
/** When anyone last did anything with this task (an ISO time). */
|
|
10728
|
+
function lastTouch(task, data) {
|
|
10729
|
+
let last = task.createdAt;
|
|
10730
|
+
const bump = (at) => {
|
|
10731
|
+
if (at && at > last) last = at;
|
|
10732
|
+
};
|
|
10733
|
+
bump(task.touchedAt);
|
|
10734
|
+
bump(task.assignedAt);
|
|
10735
|
+
bump(task.completedAt);
|
|
10736
|
+
bump(task.github?.[0]?.at);
|
|
10737
|
+
bump(latestComments(data.comments).get(task.id));
|
|
10738
|
+
bump(latestFiles(data.attachments ?? []).get(task.id));
|
|
10739
|
+
return last;
|
|
10740
|
+
}
|
|
10741
|
+
/** Whole days since the task was last touched, as of `today` (yyyy-MM-dd). */
|
|
10742
|
+
function daysUntouched(task, data, today) {
|
|
10743
|
+
const last = parseIso(lastTouch(task, data));
|
|
10744
|
+
if (Number.isNaN(last.getTime())) return 0;
|
|
10745
|
+
return Math.max(0, differenceInCalendarDays(parseIso(today), last));
|
|
10746
|
+
}
|
|
10747
|
+
function fogLevel(days) {
|
|
10748
|
+
if (days >= 35) return "thick";
|
|
10749
|
+
if (days >= 21) return "fog";
|
|
10750
|
+
return "clear";
|
|
10751
|
+
}
|
|
10752
|
+
/** "untouched 5 weeks", "untouched 3 weeks". */
|
|
10753
|
+
function untouchedText(days) {
|
|
10754
|
+
const weeks = Math.floor(days / 7);
|
|
10755
|
+
if (weeks >= 1) return `untouched ${weeks} week${weeks === 1 ? "" : "s"}`;
|
|
10756
|
+
return `untouched ${days} day${days === 1 ? "" : "s"}`;
|
|
10757
|
+
}
|
|
10758
|
+
/**
|
|
10759
|
+
* The open tasks in fog, longest untouched first. `projectIds` narrows it
|
|
10760
|
+
* to some projects; leave it out for the whole harbor.
|
|
10761
|
+
*/
|
|
10762
|
+
function foggyTasks(data, today, projectIds) {
|
|
10763
|
+
const out = [];
|
|
10764
|
+
for (const task of data.tasks) {
|
|
10765
|
+
if (task.status === "done") continue;
|
|
10766
|
+
if (projectIds && !projectIds.has(task.projectId)) continue;
|
|
10767
|
+
const days = daysUntouched(task, data, today);
|
|
10768
|
+
const level = fogLevel(days);
|
|
10769
|
+
if (level !== "clear") out.push({
|
|
10770
|
+
task,
|
|
10771
|
+
days,
|
|
10772
|
+
level
|
|
10773
|
+
});
|
|
10774
|
+
}
|
|
10775
|
+
return out.sort((a, b) => b.days - a.days);
|
|
10776
|
+
}
|
|
10777
|
+
/** Whether opening this task should count as touching it: only when it is in fog. */
|
|
10778
|
+
function isFoggy(task, data, today) {
|
|
10779
|
+
return task.status !== "done" && daysUntouched(task, data, today) >= 21;
|
|
10780
|
+
}
|
|
10781
|
+
var FOG_DAYS, THICK_FOG_DAYS, commentCache, fileCache;
|
|
10782
|
+
var init_fog = __esmMin((() => {
|
|
10783
|
+
init_date_fns();
|
|
10784
|
+
init_date_text();
|
|
10785
|
+
FOG_DAYS = 21;
|
|
10786
|
+
THICK_FOG_DAYS = 35;
|
|
10787
|
+
commentCache = /* @__PURE__ */ new WeakMap();
|
|
10788
|
+
fileCache = /* @__PURE__ */ new WeakMap();
|
|
10789
|
+
}));
|
|
10790
|
+
|
|
10542
10791
|
//#endregion
|
|
10543
10792
|
//#region src/tui/views/project.tsx
|
|
10544
10793
|
init_swatches();
|
|
10545
10794
|
init_format();
|
|
10546
10795
|
init_hill();
|
|
10547
10796
|
init_clock();
|
|
10797
|
+
init_fog();
|
|
10548
10798
|
/** One project: its board (columns side by side) or its list, and the keys to work it. */
|
|
10549
10799
|
function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
10550
10800
|
const { store, today } = useDeps();
|
|
@@ -10555,6 +10805,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
10555
10805
|
const reflections = useHarbor((s) => s.reflections);
|
|
10556
10806
|
const milestones = useHarbor((s) => s.milestones);
|
|
10557
10807
|
const raidItems = useHarbor((s) => s.raidItems);
|
|
10808
|
+
const comments = useHarbor((s) => s.comments);
|
|
10809
|
+
const attachments = useHarbor((s) => s.attachments);
|
|
10558
10810
|
const project = projects.find((p) => p.id === projectId);
|
|
10559
10811
|
const data = useMemo(() => ({
|
|
10560
10812
|
tasks,
|
|
@@ -10589,6 +10841,14 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
10589
10841
|
const [rowByCol, setRowByCol] = useState({});
|
|
10590
10842
|
const [listAt, setListAt] = useState(0);
|
|
10591
10843
|
const day = today();
|
|
10844
|
+
const fogWeeks = (t) => {
|
|
10845
|
+
if (t.status === "done") return null;
|
|
10846
|
+
const days = daysUntouched(t, {
|
|
10847
|
+
comments,
|
|
10848
|
+
attachments
|
|
10849
|
+
}, day);
|
|
10850
|
+
return days >= 21 ? Math.floor(days / 7) : null;
|
|
10851
|
+
};
|
|
10592
10852
|
const column = columns[Math.min(col, columns.length - 1)];
|
|
10593
10853
|
const row = Math.min(rowByCol[col] ?? 0, Math.max(0, (column?.tasks.length ?? 1) - 1));
|
|
10594
10854
|
const selected = mode === "board" ? column?.tasks[row] : list[Math.min(listAt, list.length - 1)];
|
|
@@ -10883,7 +11143,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
10883
11143
|
late: isLate(t, day),
|
|
10884
11144
|
waits: blocked(data, t),
|
|
10885
11145
|
priority: t.priority,
|
|
10886
|
-
selected: on && view.start + i === r
|
|
11146
|
+
selected: on && view.start + i === r,
|
|
11147
|
+
fog: fogWeeks(t)
|
|
10887
11148
|
}, t.id)),
|
|
10888
11149
|
view.start + view.items.length < c.tasks.length ? /* @__PURE__ */ jsxs(Text, {
|
|
10889
11150
|
color: theme.muted,
|
|
@@ -10924,7 +11185,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
10924
11185
|
});
|
|
10925
11186
|
}
|
|
10926
11187
|
/** One card on the board: its key, due date and owner, then the title on up to two lines. */
|
|
10927
|
-
function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
|
|
11188
|
+
function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog = null }) {
|
|
10928
11189
|
return /* @__PURE__ */ jsxs(Box, {
|
|
10929
11190
|
flexDirection: "column",
|
|
10930
11191
|
height: 3,
|
|
@@ -10950,6 +11211,14 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
|
|
|
10950
11211
|
owner ? /* @__PURE__ */ jsxs(Text, {
|
|
10951
11212
|
color: selected ? void 0 : theme.muted,
|
|
10952
11213
|
children: [" ", owner]
|
|
11214
|
+
}) : null,
|
|
11215
|
+
fog != null ? /* @__PURE__ */ jsxs(Text, {
|
|
11216
|
+
color: selected ? void 0 : theme.muted,
|
|
11217
|
+
children: [
|
|
11218
|
+
" ░ ",
|
|
11219
|
+
fog,
|
|
11220
|
+
"w"
|
|
11221
|
+
]
|
|
10953
11222
|
}) : null
|
|
10954
11223
|
]
|
|
10955
11224
|
}), /* @__PURE__ */ jsx(Box, {
|
|
@@ -10957,7 +11226,8 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
|
|
|
10957
11226
|
children: /* @__PURE__ */ jsx(Text, {
|
|
10958
11227
|
wrap: "wrap",
|
|
10959
11228
|
bold: selected,
|
|
10960
|
-
|
|
11229
|
+
dimColor: fog != null && !selected,
|
|
11230
|
+
color: selected ? theme.selected : fog != null ? theme.muted : void 0,
|
|
10961
11231
|
children: title
|
|
10962
11232
|
})
|
|
10963
11233
|
})]
|
|
@@ -12456,6 +12726,7 @@ function App({ ui }) {
|
|
|
12456
12726
|
};
|
|
12457
12727
|
const go = {
|
|
12458
12728
|
openTask: (id) => {
|
|
12729
|
+
store.getState().selectTask(id);
|
|
12459
12730
|
setPanel({
|
|
12460
12731
|
kind: "task",
|
|
12461
12732
|
id
|
|
@@ -13109,16 +13380,6 @@ function fieldChange(prev, row) {
|
|
|
13109
13380
|
was
|
|
13110
13381
|
};
|
|
13111
13382
|
}
|
|
13112
|
-
/**
|
|
13113
|
-
* Apply changes to the rows stored now. A set writes only its own fields,
|
|
13114
|
-
* so it keeps whatever someone else changed in the others. `clashIds` names
|
|
13115
|
-
* rows where someone else had already changed one of the same fields or had
|
|
13116
|
-
* removed the row (the change is dropped); `clashes` says which field and
|
|
13117
|
-
* what each side had. By default this change wins a clashed field, as the
|
|
13118
|
-
* later save; with `keepTheirs` the stored value stays (undo uses this, so
|
|
13119
|
-
* it never takes back someone else's newer edit). Rows nothing touched keep
|
|
13120
|
-
* their object.
|
|
13121
|
-
*/
|
|
13122
13383
|
function applyRowChanges(rows, changes, options = {}) {
|
|
13123
13384
|
if (changes.length === 0) return {
|
|
13124
13385
|
rows: rows.slice(),
|
|
@@ -13150,6 +13411,11 @@ function applyRowChanges(rows, changes, options = {}) {
|
|
|
13150
13411
|
const fields = {};
|
|
13151
13412
|
let clashed = false;
|
|
13152
13413
|
for (const [key, value] of Object.entries(change.fields)) {
|
|
13414
|
+
if (LATEST_WINS.has(key)) {
|
|
13415
|
+
const had = stored[key];
|
|
13416
|
+
if (typeof had !== "string" || typeof value === "string" && value > had) fields[key] = value;
|
|
13417
|
+
continue;
|
|
13418
|
+
}
|
|
13153
13419
|
if (!deepEqual(stored[key], was[key]) && !deepEqual(stored[key], value)) {
|
|
13154
13420
|
clashed = true;
|
|
13155
13421
|
clashes.push({
|
|
@@ -13180,8 +13446,10 @@ function applyRowChanges(rows, changes, options = {}) {
|
|
|
13180
13446
|
clashes
|
|
13181
13447
|
};
|
|
13182
13448
|
}
|
|
13449
|
+
var LATEST_WINS;
|
|
13183
13450
|
var init_row_changes = __esmMin((() => {
|
|
13184
13451
|
init_deep_equal();
|
|
13452
|
+
LATEST_WINS = /* @__PURE__ */ new Set(["touchedAt"]);
|
|
13185
13453
|
}));
|
|
13186
13454
|
|
|
13187
13455
|
//#endregion
|
|
@@ -13387,7 +13655,8 @@ function withStatus(task, status) {
|
|
|
13387
13655
|
if (task.status === status) return task;
|
|
13388
13656
|
const next = {
|
|
13389
13657
|
...task,
|
|
13390
|
-
status
|
|
13658
|
+
status,
|
|
13659
|
+
touchedAt: stamp()
|
|
13391
13660
|
};
|
|
13392
13661
|
if (status === "done") next.completedAt = stamp();
|
|
13393
13662
|
else if (task.status === "done") next.completedAt = null;
|
|
@@ -13500,6 +13769,14 @@ function sessionSlice({ set, get, write }) {
|
|
|
13500
13769
|
selectTask: (id) => {
|
|
13501
13770
|
if (get().selectedTaskId === id) return;
|
|
13502
13771
|
set({ selectedTaskId: id });
|
|
13772
|
+
const task = id ? get().tasks.find((t) => t.id === id) : void 0;
|
|
13773
|
+
if (task && !readOnlyHarbor(get()) && isFoggy(task, get(), todayISO())) {
|
|
13774
|
+
const at = stamp();
|
|
13775
|
+
set({ tasks: get().tasks.map((t) => t.id === id ? {
|
|
13776
|
+
...t,
|
|
13777
|
+
touchedAt: at
|
|
13778
|
+
} : t) });
|
|
13779
|
+
}
|
|
13503
13780
|
},
|
|
13504
13781
|
log: (text, href) => {
|
|
13505
13782
|
if (readOnlyHarbor(get())) return;
|
|
@@ -13552,6 +13829,8 @@ var init_session = __esmMin((() => {
|
|
|
13552
13829
|
init_shared();
|
|
13553
13830
|
init_undo_state();
|
|
13554
13831
|
init_view_only();
|
|
13832
|
+
init_fog();
|
|
13833
|
+
init_format();
|
|
13555
13834
|
}));
|
|
13556
13835
|
|
|
13557
13836
|
//#endregion
|
|
@@ -14327,7 +14606,8 @@ function tasksSlice({ set, get, write }) {
|
|
|
14327
14606
|
if (row.id !== id) return row;
|
|
14328
14607
|
const next = {
|
|
14329
14608
|
...row,
|
|
14330
|
-
...patch
|
|
14609
|
+
...patch,
|
|
14610
|
+
touchedAt: stamp()
|
|
14331
14611
|
};
|
|
14332
14612
|
if (patch.status && patch.status !== row.status) {
|
|
14333
14613
|
if (patch.status === "done" && !patch.completedAt) next.completedAt = stamp();
|
|
@@ -14478,6 +14758,7 @@ function tasksSlice({ set, get, write }) {
|
|
|
14478
14758
|
if (!name) return;
|
|
14479
14759
|
set({ tasks: get().tasks.map((t) => t.id === taskId ? {
|
|
14480
14760
|
...t,
|
|
14761
|
+
touchedAt: stamp(),
|
|
14481
14762
|
checklist: [...t.checklist ?? [], {
|
|
14482
14763
|
id: uid("ch"),
|
|
14483
14764
|
title: name,
|
|
@@ -14488,6 +14769,7 @@ function tasksSlice({ set, get, write }) {
|
|
|
14488
14769
|
toggleChecklistItem: (taskId, itemId) => {
|
|
14489
14770
|
set({ tasks: get().tasks.map((t) => t.id === taskId ? {
|
|
14490
14771
|
...t,
|
|
14772
|
+
touchedAt: stamp(),
|
|
14491
14773
|
checklist: (t.checklist ?? []).map((i) => i.id === itemId ? {
|
|
14492
14774
|
...i,
|
|
14493
14775
|
done: !i.done
|
|
@@ -14497,6 +14779,7 @@ function tasksSlice({ set, get, write }) {
|
|
|
14497
14779
|
removeChecklistItem: (taskId, itemId) => {
|
|
14498
14780
|
set({ tasks: get().tasks.map((t) => t.id === taskId ? {
|
|
14499
14781
|
...t,
|
|
14782
|
+
touchedAt: stamp(),
|
|
14500
14783
|
checklist: (t.checklist ?? []).filter((i) => i.id !== itemId)
|
|
14501
14784
|
} : t) });
|
|
14502
14785
|
},
|
|
@@ -14521,6 +14804,7 @@ function tasksSlice({ set, get, write }) {
|
|
|
14521
14804
|
setTaskCustomFieldValue: (taskId, fieldId, value) => {
|
|
14522
14805
|
write({ tasks: get().tasks.map((t) => t.id === taskId ? {
|
|
14523
14806
|
...t,
|
|
14807
|
+
touchedAt: stamp(),
|
|
14524
14808
|
customFieldValues: {
|
|
14525
14809
|
...t.customFieldValues ?? {},
|
|
14526
14810
|
[fieldId]: value
|
|
@@ -14552,6 +14836,17 @@ function tasksSlice({ set, get, write }) {
|
|
|
14552
14836
|
write({ tasks: get().tasks.map((t) => tick.has(t.id) && t.status !== "done" ? withStatus(t, "done") : t) });
|
|
14553
14837
|
for (const t of ticked) get().log(`ticked off “${t.title}” on First voyage`, `/projects/${t.projectId}`);
|
|
14554
14838
|
},
|
|
14839
|
+
keepTasks: (ids) => {
|
|
14840
|
+
const keep = new Set(ids);
|
|
14841
|
+
const kept = get().tasks.filter((t) => keep.has(t.id));
|
|
14842
|
+
if (kept.length === 0) return;
|
|
14843
|
+
const at = stamp();
|
|
14844
|
+
write({ tasks: get().tasks.map((t) => keep.has(t.id) ? {
|
|
14845
|
+
...t,
|
|
14846
|
+
touchedAt: at
|
|
14847
|
+
} : t) });
|
|
14848
|
+
get().log(kept.length === 1 ? `kept “${kept[0].title}” out of the fog` : `kept ${kept.length} tasks out of the fog`, kept.length === 1 ? `/projects/${kept[0].projectId}` : void 0);
|
|
14849
|
+
},
|
|
14555
14850
|
toggleFocus: (taskId) => {
|
|
14556
14851
|
const personId = get().currentUserId;
|
|
14557
14852
|
const date = todayISO();
|
|
@@ -14721,6 +15016,42 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
14721
15016
|
updatedAt: stamp()
|
|
14722
15017
|
} : r) });
|
|
14723
15018
|
},
|
|
15019
|
+
supersedeDecision: (id) => {
|
|
15020
|
+
const old = get().raidItems.find((r) => r.id === id && r.kind === "decision");
|
|
15021
|
+
if (!old) return null;
|
|
15022
|
+
const now = stamp();
|
|
15023
|
+
const row = normalizeRaidItem({
|
|
15024
|
+
...successorOf(old),
|
|
15025
|
+
id: uid("raid"),
|
|
15026
|
+
createdById: get().currentUserId,
|
|
15027
|
+
createdAt: now,
|
|
15028
|
+
updatedAt: now
|
|
15029
|
+
});
|
|
15030
|
+
write({ raidItems: [...get().raidItems.map((r) => r.id === id ? {
|
|
15031
|
+
...r,
|
|
15032
|
+
status: "superseded",
|
|
15033
|
+
updatedAt: now
|
|
15034
|
+
} : r), row] });
|
|
15035
|
+
get().log(`changed a decision — ${old.title}`);
|
|
15036
|
+
return row.id;
|
|
15037
|
+
},
|
|
15038
|
+
decisionStillStands: (id, reason) => {
|
|
15039
|
+
const item = get().raidItems.find((r) => r.id === id);
|
|
15040
|
+
if (!item || item.kind !== "decision") return;
|
|
15041
|
+
const now = stamp();
|
|
15042
|
+
const update = {
|
|
15043
|
+
id: uid("ru"),
|
|
15044
|
+
authorId: get().currentUserId,
|
|
15045
|
+
body: stillStandsNote(reason ?? null),
|
|
15046
|
+
createdAt: now
|
|
15047
|
+
};
|
|
15048
|
+
write({ raidItems: get().raidItems.map((r) => r.id === id ? normalizeRaidItem({
|
|
15049
|
+
...r,
|
|
15050
|
+
revisitIf: null,
|
|
15051
|
+
updates: [...r.updates, update],
|
|
15052
|
+
updatedAt: now
|
|
15053
|
+
}) : r) });
|
|
15054
|
+
},
|
|
14724
15055
|
startPremortem: (projectId) => {
|
|
14725
15056
|
const project = get().projects.find((p) => p.id === projectId);
|
|
14726
15057
|
if (!project || project.premortem) return;
|
|
@@ -14864,6 +15195,7 @@ var init_milestones_raid = __esmMin((() => {
|
|
|
14864
15195
|
init_clock();
|
|
14865
15196
|
init_premortem();
|
|
14866
15197
|
init_raid();
|
|
15198
|
+
init_decisions();
|
|
14867
15199
|
init_raid_history();
|
|
14868
15200
|
init_utils();
|
|
14869
15201
|
init_shared();
|
|
@@ -16305,356 +16637,149 @@ async function pickHarbor(deps) {
|
|
|
16305
16637
|
}
|
|
16306
16638
|
|
|
16307
16639
|
//#endregion
|
|
16308
|
-
//#region src/
|
|
16309
|
-
|
|
16310
|
-
|
|
16311
|
-
|
|
16312
|
-
|
|
16313
|
-
|
|
16314
|
-
|
|
16315
|
-
|
|
16316
|
-
|
|
16317
|
-
|
|
16318
|
-
|
|
16319
|
-
|
|
16320
|
-
|
|
16321
|
-
|
|
16322
|
-
|
|
16640
|
+
//#region src/lib/command-line.ts
|
|
16641
|
+
init_date_fns();
|
|
16642
|
+
init_format();
|
|
16643
|
+
const PAGES = {
|
|
16644
|
+
today: "/today",
|
|
16645
|
+
inbox: "/inbox",
|
|
16646
|
+
brief: "/brief",
|
|
16647
|
+
review: "/review",
|
|
16648
|
+
portfolios: "/portfolios",
|
|
16649
|
+
programs: "/programs",
|
|
16650
|
+
projects: "/projects",
|
|
16651
|
+
risks: "/risks",
|
|
16652
|
+
calendar: "/calendar",
|
|
16653
|
+
people: "/people"
|
|
16654
|
+
};
|
|
16655
|
+
const HELP = [
|
|
16656
|
+
{ text: "add \"TITLE\" [#project] [@person] [--due fri|+3|2026-11-02]" },
|
|
16657
|
+
{ text: "mine what's on you, late first" },
|
|
16658
|
+
{ text: "tasks [words] [--late] [#project] [@person]" },
|
|
16659
|
+
{ text: "show RR-12 open a task" },
|
|
16660
|
+
{ text: "start | review | done RR-12" },
|
|
16661
|
+
{ text: "comment RR-12 \"TEXT\"" },
|
|
16662
|
+
{ text: "checkin \"WHAT YOU'RE ON\" [--on-blocker]" },
|
|
16663
|
+
{ text: `go ${Object.keys(PAGES).join("|")}` },
|
|
16664
|
+
{
|
|
16665
|
+
text: "undo · clear · help",
|
|
16666
|
+
tone: "muted"
|
|
16323
16667
|
}
|
|
16324
|
-
|
|
16325
|
-
|
|
16326
|
-
|
|
16327
|
-
|
|
16328
|
-
|
|
16329
|
-
|
|
16330
|
-
|
|
16331
|
-
|
|
16332
|
-
|
|
16333
|
-
|
|
16334
|
-
|
|
16335
|
-
|
|
16336
|
-
|
|
16337
|
-
|
|
16338
|
-
|
|
16339
|
-
|
|
16340
|
-
|
|
16341
|
-
|
|
16342
|
-
if (
|
|
16343
|
-
|
|
16344
|
-
|
|
16345
|
-
|
|
16668
|
+
];
|
|
16669
|
+
var CommandError = class extends Error {};
|
|
16670
|
+
const WEEKDAYS = [
|
|
16671
|
+
"sun",
|
|
16672
|
+
"mon",
|
|
16673
|
+
"tue",
|
|
16674
|
+
"wed",
|
|
16675
|
+
"thu",
|
|
16676
|
+
"fri",
|
|
16677
|
+
"sat"
|
|
16678
|
+
];
|
|
16679
|
+
/** today, tomorrow, +N, a weekday (the next one, never today) or YYYY-MM-DD. */
|
|
16680
|
+
function dueFrom(value, now) {
|
|
16681
|
+
const v = value.trim().toLowerCase();
|
|
16682
|
+
if (v === "today") return todayISO(now);
|
|
16683
|
+
if (v === "tomorrow") return todayISO(addDays(now, 1));
|
|
16684
|
+
if (/^\+\d{1,3}$/.test(v)) return todayISO(addDays(now, Number(v.slice(1))));
|
|
16685
|
+
const day = WEEKDAYS.findIndex((d) => v.startsWith(d) && d.length >= 3 && v.length >= 3);
|
|
16686
|
+
if (day >= 0) {
|
|
16687
|
+
const ahead = (day - now.getDay() + 7) % 7 || 7;
|
|
16688
|
+
return todayISO(addDays(now, ahead));
|
|
16689
|
+
}
|
|
16690
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(parseDate$1(v).getTime())) return v;
|
|
16691
|
+
throw new CommandError(`Can't read the date "${value}". Try today, fri, +3 or 2026-11-02.`);
|
|
16346
16692
|
}
|
|
16693
|
+
|
|
16694
|
+
//#endregion
|
|
16695
|
+
//#region src/tui/fog-sweep.ts
|
|
16347
16696
|
/**
|
|
16348
|
-
*
|
|
16349
|
-
*
|
|
16350
|
-
*
|
|
16697
|
+
* `harbor fog` in a terminal: the tasks nobody has touched for three weeks
|
|
16698
|
+
* (lib/fog.ts), one at a time, on the store's own actions. k keeps it
|
|
16699
|
+
* (clears the fog), d asks for a new date, x drops it, s skips, q stops.
|
|
16700
|
+
* The CLI saves through the sync loop afterwards.
|
|
16351
16701
|
*/
|
|
16352
|
-
|
|
16353
|
-
|
|
16354
|
-
|
|
16355
|
-
const
|
|
16356
|
-
|
|
16357
|
-
|
|
16358
|
-
|
|
16359
|
-
|
|
16360
|
-
|
|
16361
|
-
|
|
16362
|
-
|
|
16363
|
-
|
|
16364
|
-
|
|
16365
|
-
|
|
16366
|
-
|
|
16367
|
-
|
|
16368
|
-
|
|
16369
|
-
|
|
16370
|
-
|
|
16371
|
-
|
|
16372
|
-
|
|
16373
|
-
|
|
16702
|
+
init_fog();
|
|
16703
|
+
const plural$2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
16704
|
+
async function sweepFog(opts) {
|
|
16705
|
+
const { store, ask, say, today } = opts;
|
|
16706
|
+
const start = store.getState();
|
|
16707
|
+
const queue = foggyTasks(start, today, opts.projectId ? /* @__PURE__ */ new Set([opts.projectId]) : void 0);
|
|
16708
|
+
const tally = {
|
|
16709
|
+
foggy: queue.length,
|
|
16710
|
+
kept: 0,
|
|
16711
|
+
dated: 0,
|
|
16712
|
+
dropped: 0,
|
|
16713
|
+
skipped: 0
|
|
16714
|
+
};
|
|
16715
|
+
if (queue.length === 0) {
|
|
16716
|
+
say("No fog: every open task was touched in the last three weeks.");
|
|
16717
|
+
return tally;
|
|
16718
|
+
}
|
|
16719
|
+
say(`Clear the fog · ${plural$2(queue.length, "task")} nobody has opened, moved or commented on for three weeks.`);
|
|
16720
|
+
say("k still needed d new date x drop it s skip q stop\n");
|
|
16721
|
+
let stopped = false;
|
|
16722
|
+
for (let i = 0; i < queue.length && !stopped; i++) {
|
|
16723
|
+
const { task, days } = queue[i];
|
|
16724
|
+
const s = store.getState();
|
|
16725
|
+
if (!s.tasks.some((t) => t.id === task.id)) continue;
|
|
16726
|
+
const project = s.projects.find((p) => p.id === task.projectId);
|
|
16727
|
+
const where = [
|
|
16728
|
+
keyOf$1(s, task),
|
|
16729
|
+
opts.projectId ? null : project?.name,
|
|
16730
|
+
untouchedText(days),
|
|
16731
|
+
task.dueDate ? `due ${shortDay(task.dueDate, today)}` : null
|
|
16732
|
+
].filter(Boolean).join(" · ");
|
|
16733
|
+
say(`${i + 1} of ${queue.length} ${task.title}`);
|
|
16734
|
+
say(` ${where}`);
|
|
16735
|
+
for (;;) {
|
|
16736
|
+
const key = (await ask(" k, d, x, s or q: ")).trim().toLowerCase();
|
|
16737
|
+
if (key === "k") {
|
|
16738
|
+
store.getState().keepTasks([task.id]);
|
|
16739
|
+
tally.kept++;
|
|
16740
|
+
say(" Kept. The fog's off it.\n");
|
|
16741
|
+
} else if (key === "d") {
|
|
16742
|
+
let due = null;
|
|
16743
|
+
while (!due) {
|
|
16744
|
+
const typed = (await ask(" New due date (today, fri, +7, YYYY-MM-DD): ")).trim();
|
|
16745
|
+
if (!typed) break;
|
|
16746
|
+
try {
|
|
16747
|
+
due = dueFrom(typed, opts.now);
|
|
16748
|
+
} catch (err) {
|
|
16749
|
+
say(` ${err.message}`);
|
|
16750
|
+
}
|
|
16751
|
+
}
|
|
16752
|
+
if (!due) continue;
|
|
16753
|
+
store.getState().updateTask(task.id, { dueDate: due });
|
|
16754
|
+
tally.dated++;
|
|
16755
|
+
say(` Due ${shortDay(due, today)} now.\n`);
|
|
16756
|
+
} else if (key === "x") {
|
|
16757
|
+
store.getState().deleteTask(task.id);
|
|
16758
|
+
tally.dropped++;
|
|
16759
|
+
say(" Dropped.\n");
|
|
16760
|
+
} else if (key === "s" || key === "") {
|
|
16761
|
+
tally.skipped++;
|
|
16762
|
+
say(" Left for next time.\n");
|
|
16763
|
+
} else if (key === "q") stopped = true;
|
|
16764
|
+
else continue;
|
|
16765
|
+
break;
|
|
16374
16766
|
}
|
|
16375
|
-
const key = kv[1].toLowerCase();
|
|
16376
|
-
values.set(key, kv[2]);
|
|
16377
|
-
open = kv[2].trim() === "" ? key : null;
|
|
16378
|
-
if (open) lists.set(open, []);
|
|
16379
16767
|
}
|
|
16380
|
-
|
|
16381
|
-
|
|
16382
|
-
if (block && block.length) return block.join(", ");
|
|
16383
|
-
return unquote(values.get(k) ?? "");
|
|
16384
|
-
};
|
|
16385
|
-
const title = value("title");
|
|
16386
|
-
if (!title) return {
|
|
16387
|
-
fields: null,
|
|
16388
|
-
problems: [...problems, "it has no title"]
|
|
16389
|
-
};
|
|
16390
|
-
const statusText = value("status").toLowerCase();
|
|
16391
|
-
const status = statusText ? STATUS_WORDS[statusText] : "todo";
|
|
16392
|
-
if (!status) problems.push(`status "${statusText}" isn't one of backlog, todo, doing, review, done`);
|
|
16393
|
-
const priorityText = value("priority").toLowerCase();
|
|
16394
|
-
const priority = priorityText ? PRIORITIES.find((p) => p === priorityText) : "medium";
|
|
16395
|
-
if (!priority) problems.push(`priority "${priorityText}" isn't low, medium, high or urgent`);
|
|
16396
|
-
const due = value("due") || null;
|
|
16397
|
-
const dueOk = !due || /^\d{4}-\d{2}-\d{2}$/.test(due) && !Number.isNaN(Date.parse(due));
|
|
16398
|
-
if (!dueOk) problems.push(`due "${due}" isn't a date like 2026-11-07`);
|
|
16399
|
-
const repeatText = value("repeat").toLowerCase();
|
|
16400
|
-
const repeat = !repeatText || repeatText === "never" ? null : REPEATS.find((r) => r === repeatText) ?? null;
|
|
16401
|
-
if (repeatText && repeatText !== "never" && !repeat) problems.push(`repeat "${repeatText}" isn't weekly, biweekly or monthly`);
|
|
16402
|
-
const waits = (lists.get("waits")?.length ? lists.get("waits") : list$1(values.get("waits") ?? "")).map((k) => k.toUpperCase());
|
|
16403
|
-
return {
|
|
16404
|
-
fields: {
|
|
16405
|
-
title,
|
|
16406
|
-
status: status ?? "todo",
|
|
16407
|
-
owner: value("owner") || null,
|
|
16408
|
-
due: dueOk ? due : null,
|
|
16409
|
-
priority: priority ?? "medium",
|
|
16410
|
-
waits,
|
|
16411
|
-
repeat,
|
|
16412
|
-
notes: fm[2].trim()
|
|
16413
|
-
},
|
|
16414
|
-
problems
|
|
16415
|
-
};
|
|
16416
|
-
}
|
|
16417
|
-
/** The key a task goes by in this harbor, like RR-42. */
|
|
16418
|
-
function keyOf(data, task) {
|
|
16419
|
-
return taskKey(task, data.projects.find((p) => p.id === task.projectId));
|
|
16420
|
-
}
|
|
16421
|
-
/** What a task's file says, read off the harbor. */
|
|
16422
|
-
function fieldsOf(data, task) {
|
|
16423
|
-
const people = byId(data.people);
|
|
16424
|
-
const tasks = byId(data.tasks);
|
|
16425
|
-
const waitIds = task.blockedByIds ?? (task.blockedById ? [task.blockedById] : []);
|
|
16426
|
-
return {
|
|
16427
|
-
title: task.title,
|
|
16428
|
-
status: task.status,
|
|
16429
|
-
owner: task.assigneeId ? people.get(task.assigneeId)?.name ?? null : null,
|
|
16430
|
-
due: task.dueDate,
|
|
16431
|
-
priority: task.priority,
|
|
16432
|
-
waits: waitIds.map((id) => tasks.get(id)).map((t) => t ? keyOf(data, t) : null).filter((k) => Boolean(k)),
|
|
16433
|
-
repeat: task.repeat ?? null,
|
|
16434
|
-
notes: task.description.trim()
|
|
16435
|
-
};
|
|
16436
|
-
}
|
|
16437
|
-
/** The person a file's owner names: their whole name, else a first name or initials only they have. */
|
|
16438
|
-
function personNamed$1(people, name) {
|
|
16439
|
-
const want = name.trim().toLowerCase();
|
|
16440
|
-
if (!want) return null;
|
|
16441
|
-
const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
|
|
16442
|
-
if (exact.length === 1) return exact[0];
|
|
16443
|
-
const first = people.filter((p) => p.name.trim().toLowerCase().split(/\s+/)[0] === want);
|
|
16444
|
-
if (first.length === 1) return first[0];
|
|
16445
|
-
const initials = people.filter((p) => p.initials.toLowerCase() === want);
|
|
16446
|
-
return initials.length === 1 ? initials[0] : null;
|
|
16768
|
+
say(fogSummary(tally));
|
|
16769
|
+
return tally;
|
|
16447
16770
|
}
|
|
16448
|
-
/**
|
|
16449
|
-
|
|
16450
|
-
|
|
16451
|
-
|
|
16452
|
-
|
|
16453
|
-
|
|
16454
|
-
|
|
16455
|
-
|
|
16456
|
-
|
|
16457
|
-
|
|
16458
|
-
|
|
16459
|
-
else clashes.push({
|
|
16460
|
-
field: k,
|
|
16461
|
-
file: shown(file[k]),
|
|
16462
|
-
harbor: shown(harbor[k])
|
|
16463
|
-
});
|
|
16464
|
-
}
|
|
16465
|
-
return {
|
|
16466
|
-
patch,
|
|
16467
|
-
clashes
|
|
16468
|
-
};
|
|
16469
|
-
}
|
|
16470
|
-
/** What a set of file fields means as a change to a task in this harbor. */
|
|
16471
|
-
function taskPatch(data, fields, say, label) {
|
|
16472
|
-
const patch = {};
|
|
16473
|
-
if (fields.title !== void 0) patch.title = fields.title;
|
|
16474
|
-
if (fields.status !== void 0) patch.status = fields.status;
|
|
16475
|
-
if (fields.due !== void 0) patch.dueDate = fields.due;
|
|
16476
|
-
if (fields.priority !== void 0) patch.priority = fields.priority;
|
|
16477
|
-
if (fields.repeat !== void 0) patch.repeat = fields.repeat;
|
|
16478
|
-
if (fields.notes !== void 0) patch.description = fields.notes;
|
|
16479
|
-
if (fields.owner !== void 0) {
|
|
16480
|
-
const person = fields.owner ? personNamed$1(data.people, fields.owner) : null;
|
|
16481
|
-
if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
|
|
16482
|
-
patch.assigneeId = person?.id ?? null;
|
|
16483
|
-
}
|
|
16484
|
-
if (fields.waits !== void 0) {
|
|
16485
|
-
const ids = [];
|
|
16486
|
-
for (const key of fields.waits) {
|
|
16487
|
-
const t = findByKey(data, key);
|
|
16488
|
-
if (t) ids.push(t.id);
|
|
16489
|
-
else say(`${label}: there's no ${key} to wait on, so that was left out.`);
|
|
16490
|
-
}
|
|
16491
|
-
patch.blockedByIds = ids;
|
|
16492
|
-
patch.blockedById = ids[0] ?? null;
|
|
16493
|
-
}
|
|
16494
|
-
return patch;
|
|
16495
|
-
}
|
|
16496
|
-
/** The base: what each of a project's tasks said when the folder last matched the harbor. */
|
|
16497
|
-
function baseOf(data, projectId) {
|
|
16498
|
-
const base = {};
|
|
16499
|
-
for (const t of data.tasks) if (t.projectId === projectId) base[t.id] = fieldsOf(data, t);
|
|
16500
|
-
return base;
|
|
16501
|
-
}
|
|
16502
|
-
/** A person a local plan adds for an owner it hasn't met. */
|
|
16503
|
-
function localPerson(name, id) {
|
|
16504
|
-
return {
|
|
16505
|
-
id,
|
|
16506
|
-
name,
|
|
16507
|
-
role: "Crew",
|
|
16508
|
-
initials: initialsOf(name),
|
|
16509
|
-
swatch: "pine",
|
|
16510
|
-
location: ""
|
|
16511
|
-
};
|
|
16512
|
-
}
|
|
16513
|
-
/** `.harbor/README.md`: what the folder is, for whoever finds it in the repository. */
|
|
16514
|
-
function readme(config) {
|
|
16515
|
-
return `# Plan
|
|
16516
|
-
|
|
16517
|
-
The tasks for this repository, one file per task in \`tasks/\`, kept by [Harbor](https://harborppm.com/ai). ${config.local ? "This plan lives only in this folder. Run `harbor` here to open it full screen; `harbor init PROJECT` later keeps it in step with a project on harborppm.com." : `It's kept in step with the ${config.project.name} project on harborppm.com: run \`harbor sync\` after editing, or after pulling, to bring both up to date.`}
|
|
16518
|
-
|
|
16519
|
-
Each file has its fields between the \`---\` lines and its notes below:
|
|
16520
|
-
|
|
16521
|
-
\`\`\`md
|
|
16522
|
-
---
|
|
16523
|
-
title: Move checkout to the new API
|
|
16524
|
-
status: doing
|
|
16525
|
-
owner: Sam Rivera
|
|
16526
|
-
due: 2026-11-07
|
|
16527
|
-
priority: high
|
|
16528
|
-
waits: [${config.project.key}-2]
|
|
16529
|
-
---
|
|
16530
|
-
Old endpoints go away on Nov 20.
|
|
16531
|
-
\`\`\`
|
|
16532
|
-
|
|
16533
|
-
- status: backlog, todo, doing, review or done
|
|
16534
|
-
- priority: low, medium, high or urgent
|
|
16535
|
-
- repeat (optional): weekly, biweekly or monthly
|
|
16536
|
-
- A new file with any name is a new task; Harbor gives it a key and renames it.
|
|
16537
|
-
- Removing a file doesn't remove the task. Mark it done instead.
|
|
16538
|
-
`;
|
|
16539
|
-
}
|
|
16540
|
-
var FIELDS, TASKS_DIR, CONFIG_FILE, BASE_FILE, LOCAL_FILE, STATUS_WORDS, PRIORITIES, REPEATS, KEY_NAME, same, shown;
|
|
16541
|
-
var init_plan_files = __esmMin((() => {
|
|
16542
|
-
init_shared();
|
|
16543
|
-
init_indexes();
|
|
16544
|
-
init_task_keys();
|
|
16545
|
-
FIELDS = [
|
|
16546
|
-
"title",
|
|
16547
|
-
"status",
|
|
16548
|
-
"owner",
|
|
16549
|
-
"due",
|
|
16550
|
-
"priority",
|
|
16551
|
-
"waits",
|
|
16552
|
-
"repeat",
|
|
16553
|
-
"notes"
|
|
16554
|
-
];
|
|
16555
|
-
TASKS_DIR = "tasks";
|
|
16556
|
-
CONFIG_FILE = "harbor.json";
|
|
16557
|
-
BASE_FILE = "state/base.json";
|
|
16558
|
-
LOCAL_FILE = "plan.json";
|
|
16559
|
-
STATUS_WORDS = {
|
|
16560
|
-
backlog: "backlog",
|
|
16561
|
-
todo: "todo",
|
|
16562
|
-
"to-do": "todo",
|
|
16563
|
-
"to do": "todo",
|
|
16564
|
-
doing: "doing",
|
|
16565
|
-
"in-progress": "doing",
|
|
16566
|
-
"in progress": "doing",
|
|
16567
|
-
started: "doing",
|
|
16568
|
-
review: "review",
|
|
16569
|
-
"in-review": "review",
|
|
16570
|
-
"in review": "review",
|
|
16571
|
-
done: "done",
|
|
16572
|
-
finished: "done"
|
|
16573
|
-
};
|
|
16574
|
-
PRIORITIES = [
|
|
16575
|
-
"low",
|
|
16576
|
-
"medium",
|
|
16577
|
-
"high",
|
|
16578
|
-
"urgent"
|
|
16579
|
-
];
|
|
16580
|
-
REPEATS = [
|
|
16581
|
-
"weekly",
|
|
16582
|
-
"biweekly",
|
|
16583
|
-
"monthly"
|
|
16584
|
-
];
|
|
16585
|
-
KEY_NAME = /^([A-Z][A-Z0-9]{0,9}-\d{1,7})\.md$/;
|
|
16586
|
-
same = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.join(",") === b.join(",") : a === b;
|
|
16587
|
-
shown = (v) => Array.isArray(v) ? v.join(", ") || "nothing" : v === null || v === "" ? "nothing" : String(v);
|
|
16588
|
-
}));
|
|
16589
|
-
|
|
16590
|
-
//#endregion
|
|
16591
|
-
//#region src/repo/folder.ts
|
|
16592
|
-
/**
|
|
16593
|
-
* The `.harbor/` folder on disk, and finding it: like git, `harbor sync`
|
|
16594
|
-
* works from anywhere inside the repository.
|
|
16595
|
-
*/
|
|
16596
|
-
function diskFolder(root) {
|
|
16597
|
-
return {
|
|
16598
|
-
async read(path) {
|
|
16599
|
-
try {
|
|
16600
|
-
return await readFile(join(root, path), "utf8");
|
|
16601
|
-
} catch (error) {
|
|
16602
|
-
if (error.code === "ENOENT") return null;
|
|
16603
|
-
throw error;
|
|
16604
|
-
}
|
|
16605
|
-
},
|
|
16606
|
-
async write(path, text) {
|
|
16607
|
-
const file = join(root, path);
|
|
16608
|
-
await mkdir(dirname(file), { recursive: true });
|
|
16609
|
-
const temp = `${file}.${process.pid}.tmp`;
|
|
16610
|
-
await writeFile(temp, text);
|
|
16611
|
-
await rename(temp, file);
|
|
16612
|
-
},
|
|
16613
|
-
async remove(path) {
|
|
16614
|
-
await rm(join(root, path), { force: true });
|
|
16615
|
-
},
|
|
16616
|
-
async list(dir) {
|
|
16617
|
-
try {
|
|
16618
|
-
return (await readdir(join(root, dir), { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name);
|
|
16619
|
-
} catch (error) {
|
|
16620
|
-
if (error.code === "ENOENT") return [];
|
|
16621
|
-
throw error;
|
|
16622
|
-
}
|
|
16623
|
-
}
|
|
16624
|
-
};
|
|
16625
|
-
}
|
|
16626
|
-
/** The nearest `.harbor/` with a plan in it, from `start` up; null when there's none. */
|
|
16627
|
-
async function findPlan(start) {
|
|
16628
|
-
let dir = resolve(start);
|
|
16629
|
-
for (;;) {
|
|
16630
|
-
const here = join(dir, PLAN_DIR);
|
|
16631
|
-
try {
|
|
16632
|
-
await readFile(join(here, CONFIG_FILE));
|
|
16633
|
-
return here;
|
|
16634
|
-
} catch {}
|
|
16635
|
-
const up = dirname(dir);
|
|
16636
|
-
if (up === dir) return null;
|
|
16637
|
-
dir = up;
|
|
16638
|
-
}
|
|
16639
|
-
}
|
|
16640
|
-
/** Where a new plan goes: the repository's top (the folder holding .git), else here. */
|
|
16641
|
-
async function planHome(start) {
|
|
16642
|
-
let dir = resolve(start);
|
|
16643
|
-
for (;;) {
|
|
16644
|
-
try {
|
|
16645
|
-
await access(join(dir, ".git"));
|
|
16646
|
-
return dir;
|
|
16647
|
-
} catch {}
|
|
16648
|
-
const up = dirname(dir);
|
|
16649
|
-
if (up === dir) return resolve(start);
|
|
16650
|
-
dir = up;
|
|
16651
|
-
}
|
|
16771
|
+
/** "2 still needed, 1 given a new date, 1 dropped." */
|
|
16772
|
+
function fogSummary(t) {
|
|
16773
|
+
const parts = [
|
|
16774
|
+
t.kept && `${t.kept} still needed`,
|
|
16775
|
+
t.dated && `${t.dated} given a new date`,
|
|
16776
|
+
t.dropped && `${t.dropped} dropped`,
|
|
16777
|
+
t.skipped && `${t.skipped} left for next time`
|
|
16778
|
+
].filter(Boolean);
|
|
16779
|
+
const left = t.foggy - t.kept - t.dated - t.dropped - t.skipped;
|
|
16780
|
+
if (left > 0) parts.push(`${left} not looked at`);
|
|
16781
|
+
return parts.length ? `${parts.join(", ")}.` : "Nothing changed.";
|
|
16652
16782
|
}
|
|
16653
|
-
var PLAN_DIR;
|
|
16654
|
-
var init_folder = __esmMin((() => {
|
|
16655
|
-
init_plan_files();
|
|
16656
|
-
PLAN_DIR = ".harbor";
|
|
16657
|
-
}));
|
|
16658
16783
|
|
|
16659
16784
|
//#endregion
|
|
16660
16785
|
//#region src/tui/engine/memory-crew.ts
|
|
@@ -16992,14 +17117,297 @@ function memoryHarbor(start, opts) {
|
|
|
16992
17117
|
}
|
|
16993
17118
|
};
|
|
16994
17119
|
}
|
|
16995
|
-
var init_memory = __esmMin((() => {
|
|
16996
|
-
init_changes();
|
|
16997
|
-
init_live_changes();
|
|
16998
|
-
init_row_changes();
|
|
16999
|
-
init_task_keys();
|
|
17120
|
+
var init_memory = __esmMin((() => {
|
|
17121
|
+
init_changes();
|
|
17122
|
+
init_live_changes();
|
|
17123
|
+
init_row_changes();
|
|
17124
|
+
init_task_keys();
|
|
17125
|
+
init_indexes();
|
|
17126
|
+
init_memory_crew();
|
|
17127
|
+
init_transport();
|
|
17128
|
+
}));
|
|
17129
|
+
|
|
17130
|
+
//#endregion
|
|
17131
|
+
//#region src/repo/plan-files.ts
|
|
17132
|
+
/** "RR-42.md" → "RR-42"; a file named anything else is a new task. */
|
|
17133
|
+
function keyOfFile(name) {
|
|
17134
|
+
return KEY_NAME.exec(name)?.[1] ?? null;
|
|
17135
|
+
}
|
|
17136
|
+
/** A front-matter value written so it reads back the same. */
|
|
17137
|
+
function scalar(value) {
|
|
17138
|
+
return value === value.trim() && value !== "" && !/^["'[\]{}>|*&!%@#`,?:-]/.test(value) && !/: |\s#/.test(value) && !/^(null|~|true|false|yes|no|-?\d[\d.]*)$/i.test(value) ? value : JSON.stringify(value);
|
|
17139
|
+
}
|
|
17140
|
+
function unquote(raw) {
|
|
17141
|
+
const v = raw.trim();
|
|
17142
|
+
if (v.startsWith("\"") && v.endsWith("\"") && v.length > 1) try {
|
|
17143
|
+
return String(JSON.parse(v));
|
|
17144
|
+
} catch {
|
|
17145
|
+
return v.slice(1, -1);
|
|
17146
|
+
}
|
|
17147
|
+
if (v.startsWith("'") && v.endsWith("'") && v.length > 1) return v.slice(1, -1).replace(/''/g, "'");
|
|
17148
|
+
return v;
|
|
17149
|
+
}
|
|
17150
|
+
function list$1(raw) {
|
|
17151
|
+
const v = raw.trim();
|
|
17152
|
+
return (v.startsWith("[") && v.endsWith("]") ? v.slice(1, -1) : v).split(",").map((x) => unquote(x)).filter(Boolean);
|
|
17153
|
+
}
|
|
17154
|
+
/** A task's file, as `harbor sync` writes it. */
|
|
17155
|
+
function renderTask(f) {
|
|
17156
|
+
const lines = [
|
|
17157
|
+
"---",
|
|
17158
|
+
`title: ${scalar(f.title)}`,
|
|
17159
|
+
`status: ${f.status}`,
|
|
17160
|
+
`owner: ${f.owner ? scalar(f.owner) : ""}`.trimEnd(),
|
|
17161
|
+
`due: ${f.due ?? ""}`.trimEnd(),
|
|
17162
|
+
`priority: ${f.priority}`
|
|
17163
|
+
];
|
|
17164
|
+
if (f.waits.length) lines.push(`waits: [${f.waits.join(", ")}]`);
|
|
17165
|
+
if (f.repeat) lines.push(`repeat: ${f.repeat}`);
|
|
17166
|
+
lines.push("---");
|
|
17167
|
+
const notes = f.notes.trim();
|
|
17168
|
+
return `${lines.join("\n")}\n${notes ? `${notes}\n` : ""}`;
|
|
17169
|
+
}
|
|
17170
|
+
/**
|
|
17171
|
+
* A task's file read back. Missing fields take their empty value (no owner,
|
|
17172
|
+
* no due date, medium priority), so deleting a line clears it. Anything it
|
|
17173
|
+
* can't read is named in `problems`, and a file without a title isn't a task.
|
|
17174
|
+
*/
|
|
17175
|
+
function parseTask(text) {
|
|
17176
|
+
const problems = [];
|
|
17177
|
+
const src = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
17178
|
+
const fm = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/.exec(src);
|
|
17179
|
+
if (!fm) return {
|
|
17180
|
+
fields: null,
|
|
17181
|
+
problems: ["it doesn't start with a --- block"]
|
|
17182
|
+
};
|
|
17183
|
+
const values = /* @__PURE__ */ new Map();
|
|
17184
|
+
const lists = /* @__PURE__ */ new Map();
|
|
17185
|
+
let open = null;
|
|
17186
|
+
for (const line of fm[1].split("\n")) {
|
|
17187
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
17188
|
+
const item = /^\s+-\s*(.*)$/.exec(line);
|
|
17189
|
+
if (item && open) {
|
|
17190
|
+
lists.get(open).push(unquote(item[1]));
|
|
17191
|
+
continue;
|
|
17192
|
+
}
|
|
17193
|
+
const kv = /^([A-Za-z][\w-]*)\s*:\s?(.*)$/.exec(line);
|
|
17194
|
+
if (!kv) {
|
|
17195
|
+
problems.push(`couldn't read "${line.trim()}"`);
|
|
17196
|
+
continue;
|
|
17197
|
+
}
|
|
17198
|
+
const key = kv[1].toLowerCase();
|
|
17199
|
+
values.set(key, kv[2]);
|
|
17200
|
+
open = kv[2].trim() === "" ? key : null;
|
|
17201
|
+
if (open) lists.set(open, []);
|
|
17202
|
+
}
|
|
17203
|
+
const value = (k) => {
|
|
17204
|
+
const block = lists.get(k);
|
|
17205
|
+
if (block && block.length) return block.join(", ");
|
|
17206
|
+
return unquote(values.get(k) ?? "");
|
|
17207
|
+
};
|
|
17208
|
+
const title = value("title");
|
|
17209
|
+
if (!title) return {
|
|
17210
|
+
fields: null,
|
|
17211
|
+
problems: [...problems, "it has no title"]
|
|
17212
|
+
};
|
|
17213
|
+
const statusText = value("status").toLowerCase();
|
|
17214
|
+
const status = statusText ? STATUS_WORDS[statusText] : "todo";
|
|
17215
|
+
if (!status) problems.push(`status "${statusText}" isn't one of backlog, todo, doing, review, done`);
|
|
17216
|
+
const priorityText = value("priority").toLowerCase();
|
|
17217
|
+
const priority = priorityText ? PRIORITIES.find((p) => p === priorityText) : "medium";
|
|
17218
|
+
if (!priority) problems.push(`priority "${priorityText}" isn't low, medium, high or urgent`);
|
|
17219
|
+
const due = value("due") || null;
|
|
17220
|
+
const dueOk = !due || /^\d{4}-\d{2}-\d{2}$/.test(due) && !Number.isNaN(Date.parse(due));
|
|
17221
|
+
if (!dueOk) problems.push(`due "${due}" isn't a date like 2026-11-07`);
|
|
17222
|
+
const repeatText = value("repeat").toLowerCase();
|
|
17223
|
+
const repeat = !repeatText || repeatText === "never" ? null : REPEATS.find((r) => r === repeatText) ?? null;
|
|
17224
|
+
if (repeatText && repeatText !== "never" && !repeat) problems.push(`repeat "${repeatText}" isn't weekly, biweekly or monthly`);
|
|
17225
|
+
const waits = (lists.get("waits")?.length ? lists.get("waits") : list$1(values.get("waits") ?? "")).map((k) => k.toUpperCase());
|
|
17226
|
+
return {
|
|
17227
|
+
fields: {
|
|
17228
|
+
title,
|
|
17229
|
+
status: status ?? "todo",
|
|
17230
|
+
owner: value("owner") || null,
|
|
17231
|
+
due: dueOk ? due : null,
|
|
17232
|
+
priority: priority ?? "medium",
|
|
17233
|
+
waits,
|
|
17234
|
+
repeat,
|
|
17235
|
+
notes: fm[2].trim()
|
|
17236
|
+
},
|
|
17237
|
+
problems
|
|
17238
|
+
};
|
|
17239
|
+
}
|
|
17240
|
+
/** The key a task goes by in this harbor, like RR-42. */
|
|
17241
|
+
function keyOf(data, task) {
|
|
17242
|
+
return taskKey(task, data.projects.find((p) => p.id === task.projectId));
|
|
17243
|
+
}
|
|
17244
|
+
/** What a task's file says, read off the harbor. */
|
|
17245
|
+
function fieldsOf(data, task) {
|
|
17246
|
+
const people = byId(data.people);
|
|
17247
|
+
const tasks = byId(data.tasks);
|
|
17248
|
+
const waitIds = task.blockedByIds ?? (task.blockedById ? [task.blockedById] : []);
|
|
17249
|
+
return {
|
|
17250
|
+
title: task.title,
|
|
17251
|
+
status: task.status,
|
|
17252
|
+
owner: task.assigneeId ? people.get(task.assigneeId)?.name ?? null : null,
|
|
17253
|
+
due: task.dueDate,
|
|
17254
|
+
priority: task.priority,
|
|
17255
|
+
waits: waitIds.map((id) => tasks.get(id)).map((t) => t ? keyOf(data, t) : null).filter((k) => Boolean(k)),
|
|
17256
|
+
repeat: task.repeat ?? null,
|
|
17257
|
+
notes: task.description.trim()
|
|
17258
|
+
};
|
|
17259
|
+
}
|
|
17260
|
+
/** The person a file's owner names: their whole name, else a first name or initials only they have. */
|
|
17261
|
+
function personNamed$1(people, name) {
|
|
17262
|
+
const want = name.trim().toLowerCase();
|
|
17263
|
+
if (!want) return null;
|
|
17264
|
+
const exact = people.filter((p) => p.name.trim().toLowerCase() === want);
|
|
17265
|
+
if (exact.length === 1) return exact[0];
|
|
17266
|
+
const first = people.filter((p) => p.name.trim().toLowerCase().split(/\s+/)[0] === want);
|
|
17267
|
+
if (first.length === 1) return first[0];
|
|
17268
|
+
const initials = people.filter((p) => p.initials.toLowerCase() === want);
|
|
17269
|
+
return initials.length === 1 ? initials[0] : null;
|
|
17270
|
+
}
|
|
17271
|
+
/**
|
|
17272
|
+
* The folder's changes to one task, worked out against the base: a field
|
|
17273
|
+
* the file changed and the harbor didn't goes to the harbor; one both
|
|
17274
|
+
* changed differently stays as the harbor has it, and is named.
|
|
17275
|
+
*/
|
|
17276
|
+
function mergeTask(base, file, harbor) {
|
|
17277
|
+
const patch = {};
|
|
17278
|
+
const clashes = [];
|
|
17279
|
+
for (const k of FIELDS) {
|
|
17280
|
+
if (same(file[k], base[k]) || same(file[k], harbor[k])) continue;
|
|
17281
|
+
if (same(harbor[k], base[k])) patch[k] = file[k];
|
|
17282
|
+
else clashes.push({
|
|
17283
|
+
field: k,
|
|
17284
|
+
file: shown(file[k]),
|
|
17285
|
+
harbor: shown(harbor[k])
|
|
17286
|
+
});
|
|
17287
|
+
}
|
|
17288
|
+
return {
|
|
17289
|
+
patch,
|
|
17290
|
+
clashes
|
|
17291
|
+
};
|
|
17292
|
+
}
|
|
17293
|
+
/** What a set of file fields means as a change to a task in this harbor. */
|
|
17294
|
+
function taskPatch(data, fields, say, label) {
|
|
17295
|
+
const patch = {};
|
|
17296
|
+
if (fields.title !== void 0) patch.title = fields.title;
|
|
17297
|
+
if (fields.status !== void 0) patch.status = fields.status;
|
|
17298
|
+
if (fields.due !== void 0) patch.dueDate = fields.due;
|
|
17299
|
+
if (fields.priority !== void 0) patch.priority = fields.priority;
|
|
17300
|
+
if (fields.repeat !== void 0) patch.repeat = fields.repeat;
|
|
17301
|
+
if (fields.notes !== void 0) patch.description = fields.notes;
|
|
17302
|
+
if (fields.owner !== void 0) {
|
|
17303
|
+
const person = fields.owner ? personNamed$1(data.people, fields.owner) : null;
|
|
17304
|
+
if (fields.owner && !person) say(`${label}: nobody called "${fields.owner}" is in the harbor, so it has no owner.`);
|
|
17305
|
+
patch.assigneeId = person?.id ?? null;
|
|
17306
|
+
}
|
|
17307
|
+
if (fields.waits !== void 0) {
|
|
17308
|
+
const ids = [];
|
|
17309
|
+
for (const key of fields.waits) {
|
|
17310
|
+
const t = findByKey(data, key);
|
|
17311
|
+
if (t) ids.push(t.id);
|
|
17312
|
+
else say(`${label}: there's no ${key} to wait on, so that was left out.`);
|
|
17313
|
+
}
|
|
17314
|
+
patch.blockedByIds = ids;
|
|
17315
|
+
patch.blockedById = ids[0] ?? null;
|
|
17316
|
+
}
|
|
17317
|
+
return patch;
|
|
17318
|
+
}
|
|
17319
|
+
/** The base: what each of a project's tasks said when the folder last matched the harbor. */
|
|
17320
|
+
function baseOf(data, projectId) {
|
|
17321
|
+
const base = {};
|
|
17322
|
+
for (const t of data.tasks) if (t.projectId === projectId) base[t.id] = fieldsOf(data, t);
|
|
17323
|
+
return base;
|
|
17324
|
+
}
|
|
17325
|
+
/** A person a local plan adds for an owner it hasn't met. */
|
|
17326
|
+
function localPerson(name, id) {
|
|
17327
|
+
return {
|
|
17328
|
+
id,
|
|
17329
|
+
name,
|
|
17330
|
+
role: "Crew",
|
|
17331
|
+
initials: initialsOf(name),
|
|
17332
|
+
swatch: "pine",
|
|
17333
|
+
location: ""
|
|
17334
|
+
};
|
|
17335
|
+
}
|
|
17336
|
+
/** `.harbor/README.md`: what the folder is, for whoever finds it in the repository. */
|
|
17337
|
+
function readme(config) {
|
|
17338
|
+
return `# Plan
|
|
17339
|
+
|
|
17340
|
+
The tasks for this repository, one file per task in \`tasks/\`, kept by [Harbor](https://harborppm.com/ai). ${config.local ? "This plan lives only in this folder. Run `harbor` here to open it full screen; `harbor init PROJECT` later keeps it in step with a project on harborppm.com." : `It's kept in step with the ${config.project.name} project on harborppm.com: run \`harbor sync\` after editing, or after pulling, to bring both up to date.`}
|
|
17341
|
+
|
|
17342
|
+
Each file has its fields between the \`---\` lines and its notes below:
|
|
17343
|
+
|
|
17344
|
+
\`\`\`md
|
|
17345
|
+
---
|
|
17346
|
+
title: Move checkout to the new API
|
|
17347
|
+
status: doing
|
|
17348
|
+
owner: Sam Rivera
|
|
17349
|
+
due: 2026-11-07
|
|
17350
|
+
priority: high
|
|
17351
|
+
waits: [${config.project.key}-2]
|
|
17352
|
+
---
|
|
17353
|
+
Old endpoints go away on Nov 20.
|
|
17354
|
+
\`\`\`
|
|
17355
|
+
|
|
17356
|
+
- status: backlog, todo, doing, review or done
|
|
17357
|
+
- priority: low, medium, high or urgent
|
|
17358
|
+
- repeat (optional): weekly, biweekly or monthly
|
|
17359
|
+
- A new file with any name is a new task; Harbor gives it a key and renames it.
|
|
17360
|
+
- Removing a file doesn't remove the task. Mark it done instead.
|
|
17361
|
+
`;
|
|
17362
|
+
}
|
|
17363
|
+
var FIELDS, TASKS_DIR, CONFIG_FILE, BASE_FILE, LOCAL_FILE, STATUS_WORDS, PRIORITIES, REPEATS, KEY_NAME, same, shown;
|
|
17364
|
+
var init_plan_files = __esmMin((() => {
|
|
17365
|
+
init_shared();
|
|
17000
17366
|
init_indexes();
|
|
17001
|
-
|
|
17002
|
-
|
|
17367
|
+
init_task_keys();
|
|
17368
|
+
FIELDS = [
|
|
17369
|
+
"title",
|
|
17370
|
+
"status",
|
|
17371
|
+
"owner",
|
|
17372
|
+
"due",
|
|
17373
|
+
"priority",
|
|
17374
|
+
"waits",
|
|
17375
|
+
"repeat",
|
|
17376
|
+
"notes"
|
|
17377
|
+
];
|
|
17378
|
+
TASKS_DIR = "tasks";
|
|
17379
|
+
CONFIG_FILE = "harbor.json";
|
|
17380
|
+
BASE_FILE = "state/base.json";
|
|
17381
|
+
LOCAL_FILE = "plan.json";
|
|
17382
|
+
STATUS_WORDS = {
|
|
17383
|
+
backlog: "backlog",
|
|
17384
|
+
todo: "todo",
|
|
17385
|
+
"to-do": "todo",
|
|
17386
|
+
"to do": "todo",
|
|
17387
|
+
doing: "doing",
|
|
17388
|
+
"in-progress": "doing",
|
|
17389
|
+
"in progress": "doing",
|
|
17390
|
+
started: "doing",
|
|
17391
|
+
review: "review",
|
|
17392
|
+
"in-review": "review",
|
|
17393
|
+
"in review": "review",
|
|
17394
|
+
done: "done",
|
|
17395
|
+
finished: "done"
|
|
17396
|
+
};
|
|
17397
|
+
PRIORITIES = [
|
|
17398
|
+
"low",
|
|
17399
|
+
"medium",
|
|
17400
|
+
"high",
|
|
17401
|
+
"urgent"
|
|
17402
|
+
];
|
|
17403
|
+
REPEATS = [
|
|
17404
|
+
"weekly",
|
|
17405
|
+
"biweekly",
|
|
17406
|
+
"monthly"
|
|
17407
|
+
];
|
|
17408
|
+
KEY_NAME = /^([A-Z][A-Z0-9]{0,9}-\d{1,7})\.md$/;
|
|
17409
|
+
same = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.join(",") === b.join(",") : a === b;
|
|
17410
|
+
shown = (v) => Array.isArray(v) ? v.join(", ") || "nothing" : v === null || v === "" ? "nothing" : String(v);
|
|
17003
17411
|
}));
|
|
17004
17412
|
|
|
17005
17413
|
//#endregion
|
|
@@ -17519,6 +17927,75 @@ var init_repo_sync = __esmMin((() => {
|
|
|
17519
17927
|
};
|
|
17520
17928
|
}));
|
|
17521
17929
|
|
|
17930
|
+
//#endregion
|
|
17931
|
+
//#region src/repo/folder.ts
|
|
17932
|
+
/**
|
|
17933
|
+
* The `.harbor/` folder on disk, and finding it: like git, `harbor sync`
|
|
17934
|
+
* works from anywhere inside the repository.
|
|
17935
|
+
*/
|
|
17936
|
+
function diskFolder(root) {
|
|
17937
|
+
return {
|
|
17938
|
+
async read(path) {
|
|
17939
|
+
try {
|
|
17940
|
+
return await readFile(join(root, path), "utf8");
|
|
17941
|
+
} catch (error) {
|
|
17942
|
+
if (error.code === "ENOENT") return null;
|
|
17943
|
+
throw error;
|
|
17944
|
+
}
|
|
17945
|
+
},
|
|
17946
|
+
async write(path, text) {
|
|
17947
|
+
const file = join(root, path);
|
|
17948
|
+
await mkdir(dirname(file), { recursive: true });
|
|
17949
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
17950
|
+
await writeFile(temp, text);
|
|
17951
|
+
await rename(temp, file);
|
|
17952
|
+
},
|
|
17953
|
+
async remove(path) {
|
|
17954
|
+
await rm(join(root, path), { force: true });
|
|
17955
|
+
},
|
|
17956
|
+
async list(dir) {
|
|
17957
|
+
try {
|
|
17958
|
+
return (await readdir(join(root, dir), { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name);
|
|
17959
|
+
} catch (error) {
|
|
17960
|
+
if (error.code === "ENOENT") return [];
|
|
17961
|
+
throw error;
|
|
17962
|
+
}
|
|
17963
|
+
}
|
|
17964
|
+
};
|
|
17965
|
+
}
|
|
17966
|
+
/** The nearest `.harbor/` with a plan in it, from `start` up; null when there's none. */
|
|
17967
|
+
async function findPlan(start) {
|
|
17968
|
+
let dir = resolve(start);
|
|
17969
|
+
for (;;) {
|
|
17970
|
+
const here = join(dir, PLAN_DIR);
|
|
17971
|
+
try {
|
|
17972
|
+
await readFile(join(here, CONFIG_FILE));
|
|
17973
|
+
return here;
|
|
17974
|
+
} catch {}
|
|
17975
|
+
const up = dirname(dir);
|
|
17976
|
+
if (up === dir) return null;
|
|
17977
|
+
dir = up;
|
|
17978
|
+
}
|
|
17979
|
+
}
|
|
17980
|
+
/** Where a new plan goes: the repository's top (the folder holding .git), else here. */
|
|
17981
|
+
async function planHome(start) {
|
|
17982
|
+
let dir = resolve(start);
|
|
17983
|
+
for (;;) {
|
|
17984
|
+
try {
|
|
17985
|
+
await access(join(dir, ".git"));
|
|
17986
|
+
return dir;
|
|
17987
|
+
} catch {}
|
|
17988
|
+
const up = dirname(dir);
|
|
17989
|
+
if (up === dir) return resolve(start);
|
|
17990
|
+
dir = up;
|
|
17991
|
+
}
|
|
17992
|
+
}
|
|
17993
|
+
var PLAN_DIR;
|
|
17994
|
+
var init_folder = __esmMin((() => {
|
|
17995
|
+
init_plan_files();
|
|
17996
|
+
PLAN_DIR = ".harbor";
|
|
17997
|
+
}));
|
|
17998
|
+
|
|
17522
17999
|
//#endregion
|
|
17523
18000
|
//#region src/repo/commands.ts
|
|
17524
18001
|
/**
|
|
@@ -23121,6 +23598,7 @@ function voyageLog(data, projectId) {
|
|
|
23121
23598
|
guessed: true
|
|
23122
23599
|
};
|
|
23123
23600
|
});
|
|
23601
|
+
const decisions = raid.filter((r) => r.kind === "decision" && r.status !== "proposed").sort((a, b) => (a.decidedAt ?? a.createdAt).localeCompare(b.decidedAt ?? b.createdAt));
|
|
23124
23602
|
const counts = /* @__PURE__ */ new Map();
|
|
23125
23603
|
for (const t of tasks) if (t.status === "done" && t.assigneeId) counts.set(t.assigneeId, (counts.get(t.assigneeId) ?? 0) + 1);
|
|
23126
23604
|
const people = new Map(data.people.map((p) => [p.id, p]));
|
|
@@ -23142,6 +23620,7 @@ function voyageLog(data, projectId) {
|
|
|
23142
23620
|
slips,
|
|
23143
23621
|
risks,
|
|
23144
23622
|
issues: issues.length,
|
|
23623
|
+
decisions,
|
|
23145
23624
|
carriers,
|
|
23146
23625
|
budget: project.budget,
|
|
23147
23626
|
spent: project.spent,
|
|
@@ -23189,6 +23668,10 @@ function voyageStory(log) {
|
|
|
23189
23668
|
out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md(s.waitedOn.completedAt)}.` : `${late}.`);
|
|
23190
23669
|
}
|
|
23191
23670
|
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)" : ""}.`);
|
|
23671
|
+
if (log.decisions.length) {
|
|
23672
|
+
const changed = log.decisions.filter((d) => d.status === "superseded").length;
|
|
23673
|
+
out.push(`${plural(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
|
|
23674
|
+
}
|
|
23192
23675
|
const top = log.carriers[0];
|
|
23193
23676
|
if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural(top.closed, "task")}, the most on the crew.`);
|
|
23194
23677
|
else if (top) out.push(`${top.person.name} closed ${plural(top.closed, "task")}.`);
|
|
@@ -23233,12 +23716,14 @@ init_hill();
|
|
|
23233
23716
|
init_raid();
|
|
23234
23717
|
init_priority();
|
|
23235
23718
|
init_premortem();
|
|
23719
|
+
init_decisions();
|
|
23236
23720
|
init_search();
|
|
23237
23721
|
init_derived_health();
|
|
23238
23722
|
init_format();
|
|
23239
23723
|
init_date_text();
|
|
23240
23724
|
init_handover();
|
|
23241
23725
|
init_work();
|
|
23726
|
+
init_fog();
|
|
23242
23727
|
init_board();
|
|
23243
23728
|
/**
|
|
23244
23729
|
* Where the website opens one task or project, switching to its harbor
|
|
@@ -23440,10 +23925,7 @@ function raidOut(data, r) {
|
|
|
23440
23925
|
...text("contingency"),
|
|
23441
23926
|
...text("resolution"),
|
|
23442
23927
|
...text("rationale"),
|
|
23443
|
-
...r.kind === "decision" ? {
|
|
23444
|
-
decidedBy: nameOf(people, r.decidedById),
|
|
23445
|
-
decidedOn: dayOf(r.decidedAt)
|
|
23446
|
-
} : {},
|
|
23928
|
+
...r.kind === "decision" ? decisionOut(data, r, people) : {},
|
|
23447
23929
|
...text("notes"),
|
|
23448
23930
|
updates: r.updates.map((u) => ({
|
|
23449
23931
|
by: nameOf(people, u.authorId) ?? "Someone",
|
|
@@ -23452,6 +23934,39 @@ function raidOut(data, r) {
|
|
|
23452
23934
|
}))
|
|
23453
23935
|
};
|
|
23454
23936
|
}
|
|
23937
|
+
/** A decision's own fields: who and when, what was turned down, what it touches, when to look again. */
|
|
23938
|
+
function decisionOut(data, r, people) {
|
|
23939
|
+
const tasks = byId(data.tasks);
|
|
23940
|
+
const items = byId(data.raidItems);
|
|
23941
|
+
const touches = [...(r.taskIds ?? []).flatMap((id) => {
|
|
23942
|
+
const t = tasks.get(id);
|
|
23943
|
+
if (!t) return [];
|
|
23944
|
+
const key = taskKey(t, data.projects.find((p) => p.id === t.projectId));
|
|
23945
|
+
return [key ? `${key} ${t.title}` : t.title];
|
|
23946
|
+
}), ...(r.riskIds ?? []).flatMap((id) => {
|
|
23947
|
+
const x = items.get(id);
|
|
23948
|
+
return x ? [`${KIND_LABEL[x.kind]}: ${x.title}`] : [];
|
|
23949
|
+
})];
|
|
23950
|
+
const older = r.supersedesId ? items.get(r.supersedesId) : null;
|
|
23951
|
+
const newer = supersededBy(data, r.id);
|
|
23952
|
+
const due = revisitReason(r, data, todayISO());
|
|
23953
|
+
return {
|
|
23954
|
+
decidedBy: nameOf(people, r.decidedById),
|
|
23955
|
+
decidedOn: dayOf(r.decidedAt),
|
|
23956
|
+
...r.turnedDown?.trim() ? { turnedDown: r.turnedDown.trim() } : {},
|
|
23957
|
+
...touches.length ? { touches } : {},
|
|
23958
|
+
...r.revisitIf ? { revisitIf: revisitText(r.revisitIf, data) } : {},
|
|
23959
|
+
...due ? { lookAgainNow: due } : {},
|
|
23960
|
+
...older ? { replaces: {
|
|
23961
|
+
id: older.id,
|
|
23962
|
+
title: older.title
|
|
23963
|
+
} } : {},
|
|
23964
|
+
...newer ? { replacedBy: {
|
|
23965
|
+
id: newer.id,
|
|
23966
|
+
title: newer.title
|
|
23967
|
+
} } : {}
|
|
23968
|
+
};
|
|
23969
|
+
}
|
|
23455
23970
|
const tool = (t) => t;
|
|
23456
23971
|
/** A project's notes for later, as get_project gives them: sealed ones without their text unless they're the caller's. */
|
|
23457
23972
|
function notesOf(ctx, p) {
|
|
@@ -24089,6 +24604,12 @@ const HARBOR_TOOLS = [
|
|
|
24089
24604
|
cameTrue: r.happened,
|
|
24090
24605
|
...r.guessed ? { guessed: true } : {}
|
|
24091
24606
|
})),
|
|
24607
|
+
decisions: log.decisions.map((d) => ({
|
|
24608
|
+
title: d.title,
|
|
24609
|
+
on: dayOf(d.decidedAt),
|
|
24610
|
+
...d.rationale?.trim() ? { why: d.rationale.trim() } : {},
|
|
24611
|
+
...d.status === "superseded" ? { changedLater: true } : {}
|
|
24612
|
+
})),
|
|
24092
24613
|
closedBy: log.carriers.map((c) => ({
|
|
24093
24614
|
who: c.person.name,
|
|
24094
24615
|
tasks: c.closed
|
|
@@ -24192,6 +24713,38 @@ const HARBOR_TOOLS = [
|
|
|
24192
24713
|
};
|
|
24193
24714
|
}
|
|
24194
24715
|
}),
|
|
24716
|
+
tool({
|
|
24717
|
+
name: "fog",
|
|
24718
|
+
title: "Tasks in fog",
|
|
24719
|
+
description: "Open tasks nobody has opened, moved, edited or commented on for three weeks (thick fog after five), longest untouched first, with how long and when they're due. Walk the person through them one at a time: still needed (keep_task clears it), a new date (update_task), or drop it (only on the website or in the terminal; nothing here deletes)." + DATA_NOTE,
|
|
24720
|
+
input: object({
|
|
24721
|
+
harbor: harborArg,
|
|
24722
|
+
project: string().max(200).optional().describe("Project id, key or name, for one only."),
|
|
24723
|
+
limit: limitArg(100, 25),
|
|
24724
|
+
offset: offsetArg
|
|
24725
|
+
}),
|
|
24726
|
+
run: (ctx, input) => {
|
|
24727
|
+
const { data } = ctx;
|
|
24728
|
+
const project = input.project ? findOne(data.projects, input.project, "project") : null;
|
|
24729
|
+
const today = todayISO(ctx.now);
|
|
24730
|
+
const fog = foggyTasks(data, today, project ? /* @__PURE__ */ new Set([project.id]) : void 0);
|
|
24731
|
+
const people = byId(data.people);
|
|
24732
|
+
const projects = byId(data.projects);
|
|
24733
|
+
return {
|
|
24734
|
+
...project ? { project: project.name } : {},
|
|
24735
|
+
inFog: fog.length,
|
|
24736
|
+
fogAfterDays: 21,
|
|
24737
|
+
tasks: fog.slice(input.offset, input.offset + input.limit).map((f) => ({
|
|
24738
|
+
...taskLine(f.task, people, projects, data.tasks),
|
|
24739
|
+
untouchedDays: f.days,
|
|
24740
|
+
untouched: untouchedText(f.days),
|
|
24741
|
+
thick: f.level === "thick",
|
|
24742
|
+
lastTouched: dayOf(lastTouch(f.task, data))
|
|
24743
|
+
})),
|
|
24744
|
+
more: fog.length > input.offset + input.limit
|
|
24745
|
+
};
|
|
24746
|
+
}
|
|
24747
|
+
}),
|
|
24195
24748
|
tool({
|
|
24196
24749
|
name: "handover_plan",
|
|
24197
24750
|
title: "What handing someone's work over would move",
|
|
@@ -24251,61 +24804,6 @@ const LIST_HARBORS = {
|
|
|
24251
24804
|
input: object({})
|
|
24252
24805
|
};
|
|
24253
24806
|
|
|
24254
|
-
//#endregion
|
|
24255
|
-
//#region src/lib/command-line.ts
|
|
24256
|
-
init_date_fns();
|
|
24257
|
-
init_format();
|
|
24258
|
-
const PAGES = {
|
|
24259
|
-
today: "/today",
|
|
24260
|
-
inbox: "/inbox",
|
|
24261
|
-
brief: "/brief",
|
|
24262
|
-
review: "/review",
|
|
24263
|
-
portfolios: "/portfolios",
|
|
24264
|
-
programs: "/programs",
|
|
24265
|
-
projects: "/projects",
|
|
24266
|
-
risks: "/risks",
|
|
24267
|
-
calendar: "/calendar",
|
|
24268
|
-
people: "/people"
|
|
24269
|
-
};
|
|
24270
|
-
const HELP = [
|
|
24271
|
-
{ text: "add \"TITLE\" [#project] [@person] [--due fri|+3|2026-11-02]" },
|
|
24272
|
-
{ text: "mine what's on you, late first" },
|
|
24273
|
-
{ text: "tasks [words] [--late] [#project] [@person]" },
|
|
24274
|
-
{ text: "show RR-12 open a task" },
|
|
24275
|
-
{ text: "start | review | done RR-12" },
|
|
24276
|
-
{ text: "comment RR-12 \"TEXT\"" },
|
|
24277
|
-
{ text: "checkin \"WHAT YOU'RE ON\" [--on-blocker]" },
|
|
24278
|
-
{ text: `go ${Object.keys(PAGES).join("|")}` },
|
|
24279
|
-
{
|
|
24280
|
-
text: "undo · clear · help",
|
|
24281
|
-
tone: "muted"
|
|
24282
|
-
}
|
|
24283
|
-
];
|
|
24284
|
-
var CommandError = class extends Error {};
|
|
24285
|
-
const WEEKDAYS = [
|
|
24286
|
-
"sun",
|
|
24287
|
-
"mon",
|
|
24288
|
-
"tue",
|
|
24289
|
-
"wed",
|
|
24290
|
-
"thu",
|
|
24291
|
-
"fri",
|
|
24292
|
-
"sat"
|
|
24293
|
-
];
|
|
24294
|
-
/** today, tomorrow, +N, a weekday (the next one, never today) or YYYY-MM-DD. */
|
|
24295
|
-
function dueFrom(value, now) {
|
|
24296
|
-
const v = value.trim().toLowerCase();
|
|
24297
|
-
if (v === "today") return todayISO(now);
|
|
24298
|
-
if (v === "tomorrow") return todayISO(addDays(now, 1));
|
|
24299
|
-
if (/^\+\d{1,3}$/.test(v)) return todayISO(addDays(now, Number(v.slice(1))));
|
|
24300
|
-
const day = WEEKDAYS.findIndex((d) => v.startsWith(d) && d.length >= 3 && v.length >= 3);
|
|
24301
|
-
if (day >= 0) {
|
|
24302
|
-
const ahead = (day - now.getDay() + 7) % 7 || 7;
|
|
24303
|
-
return todayISO(addDays(now, ahead));
|
|
24304
|
-
}
|
|
24305
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(parseDate$1(v).getTime())) return v;
|
|
24306
|
-
throw new CommandError(`Can't read the date "${value}". Try today, fri, +3 or 2026-11-02.`);
|
|
24307
|
-
}
|
|
24308
|
-
|
|
24309
24807
|
//#endregion
|
|
24310
24808
|
//#region src/lib/dry-dock.ts
|
|
24311
24809
|
init_date_fns();
|
|
@@ -24849,6 +25347,8 @@ init_transport();
|
|
|
24849
25347
|
init_errors();
|
|
24850
25348
|
init_plain();
|
|
24851
25349
|
init_search();
|
|
25350
|
+
init_view_only();
|
|
25351
|
+
init_repo_sync();
|
|
24852
25352
|
const today = () => format(/* @__PURE__ */ new Date(), "yyyy-MM-dd");
|
|
24853
25353
|
/** How long `harbor` shows its splash at the least, however fast the harbor loads. */
|
|
24854
25354
|
const SPLASH_MS = 1200;
|
|
@@ -24982,8 +25482,58 @@ async function runApp(opts) {
|
|
|
24982
25482
|
}, { alternateScreen: true });
|
|
24983
25483
|
await instance.waitUntilExit();
|
|
24984
25484
|
}
|
|
25485
|
+
/**
|
|
25486
|
+
* The harbor a one-off run works in, without asking: --harbor, else the one
|
|
25487
|
+
* `harbor use` saved, else the only one.
|
|
25488
|
+
*/
|
|
25489
|
+
function harborFor(harbors, named, saved) {
|
|
25490
|
+
if (harbors.length === 0) throw new HarborError("You're not in a harbor yet. Run harbor to make one first.");
|
|
25491
|
+
if (named) return harborNamed(harbors, named);
|
|
25492
|
+
const kept = saved ? harbors.find((h) => h.id === saved) : void 0;
|
|
25493
|
+
if (kept) return kept;
|
|
25494
|
+
if (harbors.length === 1) return harbors[0];
|
|
25495
|
+
throw new HarborError(`You're in ${harbors.length} harbors (${harbors.map((h) => h.name).join(", ")}). Add --harbor NAME, or run harbor use NAME first.`);
|
|
25496
|
+
}
|
|
25497
|
+
/** `harbor fog` in a terminal: the sweep, one task at a time, then one save. */
|
|
25498
|
+
async function fogHere(opts) {
|
|
25499
|
+
const transport = plainTransport(httpTransport(opts.source, opts.fetcher));
|
|
25500
|
+
const answer = await transport.harbors();
|
|
25501
|
+
const harbor = harborFor(answer.harbors, opts.harbor, opts.saved);
|
|
25502
|
+
const say = (line) => process.stdout.write(`${plain(line)}\n`);
|
|
25503
|
+
const store = createAppStore();
|
|
25504
|
+
const sync = createSync({
|
|
25505
|
+
store,
|
|
25506
|
+
transport,
|
|
25507
|
+
account: {
|
|
25508
|
+
id: answer.account.id,
|
|
25509
|
+
name: answer.account.name || answer.account.email
|
|
25510
|
+
},
|
|
25511
|
+
live: false,
|
|
25512
|
+
saveDelayMs: 0,
|
|
25513
|
+
onNotice: say
|
|
25514
|
+
});
|
|
25515
|
+
await sync.open(harbor.id);
|
|
25516
|
+
const terminal = terminalAsk("Stopped. What you'd answered is saved.");
|
|
25517
|
+
try {
|
|
25518
|
+
if (readOnlyHarbor(store.getState())) throw new HarborError(store.getState().keepPaused ? PAUSED_NOTICE : `You can only view ${harbor.name}, so there's no fog to clear from here.`);
|
|
25519
|
+
const project = opts.project ? projectNamed(store.getState().projects, opts.project) : null;
|
|
25520
|
+
await sweepFog({
|
|
25521
|
+
store,
|
|
25522
|
+
ask: terminal.ask,
|
|
25523
|
+
say,
|
|
25524
|
+
today: today(),
|
|
25525
|
+
now: /* @__PURE__ */ new Date(),
|
|
25526
|
+
projectId: project?.id ?? null
|
|
25527
|
+
});
|
|
25528
|
+
} finally {
|
|
25529
|
+
terminal.close();
|
|
25530
|
+
await sync.flush();
|
|
25531
|
+
sync.close();
|
|
25532
|
+
}
|
|
25533
|
+
if (sync.state !== "saved") throw new HarborError("Harbor didn't take those answers. Run harbor fog again.");
|
|
25534
|
+
}
|
|
24985
25535
|
/** Questions asked on the plain terminal, before the app takes it over. Ctrl+C stops. */
|
|
24986
|
-
function terminalAsk() {
|
|
25536
|
+
function terminalAsk(stoppedText = "Stopped. Nothing was made.") {
|
|
24987
25537
|
const rl = createInterface({
|
|
24988
25538
|
input: process.stdin,
|
|
24989
25539
|
output: process.stdout
|
|
@@ -24991,7 +25541,7 @@ function terminalAsk() {
|
|
|
24991
25541
|
let stop = () => {};
|
|
24992
25542
|
const stopped = new Promise((_, reject) => stop = reject);
|
|
24993
25543
|
stopped.catch(() => {});
|
|
24994
|
-
const nothing = () => stop(new HarborError(
|
|
25544
|
+
const nothing = () => stop(new HarborError(stoppedText));
|
|
24995
25545
|
rl.on("SIGINT", nothing);
|
|
24996
25546
|
rl.on("close", nothing);
|
|
24997
25547
|
return {
|
|
@@ -25023,4 +25573,4 @@ function planTransport(source, fetcher) {
|
|
|
25023
25573
|
}
|
|
25024
25574
|
|
|
25025
25575
|
//#endregion
|
|
25026
|
-
export { SPLASH_MS, TransportError, firstHarbor, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere };
|
|
25576
|
+
export { SPLASH_MS, TransportError, firstHarbor, fogHere, harborNamed, httpTransport, initHere, initLocalHere, localPlanAt, planTransport, runApp, runLocalApp, startApp, syncHere, tryHere };
|