@harborppm/cli 0.32.0 → 0.34.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 CHANGED
@@ -129,8 +129,12 @@ harbor report PROGRAM a program's status report
129
129
  harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
130
130
  harbor voyage PROJECT ["YOUR LINE"]
131
131
  harbor limit PROJECT [COLUMN N|none]
132
+ harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT] [--keep ID,ID] [--confirm]
133
+ harbor handback PERSON
132
134
  ```
133
135
 
136
+ `harbor handover Priya` lists everything Priya carries (open tasks, sign-offs waiting on her, risks and issues she owns, projects and programs she leads) and who should take each, from who has room this week. Nothing moves until you add `--confirm`. `--until DATE` is for time away: Priya shows as away on People, and `harbor handback Priya` gives back whatever is still with whoever took it. `--note` goes with each task and risk as a comment.
137
+
134
138
  `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.
135
139
 
136
140
  `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.
package/dist/app.mjs CHANGED
@@ -795,6 +795,9 @@ function money(n) {
795
795
  function shortDate(value) {
796
796
  return format(parseDate$1(value), "MMM d");
797
797
  }
798
+ function longDate(value) {
799
+ return format(parseDate$1(value), "EEE, MMM d");
800
+ }
798
801
  function isOverdue(value, done = false) {
799
802
  if (!value || done) return false;
800
803
  try {
@@ -10408,6 +10411,38 @@ function hierarchySlice({ get, write }) {
10408
10411
  get().log(`added the sample project`, `/projects/${id}`);
10409
10412
  return id;
10410
10413
  },
10414
+ addShelfPlan: (rows) => {
10415
+ const state = get();
10416
+ const starter = starterProgram(state);
10417
+ const program = starter ? {
10418
+ ...starter,
10419
+ ...rows.program,
10420
+ id: starter.id,
10421
+ portfolioId: starter.portfolioId,
10422
+ health: starter.health
10423
+ } : rows.program;
10424
+ const milestones = rows.milestones.map((m) => ({
10425
+ ...m,
10426
+ programId: program.id
10427
+ }));
10428
+ const project = {
10429
+ ...rows.project,
10430
+ programId: program.id
10431
+ };
10432
+ write({
10433
+ programs: starter ? state.programs.map((g) => g.id === starter.id ? program : g) : [...state.programs, program],
10434
+ milestones: [...state.milestones, ...milestones],
10435
+ projects: [...state.projects, project],
10436
+ tasks: [...state.tasks, ...rows.tasks],
10437
+ raidItems: [...state.raidItems, ...rows.raidItems],
10438
+ boardConfigs: {
10439
+ ...state.boardConfigs ?? {},
10440
+ [project.id]: defaultBoardConfig()
10441
+ }
10442
+ });
10443
+ get().log(`opened the ${rows.program.name} plan`, `/programs/${program.id}`);
10444
+ return program.id;
10445
+ },
10411
10446
  updatePortfolio: (id, patch) => {
10412
10447
  write({ portfolios: get().portfolios.map((p) => p.id === id ? {
10413
10448
  ...p,
@@ -11367,6 +11402,42 @@ function milestonesRaidSlice({ set, get, write }) {
11367
11402
  };
11368
11403
  }
11369
11404
 
11405
+ //#endregion
11406
+ //#region src/lib/handover.ts
11407
+ /** The line that travels with each handed item: who it came from, to whom, until when, and the note. */
11408
+ function handoverLine(opts) {
11409
+ const until = opts.until && opts.untilText ? `, until ${opts.untilText}` : "";
11410
+ const note = opts.note.trim();
11411
+ return `Handed over from ${opts.fromName} to ${opts.toName}${until}.${note ? ` ${note}` : ""}`;
11412
+ }
11413
+ /** What can go back after time away: moves whose item is still with the person who took it. */
11414
+ function returnable(data, person) {
11415
+ const moves = person.away?.moves ?? [];
11416
+ const tasks = byId(data.tasks);
11417
+ const milestones = byId(data.milestones);
11418
+ const raid = byId(data.raidItems);
11419
+ const projects = byId(data.projects);
11420
+ const programs = byId(data.programs);
11421
+ return moves.filter((m) => {
11422
+ switch (m.kind) {
11423
+ case "task": {
11424
+ const t = tasks.get(m.id);
11425
+ return Boolean(t && t.assigneeId === m.toId && t.status !== "done");
11426
+ }
11427
+ case "signoff": {
11428
+ const ms = milestones.get(m.id);
11429
+ return Boolean(ms && ms.approverId === m.toId && !ms.done);
11430
+ }
11431
+ case "raid": {
11432
+ const r = raid.get(m.id);
11433
+ return Boolean(r && r.ownerId === m.toId && isActive(r));
11434
+ }
11435
+ case "project": return projects.get(m.id)?.ownerId === m.toId;
11436
+ case "program": return programs.get(m.id)?.ownerId === m.toId;
11437
+ }
11438
+ });
11439
+ }
11440
+
11370
11441
  //#endregion
11371
11442
  //#region src/lib/allocation.ts
11372
11443
  /** Allocations with `programId` set to `percent`; 0 or null removes the line. */
@@ -11390,6 +11461,57 @@ function peopleSpendSlice({ set, get, write }) {
11390
11461
  allocations: withAllocation(p.allocations, programId, percent)
11391
11462
  } : p) });
11392
11463
  },
11464
+ handOver: (fromId, moves, opts) => {
11465
+ const from = get().people.find((p) => p.id === fromId);
11466
+ if (!from) return 0;
11467
+ const until = opts.until || null;
11468
+ const applied = applyMoves(get(), moves, (m) => ({
11469
+ fromName: from.name,
11470
+ toName: get().people.find((p) => p.id === m.toId)?.name ?? "someone",
11471
+ until,
11472
+ untilText: until ? longDate(until) : null,
11473
+ note: opts.note ?? ""
11474
+ }));
11475
+ write({
11476
+ ...applied.patch,
11477
+ people: get().people.map((p) => p.id === fromId ? {
11478
+ ...p,
11479
+ away: until ? {
11480
+ until,
11481
+ note: opts.note?.trim() || null,
11482
+ since: stamp(),
11483
+ moves: applied.moved
11484
+ } : null
11485
+ } : p)
11486
+ });
11487
+ get().log(until ? `handed over ${from.name}’s work until ${longDate(until)}` : `handed over ${from.name}’s work`, "/people");
11488
+ return applied.moved.length;
11489
+ },
11490
+ handBack: (personId) => {
11491
+ const person = get().people.find((p) => p.id === personId);
11492
+ if (!person?.away) return 0;
11493
+ const back = returnable(get(), person).map((m) => ({
11494
+ ...m,
11495
+ toId: personId,
11496
+ fromId: m.toId
11497
+ }));
11498
+ const applied = applyMoves(get(), back, (m) => ({
11499
+ fromName: get().people.find((p) => p.id === m.fromId)?.name ?? "someone",
11500
+ toName: person.name,
11501
+ until: null,
11502
+ untilText: null,
11503
+ note: "Back from time away."
11504
+ }));
11505
+ write({
11506
+ ...applied.patch,
11507
+ people: get().people.map((p) => p.id === personId ? {
11508
+ ...p,
11509
+ away: null
11510
+ } : p)
11511
+ });
11512
+ get().log(`handed ${person.name}’s work back`, "/people");
11513
+ return applied.moved.length;
11514
+ },
11393
11515
  updatePerson: (id, patch) => {
11394
11516
  write({ people: get().people.map((p) => p.id === id ? {
11395
11517
  ...p,
@@ -11484,6 +11606,95 @@ function peopleSpendSlice({ set, get, write }) {
11484
11606
  }
11485
11607
  };
11486
11608
  }
11609
+ /**
11610
+ * One handover's changes as a single write: each item to its new person, the
11611
+ * line that travels with it (a comment on a task, an update on a RAID item),
11612
+ * and the moves that actually applied. Done or missing items are skipped.
11613
+ */
11614
+ function applyMoves(s, moves, lineFor) {
11615
+ const by = s.currentUserId;
11616
+ const at = stamp();
11617
+ const want = (kind) => new Map(moves.filter((m) => m.kind === kind).map((m) => [m.id, m]));
11618
+ const tasksTo = want("task");
11619
+ const signoffsTo = want("signoff");
11620
+ const raidTo = want("raid");
11621
+ const projectsTo = want("project");
11622
+ const programsTo = want("program");
11623
+ const moved = [];
11624
+ const done = (m) => moved.push({
11625
+ kind: m.kind,
11626
+ id: m.id,
11627
+ toId: m.toId
11628
+ });
11629
+ const comments = [];
11630
+ const patch = {};
11631
+ if (tasksTo.size) patch.tasks = s.tasks.map((t) => {
11632
+ const m = tasksTo.get(t.id);
11633
+ if (!m || t.status === "done" || t.assigneeId === m.toId) return t;
11634
+ done(m);
11635
+ comments.push({
11636
+ id: uid("c"),
11637
+ taskId: t.id,
11638
+ authorId: by,
11639
+ body: handoverLine(lineFor(m)),
11640
+ createdAt: at,
11641
+ mentionedIds: []
11642
+ });
11643
+ return stampAssignment(t, {
11644
+ ...t,
11645
+ assigneeId: m.toId
11646
+ }, by, at);
11647
+ });
11648
+ if (comments.length) patch.comments = [...s.comments, ...comments];
11649
+ if (signoffsTo.size) patch.milestones = s.milestones.map((ms) => {
11650
+ const m = signoffsTo.get(ms.id);
11651
+ if (!m || ms.done || ms.approverId === m.toId) return ms;
11652
+ done(m);
11653
+ return {
11654
+ ...ms,
11655
+ approverId: m.toId
11656
+ };
11657
+ });
11658
+ if (raidTo.size) patch.raidItems = s.raidItems.map((r) => {
11659
+ const m = raidTo.get(r.id);
11660
+ if (!m || r.ownerId === m.toId) return r;
11661
+ done(m);
11662
+ const update = {
11663
+ id: uid("ru"),
11664
+ authorId: by,
11665
+ body: handoverLine(lineFor(m)),
11666
+ createdAt: at
11667
+ };
11668
+ return {
11669
+ ...r,
11670
+ ownerId: m.toId,
11671
+ updates: [...r.updates, update],
11672
+ updatedAt: at
11673
+ };
11674
+ });
11675
+ if (projectsTo.size) patch.projects = s.projects.map((p) => {
11676
+ const m = projectsTo.get(p.id);
11677
+ if (!m || p.ownerId === m.toId) return p;
11678
+ done(m);
11679
+ return {
11680
+ ...p,
11681
+ ownerId: m.toId
11682
+ };
11683
+ });
11684
+ if (programsTo.size) patch.programs = s.programs.map((p) => {
11685
+ const m = programsTo.get(p.id);
11686
+ if (!m || p.ownerId === m.toId) return p;
11687
+ done(m);
11688
+ return {
11689
+ ...p,
11690
+ ownerId: m.toId
11691
+ };
11692
+ });
11693
+ return {
11694
+ patch,
11695
+ moved
11696
+ };
11697
+ }
11487
11698
 
11488
11699
  //#endregion
11489
11700
  //#region src/tui/engine/store.ts
@@ -11765,6 +11976,7 @@ const FIELD = {
11765
11976
  role: "role",
11766
11977
  weeklyCapacity: "weekly capacity",
11767
11978
  allocations: "time split",
11979
+ away: "time away",
11768
11980
  location: "location",
11769
11981
  note: "note"
11770
11982
  };
package/lib/cli.mjs CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  myWork,
28
28
  premortemText,
29
29
  voyageText,
30
+ handoverText,
30
31
  raidList,
31
32
  programTree,
32
33
  projectList,
@@ -116,6 +117,10 @@ export const HELP = `harbor: Harbor from the terminal
116
117
  answer or read it
