@harborppm/cli 0.55.0 → 0.57.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
@@ -148,11 +148,13 @@ harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
148
148
  [--ends DATE] [--budget N] [--from "DONE PROJECT"]
149
149
  --from: dead reckoning, its tasks due when
150
150
  they really finished in that project
151
- harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
151
+ harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE|pilot]
152
152
  [--priority low|medium|high|urgent] [--status S] [--comment TEXT]
153
153
  [--over-limit] no TITLE: one title a line from stdin
154
+ --due pilot takes the date the owner's queue suggests;
155
+ a date that won't hold says so
154
156
  harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
155
- harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
157
+ harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|pilot|none]
156
158
  [--priority P] [--status S] [--notes TEXT]
157
159
  [--repeat weekly|biweekly|monthly|never]
158
160
  [--waits-on TASK,TASK|none] [--confirm] [--over-limit]
package/dist/app.mjs CHANGED
@@ -2274,9 +2274,158 @@ var init_premortem = __esmMin((() => {
2274
2274
  }));
2275
2275
 
2276
2276
  //#endregion
2277
- //#region src/lib/voyage-log.ts
2277
+ //#region src/lib/charter-lines.ts
2278
+ /** Nothing written on any of the five lines. */
2279
+ function charterEmpty(charter) {
2280
+ return !charter || CHARTER_FIELDS.every((f) => !clean(charter[f]));
2281
+ }
2282
+ /**
2283
+ * The charter after a change to some of its lines: trimmed, and null once
2284
+ * every line is empty so a cleared charter reads as never written.
2285
+ */
2286
+ function withCharter(current, patch, by, at) {
2287
+ const next = {
2288
+ ...current ?? {},
2289
+ updatedAt: at,
2290
+ updatedById: by
2291
+ };
2292
+ for (const field of CHARTER_FIELDS) {
2293
+ if (!(field in patch)) continue;
2294
+ const text = (patch[field] ?? "").trim();
2295
+ if (text) next[field] = text;
2296
+ else delete next[field];
2297
+ }
2298
+ return charterEmpty(next) ? null : next;
2299
+ }
2300
+ var CHARTER_FIELDS, clean;
2301
+ var init_charter_lines = __esmMin((() => {
2302
+ CHARTER_FIELDS = [
2303
+ "why",
2304
+ "inScope",
2305
+ "outScope",
2306
+ "doneWhen",
2307
+ "callsBy"
2308
+ ];
2309
+ clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
2310
+ }));
2311
+
2312
+ //#endregion
2313
+ //#region src/lib/charter.ts
2278
2314
  init_indexes();
2279
2315
  init_date_fns();
