@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/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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",