crewforth 3.1.0-rc.3 → 3.1.0-rc.4

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.
@@ -22,6 +22,7 @@ import { typeOf, roleName, shownType } from './names.js';
22
22
  export const SIZE = {
23
23
  padX: 24, padY: 16, // the canvas's own margin
24
24
  colGap: 56, // between one depth and the next
25
+ waveGap: 148, // between one wave and the next: room for the words on an inferred wire
25
26
  rowGap: 12, // between siblings
26
27
  session: { w: 200, h: 84 },
27
28
  card: { w: 248, h: 64 }, // an agent, and a folded group
@@ -48,7 +49,7 @@ export const AUTO = {
48
49
  stackAt: 3,
49
50
  };
50
51
 
51
- export const GROUPINGS = ['run', 'type', 'parent', 'none'];
52
+ export const GROUPINGS = ['run', 'type', 'parent', 'order', 'none'];
52
53
  export const DENSITIES = ['auto', 'comfortable', 'compact'];
53
54
 
54
55
  // The handful of things a status means to a reader. `killed` and `stopped` come off the transcript verbatim and
@@ -227,6 +228,8 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
227
228
  via,
228
229
  depth,
229
230
  seq: Math.min(...members.map((m) => seq.get(m.id) ?? 0), Infinity),
231
+ // The group stands where its first member does in the order the session called its agents.
232
+ first: members.filter((m) => m.order != null).sort((a, b) => a.order - b.order)[0] ?? null,
230
233
  ...extra,
231
234
  };
232
235
  }
@@ -248,6 +251,7 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
248
251
  export function plan(nodes, opts = {}) {
249
252
  const group = GROUPINGS.includes(opts.group) ? opts.group : 'run';
250
253
  const asked = DENSITIES.includes(opts.density) ? opts.density : 'auto';
254
+ if (group === 'order') return planOrder(nodes, asked, opts);
251
255
  const seq = opts.seq ?? sequence(nodes);
252
256
  const open = opts.open ?? new Map();
253
257
  const pinned = opts.pinned ?? new Map();
@@ -422,6 +426,107 @@ export function plan(nodes, opts = {}) {
422
426
  };
423
427
  }
424
428
 
