@detergent-software/conductor 1.0.1-dev.1 → 1.1.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.
Files changed (112) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +11 -0
  3. package/build/brief-cli.js.map +1 -1
  4. package/build/brief.js +24 -9
  5. package/build/brief.js.map +1 -1
  6. package/build/broker-cli.js +5 -1
  7. package/build/broker-cli.js.map +1 -1
  8. package/build/broker.js +19 -5
  9. package/build/broker.js.map +1 -1
  10. package/build/checkout-sync.js +265 -0
  11. package/build/checkout-sync.js.map +1 -0
  12. package/build/claude.js +3 -1
  13. package/build/claude.js.map +1 -1
  14. package/build/cli.js +114 -31
  15. package/build/cli.js.map +1 -1
  16. package/build/client.js +3 -1
  17. package/build/client.js.map +1 -1
  18. package/build/conditions.js +4 -2
  19. package/build/conditions.js.map +1 -1
  20. package/build/config.js +13 -1
  21. package/build/config.js.map +1 -1
  22. package/build/console.js +45 -8
  23. package/build/console.js.map +1 -1
  24. package/build/credential.js +4 -1
  25. package/build/credential.js.map +1 -1
  26. package/build/daemon.js +56 -7
  27. package/build/daemon.js.map +1 -1
  28. package/build/events.js +4 -1
  29. package/build/events.js.map +1 -1
  30. package/build/help.js +85 -42
  31. package/build/help.js.map +1 -1
  32. package/build/history.js +216 -52
  33. package/build/history.js.map +1 -1
  34. package/build/inbox.js +23 -10
  35. package/build/inbox.js.map +1 -1
  36. package/build/manager.js +84 -18
  37. package/build/manager.js.map +1 -1
  38. package/build/mod.js +3 -1
  39. package/build/mod.js.map +1 -1
  40. package/build/note.js +12 -5
  41. package/build/note.js.map +1 -1
  42. package/build/notes.js +8 -5
  43. package/build/notes.js.map +1 -1
  44. package/build/notify.js +111 -24
  45. package/build/notify.js.map +1 -1
  46. package/build/overrides.js +24 -6
  47. package/build/overrides.js.map +1 -1
  48. package/build/profile.js.map +1 -1
  49. package/build/pushover.js +4 -1
  50. package/build/pushover.js.map +1 -1
  51. package/build/queue.js +18 -3
  52. package/build/queue.js.map +1 -1
  53. package/build/registry.js.map +1 -1
  54. package/build/rule-actions.js +173 -36
  55. package/build/rule-actions.js.map +1 -1
  56. package/build/rule-input.js +3 -1
  57. package/build/rule-input.js.map +1 -1
  58. package/build/rules-service.js +41 -11
  59. package/build/rules-service.js.map +1 -1
  60. package/build/rules.js +86 -14
  61. package/build/rules.js.map +1 -1
  62. package/build/scheduler.js +48 -8
  63. package/build/scheduler.js.map +1 -1
  64. package/build/server.js +40 -10
  65. package/build/server.js.map +1 -1
  66. package/build/spend.js.map +1 -1
  67. package/build/store.js +71 -16
  68. package/build/store.js.map +1 -1
  69. package/build/token.js +3 -1
  70. package/build/token.js.map +1 -1
  71. package/build/trackers.js +3 -1
  72. package/build/trackers.js.map +1 -1
  73. package/build/upgrade.js.map +1 -1
  74. package/build/wait.js +3 -1
  75. package/build/wait.js.map +1 -1
  76. package/build/watchers/ado-api.js +5 -2
  77. package/build/watchers/ado-api.js.map +1 -1
  78. package/build/watchers/ado.js +18 -6
  79. package/build/watchers/ado.js.map +1 -1
  80. package/build/watchers/boards-brief.js +9 -3
  81. package/build/watchers/boards-brief.js.map +1 -1
  82. package/build/watchers/boards-queue.js +10 -5
  83. package/build/watchers/boards-queue.js.map +1 -1
  84. package/build/watchers/boards-writes.js +11 -3
  85. package/build/watchers/boards-writes.js.map +1 -1
  86. package/build/watchers/boards.js +12 -3
  87. package/build/watchers/boards.js.map +1 -1
  88. package/build/watchers/conflicts.js +11 -3
  89. package/build/watchers/conflicts.js.map +1 -1
  90. package/build/watchers/github.js +6 -2
  91. package/build/watchers/github.js.map +1 -1
  92. package/build/watchers/health.js.map +1 -1
  93. package/build/watchers/index.js +22 -4
  94. package/build/watchers/index.js.map +1 -1
  95. package/build/watchers/linear-api.js +1 -1
  96. package/build/watchers/linear-api.js.map +1 -1
  97. package/build/watchers/linear-labels.js +3 -1
  98. package/build/watchers/linear-labels.js.map +1 -1
  99. package/build/watchers/linear-queue.js +10 -4
  100. package/build/watchers/linear-queue.js.map +1 -1
  101. package/build/watchers/linear.js +13 -4
  102. package/build/watchers/linear.js.map +1 -1
  103. package/build/watchers/npm.js.map +1 -1
  104. package/build/watchers/source.js +1 -1
  105. package/build/watchers/source.js.map +1 -1
  106. package/build/why.js +7 -3
  107. package/build/why.js.map +1 -1
  108. package/build/worktrees.js +2 -1
  109. package/build/worktrees.js.map +1 -1
  110. package/mod/hooks/model.ts +90 -25
  111. package/mod/hooks/register.tsx +172 -62
  112. package/package.json +11 -12