117
118
  harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
118
119
  your line in it
120
+ harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
121
+ [--keep ID,ID] [--confirm]
122
+ hand someone's work to the crew
123
+ harbor handback PERSON give it back after time away
119
124
 
120
125
  Every command takes --json, and --harbor NAME when you're in more than one
121
126
  harbor (or set one with harbor use). harbor help COMMAND shows just that one.
@@ -284,10 +289,14 @@ const FLAG = {
284
289
  column: "the column",
285
290
  limit: "the limit",
286
291
  at: "--at",
292
+ until: "--until",
293
+ to: "--to",
294
+ keep: "--keep",
295
+ person: "the person",
287
296
  };
288
297
 
289
298
  /** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
290
- export function hint(message) {
299
+ export function hint(message, project) {
291
300
  const said = message.replace(/\s*Ask the person;.*$/s, "");
292
301
  if (/confirmed: true/.test(message))
293
302
  return `${said}\nTo finish it anyway, run it again with --confirm.`;
@@ -322,8 +331,9 @@ export function hint(message) {
322
331
  .replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
323
332
  .replace(
324
333
  /^(.+) has no pre-mortem running\. Start one with start_premortem first\.$/s,
334
+ // What the person typed found it, so the command they're told to run uses that (the name may be shared).
325
335
  (_, name) =>
326
- `${name} has no pre-mortem running. Start one with harbor premortem "${name}" --start.`,
336
+ `${name} has no pre-mortem running. Start one with harbor premortem "${project ?? name}" --start.`,
327
337
  )
328
338
  .replace(
329
339
  /^Nothing to change: pass a field to update\.$/,
@@ -344,11 +354,20 @@ export function hint(message) {
344
354
  }
345
355
 
346
356
  /** get_premortem's answer with its note naming the command, not the agent tool, since --json shows it. */