429
+ /* ---------------------------------------------------------------- order --- */
430
+
431
+ // What the wire from an agent to a later wave is called, and what it is: a reading of the order of events.
432
+ export const INFERRED_NOTE = 'Inferred from the order of events: this wave was called after that agent reported. It is not a call from one agent to another; the session calls every agent.';
433
+
434
+ /**
435
+ * The same agents by when the session called them.
436
+ *
437
+ * Every agent is called by the session, so the tree is one level deep and says nothing of sequence. Here a
438
+ * column is a WAVE: the agents called in one assistant message, which is what "started together" means. Waves
439
+ * run left to right in the order the messages were written, and a wave's agents stack in the order of the calls.
440
+ * Agents the session's transcript did not call (a workflow's) have no wave and take a last column of their own.
441
+ *
442
+ * Two kinds of wire. The session is wired to the first wave: it called it. A later wave is wired, dotted, from
443
+ * each agent that reported after the wave before it was called and before this one was — an inference from the
444
+ * order of events, drawn as one and labelled as one. A wave nothing reported before has no wire.
445
+ */
446
+ function planOrder(nodes, asked, opts) {
447
+ const pinned = opts.pinned ?? new Map();
448
+ const session = nodes.find((n) => n.kind === 'session') ?? null;
449
+ const agents = nodes.filter((n) => n.kind === 'agent');
450
+ const called = agents.filter((n) => n.wave != null);
451
+ const waveIds = [...new Set(called.map((n) => n.wave))].sort((a, b) => a - b);
452
+ const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.id).localeCompare(String(b.id));
453
+ const columns = waveIds.map((w) => ({ wave: w, members: called.filter((n) => n.wave === w).sort(byOrder) }));
454
+ for (const c of columns) c.calledAt = Math.min(...c.members.map((m) => m.calledAt ?? Infinity));
455
+ const rest = agents.filter((n) => n.wave == null)
456
+ .sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
457
+ if (rest.length) columns.push({ wave: null, members: rest, calledAt: Infinity });
458
+
459
+ const lay = (compact) => {
460
+ const sz = compact ? SIZE.chip : SIZE.card;
461
+ const items = [];
462
+ const x0 = SIZE.padX + (session ? SIZE.session.w + SIZE.colGap : 0);
463
+ let height = 0;
464
+ columns.forEach((col, i) => {
465
+ col.members.forEach((n, j) => {
466
+ items.push({
467
+ id: n.id, kind: 'agent', node: n, parent: null, via: null, depth: i + 1, seq: n.order ?? j, compact,
468
+ wave: col.wave, w: sz.w, h: sz.h, x: x0 + i * (sz.w + SIZE.waveGap), y: SIZE.padY + j * (sz.h + SIZE.rowGap),
469
+ });
470
+ });
471
+ height = Math.max(height, SIZE.padY * 2 + col.members.length * (sz.h + SIZE.rowGap) - SIZE.rowGap);
472
+ });
473
+ const width = x0 + Math.max(0, columns.length * (sz.w + SIZE.waveGap) - SIZE.waveGap) + SIZE.padX;
474
+ return { items, width, height };
475
+ };
476
+ let compact = asked === 'compact';
477
+ let box = lay(compact);
478
+ if (asked === 'auto' && box.height > AUTO.tallest) { compact = true; box = lay(true); }
479
+ const { items } = box;
480
+ let { height } = box;
481
+
482
+ let sessionY = opts.sessionY ?? null;
483
+ if (session) {
484
+ const first = items.filter((it) => it.depth === 1);
485
+ if (sessionY == null) {
486
+ const bottom = first.length ? Math.max(...first.map((it) => it.y + it.h)) : SIZE.padY + SIZE.session.h;
487
+ sessionY = Math.max(SIZE.padY, Math.round((SIZE.padY + bottom) / 2 - SIZE.session.h / 2));
488
+ }
489
+ items.unshift({ id: session.id, kind: 'session', node: session, parent: null, depth: 0, w: SIZE.session.w, h: SIZE.session.h, x: SIZE.padX, y: sessionY });
490
+ height = Math.max(height, sessionY + SIZE.session.h + SIZE.padY);
491
+ }
492
+ for (const it of items) {
493
+ const p = pinned.get(it.id);
494
+ if (p) { it.x = p.x; it.y = p.y; it.pinned = true; }
495
+ }
496
+
497
+ const at = new Map(items.map((it) => [it.id, it]));
498
+ const edges = [];
499
+ const wire = (from, to, extra) => edges.push({
500
+ key: `${from.id}\u0000${to.id}`, source: from.id, target: to.id,
501
+ from: { x: from.x + from.w, y: from.y + from.h / 2 }, to: { x: to.x, y: to.y + to.h / 2 },
502
+ status: to.node.status ?? 'unknown', state: stateOf(to.node.status), ...extra,
503
+ });
504
+ const waves = columns.filter((c) => c.wave != null);
505
+ if (session && waves.length) for (const m of waves[0].members) wire(at.get(session.id), at.get(m.id), {});
506
+ // An agent's report belongs to the first wave called after it: the one that could have been waiting for it.
507
+ for (let i = 1; i < waves.length; i += 1) {
508
+ const since = waves[i - 1].calledAt;
509
+ const reporters = called.filter((n) => n.reportedAt != null && n.reportedAt > since && n.reportedAt <= waves[i].calledAt
510
+ && n.wave < waves[i].wave).sort(byOrder);
511
+ waves[i].after = reporters;
512
+ for (const r of reporters) {
513
+ waves[i].members.forEach((m, k) => wire(at.get(r.id), at.get(m.id), {
514
+ kind: 'inferred', state: 'inferred',
515
+ // Said once for a reporter, on the wire to the wave's first agent; the others carry it on hover.
516
+ label: k === 0 ? `after ${shownType(r)} reported` : null,
517
+ title: `after ${shownType(r)} reported\n${INFERRED_NOTE}`,
518
+ }));
519
+ }
520
+ for (const m of waves[i].members) at.get(m.id).after = reporters.map((r) => shownType(r));
521
+ }
522
+
523
+ return {
524
+ items, edges, width: Math.max(box.width, SIZE.padX * 2), height: Math.max(height, SIZE.padY * 2),
525
+ drawn: agents.length, density: compact ? 'compact' : 'comfortable', group: 'order', stacked: false, aside: false,
526
+ waves: waves.length, sessionY,
527
+ };
528
+ }
529
+
425
530
  /**
426
531
  * Which open groups to fold when a session first opens too tall even as chips: the largest first, until the
427
532
  * picture is short enough to read or there is nothing left to fold. Returns the ids to fold; an empty list when
@@ -31,3 +31,14 @@ export const shownType = (n) => roleName(typeOf(n));
31
31
 
32
32
  /** What hovering an agent says: the type as it is declared, and its task in full, since both are cut on screen. */