2316
+ init_date_text();
2317
+ init_premortem();
2318
+ init_charter_lines();
2319
+ const CHARTER_LABEL = {
2320
+ why: "Why",
2321
+ inScope: "In",
2322
+ outScope: "Out",
2323
+ doneWhen: "Done when",
2324
+ callsBy: "Calls made by"
2325
+ };
2326
+ /** Each line's name for agents and the CLI's --json: plain words, no "Scope". */
2327
+ const CHARTER_KEY = {
2328
+ why: "why",
2329
+ inScope: "in",
2330
+ outScope: "out",
2331
+ doneWhen: "doneWhen",
2332
+ callsBy: "callsBy"
2333
+ };
2334
+ /** The lines written so far, in order. */
2335
+ function charterLines(charter) {
2336
+ if (!charter) return [];
2337
+ return CHARTER_FIELDS.map((field) => ({
2338
+ field,
2339
+ label: CHARTER_LABEL[field],
2340
+ text: clean(charter[field])
2341
+ })).filter((l) => l.text);
2342
+ }
2343
+ function charterFilled(charter) {
2344
+ return charterLines(charter).length;
2345
+ }
2346
+ /** A week after the start, an empty charter is worth a nudge. */
2347
+ const CHARTER_GRACE_DAYS = 7;
2348
+ /**
2349
+ * A project whose charter is still blank a week after it started, while
2350
+ * it's still running. Done and paused ones are left alone.
2351
+ */
2352
+ function charterOverdue(project, today) {
2353
+ if (project.health === "done" || project.health === "paused") return null;
2354
+ if (!charterEmpty(project.charter)) return null;
2355
+ const days = differenceInCalendarDays(parseIso(today), parseIso(project.startDate.slice(0, 10)));
2356
+ return days >= 7 ? days : null;
2357
+ }
2358
+ /** The items of an In or Out line: split on commas, semicolons, "and" lists and new lines. */
2359
+ function charterItems(text) {
2360
+ return (text ?? "").split(/[\n,;•]+/).map((s) => s.replace(/^\s*(?:and|or|-|\*)\s+/i, "").trim()).filter((s) => contentWords(s).size > 0);
2361
+ }
2362
+ /** True when a task's title names an item: every meaningful word of a short item, or two of a longer one. */
2363
+ function names(item, title) {
2364
+ let shared = 0;
2365
+ for (const w of item) if (title.has(w)) shared += 1;
2366
+ return item.size <= 2 ? shared === item.size : shared >= 2;
2367
+ }
2368
+ /**
2369
+ * Tasks whose titles name something the charter put out of scope, newest
2370
+ * first: the "I thought that was in" list. Tasks made before the charter
2371
+ * was last written are left out when `since` is given, so writing an Out
2372
+ * line about work already on the board doesn't flag it.
2373
+ */
2374
+ function outOfScope(charter, tasks, opts = {}) {
2375
+ const items = charterItems(charter?.outScope).map((item) => ({
2376
+ item,
2377
+ words: contentWords(item)
2378
+ }));
2379
+ if (items.length === 0) return [];
2380
+ const out = [];
2381
+ for (const task of tasks) {
2382
+ if (opts.since && task.createdAt < opts.since) continue;
2383
+ const title = contentWords(task.title);
2384
+ const hit = items.find((i) => names(i.words, title));
2385
+ if (hit) out.push({
2386
+ task,
2387
+ item: hit.item
2388
+ });
2389
+ }
2390
+ return out.sort((a, b) => b.task.createdAt.localeCompare(a.task.createdAt));
2391
+ }
2392
+ /** The voyage log's look at the charter: did the In list get done, did the Out list stay out. */
2393
+ function charterCheck(charter, tasks) {
2394
+ if (charterEmpty(charter)) return null;
2395
+ const finished = tasks.filter((t) => t.status === "done").map((t) => contentWords(t.title));
2396
+ const delivered = charterItems(charter.inScope).map((item) => {
2397
+ const words = contentWords(item);
2398
+ return {
2399
+ item,
2400
+ done: finished.filter((title) => names(words, title)).length
2401
+ };
2402
+ });
2403
+ return {
2404
+ doneWhen: clean(charter.doneWhen) || null,
2405
+ delivered,
2406
+ crept: outOfScope(charter, tasks),
2407
+ outItems: charterItems(charter.outScope).length
2408
+ };
2409
+ }
2410
+ /** One sentence for the voyage log's story; null when there's no charter. */
2411
+ function charterSentence(check) {
2412
+ if (!check) return null;
2413
+ const bits = [];
2414
+ if (check.delivered.length) {
2415
+ const missing = check.delivered.filter((d) => d.done === 0).map((d) => d.item);
2416
+ bits.push(missing.length === 0 ? "every item the charter put in has finished work against it" : `${check.delivered.length - missing.length} of ${check.delivered.length} items the charter put in have finished work against them (not: ${missing.join(", ")})`);
2417
+ }
2418
+ if (check.crept.length) {
2419
+ const items = [...new Set(check.crept.map((c) => c.item))];
2420
+ bits.push(`${check.crept.length} task${check.crept.length === 1 ? "" : "s"} touched what it put out (${items.join(", ")})`);
2421
+ } else if (check.outItems > 0) bits.push("nothing it put out crept in");
2422
+ if (!bits.length) return null;
2423
+ return `Against the charter: ${bits.join("; ")}.`;
2424
+ }
2425
+
2426
+ //#endregion
2427
+ //#region src/lib/voyage-log.ts
2428
+ init_date_fns();
2280
2429
  init_blockers();
2281
2430
  init_date_text();
2282
2431
  init_premortem();