347
- export function premortemForTerminal(r) {
357
+ export function premortemForTerminal(r, project = r.project) {
348
358
  if (r.running || !r.note) return r;
349
359
  return {
350
360
  ...r,
351
- note: `No pre-mortem on ${r.project} yet. Start one with harbor premortem "${r.project}" --start.`,
361
+ note: `No pre-mortem on ${r.project} yet. Start one with harbor premortem "${project}" --start.`,
362
+ };
363
+ }
364
+
365
+ /** get_voyage_log's answer with its note on an empty crew naming the command, not the agent tool. */
366
+ export function voyageForTerminal(r, project = r.project) {
367
+ if (!r.crewNote) return r;
368
+ return {
369
+ ...r,
370
+ crewNote: `crew is the lines people add to the log; nobody has yet. Add yours with harbor voyage "${project}" "YOUR LINE".`,
352
371
  };
353
372
  }
354
373
 
@@ -377,10 +396,26 @@ const DETAIL = {
377
396
  " PROJECT is a name, a key like RR, or an id. When two projects share a",
378
397
  " name, give the key.",
379
398
  ],
399
+ handover: [
400
+ " harbor handover PERSON reads what would move, and to whom:",
401
+ " their open tasks, sign-offs, risks",
402
+ " and issues, and what they lead",
403
+ " harbor handover PERSON --confirm writes: hands it over as shown",
404
+ "",
405
+ " --to NAME all of it to one person, instead of who has room",
406
+ " --until DATE time away: they show as away, and harbor handback PERSON",
407
+ " (or People on the website) gives back what's still with",
408
+ " whoever took it",
409
+ ' --note "TEXT" goes with each task and risk as a comment',
410
+ " --keep ID,ID leave these where they are (ids or keys from the list)",
411
+ "",
412
+ ' PERSON is a name, an id, or "me".',
413
+ ],
380
414
  voyage: [
381
415
  " harbor voyage PROJECT reads a Done project's voyage log",
382
416
  ' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
383
417
  "",
418
+ " In --json, crew is the lines people added; closedBy is who closed tasks.",
384
419
  " PROJECT is a name, a key like RR, or an id. When two projects share a",
385
420
  " name, give the key.",
386
421
  ],
@@ -766,9 +801,10 @@ export async function run(argv, io = {}) {
766
801
  const call = async (name, args, show, forTerminal = (r) => r) => {
767
802
  const result = forTerminal(
768
803
  await client.call(name, args).catch((err) => {
769
- if (err instanceof HarborError) throw new HarborError(hint(err.message));
804
+ if (err instanceof HarborError) throw new HarborError(hint(err.message, args.project));
770
805
  throw err;
771
806
  }),
807
+ args.project,
772
808
  );
773
809
  // The prompt's counts are behind now; its next draw fetches new ones.
774
810
  if (name === "create_task" || name === "update_task") await stalePromptCache(env);
@@ -1283,7 +1319,7 @@ export async function run(argv, io = {}) {
1283
1319
  const answer = words.join(" ").trim();
1284
1320
  if (values.start) {
1285
1321
  const started = await client.call("start_premortem", { harbor, project }).catch((err) => {
1286
- if (err instanceof HarborError) throw new HarborError(hint(err.message));
1322
+ if (err instanceof HarborError) throw new HarborError(hint(err.message, project));
1287
1323
  throw err;
1288
1324
  });
1289
1325
  if (!answer) {
@@ -1338,7 +1374,53 @@ export async function run(argv, io = {}) {
1338
1374
  (r) => `Your line is in ${r.added.project}'s voyage log.`,
1339
1375
  );
1340
1376
  }
1341
- return call("get_voyage_log", { harbor, project }, voyageText);
1377
+ return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
1378
+ }
1379
+ case "handover": {
1380
+ const { positionals, values, harbor } = parse(
1381
+ spec(["confirm"], ["to", "until", "note", "keep"]),
1382
+ );
1383
+ const person = positionals.join(" ").trim();
1384
+ if (!person) throw new HarborError('Say whose work: harbor handover "NAME" (or me).');
1385
+ if (!values.confirm) {
1386
+ return call("handover_plan", { harbor, person, to: values.to }, (r) => handoverText(r));
1387
+ }
1388
+ return call(
1389
+ "hand_over",
1390
+ {
1391
+ harbor,
1392
+ person,
1393
+ to: values.to,
1394
+ until: dueDate(values.until),
1395
+ note: values.note,
1396
+ keep: values.keep
1397
+ ? values.keep
1398
+ .split(",")
1399
+ .map((k) => k.trim())
1400
+ .filter(Boolean)
1401
+ : undefined,
1402
+ },
1403
+ (r) => {
1404
+ const h = r.handedOver;
1405
+ const names = [...new Set(h.items.map((i) => i.to))];
1406
+ const who =
1407
+ names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0];
1408
+ const moved = `Handed ${h.person}'s work over: ${h.moved} ${h.moved === 1 ? "thing" : "things"}${who ? ` to ${who}` : ""}.`;
1409
+ return h.awayUntil
1410
+ ? `${moved} ${h.person} shows as away until ${h.awayUntil}; harbor handback "${h.person}" gives it back.`
1411
+ : moved;
1412
+ },
1413
+ );
1414
+ }
1415
+ case "handback": {
1416
+ const { positionals, harbor } = parse(spec());
1417
+ const person = positionals.join(" ").trim();
1418
+ if (!person) throw new HarborError('Say who is back: harbor handback "NAME".');
1419
+ return call("hand_back", { harbor, person }, (r) =>
1420
+ r.handedBack.moved
1421
+ ? `Handed ${r.handedBack.moved} back to ${r.handedBack.person}.`
1422
+ : `${r.handedBack.person} is back. Nothing was still waiting to go back.`,
1423
+ );
1342
1424
  }
1343
1425
  case "risk":
1344
1426
  case "issue":
package/lib/format.mjs CHANGED
@@ -369,3 +369,30 @@ export function hillList(r) {
369
369
  }
370
370
  return blocks.join("\n\n");
371
371
  }
372
+
373
+ /** handover_plan as a list grouped like the website's dialog, each line with who takes it. */
374
+ export function handoverText(r, { confirmHint = true } = {}) {
375
+ const items = r.items ?? [];
376
+ if (items.length === 0) return `${r.person} has nothing open to hand over.`;
377
+ const lines = [
378
+ `Hand over ${r.person}'s work${r.awayUntil ? ` (away until ${r.awayUntil})` : ""}`,
379
+ ];
380
+ let group = null;
381
+ for (const i of items) {
382
+ if (i.group !== group) {
383
+ group = i.group;
384
+ lines.push("", `${group}:`);
385
+ }
386
+ lines.push(
387
+ ` ${i.what}${i.due && i.kind !== "project" && i.kind !== "program" ? ` (${i.due})` : ""} → ${i.to ?? "nobody yet"}`,
388
+ );
389
+ }
390
+ const room = (r.takers ?? []).filter((t) => t.note).map((t) => `${t.who} ${t.note}`);
391
+ if (room.length) lines.push("", `This week: ${room.join(", ")}.`);
392
+ if (confirmHint)
393
+ lines.push(
394
+ "",
395
+ 'Nothing has moved yet. Add --confirm to hand it over, --until DATE for time away, --note "…" to send a note with it, --to NAME to give it all to one person, or --keep ID,ID to leave some where it is.',
396
+ );
397
+ return lines.join("\n");
398
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.32.0",
3
+ "version": "0.34.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",