33
33
  export const hoverOf = (n) => [n?.agentType ?? null, n?.description ?? null].filter(Boolean).join('\n');
34
+
35
+ /**
36
+ * Where an agent stands among the ones the session called, as a tag: "#3 · 09:14". The number is the order of
37
+ * the calls; the time is when the call was made, in the viewer's own zone. An agent the session's transcript did
38
+ * not call has no place to state, and gets no tag.
39
+ * @param clock (ms) => "HH:MM"
40
+ */
41
+ export function orderTag(n, clock = null) {
42
+ if (n?.order == null) return '';
43
+ return `#${n.order}${clock && n.calledAt != null ? ` · ${clock(n.calledAt)}` : ''}`;
44
+ }
@@ -836,6 +836,21 @@ button.tl-lab { cursor: pointer; }
836
836
  }
837
837
 
838
838
  /* Working. The brightest and the only one that travels. */
839
+ /* A wire that is an inference, not a call: dotted, still, and it can be hovered for what it means. A working wire
840
+ is dashed and moves; this one is neither. */
841
+ .cv-edge[data-kind="inferred"] {
842
+ stroke: var(--ink-faint);
843
+ stroke-dasharray: 1.5 5;
844
+ pointer-events: stroke;
845
+ }
846
+ .cv-edge-label { font-size: 10px; fill: var(--ink-faint); pointer-events: none; }
847
+ /* "#3 · 09:14": where a card stands in the order the session called its agents. */
848
+ .cv-under { display: flex; align-items: baseline; min-width: 0; }
849
+ .cv-under .cv-task { flex: 1 1 auto; min-width: 0; }
850
+ .cv-order { flex: none; margin-left: 6px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
851
+ .cv-order[hidden] { display: none; }
852
+ .cv-chip .cv-order, .cv-member .cv-order { margin-left: 0; margin-right: 6px; }
853
+
839
854
  .cv-edge[data-state="live"] {
840
855
  stroke: var(--status-busy);
841
856
  opacity: var(--cv-edge-live);
@@ -1086,6 +1101,7 @@ button.tl-lab { cursor: pointer; }
1086
1101
  .cv-legend-line[data-state="live"] { border-top: 1.5px dashed var(--status-busy); }
1087
1102
  .cv-legend-line[data-state="waiting"] { border-top: 1.5px dashed var(--status-waiting); }
1088
1103
  .cv-legend-line[data-state="failed"] { border-top-color: var(--cv-fail); }
1104
+ .cv-legend-line[data-state="inferred"] { border-top: 1.5px dotted var(--ink-faint); }
1089
1105
  .cv-x {
1090
1106
  width: 20px; height: 20px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
1091
1107
  background: none; border: 0; border-radius: var(--radius-sm); color: var(--ink-dim); cursor: pointer;
@@ -120,6 +120,8 @@ export function groupsOf(nodes, grouping) {
120
120
  return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${shownType(p)}` };
121
121
  }
122
122
  if (grouping === 'none') return { id: 'all', label: null };
123
+ // `order`: the agents called in one assistant message are a wave, and a wave is a group.
124
+ if (grouping === 'order') return n.wave != null ? { id: `wave:${n.wave}`, label: `Wave ${n.wave}` } : { id: 'wave:none', label: 'Not called by the session' };
123
125
  // `run`: a workflow run is a group; everything the session started itself is another.
124
126
  if (n.workflow) return { id: `run:${n.workflow}`, label: String(n.workflow) };
125
127
  return { id: 'direct', label: 'Session · direct' };
@@ -135,6 +137,12 @@ export function groupsOf(nodes, grouping) {
135
137
  for (const g of out) g.members.sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
136
138
  // What the session did itself comes first; the rest in the order they began.
137
139
  out.sort((a, b) => Number(b.id === 'direct') - Number(a.id === 'direct') || first(a) - first(b) || a.id.localeCompare(b.id));
140
+ // Waves are in the order they were called, which is not always the order their agents first wrote.
141
+ if (grouping === 'order') {
142
+ const wave = (g) => g.members[0].wave ?? Infinity;
143
+ out.sort((a, b) => wave(a) - wave(b));
144
+ for (const g of out) g.members.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
145
+ }
138
146
  return out;
139
147
  }
140
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.1.0-rc.3",
3
+ "version": "3.1.0-rc.4",
4
4
  "description": "Crewforth — your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"