@harborppm/cli 0.34.0 → 0.36.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
@@ -128,16 +128,21 @@ harbor brief [--portfolio P] [--say] the weekly brief, ready to send
128
128
  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
+ harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
131
132
  harbor limit PROJECT [COLUMN N|none]
132
133
  harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT] [--keep ID,ID] [--confirm]
133
134
  harbor handback PERSON
135
+ harbor agent [--agent NAME]
136
+ harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
134
137
  ```
135
138
 
136
139
  `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
140
 
141
+ Agents on the crew: put an AI app on People with Add an agent and assign it tasks like anyone else. An agent working through the CLI (Claude Code, say) runs `harbor agent` for its tasks, in progress first, with their notes, checklists and latest comments. When it's done, `harbor agent report RR-4 "Drafted 12 questions; two need Dana"` adds its note as the agent's comment and moves the task to In review for a person to check. An agent never marks its own work Done.
142
+
138
143
  `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.
139
144
 
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.
145
+ `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. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief.
141
146
 
142
147
  `harbor brief --say` turns the brief into the BBC's Shipping Forecast: each program an area, wind for late work, visibility for health, gale warnings for anything off track. It prints the forecast and speaks it with your computer's own voice (`say` on a Mac, Windows' speech, or `espeak-ng` on Linux).
143
148
 
package/dist/app.mjs CHANGED
@@ -3524,6 +3524,123 @@ function programLateCount(programId, data, now) {
3524
3524
  return late;
3525
3525
  }
3526
3526
 
3527
+ //#endregion
3528
+ //#region src/lib/bottle-rules.ts
3529
+ /**
3530
+ * What a note for later needs before it's sealed (the rest is in bottle.ts).
3531
+ * Apart so the store's action carries only this into every page's first load.
3532
+ */
3533
+ /** Longest note kept. */
3534
+ const BOTTLE_MAX = 1e3;
3535
+ /**
3536
+ * Why a new note can't be sealed, or null when it can: nothing written, a
3537
+ * date that isn't after today (it would open at once), or a milestone that
3538
+ * isn't this project's program's or is already reached.
3539
+ */
3540
+ function bottleProblem(body, opens, ctx) {
3541
+ if (!body.trim()) return "Write the note first.";
3542
+ if (opens.on === "date") {
3543
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(opens.date)) return "Pick the day it opens.";
3544
+ if (opens.date <= todayISO(ctx.now)) return "Pick a day after today.";
3545
+ }
3546
+ if (opens.on === "milestone") {
3547
+ const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
3548
+ if (!m || m.programId !== ctx.programId) return "Pick one of this program's milestones.";
3549
+ if (m.done) return `${m.name} is already reached.`;
3550
+ }
3551
+ return null;
3552
+ }
3553
+
3554
+ //#endregion
3555
+ //#region src/lib/bottle.ts
3556
+ /**
3557
+ * Message in a bottle: a note someone leaves on a project now that opens
3558
+ * later, on a date, when one of the program's milestones is reached, or if
3559
+ * the project turns Off track. Until then the crew sees only that a sealed
3560
+ * note is there, who left it and what opens it; the writer can read their
3561
+ * own. Once open it sits at the top of the project and in the brief.
3562
+ *
3563
+ * Notes are `reflections` of kind "bottle". Whether one is open is worked
3564
+ * out from the project as it stands (nothing stamps it), so every screen,
3565
+ * the CLI and the agents agree. Like the pre-mortem's wait, the seal is a
3566
+ * courtesy the screens keep, not a secret from the server.
3567
+ */
3568
+ /** An opened note stays at the top of its project this many days, then folds away. */
3569
+ const BOTTLE_FRESH_DAYS = 14;
3570
+ const isBottle = (r) => r.kind === "bottle";
3571
+ /**
3572
+ * Whether a note is open, and since when. A date opens it that morning; a
3573
+ * milestone when it's marked done (dated by its due date, or today if that's
3574
+ * still ahead); Off track the first time the project went Off track after
3575
+ * the note was left. A milestone since deleted opens it, so nothing is
3576
+ * sealed for good.
3577
+ */
3578
+ function bottleState(note, ctx) {
3579
+ const today = todayISO(ctx.now);
3580
+ const opens = note.opens;
3581
+ if (!opens) return {
3582
+ open: true,
3583
+ openedOn: note.createdAt.slice(0, 10)
3584
+ };
3585
+ if (opens.on === "date") return today >= opens.date ? {
3586
+ open: true,
3587
+ openedOn: opens.date
3588
+ } : sealed;
3589
+ if (opens.on === "milestone") {
3590
+ const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
3591
+ if (!m) return {
3592
+ open: true,
3593
+ openedOn: today
3594
+ };
3595
+ if (!m.done) return sealed;
3596
+ return {
3597
+ open: true,
3598
+ openedOn: m.dueDate && m.dueDate < today ? m.dueDate : today
3599
+ };
3600
+ }
3601
+ const flip = (ctx.project.healthHistory ?? []).find((h) => h.health === "off-track" && h.at >= note.createdAt);
3602
+ if (flip) return {
3603
+ open: true,
3604
+ openedOn: flip.at.slice(0, 10)
3605
+ };
3606
+ if (ctx.project.health === "off-track") return {
3607
+ open: true,
3608
+ openedOn: today
3609
+ };
3610
+ return sealed;
3611
+ }
3612
+ const sealed = {
3613
+ open: false,
3614
+ openedOn: null
3615
+ };
3616
+ /** Opened in the last `days` days, so it still belongs at the top and in the brief. */
3617
+ function isFresh(state, now, days = 14) {
3618
+ if (!state.open || !state.openedOn) return false;
3619
+ return differenceInCalendarDays(now, parseIso(state.openedOn)) < days;
3620
+ }
3621
+ /** Notes opened in the last fortnight across these projects, newest first: the brief's. */
3622
+ function freshlyOpened(data, projects, now) {
3623
+ const notes = (data.reflections ?? []).filter(isBottle);
3624
+ if (notes.length === 0) return [];
3625
+ const byProject = new Map(projects.map((p) => [p.id, p]));
3626
+ const out = [];
3627
+ for (const note of notes) {
3628
+ const project = byProject.get(note.projectId);
3629
+ if (!project) continue;
3630
+ const state = bottleState(note, {
3631
+ project,
3632
+ milestones: data.milestones,
3633
+ now
3634
+ });
3635
+ if (isFresh(state, now)) out.push({
3636
+ note,
3637
+ project,
3638
+ state
3639
+ });
3640
+ }
3641
+ return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
3642
+ }
3643
+
3527
3644
  //#endregion
