@harborppm/cli 0.41.0 → 0.43.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 -3
- package/dist/app.mjs +84 -27
- package/lib/cli.mjs +74 -26
- package/lib/format.mjs +83 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ waits: [CS-2]
|
|
|
83
83
|
Old endpoints go away on Nov 20.
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
A new file with any name is a new task: `harbor sync` adds it and renames it to its key. A field changed in a file and not on the website goes to Harbor; one changed in both places keeps the website's and says so. Removing a file doesn't delete the task (mark it done instead). On a fresh clone, run `harbor sync` before editing. `harbor init --hook` adds a git post-commit hook that syncs after each commit.
|
|
86
|
+
A new file with any name is a new task: `harbor sync` adds it and renames it to its key. A field changed in a file and not on the website goes to Harbor; one changed in both places keeps the website's and says so. Removing a file doesn't delete the task (mark it done instead). On a fresh clone, run `harbor sync` before editing. `harbor init --hook` adds a git post-commit hook that syncs after each commit, and `--dry-run` on either says what it would do without writing or sending anything.
|
|
87
87
|
|
|
88
88
|
No account? `harbor init --local` makes a plan that lives only in the folder (the rest of it in `.harbor/plan.json`), and `harbor` in that folder opens it full screen, offline and free. Later, `harbor init PROJECT` moves its tasks into a project on harborppm.com.
|
|
89
89
|
|
|
@@ -161,9 +161,9 @@ harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
|
161
161
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
162
162
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
163
163
|
harbor limit PROJECT [COLUMN N|none]
|
|
164
|
-
harbor init [PROJECT] [--hook]
|
|
164
|
+
harbor init [PROJECT] [--hook] [--dry-run] a project's tasks as files in this repository
|
|
165
165
|
harbor init --local [NAME] a plan in this folder alone, no account
|
|
166
|
-
harbor sync [--quiet]
|
|
166
|
+
harbor sync [--quiet] [--dry-run] the files and the website, in step both ways
|
|
167
167
|
harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT] [--keep ID,ID] [--confirm]
|
|
168
168
|
harbor handback PERSON
|
|
169
169
|
harbor agent [--agent NAME]
|
package/dist/app.mjs
CHANGED
|
@@ -3009,10 +3009,18 @@ function tideForecast(project, tasks, now) {
|
|
|
3009
3009
|
remaining
|
|
3010
3010
|
};
|
|
3011
3011
|
const today = todayISO(now);
|
|
3012
|
-
const runs = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`));
|
|
3013
3012
|
const base = noon(today);
|
|
3013
|
+
const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`));
|
|
3014
|
+
const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
|
|
3015
|
+
let lastDue = null;
|
|
3016
|
+
for (const t of tasks) {
|
|
3017
|
+
if (t.status === "done" || !t.dueDate) continue;
|
|
3018
|
+
if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
|
|
3019
|
+
}
|
|
3020
|
+
const floor = lastDue ? differenceInCalendarDays(noon(lastDue.dueDate), base) : 0;
|
|
3021
|
+
const runs = floor > 0 ? paced.map((d) => Math.max(d, floor)) : paced;
|
|
3022
|
+
const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
|
|
3014
3023
|
const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
|
|
3015
|
-
const at = (share) => runs[Math.min(runs.length - 1, Math.ceil(share * runs.length) - 1)];
|
|
3016
3024
|
const toEnd = differenceInCalendarDays(noon(project.endDate), base);
|
|
3017
3025
|
const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
|
|
3018
3026
|
const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
|
|
@@ -3037,7 +3045,8 @@ function tideForecast(project, tasks, now) {
|
|
|
3037
3045
|
almostCertain: dayOf(at(.95)),
|
|
3038
3046
|
chanceByEnd: Math.round(byEnd * 100) / 100,
|
|
3039
3047
|
curve,
|
|
3040
|
-
rough: finished < ROUGH_BELOW
|
|
3048
|
+
rough: finished < ROUGH_BELOW,
|
|
3049
|
+
heldBy
|
|
3041
3050
|
};
|
|
3042
3051
|
}
|
|
3043
3052
|
/** "15%", with "under 5%" and "over 95%" at the ends, where a thousand runs can't be that sure. */
|
|
@@ -3046,6 +3055,13 @@ function chanceText(chance) {
|
|
|
3046
3055
|
if (chance > .95) return "over 95%";
|
|
3047
3056
|
return `${Math.round(chance * 20) * 5}%`;
|
|
3048
3057
|
}
|
|
3058
|
+
/**
|
|
3059
|
+
* The open task whose due date sets the finish, when the crew's pace alone
|
|
3060
|
+
* would be done sooner; null otherwise.
|
|
3061
|
+
*/
|
|
3062
|
+
function dueAfterFinish(tide) {
|
|
3063
|
+
return tide.kind === "forecast" ? tide.heldBy : null;
|
|
3064
|
+
}
|
|
3049
3065
|
|
|
3050
3066
|
//#endregion
|
|
3051
3067
|
//#region src/lib/flow.ts
|
|
@@ -3956,11 +3972,13 @@ function filterTasks(tasks, f) {
|
|
|
3956
3972
|
}
|
|
3957
3973
|
/**
|
|
3958
3974
|
* When the project will likely finish at its crew's recent pace, in one
|
|
3959
|
-
* line under its name,
|
|
3975
|
+
* line under its name, whether that misses the end date, and the open task
|
|
3976
|
+
* whose due date holds it later than the pace would. Empty when
|
|
3960
3977
|
* there's no forecast (all done, paused, or nothing finished lately).
|
|
3961
3978
|
*/
|
|
3962
3979
|
function tideLine(data, project, today) {
|
|
3963
|
-
const
|
|
3980
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
3981
|
+
const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`));
|
|
3964
3982
|
if (tide.kind === "no-pace") return {
|
|
3965
3983
|
text: "No forecast yet: nothing finished in the last few weeks",
|
|
3966
3984
|
late: false
|
|
@@ -3972,8 +3990,10 @@ function tideLine(data, project, today) {
|
|
|
3972
3990
|
};
|
|
3973
3991
|
const very = tide.veryLikely ? `, very likely by ${shortDay(tide.veryLikely, today)}` : "";
|
|
3974
3992
|
const end = project.endDate >= today ? ` · ${chanceText(tide.chanceByEnd)} by its end, ${shortDay(project.endDate, today)}` : "";
|
|
3993
|
+
const later = dueAfterFinish(tide);
|
|
3994
|
+
const due = later ? ` · ${taskKey(later, project) ?? `“${later.title}”`} is due ${shortDay(later.dueDate.slice(0, 10), today)}` : "";
|
|
3975
3995
|
return {
|
|
3976
|
-
text: `Probably done ${shortDay(tide.likely, today)}${very}${end}${tide.rough ? " · rough" : ""}`,
|
|
3996
|
+
text: `Probably done ${shortDay(tide.likely, today)}${very}${end}${due}${tide.rough ? " · rough" : ""}`,
|
|
3977
3997
|
late: tide.chanceByEnd < .5
|
|
3978
3998
|
};
|
|
3979
3999
|
}
|
|
@@ -15529,12 +15549,33 @@ async function saveOrStop(sync) {
|
|
|
15529
15549
|
await sync.flush();
|
|
15530
15550
|
if (sync.state !== "saved") throw new HarborError(sync.state === "offline" ? "Can't reach Harbor, so nothing was sent and the folder was left as it was." : "Harbor didn't take the changes, so the folder was left as it was. Try harbor sync again.");
|
|
15531
15551
|
}
|
|
15532
|
-
/**
|
|
15552
|
+
/**
|
|
15553
|
+
* A --dry-run's folder and transport: everything is read as usual, and
|
|
15554
|
+
* nothing is written to disk or sent to Harbor.
|
|
15555
|
+
*/
|
|
15556
|
+
function dry(folder, transport) {
|
|
15557
|
+
return {
|
|
15558
|
+
folder: {
|
|
15559
|
+
...folder,
|
|
15560
|
+
write: async () => {},
|
|
15561
|
+
remove: async () => {}
|
|
15562
|
+
},
|
|
15563
|
+
transport: {
|
|
15564
|
+
...transport,
|
|
15565
|
+
save: () => new Promise(() => {})
|
|
15566
|
+
}
|
|
15567
|
+
};
|
|
15568
|
+
}
|
|
15569
|
+
/** `harbor sync` on a linked plan; with dryRun, only what it would do. */
|
|
15533
15570
|
async function syncLinked(opts) {
|
|
15534
|
-
const {
|
|
15571
|
+
const { config, say } = opts;
|
|
15572
|
+
const { folder, transport } = opts.dryRun ? dry(opts.folder, opts.transport) : {
|
|
15573
|
+
folder: opts.folder,
|
|
15574
|
+
transport: opts.transport
|
|
15575
|
+
};
|
|
15535
15576
|
if (!config.harbor) throw new HarborError("This plan isn't linked to a harbor.");
|
|
15536
|
-
const { account } = await
|
|
15537
|
-
const { store, sync } = await openHarbor(
|
|
15577
|
+
const { account } = await transport.harbors();
|
|
15578
|
+
const { store, sync } = await openHarbor(transport, account, config.harbor.id, say);
|
|
15538
15579
|
try {
|
|
15539
15580
|
const projectId = config.project.id;
|
|
15540
15581
|
if (!store.getState().projects.some((p) => p.id === projectId)) throw new HarborError(`${config.project.name} isn't in ${config.harbor.name} any more. Run harbor init PROJECT to link another.`);
|
|
@@ -15544,7 +15585,7 @@ async function syncLinked(opts) {
|
|
|
15544
15585
|
say
|
|
15545
15586
|
});
|
|
15546
15587
|
if (done.report.sent.length + done.report.added.length > 0 && readOnlyHarbor(store.getState())) throw new HarborError(store.getState().keepPaused ? "This harbor's free trial has ended, so the folder's changes weren't sent. Its owner can subscribe at harborppm.com/keep." : "You can only view this harbor, so the folder's changes weren't sent. The folder was left as it was.");
|
|
15547
|
-
await saveOrStop(sync);
|
|
15588
|
+
if (!opts.dryRun) await saveOrStop(sync);
|
|
15548
15589
|
const project = store.getState().projects.find((p) => p.id === projectId);
|
|
15549
15590
|
if (project.key && project.key !== config.project.key) await folder.write(CONFIG_FILE, json({
|
|
15550
15591
|
...config,
|
|
@@ -15579,18 +15620,22 @@ function projectNamed(projects, text, opts = {}) {
|
|
|
15579
15620
|
* A local plan here moves its tasks into the project first.
|
|
15580
15621
|
*/
|
|
15581
15622
|
async function initLinked(opts) {
|
|
15582
|
-
const {
|
|
15623
|
+
const { say } = opts;
|
|
15624
|
+
const { folder, transport } = opts.dryRun ? dry(opts.folder, opts.transport) : {
|
|
15625
|
+
folder: opts.folder,
|
|
15626
|
+
transport: opts.transport
|
|
15627
|
+
};
|
|
15583
15628
|
const existing = await readConfig(folder);
|
|
15584
15629
|
if (existing && !existing.local) throw new HarborError(`This folder already follows ${existing.project.name}. Run harbor sync to bring it up to date.`);
|
|
15585
|
-
const answer = await
|
|
15630
|
+
const answer = await transport.harbors();
|
|
15586
15631
|
if (answer.harbors.length === 0) throw new HarborError("You're not in a harbor yet. Run harbor to make one first.");
|
|
15587
15632
|
const harbor = opts.pickHarbor(answer.harbors);
|
|
15588
|
-
const { store, sync } = await openHarbor(
|
|
15633
|
+
const { store, sync } = await openHarbor(transport, answer.account, harbor.id, say);
|
|
15589
15634
|
try {
|
|
15590
15635
|
const project = projectNamed(store.getState().projects, opts.project, { exactOnly: opts.exactOnly });
|
|
15591
15636
|
let moved = 0;
|
|
15592
15637
|
if (existing?.local) moved = await moveLocalTasks(folder, store, project.id, say);
|
|
15593
|
-
await saveOrStop(sync);
|
|
15638
|
+
if (!opts.dryRun) await saveOrStop(sync);
|
|
15594
15639
|
const settled = store.getState().projects.find((p) => p.id === project.id);
|
|
15595
15640
|
const config = {
|
|
15596
15641
|
version: 1,
|
|
@@ -15614,9 +15659,12 @@ async function initLinked(opts) {
|
|
|
15614
15659
|
consumed: /* @__PURE__ */ new Set(),
|
|
15615
15660
|
skipped: /* @__PURE__ */ new Set()
|
|
15616
15661
|
}, report, true);
|
|
15662
|
+
const mine = data.tasks.filter((t) => t.projectId === project.id);
|
|
15663
|
+
const open = mine.filter((t) => t.status !== "done").length;
|
|
15617
15664
|
return {
|
|
15618
15665
|
config,
|
|
15619
|
-
tasks:
|
|
15666
|
+
tasks: mine.length,
|
|
15667
|
+
open,
|
|
15620
15668
|
moved,
|
|
15621
15669
|
report
|
|
15622
15670
|
};
|
|
@@ -15853,13 +15901,14 @@ var commands_exports = /* @__PURE__ */ __exportAll({
|
|
|
15853
15901
|
syncHere: () => syncHere,
|
|
15854
15902
|
syncLine: () => syncLine
|
|
15855
15903
|
});
|
|
15856
|
-
/** The one line a sync ends with. */
|
|
15857
|
-
function syncLine(report) {
|
|
15904
|
+
/** The one line a sync ends with; a dry run's says what it would have done. */
|
|
15905
|
+
function syncLine(report, dryRun = false) {
|
|
15858
15906
|
const parts = [
|
|
15859
|
-
report.sent.length ?
|
|
15860
|
-
report.added.length ?
|
|
15861
|
-
report.took.length ?
|
|
15907
|
+
report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural(report.sent.length, "change")} to Harbor` : "",
|
|
15908
|
+
report.added.length ? `${dryRun ? "would add" : "added"} ${plural(report.added.length, "task")}` : "",
|
|
15909
|
+
report.took.length ? `${dryRun ? "would bring in" : "brought in"} ${plural(report.took.length, "change")} from Harbor` : ""
|
|
15862
15910
|
].filter(Boolean);
|
|
15911
|
+
if (dryRun) return `⚓ Dry run: ${parts.length ? parts.join(", ") : "already in step with Harbor"}. Nothing was written or sent.`;
|
|
15863
15912
|
return parts.length ? `⚓ ${parts.join(", ")}.` : "⚓ Already in step with Harbor.";
|
|
15864
15913
|
}
|
|
15865
15914
|
/** The local plan above `cwd`, if that's what's there. */
|
|
@@ -15908,22 +15957,29 @@ async function initHere(opts) {
|
|
|
15908
15957
|
project: opts.project?.trim() || basename(home),
|
|
15909
15958
|
exactOnly: !opts.project?.trim(),
|
|
15910
15959
|
pickHarbor: opts.pickHarbor,
|
|
15911
|
-
say: opts.say
|
|
15960
|
+
say: opts.say,
|
|
15961
|
+
dryRun: opts.dryRun
|
|
15912
15962
|
});
|
|
15913
15963
|
const { project, harbor } = made.config;
|
|
15914
|
-
|
|
15964
|
+
const counted = made.open === made.tasks ? plural(made.tasks, "task") : `${made.open} open and ${made.tasks - made.open} done ${made.tasks === 1 ? "task" : "tasks"}`;
|
|
15965
|
+
if (opts.dryRun) {
|
|
15966
|
+
opts.say(made.moved ? `Would move this folder's ${plural(made.moved, "task")} into ${project.name} (${project.key}) in ${harbor.name}.` : `Would make ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with a file for each of its ${counted}.`);
|
|
15967
|
+
opts.say("Dry run: nothing was written or sent. Run it again without --dry-run to do it.");
|
|
15968
|
+
return made.config;
|
|
15969
|
+
}
|
|
15970
|
+
opts.say(made.moved ? `Moved this folder's ${plural(made.moved, "task")} into ${project.name} in ${harbor.name}.` : `Made ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with a file for each of its ${counted}. Commit it like any code.`);
|
|
15915
15971
|
opts.say("Edit a file, or add one, then run harbor sync. Changes on the website come back the same way.");
|
|
15916
15972
|
if (opts.hook) opts.say(await addHook(home));
|
|
15917
15973
|
return made.config;
|
|
15918
15974
|
}
|
|
15919
|
-
/** `harbor sync`: the plan above `cwd` and its project, both ways. */
|
|
15975
|
+
/** `harbor sync`: the plan above `cwd` and its project, both ways; with dryRun, what it would do. */
|
|
15920
15976
|
async function syncHere(opts) {
|
|
15921
15977
|
const dir = await findPlan(opts.cwd);
|
|
15922
15978
|
if (!dir) throw new HarborError("There's no plan here. Run harbor init PROJECT in your repository to make one.");
|
|
15923
15979
|
const folder = diskFolder(dir);
|
|
15924
15980
|
const config = await readConfig(folder);
|
|
15925
15981
|
if (config.local) {
|
|
15926
|
-
await loadLocal(folder, opts.say);
|
|
15982
|
+
if (!opts.dryRun) await loadLocal(folder, opts.say);
|
|
15927
15983
|
opts.say("⚓ This plan lives only in this folder, so there's nothing to sync with. harbor init PROJECT keeps it in step with a project on harborppm.com.");
|
|
15928
15984
|
return null;
|
|
15929
15985
|
}
|
|
@@ -15931,10 +15987,11 @@ async function syncHere(opts) {
|
|
|
15931
15987
|
transport: await opts.transport(),
|
|
15932
15988
|
folder,
|
|
15933
15989
|
config,
|
|
15934
|
-
say: opts.say
|
|
15990
|
+
say: opts.say,
|
|
15991
|
+
dryRun: opts.dryRun
|
|
15935
15992
|
});
|
|
15936
15993
|
for (const c of report.clashes) opts.say(c);
|
|
15937
|
-
opts.say(syncLine(report));
|
|
15994
|
+
opts.say(syncLine(report, opts.dryRun));
|
|
15938
15995
|
return report;
|
|
15939
15996
|
}
|
|
15940
15997
|
/** A post-commit hook that syncs after each commit; an existing hook is only added to. */
|
package/lib/cli.mjs
CHANGED
|
@@ -22,8 +22,11 @@ import {
|
|
|
22
22
|
forecastList,
|
|
23
23
|
forecastOne,
|
|
24
24
|
STATUS,
|
|
25
|
+
statusWord,
|
|
26
|
+
exportData,
|
|
25
27
|
harbors,
|
|
26
28
|
whoami,
|
|
29
|
+
whoamiData,
|
|
27
30
|
limitsText,
|
|
28
31
|
hillData,
|
|
29
32
|
hillList,
|
|
@@ -94,13 +97,14 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
94
97
|
a project's public roadmap: read,
|
|
95
98
|
publish or take it down
|
|
96
99
|
|
|
97
|
-
harbor init [PROJECT] [--hook]
|
|
100
|
+
harbor init [PROJECT] [--hook] [--dry-run] keep a project's tasks as files in
|
|
98
101
|
this repository (.harbor/), one
|
|
99
102
|
per task, in step both ways
|
|
100
103
|
harbor init --local [NAME] a plan in this folder alone, no
|
|
101
104
|
account; harbor here opens it
|
|
102
|
-
harbor sync [--quiet]
|
|
105
|
+
harbor sync [--quiet] [--dry-run] send the files' edits to Harbor
|
|
103
106
|
and bring its changes back
|
|
107
|
+
(--dry-run: only say what)
|
|
104
108
|
|
|
105
109
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
106
110
|
[--ends DATE]
|
|
@@ -153,11 +157,12 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
153
157
|
the crew, in the order to do them
|
|
154
158
|
harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
155
159
|
the agent's note; moves the task
|
|
156
|
-
to In review (--start:
|
|
160
|
+
to In review (--start: In play)
|
|
157
161
|
|
|
158
162
|
Every command takes --json, and --harbor NAME when you're in more than one
|
|
159
163
|
harbor (or set one with harbor use). harbor help COMMAND shows just that one.
|
|
160
|
-
Statuses: backlog, todo, doing, review, done
|
|
164
|
+
Statuses: backlog, todo, doing, review, done (the board's words work too:
|
|
165
|
+
"to do", "in play"; --json says doing for In play).
|
|
161
166
|
DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
|
|
162
167
|
Changes show in Harbor's History as yours, "via Harbor CLI". These commands
|
|
163
168
|
never delete; the full-screen app can, as the website can, and asks first.
|
|
@@ -417,10 +422,17 @@ const DETAIL = {
|
|
|
417
422
|
" repository, one file per task in",
|
|
418
423
|
" PROJECT (a key like RR or a name;",
|
|
419
424
|
" the folder's name when left out)",
|
|
425
|
+
" harbor init [PROJECT] --dry-run reads: says which project and how",
|
|
426
|
+
" many tasks, and writes nothing",
|
|
420
427
|
" harbor init --local [NAME] writes: a plan that lives only in",
|
|
421
428
|
" this folder, with no account;",
|
|
422
429
|
" harbor here opens it full screen",
|
|
423
430
|
"",
|
|
431
|
+
" .harbor/ goes at the top of the git repository you're in; outside one, in",
|
|
432
|
+
" this folder. Without PROJECT, only a project called exactly what the",
|
|
433
|
+
" folder is will do: when none is, nothing is made and Harbor lists the",
|
|
434
|
+
" projects there are, with their keys, to name one.",
|
|
435
|
+
"",
|
|
424
436
|
" Each file has title, status, owner, due, priority and waits between --- lines,",
|
|
425
437
|
" and the task's notes below. Edit them, add new ones (any name; Harbor gives",
|
|
426
438
|
" them a key), review them in pull requests, then run harbor sync. --hook adds",
|
|
@@ -430,11 +442,14 @@ const DETAIL = {
|
|
|
430
442
|
sync: [
|
|
431
443
|
" harbor sync [--quiet] writes: sends what the files changed to",
|
|
432
444
|
" Harbor and rewrites the files from it",
|
|
445
|
+
" harbor sync --dry-run reads: says what it would send and",
|
|
446
|
+
" bring in, and changes nothing",
|
|
433
447
|
"",
|
|
434
448
|
" A field you changed and nobody changed on the website goes to Harbor; one",
|
|
435
449
|
" changed in both places keeps the website's, and says so. Removing a file",
|
|
436
450
|
" doesn't delete the task (mark it done instead). On a fresh clone, run",
|
|
437
|
-
" harbor sync before editing. --quiet prints only problems.",
|
|
451
|
+
" harbor sync before editing. --quiet prints only problems. It works from",
|
|
452
|
+
" anywhere inside the folder that holds .harbor/.",
|
|
438
453
|
],
|
|
439
454
|
limit: [
|
|
440
455
|
" harbor limit PROJECT reads its board's column limits",
|
|
@@ -443,7 +458,8 @@ const DETAIL = {
|
|
|
443
458
|
" 1-5, 8, 10 and 15)",
|
|
444
459
|
" harbor limit PROJECT COLUMN none writes: no limit on COLUMN",
|
|
445
460
|
"",
|
|
446
|
-
" COLUMN is a status: doing or review usually, or backlog, todo,
|
|
461
|
+
" COLUMN is a status: doing (In play) or review usually, or backlog, todo,",
|
|
462
|
+
" done.",
|
|
447
463
|
" Only the harbor's owner can change a limit. A move past a limit is",
|
|
448
464
|
" refused until you run it again with --over-limit.",
|
|
449
465
|
],
|
|
@@ -467,12 +483,14 @@ const DETAIL = {
|
|
|
467
483
|
" harbor roadmap PROJECT --publish --dates",
|
|
468
484
|
" writes: also shows the day each thing",
|
|
469
485
|
" shipped (--no-dates: titles only, the",
|
|
470
|
-
" default
|
|
486
|
+
" first time's default; without either,",
|
|
487
|
+
" it stays as it is)",
|
|
471
488
|
" harbor roadmap PROJECT --publish --site",
|
|
472
489
|
" writes: also shows it at",
|
|
473
490
|
" harborppm.com/roadmap, in place of any",
|
|
474
491
|
" other (Harbor's own team only;",
|
|
475
|
-
" --no-site
|
|
492
|
+
" --no-site takes it off; without",
|
|
493
|
+
" either, it stays as it is)",
|
|
476
494
|
" harbor roadmap PROJECT --off writes: takes it down at once; the link",
|
|
477
495
|
" then says it isn't published, and",
|
|
478
496
|
" publishing again makes a new link",
|
|
@@ -488,12 +506,13 @@ const DETAIL = {
|
|
|
488
506
|
" harbor forecast PROJECT reads one project's forecast and what",
|
|
489
507
|
" it rests on",
|
|
490
508
|
"",
|
|
491
|
-
" Worked out from the crew's pace
|
|
492
|
-
"
|
|
509
|
+
" Worked out from the crew's pace: how many tasks they finished each week",
|
|
510
|
+
" over the last six (or the project's life, if shorter), replayed a",
|
|
493
511
|
" thousand times until its open tasks run out. Probably is half of those",
|
|
494
512
|
" runs, very likely 85%, almost certainly 95%. No estimates or points.",
|
|
495
|
-
"
|
|
496
|
-
"
|
|
513
|
+
" No run finishes before the last open task is due, so a task due later",
|
|
514
|
+
" than the pace would finish sets the dates, and the forecast names it. It",
|
|
515
|
+
" warns when under two weeks of pace is all there is.",
|
|
497
516
|
],
|
|
498
517
|
report: [
|
|
499
518
|
" harbor report PROGRAM reads the program's status report",
|
|
@@ -509,7 +528,9 @@ const DETAIL = {
|
|
|
509
528
|
" harbor export review reads the review as CSV",
|
|
510
529
|
"",
|
|
511
530
|
" The same spreadsheets the website's Export buttons download. They print;",
|
|
512
|
-
" harbor export tasks RR > tasks.csv saves one.",
|
|
531
|
+
" harbor export tasks RR > tasks.csv saves one. A task export starts with",
|
|
532
|
+
" each task's key, so harbor import matches those tasks by key. With",
|
|
533
|
+
" --json, rows is one object per row, keyed by column.",
|
|
513
534
|
],
|
|
514
535
|
import: [
|
|
515
536
|
" harbor import FILE --project P writes: adds the file's tasks",
|
|
@@ -519,7 +540,8 @@ const DETAIL = {
|
|
|
519
540
|
" FILE is CSV or tab-separated: a spreadsheet, or a Jira, Asana or Linear",
|
|
520
541
|
" export. It needs a column of titles; rows naming a Project column go",
|
|
521
542
|
" there. People it names who aren't in the harbor are added unless you say",
|
|
522
|
-
" --no-people. A task already in its project
|
|
543
|
+
" --no-people. A task already in its project is left alone: by its key",
|
|
544
|
+
" (a Key column, as harbor export writes) or by title.",
|
|
523
545
|
],
|
|
524
546
|
note: [
|
|
525
547
|
" harbor note PROJECT reads its notes for later: who left",
|
|
@@ -564,14 +586,17 @@ const DETAIL = {
|
|
|
564
586
|
agent: [
|
|
565
587
|
" harbor agent reads the tasks people gave the crew's",
|
|
566
588
|
" agent (an AI crew member on People),",
|
|
567
|
-
" in
|
|
589
|
+
" in play first, with their notes",
|
|
568
590
|
' harbor agent report TASK "TEXT" writes: the agent\'s note on the task,',
|
|
569
591
|
" and moves it to In review for a person",
|
|
570
592
|
" to check",
|
|
571
593
|
' harbor agent report TASK "TEXT" --start',
|
|
572
594
|
" writes: the note, and moves it to",
|
|
573
|
-
" In
|
|
595
|
+
" In play",
|
|
574
596
|
"",
|
|
597
|
+
" TASK is a task key, like RR-42 (harbor agent lists them), or an id. It",
|
|
598
|
+
" has to be assigned to an agent: for anyone else's task, report refuses",
|
|
599
|
+
" and nothing changes, so assign it to the agent on the website first.",
|
|
575
600
|
" --agent NAME picks the agent when the crew has more than one. An agent",
|
|
576
601
|
" never marks its own work Done; a person does, on the website or in the",
|
|
577
602
|
" terminal app.",
|
|
@@ -976,7 +1001,7 @@ export async function run(argv, io = {}) {
|
|
|
976
1001
|
if (command === "init" || command === "sync") {
|
|
977
1002
|
const init = command === "init";
|
|
978
1003
|
const { values, positionals } = parseArgs(rest, {
|
|
979
|
-
flags: init ? ["local", "hook", "json"] : ["quiet", "json"],
|
|
1004
|
+
flags: init ? ["local", "hook", "json", "dry-run"] : ["quiet", "json", "dry-run"],
|
|
980
1005
|
options: init ? ["harbor"] : [],
|
|
981
1006
|
});
|
|
982
1007
|
const cwd = io.cwd ?? process.cwd();
|
|
@@ -989,7 +1014,12 @@ export async function run(argv, io = {}) {
|
|
|
989
1014
|
}
|
|
990
1015
|
: (line) => out(plain(line));
|
|
991
1016
|
const app = await loadApp();
|
|
1017
|
+
const dryRun = Boolean(values["dry-run"]);
|
|
992
1018
|
if (init && values.local) {
|
|
1019
|
+
if (dryRun)
|
|
1020
|
+
throw new HarborError(
|
|
1021
|
+
"harbor init --local only writes files in this folder, with no account; leave out --dry-run.",
|
|
1022
|
+
);
|
|
993
1023
|
if (positionals.length > 1)
|
|
994
1024
|
throw new HarborError(`harbor init --local takes one name; put it in quotes.`);
|
|
995
1025
|
const config = await app.initLocalHere({ cwd, name: positionals[0], say });
|
|
@@ -1023,6 +1053,7 @@ export async function run(argv, io = {}) {
|
|
|
1023
1053
|
transport: await transport(),
|
|
1024
1054
|
project: positionals[0],
|
|
1025
1055
|
hook: Boolean(values.hook),
|
|
1056
|
+
dryRun,
|
|
1026
1057
|
say,
|
|
1027
1058
|
pickHarbor: (list) => {
|
|
1028
1059
|
if (values.harbor) return app.harborNamed(list, values.harbor);
|
|
@@ -1034,11 +1065,14 @@ export async function run(argv, io = {}) {
|
|
|
1034
1065
|
);
|
|
1035
1066
|
},
|
|
1036
1067
|
});
|
|
1037
|
-
if (values.json) out(jsonText({ plan: config, notes: lines }));
|
|
1068
|
+
if (values.json) out(jsonText({ plan: config, ...(dryRun ? { dryRun } : {}), notes: lines }));
|
|
1038
1069
|
return 0;
|
|
1039
1070
|
}
|
|
1040
|
-
const report = await app.syncHere({ cwd, transport, say });
|
|
1041
|
-
if (values.json)
|
|
1071
|
+
const report = await app.syncHere({ cwd, transport, say, dryRun });
|
|
1072
|
+
if (values.json)
|
|
1073
|
+
out(
|
|
1074
|
+
jsonText({ ...(report ?? { local: true }), ...(dryRun ? { dryRun } : {}), notes: lines }),
|
|
1075
|
+
);
|
|
1042
1076
|
return 0;
|
|
1043
1077
|
}
|
|
1044
1078
|
|
|
@@ -1087,7 +1121,12 @@ export async function run(argv, io = {}) {
|
|
|
1087
1121
|
}
|
|
1088
1122
|
case "whoami": {
|
|
1089
1123
|
none(parse(spec()).positionals);
|
|
1090
|
-
return call(
|
|
1124
|
+
return call(
|
|
1125
|
+
"list_harbors",
|
|
1126
|
+
{},
|
|
1127
|
+
(r) => whoami(r, creds),
|
|
1128
|
+
(r) => whoamiData(r, creds),
|
|
1129
|
+
);
|
|
1091
1130
|
}
|
|
1092
1131
|
case "use": {
|
|
1093
1132
|
const { positionals } = parse({ flags: ["json"], options: [] });
|
|
@@ -1251,6 +1290,7 @@ export async function run(argv, io = {}) {
|
|
|
1251
1290
|
.split(",")
|
|
1252
1291
|
.map((x) => x.trim())
|
|
1253
1292
|
.filter(Boolean)
|
|
1293
|
+
.map(statusWord)
|
|
1254
1294
|
: undefined,
|
|
1255
1295
|
overdue: values.late || undefined,
|
|
1256
1296
|
};
|
|
@@ -1463,7 +1503,7 @@ export async function run(argv, io = {}) {
|
|
|
1463
1503
|
assignee: values.assignee ?? "me",
|
|
1464
1504
|
due: dueDate(values.due),
|
|
1465
1505
|
priority: values.priority,
|
|
1466
|
-
status: values.status,
|
|
1506
|
+
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
1467
1507
|
overLimit: values["over-limit"] || undefined,
|
|
1468
1508
|
},
|
|
1469
1509
|
(r) => `Added: ${taskLine(r.created, opts)}`,
|
|
@@ -1512,7 +1552,7 @@ export async function run(argv, io = {}) {
|
|
|
1512
1552
|
assignee: values.assignee,
|
|
1513
1553
|
due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
|
|
1514
1554
|
priority: values.priority,
|
|
1515
|
-
status: values.status,
|
|
1555
|
+
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
1516
1556
|
description: values.notes,
|
|
1517
1557
|
repeat: values.repeat,
|
|
1518
1558
|
waitsOn:
|
|
@@ -1630,6 +1670,8 @@ export async function run(argv, io = {}) {
|
|
|
1630
1670
|
"export_csv",
|
|
1631
1671
|
{ harbor, what, project: project || undefined, all: Boolean(values.all) },
|
|
1632
1672
|
(r) => r.csv.replace(/\r\n/g, "\n"),
|
|
1673
|
+
// --json: the rows, each keyed by its column, not one CSV string.
|
|
1674
|
+
(r) => (asJson ? exportData(r) : r),
|
|
1633
1675
|
);
|
|
1634
1676
|
}
|
|
1635
1677
|
case "import": {
|
|
@@ -1709,7 +1751,7 @@ export async function run(argv, io = {}) {
|
|
|
1709
1751
|
if (limit !== null && !(Number.isInteger(limit) && limit >= 1 && limit <= 20)) {
|
|
1710
1752
|
throw new HarborError("A limit is a whole number from 1 to 20, or none.");
|
|
1711
1753
|
}
|
|
1712
|
-
return call("set_column_limit", { harbor, project, column, limit }, (r) =>
|
|
1754
|
+
return call("set_column_limit", { harbor, project, column: statusWord(column), limit }, (r) =>
|
|
1713
1755
|
limitsText(r.set.project, r.limits, r.tasks, r.hiddenColumns),
|
|
1714
1756
|
);
|
|
1715
1757
|
}
|
|
@@ -1724,7 +1766,13 @@ export async function run(argv, io = {}) {
|
|
|
1724
1766
|
if (!note) {
|
|
1725
1767
|
if (values.opens || values.milestone || values["off-track"])
|
|
1726
1768
|
throw new HarborError("Write the note too, in quotes after the project.");
|
|
1727
|
-
|
|
1769
|
+
// --json: the project and its notes, an empty list when there are none.
|
|
1770
|
+
return call(
|
|
1771
|
+
"get_project",
|
|
1772
|
+
{ harbor, project },
|
|
1773
|
+
(r) => notesText(r.project, r.notes),
|
|
1774
|
+
(r) => ({ id: r.id, project: r.name, notes: r.notesForLater ?? [] }),
|
|
1775
|
+
);
|
|
1728
1776
|
}
|
|
1729
1777
|
return call(
|
|
1730
1778
|
"leave_note",
|
|
@@ -1805,7 +1853,7 @@ export async function run(argv, io = {}) {
|
|
|
1805
1853
|
"agent_update",
|
|
1806
1854
|
{ harbor, task, note, status: values.start ? "doing" : "review", agent: values.agent },
|
|
1807
1855
|
(r) =>
|
|
1808
|
-
`${r.updated.key ?? r.updated.title} is ${r.updated.status === "review" ? "in review for a person to check" : "in
|
|
1856
|
+
`${r.updated.key ?? r.updated.title} is ${r.updated.status === "review" ? "in review for a person to check" : "in play"}, with ${r.commentedAs}'s note.`,
|
|
1809
1857
|
);
|
|
1810
1858
|
}
|
|
1811
1859
|
case "handback": {
|
package/lib/format.mjs
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
/** Plain text for a terminal: one task per line, its key (or id) first so it can be copied. */
|
|
2
2
|
|
|
3
|
+
/** Statuses in the board's words, as the website, the app and an export say them. */
|
|
3
4
|
export const STATUS = {
|
|
4
5
|
backlog: "backlog",
|
|
5
6
|
todo: "to do",
|
|
6
|
-
doing: "
|
|
7
|
+
doing: "in play",
|
|
7
8
|
review: "review",
|
|
8
9
|
done: "done",
|
|
9
10
|
};
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* A status as someone typed it: Harbor's own (doing) or the board's (In play,
|
|
14
|
+
* To do), any case, with a space, a hyphen or neither. Anything else comes
|
|
15
|
+
* back as typed, for Harbor to refuse with the list.
|
|
16
|
+
*/
|
|
17
|
+
export function statusWord(typed) {
|
|
18
|
+
const v = String(typed)
|
|
19
|
+
.trim()
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[\s_-]+/g, "");
|
|
22
|
+
return (
|
|
23
|
+
{ inplay: "doing", inprogress: "doing", todo: "todo", inreview: "review" }[v] ??
|
|
24
|
+
(["backlog", "doing", "review", "done"].includes(v) ? v : String(typed).trim())
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
/**
|
|
12
29
|
* `mine` leaves out whose each task is: every one is yours. `width` (the
|
|
13
30
|
* terminal's, when printing to one) shortens a long title so the line fits;
|
|
@@ -191,6 +208,25 @@ export function whoami(r, creds) {
|
|
|
191
208
|
return lines.join("\n");
|
|
192
209
|
}
|
|
193
210
|
|
|
211
|
+
/** whoami --json: the account and its harbors, with where it signed in and the harbor commands use. */
|
|
212
|
+
export function whoamiData(r, creds) {
|
|
213
|
+
const list = r.harbors ?? [];
|
|
214
|
+
const set = creds.harbor ? list.find((h) => h.id === creds.harbor) : null;
|
|
215
|
+
const using = set
|
|
216
|
+
? { id: set.id, name: set.name, from: "harbor use" }
|
|
217
|
+
: creds.harbor
|
|
218
|
+
? { id: creds.harbor, name: creds.harborName ?? null, from: "harbor use", left: true }
|
|
219
|
+
: list.length === 1
|
|
220
|
+
? { id: list[0].id, name: list[0].name, from: "your only harbor" }
|
|
221
|
+
: null;
|
|
222
|
+
return {
|
|
223
|
+
...r,
|
|
224
|
+
server: creds.server,
|
|
225
|
+
readOnly: !creds.scope?.split(" ").includes("harbor:write"),
|
|
226
|
+
harbor: using,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
194
230
|
export function harbors(r) {
|
|
195
231
|
const list = r.harbors ?? [];
|
|
196
232
|
if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
|
|
@@ -310,7 +346,7 @@ export function forecastOne(p) {
|
|
|
310
346
|
if (f.lastDue) {
|
|
311
347
|
const which = f.lastDue.key ? `${f.lastDue.key} “${f.lastDue.title}”` : `“${f.lastDue.title}”`;
|
|
312
348
|
lines.push(
|
|
313
|
-
`${which} is due ${f.lastDue.due},
|
|
349
|
+
`${which} is due ${f.lastDue.due}, later than the crew's pace alone would finish, so the dates wait for it.`,
|
|
314
350
|
);
|
|
315
351
|
}
|
|
316
352
|
return lines.join("\n");
|
|
@@ -318,13 +354,17 @@ export function forecastOne(p) {
|
|
|
318
354
|
|
|
319
355
|
/** forecast --json: the finish forecast itself, with only what names the project around it. */
|
|
320
356
|
export function forecastData(r) {
|
|
357
|
+
// The same fields every time, so a script needn't test for them; notes are
|
|
358
|
+
// what the pace can't see, as the list shows them.
|
|
321
359
|
const one = (p) => ({
|
|
322
360
|
id: p.id,
|
|
323
361
|
name: p.name,
|
|
324
362
|
...(p.key ? { key: p.key } : {}),
|
|
325
363
|
...(p.program !== undefined ? { program: p.program } : {}),
|
|
326
364
|
ends: p.ends ?? null,
|
|
365
|
+
...(p.finish?.none ? {} : { rough: false, shortHistory: false, lastDue: null }),
|
|
327
366
|
...p.finish,
|
|
367
|
+
notes: p.finish?.none ? [] : finishNotes(p.finish ?? {}),
|
|
328
368
|
});
|
|
329
369
|
if (!r.projects) return one(r);
|
|
330
370
|
return { projects: r.projects.filter((p) => p.health !== "done").map(one) };
|
|
@@ -533,7 +573,7 @@ export function agentWorkText(r) {
|
|
|
533
573
|
const lines = [`Work for ${r.agent}${r.addedBy ? ` (added by ${r.addedBy})` : ""}`];
|
|
534
574
|
if (!r.tasks?.length) lines.push("", "Nothing to do: no open tasks are assigned to it.");
|
|
535
575
|
for (const t of r.tasks ?? []) {
|
|
536
|
-
const bits = [t.status
|
|
576
|
+
const bits = [STATUS[t.status] ?? t.status, t.priority];
|
|
537
577
|
if (t.due) bits.push(`due ${t.due}`);
|
|
538
578
|
lines.push(
|
|
539
579
|
"",
|
|
@@ -555,3 +595,43 @@ export function agentWorkText(r) {
|
|
|
555
595
|
);
|
|
556
596
|
return lines.join("\n");
|
|
557
597
|
}
|
|
598
|
+
|
|
599
|
+
/** CSV text as rows of cells: quoted cells may hold commas, quotes ("") and line breaks. */
|
|
600
|
+
export function csvRows(text) {
|
|
601
|
+
const rows = [];
|
|
602
|
+
let row = [];
|
|
603
|
+
let cell = "";
|
|
604
|
+
let quoted = false;
|
|
605
|
+
for (let i = 0; i < text.length; i++) {
|
|
606
|
+
const c = text[i];
|
|
607
|
+
if (quoted) {
|
|
608
|
+
if (c === '"' && text[i + 1] === '"') {
|
|
609
|
+
cell += '"';
|
|
610
|
+
i++;
|
|
611
|
+
} else if (c === '"') quoted = false;
|
|
612
|
+
else cell += c;
|
|
613
|
+
} else if (c === '"') quoted = true;
|
|
614
|
+
else if (c === ",") {
|
|
615
|
+
row.push(cell);
|
|
616
|
+
cell = "";
|
|
617
|
+
} else if (c === "\n" || c === "\r") {
|
|
618
|
+
if (c === "\r" && text[i + 1] === "\n") i++;
|
|
619
|
+
row.push(cell);
|
|
620
|
+
rows.push(row);
|
|
621
|
+
row = [];
|
|
622
|
+
cell = "";
|
|
623
|
+
} else cell += c;
|
|
624
|
+
}
|
|
625
|
+
if (cell || row.length) rows.push([...row, cell]);
|
|
626
|
+
return rows;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** export --json: each row as an object keyed by the file's headers, not one CSV string. */
|
|
630
|
+
export function exportData(r) {
|
|
631
|
+
const [columns = [], ...body] = csvRows(r.csv ?? "");
|
|
632
|
+
return {
|
|
633
|
+
filename: r.filename,
|
|
634
|
+
columns,
|
|
635
|
+
rows: body.map((cells) => Object.fromEntries(columns.map((c, i) => [c, cells[i] ?? ""]))),
|
|
636
|
+
};
|
|
637
|
+
}
|