@harborppm/cli 0.24.0 → 0.29.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 +5 -1
- package/dist/app.mjs +71 -4
- package/lib/cli.mjs +75 -0
- package/lib/format.mjs +34 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ Your harbor is the same one at [harborppm.com](https://harborppm.com/today?utm_s
|
|
|
57
57
|
- **GitHub**: a pull request that names a task's key (RR-42) shows on the task and moves it along.
|
|
58
58
|
- **Claude and other AI apps**, connected over MCP from [harborppm.com/ai](https://harborppm.com/ai?utm_source=npm&utm_medium=readme&utm_campaign=cli).
|
|
59
59
|
|
|
60
|
-
`harbor open RR-42` opens a task on the website (in the right harbor, even when the browser had another one open), `harbor open Ridge` a project, and `harbor open` on its own opens Today. `harbor invite sam@example.com` sends Sam the same invite letter the People page does (`--viewer` to let them look but not change anything); only a harbor's owner can invite. `harbor calendar` prints your private calendar link, to add to Google, Apple or Outlook calendar: your due dates, milestones and sign-offs beside your meetings, kept up to date (`--program NAME` for everything dated in one program, `--new` to swap the link for a fresh one, `--off` to turn it off).
|
|
60
|
+
`harbor open RR-42` opens a task on the website (in the right harbor, even when the browser had another one open), `harbor open Ridge` a project, and `harbor open` on its own opens Today. `harbor invite sam@example.com` sends Sam the same invite letter the People page does (`--viewer` to let them look but not change anything); only a harbor's owner can invite. `harbor calendar` prints your private calendar link, to add to Google, Apple or Outlook calendar: your due dates, milestones and sign-offs beside your meetings, kept up to date (`--program NAME` for everything dated in one program, `--new` to swap the link for a fresh one, `--off` to turn it off). `harbor roadmap "Next steps"` publishes a project as a public roadmap page (Now, Next and Shipped, task titles only) and prints its link; `--dates` adds the day each thing shipped and `--off` takes it down. Only a harbor's owner can publish.
|
|
61
61
|
|
|
62
62
|
The website has the commands too: turn on **Command line at the bottom** in your menu, press `/`, and type `mine`, `add "Call the mill" #ridge @sam --due fri` or `done RR-12`.
|
|
63
63
|
|
|
@@ -94,12 +94,16 @@ harbor programs programs and their projects
|
|
|
94
94
|
harbor projects [words] [--program P] projects with health and open work
|
|
95
95
|
harbor forecast [PROJECT] [--program P] when projects will likely finish, from
|
|
96
96
|
how fast their crews finish tasks
|
|
97
|
+
harbor hill [--program P] where each project is on its hill
|
|
98
|
+
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
|
|
99
|
+
past 50 is getting it done
|
|
97
100
|
harbor mine what's on you, late first
|
|
98
101
|
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
|
|
99
102
|
harbor show TASK one task in full, with its web link
|
|
100
103
|
harbor open [TASK|PROJECT] open it on the website (or Today)
|
|
101
104
|
harbor invite EMAIL [--viewer] invite someone to your harbor by email
|
|
102
105
|
harbor calendar [--program P] [--new|--off] your private calendar link (.ics)
|
|
106
|
+
harbor roadmap PROJECT [--dates] [--off] publish a project's public roadmap
|
|
103
107
|
|
|
104
108
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
105
109
|
[--ends DATE]
|
package/dist/app.mjs
CHANGED
|
@@ -4062,6 +4062,48 @@ function CalendarView({ active, width, height, go }) {
|
|
|
4062
4062
|
});
|
|
4063
4063
|
}
|
|
4064
4064
|
|
|
4065
|
+
//#endregion
|
|
4066
|
+
//#region src/lib/hill.ts
|
|
4067
|
+
/**
|
|
4068
|
+
* Hill charts, after Basecamp's Shape Up: each project is a dot on a hill.
|
|
4069
|
+
* Climbing the left side means still figuring out how to do it; rolling down
|
|
4070
|
+
* the right means the unknowns are solved and it's just the work. Owners
|
|
4071
|
+
* place their own dot, and a dot nobody has moved in two weeks fades, so a
|
|
4072
|
+
* stuck project shows without anyone saying so.
|
|
4073
|
+
*/
|
|
4074
|
+
/** Where the top of the hill is, on the 0–100 scale. */
|
|
4075
|
+
const HILL_TOP = 50;
|
|
4076
|
+
/** Days without a move before a dot reads as still. */
|
|
4077
|
+
const STILL_DAYS = 14;
|
|
4078
|
+
const HILL_PHASE_LABEL = {
|
|
4079
|
+
figuring: "Figuring it out",
|
|
4080
|
+
doing: "Getting it done"
|
|
4081
|
+
};
|
|
4082
|
+
/** A position kept on the scale, in whole steps. */
|
|
4083
|
+
function clampHill(at) {
|
|
4084
|
+
if (!Number.isFinite(at)) return 0;
|
|
4085
|
+
return Math.max(0, Math.min(100, Math.round(at)));
|
|
4086
|
+
}
|
|
4087
|
+
function hillPhase(at) {
|
|
4088
|
+
return clampHill(at) < 50 ? "figuring" : "doing";
|
|
4089
|
+
}
|
|
4090
|
+
/** Days since the dot last moved, or null when it's never been placed. */
|
|
4091
|
+
function stillFor(project, today) {
|
|
4092
|
+
if (!project.hillMovedAt) return null;
|
|
4093
|
+
return Math.max(0, differenceInCalendarDays(/* @__PURE__ */ new Date(`${today}T12:00:00`), /* @__PURE__ */ new Date(`${project.hillMovedAt.slice(0, 10)}T12:00:00`)));
|
|
4094
|
+
}
|
|
4095
|
+
function isStill(project, today) {
|
|
4096
|
+
const days = stillFor(project, today);
|
|
4097
|
+
return days != null && days >= 14;
|
|
4098
|
+
}
|
|
4099
|
+
/** "Figuring it out, 40 of 100", or "Not on the hill yet". */
|
|
4100
|
+
function hillText(project) {
|
|
4101
|
+
if (project.hillAt == null) return "Not on the hill yet";
|
|
4102
|
+
const at = clampHill(project.hillAt);
|
|
4103
|
+
if (at === 100) return "Over the hill: done";
|
|
4104
|
+
return `${HILL_PHASE_LABEL[hillPhase(at)]} (${at} of 100)`;
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4065
4107
|
//#endregion
|
|
4066
4108
|
//#region src/lib/history-copy.ts
|
|
4067
4109
|
/**
|
|
@@ -4231,6 +4273,10 @@ function describeEvent(e, names) {
|
|
|
4231
4273
|
action: "cleared the health override on",
|
|
4232
4274
|
detail: null
|
|
4233
4275
|
};
|
|
4276
|
+
case "hillAt": return {
|
|
4277
|
+
action: "moved",
|
|
4278
|
+
detail: `on the hill to ${hillText({ hillAt: to == null ? null : Number(to) }).toLowerCase()}`
|
|
4279
|
+
};
|
|
4234
4280
|
case "programId": return {
|
|
4235
4281
|
action: "moved",
|
|
4236
4282
|
detail: `to ${nameOf$1(names, to, "another program")}`
|
|
@@ -5475,7 +5521,7 @@ const HELP = [
|
|
|
5475
5521
|
["Enter / Esc", "open / back"],
|
|
5476
5522
|
["n", "new: a task, or what the screen lists"],
|
|
5477
5523
|
["e", "change what's open (health, lead, dates)"],
|
|
5478
|
-
["[ ]", "move a card
|
|
5524
|
+
["[ ] / { }", "move a card a column / a project along its hill"],
|
|
5479
5525
|
["x / d", "mark done / delete (asks first)"],
|
|
5480
5526
|
["u / r", "undo your last change / retry saving"],
|
|
5481
5527
|
["v", "switch layout: board, list, budget, by project"],
|
|
@@ -7205,6 +7251,10 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
7205
7251
|
else if (input === "d" && entry) go.confirm(`Take out ${money(entry.amount)}${entry.note ? ` for “${entry.note}”` : ""}? u undoes it.`, () => store.getState().removeSpend(entry.id), "Take it out");
|
|
7206
7252
|
return;
|
|
7207
7253
|
}
|
|
7254
|
+
if ((input === "{" || input === "}") && project) {
|
|
7255
|
+
const at = project.hillAt ?? (input === "}" ? -5 : 0);
|
|
7256
|
+
return store.getState().moveOnHill(projectId, at + (input === "}" ? 5 : -5));
|
|
7257
|
+
}
|
|
7208
7258
|
if (input === "[") return moveSelected(-1);
|
|
7209
7259
|
if (input === "]") return moveSelected(1);
|
|
7210
7260
|
if (key.return && selected) return go.openTask(selected.id);
|
|
@@ -7299,10 +7349,19 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
7299
7349
|
})
|
|
7300
7350
|
})]
|
|
7301
7351
|
}),
|
|
7302
|
-
/* @__PURE__ */
|
|
7303
|
-
color: tide?.late ? theme.warn : theme.muted,
|
|
7352
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
7304
7353
|
wrap: "truncate-end",
|
|
7305
|
-
children:
|
|
7354
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
7355
|
+
color: tide?.late ? theme.warn : theme.muted,
|
|
7356
|
+
children: tide ? `${tide.text} · ` : ""
|
|
7357
|
+
}), /* @__PURE__ */ jsxs(Text, {
|
|
7358
|
+
color: isStill(project, day) ? theme.warn : theme.muted,
|
|
7359
|
+
children: [
|
|
7360
|
+
project.hillAt == null ? "Not on the hill · } puts it on" : `Hill: ${hillText(project).toLowerCase()}`,
|
|
7361
|
+
isStill(project, day) ? `, still ${stillFor(project, day)} days` : "",
|
|
7362
|
+
project.hillAt == null ? "" : " · { } move it"
|
|
7363
|
+
]
|
|
7364
|
+
})]
|
|
7306
7365
|
}),
|
|
7307
7366
|
mode === "budget" ? /* @__PURE__ */ jsx(BudgetPane, {
|
|
7308
7367
|
project,
|
|
@@ -9774,6 +9833,14 @@ function hierarchySlice({ get, write }) {
|
|
|
9774
9833
|
...patch
|
|
9775
9834
|
} : p) });
|
|
9776
9835
|
},
|
|
9836
|
+
moveOnHill: (id, at) => {
|
|
9837
|
+
const hillAt = clampHill(at);
|
|
9838
|
+
write({ projects: get().projects.map((p) => p.id === id ? {
|
|
9839
|
+
...p,
|
|
9840
|
+
hillAt,
|
|
9841
|
+
hillMovedAt: stamp()
|
|
9842
|
+
} : p) });
|
|
9843
|
+
},
|
|
9777
9844
|
addCustomField: (projectId, def) => {
|
|
9778
9845
|
const id = uid("cf");
|
|
9779
9846
|
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
package/lib/cli.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
STATUS,
|
|
23
23
|
harbors,
|
|
24
24
|
limitsText,
|
|
25
|
+
hillList,
|
|
25
26
|
myWork,
|
|
26
27
|
premortemText,
|
|
27
28
|
voyageText,
|
|
@@ -64,6 +65,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
64
65
|
projects with health and open work
|
|
65
66
|
(--json adds each board's limits)
|
|
66
67
|
harbor forecast [PROJECT] [--program P] when projects will likely finish
|
|
68
|
+
harbor hill [--program P] where each project is on its hill
|
|
69
|
+
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
|
|
70
|
+
out, past 50 is getting it done
|
|
67
71
|
harbor mine what's on you, late first
|
|
68
72
|
harbor tasks [words] [--project P] [--assignee me|none|NAME]
|
|
69
73
|
[--late] [--status S,S]
|
|
@@ -75,6 +79,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
75
79
|
harbor prompt --init SHELL how to add it: zsh, bash, fish
|
|
76
80
|
or starship
|
|
77
81
|
harbor calendar [--program P] [--new|--off] your private calendar link (.ics)
|
|
82
|
+
harbor roadmap PROJECT [--dates|--no-dates] [--off]
|
|
83
|
+
publish a project's public roadmap
|
|
78
84
|
|
|
79
85
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
80
86
|
[--ends DATE]
|
|
@@ -223,6 +229,7 @@ const FLAG = {
|
|
|
223
229
|
body: "the comment",
|
|
224
230
|
column: "the column",
|
|
225
231
|
limit: "the limit",
|
|
232
|
+
at: "--at",
|
|
226
233
|
};
|
|
227
234
|
|
|
228
235
|
/** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
|
|
@@ -834,6 +841,27 @@ export async function run(argv, io = {}) {
|
|
|
834
841
|
if (project) return call("get_project", { harbor, project }, forecastOne);
|
|
835
842
|
return call("list_projects", { harbor, program: values.program, limit: 100 }, forecastList);
|
|
836
843
|
}
|
|
844
|
+
case "hill": {
|
|
845
|
+
const { positionals, values, harbor } = parse(spec([], ["program", "at"]));
|
|
846
|
+
const project = positionals.join(" ").trim();
|
|
847
|
+
if (!project) {
|
|
848
|
+
if (values.at !== undefined)
|
|
849
|
+
throw new HarborError("Say which project: harbor hill PROJECT --at N.");
|
|
850
|
+
return call("list_projects", { harbor, program: values.program, limit: 100 }, hillList);
|
|
851
|
+
}
|
|
852
|
+
if (values.at === undefined) {
|
|
853
|
+
throw new HarborError("Say where with --at: 0 to 100, or start, top, doing or done.");
|
|
854
|
+
}
|
|
855
|
+
const at = readHillWord(values.at);
|
|
856
|
+
if (at === null)
|
|
857
|
+
throw new HarborError("--at is 0 to 100, or start, figuring, top, doing or done.");
|
|
858
|
+
return call(
|
|
859
|
+
"move_on_hill",
|
|
860
|
+
{ harbor, project, at },
|
|
861
|
+
(r) =>
|
|
862
|
+
`Moved ${r.moved.project} on the hill: ${r.moved.where.replace(/^\w/, (c) => c.toLowerCase())}.`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
837
865
|
case "mine": {
|
|
838
866
|
const { positionals, harbor } = parse(spec());
|
|
839
867
|
none(positionals);
|
|
@@ -985,6 +1013,46 @@ export async function run(argv, io = {}) {
|
|
|
985
1013
|
);
|
|
986
1014
|
return 0;
|
|
987
1015
|
}
|
|
1016
|
+
case "roadmap": {
|
|
1017
|
+
const { positionals, values, harbor } = parse(
|
|
1018
|
+
spec(["dates", "no-dates", "site", "no-site", "off"]),
|
|
1019
|
+
);
|
|
1020
|
+
const project = text(positionals, "which project");
|
|
1021
|
+
if (values.dates && values["no-dates"])
|
|
1022
|
+
throw new HarborError("Say --dates or --no-dates, not both.");
|
|
1023
|
+
if (values.site && values["no-site"])
|
|
1024
|
+
throw new HarborError("Say --site or --no-site, not both.");
|
|
1025
|
+
if (!hasFullAccess(creds)) {
|
|
1026
|
+
throw new HarborError("Publishing needs full access. Run harbor login, then try again.");
|
|
1027
|
+
}
|
|
1028
|
+
const r = await client.app({
|
|
1029
|
+
op: "roadmap",
|
|
1030
|
+
...(harbor ? { harbor } : {}),
|
|
1031
|
+
project,
|
|
1032
|
+
...(values.off ? { off: true } : {}),
|
|
1033
|
+
...(values.dates || values["no-dates"] ? { showDates: Boolean(values.dates) } : {}),
|
|
1034
|
+
...(values.site || values["no-site"] ? { onSite: Boolean(values.site) } : {}),
|
|
1035
|
+
});
|
|
1036
|
+
if (asJson) {
|
|
1037
|
+
out(jsonText(r));
|
|
1038
|
+
return 0;
|
|
1039
|
+
}
|
|
1040
|
+
if (!r.url) {
|
|
1041
|
+
out(
|
|
1042
|
+
plain(
|
|
1043
|
+
`${r.project} isn't published any more. Its link says the roadmap isn't published.`,
|
|
1044
|
+
),
|
|
1045
|
+
);
|
|
1046
|
+
return 0;
|
|
1047
|
+
}
|
|
1048
|
+
const shows = r.showDates ? "titles and the day each shipped" : "titles only";
|
|
1049
|
+
out(
|
|
1050
|
+
plain(
|
|
1051
|
+
`${r.project}'s public roadmap (Now, Next and Shipped; ${shows}, no people):\n\n ${r.url}${r.siteUrl ? `\n\nIt's also Harbor's own roadmap at ${r.siteUrl}.` : ""}\n\nharbor roadmap "${r.project}" --off takes it down.`,
|
|
1052
|
+
),
|
|
1053
|
+
);
|
|
1054
|
+
return 0;
|
|
1055
|
+
}
|
|
988
1056
|
case "add": {
|
|
989
1057
|
const { positionals, values, harbor } = parse(
|
|
990
1058
|
spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
|
|
@@ -1223,3 +1291,10 @@ export async function run(argv, io = {}) {
|
|
|
1223
1291
|
throw new HarborError(`No command "${command}". Run harbor help.`);
|
|
1224
1292
|
}
|
|
1225
1293
|
}
|
|
1294
|
+
|
|
1295
|
+
/** --at for harbor hill: a number 0–100 or a word (src/lib/hill.ts readHill says the same). */
|
|
1296
|
+
function readHillWord(text) {
|
|
1297
|
+
const t = String(text).trim().toLowerCase();
|
|
1298
|
+
if (/^\d{1,3}$/.test(t)) return Number(t) <= 100 ? Number(t) : null;
|
|
1299
|
+
return { start: 0, figuring: 25, top: 50, doing: 75, done: 100 }[t] ?? null;
|
|
1300
|
+
}
|
package/lib/format.mjs
CHANGED
|
@@ -287,3 +287,37 @@ export function voyageText(r) {
|
|
|
287
287
|
"\n",
|
|
288
288
|
);
|
|
289
289
|
}
|
|
290
|
+
|
|
291
|
+
/** A position on a 21-wide track, the top of the hill marked in the middle. */
|
|
292
|
+
function hillTrack(at) {
|
|
293
|
+
const cells = Array.from({ length: 21 }, (_, i) => (i === 10 ? "┼" : "─"));
|
|
294
|
+
cells[Math.round(at / 5)] = "●";
|
|
295
|
+
return cells.join("");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Each open project's place on its program's hill, by program, unplaced ones named after. */
|
|
299
|
+
export function hillList(r) {
|
|
300
|
+
const projects = (r.projects ?? []).filter((p) => p.health !== "done");
|
|
301
|
+
if (!projects.length) return "No open projects match.";
|
|
302
|
+
const byProgram = new Map();
|
|
303
|
+
for (const p of projects) {
|
|
304
|
+
const key = p.program ?? "No program";
|
|
305
|
+
if (!byProgram.has(key)) byProgram.set(key, []);
|
|
306
|
+
byProgram.get(key).push(p);
|
|
307
|
+
}
|
|
308
|
+
const width = Math.min(28, Math.max(...projects.map((p) => p.name.length)));
|
|
309
|
+
const blocks = [];
|
|
310
|
+
for (const [program, list] of byProgram) {
|
|
311
|
+
const placed = list.filter((p) => p.hill).sort((a, b) => a.hill.at - b.hill.at);
|
|
312
|
+
const unplaced = list.filter((p) => !p.hill);
|
|
313
|
+
const lines = [program, ` ${"".padEnd(width)} figuring it out ┼ getting it done`];
|
|
314
|
+
for (const p of placed) {
|
|
315
|
+
const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
|
|
316
|
+
const still = p.hill.daysSinceMoved >= 14 ? ` still ${p.hill.daysSinceMoved} days` : "";
|
|
317
|
+
lines.push(` ${name} ${hillTrack(p.hill.at)} ${String(p.hill.at).padStart(3)}${still}`);
|
|
318
|
+
}
|
|
319
|
+
if (unplaced.length) lines.push(` Not on the hill: ${unplaced.map((p) => p.name).join(", ")}`);
|
|
320
|
+
blocks.push(lines.join("\n"));
|
|
321
|
+
}
|
|
322
|
+
return blocks.join("\n\n");
|
|
323
|
+
}
|