@harborppm/cli 0.49.0 → 0.51.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
@@ -155,9 +155,11 @@ harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
155
155
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
156
156
  [--due DATE]
157
157
  harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
158
+ [--turned-down T] [--look-again DATE] [--supersedes "OLD"]
158
159
  harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
159
160
  harbor raid "TITLE" [--status S] [--owner me|none|NAME] [--impact 1-5] [--probability 1-5]
160
- [--due DATE|none] [--update TEXT] [--mitigation T] [--resolution T] …
161
+ [--due DATE|none] [--update TEXT] [--mitigation T] [--resolution T]
162
+ [--turned-down T] [--look-again DATE|none] …
161
163
  one risk, issue or decision in full, or change it
162
164
  harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
163
165
  the review: programs worst first, with next moves
package/dist/app.mjs CHANGED
@@ -2069,7 +2069,8 @@ function normalizeRaidItem(r) {
2069
2069
  updates: Array.isArray(r.updates) ? r.updates : [],
2070
2070
  scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0,
2071
2071
  ...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {},
2072
- ...kind === "decision" ? decisionFields(r) : {}
2072
+ ...kind === "decision" ? decisionFields(r) : {},
2073
+ ...ids$1(r.followerIds).length ? { followerIds: ids$1(r.followerIds) } : {}
2073
2074
  };
2074
2075
  }
2075
2076
  /** A revisit condition that still says something, or null. */
@@ -14719,6 +14720,24 @@ var init_assignment = __esmMin((() => {}));
14719
14720
  //#region src/lib/slices/tasks.ts
