@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.
- package/dist/commands/cal-view-settings.js +61 -0
- package/dist/commands/cal.js +4 -0
- package/dist/commands/local-arg-values.js +90 -19
- package/dist/commands/local-args-tower-cal.js +52 -3
- package/dist/commands/local-args-tower-manager.js +128 -28
- package/dist/commands/local-help-commands-tower-ops.js +32 -18
- package/dist/commands/local-help-commands-tower.js +2 -2
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/manager-fields-file.js +208 -0
- package/dist/commands/manager-hints.js +88 -0
- package/dist/commands/manager-limits.json +269 -0
- package/dist/commands/manager-read-reasons.js +78 -0
- package/dist/commands/manager-render-home.js +213 -37
- package/dist/commands/manager-render.js +119 -21
- package/dist/commands/manager-requests.js +7 -3
- package/dist/commands/manager.js +67 -43
- package/dist/commands/public-root.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
|
97
|
+
return `${SUMMARY_SOURCE_WORDS.model}, ${at}.`;
|
|
75
98
|
if (summary.source === "computed")
|
|
76
|
-
return
|
|
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
|
|
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)}
|
|
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 "
|
|
121
|
-
// the review queue's size (`review.total
|
|
122
|
-
|
|
123
|
-
const
|
|
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 (
|
|
136
|
-
lines.push(
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
/**
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
lines.push("",
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
106
|
-
|
|
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. */
|