3528
3645
  //#region src/lib/brief.ts
3529
3646
  const BRIEF_QUIET = "Nothing late or at risk. Say what shipped — or enjoy the quiet week.";
@@ -3618,6 +3735,14 @@ function composeBriefBody(opts) {
3618
3735
  const lateMilestones = milestones.filter((m) => m.daysLate > 0);
3619
3736
  const quiet = opts.offTrack.length === 0 && opts.atRisk.length === 0 && opts.late.length === 0 && watch.length === 0 && lateMilestones.length === 0;
3620
3737
  const lines = [`Harbor brief — ${opts.workspace} — ${date}`, ""];
3738
+ if (opts.opened?.length) {
3739
+ lines.push("Notes that opened");
3740
+ for (const n of opts.opened) {
3741
+ const body = n.body.replace(/\s+/g, " ").trim();
3742
+ lines.push(`• ${n.project}${n.who ? `, from ${n.who}` : ""}: “${body}”`);
3743
+ }
3744
+ lines.push("");
3745
+ }
3621
3746
  const done = () => {
3622
3747
  if (!opts.shipped?.length) return;
3623
3748
  lines.push("Done this week");
@@ -3861,6 +3986,7 @@ function briefOf(data, opts = {}) {
3861
3986
  finished,
3862
3987
  said: saidThisWeek(data, now, portfolioId ? new Set(tasks.flatMap((t) => t.assigneeId ? [t.assigneeId] : [])) : null),
3863
3988
  late: lateLinesOf(data, tasks, now),
3989
+ opened: freshlyOpened(data, projects, now),
3864
3990
  talk,
3865
3991
  milestonesSoon,
3866
3992
  milestonesLate,
@@ -3906,6 +4032,11 @@ function composeBriefBodyFrom(brief, data, workspace) {
3906
4032
  program: data.programs.find((p) => p.id === m.programId)?.name ?? null,
3907
4033
  dueDate: m.dueDate,
3908
4034
  daysLate: -daysUntil(m.dueDate, brief.asOf)
4035
+ })),
4036
+ opened: brief.opened.map((b) => ({
4037
+ project: b.project.name,
4038
+ who: data.people.find((p) => p.id === b.note.authorId)?.name ?? null,
4039
+ body: b.note.body
3909
4040
  }))
3910
4041
  });
