@bli-cockpit/cli 0.2.240 → 0.2.241

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.
@@ -7,34 +7,50 @@
7
7
  * AI's working notes, kept inside the manager server. "Today" is Vancouver's day, as the manager server counts it.
8
8
  */
9
9
  import { dim } from "./cli-io.js";
10
+ import { readReasonText } from "./manager-read-reasons.js";
10
11
  /** The manager's day (`TOWER_MANAGER_TIMEZONE`): a task due today is not overdue at 5 pm Pacific. */
11
12
  export const MANAGER_TIMEZONE = "America/Vancouver";
12
- const HEALTH = { on_track: "On track", at_risk: "At risk", blocked: "Blocked" };
13
- const FRESHNESS = {
13
+ export const HEALTH = { on_track: "On track", at_risk: "At risk", blocked: "Blocked" };
14
+ export const FRESHNESS = {
14
15
  reading: "being read now",
15
16
  never_read: "never read yet",
16
17
  failed: "the latest read failed",
17
18
  stale: "may be out of date (no good read in the last 36 hours)",
18
19
  fresh: "fresh",
19
20
  };
20
- const MISSING = {
21
+ export const MISSING = {
21
22
  never_checked: "Not written yet: the next read writes it.",
22
23
  check_failed: "Not written yet: the last check failed; the next read tries again.",
23
24
  nothing_to_summarise: "Nothing to summarise yet: Tower has no open actions or timeline for this client.",
24
25
  };
25
- const CHANGE = { added: "added", changed: "changed", proposal: "proposed", closed: "closed" };
26
+ export const CHANGE = { added: "added", changed: "changed", proposal: "proposed", closed: "closed" };
26
27
  const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
27
28
  /** `YYYY-MM-DD` of Vancouver's day for an instant. */
28
29
  export function managerDay(at) {
29
30
  return new Intl.DateTimeFormat("en-CA", { timeZone: MANAGER_TIMEZONE, year: "numeric", month: "2-digit", day: "2-digit" }).format(at);
30
31
  }
31
32
  /** "Oct 16" for an instant, on Vancouver's calendar. */
32
- function shortDate(at) {
33
+ export function shortDate(at) {
33
34
  const date = new Date(at);
34
35
  if (Number.isNaN(date.getTime()))
35
36
  return at;
36
37
  return new Intl.DateTimeFormat("en-US", { timeZone: MANAGER_TIMEZONE, month: "short", day: "numeric" }).format(date);
37
38
  }
39
+ /** "Oct 1" for a `YYYY-MM-DD` day as written (no timezone shift: a bare date is a calendar day, not an instant). */
40
+ function dayWords(day) {
41
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(day);
42
+ if (!match)
43
+ return day;
44
+ const noon = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12));
45
+ return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "short", day: "numeric" }).format(noon);
46
+ }
47
+ /** "Oct 16, 5:45 PM PDT": an instant as Tower's day and time (America/Vancouver, as Home reads it); an unreadable one is printed as it came. `--json` keeps the raw ISO. */
48
+ export function managerTime(at) {
49
+ const date = new Date(at);
50
+ if (Number.isNaN(date.getTime()))
51
+ return at;
52
+ return new Intl.DateTimeFormat("en-US", { timeZone: MANAGER_TIMEZONE, month: "short", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" }).format(date);
53
+ }
38
54
  /** "3 h ago", "2 d ago", "just now": how old an instant is. */
39
55
  export function ageText(at, now) {
40
56
  const ms = now.getTime() - new Date(at).getTime();
@@ -67,15 +83,22 @@ export function withTodayLine(rows, dates, today) {
67
83
  const line = [todayLine(today)];
68
84
  return at === -1 ? [...rows, line] : [...rows.slice(0, at), line, ...rows.slice(at)];
69
85
  }
86
+ /** Who wrote the card, by its `source`: the credit's opening words (the day follows; a person's name comes after "Edited by"). */
87
+ export const SUMMARY_SOURCE_WORDS = {
88
+ model: "Written by AI",
89
+ computed: "AI unavailable · computed from counts",
90
+ person: "Edited by",
91
+ };
70
92
  /** Who wrote the card and when: "written by AI", "computed from counts", or "edited by" a person. */
71
93
  function authorLine(summary, now) {
72
- const at = when(summary.writtenAt, now);
94
+ // The server's own Vancouver day when it sends one; never worked out here when it did.
95
+ const at = summary.writtenOn ? `${dayWords(summary.writtenOn)} (${ageText(summary.writtenAt, now)})` : when(summary.writtenAt, now);
73
96
  if (summary.source === "model")
74
- return `Written by AI, ${at}.`;
97
+ return `${SUMMARY_SOURCE_WORDS.model}, ${at}.`;
75
98
  if (summary.source === "computed")
76
- return `Computed from counts, ${at} (the model could not write it).`;
99
+ return `${SUMMARY_SOURCE_WORDS.computed}, ${at}.`;
77
100
  // Never a user id in a person's terminal: the door fills the name, or says "a teammate".
78
- return `Edited by ${summary.editedByName ?? "a teammate"}, ${at}.`;
101
+ return `${SUMMARY_SOURCE_WORDS.person} ${summary.editedByName ?? "a teammate"}, ${at}.`;
79
102
  }
80
103
  const CAP_WORDS = {
81
104
  max_depth: "depth cap",
@@ -108,22 +131,25 @@ function freshnessLines(home, now) {
108
131
  const synced = home.client.lastSyncedAt;
109
132
  // A failed latest read names its reason, and the data shown is as old as the last good read (availability over completeness).
110
133
  const failed = read && home.freshness.state === "failed"
111
- ? `failed ${when(read.finishedAt ?? read.startedAt, now)}${read.reason ? `: ${read.reason}` : ""}; ${synced ? `last good read ${when(synced, now)}` : "no good read yet"}`
134
+ ? `failed ${when(read.finishedAt ?? read.startedAt, now)}; ${synced ? `last good read ${when(synced, now)}` : "no good read yet"}`
112
135
  : null;
113
136
  const lastRead = failed ?? (read ? `last read ${when(read.finishedAt ?? read.startedAt, now)}${read.finishedAt ? "" : ", still running"}` : synced ? `last synced ${when(synced, now)}` : "never read");
114
137
  const notion = home.freshness.notionLastEditedAt ? `Notion last changed ${when(home.freshness.notionLastEditedAt, now)}` : "no Notion page yet";
115
138
  // What the read could not see sits under the Data line, so "fresh" never hides it.
116
- return [`Data: ${FRESHNESS[home.freshness.state] ?? home.freshness.state}. ${lastRead[0].toUpperCase()}${lastRead.slice(1)}; ${notion}.`, ...coverageLines(home.client).map((line) => ` ${line}`)];
139
+ return [`Data: ${FRESHNESS[home.freshness.state] ?? home.freshness.state}. ${lastRead[0].toUpperCase()}${lastRead.slice(1)}; ${notion}.`, ...coverageLines(home.client).map((line) => ` ${line}`), ...(failed && read?.reason ? [` Why the latest read failed: ${readReasonText(read.reason)}`] : [])];
117
140
  }
118
141
  function summaryLines(home, now, color) {
119
142
  const c = home.counts;
120
- // The page's numbers beside the card: due this week is what needs attention beyond the overdue ones, and "to review" is
121
- // the review queue's size (`review.total`), not `counts.toReview` (which also counts the first read's items).
122
- const dueThisWeek = Math.max(0, home.needsAttention.total - c.overdue);
123
- const counts = ` Live: ${plural(c.openActions, "open action")} (${c.overdue} overdue, ${dueThisWeek} due this week), ${plural(c.timeline, "timeline item")}, ${home.review.total} to review.`;
143
+ // The page's numbers beside the card: due this week is what needs attention beyond the overdue ones, and "new from syncs"
144
+ // is the review queue's size (`review.total`: changes after the first read), not `counts.toReview` (every unreviewed
145
+ // item, the first read's included), which `list` prints as "need review".
146
+ const dueThisWeek = home.needsAttention.dueThisWeek?.total ?? Math.max(0, home.needsAttention.total - c.overdue);
147
+ const counts = ` Live: ${plural(c.openActions, "open action")} (${c.overdue} overdue, ${dueThisWeek} due this week), ${plural(c.timeline, "timeline item")}, ${home.review.total} new from syncs.`;
124
148
  const summary = home.summary;
149
+ const first = home.client.onboardingStatus === "failed" ? firstReadSentence(home) : null;
150
+ // No promise that "the next read writes it" while the first read has failed: the page shows only the failure.
125
151
  if (!summary)
126
- return [`Where we are: ${MISSING[home.summaryMissingReason ?? "never_checked"]}`, counts];
152
+ return [`Where we are: ${first ? `Not written yet. ${first}` : (MISSING[home.summaryMissingReason ?? "never_checked"] ?? "Not written yet.")}`, counts];
127
153
  const lines = [`Where we are: ${HEALTH[summary.health] ?? summary.health} — ${summary.headline}`];
128
154
  for (const bullet of summary.bullets)
129
155
  lines.push(` ${bullet.date ?? "(no date)"} ${bullet.text}`);
@@ -132,26 +158,71 @@ function summaryLines(home, now, color) {
132
158
  if (summary.blocker)
133
159
  lines.push(` Blocker: ${summary.blocker}`);
134
160
  lines.push(` ${authorLine(summary, now)}`);
135
- if (summary.inputsChanged)
136
- lines.push(" Inputs changed since: the client's data changed or an action went overdue (for an AI-written card, also a meeting or milestone came within a week or passed) after this was written; the next check writes it again.");
161
+ if (first)
162
+ lines.push(` ${first}`);
163
+ // A failed check keeps the card and promises nothing; otherwise say how old the last look is.
164
+ if (summary.lastCheckFailed)
165
+ lines.push(` The last check failed; showing the card from ${shortDate(summary.writtenAt)}.`);
166
+ else {
167
+ // Null until a real check has looked at the card: no line then (never "56 years ago").
168
+ const checked = summary.checkedAt ?? null;
169
+ if (checked !== null && Number.isFinite(new Date(checked).getTime()))
170
+ lines.push(` Last checked ${ageText(checked, now)}.`);
171
+ }
172
+ if (summary.inputsChanged && !summary.lastCheckFailed)
173
+ lines.push(" Inputs changed since: the client's data changed, an action went overdue or an open action passed 14 days overdue (for an AI-written card, also a meeting or milestone came within a week or passed) after this was written; the next check writes it again.");
137
174
  lines.push(counts, dim(` version ${summary.updatedAt} (what \`summary --version\` takes)`, color));
138
175
  return lines;
139
176
  }
140
- function attentionLines(home, color) {
141
- const attention = home.needsAttention;
142
- const head = `Needs attention: ${plural(attention.total, "open action")} overdue or due within 7 days.${attention.truncated ? ` Showing ${attention.items.length} of ${attention.total}.` : ""}`;
143
- const lines = [head];
144
- const byId = new Map(attention.items.map((item) => [item.id, item]));
145
- for (const group of attention.byOwner) {
177
+ /** The rows of one part, under their owners in the order `byOwner` gives; a row no group names is printed flat. */
178
+ function partRows(home, part, color, keepEmptyOwners = false) {
179
+ const byId = new Map(part.items.map((item) => [item.id, item]));
180
+ const row = (item, indent) => `${indent}${item.dueAt ? `due ${item.dueAt}` : "no due date"}${item.overdue ? " (overdue)" : ""} ${item.title} ${dim(`id ${item.id}`, color)}`;
181
+ const lines = [];
182
+ const placed = new Set();
183
+ for (const group of home.needsAttention.byOwner) {
184
+ const rows = group.actionIds.map((id) => byId.get(id)).filter((item) => item !== undefined);
185
+ if (rows.length === 0 && !keepEmptyOwners)
186
+ continue;
146
187
  lines.push(` ${group.owner ?? "No owner"}`);
147
- for (const id of group.actionIds) {
148
- const item = byId.get(id);
149
- if (item)
150
- lines.push(` ${item.dueAt ? `due ${item.dueAt}` : "no due date"}${item.overdue ? " (overdue)" : ""} ${item.title} ${dim(`id ${item.id}`, color)}`);
188
+ for (const item of rows) {
189
+ lines.push(row(item, " "));
190
+ placed.add(item.id);
151
191
  }
152
192
  }
193
+ for (const item of part.items)
194
+ if (!placed.has(item.id))
195
+ lines.push(row(item, " "));
196
+ return lines;
197
+ }
198
+ function attentionLines(home, ref, color) {
199
+ const attention = home.needsAttention;
200
+ const parts = attention.dueThisWeek || attention.overdue ? [
201
+ ["Due this week", attention.dueThisWeek, "by owner; today to six days ahead, soonest first under each", `cockpit manager list ${ref}`],
202
+ ["Overdue", attention.overdue, "by owner; oldest first under each", `cockpit manager list ${ref} --overdue`],
203
+ ] : null;
204
+ const lines = [];
205
+ if (parts) {
206
+ const total = (attention.dueThisWeek?.total ?? 0) + (attention.overdue?.total ?? 0);
207
+ lines.push(`Needs attention: ${plural(total, "open action")} overdue or due within 7 days.`);
208
+ for (const [label, part, order, command] of parts) {
209
+ if (!part)
210
+ continue;
211
+ // Each part says its own total, and when it was cut, how to see the rest.
212
+ lines.push(`${label}: ${plural(part.total, "open action")} (${order}).${part.truncated ? ` Showing ${part.items.length} of ${part.total}; \`${command}\` lists them all.` : ""}`);
213
+ // The rows sit under their owners, so the first row is not the oldest: the server's overdue list is oldest-due first, and its first item is the longest overdue.
214
+ const longest = label === "Overdue" ? part.items[0] : undefined;
215
+ if (longest?.dueAt)
216
+ lines.push(` Overdue longest: ${printable(longest.title)}, due ${dayWords(longest.dueAt)}.`);
217
+ lines.push(...partRows(home, part, color));
218
+ }
219
+ }
220
+ else {
221
+ lines.push(`Needs attention: ${plural(attention.total, "open action")} overdue or due within 7 days.${attention.truncated ? ` Showing ${attention.items.length} of ${attention.total}.` : ""}`);
222
+ lines.push(...partRows(home, { items: attention.items, total: attention.total, truncated: attention.truncated }, color, true));
223
+ }
153
224
  if (attention.undated > 0)
154
- lines.push(` ${plural(attention.undated, "open action")} ${attention.undated === 1 ? "has" : "have"} no due date (\`list <client>\` shows them).`);
225
+ lines.push(` ${plural(attention.undated, "open action")} ${attention.undated === 1 ? "has" : "have"} no due date (\`cockpit manager list ${ref}\` shows them).`);
155
226
  return lines;
156
227
  }
157
228
  function comingUpLines(home) {
@@ -163,16 +234,113 @@ function comingUpLines(home) {
163
234
  lines.push(` ${coming.undated} more ${coming.undated === 1 ? "has" : "have"} no date ("Needs a date").`);
164
235
  return lines;
165
236
  }
237
+ /**
238
+ * The first read is not done: said instead of "nothing changed" / "nothing waits", which would be false. Null once a read has finished.
239
+ * A FAILED first read is the page's failure panel alone (it shows nothing else until a read succeeds): writes made here are saved, and
240
+ * the next read does not promise to write anything the page shows.
241
+ */
242
+ export function firstReadSentence(home) {
243
+ if (home.client.onboardingStatus === "failed") {
244
+ const reason = home.client.lastRead?.reason;
245
+ const why = reason && reasonSentence(reason) ? ` ${reasonSentence(reason)}` : "";
246
+ return `First read failed.${why} The manager page shows only that failure until a read succeeds; anything written here is saved but does not show there until then. Sync now on the manager page tries again.`;
247
+ }
248
+ if (home.freshness.state === "never_read")
249
+ return "First read not done yet. Sync now on the manager page starts it.";
250
+ if (home.freshness.state === "reading" && home.client.lastSyncedAt === null)
251
+ return "First read running…";
252
+ return null;
253
+ }
254
+ /** The page's sentence alone (no label), or null for a label it has no sentence for. */
255
+ function reasonSentence(reason) {
256
+ const text = readReasonText(reason);
257
+ return text === reason ? null : text.slice(0, text.lastIndexOf(" ("));
258
+ }
259
+ const CHANGE_ORDER = { proposal: 0, added: 1, changed: 2, closed: 3 };
260
+ /** The window an answer without `days` is taken to span (a copy of the server's `TOWER_MANAGER_IN_TOWER_DAYS`, held equal by `manager-cli-mcp-rules.test.ts`). */
261
+ export const IN_TOWER_DAYS_DEFAULT = 7;
262
+ export const IN_TOWER = { added: "added", closed: "closed", dropped: "dropped", dismissed: "dismissed" };
263
+ /**
264
+ * The names in the In Tower heading: every person in the window when the server sends `people`, else those on the rows, with "…"
265
+ * when the rows were cut. A person with no name is "a teammate" (several: "N teammates"), never their user id.
266
+ */
267
+ export function inTowerNames(tower) {
268
+ const names = [];
269
+ if (tower.people) {
270
+ let unnamed = 0;
271
+ for (const person of tower.people) {
272
+ if (person.name) {
273
+ if (!names.includes(person.name))
274
+ names.push(person.name);
275
+ }
276
+ else
277
+ unnamed += 1;
278
+ }
279
+ if (unnamed > 0)
280
+ names.push(unnamed === 1 ? "a teammate" : `${unnamed} teammates`);
281
+ return names;
282
+ }
283
+ for (const item of tower.items) {
284
+ const name = item.byName || "a teammate";
285
+ if (!names.includes(name))
286
+ names.push(name);
287
+ }
288
+ return tower.truncated ? [...names, "…"] : names;
289
+ }
290
+ /**
291
+ * "In Tower, last 7 days: people and agents added 3, closed 2, dropped 1, dismissed 1 (Can, Pip)": a part with none is left out, and when every part
292
+ * is none, "nothing added, closed, dropped or dismissed by people or agents" (no names). `dropped` absent is 0.
293
+ */
294
+ export function inTowerHeading(days, counts, names) {
295
+ const parts = [["added", counts.added], ["closed", counts.closed], ["dropped", counts.dropped ?? 0], ["dismissed", counts.dismissed]].filter(([, n]) => n > 0).map(([word, n]) => `${word} ${n}`);
296
+ if (parts.length === 0)
297
+ return `In Tower, last ${days} days: nothing added, closed, dropped or dismissed by people or agents.`;
298
+ return `In Tower, last ${days} days: people and agents ${parts.join(", ")}${names.length > 0 ? ` (${names.join(", ")})` : ""}.`;
299
+ }
300
+ /** What people and agents did in Tower lately, with who (by name, never an id). Nothing when the server does not send it. */
301
+ function inTowerLines(home) {
302
+ const tower = home.whatChanged.inTower;
303
+ if (!tower)
304
+ return [];
305
+ const names = inTowerNames(tower);
306
+ const c = tower.counts;
307
+ const head = `${inTowerHeading(tower.days ?? IN_TOWER_DAYS_DEFAULT, c, names)}${tower.truncated ? ` Showing ${tower.items.length} of ${tower.total}.` : ""}`;
308
+ return [head, ...tower.items.map((item) => ` ${IN_TOWER[item.change] ?? item.change} ${item.table} ${item.title} (by ${item.byName ?? "a teammate"}, ${shortDate(item.at)})`)];
309
+ }
166
310
  function changedLines(home, now) {
167
311
  const changed = home.whatChanged;
312
+ const first = firstReadSentence(home);
313
+ const tower = inTowerLines(home);
168
314
  if (!changed.read)
169
- return ["What changed: no read has changed anything yet."];
170
- const counts = Object.keys(CHANGE).map((kind) => `${changed.counts[kind] ?? 0} ${CHANGE[kind]}`).join(", ");
315
+ return [first ? `What changed: ${first}` : "What changed: no read has changed anything yet.", ...tower];
316
+ // As the page words it: a kind with none is left out ("12 added, 2 proposed"), and none at all is "nothing changed".
317
+ const counts = Object.keys(CHANGE).filter((kind) => (changed.counts[kind] ?? 0) > 0).map((kind) => `${changed.counts[kind]} ${CHANGE[kind]}`).join(", ") || "nothing changed";
171
318
  const total = Object.values(changed.counts).reduce((sum, n) => sum + (n ?? 0), 0);
172
319
  const lines = [`What changed in the newest read that changed anything (${when(changed.read.finishedAt ?? changed.read.startedAt, now)}): ${counts}.${changed.truncated ? ` Showing ${changed.items.length} of ${total}.` : ""}`];
173
- for (const item of changed.items)
320
+ // Proposals first (they need a person), then added, changed, closed; the server already sends this order, and a stable sort keeps it if an older one does not.
321
+ const ordered = [...changed.items].sort((a, b) => (CHANGE_ORDER[a.change] ?? 9) - (CHANGE_ORDER[b.change] ?? 9));
322
+ for (const item of ordered)
174
323
  lines.push(` ${CHANGE[item.change] ?? item.change} ${item.table} ${item.title}`);
175
- return lines;
324
+ return [...lines, ...tower];
325
+ }
326
+ /** "New from syncs": changes a read made after the first one (`review.total`), which the queue lists. */
327
+ function reviewLine(home, review, ref) {
328
+ const first = firstReadSentence(home);
329
+ const { decisions, toCheck } = home.review;
330
+ // The queue split, when the server sends it: suggested changes need a decision, new items need a look.
331
+ if (decisions !== undefined && toCheck !== undefined) {
332
+ // As the page words it: a part with none is left out; with both none, nothing waits.
333
+ const parts = [
334
+ decisions > 0 ? `${plural(decisions, "suggested change")} ${decisions === 1 ? "needs" : "need"} a decision` : null,
335
+ toCheck > 0 ? `${plural(toCheck, "new item")} to check` : null,
336
+ ].filter((part) => part !== null);
337
+ if (parts.length > 0)
338
+ return `Review: ${parts.join(" · ")} (\`cockpit manager queue ${ref}\` lists them).`;
339
+ return first ? `Review: nothing waits for review yet. ${first}` : "Review: nothing waits for review.";
340
+ }
341
+ if (review === 0)
342
+ return first ? `New from syncs: none yet. ${first}` : "New from syncs: 0 (no sync change waits for review).";
343
+ return `New from syncs: ${review} (\`cockpit manager queue ${ref}\` lists them).`;
176
344
  }
177
345
  /** The whole Home, top to bottom, for a person. `clientRef` is how they named the client, for the commands it suggests. */
178
346
  export function homeLines(home, clientRef, color, now = new Date()) {
@@ -184,25 +352,33 @@ export function homeLines(home, clientRef, color, now = new Date()) {
184
352
  "",
185
353
  ...summaryLines(home, now, color),
186
354
  "",
187
- ...attentionLines(home, color),
355
+ ...attentionLines(home, ref, color),
188
356
  "",
189
357
  ...comingUpLines(home),
190
358
  "",
191
359
  ...changedLines(home, now),
192
360
  "",
193
- review === 0 ? "Review: no sync change waits for review." : `Review: ${plural(review, "sync change")} ${review === 1 ? "waits" : "wait"} for review: \`cockpit manager queue ${ref}\`.`,
361
+ reviewLine(home, review, ref),
194
362
  ];
195
363
  }
364
+ /** Anything that is not printable text in a title (an escape sequence, a line break) is a space: a title is a client's data, printed in a person's terminal. */
365
+ const printable = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
366
+ /** One line per warning on a saved card. Never a refusal: the card is saved either way. */
367
+ export function warningLines(warnings) {
368
+ return (warnings ?? []).map((warning) => warning.reason === "on_track_with_long_overdue" && warning.title && warning.dueAt
369
+ ? `Warning: “${printable(warning.title)}” was due ${dayWords(warning.dueAt)} and is still open; the card says ${HEALTH.on_track}.`
370
+ : `Warning: Tower flagged this card (${printable(warning.reason)}); it was saved.`);
371
+ }
196
372
  /** What a summary edit printed: the card as written. */
197
373
  export function summaryWrittenLines(summary, color, now = new Date()) {
198
- const lines = [`Saved "Where we are": ${HEALTH[summary.health] ?? summary.health} — ${summary.headline}`];
374
+ const lines = [`Saved "Where we are": ${HEALTH[summary.health] ?? summary.health} — ${summary.headline}`, ...warningLines(summary.warnings)];
199
375
  for (const bullet of summary.bullets)
200
376
  lines.push(` ${bullet.date ?? "(no date)"} ${bullet.text}`);
201
377
  if (summary.next)
202
378
  lines.push(` Next: ${summary.next}`);
203
379
  if (summary.blocker)
204
380
  lines.push(` Blocker: ${summary.blocker}`);
205
- lines.push(` ${authorLine(summary, now)} It stands until the client's data changes or an action goes overdue; the next check after that writes it again.`);
381
+ lines.push(` ${authorLine(summary, now)} It stands until the client's data changes, an action goes overdue or an open action passes 14 days overdue; the next check after that writes it again.`);
206
382
  lines.push(dim(` version ${summary.updatedAt}`, color));
207
383
  return lines;
208
384
  }
@@ -7,8 +7,9 @@
7
7
  * change nobody read. Readable, not exhaustive: `--json` is the whole answer and never comes through here.
8
8
  */
9
9
  import { dim } from "./cli-io.js";
10
- import { managerDay, withTodayLine } from "./manager-render-home.js";
11
- const QUEUE_REASON = {
10
+ import { managerDay, managerTime, shortDate, withTodayLine } from "./manager-render-home.js";
11
+ import { keptReasonText, readReasonText, summaryRefusalText, summaryTriggerText } from "./manager-read-reasons.js";
12
+ export const QUEUE_REASON = {
12
13
  added: "added by a sync, not looked at yet",
13
14
  changed: "changed by a sync since you last saw it",
14
15
  proposal: "has a suggested change waiting",
@@ -18,14 +19,18 @@ export function clientLines(clients, color) {
18
19
  return ["Tower has no clients yet."];
19
20
  return clients.flatMap((client) => [
20
21
  `${client.name} [${client.enabled === false ? "disabled" : client.onboardingStatus}]${client.running ? " [reading now]" : ""}`,
21
- ` ${client.lastSyncedAt ? `last synced ${client.lastSyncedAt}` : "never synced"}${client.pagesWaiting ? `, ${client.pagesWaiting} pages waiting to be read` : ""}`,
22
+ ` ${client.lastSyncedAt ? `last synced ${managerTime(client.lastSyncedAt)}` : "never synced"}${client.pagesWaiting ? `, ${client.pagesWaiting} pages waiting to be read` : ""}`,
22
23
  dim(` id ${client.id}`, color),
23
24
  "",
24
25
  ]).slice(0, -1);
25
26
  }
27
+ /** An action's status in a list row: a dropped action was not done, and says so. */
28
+ export const ACTION_STATUS_WORDS = { open: "open", done: "done", dropped: "dropped (not done)" };
29
+ /** An item's review state in its row. */
30
+ export const REVIEW_STATUS_WORDS = { unreviewed: "needs review", reviewed: "reviewed", rejected: "dismissed" };
26
31
  /** "needs review" when nobody has looked at it; a suggested change waiting is said too. */
27
32
  export function reviewLabel(item) {
28
- const parts = [item.reviewStatus === "unreviewed" ? "needs review" : item.reviewStatus === "rejected" ? "dismissed" : "reviewed"];
33
+ const parts = [REVIEW_STATUS_WORDS[item.reviewStatus] ?? "reviewed"];
29
34
  const proposals = item.proposedChanges?.length ?? 0;
30
35
  if (proposals > 0)
31
36
  parts.push(`${proposals} suggested change${proposals === 1 ? "" : "s"} waiting`);
@@ -54,21 +59,31 @@ export function itemRowLines(item, timeline, color) {
54
59
  const review = reviewLabel(item);
55
60
  const facts = timeline
56
61
  ? [item.kind ?? "item", whenText(item, true), ownersText(item, true)]
57
- : [ownersText(item, false), whenText(item, false), item.status ?? "open"];
62
+ // A dropped action was not done: the row says so, never just "closed".
63
+ : [ownersText(item, false), whenText(item, false), ACTION_STATUS_WORDS[item.status ?? "open"] ?? item.status ?? "open"];
58
64
  return [`${item.title} [${review}]`, ` ${facts.join(" | ")}`, dim(` id ${item.id} version ${item.updatedAt}`, color)];
59
65
  }
60
- /** A list for a person; a timeline list carries the Today line between upcoming and past (Vancouver's day). */
61
- export function listLines(list, timeline, color, now = new Date()) {
66
+ /**
67
+ * A list for a person; a timeline list carries the Today line between upcoming and past (Vancouver's day).
68
+ * Deleted (`--archived`) is ordered by when each item was archived; Dismissed (`--review dismissed`) is in date
69
+ * order but, as on the page, not about now. Neither has a Today line, so `dateOrdered` is false for both.
70
+ */
71
+ export function listLines(list, timeline, color, now = new Date(), dateOrdered = true, ownerScoped = false) {
62
72
  const noun = timeline ? "timeline item" : "action";
63
73
  const shown = list.items.length;
64
74
  const head = shown < list.total ? `Showing ${shown} of ${list.total} ${noun}s.` : `${list.total} ${noun}${list.total === 1 ? "" : "s"}.`;
65
75
  const c = list.counts;
66
76
  const cut = list.truncated ? " A list holds at most 500 rows: narrow it with --status, --owner, --kind, --search or --needs-review." : "";
67
- const lines = [`${head}${cut}`, `The client has ${c.openActions} open ${c.openActions === 1 ? "action" : "actions"} (${c.overdue} overdue) and ${c.toReview} ${c.toReview === 1 ? "item" : "items"} to review.`];
77
+ const review = `${c.toReview} ${c.toReview === 1 ? "needs" : "need"} review (every unreviewed item, the first read's included)`;
78
+ // An owner filter (--owner, --no-owner) narrows the open and overdue counts to that scope (the server's `OwnerScope`); the review count stays the client's.
79
+ const counts = ownerScoped
80
+ ? `${c.openActions} open ${c.openActions === 1 ? "action matches" : "actions match"} these filters (${c.overdue} overdue). The client has ${review}.`
81
+ : `The client has ${c.openActions} open ${c.openActions === 1 ? "action" : "actions"} (${c.overdue} overdue) and ${review}.`;
82
+ const lines = [`${head}${cut}`, counts];
68
83
  if (shown === 0)
69
84
  lines.push(`No ${noun}s match.`);
70
85
  const rows = list.items.map((item) => itemRowLines(item, timeline, color));
71
- const placed = timeline && shown > 0 ? withTodayLine(rows, list.items.map((item) => item.occurredAt), managerDay(now)) : rows;
86
+ const placed = timeline && dateOrdered && shown > 0 ? withTodayLine(rows, list.items.map((item) => item.occurredAt), managerDay(now)) : rows;
72
87
  for (const row of placed)
73
88
  lines.push("", ...row);
74
89
  return lines;
@@ -78,13 +93,31 @@ export function queueLines(queue, color) {
78
93
  const lines = [`${count(queue.total.timeline, "timeline item")} and ${count(queue.total.actions, "action")} from syncs after onboarding are waiting for a person.`];
79
94
  if (queue.truncated)
80
95
  lines.push(`Showing ${queue.timeline.length} and ${queue.actions.length}: the oldest first.`);
81
- for (const [heading, items, timeline] of [["Timeline items", queue.timeline, true], ["Actions", queue.actions, false]]) {
82
- if (items.length === 0)
83
- continue;
84
- lines.push("", `${heading}:`);
85
- for (const item of items) {
86
- const rows = itemRowLines(item, timeline, color);
87
- lines.push("", rows[0], ` ${QUEUE_REASON[item.queuedFor ?? ""] ?? "waiting for review"}`, ...rows.slice(1));
96
+ const rowsOf = (items, timeline, filter) => items.filter(filter).map((item) => ({ item, timeline }));
97
+ const printRow = ({ item, timeline }) => {
98
+ const rows = itemRowLines(item, timeline, color);
99
+ lines.push("", rows[0], ` ${QUEUE_REASON[item.queuedFor ?? ""] ?? "waiting for review"}`, ...rows.slice(1));
100
+ };
101
+ // The split: suggested changes that need a decision first, then the new items to check. An older server sends neither, and the plain two lists stay.
102
+ const split = queue.counts !== undefined || [...queue.timeline, ...queue.actions].some((item) => item.needsDecision !== undefined);
103
+ if (split) {
104
+ const decide = (item) => item.needsDecision === true;
105
+ const decisions = [...rowsOf(queue.timeline, true, decide), ...rowsOf(queue.actions, false, decide)];
106
+ const check = [...rowsOf(queue.timeline, true, (item) => !decide(item)), ...rowsOf(queue.actions, false, (item) => !decide(item))];
107
+ for (const [heading, total, rows] of [
108
+ ["Needs your decision (a suggested change waits on each; decide it with `proposal`, or approve or dismiss the item)", queue.counts?.decisions ?? decisions.length, decisions],
109
+ ["New items to check", queue.counts?.toCheck ?? check.length, check],
110
+ ]) {
111
+ lines.push("", `${heading}: ${total}${total > rows.length ? `, showing ${rows.length}` : ""}`);
112
+ rows.forEach(printRow);
113
+ }
114
+ }
115
+ else {
116
+ for (const [heading, items, timeline] of [["Timeline items", queue.timeline, true], ["Actions", queue.actions, false]]) {
117
+ if (items.length === 0)
118
+ continue;
119
+ lines.push("", `${heading}:`);
120
+ items.forEach((item) => printRow({ item, timeline }));
88
121
  }
89
122
  }
90
123
  // The queue leaves out the onboarding's own items by design, so an empty one is not "all reviewed".
@@ -97,16 +130,58 @@ function valueText(value, label) {
97
130
  const text = Array.isArray(value) ? value.join(", ") : value;
98
131
  return label ? `${label} (${text})` : text;
99
132
  }
100
- function sourceLine(source) {
133
+ export function sourceLine(source) {
101
134
  if (source.addedBy === "human")
102
135
  return `from a person: ${[source.url, source.note].filter(Boolean).join(" | ") || "(empty)"}`;
103
136
  const page = source.pageTitle ?? source.pageUrl ?? "a page";
104
137
  return `${page}${source.quote ? `: "${source.quote}"` : ""}${source.verified === false ? " (not found in the page)" : ""}`;
105
138
  }
139
+ /** A person by name; "a teammate" when the door could not name them. Never the user id. */
140
+ const person = (name) => name ?? "a teammate";
141
+ const onDay = (at) => (at ? `, ${shortDate(at)}` : "");
142
+ /**
143
+ * Who handled an item, in the page drawer's words: who added it, who edited or reviewed or dismissed it, who closed it,
144
+ * who archived it. A line appears only when the answer has a person id for it (a model's item has no adder), and
145
+ * carries the name the door filled in, never the id.
146
+ */
147
+ export function handledByLines(item, timeline) {
148
+ const lines = [];
149
+ const human = item.origin === "human";
150
+ if (human && item.createdBy)
151
+ lines.push(`Added by ${person(item.createdByName)}${onDay(item.createdAt)}.`);
152
+ if (item.reviewStatus === "rejected" && item.reviewedBy)
153
+ lines.push(`Dismissed by ${person(item.reviewedByName)}${onDay(item.reviewedAt)}.`);
154
+ else if (item.reviewedBy && item.reviewStatus === "reviewed") {
155
+ const sameAsAdder = human && item.reviewedBy === item.createdBy && Math.abs(Date.parse(item.reviewedAt ?? "") - Date.parse(item.createdAt ?? "")) <= 1_000;
156
+ if (!sameAsAdder)
157
+ lines.push(`${human ? "Edited" : "Reviewed"} by ${person(item.reviewedByName)}${onDay(item.reviewedAt)}.`);
158
+ }
159
+ if (!timeline && item.status && item.status !== "open" && item.closedBy) {
160
+ const who = item.closedBy === "ai" ? "the model" : person(item.closedByName);
161
+ lines.push(item.status === "done" ? `Closed as done by ${who}${onDay(item.closedAt)}.` : `Dropped by ${who}${onDay(item.closedAt)}: the work no longer applied and was not done.`);
162
+ }
163
+ if (item.archivedAt && item.archivedBy)
164
+ lines.push(`Archived by ${person(item.archivedByName)}${onDay(item.archivedAt)}.`);
165
+ return lines;
166
+ }
167
+ /**
168
+ * What an add saved beyond its row, so a person can check it is their words (the help says to): the detail, cut to 200 characters, and the source
169
+ * (a link, a note, or both) a person gave. An add with neither prints nothing more.
170
+ */
171
+ export function savedTextLines(detail) {
172
+ const lines = [];
173
+ const text = (detail.item.detail ?? "").replace(/\s+/g, " ").trim();
174
+ if (text !== "")
175
+ lines.push(` Detail: ${[...text].length > 200 ? `${[...text].slice(0, 200).join("")}…` : text}`);
176
+ for (const source of (detail.evidence ?? []))
177
+ if (source.addedBy === "human")
178
+ lines.push(` Source: ${[source.url, source.note].filter(Boolean).join(" | ") || "(empty)"}`);
179
+ return lines;
180
+ }
106
181
  /** One item in full: the detail, the sources, every suggested change with its id, and what a sync changed and why. */
107
182
  export function itemDetailLines(detail, timeline, color) {
108
183
  const item = detail.item;
109
- const lines = [...itemRowLines(item, timeline, color)];
184
+ const lines = [...itemRowLines(item, timeline, color), ...handledByLines(item, timeline).map((line) => ` ${line}`)];
110
185
  if (!timeline && detail.timelineItem)
111
186
  lines.push(` From the meeting: ${detail.timelineItem.title}${detail.timelineItem.occurredAt ? ` (${detail.timelineItem.occurredAt})` : ""}`);
112
187
  if (timeline && detail.actions && detail.actions.length > 0)
@@ -124,6 +199,9 @@ export function itemDetailLines(detail, timeline, color) {
124
199
  for (const proposal of proposals) {
125
200
  const fields = Object.entries(proposal.fields).map(([name, value]) => `${name}: ${valueText(value, proposal.labels?.[name])}`);
126
201
  lines.push(` ${proposal.id} (${proposal.kind}): ${fields.join("; ") || "no field change"}`);
202
+ // Why the model suggested it: the page and quote it cited, or "not found in the page" when the quote was not in what it was handed.
203
+ for (const source of proposal.sources ?? [])
204
+ lines.push(` source: ${sourceLine(source)}`);
127
205
  }
128
206
  lines.push(` Decide one with: cockpit manager proposal <id> <proposal id> accept|dismiss|add-new --version <version>${timeline ? " --timeline" : ""}`);
129
207
  }
@@ -147,7 +225,7 @@ export function clientDetailLines(client, color) {
147
225
  lines.push(` ${client.storedPages} stored pages${client.incompletePages ? `, ${client.incompletePages} only partly handed over by Notion` : ""}`);
148
226
  const read = client.lastRead;
149
227
  if (read) {
150
- lines.push(` Last read: ${read.kind}, ${read.reason}, started ${read.startedAt}${read.finishedAt ? `, finished ${read.finishedAt}` : ", still running"}`);
228
+ lines.push(` Last read: ${read.kind}, ${read.reason}, started ${managerTime(read.startedAt)}${read.finishedAt ? `, finished ${managerTime(read.finishedAt)}` : ", still running"}`);
151
229
  lines.push(` ${read.pagesSeen} pages seen, ${read.pagesChanged} changed; ${read.opsApplied} changes applied, ${read.opsProposed} proposed, ${read.opsRejected} refused`);
152
230
  }
153
231
  else {
@@ -155,14 +233,34 @@ export function clientDetailLines(client, color) {
155
233
  }
156
234
  const rebuild = client.lastBriefRebuild;
157
235
  if (rebuild)
158
- lines.push(` Last Rebuild brief: ${rebuild.reason}, started ${rebuild.startedAt}, ${rebuild.pages} pages, ${rebuild.briefWritten ? "brief replaced" : "old brief kept"}`);
236
+ lines.push(` Last Rebuild brief: ${rebuild.reason}, started ${managerTime(rebuild.startedAt)}, ${rebuild.pages} pages, ${rebuild.briefWritten ? "brief replaced" : "old brief kept"}`);
159
237
  return lines;
160
238
  }
239
+ /** A summary check is not a read: what it decided about the card, from the receipt's `summary` (null: an older server, which does not say). */
240
+ function summaryRunLines(run) {
241
+ const check = run.summary;
242
+ const outcome = check
243
+ ? check.cardWritten
244
+ ? "Card written."
245
+ : `Card kept${check.keptReason ? `: ${keptReasonText(check.keptReason)}` : ", nothing written"}.`
246
+ : "This receipt does not say whether the card was written.";
247
+ return [
248
+ `summary check of "Where we are" (not a read): ${readReasonText(run.reason)}`,
249
+ ` started ${managerTime(run.startedAt)}${run.finishedAt ? `, finished ${managerTime(run.finishedAt)}` : ", still running"}`,
250
+ ` ${outcome}${check?.refusal ? ` ${summaryRefusalText(check.refusal)}` : ""}`,
251
+ ...(check && (check.promptVersion || check.trigger) ? [` ${[check.promptVersion ? `prompt ${check.promptVersion}` : null, check.trigger ? `started by ${summaryTriggerText(check.trigger)}` : null].filter(Boolean).join(", ")}`] : []),
252
+ ...(run.model ? [` model ${run.model}${run.tokensIn !== null ? `, ${run.tokensIn} tokens in, ${run.tokensOut ?? 0} out` : ""}`] : []),
253
+ ...(run.clientId ? [` client ${run.clientId}`] : []),
254
+ ` id ${run.id}`,
255
+ ];
256
+ }
161
257
  /** One run's receipt: what it read, what it changed, and why it ended. */
162
258
  export function runLines(run) {
259
+ if (run.kind === "summary")
260
+ return summaryRunLines(run);
163
261
  return [
164
262
  `${run.kind} run: ${run.reason}`,
165
- ` started ${run.startedAt}${run.finishedAt ? `, finished ${run.finishedAt}` : ", still running"}`,
263
+ ` started ${managerTime(run.startedAt)}${run.finishedAt ? `, finished ${managerTime(run.finishedAt)}` : ", still running"}`,
166
264
  ` ${run.pagesSeen} pages seen, ${run.pagesChanged} changed, ${run.pagesFetched} fetched, ${run.pagesArchived} archived; ${run.versionsConsumed} versions read`,
167
265
  ` ${run.opsApplied} changes applied, ${run.opsProposed} proposed, ${run.opsRejected} refused${run.batchNo !== null ? ` (batch ${run.batchNo})` : ""}`,
168
266
  ...(run.model ? [` model ${run.model}${run.tokensIn !== null ? `, ${run.tokensIn} tokens in, ${run.tokensOut ?? 0} out` : ""}`] : []),
@@ -24,7 +24,7 @@ export function planManagerRequest(command, clientId, newRequestId) {
24
24
  fields["next"] = command.next;
25
25
  if (command.blocker !== undefined)
26
26
  fields["blocker"] = command.blocker;
27
- return { method: "PATCH", path: `${BASE}/clients/${enc(clientId ?? "")}/summary`, body: fields, logFields: (a) => ({ fields: Object.keys(fields).filter((key) => key !== "expectedUpdatedAt"), source: stringField(a, "source"), bullets: arrayLength(a, "bullets") }) };
27
+ return { method: "PATCH", path: `${BASE}/clients/${enc(clientId ?? "")}/summary`, body: fields, logFields: (a) => ({ fields: Object.keys(fields).filter((key) => key !== "expectedUpdatedAt"), source: stringField(a, "source"), bullets: arrayLength(a, "bullets"), warnings: arrayLength(a, "warnings") }) };
28
28
  }
29
29
  case "run":
30
30
  return { method: "GET", path: `${BASE}/runs/${enc(command.runId ?? "")}`, logFields: () => ({}) };
@@ -68,6 +68,8 @@ export function planManagerRequest(command, clientId, newRequestId) {
68
68
  return write("PATCH", item, { ...seen, ...fields });
69
69
  }
70
70
  case "done": return write("POST", `${item}/status`, { ...seen, status: "done" });
71
+ // A dropped action was not done: the work no longer applies. Same door as done and reopen.
72
+ case "drop": return write("POST", `${item}/status`, { ...seen, status: "dropped" });
71
73
  case "reopen": return write("POST", `${item}/status`, { ...seen, status: "open" });
72
74
  case "link": return write("POST", `${item}/link`, { ...seen, timelineItemId: command.timelineItemId ?? null });
73
75
  case "approve": return write("POST", `${item}/review`, { ...seen, decision: "accept" });
@@ -102,8 +104,10 @@ function listQuery(command) {
102
104
  }
103
105
  function itemLog(table) {
104
106
  return (answer) => {
105
- const item = answer && typeof answer === "object" ? answer.item : undefined;
106
- return { table, item_id: item?.id ?? null, review_status: item?.reviewStatus ?? null };
107
+ const body = answer && typeof answer === "object" ? answer : undefined;
108
+ const item = body?.item;
109
+ // An add's `created` (false: a true retry, nothing written) is logged when the answer has it; an older server sends none.
110
+ return { table, item_id: item?.id ?? null, review_status: item?.reviewStatus ?? null, ...(typeof body?.created === "boolean" ? { created: body.created } : {}) };
107
111
  };
108
112
  }
109
113
  /** Home's log line: which parts it holds and their sizes, never their words. */