14720
14721
  function tasksSlice({ set, get, write }) {
14721
14722
  return {
14723
+ followWork: (personId, taskIds, raidIds = []) => {
14724
+ const tasks = new Set(taskIds);
14725
+ const raid = new Set(raidIds);
14726
+ const add = (ids) => ids?.includes(personId) ? ids : [...ids ?? [], personId];
14727
+ write({
14728
+ tasks: get().tasks.map((t) => tasks.has(t.id) && t.assigneeId !== personId ? {
14729
+ ...t,
14730
+ followerIds: add(t.followerIds)
14731
+ } : t),
14732
+ raidItems: get().raidItems.map((r) => raid.has(r.id) && r.ownerId !== personId ? {
14733
+ ...r,
14734
+ followerIds: add(r.followerIds),
14735
+ updatedAt: stamp()
14736
+ } : r)
14737
+ });
14738
+ const who = get().people.find((p) => p.id === personId);
14739
+ get().log(`added ${who?.name ?? "someone"} as a second pair of hands on ${taskIds.length + raidIds.length} item${taskIds.length + raidIds.length === 1 ? "" : "s"}`, "/people");
14740
+ },
14722
14741
  addTask: (t, opts) => {
14723
14742
  if (!opts?.allowOverCap) {
14724
14743
  if (columnWip(get(), t.projectId, t.status, 1)) return "";
@@ -25377,9 +25396,30 @@ function dockCompare(data, projectId, changes, now) {
25377
25396
  pace: plan.pace,
25378
25397
  shift: a && b ? dayNumber(b) - dayNumber(a) : null,
25379
25398
  load,
25380
- stale: plan.stale
25399
+ stale: plan.stale,
25400
+ notes: dockNotes(plan.project, realTasks, dockTide, now)
25381
25401
  };
25382
25402
  }
25403
+ /**
25404
+ * What to know before trusting the shift: the pace is read from weeks of
25405
+ * finished work, so a short or lumpy record (work marked done in one go) or
25406
+ * a due date that holds the finish can leave the dates where they were.
25407
+ */
25408
+ function dockNotes(project, realTasks, docked, now) {
25409
+ if (docked.kind !== "forecast") return [];
25410
+ const notes = [];
25411
+ const weekly = weeklyFinished(realTasks, project.startDate, now);
25412
+ const finished = weekly.reduce((a, b) => a + b, 0);
25413
+ const busiest = Math.max(0, ...weekly);
25414
+ if (weekly.length < 2) notes.push("The pace comes from under two weeks of finished work, so treat the dates as rough.");
25415
+ else if (docked.rough) notes.push(`Only ${finished} task${finished === 1 ? "" : "s"} finished in the last ${weekly.length} weeks, so treat the dates as rough.`);
25416
+ if (weekly.length >= 2 && finished >= 4 && busiest * 2 > finished) notes.push(`${busiest} of the ${finished} tasks finished lately were marked done in one week, so the pace jumps from week to week and a few tasks more or less may not move the dates.`);
25417
+ if (docked.heldBy?.dueDate) {
25418
+ const name = taskKey(docked.heldBy, project) ?? docked.heldBy.title;
25419
+ notes.push(`${name} is due ${shortDate(docked.heldBy.dueDate)}, after the pace would finish, so its due date sets the finish and work done before then doesn't move it.`);
25420
+ }
25421
+ return notes;
25422
+ }
25383
25423
  /** "11 days sooner", "3 days later", "Same day", or null without both forecasts. */
25384
25424
  function shiftText(shift) {
25385
25425
  if (shift == null) return null;
@@ -25573,6 +25613,10 @@ function tryLines(data, project, changes, cmp, today) {
25573
25613
  const end = docked.project.endDate;
25574
25614
  if (end >= today && real.tide.kind === "forecast" && docked.tide.kind === "forecast") out.push(row(`Making ${shortDate(end)}`, end === real.project.endDate ? chanceText(real.tide.chanceByEnd) : "-", chanceText(docked.tide.chanceByEnd)));
25575
25615
  if (cmp.pace !== 1) out.push(row("Crew pace", "100%", `${Math.round(cmp.pace * 100)}%`));
25616
+ if (changes.length && cmp.notes.length) {
25617
+ out.push("");
25618
+ for (const n of cmp.notes) out.push(` Note: ${n}`);
25619
+ }
25576
25620
  if (cmp.load.length) {
25577
25621
  out.push("", " Due in the next six weeks:");
25578
25622
  for (const l of cmp.load) {
@@ -25621,6 +25665,14 @@ async function tryHere(opts) {
25621
25665
  lines
25622
25666
  };
25623
25667
  if ((opts.apply || opts.save) && changes.length === 0) throw new HarborError("There are no changes to apply or save. Add some, like --drop RL-4.");
25668
+ if (opts.apply && !opts.confirm) {
25669
+ const gone = changes.flatMap((c) => {
25670
+ if (c.kind !== "drop") return [];
25671
+ const t = data.tasks.find((x) => x.id === c.taskId);
25672
+ return t ? [`${taskKey(t, project) ?? t.id} ${t.title}`] : [];
25673
+ });
25674
+ if (gone.length) throw new HarborError(`--apply removes ${gone.length === 1 ? "this task" : `these ${gone.length} tasks`} from the harbor:\n${gone.map((g) => ` ${g}`).join("\n")}\nTo remove ${gone.length === 1 ? "it" : "them"}, run it again with --confirm. Nothing was changed.`);
25675
+ }
25624
25676
  if (opts.apply) {
25625
25677
  const s = store.getState();
25626
25678
  result.applied = applyDock({
package/lib/cli.mjs CHANGED
@@ -85,7 +85,7 @@ export const HELP = `harbor: Harbor from the terminal
85
85
  harbor try PROJECT [--drop KEYS] [--add "TITLE" [--count N]]
86
86
  [--due KEY=DATE] [--give KEY=NAME] [--end DATE]
87
87
  [--borrow NAME[=DAYS]] [--release NAME]
88
- [--saved] [--apply|--save|--discard]
88
+ [--saved] [--apply [--confirm]|--save|--discard]
89
89
  dry dock: the forecast with plan
90
90
  changes tried on a copy; nothing
91
91
  changes without --apply
@@ -142,6 +142,8 @@ export const HELP = `harbor: Harbor from the terminal
142
142
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
143
143
  [--due DATE]
144
144
  harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
145
+ [--turned-down T] [--look-again DATE]
146
+ [--supersedes "OLD DECISION"]
145
147
  harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
146
148
  the register, worst first
147
149
  (--closed adds closed ones)
@@ -149,7 +151,7 @@ export const HELP = `harbor: Harbor from the terminal
149
151
  [--probability 1-5] [--due DATE|none] [--update TEXT]
150
152
  [--title T] [--description T] [--mitigation T]
151
153
  [--contingency T] [--resolution T] [--rationale T]
152
- [--notes T]
154
+ [--notes T] [--turned-down T] [--look-again DATE|none]
153
155
  one risk, issue or decision in
154
156
  full, or change it
155
157
  harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
@@ -566,6 +568,8 @@ const DETAIL = {
566
568
  " harbor try PROJECT [changes] reads the forecast with the changes",
567
569
  " tried on a copy of the plan",
568
570
  " harbor try PROJECT [changes] --apply writes: makes the changes for real",
571
+ " (--confirm too when it drops tasks:",
572
+ " they're removed from the harbor)",
569
573
  " harbor try PROJECT [changes] --save writes: keeps them on the project to",
570
574
  " show someone (Forecast tab, Open dry",
571
575
  " dock)",
@@ -574,7 +578,8 @@ const DETAIL = {
574
578
  " harbor try PROJECT --discard writes: throws the saved ones away",
575
579
  "",
576
580
  " Changes, each taking several with commas between:",
577
- " --drop RL-4,RL-5 take tasks out of the plan (--apply removes them)",
581
+ " --drop RL-4,RL-5 take tasks out of the plan (--apply --confirm",
582
+ " removes them)",
578
583
  ' --add "TITLE" --count N new work, N tasks called TITLE',
579
584
  " --due RL-4=fri move a due date (none takes it off)",
580
585
  " --give RL-4=Sam give a task to someone (none: nobody)",
@@ -588,6 +593,13 @@ const DETAIL = {
588
593
  " date, the crew's pace, and the load of everyone the changes touch. A",
589
594
  " borrowed person adds an average crew member's pace for each day a week",
590
595
  " they give; a released one takes away their share of what was finished.",
596
+ " Notes under it say when the dates may not move: a short or lumpy record",
597
+ " of finished work, or a due date that sets the finish.",
598
+ "",
599
+ " --json gives each plan's endDate, open, likely, veryLikely, chanceByEnd",
600
+ " and tasksPerWeek (noForecast says why when there are no dates), the",
601
+ " shiftDays between them, crewPacePercent (100 is the crew as it is) and",
602
+ " the notes.",
591
603
  ],
592
604
  report: [
593
605
  " harbor report PROGRAM reads the program's status report",
@@ -733,7 +745,8 @@ const DETAIL = {
733
745
  " Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
734
746
  " --due DATE|none (review by, or decide by), --title, --description,",
735
747
  " --mitigation and --contingency (risks), --resolution (issues and",
736
- " decisions), --rationale (decisions) and --notes. TITLE is its title or",
748
+ " decisions), --rationale, --turned-down and --look-again DATE|none",
749
+ " (decisions) and --notes. TITLE is its title or",
737
750
  " enough of it to pick one out; harbor risks lists them. Changes several",
738
751
  " at once, as one change.",
739
752
  ],
@@ -784,6 +797,12 @@ const DETAIL = {
784
797
  ' harbor decision "TITLE" --project P writes: logs a decision to make',
785
798
  ' harbor decision "TITLE" --program P writes: logs one on the program',
786
799
  " [--owner NAME] [--due DATE] (the date to decide by)",
800
+ " [--turned-down T] (what was turned down, and why not)",
801
+ " [--look-again DATE] (when it comes back to the top of the brief)",
802
+ ' harbor decision "TITLE" --supersedes "OLD"',
803
+ " writes: a decision that replaces an",
804
+ " earlier one, which reads as",
805
+ " superseded",
787
806
  ],
788
807
  };
789
808
 
@@ -840,6 +859,9 @@ function distance(a, b) {
840
859
  /** A mistyped command, with the one it was probably meant to be. */
841
860
  export function noCommand(name) {
842
861
  if (name.startsWith("-")) return `${name} goes after a command, like harbor mine ${name}.`;
862
+ // A plural kind reads the register, not the command that writes one.
863
+ if (["decisions", "issues"].includes(name))
864
+ return `No command "${name}". harbor risks --kind ${name.slice(0, -1)} lists them.`;
843
865
  const near = COMMANDS.filter((c) => distance(name, c) <= (c.length > 4 ? 2 : 1));
844
866
  const guess = near.length === 1 ? ` Did you mean harbor ${near[0]}?` : "";
845
867
  return `No command "${name}".${guess} Run harbor help.`;
@@ -1169,7 +1191,7 @@ export async function run(argv, io = {}) {
1169
1191
 
1170
1192
  if (command === "try") {
1171
1193
  const { values, positionals } = parseArgs(rest, {
1172
- flags: ["apply", "save", "discard", "saved", "json"],
1194
+ flags: ["apply", "confirm", "save", "discard", "saved", "json"],
1173
1195
  options: ["harbor", "drop", "add", "count", "due", "give", "end", "borrow", "release"],
1174
1196
  });
1175
1197
  if (positionals.length !== 1)
@@ -1180,6 +1202,8 @@ export async function run(argv, io = {}) {
1180
1202
  );
1181
1203
  if (["apply", "save", "discard"].filter((k) => values[k]).length > 1)
1182
1204
  throw new HarborError("Use one of --apply, --save or --discard.");
1205
+ if (values.confirm && !values.apply)
1206
+ throw new HarborError("--confirm goes with --apply, to remove the tasks it drops.");
1183
1207
  const creds = await readCredentials(env);
1184
1208
  if (!hasFullAccess(creds))
1185
1209
  throw new HarborError(
@@ -1198,6 +1222,7 @@ export async function run(argv, io = {}) {
1198
1222
  project: positionals[0],
1199
1223
  flags: values,
1200
1224
  apply: Boolean(values.apply),
1225
+ confirm: Boolean(values.confirm),
1201
1226
  save: Boolean(values.save),
1202
1227
  discard: Boolean(values.discard),
1203
1228
  say,
@@ -1216,14 +1241,19 @@ export async function run(argv, io = {}) {
1216
1241
  return 0;
1217
1242
  }
1218
1243
  if (values.json) {
1219
- const side = (s) => ({
1220
- endDate: s.project.endDate,
1221
- open: s.open,
1222
- likely: s.tide.kind === "forecast" ? s.tide.likely : null,
1223
- veryLikely: s.tide.kind === "forecast" ? s.tide.veryLikely : null,
1224
- chanceByEnd: s.tide.kind === "forecast" ? s.tide.chanceByEnd : null,
1225
- forecast: s.tide.kind,
1226
- });
1244
+ const side = (s) => {
1245
+ const f = s.tide.kind === "forecast" ? s.tide : null;
1246
+ return {
1247
+ endDate: s.project.endDate,
1248
+ open: s.open,
1249
+ likely: f?.likely ?? null,
1250
+ veryLikely: f?.veryLikely ?? null,
1251
+ chanceByEnd: f?.chanceByEnd ?? null,
1252
+ tasksPerWeek: f?.perWeek ?? null,
1253
+ // Why there are no dates, when there aren't: no pace yet, paused, done.
1254
+ noForecast: f ? null : s.tide.kind,
1255
+ };
1256
+ };
1227
1257
  const c = result.comparison;
1228
1258
  out(
1229
1259
  jsonText({
@@ -1232,10 +1262,10 @@ export async function run(argv, io = {}) {
1232
1262
  real: side(c.real),
1233
1263
  dryDock: side(c.docked),
1234
1264
  shiftDays: c.shift,
1235
- pace: c.pace,
1265
+ crewPacePercent: Math.round(c.pace * 100),
1236
1266
  ...(result.applied !== undefined ? { applied: result.applied } : {}),
1237
1267
  ...(result.saved ? { saved: true } : {}),
1238
- notes,
1268
+ notes: [...c.notes, ...notes],
1239
1269
  }),
1240
1270
  );
1241
1271
  return 0;
@@ -1790,7 +1820,9 @@ export async function run(argv, io = {}) {
1790
1820
  );
1791
1821
  }
1792
1822
  if (values.confirm || values["over-limit"]) {
1793
- throw new HarborError("--confirm and --over-limit are for moving a task: harbor submit TASK.");
1823
+ throw new HarborError(
1824
+ "--confirm and --over-limit are for moving a task: harbor submit TASK.",
1825
+ );
1794
1826
  }
1795
1827
  if (values.mine && values.owner) throw new HarborError("Say --mine or --owner, not both.");
1796
1828
  return call(
@@ -1943,7 +1975,7 @@ export async function run(argv, io = {}) {
1943
1975
  includeClosed: Boolean(values.closed),
1944
1976
  limit: 50,
1945
1977
  },
1946
- (r) => raidList(r, positionals.join(" ")),
1978
+ (r) => raidList(r, positionals.join(" "), kind),
1947
1979
  );
1948
1980
  }
1949
1981
  case "report": {
@@ -2272,6 +2304,8 @@ export async function run(argv, io = {}) {
2272
2304
  "update",
2273
2305
  "decided-by",
2274
2306
  "decided-on",
2307
+ "turned-down",
2308
+ "look-again",
2275
2309
  ...fields,
2276
2310
  ],
2277
2311
  ),
@@ -2289,6 +2323,9 @@ export async function run(argv, io = {}) {
2289
2323
  decidedBy: values["decided-by"],
2290
2324
  decidedOn:
2291
2325
  values["decided-on"] === undefined ? undefined : nullable(dueDate(values["decided-on"])),
2326
+ turnedDown: values["turned-down"],
2327
+ revisitOn:
2328
+ values["look-again"] === undefined ? undefined : nullable(dueDate(values["look-again"])),
2292
2329
  update: values.update,
2293
2330
  ...Object.fromEntries(fields.map((f) => [f, values[f]])),
2294
2331
  };
@@ -2350,11 +2387,15 @@ export async function run(argv, io = {}) {
2350
2387
  spec(
2351
2388
  [],
2352
2389
  ["project", "program", "owner", "due"].concat(
2353
- command === "risk" ? ["impact", "probability"] : command === "issue" ? ["impact"] : [],
2390
+ command === "risk"
2391
+ ? ["impact", "probability"]
2392
+ : command === "issue"
2393
+ ? ["impact"]
2394
+ : ["supersedes", "turned-down", "look-again"],
2354
2395
  ),
2355
2396
  ),
2356
2397
  );
2357
- if (!values.project && !values.program) {
2398
+ if (!values.project && !values.program && !values.supersedes) {
2358
2399
  throw new HarborError("Say where it belongs: --project NAME or --program NAME.");
2359
2400
  }
2360
2401
  return call(
@@ -2369,8 +2410,14 @@ export async function run(argv, io = {}) {
2369
2410
  impact: level(values.impact, "impact"),
2370
2411
  probability: level(values.probability, "probability"),
2371
2412
  due: dueDate(values.due),
2413
+ supersedes: values.supersedes,
2414
+ turnedDown: values["turned-down"],
2415
+ revisitOn: values["look-again"] === undefined ? undefined : dueDate(values["look-again"]),
2372
2416
  },
2373
- (r) => `Logged the ${command} “${r.raised.title}”.`,
2417
+ (r) =>
2418
+ r.replaces
2419
+ ? `Logged the decision “${r.raised.title}”; it replaces “${r.replaces.title}”.`
2420
+ : `Logged the ${command} “${r.raised.title}”.`,
2374
2421
  );
2375
2422
  }
2376
2423
  default:
package/lib/format.mjs CHANGED
@@ -246,12 +246,19 @@ export function todayLocal(d = new Date()) {
246
246
  }
247
247
 
248
248
  /** The register, worst first: kind, title, then status, scores, owner and where it sits. */
249
- export function raidList(r, words = "") {
249
+ export function raidList(r, words = "", kinds = []) {
250
250
  const want = words.trim().toLowerCase();
251
251
  const items = (r.items ?? []).filter(
252
252
  (x) => !want || `${x.title} ${x.description ?? ""}`.toLowerCase().includes(want),
253
253
  );
254
- if (!items.length) return want ? "Nothing in the register matches." : "The register is clear.";
254
+ if (!items.length) {
255
+ if (want) return "Nothing in the register matches.";
256
+ const only =
257
+ kinds?.length === 1
258
+ ? { risk: "risks", issue: "issues", decision: "decisions" }[kinds[0]]
259
+ : null;
260
+ return only ? `No ${only} in the register.` : "The register is clear.";
261
+ }
255
262
  const lines = items.map((x) => {
256
263
  const bits = [
257
264
  x.status?.toLowerCase(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.49.0",
3
+ "version": "0.51.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",