package/build/history.js CHANGED
@@ -7,7 +7,16 @@ import { ACTIVE_STATUSES } from "./store.js";
7
7
  // already (phase notes, waits, worker events, inbox items, rule events, spend) and writes nothing.
8
8
  // The notes that start a phase of work-issue's lifecycle. A phase lasts until the item's next one;
9
9
  // Handoff, Progress and Resumed notes fall inside a phase. Landed ends the item.
10
- export const LIFECYCLE_PHASES = ['Started', 'Done when', 'Repro', 'Plan', 'Plan approved', 'Verified', 'PR opened', 'Landed'];
10
+ export const LIFECYCLE_PHASES = [
11
+ 'Started',
12
+ 'Done when',
13
+ 'Repro',
14
+ 'Plan',
15
+ 'Plan approved',
16
+ 'Verified',
17
+ 'PR opened',
18
+ 'Landed',
19
+ ];
11
20
  const LANDED = 'Landed';
12
21
  const FAILURE_KINDS = [
13
22
  'worker.launch-failed',
@@ -48,22 +57,47 @@ const isDay = (t) => DATE.test(t) || t === 'today' || t === 'yesterday';
48
57
  const MINUTE_MS = 60_000;
49
58
  const DAY_MS = 24 * 60 * MINUTE_MS;
50
59
  const SPAN_UNITS = { d: DAY_MS, h: 60 * MINUTE_MS, m: MINUTE_MS };
51
- const durations = z.object({ count: z.number(), maxMinutes: z.number(), medianMinutes: z.number(), totalMinutes: z.number() });
60
+ const durations = z.object({
61
+ count: z.number(),
62
+ maxMinutes: z.number(),
63
+ medianMinutes: z.number(),
64
+ totalMinutes: z.number(),
65
+ });
52
66
  const counted = z.object({ count: z.number(), items: z.array(z.string()), kind: z.string() });
53
67
  export const historySchema = z.object({
54
68
  failures: z.array(counted),
55
69
  // Handoff notes by their kind (jason, npm-otp, merge, blocked …).
56
70
  handoffs: z.array(counted),
57
- inbox: z.array(z.object({ kind: z.string(), open: z.number(), raised: z.number(), resolve: durations, resolvedBy: z.record(z.string(), z.number()) })),
71
+ inbox: z.array(z.object({
72
+ kind: z.string(),
73
+ open: z.number(),
74
+ raised: z.number(),
75
+ resolve: durations,
76
+ resolvedBy: z.record(z.string(), z.number()),
77
+ })),
58
78
  // Time in each phase, per item, by the item's plan tier ("?" without one).
59
79
  phases: z.array(durations.extend({ phase: z.string(), tier: z.string() })),
60
80
  // Each plan approved in the window, or still waiting at its end: from its newest Plan note.
61
- plans: z.array(z.object({ approvedAt: z.string().nullable(), item: z.string(), minutes: z.number(), planAt: z.string(), project: z.string(), revisions: z.number(), tier: z.string() })),
81
+ plans: z.array(z.object({
82
+ approvedAt: z.string().nullable(),
83
+ item: z.string(),
84
+ minutes: z.number(),
85
+ planAt: z.string(),
86
+ project: z.string(),
87
+ revisions: z.number(),
88
+ tier: z.string(),
89
+ })),
62
90
  range: z.object({ project: z.string().nullable(), since: z.string(), until: z.string() }),
63
91
  // Items launched more than once in the window, and rule retries (worker.revived).
64
92
  retries: z.object({ items: z.array(z.object({ item: z.string(), launches: z.number() })), revived: z.number() }),
65
93
  rules: z.array(z.object({ done: z.number(), failed: z.number(), fired: z.number(), ref: z.string() })),
66
- shipped: z.array(z.object({ item: z.string(), landedAt: z.string(), leadMinutes: z.number().nullable(), project: z.string(), title: z.string().nullable() })),
94
+ shipped: z.array(z.object({
95
+ item: z.string(),
96
+ landedAt: z.string(),
97
+ leadMinutes: z.number().nullable(),
98
+ project: z.string(),
99
+ title: z.string().nullable(),
100
+ })),
67
101
  slots: z.object({
68
102
  average: z.number(),
69
103
  // Most running time first: a worker left attached and idle holds its slot all the while.
@@ -85,7 +119,13 @@ export const historySchema = z.object({
85
119
  stalls: z.array(counted.extend({ maxMinutes: z.number() })),
86
120
  totals: z.object({ daemonStarts: z.number(), items: z.number(), launches: z.number(), notes: z.number() }),
87
121
  // Items whose newest lifecycle note at the window's end isn't Landed.
88
- unfinished: z.array(z.object({ item: z.string(), phase: z.string(), project: z.string(), since: z.string(), title: z.string().nullable() })),
122
+ unfinished: z.array(z.object({
123
+ item: z.string(),
124
+ phase: z.string(),
125
+ project: z.string(),
126
+ since: z.string(),
127
+ title: z.string().nullable(),
128
+ })),
89
129
  // Waits registered in the window by kind; open ones count up to the window's end.
90
130
  waits: z.array(durations.extend({ closed: z.number(), kind: z.string(), open: z.number() })),
91
131
  });
@@ -114,7 +154,10 @@ export function historyLines(r) {
114
154
  lines.push(r.waits.length === 0 ? 'waits: none' : 'waits:');
115
155
  r.waits.forEach((w) => lines.push(` ${w.kind.padEnd(8)} ${w.count} · median ${minutesText(w.medianMinutes)} · max ${minutesText(w.maxMinutes)} · total ${minutesText(w.totalMinutes)}${w.open > 0 ? ` · ${w.open} open` : ''}`));
116
156
  for (const tier of unique(r.phases.map((p) => p.tier))) {
117
- lines.push(`phases (tier ${tier}): ${r.phases.filter((p) => p.tier === tier).map((p) => `${p.phase} ${minutesText(p.medianMinutes)}`).join(' · ')} (median per item)`);
157
+ lines.push(`phases (tier ${tier}): ${r.phases
158
+ .filter((p) => p.tier === tier)
159
+ .map((p) => `${p.phase} ${minutesText(p.medianMinutes)}`)
160
+ .join(' · ')} (median per item)`);
118
161
  }
119
162
  lines.push(`stalls: ${r.stalls.length === 0 ? 'none' : r.stalls.map((s) => `${s.kind} ${s.count} (max ${minutesText(s.maxMinutes)})`).join(', ')}`);
120
163
  lines.push(`handoffs: ${r.handoffs.length === 0 ? 'none' : r.handoffs.map((h) => `${h.kind} ${h.count}`).join(', ')}`);
@@ -124,10 +167,25 @@ export function historyLines(r) {
124
167
  }
125
168
  lines.push(`inbox: ${r.inbox.length === 0 ? 'none' : r.inbox.map((i) => `${i.kind} ${i.raised}${i.open > 0 ? ` (${i.open} open)` : ''}`).join(', ')}`);
126
169
  lines.push(`slots: peak ${r.slots.peak}, average ${r.slots.average.toFixed(1)}, running ${r.slots.runningHours.toFixed(1)} h, parked ${r.slots.parkedHours.toFixed(1)} h` +
127
- (r.slots.byItem.length > 0 ? `; longest held: ${r.slots.byItem.slice(0, 3).map((s) => `${s.item} ${s.runningHours.toFixed(1)} h`).join(', ')}` : ''));
170
+ (r.slots.byItem.length > 0
171
+ ? `; longest held: ${r.slots.byItem
172
+ .slice(0, 3)
173
+ .map((s) => `${s.item} ${s.runningHours.toFixed(1)} h`)
174
+ .join(', ')}`
175
+ : ''));
128
176
  lines.push(`spend (${r.spend.fromDay}..${r.spend.toDay}): ${formatTokens(r.spend.tokens)} tokens + ${formatTokens(r.spend.cacheRead)} cache reads` +
129
- (r.spend.byItem.length > 0 ? `; most: ${r.spend.byItem.slice(0, 5).map((s) => `${s.item} ${formatTokens(s.tokens)}`).join(', ')}` : ''));
130
- lines.push(`rules: ${r.rules.length === 0 ? 'none fired' : r.rules.slice(0, 5).map((x) => `${x.ref} ${x.fired}`).join(', ')}`);
177
+ (r.spend.byItem.length > 0
178
+ ? `; most: ${r.spend.byItem
179
+ .slice(0, 5)
180
+ .map((s) => `${s.item} ${formatTokens(s.tokens)}`)
181
+ .join(', ')}`
182
+ : ''));
183
+ lines.push(`rules: ${r.rules.length === 0
184
+ ? 'none fired'
185
+ : r.rules
186
+ .slice(0, 5)
187
+ .map((x) => `${x.ref} ${x.fired}`)
188
+ .join(', ')}`);
131
189
  if (r.unfinished.length > 0)
132
190
  lines.push(`unfinished: ${r.unfinished.map((u) => `${u.item} (${u.phase})`).join(', ')}`);
133
191
  return lines;
@@ -147,7 +205,9 @@ export function historyReport(store, { project = null, since, until }) {
147
205
  ...events.flatMap((e) => (e.kind === 'note.posted' && e.subject ? [e.subject.toUpperCase()] : [])),
148
206
  ...workers.map((w) => w.item.toUpperCase()),
149
207
  ]);
150
- const notes = subjects.length === 0 ? [] : store.eventsBetween({ since: null, until }, { ...scope, kinds: ['note.posted'], subjects }).flatMap(toNote);
208
+ const notes = subjects.length === 0
209
+ ? []
210
+ : store.eventsBetween({ since: null, until }, { ...scope, kinds: ['note.posted'], subjects }).flatMap(toNote);
151
211
  const byItem = groupBy(notes, (n) => key(n.project, n.item));
152
212
  const inWindow = (at) => at >= since && at < until;
153
213
  const phaseMinutes = new Map();
@@ -190,7 +250,15 @@ export function historyReport(store, { project = null, since, until }) {
190
250
  }
191
251
  else if (n.phase === 'Plan approved' && plan) {
192
252
  if (inWindow(n.at))
193
- plans.push({ approvedAt: n.at, item: n.item, minutes: minutesBetween(plan.at, n.at), planAt: plan.at, project: n.project, revisions, tier });
253
+ plans.push({
254
+ approvedAt: n.at,
255
+ item: n.item,
256
+ minutes: minutesBetween(plan.at, n.at),
257
+ planAt: plan.at,
258
+ project: n.project,
259
+ revisions,
260
+ tier,
261
+ });
194
262
  plan = null;
195
263
  revisions = 0;
196
264
  }
@@ -202,7 +270,15 @@ export function historyReport(store, { project = null, since, until }) {
202
270
  }
203
271
  }
204
272
  if (plan && end === untilMs) {
205
- plans.push({ approvedAt: null, item: plan.item, minutes: minutesBetween(plan.at, until), planAt: plan.at, project: plan.project, revisions, tier });
273
+ plans.push({
274
+ approvedAt: null,
275
+ item: plan.item,
276
+ minutes: minutesBetween(plan.at, until),
277
+ planAt: plan.at,
278
+ project: plan.project,
279
+ revisions,
280
+ tier,
281
+ });
206
282
  }
207
283
  const last = lifecycle.at(-1);
208
284
  const landed = lifecycle.findLast((n) => n.phase === LANDED);
@@ -217,7 +293,13 @@ export function historyReport(store, { project = null, since, until }) {
217
293
  });
218
294
  }