@@ -2342,6 +2491,7 @@ function voyageLog(data, projectId) {
2342
2491
  slips,
2343
2492
  risks,
2344
2493
  issues: issues.length,
2494
+ charter: charterCheck(project.charter, tasks),
2345
2495
  decisions,
2346
2496
  carriers,
2347
2497
  budget: project.budget,
@@ -2390,6 +2540,8 @@ function voyageStory(log) {
2390
2540
  out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md$2(s.waitedOn.completedAt)}.` : `${late}.`);
2391
2541
  }
2392
2542
  for (const r of log.risks.filter((x) => x.happened)) out.push(`The risk “${r.risk.title}” came true${r.guessed ? " (an issue names it)" : ""}.`);
2543
+ const against = charterSentence(log.charter);
2544
+ if (against) out.push(against);
2393
2545
  if (log.decisions.length) {
2394
2546
  const changed = log.decisions.filter((d) => d.status === "superseded").length;
2395
2547
  out.push(`${plural$4(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
@@ -5026,6 +5178,29 @@ function premortemNote(data, project, now, today) {
5026
5178
  const pending = answersOf(data.reflections, project.id).filter(isPending).length;
5027
5179
  return pending ? `pre-mortem: ${pending} to sort · P` : "pre-mortem · P";
5028
5180
  }
5181
+ /**
5182
+ * The charter in a few words for the project's header: how many of its five
5183
+ * lines are written and any open task that names what it leaves out, or a
5184
+ * nudge (warned once it's a week overdue). Null on a finished project with none.
5185
+ */
5186
+ function charterNote(project, tasks, today) {
5187
+ const filled = charterFilled(project.charter);
5188
+ if (filled === 0) {
5189
+ if (project.health === "done") return null;
5190
+ return {
5191
+ text: "no charter yet · e writes it",
5192
+ warn: charterOverdue(project, today) !== null
5193
+ };
5194
+ }
5195
+ const creep = outOfScope(project.charter, tasksOf(tasks, project.id).filter((t) => t.status !== "done")).length;
5196
+ return creep ? {
5197
+ text: `charter: ${creep} task${creep === 1 ? "" : "s"} look out of scope · e`,
5198
+ warn: true
5199
+ } : {
5200
+ text: `charter ${filled} of 5 · e`,
5201
+ warn: false
5202
+ };
5203
+ }
5029
5204
  const ROLE_LABEL = {
5030
5205
  owner: "Owner",
5031
5206
  member: "Can edit",
@@ -7416,6 +7591,24 @@ function reviewPrograms(data, opts = {}) {
7416
7591
  const { ownerId, portfolioId } = opts;
7417
7592
  return data.programs.filter((p) => (!portfolioId || p.portfolioId === portfolioId) && (!ownerId || programTouches(p, data, ownerId))).sort((a, b) => rankHealth(a.health) - rankHealth(b.health) || a.endDate.localeCompare(b.endDate));
7418
7593
  }
7594
+ /**
7595
+ * The projects in these programs whose charter is still blank a week after
7596
+ * they started (src/lib/charter.ts), longest-running first: the review's
7597
+ * nudge to write the five lines.
7598
+ */
7599
+ function blankCharters(data, programs, today) {
7600
+ const ids = new Set(programs.filter((p) => p.health !== "done").map((p) => p.id));
7601
+ const out = [];
7602
+ for (const project of data.projects) {
7603
+ if (!ids.has(project.programId)) continue;
7604
+ const days = charterOverdue(project, today);
7605
+ if (days !== null) out.push({
7606
+ project,
7607
+ days
7608
+ });
7609
+ }
7610
+ return out.sort((a, b) => b.days - a.days || a.project.name.localeCompare(b.project.name));
7611
+ }
7419
7612
 
7420
7613
  //#endregion
7421
7614
  //#region src/tui/moves.ts
@@ -10070,7 +10263,17 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
10070
10263
  initial: project.description,
10071
10264
  onSave: (v) => up({ description: v })
10072
10265
  }
10073
- }
10266
+ },
10267
+ ...CHARTER_FIELDS.map((f) => ({
10268
+ label: `Charter: ${CHARTER_LABEL[f].toLowerCase()}`,
10269
+ value: project.charter?.[f]?.replace(/\s+/g, " ") || "Not written",
10270
+ color: project.charter?.[f] ? void 0 : theme.muted,
10271
+ edit: {
10272
+ kind: "text",
10273
+ initial: project.charter?.[f] ?? "",
10274
+ onSave: (v) => store.getState().setCharter(id, { [f]: v })
10275
+ }
10276
+ }))
10074
10277
  ];
