@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 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] a project's tasks as files in this repository
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] the files and the website, in step both ways
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, and whether that misses the end date. Empty when
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 tide = tideForecast(project, tasksOf(data.tasks, project.id), parseIso(`${today}T12:00:00`));
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
- /** `harbor sync` on a linked plan. */
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 { folder, config, say } = opts;
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 opts.transport.harbors();
15537
- const { store, sync } = await openHarbor(opts.transport, account, config.harbor.id, say);
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 { folder, say } = opts;
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 opts.transport.harbors();
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(opts.transport, answer.account, harbor.id, say);
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: data.tasks.filter((t) => t.projectId === project.id).length,
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 ? `sent ${plural(report.sent.length, "change")} to Harbor` : "",
15860
- report.added.length ? `added ${plural(report.added.length, "task")}` : "",
15861
- report.took.length ? `brought in ${plural(report.took.length, "change")} from Harbor` : ""
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
- 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 ${plural(made.tasks, "task")}. Commit it like any code.`);
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] keep a project's tasks as files in
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] send the files' edits to Harbor
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: doing)
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, done.",
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, the default, takes it off)",
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 alone: how many tasks they finished each",
492
- " week over the last six (or the project's life, if shorter), replayed a",
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
- " It warns when under two weeks of pace is all there is, and when an open",
496
- " task is due after the likely finish, since the pace can't see due dates.",
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 by title is left alone.",
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 progress first, with their notes",
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 progress",
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) out(jsonText({ ...(report ?? { local: true }), notes: lines }));
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("list_harbors", {}, (r) => whoami(r, creds));
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
- return call("get_project", { harbor, project }, (r) => notesText(r.name, r.notesForLater));
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 progress"}, with ${r.commentedAs}'s note.`,
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: "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}, after the likely finish, so the plan runs longer than the pace says.`,
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 === "doing" ? "in progress" : t.status, t.priority];
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.41.0",
3
+ "version": "0.43.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",