219
295
  if (last && last.phase !== LANDED) {
220
- unfinished.push({ item: last.item, phase: last.phase, project: last.project, since: last.at, title: titleOf(last.project, last.item) });
296
+ unfinished.push({
297
+ item: last.item,
298
+ phase: last.phase,
299
+ project: last.project,
300
+ since: last.at,
301
+ title: titleOf(last.project, last.item),
302
+ });
221
303
  }
222
304
  }
223
305
  const phases = [...phaseMinutes].map(([k, minutes]) => {
@@ -291,7 +373,9 @@ export function parseWhen(text, now) {
291
373
  return new Date(Number(date[1]), Number(date[2]) - 1, Number(date[3]));
292
374
  const at = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(t) ? new Date(t) : null;
293
375
  if (!at || Number.isNaN(at.getTime())) {
294
- throw new ConductorError(`"${text}" is not a time`, ['use today, yesterday, a date (2026-10-10), a date and time (2026-10-10T09:00), or a span such as 24h or 7d']);
376
+ throw new ConductorError(`"${text}" is not a time`, [
377
+ 'use today, yesterday, a date (2026-10-10), a date and time (2026-10-10T09:00), or a span such as 24h or 7d',
378
+ ]);
295
379
  }
296
380
  return at;
297
381
  }
@@ -313,22 +397,57 @@ export function retroMarkdown(r) {
313
397
  const planStats = summarize(approved.map((p) => p.minutes));
314
398
  const waitTotal = r.waits.reduce((n, w) => n + w.totalMinutes, 0);
315
399
  out.push(`- **Shipped:** ${r.shipped.length} of ${r.totals.items} items worked (${r.totals.launches} launches, ${r.totals.notes} phase notes, ${r.totals.daemonStarts} daemon starts).`, `- **Plan latency:** ${approved.length === 0 ? 'no plan approved' : `median ${minutesText(planStats.medianMinutes)}, max ${minutesText(planStats.maxMinutes)} over ${approved.length} plans`}${r.plans.some((p) => p.approvedAt === null) ? `; ${r.plans.filter((p) => p.approvedAt === null).length} still waiting` : ''}.`, `- **Waiting:** ${minutesText(waitTotal)} across ${r.waits.reduce((n, w) => n + w.count, 0)} waits${r.waits[0] ? `, most on ${r.waits[0].kind} (${minutesText(r.waits[0].totalMinutes)})` : ''}.`, `- **Stalls:** ${r.stalls.reduce((n, s) => n + s.count, 0)} stall events, ${r.handoffs.reduce((n, h) => n + h.count, 0)} handoffs, ${r.failures.reduce((n, f) => n + f.count, 0)} failures.`, `- **Spend:** ${formatTokens(r.spend.tokens)} tokens (input, output and cache writes) and ${formatTokens(r.spend.cacheRead)} cache reads, ${r.spend.fromDay} to ${r.spend.toDay}.`, `- **Slots:** peak ${r.slots.peak} running, average ${r.slots.average.toFixed(1)}; ${r.slots.runningHours.toFixed(1)} h running, ${r.slots.parkedHours.toFixed(1)} h parked${r.slots.byItem[0] ? `; longest held by ${r.slots.byItem[0].item} (${r.slots.byItem[0].runningHours.toFixed(1)} h)` : ''}.`, '');
316
- section(out, 'What shipped', r.shipped.length === 0 ? ['Nothing landed in this window.'] : table(['Item', 'Title', 'Landed', 'Started → Landed'], r.shipped.map((s) => [s.item, s.title ?? '', localTime(s.landedAt), s.leadMinutes === null ? '' : minutesText(s.leadMinutes)])));
317
- section(out, 'Where time went', r.phases.length === 0 ? ['No lifecycle notes in this window.'] : [
318
- 'Time from each lifecycle note to the item\'s next one, inside the window, per item. A phase is named by the note that starts it: *Plan* is waiting for approval, *Plan approved* is implementing, *Verified* is opening the PR, *PR opened* is review through merge.',
319
- '',
320
- ...table(['Tier', 'Phase', 'Items', 'Median', 'Max', 'Total'], r.phases.map((p) => [p.tier, p.phase, String(p.count), minutesText(p.medianMinutes), minutesText(p.maxMinutes), minutesText(p.totalMinutes)])),
321
- ]);
322
- section(out, 'Plan latency', r.plans.length === 0 ? ['No plans in this window.'] : [
323
- 'From the newest Plan note to Plan approved (a revision restarts it).',
324
- '',
325
- ...table(['Item', 'Tier', 'Plan', 'Approved', 'Waited', 'Revisions'], r.plans.map((p) => [p.item, p.tier, localTime(p.planAt), p.approvedAt === null ? 'still waiting' : localTime(p.approvedAt), minutesText(p.minutes), String(p.revisions)])),
326
- ]);
327
- section(out, 'Waits', r.waits.length === 0 ? ['No waits registered in this window.'] : [
328
- 'Waits registered in the window, by what they held up. A PR event that resolves several waits counts once.',
329
- '',
330
- ...table(['Kind', 'Waits', 'Median', 'Max', 'Total', 'Open', 'Closed'], r.waits.map((w) => [w.kind, String(w.count), minutesText(w.medianMinutes), minutesText(w.maxMinutes), minutesText(w.totalMinutes), String(w.open), String(w.closed)])),
331
- ]);
400
+ section(out, 'What shipped', r.shipped.length === 0
401
+ ? ['Nothing landed in this window.']
402
+ : table(['Item', 'Title', 'Landed', 'Started → Landed'], r.shipped.map((s) => [
403
+ s.item,
404
+ s.title ?? '',
405
+ localTime(s.landedAt),
406
+ s.leadMinutes === null ? '' : minutesText(s.leadMinutes),
407
+ ])));
408
+ section(out, 'Where time went', r.phases.length === 0
409
+ ? ['No lifecycle notes in this window.']
410
+ : [
411
+ "Time from each lifecycle note to the item's next one, inside the window, per item. A phase is named by the note that starts it: *Plan* is waiting for approval, *Plan approved* is implementing, *Verified* is opening the PR, *PR opened* is review through merge.",
412
+ '',
413
+ ...table(['Tier', 'Phase', 'Items', 'Median', 'Max', 'Total'], r.phases.map((p) => [
414
+ p.tier,
415
+ p.phase,
416
+ String(p.count),
417
+ minutesText(p.medianMinutes),
418
+ minutesText(p.maxMinutes),
419
+ minutesText(p.totalMinutes),
420
+ ])),
421
+ ]);
422
+ section(out, 'Plan latency', r.plans.length === 0
423
+ ? ['No plans in this window.']
424
+ : [
425
+ 'From the newest Plan note to Plan approved (a revision restarts it).',
426
+ '',
427
+ ...table(['Item', 'Tier', 'Plan', 'Approved', 'Waited', 'Revisions'], r.plans.map((p) => [
428
+ p.item,
429
+ p.tier,
430
+ localTime(p.planAt),
431
+ p.approvedAt === null ? 'still waiting' : localTime(p.approvedAt),
432
+ minutesText(p.minutes),
433
+ String(p.revisions),
434
+ ])),
435
+ ]);
436
+ section(out, 'Waits', r.waits.length === 0
437
+ ? ['No waits registered in this window.']
438
+ : [
439
+ 'Waits registered in the window, by what they held up. A PR event that resolves several waits counts once.',
440
+ '',
441
+ ...table(['Kind', 'Waits', 'Median', 'Max', 'Total', 'Open', 'Closed'], r.waits.map((w) => [
442
+ w.kind,
443
+ String(w.count),
444
+ minutesText(w.medianMinutes),
445
+ minutesText(w.maxMinutes),
446
+ minutesText(w.totalMinutes),
447
+ String(w.open),
448
+ String(w.closed),
449
+ ])),
450
+ ]);
332
451
  const stalled = [
333
452
  ...r.stalls.map((s) => `- Stalled (${s.kind}): ${s.count}× on ${s.items.join(', ')}, longest ${minutesText(s.maxMinutes)}.`),
334
453
  ...r.handoffs.map((h) => `- Handoff · ${h.kind}: ${h.count}× on ${h.items.join(', ')}.`),
@@ -338,15 +457,21 @@ export function retroMarkdown(r) {
338
457
  ...r.unfinished.map((u) => `- Unfinished: ${u.item} in ${u.phase} since ${localTime(u.since)}${u.title ? ` (${u.title})` : ''}.`),
339
458
  ];
340
459
  section(out, 'What stalled', stalled.length === 0 ? ['Nothing stalled, failed or was left unfinished.'] : stalled);
341
- section(out, 'Inbox', r.inbox.length === 0 ? ['Nothing was raised for Jason.'] : table(['Kind', 'Raised', 'Open', 'Median to resolve', 'Max', 'Resolved by'], r.inbox.map((i) => [
342
- i.kind,
343
- String(i.raised),
344
- String(i.open),
345
- i.resolve.count === 0 ? '' : minutesText(i.resolve.medianMinutes),
346
- i.resolve.count === 0 ? '' : minutesText(i.resolve.maxMinutes),
347
- Object.entries(i.resolvedBy).map(([how, n]) => `${how} ${n}`).join(', '),
348
- ])));
349
- section(out, 'Rules', r.rules.length === 0 ? ['No rule fired.'] : table(['Rule', 'Fired', 'Done', 'Failed'], r.rules.map((x) => [x.ref, String(x.fired), String(x.done), String(x.failed)])));
460
+ section(out, 'Inbox', r.inbox.length === 0
461
+ ? ['Nothing was raised for Jason.']
462
+ : table(['Kind', 'Raised', 'Open', 'Median to resolve', 'Max', 'Resolved by'], r.inbox.map((i) => [
463
+ i.kind,
464
+ String(i.raised),
465
+ String(i.open),
466
+ i.resolve.count === 0 ? '' : minutesText(i.resolve.medianMinutes),
467
+ i.resolve.count === 0 ? '' : minutesText(i.resolve.maxMinutes),
468
+ Object.entries(i.resolvedBy)
469
+ .map(([how, n]) => `${how} ${n}`)
470
+ .join(', '),
471
+ ])));
472
+ section(out, 'Rules', r.rules.length === 0
473
+ ? ['No rule fired.']
474
+ : table(['Rule', 'Fired', 'Done', 'Failed'], r.rules.map((x) => [x.ref, String(x.fired), String(x.done), String(x.failed)])));
350
475
  section(out, 'Spend', [
351
476
  `Kept per local day, so this covers ${r.spend.fromDay} to ${r.spend.toDay}. Tokens are input, output and cache writes; cache reads (about a tenth of the price) are their own column.`,
352
477
  '',
@@ -355,11 +480,13 @@ export function retroMarkdown(r) {
355
480
  ...table(['Item', 'Tokens', 'Cache reads'], r.spend.byItem.slice(0, 15).map((s) => [s.item, formatTokens(s.tokens), formatTokens(s.cacheRead)])),
356
481
  ]);
357
482
  section(out, 'Slots', [
358
- 'A slot is held as the scheduler counts it: an active worker that isn\'t parked, including one attached and idle, or stopped unexpectedly and not yet retired.',
483
+ "A slot is held as the scheduler counts it: an active worker that isn't parked, including one attached and idle, or stopped unexpectedly and not yet retired.",
359
484
  '',
360
485
  ...table(['Project', 'Running', 'Parked'], r.slots.byProject.map((p) => [p.project, `${p.runningHours.toFixed(1)} h`, `${p.parkedHours.toFixed(1)} h`])),
361
486
  '',
362
- ...table(['Item', 'Running', 'Parked'], r.slots.byItem.slice(0, 10).map((s) => [s.item, `${s.runningHours.toFixed(1)} h`, `${s.parkedHours.toFixed(1)} h`])),
487
+ ...table(['Item', 'Running', 'Parked'], r.slots.byItem
488
+ .slice(0, 10)
489
+ .map((s) => [s.item, `${s.runningHours.toFixed(1)} h`, `${s.parkedHours.toFixed(1)} h`])),
363
490
  ]);
364
491
  return `${out.join('\n').trimEnd()}\n`;
365
492
  }
@@ -403,7 +530,11 @@ function checkedWindow(found, now, what) {
403
530
  }
404
531
  function countBy(list, kindOf) {
405
532
  return [...groupBy(list, kindOf)]
406
- .map(([kind, all]) => ({ count: all.length, items: unique(all.flatMap((e) => (e.subject ? [e.subject.toUpperCase()] : []))), kind }))
533
+ .map(([kind, all]) => ({
534
+ count: all.length,
535
+ items: unique(all.flatMap((e) => (e.subject ? [e.subject.toUpperCase()] : []))),
536
+ kind,
537
+ }))
407
538
  .sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
408
539
  }
409
540
  function dayStart(date) {
@@ -501,7 +632,9 @@ function section(out, title, body) {
501
632
  // Running and parked time, as the scheduler counts slots, integrated over the window.
502
633
  function slotUse(store, workers, sinceMs, untilMs, until) {
503
634
  const workerOf = new Map(workers.map((w) => [w.id, w]));
504
- const events = workers.length === 0 ? [] : store.eventsBetween({ since: null, until }, { kinds: Object.keys(SLOT_EVENTS), workers: workers.map((w) => w.id) });
635
+ const events = workers.length === 0
636
+ ? []
637
+ : store.eventsBetween({ since: null, until }, { kinds: Object.keys(SLOT_EVENTS), workers: workers.map((w) => w.id) });
505
638
  const state = new Map();
506
639
  const byProject = new Map();
507
640
  const byItem = new Map();
@@ -552,7 +685,9 @@ function slotUse(store, workers, sinceMs, untilMs, until) {
552
685
  return { item, parkedHours: toHours(h.parked), project, runningHours: toHours(h.running) };
553
686
  })
554
687
  .sort((a, b) => b.runningHours - a.runningHours || a.item.localeCompare(b.item)),
555
- byProject: [...byProject].map(([project, h]) => ({ parkedHours: toHours(h.parked), project, runningHours: toHours(h.running) })).sort((a, b) => a.project.localeCompare(b.project)),
688
+ byProject: [...byProject]
689
+ .map(([project, h]) => ({ parkedHours: toHours(h.parked), project, runningHours: toHours(h.running) }))
690
+ .sort((a, b) => a.project.localeCompare(b.project)),
556
691
  parkedHours: toHours(parkedMs),
557
692
  peak,
558
693
  runningHours: toHours(runningMs),
@@ -571,7 +706,9 @@ function spendStats(store, since, until, inProject) {
571
706
  byItem: [...groupBy(rows, (r) => key(r.project, r.item))]
572
707
  .map(([, list]) => ({ item: list[0]?.item.toUpperCase() ?? '', project: list[0]?.project ?? '', ...sum(list) }))
573
708
  .sort((a, b) => b.tokens - a.tokens),
574
- byProject: [...groupBy(rows, (r) => r.project)].map(([p, list]) => ({ project: p, ...sum(list) })).sort((a, b) => b.tokens - a.tokens),
709
+ byProject: [...groupBy(rows, (r) => r.project)]
710
+ .map(([p, list]) => ({ project: p, ...sum(list) }))
711
+ .sort((a, b) => b.tokens - a.tokens),
575
712
  ...sum(rows),
576
713
  fromDay,
577
714
  toDay,
@@ -582,24 +719,49 @@ function stallStats(events) {
582
719
  const data = e.kind === 'worker.stalled' ? stallData.safeParse(e.data).data : undefined;
583
720
  return data ? [{ kind: data.kind, minutes: data.minutes ?? 0, subject: e.subject }] : [];
584
721
  });
585
- return countBy(stalls, (s) => s.kind).map((c) => ({ ...c, maxMinutes: Math.max(0, ...stalls.filter((s) => s.kind === c.kind).map((s) => s.minutes)) }));
722
+ return countBy(stalls, (s) => s.kind).map((c) => ({
723
+ ...c,
724
+ maxMinutes: Math.max(0, ...stalls.filter((s) => s.kind === c.kind).map((s) => s.minutes)),
725
+ }));
586
726
  }
587
727
  function summarize(minutes) {
588
728
  const sorted = [...minutes].sort((a, b) => a - b);
589
729
  const mid = Math.floor(sorted.length / 2);
590
- const median = sorted.length === 0 ? 0 : sorted.length % 2 === 1 ? (sorted[mid] ?? 0) : ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
730
+ const median = sorted.length === 0
731
+ ? 0
732
+ : sorted.length % 2 === 1
733
+ ? (sorted[mid] ?? 0)
734
+ : ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
591
735
  const round = (n) => Math.round(n * 10) / 10;
592
- return { count: sorted.length, maxMinutes: round(sorted.at(-1) ?? 0), medianMinutes: round(median), totalMinutes: round(sorted.reduce((n, m) => n + m, 0)) };
736
+ return {
737
+ count: sorted.length,
738
+ maxMinutes: round(sorted.at(-1) ?? 0),
739
+ medianMinutes: round(median),
740
+ totalMinutes: round(sorted.reduce((n, m) => n + m, 0)),
741
+ };
593
742
  }
594
743
  function table(head, rows) {
595
744
  const cell = (s) => s.replaceAll('|', '\\|');
596
- return [`| ${head.join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`, ...rows.map((r) => `| ${r.map(cell).join(' | ')} |`)];
745
+ return [
746
+ `| ${head.join(' | ')} |`,
747
+ `| ${head.map(() => '---').join(' | ')} |`,
748
+ ...rows.map((r) => `| ${r.map(cell).join(' | ')} |`),
749
+ ];
597
750
  }
598
751
  function toNote(e) {
599
752
  const data = noteData.safeParse(e.data).data;
600
753
  if (!data || !e.subject || !e.project)
601
754
  return [];
602
- return [{ at: e.at, item: e.subject.toUpperCase(), kind: data.kind ?? null, phase: data.phase, project: e.project, tier: data.tier ?? null }];
755
+ return [
756
+ {
757
+ at: e.at,
758
+ item: e.subject.toUpperCase(),
759
+ kind: data.kind ?? null,
760
+ phase: data.phase,
761
+ project: e.project,
762
+ tier: data.tier ?? null,
763
+ },
764
+ ];
603
765
  }
604
766
  function unique(list) {
605
767
  return [...new Set(list)];
@@ -616,7 +778,9 @@ function waitStats(waits, until) {
616
778
  return true;
617
779
  });
618
780
  // A closed wait's resolution says nothing about what it waited on: its condition does.
619
- return [...groupBy(kept, (w) => waitKind(w.condition, w.resolution !== null && CLOSED_RESOLUTIONS.has(w.resolution) ? null : w.resolution))]
781
+ return [
782
+ ...groupBy(kept, (w) => waitKind(w.condition, w.resolution !== null && CLOSED_RESOLUTIONS.has(w.resolution) ? null : w.resolution)),
783
+ ]
620
784
  .map(([kind, list]) => {
621
785
  // A wait resolved (or closed) after the window was still open at its end, and counts up to it.
622
786
  const endOf = (w) => (w.resolvedAt !== null && w.resolvedAt < until ? w.resolvedAt : until);