@harborppm/cli 0.18.1 → 0.20.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 +3 -0
- package/dist/app.mjs +146 -93
- package/lib/cli.mjs +116 -22
- package/lib/format.mjs +13 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -118,8 +118,11 @@ harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
|
118
118
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
119
119
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
120
120
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
121
|
+
harbor limit PROJECT [COLUMN N|none]
|
|
121
122
|
```
|
|
122
123
|
|
|
124
|
+
`harbor limit RR` prints how many tasks each of a board's columns takes at once; `harbor limit RR doing 8` changes one (1 to 20, or `none` for no limit), if you own the harbor. `harbor projects --json` carries each board's `limits` too.
|
|
125
|
+
|
|
123
126
|
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours.
|
|
124
127
|
|
|
125
128
|
Every command takes `--json` for the raw result (a refusal then comes out as `{"error": "…"}` on standard output, still with exit code 1), and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
|
package/dist/app.mjs
CHANGED
|
@@ -113,17 +113,16 @@ const MINUTE = 6e4;
|
|
|
113
113
|
/**
|
|
114
114
|
* How long ago (or until) `date` is, in the one unit that fits: "5 minutes
|
|
115
115
|
* ago", "in 2 days", "1 year ago". Rounds to the nearest whole unit, and
|
|
116
|
-
* matches date-fns's formatDistanceToNowStrict with addSuffix
|
|
116
|
+
* matches date-fns's formatDistanceToNowStrict with addSuffix, except that
|
|
117
|
+
* anything under a minute either way is "just now", never "0 seconds ago".
|
|
117
118
|
*/
|
|
118
119
|
function timeAgo(date, now = clockNow()) {
|
|
119
120
|
const diff = date.getTime() - now.getTime();
|
|
120
121
|
const minutes = Math.abs(diff) / MINUTE;
|
|
121
122
|
let n;
|
|
122
123
|
let unit;
|
|
123
|
-
if (minutes < 1)
|
|
124
|
-
|
|
125
|
-
unit = "second";
|
|
126
|
-
} else if (minutes < 60) {
|
|
124
|
+
if (minutes < 1) return "just now";
|
|
125
|
+
else if (minutes < 60) {
|
|
127
126
|
n = Math.round(minutes);
|
|
128
127
|
unit = "minute";
|
|
129
128
|
} else if (minutes < 1440) {
|
|
@@ -826,7 +825,7 @@ function scoreBand(score) {
|
|
|
826
825
|
function normalizeRaidItem(r) {
|
|
827
826
|
const kind = r.kind === "issue" || r.kind === "decision" ? r.kind : "risk";
|
|
828
827
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
829
|
-
const impact = kind === "decision" ? null : clampScale(r.impact) ?? (r.severity ? impactFromSeverity(r.severity) :
|
|
828
|
+
const impact = kind === "decision" ? null : clampScale(r.impact) ?? (r.impact === void 0 && r.severity ? impactFromSeverity(r.severity) : null);
|
|
830
829
|
const probability = kind === "risk" ? clampScale(r.probability) : null;
|
|
831
830
|
const allowed = STATUSES[kind];
|
|
832
831
|
const status = r.status && allowed.includes(r.status) ? r.status : allowed[0];
|
|
@@ -964,7 +963,9 @@ function stepsFor(kind, data, ctx, me) {
|
|
|
964
963
|
{
|
|
965
964
|
key: "endDate",
|
|
966
965
|
label: "Ends",
|
|
967
|
-
kind: "date"
|
|
966
|
+
kind: "date",
|
|
967
|
+
optional: true,
|
|
968
|
+
skipSays: "for 60 days from today"
|
|
968
969
|
},
|
|
969
970
|
{
|
|
970
971
|
key: "budget",
|
|
@@ -1263,6 +1264,18 @@ const ALL_FIELDS = {
|
|
|
1263
1264
|
};
|
|
1264
1265
|
const DEFAULT_DOING_WIP = 3;
|
|
1265
1266
|
const DEFAULT_REVIEW_WIP = 2;
|
|
1267
|
+
/** The column's cap after "No cap" (`uncapped`) or a number is picked. */
|
|
1268
|
+
function withWipLimit(col, limit) {
|
|
1269
|
+
const { uncapped: _drop, ...rest } = col;
|
|
1270
|
+
return limit == null ? {
|
|
1271
|
+
...rest,
|
|
1272
|
+
wipLimit: null,
|
|
1273
|
+
uncapped: true
|
|
1274
|
+
} : {
|
|
1275
|
+
...rest,
|
|
1276
|
+
wipLimit: limit
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1266
1279
|
function defaultWipFor(status) {
|
|
1267
1280
|
if (status === "doing") return 3;
|
|
1268
1281
|
if (status === "review") return 2;
|
|
@@ -1336,6 +1349,15 @@ function clampWip(value) {
|
|
|
1336
1349
|
if (!Number.isFinite(n) || n <= 0) return null;
|
|
1337
1350
|
return Math.min(20, Math.round(n));
|
|
1338
1351
|
}
|
|
1352
|
+
function capOf(c) {
|
|
1353
|
+
const n = clampWip(c.wipLimit);
|
|
1354
|
+
if (n != null) return { wipLimit: n };
|
|
1355
|
+
if (c.uncapped === true) return {
|
|
1356
|
+
wipLimit: null,
|
|
1357
|
+
uncapped: true
|
|
1358
|
+
};
|
|
1359
|
+
return { wipLimit: defaultWipFor(c.status) };
|
|
1360
|
+
}
|
|
1339
1361
|
function normalizeBoardConfig(raw) {
|
|
1340
1362
|
const base = defaultBoardConfig();
|
|
1341
1363
|
if (!raw) return base;
|
|
@@ -1349,7 +1371,7 @@ function normalizeBoardConfig(raw) {
|
|
|
1349
1371
|
status: c.status,
|
|
1350
1372
|
label: typeof c.label === "string" && c.label.trim() ? c.label.trim() : TASK_STATUS_LABEL[c.status],
|
|
1351
1373
|
visible: c.visible !== false,
|
|
1352
|
-
|
|
1374
|
+
...capOf(c)
|
|
1353
1375
|
});
|
|
1354
1376
|
}
|
|
1355
1377
|
for (const status of TASK_COLUMNS) if (!order.includes(status)) order.push(status);
|
|
@@ -2333,15 +2355,22 @@ function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel
|
|
|
2333
2355
|
bold: true,
|
|
2334
2356
|
children: label
|
|
2335
2357
|
}),
|
|
2336
|
-
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { children: [value
|
|
2337
|
-
color: theme.muted,
|
|
2338
|
-
children: placeholder ?? ""
|
|
2339
|
-
}), /* @__PURE__ */ jsx(Text, {
|
|
2358
|
+
/* @__PURE__ */ jsx(Box, { children: value ? /* @__PURE__ */ jsxs(Text, { children: [value, /* @__PURE__ */ jsx(Text, {
|
|
2340
2359
|
inverse: true,
|
|
2341
2360
|
children: " "
|
|
2342
|
-
})] })
|
|
2361
|
+
})] }) : /* @__PURE__ */ jsxs(Text, {
|
|
2362
|
+
wrap: "truncate-end",
|
|
2363
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
2364
|
+
inverse: true,
|
|
2365
|
+
children: " "
|
|
2366
|
+
}), /* @__PURE__ */ jsx(Text, {
|
|
2367
|
+
color: theme.muted,
|
|
2368
|
+
children: placeholder ?? ""
|
|
2369
|
+
})]
|
|
2370
|
+
}) }),
|
|
2343
2371
|
/* @__PURE__ */ jsx(Text, {
|
|
2344
2372
|
color: theme.muted,
|
|
2373
|
+
wrap: "truncate-end",
|
|
2345
2374
|
children: hint ?? "Enter to keep · Esc to leave it"
|
|
2346
2375
|
})
|
|
2347
2376
|
]
|
|
@@ -2654,7 +2683,7 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
|
|
|
2654
2683
|
}, step.key) : /* @__PURE__ */ jsx(TextField, {
|
|
2655
2684
|
label,
|
|
2656
2685
|
placeholder: step.kind === "date" ? "YYYY-MM-DD, today, +30" : step.kind === "text" ? step.placeholder : void 0,
|
|
2657
|
-
hint: error ?? (step.optional ?
|
|
2686
|
+
hint: error ?? (step.optional ? `Enter ${step.kind === "date" && step.skipSays ? step.skipSays : "to skip"} · Esc to leave it` : void 0),
|
|
2658
2687
|
onCancel,
|
|
2659
2688
|
onSubmit: (text) => {
|
|
2660
2689
|
const t = text.trim();
|
|
@@ -5204,37 +5233,45 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
5204
5233
|
children: [
|
|
5205
5234
|
/* @__PURE__ */ jsxs(Box, {
|
|
5206
5235
|
justifyContent: "space-between",
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5236
|
+
columnGap: 2,
|
|
5237
|
+
children: [/* @__PURE__ */ jsx(Box, {
|
|
5238
|
+
flexShrink: 1,
|
|
5239
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
5240
|
+
wrap: "truncate-end",
|
|
5241
|
+
children: [
|
|
5242
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5243
|
+
bold: true,
|
|
5244
|
+
children: program.name
|
|
5245
|
+
}),
|
|
5246
|
+
" ",
|
|
5247
|
+
/* @__PURE__ */ jsx(HealthMark, {
|
|
5248
|
+
health: program.health,
|
|
5249
|
+
label: true
|
|
5250
|
+
}),
|
|
5251
|
+
program.healthOverride ? /* @__PURE__ */ jsxs(Text, {
|
|
5252
|
+
color: theme.muted,
|
|
5253
|
+
children: [
|
|
5254
|
+
" (by hand: ",
|
|
5255
|
+
program.healthNote || "no note",
|
|
5256
|
+
")"
|
|
5257
|
+
]
|
|
5258
|
+
}) : null
|
|
5259
|
+
]
|
|
5260
|
+
})
|
|
5261
|
+
}), /* @__PURE__ */ jsx(Box, {
|
|
5262
|
+
flexShrink: 2,
|
|
5263
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
5264
|
+
color: theme.muted,
|
|
5265
|
+
wrap: "truncate-end",
|
|
5266
|
+
children: [
|
|
5267
|
+
portfolio?.name,
|
|
5268
|
+
lead ? ` · ${lead.name}` : "",
|
|
5269
|
+
" · ",
|
|
5270
|
+
shortDay(program.startDate, today()),
|
|
5271
|
+
"–",
|
|
5272
|
+
shortDay(program.endDate, today())
|
|
5273
|
+
]
|
|
5274
|
+
})
|
|
5238
5275
|
})]
|
|
5239
5276
|
}),
|
|
5240
5277
|
/* @__PURE__ */ jsxs(Text, {
|
|
@@ -5798,37 +5835,47 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5798
5835
|
borderColor: on ? theme.accent : theme.border,
|
|
5799
5836
|
paddingX: 1,
|
|
5800
5837
|
children: [
|
|
5801
|
-
/* @__PURE__ */ jsxs(Box, {
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5838
|
+
/* @__PURE__ */ jsxs(Box, {
|
|
5839
|
+
height: 1,
|
|
5840
|
+
overflow: "hidden",
|
|
5841
|
+
children: [
|
|
5842
|
+
i === 0 && firstColumn > 0 ? /* @__PURE__ */ jsx(Box, {
|
|
5843
|
+
flexShrink: 0,
|
|
5844
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
5845
|
+
color: theme.muted,
|
|
5846
|
+
children: "‹ "
|
|
5847
|
+
})
|
|
5848
|
+
}) : null,
|
|
5849
|
+
/* @__PURE__ */ jsx(Box, {
|
|
5850
|
+
flexGrow: 1,
|
|
5851
|
+
flexShrink: 1,
|
|
5852
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
5853
|
+
bold: true,
|
|
5854
|
+
color: on ? theme.accent : void 0,
|
|
5855
|
+
wrap: "truncate-end",
|
|
5856
|
+
children: [
|
|
5857
|
+
c.label.toUpperCase(),
|
|
5858
|
+
" ",
|
|
5859
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5860
|
+
color: over ? theme.bad : theme.muted,
|
|
5861
|
+
children: [
|
|
5862
|
+
c.tasks.length < c.all ? `${c.tasks.length} of ` : "",
|
|
5863
|
+
c.all,
|
|
5864
|
+
c.limit != null ? `/${c.limit}` : ""
|
|
5865
|
+
]
|
|
5866
|
+
})
|
|
5867
|
+
]
|
|
5868
|
+
})
|
|
5869
|
+
}),
|
|
5870
|
+
i === shownColumns.length - 1 && ci < columns.length - 1 ? /* @__PURE__ */ jsx(Box, {
|
|
5871
|
+
flexShrink: 0,
|
|
5872
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
5873
|
+
color: theme.muted,
|
|
5874
|
+
children: " ›"
|
|
5875
|
+
})
|
|
5876
|
+
}) : null
|
|
5877
|
+
]
|
|
5878
|
+
}),
|
|
5832
5879
|
c.tasks.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
5833
5880
|
color: theme.muted,
|
|
5834
5881
|
children: "—"
|
|
@@ -6743,6 +6790,7 @@ function TodayView({ active, width, height, go }) {
|
|
|
6743
6790
|
else if (key.pageUp) setAt((i) => step(i, -10, mine.length));
|
|
6744
6791
|
else if (key.return && selected) go.openTask(selected.id);
|
|
6745
6792
|
else if (input === "x" && selected) moveTask(store, go, selected, "done");
|
|
6793
|
+
else if (input === "n" && projects.length === 0) go.create("project");
|
|
6746
6794
|
else if (input === "n") go.newTask(selected?.projectId ?? null);
|
|
6747
6795
|
else if (input === "f" && selected) {
|
|
6748
6796
|
if (store.getState().toggleFocus(selected.id) === "full") go.notify(`Today's focus holds ${3}. f on one of them takes it off first.`);
|
|
@@ -6800,7 +6848,7 @@ function TodayView({ active, width, height, go }) {
|
|
|
6800
6848
|
}),
|
|
6801
6849
|
mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
6802
6850
|
color: theme.muted,
|
|
6803
|
-
children: "Nothing open is yours. n adds a task."
|
|
6851
|
+
children: projects.length ? "Nothing open is yours. n adds a task." : "Nothing open is yours. n starts your first project."
|
|
6804
6852
|
}) : view.items.map((t, i) => /* @__PURE__ */ jsx(TaskLine, {
|
|
6805
6853
|
task: t,
|
|
6806
6854
|
projects,
|
|
@@ -7505,8 +7553,9 @@ function daysUntil(date, now) {
|
|
|
7505
7553
|
function incomplete(task) {
|
|
7506
7554
|
return task.status !== "done";
|
|
7507
7555
|
}
|
|
7508
|
-
function wipLimitOf(status, stored) {
|
|
7556
|
+
function wipLimitOf(status, stored, uncapped) {
|
|
7509
7557
|
if (stored != null) return stored;
|
|
7558
|
+
if (uncapped === true) return null;
|
|
7510
7559
|
if (status === "doing") return 3;
|
|
7511
7560
|
if (status === "review") return 2;
|
|
7512
7561
|
return null;
|
|
@@ -7515,7 +7564,7 @@ function wipOverflowing(data, projectId) {
|
|
|
7515
7564
|
const cols = data.boardConfigs?.[projectId]?.columns;
|
|
7516
7565
|
if (cols?.length) return cols.some((c) => {
|
|
7517
7566
|
if (c.visible === false) return false;
|
|
7518
|
-
const limit = wipLimitOf(c.status, c.wipLimit);
|
|
7567
|
+
const limit = wipLimitOf(c.status, c.wipLimit, c.uncapped);
|
|
7519
7568
|
if (limit == null) return false;
|
|
7520
7569
|
return tasksOf(data.tasks, projectId).filter((t) => t.status === c.status).length > limit;
|
|
7521
7570
|
});
|
|
@@ -8677,10 +8726,7 @@ function hierarchySlice({ get, write }) {
|
|
|
8677
8726
|
const columns = config.columns.map((c) => {
|
|
8678
8727
|
if (c.status !== status) return c;
|
|
8679
8728
|
const floor = Math.max(c.wipLimit ?? 0, current);
|
|
8680
|
-
return
|
|
8681
|
-
...c,
|
|
8682
|
-
wipLimit: Math.min(20, floor + 1)
|
|
8683
|
-
};
|
|
8729
|
+
return withWipLimit(c, Math.min(20, floor + 1));
|
|
8684
8730
|
});
|
|
8685
8731
|
get().setBoardConfig(projectId, {
|
|
8686
8732
|
...config,
|
|
@@ -9090,15 +9136,17 @@ function riskTitle(answer) {
|
|
|
9090
9136
|
title = title.replace(/\.$/, "");
|
|
9091
9137
|
return title;
|
|
9092
9138
|
}
|
|
9093
|
-
/**
|
|
9094
|
-
|
|
9139
|
+
/**
|
|
9140
|
+
* The risk an answer becomes: on its project, open, unrated, with the whole
|
|
9141
|
+
* answer as what could happen. Scoped to the project alone, like a risk
|
|
9142
|
+
* logged on the project page; the program and portfolio roll it up from there.
|
|
9143
|
+
*/
|
|
9144
|
+
function riskFromAnswer(answer, project) {
|
|
9095
9145
|
return {
|
|
9096
9146
|
kind: "risk",
|
|
9097
9147
|
title: riskTitle(answer.body),
|
|
9098
9148
|
description: `${answer.body.trim()}\n\nRaised in the pre-mortem.`,
|
|
9099
9149
|
projectId: project.id,
|
|
9100
|
-
programId: project.programId,
|
|
9101
|
-
portfolioId,
|
|
9102
9150
|
status: "open",
|
|
9103
9151
|
ownerId: null,
|
|
9104
9152
|
dueDate: null
|
|
@@ -9304,8 +9352,7 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
9304
9352
|
if (!answer || answer.raidId) return answer?.raidId ?? null;
|
|
9305
9353
|
const project = get().projects.find((p) => p.id === answer.projectId);
|
|
9306
9354
|
if (!project) return null;
|
|
9307
|
-
const
|
|
9308
|
-
const raidId = get().addRaidItem(riskFromAnswer(answer, project, program?.portfolioId ?? null));
|
|
9355
|
+
const raidId = get().addRaidItem(riskFromAnswer(answer, project));
|
|
9309
9356
|
write({ reflections: (get().reflections ?? []).map((r) => r.id === id ? {
|
|
9310
9357
|
...r,
|
|
9311
9358
|
raidId,
|
|
@@ -10328,11 +10375,17 @@ function createUiStore() {
|
|
|
10328
10375
|
|
|
10329
10376
|
//#endregion
|
|
10330
10377
|
//#region src/lib/empty.ts
|
|
10331
|
-
/**
|
|
10332
|
-
|
|
10378
|
+
/**
|
|
10379
|
+
* A name worth offering in the harbor-name field: "Sam's harbor", or "" when
|
|
10380
|
+
* nobody is known yet, so the field stays empty and shows its placeholder.
|
|
10381
|
+
*/
|
|
10382
|
+
function harborNameFor(displayName, email) {
|
|
10333
10383
|
const first = (displayName || email || "").split(/[\s@]/)[0]?.trim();
|
|
10334
|
-
|
|
10335
|
-
|
|
10384
|
+
return first ? `${first}'s harbor` : "";
|
|
10385
|
+
}
|
|
10386
|
+
/** What a new harbor is called until its owner names it: "Sam's harbor", else "My harbor". */
|
|
10387
|
+
function suggestedHarborName(displayName, email) {
|
|
10388
|
+
return harborNameFor(displayName, email) || "My harbor";
|
|
10336
10389
|
}
|
|
10337
10390
|
|
|
10338
10391
|
//#endregion
|
package/lib/cli.mjs
CHANGED
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
writeSettings,
|
|
18
18
|
} from "./config.mjs";
|
|
19
19
|
import {
|
|
20
|
+
STATUS,
|
|
20
21
|
harbors,
|
|
22
|
+
limitsText,
|
|
21
23
|
myWork,
|
|
22
24
|
premortemText,
|
|
23
25
|
voyageText,
|
|
@@ -58,6 +60,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
58
60
|
harbor programs [--done] programs and their projects
|
|
59
61
|
harbor projects [words] [--program P] [--done]
|
|
60
62
|
projects with health and open work
|
|
63
|
+
(--json adds each board's limits)
|
|
61
64
|
harbor mine what's on you, late first
|
|
62
65
|
harbor tasks [words] [--project P] [--assignee me|none|NAME]
|
|
63
66
|
[--late] [--status S,S]
|
|
@@ -82,6 +85,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
82
85
|
[--priority P] [--status S] [--notes TEXT]
|
|
83
86
|
[--repeat weekly|biweekly|monthly|never]
|
|
84
87
|
[--waits-on TASK,TASK|none] [--confirm] [--over-limit]
|
|
88
|
+
harbor limit PROJECT [COLUMN N|none] how many tasks a board column
|
|
89
|
+
takes at once; the owner sets it
|
|
85
90
|
harbor comment TASK "TEXT"
|
|
86
91
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
87
92
|
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
@@ -91,10 +96,11 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
91
96
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
92
97
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
93
98
|
the register, worst first
|
|
99
|
+
(--closed adds closed ones)
|
|
94
100
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
95
101
|
a project's pre-mortem: start,
|
|
96
102
|
answer or read it
|
|
97
|
-
harbor voyage PROJECT ["YOUR LINE"]
|
|
103
|
+
harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
|
|
98
104
|
your line in it
|
|
99
105
|
|
|
100
106
|
Every command takes --json, and --harbor NAME when you're in more than one
|
|
@@ -211,6 +217,8 @@ const FLAG = {
|
|
|
211
217
|
repeat: "--repeat",
|
|
212
218
|
description: "--notes",
|
|
213
219
|
body: "the comment",
|
|
220
|
+
column: "the column",
|
|
221
|
+
limit: "the limit",
|
|
214
222
|
};
|
|
215
223
|
|
|
216
224
|
/** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
|
|
@@ -234,7 +242,9 @@ export function hint(message) {
|
|
|
234
242
|
.replace(
|
|
235
243
|
/^Invalid option: expected one of (.*)$/,
|
|
236
244
|
(_, list) => `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
|
|
237
|
-
)
|
|
245
|
+
)
|
|
246
|
+
// The CLI reads today, tomorrow and +N before Harbor sees the date.
|
|
247
|
+
.replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow or +N days");
|
|
238
248
|
if (!seen.has(flag)) seen.set(flag, said);
|
|
239
249
|
}
|
|
240
250
|
return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
|
|
@@ -279,6 +289,17 @@ export function premortemForTerminal(r) {
|
|
|
279
289
|
|
|
280
290
|
/** Commands whose forms do different things: one line each, saying which read and which write. */
|
|
281
291
|
const DETAIL = {
|
|
292
|
+
limit: [
|
|
293
|
+
" harbor limit PROJECT reads its board's column limits",
|
|
294
|
+
" harbor limit PROJECT COLUMN N writes: COLUMN takes at most N tasks",
|
|
295
|
+
" at once (1 to 20; the website offers",
|
|
296
|
+
" 1-5, 8, 10 and 15)",
|
|
297
|
+
" harbor limit PROJECT COLUMN none writes: no limit on COLUMN",
|
|
298
|
+
"",
|
|
299
|
+
" COLUMN is a status: doing or review usually, or backlog, todo, done.",
|
|
300
|
+
" Only the harbor's owner can change a limit. A move past a limit is",
|
|
301
|
+
" refused until you run it again with --over-limit.",
|
|
302
|
+
],
|
|
282
303
|
premortem: [
|
|
283
304
|
" harbor premortem PROJECT reads it: the question, the time left,",
|
|
284
305
|
" and the answers you may see",
|
|
@@ -288,13 +309,15 @@ const DETAIL = {
|
|
|
288
309
|
' harbor premortem PROJECT "ANSWER" --start',
|
|
289
310
|
" writes: starts one and adds your answer",
|
|
290
311
|
"",
|
|
291
|
-
" PROJECT is a name or an id. When two projects share a
|
|
312
|
+
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
313
|
+
" name, give the key.",
|
|
292
314
|
],
|
|
293
315
|
voyage: [
|
|
294
316
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
295
317
|
' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
|
|
296
318
|
"",
|
|
297
|
-
" PROJECT is a name or an id. When two projects share a
|
|
319
|
+
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
320
|
+
" name, give the key.",
|
|
298
321
|
],
|
|
299
322
|
};
|
|
300
323
|
|
|
@@ -307,11 +330,12 @@ export function commandHelp(command) {
|
|
|
307
330
|
const names = words.filter(
|
|
308
331
|
(_, j) => j > 0 && (words[j - 1] === "harbor" || words[j - 1] === "|"),
|
|
309
332
|
);
|
|
310
|
-
|
|
333
|
+
// A command line names one (the footer's "harbor (or set one…" doesn't).
|
|
334
|
+
if (words[0] !== "harbor" || !/^[a-z-]+$/.test(words[1] ?? "") || !names.includes(command))
|
|
335
|
+
continue;
|
|
311
336
|
picked.push(lines[i]);
|
|
312
|
-
// Its options carry on below it, further in.
|
|
313
|
-
while (lines[i + 1]
|
|
314
|
-
picked.push(lines[++i]);
|
|
337
|
+
// Its options and the rest of its description carry on below it, further in.
|
|
338
|
+
while (/^ {6,}\S/.test(lines[i + 1] ?? "")) picked.push(lines[++i]);
|
|
315
339
|
}
|
|
316
340
|
if (DETAIL[command]) picked.splice(0, picked.length, ...DETAIL[command]);
|
|
317
341
|
if (picked.length === 0) return null;
|
|
@@ -321,6 +345,40 @@ export function commandHelp(command) {
|
|
|
321
345
|
return [...picked, ...common].join("\n");
|
|
322
346
|
}
|
|
323
347
|
|
|
348
|
+
/** Every command the help names, for spotting a typo. */
|
|
349
|
+
/** "zsh, bash or fish". */
|
|
350
|
+
const either = (list) => `${list.slice(0, -1).join(", ")} or ${list.at(-1)}`;
|
|
351
|
+
|
|
352
|
+
const COMMANDS = [
|
|
353
|
+
...new Set(
|
|
354
|
+
HELP.split("\n").flatMap((line) =>
|
|
355
|
+
[...line.matchAll(/(?:^\s+harbor|\|) ([a-z][a-z-]+)/g)].map((m) => m[1]),
|
|
356
|
+
),
|
|
357
|
+
),
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
/** How many single-letter changes (or swaps of neighbours) turn a into b. */
|
|
361
|
+
function distance(a, b) {
|
|
362
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
363
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
364
|
+
for (let i = 1; i <= a.length; i++)
|
|
365
|
+
for (let j = 1; j <= b.length; j++) {
|
|
366
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
367
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
368
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
|
|
369
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
370
|
+
}
|
|
371
|
+
return d[a.length][b.length];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A mistyped command, with the one it was probably meant to be. */
|
|
375
|
+
export function noCommand(name) {
|
|
376
|
+
if (name.startsWith("-")) return `${name} goes after a command, like harbor mine ${name}.`;
|
|
377
|
+
const near = COMMANDS.filter((c) => distance(name, c) <= (c.length > 4 ? 2 : 1));
|
|
378
|
+
const guess = near.length === 1 ? ` Did you mean harbor ${near[0]}?` : "";
|
|
379
|
+
return `No command "${name}".${guess} Run harbor help.`;
|
|
380
|
+
}
|
|
381
|
+
|
|
324
382
|
/** Room for --json and --harbor before the command too: `harbor --json mine`. */
|
|
325
383
|
function commandFirst(argv) {
|
|
326
384
|
const front = [];
|
|
@@ -386,9 +444,9 @@ export async function run(argv, io = {}) {
|
|
|
386
444
|
|
|
387
445
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
388
446
|
// `harbor help mine`: that command's help, as `harbor mine --help` gives it.
|
|
389
|
-
if (rest[0]) {
|
|
447
|
+
if (rest[0] && rest[0] !== "help") {
|
|
390
448
|
const help = commandHelp(rest[0]);
|
|
391
|
-
if (help === null) throw new HarborError(
|
|
449
|
+
if (help === null) throw new HarborError(noCommand(rest[0]));
|
|
392
450
|
out(help);
|
|
393
451
|
return 0;
|
|
394
452
|
}
|
|
@@ -413,7 +471,7 @@ export async function run(argv, io = {}) {
|
|
|
413
471
|
if (command && asksHelp(rest)) {
|
|
414
472
|
const help = commandHelp(command === "app" ? "" : command);
|
|
415
473
|
if (help === null && command !== "app") {
|
|
416
|
-
throw new HarborError(
|
|
474
|
+
throw new HarborError(noCommand(command));
|
|
417
475
|
}
|
|
418
476
|
out(help ?? HELP);
|
|
419
477
|
return 0;
|
|
@@ -515,7 +573,20 @@ export async function run(argv, io = {}) {
|
|
|
515
573
|
await writeCredentials(creds, env);
|
|
516
574
|
const canWrite = creds.scope.split(" ").includes("harbor:write");
|
|
517
575
|
const said = `Signed in to ${server}${canWrite ? "." : ", reading only."}`;
|
|
518
|
-
|
|
576
|
+
// Someone who made their account on the sign-in page has no harbor yet either.
|
|
577
|
+
const fresh =
|
|
578
|
+
signUp ||
|
|
579
|
+
(await createClient({
|
|
580
|
+
creds,
|
|
581
|
+
// A renewed sign-in keeps the harbor set with harbor use.
|
|
582
|
+
save: (c) =>
|
|
583
|
+
writeCredentials({ ...c, harbor: creds.harbor, harborName: creds.harborName }, env),
|
|
584
|
+
fetcher,
|
|
585
|
+
})
|
|
586
|
+
.call("list_harbors", {})
|
|
587
|
+
.then((r) => (r.harbors ?? []).length === 0)
|
|
588
|
+
.catch(() => false));
|
|
589
|
+
const next = fresh ? "Next: run harbor, and it helps you make your first harbor." : null;
|
|
519
590
|
const welcome = banner(io, env, ({ bold, dim }) => [
|
|
520
591
|
bold("Welcome aboard."),
|
|
521
592
|
said,
|
|
@@ -565,7 +636,7 @@ export async function run(argv, io = {}) {
|
|
|
565
636
|
none(positionals);
|
|
566
637
|
if (values.init !== undefined) {
|
|
567
638
|
const snippet = initSnippet(values.init);
|
|
568
|
-
if (!snippet) throw new HarborError(`Pick ${SHELLS
|
|
639
|
+
if (!snippet) throw new HarborError(`Pick ${either(SHELLS)} (not "${values.init}").`);
|
|
569
640
|
out(
|
|
570
641
|
// Comments, so the whole of it can be appended straight to the file.
|
|
571
642
|
`${snippet}\n# Shows what's due today and late, from the prompt after next;\n# nothing when you're clear. harbor prompt --refresh checks now.`,
|
|
@@ -574,7 +645,7 @@ export async function run(argv, io = {}) {
|
|
|
574
645
|
}
|
|
575
646
|
const shell = values.shell;
|
|
576
647
|
if (shell !== undefined && !SHELLS.includes(shell))
|
|
577
|
-
throw new HarborError(`--shell is ${SHELLS
|
|
648
|
+
throw new HarborError(`--shell is ${either(SHELLS)} (not "${shell}").`);
|
|
578
649
|
const creds = await readCredentials(env);
|
|
579
650
|
const harbor = values.harbor ?? creds?.harbor ?? null;
|
|
580
651
|
const today = todayLocal(io.now ? new Date(io.now()) : new Date());
|
|
@@ -615,7 +686,7 @@ export async function run(argv, io = {}) {
|
|
|
615
686
|
|
|
616
687
|
// A mistyped command says so, signed in or not.
|
|
617
688
|
if (commandHelp(command) === null) {
|
|
618
|
-
throw new HarborError(
|
|
689
|
+
throw new HarborError(noCommand(command));
|
|
619
690
|
}
|
|
620
691
|
const creds = await readCredentials(env);
|
|
621
692
|
if (!creds)
|
|
@@ -782,13 +853,14 @@ export async function run(argv, io = {}) {
|
|
|
782
853
|
}
|
|
783
854
|
case "show": {
|
|
784
855
|
const { positionals, harbor } = parse(spec());
|
|
785
|
-
return call("get_task", { harbor, task: one(positionals, "task
|
|
856
|
+
return call("get_task", { harbor, task: one(positionals, "task key, like RR-42") }, (t) => {
|
|
786
857
|
const lines = [
|
|
787
858
|
taskLine(
|
|
788
859
|
{ ...t, waitingOn: t.waitingOn.filter((w) => w.status !== "done").length },
|
|
789
860
|
{ ...opts, width: undefined },
|
|
790
861
|
),
|
|
791
862
|
];
|
|
863
|
+
if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
|
|
792
864
|
if (t.description) lines.push("", t.description);
|
|
793
865
|
if (t.checklist.length)
|
|
794
866
|
lines.push("", ...t.checklist.map((c) => ` [${c.done ? "x" : " "}] ${c.step}`));
|
|
@@ -796,13 +868,17 @@ export async function run(argv, io = {}) {
|
|
|
796
868
|
lines.push(
|
|
797
869
|
"",
|
|
798
870
|
"Waits on:",
|
|
799
|
-
...t.waitingOn.map(
|
|
871
|
+
...t.waitingOn.map(
|
|
872
|
+
(w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`,
|
|
873
|
+
),
|
|
800
874
|
);
|
|
801
875
|
if (t.waitedOnBy.length)
|
|
802
876
|
lines.push(
|
|
803
877
|
"",
|
|
804
878
|
"Holds up:",
|
|
805
|
-
...t.waitedOnBy.map(
|
|
879
|
+
...t.waitedOnBy.map(
|
|
880
|
+
(w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`,
|
|
881
|
+
),
|
|
806
882
|
);
|
|
807
883
|
if (t.comments.length)
|
|
808
884
|
lines.push(
|
|
@@ -857,7 +933,7 @@ export async function run(argv, io = {}) {
|
|
|
857
933
|
out(jsonText(r));
|
|
858
934
|
return 0;
|
|
859
935
|
}
|
|
860
|
-
const as = r.role === "viewer" ? "
|
|
936
|
+
const as = r.role === "viewer" ? "someone who can view (not change)" : "crew who can edit";
|
|
861
937
|
out(
|
|
862
938
|
plain(
|
|
863
939
|
r.sent
|
|
@@ -924,7 +1000,7 @@ export async function run(argv, io = {}) {
|
|
|
924
1000
|
"update_task",
|
|
925
1001
|
{
|
|
926
1002
|
harbor,
|
|
927
|
-
task: one(positionals, "task
|
|
1003
|
+
task: one(positionals, "task key, like RR-42"),
|
|
928
1004
|
status,
|
|
929
1005
|
confirmed: values.confirm || undefined,
|
|
930
1006
|
overLimit: values["over-limit"] || undefined,
|
|
@@ -950,7 +1026,7 @@ export async function run(argv, io = {}) {
|
|
|
950
1026
|
"update_task",
|
|
951
1027
|
{
|
|
952
1028
|
harbor,
|
|
953
|
-
task: one(positionals, "task
|
|
1029
|
+
task: one(positionals, "task key, like RR-42"),
|
|
954
1030
|
title: values.title,
|
|
955
1031
|
assignee: values.assignee,
|
|
956
1032
|
due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
|
|
@@ -983,7 +1059,7 @@ export async function run(argv, io = {}) {
|
|
|
983
1059
|
case "comment": {
|
|
984
1060
|
const { positionals, harbor } = parse(spec());
|
|
985
1061
|
const [task, ...words] = positionals;
|
|
986
|
-
if (!task) throw new HarborError("Give a task
|
|
1062
|
+
if (!task) throw new HarborError("Give a task key, like RR-42, then the comment.");
|
|
987
1063
|
return call(
|
|
988
1064
|
"add_comment",
|
|
989
1065
|
{ harbor, task, body: text(words, "the comment") },
|
|
@@ -1056,6 +1132,24 @@ export async function run(argv, io = {}) {
|
|
|
1056
1132
|
}
|
|
1057
1133
|
return call("get_premortem", { harbor, project }, premortemText, premortemForTerminal);
|
|
1058
1134
|
}
|
|
1135
|
+
case "limit": {
|
|
1136
|
+
const { positionals, harbor } = parse(spec());
|
|
1137
|
+
const [project, column, value, ...extra] = positionals;
|
|
1138
|
+
if (!project) throw new HarborError("Give a project, like harbor limit RR doing 5.");
|
|
1139
|
+
if (!column) {
|
|
1140
|
+
return call("get_project", { harbor, project }, (r) => limitsText(r.name, r.limits));
|
|
1141
|
+
}
|
|
1142
|
+
if (value === undefined || extra.length) {
|
|
1143
|
+
throw new HarborError(`Give one limit for ${column}: a number like 5, or none.`);
|
|
1144
|
+
}
|
|
1145
|
+
const limit = /^(none|no|off|0)$/i.test(value) ? null : Number(value);
|
|
1146
|
+
if (limit !== null && !(Number.isInteger(limit) && limit >= 1 && limit <= 20)) {
|
|
1147
|
+
throw new HarborError("A limit is a whole number from 1 to 20, or none.");
|
|
1148
|
+
}
|
|
1149
|
+
return call("set_column_limit", { harbor, project, column, limit }, (r) =>
|
|
1150
|
+
limitsText(r.set.project, r.limits),
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1059
1153
|
case "voyage": {
|
|
1060
1154
|
const { positionals, harbor } = parse(spec());
|
|
1061
1155
|
const [project, ...words] = positionals;
|
package/lib/format.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Plain text for a terminal: one task per line, its key (or id) first so it can be copied. */
|
|
2
2
|
|
|
3
|
-
const STATUS = {
|
|
3
|
+
export const STATUS = {
|
|
4
4
|
backlog: "backlog",
|
|
5
5
|
todo: "to do",
|
|
6
6
|
doing: "doing",
|
|
@@ -55,7 +55,9 @@ export function myWork(w, opts) {
|
|
|
55
55
|
out.push("", "Yours in the register:");
|
|
56
56
|
for (const r of w.raid)
|
|
57
57
|
// No command takes a risk's id, so it isn't shown.
|
|
58
|
-
out.push(
|
|
58
|
+
out.push(
|
|
59
|
+
` ${r.kind}: ${r.title}${r.due ? ` (${r.kind === "Decision" ? "decide" : "review"} by ${r.due})` : ""}`,
|
|
60
|
+
);
|
|
59
61
|
}
|
|
60
62
|
return out.join("\n");
|
|
61
63
|
}
|
|
@@ -127,6 +129,7 @@ export function projectList(r, { done = false } = {}) {
|
|
|
127
129
|
.map((p) => {
|
|
128
130
|
const late = p.tasks.late ? `, ${p.tasks.late} late` : "";
|
|
129
131
|
const bits = [
|
|
132
|
+
p.key,
|
|
130
133
|
p.program,
|
|
131
134
|
health(p.health),
|
|
132
135
|
`${p.tasks.total - p.tasks.done} open${late}`,
|
|
@@ -141,6 +144,14 @@ export function projectList(r, { done = false } = {}) {
|
|
|
141
144
|
);
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
/** A project's column limits: "doing 5, review no limit". */
|
|
148
|
+
export function limitsText(name, limits) {
|
|
149
|
+
const bits = Object.entries(limits ?? {}).map(
|
|
150
|
+
([status, n]) => `${STATUS[status] ?? status} ${n == null ? "no limit" : n}`,
|
|
151
|
+
);
|
|
152
|
+
return bits.length ? `${name}: ${bits.join(", ")}.` : `${name}'s board has no column limits.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
144
155
|
export function harbors(r) {
|
|
145
156
|
const list = r.harbors ?? [];
|
|
146
157
|
if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
|