3911
4042
  }
@@ -7757,6 +7888,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7757
7888
  const projects = useHarbor((s) => s.projects);
7758
7889
  const people = useHarbor((s) => s.people);
7759
7890
  const boardConfigs = useHarbor((s) => s.boardConfigs);
7891
+ const reflections = useHarbor((s) => s.reflections);
7892
+ const milestones = useHarbor((s) => s.milestones);
7760
7893
  const project = projects.find((p) => p.id === projectId);
7761
7894
  const data = useMemo(() => ({
7762
7895
  tasks,
@@ -7893,7 +8026,12 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7893
8026
  const tide = tideLine(data, project, day);
7894
8027
  const filtered = [filter.who === "none" ? "nobody's" : filter.who !== "any" ? `${people.find((p) => p.id === filter.who)?.name ?? "someone"}'s` : null, filter.priority !== "any" ? PRIORITY_LABEL[filter.priority].toLowerCase() : null].filter(Boolean);
7895
8028
  const lead = people.find((p) => p.id === project.ownerId);
7896
- const body = height - 3;
8029
+ const opened = freshlyOpened({
8030
+ reflections,
8031
+ milestones
8032
+ }, [project], /* @__PURE__ */ new Date(`${day}T12:00:00`))[0];
8033
+ const openedBy = opened ? people.find((p) => p.id === opened.note.authorId)?.name : null;
8034
+ const body = height - 3 - (opened ? 1 : 0);
7897
8035
  const fit = Math.max(1, Math.floor((width - 2) / 14));
7898
8036
  const firstColumn = Math.min(Math.max(0, col - Math.floor(fit / 2)), Math.max(0, columns.length - fit));
7899
8037
  const shownColumns = columns.slice(firstColumn, firstColumn + fit);
@@ -7968,6 +8106,21 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7968
8106
  ]
7969
8107
  })]
7970
8108
  }),