10075
10278
  return /* @__PURE__ */ jsx(DetailPanel, {
10076
10279
  title: /* @__PURE__ */ jsx(Text, {
@@ -10708,6 +10911,8 @@ function ProjectsView({ active, width, height, go }) {
10708
10911
  }
10709
10912
  });
10710
10913
  }
10914
+ /** Projects a week in without a charter, in a few words; null for none. */
10915
+ const charterGap = (n) => n ? `${n} blank charter${n === 1 ? "" : "s"}` : null;
10711
10916
  /**
10712
10917
  * The review, as on the website: programs worst first, then by end date,
10713
10918
  * each with its lead, how much is late, where it sits on the priority matrix
@@ -10773,7 +10978,8 @@ function ProgramsView({ active, width, height, go }) {
10773
10978
  late ? `${late} late` : null,
10774
10979
  slip >= 100 ? `slipping costs ${dollars$1(slip)}` : null,
10775
10980
  isRated(program) ? quadrantOf(program, maxBudget, rules) : null,
10776
- program.nextMove ? `next: ${program.nextMove}` : "no next move"
10981
+ program.nextMove ? `next: ${program.nextMove}` : "no next move",
10982
+ charterGap(blankCharters({ projects }, [program], day).length)
10777
10983
  ].filter(Boolean).join(" · ")
10778
10984
  };
10779
10985
  });
@@ -18297,6 +18503,24 @@ function decisionOut(data, r, people) {
18297
18503
  };
18298
18504
  }
18299
18505
  const tool = (t) => t;
18506
+ /** A project's charter, as get_project gives it: the lines written, and open tasks that name what it leaves out. */
18507
+ function charterOf(p, tasks) {
18508
+ const lines = charterLines(p.charter);
18509
+ if (lines.length === 0) return { charter: null };
18510
+ const creep = outOfScope(p.charter, tasks.filter((t) => t.status !== "done")).slice(0, 10);
18511
+ return {
18512
+ charter: {
18513
+ ...Object.fromEntries(lines.map((l) => [CHARTER_KEY[l.field], l.text])),
18514
+ ...p.charter?.updatedAt ? { updated: dayOf(p.charter.updatedAt) } : {}
18515
+ },
18516
+ ...creep.length ? { outOfScope: creep.map((c) => ({
18517
+ task: taskKey(c.task, p) ?? c.task.id,
18518
+ title: c.task.title,
18519
+ names: c.item,
18520
+ added: dayOf(c.task.createdAt)
18521
+ })) } : {}
18522
+ };
18523
+ }
18300
18524
  /** A project's notes for later, as get_project gives them: sealed ones without their text unless they're the caller's. */
18301
18525
  function notesOf(ctx, p) {
18302
18526
  const notes = bottlesOf(ctx.data.reflections, p.id);
@@ -18466,7 +18690,7 @@ const HARBOR_TOOLS = [
18466
18690
  tool({
18467
18691
  name: "get_project",
18468
18692
  title: "Project details",
18469
- description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), and its url on the website." + DATA_NOTE,
18693
+ description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), and its url on the website." + DATA_NOTE,
18470
18694
  input: object({
18471
18695
  harbor: harborArg,
18472
18696
  project: string().min(1).max(200).describe("Project id or name.")
@@ -18526,6 +18750,7 @@ const HARBOR_TOOLS = [
18526
18750
  milestones,
18527
18751
  openRaid: raid,
18528
18752
  finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data.reflections),
18753
+ ...charterOf(p, tasks),
18529
18754
  ...notesOf(ctx, p),
18530
18755
  ...hillOf(p, today),
18531
18756
  ...webLink(ctx, { project: p.id })
@@ -18844,7 +19069,7 @@ const HARBOR_TOOLS = [
18844
19069
  tool({
18845
19070
  name: "program_review",
18846
19071
  title: "The review",
18847
- description: "The website's Review: programs worst health first, then soonest end, each with its lead, end date, health (and the note when set by hand), late tasks, where it sits on the priority matrix and its next move. Done programs only with includeDone." + DATA_NOTE,
19072
+ description: "The website's Review: programs worst health first, then soonest end, each with its lead, end date, health (and the note when set by hand), late tasks, where it sits on the priority matrix and its next move. Done programs only with includeDone. blankCharters lists running projects in them whose charter is still empty a week after they started (set_charter writes one)." + DATA_NOTE,
18848
19073
  input: object({
18849
19074
  harbor: harborArg,
18850
19075
  owner: string().max(200).optional().describe("\"me\", or a person's id or name: programs they lead, lead a project in or hold a task in."),
@@ -18881,6 +19106,11 @@ const HARBOR_TOOLS = [
18881
19106
  nextMove: p.nextMove?.trim() || null
18882
19107
  })),
18883
19108
  done: all.length - listed.length,
19109
+ blankCharters: blankCharters(data, listed, todayISO(ctx.now)).map((b) => ({
19110
+ project: b.project.name,
19111
+ program: data.programs.find((x) => x.id === b.project.programId)?.name ?? null,
19112
+ startedDaysAgo: b.days
19113
+ })),
18884
19114
  onlyOneKnows: lifeboats(data, ctx.now, { programIds: new Set(listed.map((p) => p.id)) }).slice(0, 25).map((l) => lifeboatOut(data, l, people))
18885
19115
  };
18886
19116
  }
@@ -18964,6 +19194,17 @@ const HARBOR_TOOLS = [
18964
19194
  cameTrue: r.happened,
18965
19195
  ...r.guessed ? { guessed: true } : {}
18966
19196
  })),
19197
+ ...log.charter ? { charter: {
19198
+ doneWhen: log.charter.doneWhen,
19199
+ inItems: log.charter.delivered.map((d) => ({
19200
+ item: d.item,
19201
+ finishedTasks: d.done
19202
+ })),
19203
+ outOfScopeTasks: log.charter.crept.map((c) => ({
19204
+ title: c.task.title,
19205
+ names: c.item
19206
+ }))
19207
+ } } : {},
18967
19208
  decisions: log.decisions.map((d) => ({
18968
19209
  title: d.title,
18969
19210
  on: dayOf(d.decidedAt),
@@ -20226,6 +20467,13 @@ function hierarchySlice({ get, write }) {
20226
20467
  hillMovedAt: stamp()
20227
20468
  } : p) });
20228
20469
  },
20470
+ setCharter: (projectId, patch) => {
20471
+ const by = get().currentUserId;
20472
+ write({ projects: get().projects.map((p) => p.id === projectId ? {
20473
+ ...p,
20474
+ charter: withCharter(p.charter, patch, by, stamp())
20475
+ } : p) });
20476
+ },
20229
20477
  addCustomField: (projectId, def) => {
20230
20478
  const id = uid("cf");
20231
20479
  write({ projects: get().projects.map((p) => p.id === projectId ? {
@@ -20502,6 +20750,7 @@ var init_hierarchy = __esmMin((() => {
20502
20750
  init_derived_health();
20503
20751
  init_shared();
20504
20752
  init_hill();
20753
+ init_charter_lines();
20505
20754
  init_sample_project();
20506
20755
  init_format();
20507
20756
  init_empty();
@@ -25611,6 +25860,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
25611
25860
  reflections,
25612
25861
  raidItems
25613
25862
  }, project, clockNow(), day);
25863
+ const charter = charterNote(project, tasks, day);
25614
25864
  const opened = freshlyOpened({
25615
25865
  reflections,
25616
25866
  milestones
@@ -25699,6 +25949,10 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
25699
25949
  pmNote ? /* @__PURE__ */ jsxs(Text, {
25700
25950
  color: theme.muted,
25701
25951
  children: [" · ", pmNote]
25952
+ }) : null,
25953
+ charter ? /* @__PURE__ */ jsxs(Text, {
25954
+ color: charter.warn ? theme.warn : theme.muted,
25955
+ children: [" · ", charter.text]
25702
25956
  }) : null
25703
25957
  ]
25704
25958
  }),
package/lib/cli.mjs CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  myWork,
34
34
  premortemText,
35
35
  notesText,
36
+ charterText,
36
37
  daysText,
37
38
  soundingText,
38
39
  lifeboatsText,
@@ -135,15 +136,17 @@ export const HELP = `harbor: Harbor from the terminal
135
136
  [--ends DATE] [--budget N] [--from "DONE PROJECT"]
136
137
  --from: plan it from a finished
137
138
  project as that one really ran
138
- harbor add "TITLE" --project P [--assignee me|none|NAME] [--due DATE]
139
- [--priority low|medium|high|urgent] [--status S]
140
- [--comment TEXT] [--over-limit]
139
+ harbor add "TITLE" --project P [--assignee me|none|NAME]
140
+ [--due DATE|pilot] [--priority low|medium|high|urgent]
141
+ [--status S] [--comment TEXT] [--over-limit]
141
142
  with no TITLE: one title a line
142
- from stdin
143
+ from stdin; --due pilot takes the
144
+ date the owner's queue suggests
143
145
  harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
144
146
  move a task to In play, In review
145
147
  or Done
146
- harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
148
+ harbor edit TASK [--title T] [--assignee me|none|NAME]
149
+ [--due DATE|pilot|none]
147
150
  [--priority P] [--status S] [--notes TEXT]
148
151
  [--repeat weekly|biweekly|monthly|never]
149
152
  [--waits-on TASK,TASK|none] [--confirm] [--over-limit]
@@ -203,6 +206,10 @@ export const HELP = `harbor: Harbor from the terminal
203
206
  answer or read it
204
207
  harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
205
208
  your line in it
209
+ harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
210
+ [--calls T]
211
+ a project's five-line charter:
212
+ read it, or write some lines
206
213
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
207
214
  a note for later: sealed until
208
215
  its day, milestone, or Off track
@@ -378,6 +385,21 @@ export function dueDate(v, today = new Date()) {
378
385
  return todayLocal(d);
379
386
  }
380
387
 
388
+ /** --due pilot: the date the owner's queue suggests, set by Harbor. */
389
+ const PILOT = new Set(["pilot", "suggested"]);
390
+ const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
391
+
392
+ /** Pilot's word when the date given won't hold: what the queue says, why, and how to take it. */
393
+ function pilotLines(r, task) {
394
+ const p = r.pilot;
395
+ if (!p) return [];
396
+ const ref = task.key ?? task.id;
397
+ return [
398
+ `${p.says}: ${p.why.map((w, i) => (i ? w.charAt(0).toLowerCase() + w.slice(1) : w)).join("; ")}.`,
399
+ `harbor edit ${ref} --due pilot takes ${p.earliest}.`,
400
+ ];
401
+ }
402
+
381
403
  /** Harbor's tool fields, as the CLI's flags. */
382
404
  const FLAG = {
383
405
  due: "--due",
@@ -406,6 +428,10 @@ const FLAG = {
406
428
  text: "the next move",
407
429
  waitsOn: "--waits-on",
408
430
  repeat: "--repeat",
431
+ in: "--in",
432
+ out: "--out",
433
+ doneWhen: "--done-when",
434
+ callsBy: "--calls",
409
435
  description: "--notes",
410
436
  body: "the comment",
411
437
  column: "the column",
@@ -684,6 +710,18 @@ const DETAIL = {
684
710
  " --no-people. A task already in its project is left alone: by its key",
685
711
  " (a Key column, as harbor export writes) or by title.",
686
712
  ],
713
+ charter: [
714
+ " harbor charter PROJECT reads its charter, and any open task",
715
+ " that names something it leaves out",
716
+ ' harbor charter PROJECT --why "TEXT" writes: that line; give any of',
717
+ " --why, --in, --out, --done-when and",
718
+ " --calls at once",
719
+ "",
720
+ " none clears a line: --out none. Separate items in --in and --out",
721
+ " with commas, so Harbor can spot a task that names one. The review lists",
722
+ " running projects still without a charter a week after they started.",
723
+ " PROJECT is a name, a key like RR, or an id.",
724
+ ],
687
725
  sound: [
688
726
  " harbor sound TASK reads its sounding: who hasn't answered,",
689
727
  " and the spread once everyone has",
@@ -1991,7 +2029,8 @@ export async function run(argv, io = {}) {
1991
2029
  title,
1992
2030
  // Yours unless you say otherwise, as with n in the app.
1993
2031
  assignee: values.assignee ?? "me",
1994
- due: dueDate(values.due),
2032
+ due: duePilot(values.due) ? undefined : dueDate(values.due),
2033
+ suggestedDue: duePilot(values.due),
1995
2034
  priority: values.priority,
1996
2035
  status: values.status === undefined ? undefined : statusWord(values.status),
1997
2036
  overLimit: values["over-limit"] || undefined,
@@ -2018,7 +2057,13 @@ export async function run(argv, io = {}) {
2018
2057
  out(
2019
2058
  plain(
2020
2059
  added
2021
- .map((r) => `Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`)
2060
+ .map((r) =>
2061
+ [
2062
+ `Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
2063
+ // Pilot speaks up only when a date was given that the queue says won't hold.
2064
+ ...(values.due ? pilotLines(r, r.created) : []),
2065
+ ].join("\n"),
2066
+ )
2022
2067
  .join("\n"),
2023
2068
  ),
2024
2069
  );
@@ -2059,7 +2104,13 @@ export async function run(argv, io = {}) {
2059
2104
  words && r.programs.length === 0
2060
2105
  ? `No program in the review matches "${words}".`
2061
2106
  : reviewList(r),
2062
- (r) => (words ? { ...r, programs: r.programs.filter((p) => nameHas(p.name, words)) } : r),
2107
+ (r) => {
2108
+ if (!words) return r;
2109
+ const programs = r.programs.filter((p) => nameHas(p.name, words));
2110
+ const names = new Set(programs.map((p) => p.name));
2111
+ const blankCharters = (r.blankCharters ?? []).filter((b) => names.has(b.program));
2112
+ return { ...r, programs, blankCharters };
2113
+ },
2063
2114
  );
2064
2115
  }
2065
2116
  const status = { start: "doing", submit: "review", done: "done" }[command];
@@ -2099,7 +2150,11 @@ export async function run(argv, io = {}) {
2099
2150
  task: one(positionals, "task key, like RR-42"),
2100
2151
  title: values.title,
2101
2152
  assignee: values.assignee,
2102
- due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
2153
+ due:
2154
+ values.due === undefined || duePilot(values.due)
2155
+ ? undefined
2156
+ : nullable(dueDate(values.due)),
2157
+ suggestedDue: duePilot(values.due),
2103
2158
  priority: values.priority,
2104
2159
  status: values.status === undefined ? undefined : statusWord(values.status),
2105
2160
  description: values.notes,
@@ -2121,6 +2176,7 @@ export async function run(argv, io = {}) {
2121
2176
  `Updated: ${taskLine(r.updated, opts)}`,
2122
2177
  r.updated.waitsOn ? `Waits on ${r.updated.waitsOn.join(", ")}.` : null,
2123
2178
  r.updated.repeat ? `Repeats ${REPEAT_WORD[r.updated.repeat]}.` : null,
2179
+ ...(values.due && !duePilot(values.due) ? pilotLines(r, r.updated) : []),
2124
2180
  ]
2125
2181
  .filter(Boolean)
2126
2182
  .join("\n"),
@@ -2376,6 +2432,44 @@ export async function run(argv, io = {}) {
2376
2432
  (r) => `Sealed your note on ${r.sealed.project}. It ${r.sealed.opens}.`,
2377
2433
  );
2378
2434
  }
2435
+ case "charter": {
2436
+ const { positionals, values, harbor } = parse(
2437
+ spec([], ["why", "in", "out", "done-when", "calls"]),
2438
+ );
2439
+ const project = one(positionals, 'project: harbor charter "NAME"');
2440
+ // none clears a line, as --due none does.
2441
+ const line = (v) => (v.trim().toLowerCase() === "none" ? "" : v);
2442
+ const lines = Object.fromEntries(
2443
+ [
2444
+ ["why", values.why],
2445
+ ["in", values.in],
2446
+ ["out", values.out],
2447
+ ["doneWhen", values["done-when"]],
2448
+ ["callsBy", values.calls],
2449
+ ]
2450
+ .filter(([, v]) => v !== undefined)
2451
+ .map(([k, v]) => [k, line(v)]),
2452
+ );
2453
+ if (Object.keys(lines).length === 0) {
2454
+ // --json: the project's charter (null until written) and any out-of-scope tasks.
2455
+ return call(
2456
+ "get_project",
2457
+ { harbor, project },
2458
+ (r) => charterText(r),
2459
+ (r) => ({
2460
+ id: r.id,
2461
+ project: r.name,
2462
+ charter: r.charter ?? null,
2463
+ ...(r.outOfScope ? { outOfScope: r.outOfScope } : {}),
2464
+ }),
2465
+ );
2466
+ }
2467
+ return call("set_charter", { harbor, project, ...lines }, (r) =>
2468
+ r.charter
2469
+ ? `Saved ${r.project}'s charter.\n\n${charterText({ project: r.project, charter: r.charter })}`
2470
+ : `Cleared ${r.project}'s charter.`,
2471
+ );
2472
+ }
2379
2473
  case "sound": {
2380
2474
  const { positionals, values, harbor } = parse(spec([], ["why", "ask", "settle"]));
2381
2475
  const [task, days, ...extra] = positionals;
package/lib/format.mjs CHANGED
@@ -344,6 +344,14 @@ export function reviewList(r) {
344
344
  out.push(`${p.name} (${bits.join(" · ")})`);
345
345
  out.push(` ${p.nextMove ? `next: ${p.nextMove}` : "no next move yet"}`);
346
346
  }
347
+ const blank = r.blankCharters ?? [];
348
+ if (blank.length) {
349
+ out.push("", "Charters still blank a week in:");
350
+ for (const b of blank.slice(0, 8))
351
+ out.push(` ${b.project} (started ${b.startedDaysAgo} days ago)`);
352
+ if (blank.length > 8) out.push(` and ${blank.length - 8} more`);
353
+ out.push(' harbor charter "PROJECT" --why "…" writes one.');
354
+ }
347
355
  return out.join("\n") + more;
348
356
  }
349
357
 
@@ -650,6 +658,36 @@ export function importText(r) {
650
658
  }
651
659
 
652
660
  /** A project's notes for later, as get_project lists them. */
661
+ const CHARTER_LINES = [
662
+ ["why", "Why"],
663
+ ["in", "In"],
664
+ ["out", "Out"],
665
+ ["doneWhen", "Done when"],
666
+ ["callsBy", "Calls made by"],
667
+ ];
668
+
669
+ /** A project's charter as get_project or set_charter gives it, with open tasks that name what it leaves out. */
670
+ export function charterText(r) {
671
+ const project = r.name ?? r.project;
672
+ if (!r.charter) {
673
+ return `${project} has no charter yet. Write one, a line at a time:\n harbor charter "${project}" --why "…" --in "…, …" --out "…, …" --done-when "…" --calls "…"`;
674
+ }
675
+ const lines = [`Charter · ${project}`];
676
+ const width = Math.max(...CHARTER_LINES.map(([, label]) => label.length));
677
+ for (const [key, label] of CHARTER_LINES) {
678
+ if (r.charter[key]) lines.push(` ${label.padEnd(width)} ${r.charter[key]}`);
679
+ }
680
+ if (r.outOfScope?.length) {
681
+ lines.push("", "Out of scope?");
682
+ for (const t of r.outOfScope) {
683
+ lines.push(
684
+ ` ${t.task} ${t.title} (added ${t.added}; the charter leaves out "${t.names}")`,
685
+ );
686
+ }
687
+ }
688
+ return lines.join("\n");
689
+ }
690
+
653
691
  export function notesText(project, notes) {
654
692
  if (!notes?.length)
655
693
  return `No notes for later on ${project}. Leave one: harbor note "${project}" "NOTE" --opens +30`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.55.0",
3
+ "version": "0.57.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",