@harborppm/cli 0.47.0 → 0.49.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 +7 -1
- package/dist/app.mjs +1062 -433
- package/lib/cli.mjs +141 -1
- package/lib/format.mjs +94 -0
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -33,8 +33,12 @@ import {
|
|
|
33
33
|
myWork,
|
|
34
34
|
premortemText,
|
|
35
35
|
notesText,
|
|
36
|
+
daysText,
|
|
37
|
+
soundingText,
|
|
38
|
+
soundingsText,
|
|
36
39
|
voyageText,
|
|
37
40
|
handoverText,
|
|
41
|
+
fogText,
|
|
38
42
|
agentWorkText,
|
|
39
43
|
raidDetail,
|
|
40
44
|
raidList,
|
|
@@ -174,10 +178,20 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
174
178
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
175
179
|
a note for later: sealed until
|
|
176
180
|
its day, milestone, or Off track
|
|
181
|
+
harbor sound TASK [DAYS] [--why TEXT] [--ask NAMES|crew] [--settle DAYS]
|
|
182
|
+
a sounding: ask a few people how
|
|
183
|
+
many days a task will take, give
|
|
184
|
+
your answer, or read the spread
|
|
185
|
+
harbor soundings [PROJECT] open tasks with a sounding, and
|
|
186
|
+
the ones waiting on your answer
|
|
177
187
|
harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
|
|
178
188
|
[--keep ID,ID] [--confirm]
|
|
179
189
|
hand someone's work to the crew
|
|
180
190
|
harbor handback PERSON give it back after time away
|
|
191
|
+
harbor fog [--project P] [--list] tasks nobody has touched for three
|
|
192
|
+
weeks, one at a time: still
|
|
193
|
+
needed, a new date, or drop it
|
|
194
|
+
harbor fog keep TASK it's still needed: out of the fog
|
|
181
195
|
harbor agent [--agent NAME] the tasks given to an AI agent on
|
|
182
196
|
the crew, in the order to do them
|
|
183
197
|
harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
@@ -190,7 +204,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
190
204
|
"to do", "in play"; --json says doing for In play).
|
|
191
205
|
DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
|
|
192
206
|
Changes show in Harbor's History as yours, "via Harbor CLI". These commands
|
|
193
|
-
never delete; the full-screen app can, as the website can, and asks first
|
|
207
|
+
never delete; the full-screen app can, as the website can, and asks first,
|
|
208
|
+
and so can harbor fog's x (drop it), one task at a time.
|
|
194
209
|
`;
|
|
195
210
|
|
|
196
211
|
export const VERSION = JSON.parse(
|
|
@@ -363,6 +378,9 @@ const FLAG = {
|
|
|
363
378
|
to: "--to",
|
|
364
379
|
keep: "--keep",
|
|
365
380
|
person: "the person",
|
|
381
|
+
days: "the days",
|
|
382
|
+
why: "--why",
|
|
383
|
+
people: "--ask",
|
|
366
384
|
};
|
|
367
385
|
|
|
368
386
|
/** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
|
|
@@ -600,6 +618,25 @@ const DETAIL = {
|
|
|
600
618
|
" --no-people. A task already in its project is left alone: by its key",
|
|
601
619
|
" (a Key column, as harbor export writes) or by title.",
|
|
602
620
|
],
|
|
621
|
+
sound: [
|
|
622
|
+
" harbor sound TASK reads its sounding: who hasn't answered,",
|
|
623
|
+
" and the spread once everyone has",
|
|
624
|
+
" harbor sound TASK --ask crew writes: asks you, its owner and whoever",
|
|
625
|
+
" has open work on its project",
|
|
626
|
+
' harbor sound TASK --ask "Sam,Priya" writes: asks those people',
|
|
627
|
+
' harbor sound TASK 4 [--why "TEXT"] writes: your answer, in working days',
|
|
628
|
+
" harbor sound TASK --settle 6 writes: the days you agreed on after",
|
|
629
|
+
" talking a wide spread through",
|
|
630
|
+
"",
|
|
631
|
+
" Each person answers alone, and nobody sees the others' numbers until",
|
|
632
|
+
" everyone has. Close guesses give the task an estimate; far-apart ones",
|
|
633
|
+
" say talk first. The forecast draws on every answer.",
|
|
634
|
+
],
|
|
635
|
+
soundings: [
|
|
636
|
+
" harbor soundings [PROJECT] reads: open tasks with a sounding",
|
|
637
|
+
" (all projects when left out), and the",
|
|
638
|
+
" ones waiting on your answer",
|
|
639
|
+
],
|
|
603
640
|
note: [
|
|
604
641
|
" harbor note PROJECT reads its notes for later: who left",
|
|
605
642
|
" each, what opens it, and the opened ones",
|
|
@@ -628,6 +665,20 @@ const DETAIL = {
|
|
|
628
665
|
"",
|
|
629
666
|
' PERSON is a name, an id, or "me".',
|
|
630
667
|
],
|
|
668
|
+
fog: [
|
|
669
|
+
" harbor fog [--project P] in a terminal: the tasks in fog, one",
|
|
670
|
+
" at a time. k still needed (clears the",
|
|
671
|
+
" fog), d a new date, x drop it, s skip,",
|
|
672
|
+
" q stop; then it saves what you chose",
|
|
673
|
+
" harbor fog --list reads them: open tasks nobody has",
|
|
674
|
+
" opened, moved, edited or commented on",
|
|
675
|
+
" for three weeks, longest first",
|
|
676
|
+
" harbor fog keep TASK writes: it's still needed, so it",
|
|
677
|
+
" leaves the fog",
|
|
678
|
+
"",
|
|
679
|
+
" Piped, or with --json, it lists. The one-at-a-time sweep needs full access",
|
|
680
|
+
" (harbor login without --read-only), since dropping a task deletes it.",
|
|
681
|
+
],
|
|
631
682
|
handback: [
|
|
632
683
|
" harbor handback PERSON writes, straight away: gives back",
|
|
633
684
|
" what was handed over for their time",
|
|
@@ -2031,6 +2082,65 @@ export async function run(argv, io = {}) {
|
|
|
2031
2082
|
(r) => `Sealed your note on ${r.sealed.project}. It ${r.sealed.opens}.`,
|
|
2032
2083
|
);
|
|
2033
2084
|
}
|
|
2085
|
+
case "sound": {
|
|
2086
|
+
const { positionals, values, harbor } = parse(spec([], ["why", "ask", "settle"]));
|
|
2087
|
+
const [task, days, ...extra] = positionals;
|
|
2088
|
+
if (!task) throw new HarborError("Give a task's key, like harbor sound RR-4.");
|
|
2089
|
+
if (extra.length) throw new HarborError(`Unexpected "${extra.join(" ")}".`);
|
|
2090
|
+
const number = (text) => {
|
|
2091
|
+
const n = Number(String(text).replace(/d(ays?)?$/i, ""));
|
|
2092
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
2093
|
+
throw new HarborError(`Give days as a number, like 3 or 2.5 (not "${text}").`);
|
|
2094
|
+
return n;
|
|
2095
|
+
};
|
|
2096
|
+
const given = [days !== undefined, values.ask !== undefined, values.settle !== undefined];
|
|
2097
|
+
if (given.filter(Boolean).length > 1)
|
|
2098
|
+
throw new HarborError("Answer, ask or settle: one at a time.");
|
|
2099
|
+
if (values.why !== undefined && days === undefined)
|
|
2100
|
+
throw new HarborError('--why goes with your answer: harbor sound TASK 4 --why "TEXT".');
|
|
2101
|
+
if (values.ask !== undefined) {
|
|
2102
|
+
const crew = /^crew$/i.test(values.ask.trim())
|
|
2103
|
+
? undefined
|
|
2104
|
+
: values.ask
|
|
2105
|
+
.split(",")
|
|
2106
|
+
.map((x) => x.trim())
|
|
2107
|
+
.filter(Boolean);
|
|
2108
|
+
return call(
|
|
2109
|
+
"take_sounding",
|
|
2110
|
+
{ harbor, task, ...(crew ? { people: crew } : {}) },
|
|
2111
|
+
(r) =>
|
|
2112
|
+
`Asked ${r.asked.people.join(", ")} how many days ${r.asked.key ?? r.asked.title} will take. The answers show together once everyone's in.`,
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
if (values.settle !== undefined) {
|
|
2116
|
+
return call(
|
|
2117
|
+
"settle_sounding",
|
|
2118
|
+
{ harbor, task, days: number(values.settle) },
|
|
2119
|
+
(r) => `Settled ${r.settled.key ?? r.settled.title} at ${daysText(r.settled.days)}.`,
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
if (days !== undefined) {
|
|
2123
|
+
return call(
|
|
2124
|
+
"answer_sounding",
|
|
2125
|
+
{ harbor, task, days: number(days), ...(values.why ? { why: values.why } : {}) },
|
|
2126
|
+
(r) => soundingText(r.answered, r.sounding, { answered: true }),
|
|
2127
|
+
);
|
|
2128
|
+
}
|
|
2129
|
+
return call(
|
|
2130
|
+
"get_task",
|
|
2131
|
+
{ harbor, task },
|
|
2132
|
+
(r) => soundingText(r, r.sounding ?? null),
|
|
2133
|
+
(r) => ({ id: r.id, key: r.key ?? null, title: r.title, sounding: r.sounding ?? null }),
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
case "soundings": {
|
|
2137
|
+
const { positionals, harbor } = parse(spec());
|
|
2138
|
+
const [project, ...extra] = positionals;
|
|
2139
|
+
if (extra.length) throw new HarborError(`Unexpected "${extra.join(" ")}".`);
|
|
2140
|
+
return call("list_soundings", { harbor, ...(project ? { project } : {}) }, (r) =>
|
|
2141
|
+
soundingsText(r),
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2034
2144
|
case "voyage": {
|
|
2035
2145
|
const { positionals, harbor } = parse(spec());
|
|
2036
2146
|
const [project, ...words] = positionals;
|
|
@@ -2045,6 +2155,36 @@ export async function run(argv, io = {}) {
|
|
|
2045
2155
|
}
|
|
2046
2156
|
return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
|
|
2047
2157
|
}
|
|
2158
|
+
case "fog": {
|
|
2159
|
+
const { positionals, values, harbor } = parse(spec(["list"], ["project"]));
|
|
2160
|
+
if (positionals[0] === "keep") {
|
|
2161
|
+
const task = one(positionals.slice(1), "task");
|
|
2162
|
+
return call(
|
|
2163
|
+
"keep_task",
|
|
2164
|
+
{ harbor, task },
|
|
2165
|
+
(r) => `Kept ${r.kept.key ?? r.kept.task} out of the fog: it counts as touched today.`,
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
none(positionals);
|
|
2169
|
+
const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
2170
|
+
if (terminal && !values.list && !asJson && hasFullAccess(creds)) {
|
|
2171
|
+
const { fogHere } = await loadApp();
|
|
2172
|
+
await fogHere({
|
|
2173
|
+
source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
|
|
2174
|
+
fetcher,
|
|
2175
|
+
harbor,
|
|
2176
|
+
saved: creds.harbor ?? null,
|
|
2177
|
+
project: values.project,
|
|
2178
|
+
});
|
|
2179
|
+
await stalePromptCache(env);
|
|
2180
|
+
return 0;
|
|
2181
|
+
}
|
|
2182
|
+
return call(
|
|
2183
|
+
"fog",
|
|
2184
|
+
{ harbor, project: values.project, limit: 100 },
|
|
2185
|
+
(r) => fogText(r),
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2048
2188
|
case "handover": {
|
|
2049
2189
|
const { positionals, values, harbor } = parse(
|
|
2050
2190
|
spec(["confirm"], ["to", "until", "note", "keep"]),
|
package/lib/format.mjs
CHANGED
|
@@ -600,6 +600,78 @@ export function notesText(project, notes) {
|
|
|
600
600
|
return lines.join("\n");
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
+
/** "half a day", "1 day", "2.5 days". */
|
|
604
|
+
export function daysText(days) {
|
|
605
|
+
if (days === 0.5) return "half a day";
|
|
606
|
+
return `${days} day${days === 1 ? "" : "s"}`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** The spread as a line of dots, 0 to the highest guess, like the website's. */
|
|
610
|
+
function spreadLine(answers, width = 30) {
|
|
611
|
+
const days = answers.map((a) => a.days);
|
|
612
|
+
const top = Math.max(5, ...days);
|
|
613
|
+
const cells = Array(width + 1).fill("·");
|
|
614
|
+
for (const d of days) cells[Math.round((d / top) * width)] = "●";
|
|
615
|
+
return `0 ${cells.join("")} ${daysText(top)}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** One task's sounding: who's left to answer, or the spread and what it means. */
|
|
619
|
+
export function soundingText(task, s, { answered = false } = {}) {
|
|
620
|
+
const name = task.key ? `${task.key} ${task.title}` : task.title;
|
|
621
|
+
if (!s)
|
|
622
|
+
return `No sounding on ${name}. Ask the crew: harbor sound ${task.key ?? task.id} --ask crew`;
|
|
623
|
+
const lines = [`Sounding · ${name} · ${s.answered} of ${s.crew.length} answered`];
|
|
624
|
+
if (answered) lines[0] = `Your answer is in. ${lines[0]}`;
|
|
625
|
+
if (!s.shown) {
|
|
626
|
+
if (s.waitingOn.length) lines.push(` Waiting on ${s.waitingOn.join(", ")}.`);
|
|
627
|
+
lines.push(" The answers show together once everyone's in.");
|
|
628
|
+
for (const a of s.answers)
|
|
629
|
+
lines.push(` You said ${daysText(a.days)}${a.why ? ` · "${a.why}"` : ""}`);
|
|
630
|
+
if (s.youWereAsked && !s.answers.length)
|
|
631
|
+
lines.push(` Give yours: harbor sound ${task.key ?? task.id} DAYS`);
|
|
632
|
+
return lines.join("\n");
|
|
633
|
+
}
|
|
634
|
+
lines.push(` ${spreadLine(s.answers)}`);
|
|
635
|
+
for (const a of s.answers)
|
|
636
|
+
lines.push(` ${a.who}: ${daysText(a.days)}${a.why ? ` · "${a.why}"` : ""}`);
|
|
637
|
+
if (s.settled) lines.push(` Settled on ${daysText(s.estimateDays)}.`);
|
|
638
|
+
else if (s.talkFirst)
|
|
639
|
+
lines.push(
|
|
640
|
+
` Talk about this first: ${daysText(s.low)} to ${daysText(s.high)} means someone sees work the others don't.`,
|
|
641
|
+
` Once you've talked: harbor sound ${task.key ?? task.id} --settle DAYS`,
|
|
642
|
+
);
|
|
643
|
+
else lines.push(` Close enough: about ${daysText(s.estimateDays)}.`);
|
|
644
|
+
return lines.join("\n");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** list_soundings: the ones waiting on you first, then the rest, wide ones flagged. */
|
|
648
|
+
export function soundingsText(r) {
|
|
649
|
+
const all = r.soundings ?? [];
|
|
650
|
+
const where = r.project ? ` on ${r.project}` : "";
|
|
651
|
+
if (!all.length)
|
|
652
|
+
return `No soundings${where}. Ask how long a task will take: harbor sound TASK --ask crew`;
|
|
653
|
+
const name = (t) => (t.key ? `${t.key} ${t.title}` : t.title);
|
|
654
|
+
const yours = all.filter((t) => t.youWereAsked && !t.answers.length && !t.shown);
|
|
655
|
+
const lines = [];
|
|
656
|
+
if (yours.length) {
|
|
657
|
+
lines.push("Waiting on your answer");
|
|
658
|
+
for (const t of yours) lines.push(` ${name(t)} · asked by ${t.askedBy ?? "someone"}`);
|
|
659
|
+
lines.push("");
|
|
660
|
+
}
|
|
661
|
+
lines.push(`Soundings${where}`);
|
|
662
|
+
for (const t of all) {
|
|
663
|
+
const state = t.settled
|
|
664
|
+
? `settled · ${daysText(t.estimateDays)}`
|
|
665
|
+
: t.talkFirst
|
|
666
|
+
? `talk first · ${daysText(t.low)} to ${daysText(t.high)}`
|
|
667
|
+
: t.shown
|
|
668
|
+
? `about ${daysText(t.estimateDays)}`
|
|
669
|
+
: `${t.answered} of ${t.crew.length} answered`;
|
|
670
|
+
lines.push(` ${name(t)} · ${state}`);
|
|
671
|
+
}
|
|
672
|
+
return lines.join("\n");
|
|
673
|
+
}
|
|
674
|
+
|
|
603
675
|
/** handover_plan as a list grouped like the website's dialog, each line with who takes it. */
|
|
604
676
|
export function handoverText(r, { confirmHint = true } = {}) {
|
|
605
677
|
const items = r.items ?? [];
|
|
@@ -705,3 +777,25 @@ export function exportData(r) {
|
|
|
705
777
|
rows: body.map((cells) => Object.fromEntries(columns.map((c, i) => [c, cells[i] ?? ""]))),
|
|
706
778
|
};
|
|
707
779
|
}
|
|
780
|
+
|
|
781
|
+
/** fog: the open tasks nobody has touched for three weeks, longest first. */
|
|
782
|
+
export function fogText(r, { sweepHint = true } = {}) {
|
|
783
|
+
const where = r.project ? ` in ${r.project}` : "";
|
|
784
|
+
if (!r.inFog) return `No fog${where}: every open task was touched in the last three weeks.`;
|
|
785
|
+
const lines = [
|
|
786
|
+
`${r.inFog} ${r.inFog === 1 ? "task" : "tasks"} in fog${where}: nobody has opened, moved or commented on ${r.inFog === 1 ? "it" : "them"} for three weeks.`,
|
|
787
|
+
"",
|
|
788
|
+
];
|
|
789
|
+
for (const t of r.tasks) {
|
|
790
|
+
const bits = [t.untouched, r.project ? null : t.project, t.due ? `due ${t.due}` : null];
|
|
791
|
+
lines.push(` ${(t.key ?? "").padEnd(7)} ${t.title}`);
|
|
792
|
+
lines.push(` ${bits.filter(Boolean).join(" · ")}`);
|
|
793
|
+
}
|
|
794
|
+
if (r.more) lines.push("", `And ${r.inFog - r.tasks.length} more.`);
|
|
795
|
+
if (sweepHint)
|
|
796
|
+
lines.push(
|
|
797
|
+
"",
|
|
798
|
+
"In a terminal, harbor fog walks through them one at a time: k still needed, d new date, x drop it. harbor fog keep TASK keeps one.",
|
|
799
|
+
);
|
|
800
|
+
return lines.join("\n");
|
|
801
|
+
}
|