8109
+ opened && /* @__PURE__ */ jsxs(Text, {
8110
+ wrap: "truncate-end",
8111
+ color: theme.warn,
8112
+ children: [
8113
+ "A note opened",
8114
+ openedBy ? `, from ${openedBy}` : "",
8115
+ ":",
8116
+ " ",
8117
+ /* @__PURE__ */ jsxs(Text, { children: [
8118
+ "“",
8119
+ opened.note.body.replace(/\s+/g, " "),
8120
+ "”"
8121
+ ] })
8122
+ ]
8123
+ }),
7971
8124
  mode === "budget" ? /* @__PURE__ */ jsx(BudgetPane, {
7972
8125
  project,
7973
8126
  spendEntries,
@@ -10645,7 +10798,7 @@ function hierarchySlice({ get, write }) {
10645
10798
  const name = rows.portfolios?.[0]?.name ?? rows.programs?.[0]?.name ?? rows.projects?.[0]?.name;
10646
10799
  if (name) get().log(`put back “${name}”`);
10647
10800
  },
10648
- importRows: (rows) => {
10801
+ importRows: (rows, from = "a spreadsheet") => {
10649
10802
  const state = get();
10650
10803
  const have = (current) => new Set(current.map((r) => r.id));
10651
10804
  const add = (current, more) => {
@@ -10656,10 +10809,11 @@ function hierarchySlice({ get, write }) {
10656
10809
  programs: add(state.programs, rows.programs),
10657
10810
  projects: add(state.projects, rows.projects),
10658
10811
  tasks: add(state.tasks, rows.tasks),
10659
- people: add(state.people, rows.people)
10812
+ people: add(state.people, rows.people),
10813
+ raidItems: add(state.raidItems, rows.raidItems)
10660
10814
  });
10661
10815
  const n = rows.tasks?.length ?? 0;
10662
- get().log(`imported ${n} task${n === 1 ? "" : "s"} from a spreadsheet`);
10816
+ get().log(`imported ${n} task${n === 1 ? "" : "s"} from ${from}`);
10663
10817
  },
10664
10818
  dropRows: (ids) => {
10665
10819
  const state = get();
@@ -10667,7 +10821,8 @@ function hierarchySlice({ get, write }) {
10667
10821
  programs: new Set(ids.programs ?? []),
10668
10822
  projects: new Set(ids.projects ?? []),
10669
10823
  tasks: new Set(ids.tasks ?? []),
10670
- people: new Set(ids.people ?? [])
10824
+ people: new Set(ids.people ?? []),
10825
+ raidItems: new Set(ids.raidItems ?? [])
10671
10826
  };
10672
10827
  const tasks = state.tasks.filter((r) => !gone.tasks.has(r.id));
10673
10828
  if (gone.people.size) {
@@ -10678,6 +10833,7 @@ function hierarchySlice({ get, write }) {
10678
10833
  projects: state.projects.filter((r) => !gone.projects.has(r.id)),
10679
10834
  tasks,
10680
10835
  people: gone.people.size ? state.people.filter((r) => !gone.people.has(r.id)) : state.people,
10836
+ raidItems: gone.raidItems.size ? state.raidItems.filter((r) => !gone.raidItems.has(r.id)) : state.raidItems,
10681
10837
  selectedTaskId: gone.tasks.has(state.selectedTaskId ?? "") ? null : state.selectedTaskId
10682
10838
  });
10683
10839
  },
@@ -10995,6 +11151,27 @@ function tasksSlice({ set, get, write }) {
10995
11151
  else get().log(`commented on “${task.title}”`, `/projects/${task.projectId}`);
10996
11152
  }
10997
11153
  },
11154
+ agentUpdate: (agentId, taskId, body, status, opts) => {
11155
+ const task = get().tasks.find((t) => t.id === taskId);
11156
+ const text = body.trim();
11157
+ if (!task || !text) return;
11158
+ if (task.status !== status) get().moveTask(taskId, status, { allowOverCap: opts?.allowOverCap });
11159
+ const at = stamp();
11160
+ write({
11161
+ comments: [...get().comments, {
11162
+ id: uid("c"),
11163
+ taskId,
11164
+ authorId: agentId,
11165
+ body: text,
11166
+ createdAt: at,
11167
+ mentionedIds: parseMentions(text, get().people).filter((id) => id !== agentId)
11168
+ }],
11169
+ people: get().people.map((p) => p.id === agentId ? {
11170
+ ...p,
11171
+ lastSeenAt: at
11172
+ } : p)
11173
+ });
11174
+ },
10998
11175
  updateComment: (id, body) => {
10999
11176
  const comment = get().comments.find((c) => c.id === id);
11000
11177
  if (!comment || comment.authorId !== get().currentUserId) return;
@@ -11085,6 +11262,13 @@ function tasksSlice({ set, get, write }) {
11085
11262
  for (const id of ids) have.add(id);
11086
11263
  set({ readPingIds: [...have] });
11087
11264
  },
11265
+ tickVoyage: (ids) => {
11266
+ const tick = new Set(ids);
11267
+ const ticked = get().tasks.filter((t) => tick.has(t.id) && t.status !== "done");
11268
+ if (ticked.length === 0) return;
11269
+ write({ tasks: get().tasks.map((t) => tick.has(t.id) && t.status !== "done" ? withStatus(t, "done") : t) });
11270
+ for (const t of ticked) get().log(`ticked off “${t.title}” on First voyage`, `/projects/${t.projectId}`);
11271
+ },
11088
11272
  toggleFocus: (taskId) => {
11089
11273
  const personId = get().currentUserId;
11090
11274
  const date = todayISO();
@@ -11322,13 +11506,36 @@ function milestonesRaidSlice({ set, get, write }) {
11322
11506
  return row.id;
11323
11507
  },
11324
11508
  editReflection: (id, body) => {
11325
- const text = body.trim().slice(0, 600);
11326
- if (!text) return;
11327
11509
  const me = get().currentUserId;
11328
- write({ reflections: (get().reflections ?? []).map((r) => r.id === id && r.authorId === me ? {
11329
- ...r,
11330
- body: text
11331
- } : r) });
11510
+ write({ reflections: (get().reflections ?? []).map((r) => {
11511
+ if (r.id !== id || r.authorId !== me) return r;
11512
+ const text = body.trim().slice(0, r.kind === "bottle" ? BOTTLE_MAX : 600);
11513
+ return text ? {
11514
+ ...r,
11515
+ body: text
11516
+ } : r;
11517
+ }) });
11518
+ },
11519
+ leaveBottle: (projectId, body, opens) => {
11520
+ const s = get();
11521
+ const project = s.projects.find((p) => p.id === projectId);
11522
+ if (!project) return null;
11523
+ if (bottleProblem(body, opens, {
11524
+ programId: project.programId,
11525
+ milestones: s.milestones,
11526
+ now: clockNow()
11527
+ })) return null;
11528
+ const row = {
11529
+ id: uid("rf"),
11530
+ projectId,
11531
+ kind: "bottle",
11532
+ authorId: s.currentUserId,
11533
+ body: body.trim().slice(0, BOTTLE_MAX),
11534
+ createdAt: stamp(),
11535
+ opens
11536
+ };
11537
+ write({ reflections: [...s.reflections ?? [], row] });
11538
+ return row.id;
11332
11539
  },
11333
11540
  deleteReflection: (id) => {
11334
11541
  const me = get().currentUserId;
@@ -11512,6 +11719,23 @@ function peopleSpendSlice({ set, get, write }) {
11512
11719
  get().log(`handed ${person.name}’s work back`, "/people");
11513
11720
  return applied.moved.length;
11514
11721
  },
11722
+ addAgent: (a) => {
11723
+ const id = uid("p");
11724
+ const row = {
11725
+ id,
11726
+ name: a.name.trim() || "Claude",
11727
+ role: "Agent",
11728
+ initials: "AI",
11729
+ swatch: a.swatch,
11730
+ location: "",
11731
+ kind: "agent",
11732
+ addedById: get().currentUserId,
11733
+ lastSeenAt: null
11734
+ };
11735
+ set({ people: [...get().people, row] });
11736
+ get().log(`put ${row.name} on the crew as an agent`, "/people");
11737
+ return id;
11738
+ },
11515
11739
  updatePerson: (id, patch) => {
11516
11740
  write({ people: get().people.map((p) => p.id === id ? {
11517
11741
  ...p,
package/lib/cli.mjs CHANGED
@@ -26,8 +26,10 @@ import {
26
26
  hillList,
27
27
  myWork,
28
28
  premortemText,
29
+ notesText,
29
30
  voyageText,
30
31
  handoverText,
32
+ agentWorkText,
31
33
  raidList,
32
34
  programTree,
33
35
  projectList,
@@ -117,10 +119,18 @@ export const HELP = `harbor: Harbor from the terminal
117
119
  answer or read it
118
120
  harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
119
121
  your line in it
122
+ harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
123
+ a note for later: sealed until
124
+ its day, milestone, or Off track
120
125
  harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT]
121
126
  [--keep ID,ID] [--confirm]
122
127
  hand someone's work to the crew
123
128
  harbor handback PERSON give it back after time away
129
+ harbor agent [--agent NAME] the tasks given to an AI agent on
130
+ the crew, in the order to do them
131
+ harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
132
+ the agent's note; moves the task
133
+ to In review (--start: doing)
124
134
 
125
135
  Every command takes --json, and --harbor NAME when you're in more than one
126
136
  harbor (or set one with harbor use). harbor help COMMAND shows just that one.
@@ -289,6 +299,8 @@ const FLAG = {
289
299
  column: "the column",
290
300
  limit: "the limit",
291
301
  at: "--at",
302
+ opensOn: "--opens",
303
+ milestone: "--milestone",
292
304
  until: "--until",
293
305
  to: "--to",
294
306
  keep: "--keep",
@@ -335,6 +347,10 @@ export function hint(message, project) {
335
347
  (_, name) =>
336
348
  `${name} has no pre-mortem running. Start one with harbor premortem "${project ?? name}" --start.`,
337
349
  )
350
+ .replace(
351
+ /^Say what opens the note: exactly one of opensOn, milestone or ifOffTrack\.$/,
352
+ "Say what opens it: --opens DATE, --milestone NAME or --off-track (one of them).",
353
+ )
338
354
  .replace(
339
355
  /^Nothing to change: pass a field to update\.$/,
340
356
  "Nothing to change. Say what with --title, --assignee, --due, --priority, --status, --notes, --repeat or --waits-on.",
@@ -396,6 +412,19 @@ const DETAIL = {
396
412
  " PROJECT is a name, a key like RR, or an id. When two projects share a",
397
413
  " name, give the key.",
398
414
  ],
415
+ note: [
416
+ " harbor note PROJECT reads its notes for later: who left",
417
+ " each, what opens it, and the opened ones",
418
+ ' harbor note PROJECT "NOTE" --opens DATE',
419
+ " writes: seals a note that opens that day",
420
+ ' harbor note PROJECT "NOTE" --milestone NAME',
421
+ " writes: opens when that milestone is reached",
422
+ ' harbor note PROJECT "NOTE" --off-track',
423
+ " writes: opens if the project turns Off track",
424
+ "",
425
+ " Until it opens, the crew sees only that a note is there and who left it.",
426
+ " DATE: YYYY-MM-DD, tomorrow, or +N for N days from today.",
427
+ ],
399
428
  handover: [
400
429
  " harbor handover PERSON reads what would move, and to whom:",
401
430
  " their open tasks, sign-offs, risks",
@@ -411,6 +440,33 @@ const DETAIL = {
411
440
  "",
412
441
  ' PERSON is a name, an id, or "me".',
413
442
  ],
443
+ handback: [
444
+ " harbor handback PERSON writes, straight away: gives back",
445
+ " what was handed over for their time",
446
+ " away and is still with whoever took",
447
+ " it, and stops showing them as away",
448
+ "",
449
+ " Anything someone else has since taken, finished or closed stays where it",
450
+ " is. If PERSON isn't marked away, it says so and changes nothing.",
451
+ " harbor handover PERSON --until DATE is what marks someone away.",
452
+ "",
453
+ ' PERSON is a name, an id, or "me".',
454
+ ],
455
+ agent: [
456
+ " harbor agent reads the tasks people gave the crew's",
457
+ " agent (an AI crew member on People),",
458
+ " in progress first, with their notes",
459
+ ' harbor agent report TASK "TEXT" writes: the agent\'s note on the task,',
460
+ " and moves it to In review for a person",
461
+ " to check",
462
+ ' harbor agent report TASK "TEXT" --start',
463
+ " writes: the note, and moves it to",
464
+ " In progress",
465
+ "",
466
+ " --agent NAME picks the agent when the crew has more than one. An agent",
467
+ " never marks its own work Done; a person does, on the website or in the",
468
+ " terminal app.",
469
+ ],
414
470
  voyage: [
415
471
  " harbor voyage PROJECT reads a Done project's voyage log",
416
472
  ' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
@@ -1362,6 +1418,32 @@ export async function run(argv, io = {}) {
1362
1418
  limitsText(r.set.project, r.limits, r.tasks, r.hiddenColumns),
1363
1419
  );
1364
1420
  }
1421
+ case "note": {
1422
+ const { positionals, values, harbor } = parse(spec(["off-track"], ["opens", "milestone"]));
1423
+ const [project, ...words] = positionals;
1424
+ if (!project)
1425
+ throw new HarborError(
1426
+ 'Give a project, then the note: harbor note "NAME" "NOTE" --opens +30.',
1427
+ );
1428
+ const note = words.join(" ").trim();
1429
+ if (!note) {
1430
+ if (values.opens || values.milestone || values["off-track"])
1431
+ throw new HarborError("Write the note too, in quotes after the project.");
1432
+ return call("get_project", { harbor, project }, (r) => notesText(r.name, r.notesForLater));
1433
+ }
1434
+ return call(
1435
+ "leave_note",
1436
+ {
1437
+ harbor,
1438
+ project,
1439
+ note,
1440
+ ...(values.opens ? { opensOn: dueDate(values.opens) } : {}),
1441
+ ...(values.milestone ? { milestone: values.milestone } : {}),
1442
+ ...(values["off-track"] ? { ifOffTrack: true } : {}),
1443
+ },
1444
+ (r) => `Sealed your note on ${r.sealed.project}. It ${r.sealed.opens}.`,
1445
+ );
1446
+ }
1365
1447
  case "voyage": {
1366
1448
  const { positionals, harbor } = parse(spec());
1367
1449
  const [project, ...words] = positionals;
@@ -1412,6 +1494,25 @@ export async function run(argv, io = {}) {
1412
1494
  },
1413
1495
  );
1414
1496
  }
1497
+ case "agent": {
1498
+ const { positionals, values, harbor } = parse(spec(["start"], ["agent"]));
1499
+ if (positionals[0] !== "report") {
1500
+ none(positionals);
1501
+ return call("agent_work", { harbor, agent: values.agent }, agentWorkText);
1502
+ }
1503
+ const [, task, ...words] = positionals;
1504
+ const note = words.join(" ").trim();
1505
+ if (!task || !note)
1506
+ throw new HarborError(
1507
+ 'Give the task and what the agent did: harbor agent report RR-4 "Drafted it".',
1508
+ );
1509
+ return call(
1510
+ "agent_update",
1511
+ { harbor, task, note, status: values.start ? "doing" : "review", agent: values.agent },
1512
+ (r) =>
1513
+ `${r.updated.key ?? r.updated.title} is ${r.updated.status === "review" ? "in review for a person to check" : "in progress"}, with ${r.commentedAs}'s note.`,
1514
+ );
1515
+ }
1415
1516
  case "handback": {
1416
1517
  const { positionals, harbor } = parse(spec());
1417
1518
  const person = positionals.join(" ").trim();
package/lib/format.mjs CHANGED
@@ -370,6 +370,35 @@ export function hillList(r) {
370
370
  return blocks.join("\n\n");
371
371
  }
372
372
 
373
+ /** A project's notes for later, as get_project lists them. */
374
+ export function notesText(project, notes) {
375
+ if (!notes?.length)
376
+ return `No notes for later on ${project}. Leave one: harbor note "${project}" "NOTE" --opens +30`;
377
+ const open = notes.filter((n) => n.open);
378
+ const sealed = notes.filter((n) => !n.open);
379
+ const lines = [`Notes for later · ${project}`];
380
+ for (const n of open) {
381
+ const when = n.openedOn ? `Opened ${n.openedOn}` : "Opened";
382
+ lines.push("", ` ${when} · left by ${n.yours ? "you" : (n.from ?? "someone")} on ${n.left}`);
383
+ lines.push(
384
+ ...String(n.note ?? "")
385
+ .split("\n")
386
+ .map((l) => ` ${l}`),
387
+ );
388
+ }
389
+ if (sealed.length) lines.push("");
390
+ for (const n of sealed) {
391
+ lines.push(` Sealed · ${n.yours ? "yours" : `from ${n.from ?? "someone"}`} · ${n.opens}`);
392
+ if (n.yours && n.note)
393
+ lines.push(
394
+ ...String(n.note)
395
+ .split("\n")
396
+ .map((l) => ` ${l}`),
397
+ );
398
+ }
399
+ return lines.join("\n");
400
+ }
401
+
373
402
  /** handover_plan as a list grouped like the website's dialog, each line with who takes it. */
374
403
  export function handoverText(r, { confirmHint = true } = {}) {
375
404
  const items = r.items ?? [];
@@ -383,9 +412,19 @@ export function handoverText(r, { confirmHint = true } = {}) {
383
412
  group = i.group;
384
413
  lines.push("", `${group}:`);
385
414
  }
415
+ const due = i.due && i.kind !== "project" && i.kind !== "program" ? ` (${i.due})` : "";
416
+ lines.push(` ${i.what}${due}${i.to ? ` → ${i.to}` : ""}`);
417
+ }
418
+ if (r.nobodyToTake) {
386
419
  lines.push(
387
- ` ${i.what}${i.due && i.kind !== "project" && i.kind !== "program" ? ` (${i.due})` : ""} → ${i.to ?? "nobody yet"}`,
420
+ "",
421
+ `${r.nobodyToTake} Invite someone with harbor invite EMAIL, then run this again.`,
388
422
  );
423
+ if (confirmHint)
424
+ lines.push(
425
+ `Until then, --confirm moves nothing; with --until DATE it only marks ${r.person} as away.`,
426
+ );
427
+ return lines.join("\n");
389
428
  }
390
429
  const room = (r.takers ?? []).filter((t) => t.note).map((t) => `${t.who} ${t.note}`);
391
430
  if (room.length) lines.push("", `This week: ${room.join(", ")}.`);
@@ -396,3 +435,32 @@ export function handoverText(r, { confirmHint = true } = {}) {
396
435
  );
397
436
  return lines.join("\n");
398
437
  }
438
+
439
+ /** agent_work: the agent's tasks in the order to do them, then what waits on a person. */
440
+ export function agentWorkText(r) {
441
+ if (!r.agent) return r.note;
442
+ const lines = [`Work for ${r.agent}${r.addedBy ? ` (added by ${r.addedBy})` : ""}`];
443
+ if (!r.tasks?.length) lines.push("", "Nothing to do: no open tasks are assigned to it.");
444
+ for (const t of r.tasks ?? []) {
445
+ const bits = [t.status === "doing" ? "in progress" : t.status, t.priority];
446
+ if (t.due) bits.push(`due ${t.due}`);
447
+ lines.push(
448
+ "",
449
+ `${t.key ?? t.id} ${t.title} (${bits.join(", ")}${t.project ? `, ${t.project}` : ""})`,
450
+ );
451
+ if (t.notes) lines.push(...t.notes.split("\n").map((l) => ` ${l}`));
452
+ for (const c of t.checklist ?? []) lines.push(` [${c.done ? "x" : " "}] ${c.step}`);
453
+ for (const c of t.comments ?? []) lines.push(` ${c.author ?? "Someone"}: ${c.body}`);
454
+ }
455
+ if (r.total > (r.tasks ?? []).length) lines.push("", `…and ${r.total - r.tasks.length} more.`);
456
+ if (r.forReview?.length)
457
+ lines.push(
458
+ "",
459
+ `Waiting for a person to check: ${r.forReview.map((t) => t.key ?? t.title).join(", ")}.`,
460
+ );
461
+ lines.push(
462
+ "",
463
+ 'When a task is done, harbor agent report TASK "WHAT YOU DID" moves it to In review for a person to check.',
464
+ );
465
+ return lines.join("\n");
466
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.34.0",
3
+ "version": "0.36.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",