@nanobpm/nano-workforce 0.185.1 → 0.186.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.
@@ -1,30 +1,53 @@
1
- // The cockpit STRUCTURED transcript view — derived from the one event fold (ADR 0056, #251).
1
+ // The cockpit STRUCTURED transcript view — derived from the one ORDERED DISPLAY projection (#566, #757).
2
2
  //
3
3
  // Beside the byte-level replay (`transcript-render.ts` feeds stored chunks through the live terminal
4
- // renderer for pixel-faithful playback), the cockpit can also show a STRUCTURED view of a captured
5
- // session: its derived message history, tool cards and per-turn boundaries. Per the issue's acceptance
6
- // criterion, that structured view is a DERIVATION of the one typed event log — it re-parses nothing.
7
- // It reads a fetched transcript page and folds it through the single {@link deriveViewFromChunks} entry
8
- // point (which routes every chunk through the ONE parser, `parseTranscriptEvent`), so there is no
9
- // second parser of the raw bytes. The drift-guard test enforces that this module never parses chunks
10
- // itself.
4
+ // renderer for pixel-faithful playback), the cockpit shows a STRUCTURED view of a captured session. That
5
+ // view is a DERIVATION of the one typed event log — it re-parses nothing. It folds the stored chunks
6
+ // through the single {@link parseTranscriptEvent} parser and the canonical ORDERED DISPLAY projection
7
+ // ({@link createDisplayProjection}, agentic #566): the projection coalesces transport-fragmented message
8
+ // deltas ("I", "not", "ice I am act", …) back into ONE growing block per logical message, and interleaves
9
+ // text blocks, tool cards and permission prompts in strict chronological (offset) order. So a split word
10
+ // reconstructs into exactly that word in one coherent block — never one bordered card per delta — and a
11
+ // tool call issued mid-message renders between the text before and after it (issue #757). The raw log,
12
+ // its offsets and byte-faithful replay are untouched; the drift-guard test enforces this module never
13
+ // parses chunks itself.
11
14
  //
12
- // Framework-free and side-effect-free, like the sibling cockpit views: the same report always yields
13
- // the same {@link DerivedView}, and the renderer draws into the injected {@link DocumentLike} subset so
14
- // a real DOM satisfies it at runtime and an in-memory fake satisfies it for DOM-free Node tests.
15
+ // INCREMENTAL, not rebuild-on-every-chunk. {@link createIncrementalTranscript} keeps the display
16
+ // projection and a `blockId DOM node` map as mutable state, so a live delta updates the ONE active
17
+ // block's node in place (append a new node, or patch an existing one) instead of rebuilding the whole
18
+ // transcript tree. That is what lets the browser adapter (`pages/cockpit/mount.js`) preserve the
19
+ // operator's selection, expansion and scroll position and auto-follow only at the tail. {@link
20
+ // renderDerivedTranscript} is the pure batch convenience over the same fold — historical replay renders
21
+ // IDENTICALLY to the final live rendering because both drive the one incremental renderer.
22
+ //
23
+ // Framework-free and DOM-agnostic, like the sibling cockpit views: it draws into the injected {@link
24
+ // DocumentLike} subset so a real DOM satisfies it at runtime and an in-memory fake satisfies it for
25
+ // DOM-free Node tests.
15
26
  import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
27
+ import { createDisplayProjection } from "../transcript-display.ts";
16
28
  import {
17
29
  type DerivedPermission,
18
30
  type DerivedTool,
19
31
  type DerivedView,
32
+ type DisplayBlock,
33
+ type DisplayGapBlock,
34
+ type DisplayProjection,
35
+ type DisplayTextBlock,
20
36
  deriveViewFromChunks,
21
37
  optionKindAllows,
38
+ parseTranscriptEvent,
39
+ type StoredChunk,
40
+ type TranscriptEvent,
41
+ utf8ByteLength,
22
42
  } from "../transcript-events.ts";
23
43
  import type { TranscriptDataReport } from "./transcript-render.ts";
24
44
 
25
45
  /**
26
- * Derive the structured view of a fetched transcript page by folding its stored chunks through the ONE
27
- * event parser + fold. Pure: the cockpit reads THIS instead of re-parsing raw frame bytes.
46
+ * Derive the structured (event-fold) view of a fetched transcript page the flat message/tool/permission
47
+ * history, per-turn structure and raw-byte accounting. This is the {@link DerivedView} fold, kept for the
48
+ * raw-fidelity footer and summary counts; the ordered, human-facing block SEQUENCE is the separate
49
+ * display projection {@link renderDerivedTranscript} draws. Pure: the cockpit reads THESE instead of
50
+ * re-parsing raw frame bytes.
28
51
  */
29
52
  export function deriveTranscript(data: TranscriptDataReport): DerivedView {
30
53
  return deriveViewFromChunks(data.entries);
@@ -156,9 +179,11 @@ function detectDiff(tool: DerivedTool): DetectedDiff | undefined {
156
179
  return undefined;
157
180
  }
158
181
 
159
- /** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
160
- function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
161
- const card = el(doc, "div", "cockpit-transcript-tool");
182
+ /** Fill an EXISTING tool card node with one tool's content (name, status, args/result, diff block). Clears
183
+ * the node first so it is safe to re-invoke in place when the tool's result later arrives (bounded to
184
+ * this one card no sibling block is touched). */
185
+ function applyTool(card: ElementLike, doc: DocumentLike, tool: DerivedTool): void {
186
+ card.replaceChildren();
162
187
  card.setAttribute("data-tool", tool.name);
163
188
  card.setAttribute("data-offset", String(tool.offset));
164
189
  card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
@@ -195,6 +220,12 @@ function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
195
220
  resEl.setAttribute("data-tool-result", "true");
196
221
  card.appendChild(resEl);
197
222
  }
223
+ }
224
+
225
+ /** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
226
+ function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
227
+ const card = el(doc, "div", "cockpit-transcript-tool");
228
+ applyTool(card, doc, tool);
198
229
  return card;
199
230
  }
200
231
 
@@ -204,8 +235,8 @@ function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
204
235
  * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
205
236
  * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
206
237
  */
207
- function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): ElementLike {
208
- const card = el(doc, "div", "cockpit-transcript-permission");
238
+ function applyPermission(card: ElementLike, doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): void {
239
+ card.replaceChildren();
209
240
  card.setAttribute("data-permission", "request");
210
241
  card.setAttribute("data-policy", perm.policy);
211
242
  card.setAttribute("data-call-id", perm.callId);
@@ -222,14 +253,14 @@ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: R
222
253
  settled.setAttribute("data-chosen-option", perm.resolved.optionId);
223
254
  if (perm.resolved.by !== undefined) settled.setAttribute("data-by", perm.resolved.by);
224
255
  card.appendChild(settled);
225
- return card;
256
+ return;
226
257
  }
227
258
 
228
259
  if (perm.policy === "yolo") {
229
260
  // Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
230
261
  card.setAttribute("data-status", "auto");
231
262
  card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
232
- return card;
263
+ return;
233
264
  }
234
265
 
235
266
  // Pending escalate: one interactive button per offered option, wired to the resolve seam.
@@ -249,65 +280,268 @@ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: R
249
280
  actions.appendChild(button);
250
281
  }
251
282
  card.appendChild(actions);
283
+ }
284
+
285
+ /**
286
+ * Render one permission prompt card from a {@link DerivedPermission}:
287
+ * - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
288
+ * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
289
+ * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
290
+ */
291
+ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): ElementLike {
292
+ const card = el(doc, "div", "cockpit-transcript-permission");
293
+ applyPermission(card, doc, perm, options);
252
294
  return card;
253
295
  }
254
296
 
297
+ /** Fill an EXISTING text-block node with a coalesced message's text + offsets. The block is ONE growing
298
+ * node per logical message — a delta patches this node's `textContent` in place (never a new card per
299
+ * fragment), so a split word reconstructs into exactly that word. */
300
+ function applyText(node: ElementLike, block: DisplayTextBlock): void {
301
+ node.setAttribute("data-role", block.role);
302
+ node.setAttribute("data-offset", String(block.startOffset));
303
+ node.setAttribute("data-end-offset", String(block.endOffset));
304
+ node.setAttribute("data-block-id", block.id);
305
+ if (block.messageId !== undefined) node.setAttribute("data-message-id", block.messageId);
306
+ node.setAttribute("data-complete", String(block.complete));
307
+ node.textContent = block.text;
308
+ }
309
+
310
+ /** Render one coalesced-message text block (a single growing node). */
311
+ function renderText(doc: DocumentLike, block: DisplayTextBlock): ElementLike {
312
+ const node = el(doc, "div", "cockpit-transcript-message");
313
+ applyText(node, block);
314
+ return node;
315
+ }
316
+
317
+ /** Fill an EXISTING retention-gap node. A gap is a first-class visible break so a reattach that dropped
318
+ * chunks never implies the surrounding text is continuous; its `beforeOffset` is anchored once the first
319
+ * post-gap block opens. */
320
+ function applyGap(node: ElementLike, block: DisplayGapBlock): void {
321
+ node.setAttribute("data-gap", "true");
322
+ node.setAttribute("data-block-id", block.id);
323
+ if (block.beforeOffset !== undefined) node.setAttribute("data-before-offset", String(block.beforeOffset));
324
+ node.textContent = "⋯ retained-data gap — earlier output was evicted ⋯";
325
+ }
326
+
327
+ /** Render one retention-gap block. */
328
+ function renderGap(doc: DocumentLike, block: DisplayGapBlock): ElementLike {
329
+ const node = el(doc, "div", "cockpit-transcript-gap");
330
+ applyGap(node, block);
331
+ return node;
332
+ }
333
+
334
+ /** Build a fresh DOM node for any display block kind. */
335
+ function renderBlock(doc: DocumentLike, block: DisplayBlock, options: RenderDerivedTranscriptOptions): ElementLike {
336
+ switch (block.kind) {
337
+ case "text":
338
+ return renderText(doc, block);
339
+ case "tool":
340
+ return renderTool(doc, block.tool);
341
+ case "permission":
342
+ return renderPermission(doc, block.permission, options);
343
+ case "gap":
344
+ return renderGap(doc, block);
345
+ }
346
+ }
347
+
348
+ /** Patch an EXISTING block node in place (bounded to that one block — no sibling node is touched). */
349
+ function patchBlock(node: ElementLike, doc: DocumentLike, block: DisplayBlock, options: RenderDerivedTranscriptOptions): void {
350
+ switch (block.kind) {
351
+ case "text":
352
+ applyText(node, block);
353
+ return;
354
+ case "tool":
355
+ applyTool(node, doc, block.tool);
356
+ return;
357
+ case "permission":
358
+ applyPermission(node, doc, block.permission, options);
359
+ return;
360
+ case "gap":
361
+ applyGap(node, block);
362
+ return;
363
+ }
364
+ }
365
+
255
366
  /**
256
- * Render the DERIVED structured view of a fetched transcript into `host`, replacing whatever was there.
257
- * Draws per-turn sections with their derived messages, rich tool/diff cards and permission prompts, plus
258
- * a raw-fidelity footer (retained bytes/chunks) so the operator sees the byte-replay is preserved
259
- * alongside the structure. Idempotent call again on each refresh. Everything it shows is a derivation
260
- * of the one event log. `options.onPermissionResolve`, when provided, is invoked by a pending
261
- * escalate-permission prompt's Allow/Deny buttons.
367
+ * A STATEFUL, incremental transcript renderer over a `host` element. It maintains the ordered
368
+ * {@link DisplayBlock} sequence (via the canonical {@link createDisplayProjection}) and a `blockId DOM
369
+ * node` map, so feeding it one live chunk at a time updates just the ONE touched block's node in place —
370
+ * append a brand-new block node, or patch an existing block's node (a growing text delta, a tool result
371
+ * pairing, a permission resolution, or a now-anchored gap) instead of rebuilding the whole transcript
372
+ * tree. That bounded update is what lets the browser adapter preserve selection, expansion and scroll.
373
+ *
374
+ * DOM shape (stable across live growth so unaffected nodes are never replaced):
375
+ * div.cockpit-transcript-derived[data-*]
376
+ * div.cockpit-transcript-blocks ← ordered block nodes are appended here / patched in place
377
+ * div.cockpit-transcript-empty ← shown (data-empty="true") only while there are zero blocks
378
+ * footer.cockpit-transcript-raw ← retained raw bytes/chunks (byte-replay is preserved alongside)
262
379
  */
263
- export function renderDerivedTranscript(
380
+ export interface IncrementalTranscript {
381
+ /** The rendered root (a `cockpit-transcript-derived` element) appended under the host. */
382
+ readonly root: ElementLike;
383
+ /**
384
+ * Fold ONE stored chunk (by offset) into the display and update the DOM minimally. Idempotent on
385
+ * offset — re-feeding an already-applied offset (reconnect, pagination overlap, a duplicated chunk) is
386
+ * a no-op, so replayed text never doubles. Feed chunks in offset order (the projection drops a late
387
+ * lower offset rather than merging it out of place).
388
+ */
389
+ applyChunk(chunk: StoredChunk): void;
390
+ /**
391
+ * Record a retention gap at the current tail BEFORE feeding the post-gap chunks: the consumer resumed
392
+ * from an offset older than the oldest retained chunk, so what follows is NOT continuous with what
393
+ * precedes. Renders a visible break; its `beforeOffset` is anchored when the next block opens.
394
+ */
395
+ noteGap(): void;
396
+ /** A snapshot of the ordered display blocks as they stand now (for tests/inspection). */
397
+ blocks(): readonly DisplayBlock[];
398
+ }
399
+
400
+ /** Running summary tallies for the root attributes + raw footer, maintained WITHOUT re-folding. */
401
+ interface Tallies {
402
+ text: number;
403
+ tool: number;
404
+ permission: number;
405
+ gap: number;
406
+ turns: number;
407
+ rawBytes: number;
408
+ rawChunks: number;
409
+ lifecycle: "open" | "completed" | "exited";
410
+ }
411
+
412
+ /**
413
+ * Build the incremental renderer's stable DOM scaffold under `host` and return the mutable render state.
414
+ * Shared by {@link createIncrementalTranscript} (live) and {@link renderDerivedTranscript} (batch) so
415
+ * historical replay renders IDENTICALLY to the final live rendering.
416
+ */
417
+ export function createIncrementalTranscript(
264
418
  host: ElementLike,
265
419
  doc: DocumentLike,
266
- data: TranscriptDataReport,
420
+ stream: string,
267
421
  options: RenderDerivedTranscriptOptions = {},
268
- ): DerivedTranscriptDom {
269
- const view = deriveTranscript(data);
422
+ ): IncrementalTranscript {
270
423
  host.replaceChildren();
424
+ const projection: DisplayProjection = createDisplayProjection();
425
+ const nodes = new Map<string, ElementLike>();
426
+ const tallies: Tallies = { text: 0, tool: 0, permission: 0, gap: 0, turns: 0, rawBytes: 0, rawChunks: 0, lifecycle: "open" };
427
+ // A turn opens implicitly before the first structured block even without an explicit `turn` event
428
+ // (mirrors deriveView's implicit turn 0), so any structured content means at least one turn.
429
+ let structured = false;
430
+
271
431
  const root = el(doc, "div", "cockpit-transcript-derived");
272
- root.setAttribute("data-stream", data.stream);
273
- root.setAttribute("data-lifecycle", view.lifecycle);
274
- root.setAttribute("data-turn-count", String(view.turns.length));
275
- root.setAttribute("data-message-count", String(view.messages.length));
276
- root.setAttribute("data-tool-count", String(view.tools.length));
277
- root.setAttribute("data-permission-count", String(view.permissions.length));
278
-
279
- if (view.turns.length === 0) {
280
- const empty = el(doc, "div", "cockpit-transcript-empty", "No structured events derived — raw replay only.");
281
- empty.setAttribute("data-empty", "true");
282
- root.appendChild(empty);
432
+ root.setAttribute("data-stream", stream);
433
+ const blocksHost = el(doc, "div", "cockpit-transcript-blocks");
434
+ const empty = el(doc, "div", "cockpit-transcript-empty");
435
+ const footer = el(doc, "footer", "cockpit-transcript-raw");
436
+ root.appendChild(blocksHost);
437
+ root.appendChild(empty);
438
+ root.appendChild(footer);
439
+ host.appendChild(root);
440
+
441
+ function refreshSummary(): void {
442
+ const turnCount = tallies.turns > 0 ? tallies.turns : structured ? 1 : 0;
443
+ root.setAttribute("data-lifecycle", tallies.lifecycle);
444
+ root.setAttribute("data-turn-count", String(turnCount));
445
+ root.setAttribute("data-message-count", String(tallies.text));
446
+ root.setAttribute("data-tool-count", String(tallies.tool));
447
+ root.setAttribute("data-permission-count", String(tallies.permission));
448
+ root.setAttribute("data-gap-count", String(tallies.gap));
449
+ root.setAttribute("data-block-count", String(nodes.size));
450
+
451
+ const hasBlocks = tallies.text + tallies.tool + tallies.permission > 0;
452
+ // Toggle (never remove — ElementLike has no removeChild) so a live first block clears the empty note
453
+ // without rebuilding, and an all-raw page still shows exactly one data-empty="true" element.
454
+ empty.setAttribute("data-empty", String(!hasBlocks));
455
+ empty.textContent = hasBlocks ? "" : "No structured events derived — raw replay only.";
456
+
457
+ footer.setAttribute("data-raw-bytes", String(tallies.rawBytes));
458
+ footer.setAttribute("data-raw-chunks", String(tallies.rawChunks));
459
+ footer.textContent = `${tallies.rawChunks} raw chunk(s) · ${tallies.rawBytes} B retained for replay`;
460
+ }
461
+
462
+ function countAppended(block: DisplayBlock): void {
463
+ structured = structured || block.kind !== "gap";
464
+ if (block.kind === "text") tallies.text++;
465
+ else if (block.kind === "tool") tallies.tool++;
466
+ else if (block.kind === "permission") tallies.permission++;
467
+ else tallies.gap++;
283
468
  }
284
469
 
285
- for (const turn of view.turns) {
286
- const section = el(doc, "section", "cockpit-transcript-turn");
287
- section.setAttribute("data-turn", String(turn.index));
288
- section.setAttribute("data-steps", String(turn.steps));
289
- section.appendChild(el(doc, "h3", "cockpit-transcript-turn-title", `Turn ${turn.index}`));
290
- for (const msg of turn.messages) {
291
- const row = el(doc, "div", "cockpit-transcript-message", msg.text);
292
- row.setAttribute("data-role", msg.role);
293
- row.setAttribute("data-offset", String(msg.offset));
294
- section.appendChild(row);
470
+ /** Reconcile ONE projection apply-result into the DOM: append a new node, patch an existing one, and/or
471
+ * patch a secondary now-anchored gap. Bounded to the touched block(s) no unaffected node is replaced. */
472
+ function reconcile(changed: DisplayBlock | undefined, appended: boolean, anchored: DisplayBlock | undefined): void {
473
+ if (changed !== undefined) {
474
+ if (appended) {
475
+ const node = renderBlock(doc, changed, options);
476
+ nodes.set(changed.id, node);
477
+ blocksHost.appendChild(node);
478
+ countAppended(changed);
479
+ } else {
480
+ const node = nodes.get(changed.id);
481
+ if (node !== undefined) patchBlock(node, doc, changed, options);
482
+ }
295
483
  }
296
- for (const tool of turn.tools) {
297
- section.appendChild(renderTool(doc, tool));
484
+ if (anchored !== undefined) {
485
+ const node = nodes.get(anchored.id);
486
+ if (node !== undefined) patchBlock(node, doc, anchored, options);
298
487
  }
299
- for (const perm of turn.permissions) {
300
- section.appendChild(renderPermission(doc, perm, options));
488
+ }
489
+
490
+ function applyEvent(event: TranscriptEvent): void {
491
+ // Raw bytes feed the byte-replay footer but produce no display block (the projection ignores them).
492
+ if (event.kind === "stream-chunk") {
493
+ tallies.rawChunks++;
494
+ tallies.rawBytes += utf8ByteLength(event.chunk);
495
+ } else if (event.kind === "turn") {
496
+ tallies.turns++;
497
+ } else if (event.kind === "lifecycle") {
498
+ tallies.lifecycle = event.phase;
301
499
  }
302
- root.appendChild(section);
500
+ const result = projection.apply(event);
501
+ reconcile(result.changed, result.appended, result.anchored);
303
502
  }
304
503
 
305
- const footer = el(doc, "footer", "cockpit-transcript-raw");
306
- footer.setAttribute("data-raw-bytes", String(view.rawByteLength));
307
- footer.setAttribute("data-raw-chunks", String(view.rawChunkCount));
308
- footer.textContent = `${view.rawChunkCount} raw chunk(s) · ${view.rawByteLength} B retained for replay`;
309
- root.appendChild(footer);
504
+ refreshSummary();
310
505
 
311
- host.appendChild(root);
312
- return { root };
506
+ return {
507
+ root,
508
+ applyChunk(chunk: StoredChunk): void {
509
+ applyEvent(parseTranscriptEvent(chunk));
510
+ refreshSummary();
511
+ },
512
+ noteGap(): void {
513
+ const result = projection.noteGap();
514
+ reconcile(result.changed, result.appended, result.anchored);
515
+ refreshSummary();
516
+ },
517
+ blocks(): readonly DisplayBlock[] {
518
+ return projection.blocks();
519
+ },
520
+ };
521
+ }
522
+
523
+ /**
524
+ * Render the DERIVED, ORDERED display of a fetched transcript page into `host`, replacing whatever was
525
+ * there — the pure BATCH convenience over {@link createIncrementalTranscript}. Draws one growing text
526
+ * block per logical message, with tool/diff cards and permission prompts interleaved in chronological
527
+ * (offset) order, plus a raw-fidelity footer (retained bytes/chunks) so the byte-replay stays visibly
528
+ * preserved. A retention `gap` on the page (`data.gap`) renders a leading visible break. Idempotent —
529
+ * call again on each refresh. Everything it shows is a derivation of the one event log, and it renders
530
+ * IDENTICALLY to the final live rendering (same incremental fold). `options.onPermissionResolve`, when
531
+ * provided, is invoked by a pending escalate-permission prompt's Allow/Deny buttons.
532
+ */
533
+ export function renderDerivedTranscript(
534
+ host: ElementLike,
535
+ doc: DocumentLike,
536
+ data: TranscriptDataReport,
537
+ options: RenderDerivedTranscriptOptions = {},
538
+ ): DerivedTranscriptDom {
539
+ const incremental = createIncrementalTranscript(host, doc, data.stream, options);
540
+ // A page-level retention gap precedes the page's first chunk: note it before folding so a leading
541
+ // visible break renders (a reattach that dropped chunks never implies false continuity).
542
+ if (data.gap) incremental.noteGap();
543
+ // The batch path folds the SAME chunks the live path does, in offset order, through the ONE projection,
544
+ // so historical and live rendering are byte-for-byte the same tree.
545
+ for (const chunk of [...data.entries].sort((a, b) => a.offset - b.offset)) incremental.applyChunk(chunk);
546
+ return { root: incremental.root };
313
547
  }
@@ -0,0 +1,17 @@
1
+ // nano-workforce — the ordered DISPLAY projection, DERIVED from its one owner (agentic #566).
2
+ //
3
+ // Sibling of `transcript-events.ts`: where that barrel re-exports the transcript EVENT grammar (the one
4
+ // parser + fold), THIS barrel re-exports the canonical ordered DISPLAY derivation — the projection that
5
+ // coalesces transport-fragmented message deltas back into logical blocks and interleaves them
6
+ // chronologically with tool cards and permission prompts (`createDisplayProjection` / `deriveDisplay`).
7
+ // The cockpit renders THIS instead of the raw per-event groups, so a split word reconstructs into one
8
+ // block and text/tool/permission order never scrambles.
9
+ //
10
+ // Why a SEPARATE barrel rather than folding these into `transcript-events.ts`: the browser bundle
11
+ // (`scripts/build-cockpit-browser.ts`) emits one generated ESM sibling per agentic source module, and
12
+ // the display projection lives in agentic's own `dist/transcript/display.js` — a self-contained,
13
+ // browser-safe module distinct from `events.js`. Keeping the display import on its own specifier lets
14
+ // the bundle rewrite it to `./transcript-display.js` (agentic's display module) while the event import
15
+ // still rewrites to `./transcript-events.js`. Both remain thin re-exports of the ONE agentic source of
16
+ // truth — there is no local display algorithm here (Derivation Over Duplication).
17
+ export { createDisplayProjection, deriveDisplay } from "@nanobpm/agentic/transcript";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.185.1",
3
+ "version": "0.186.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -64,7 +64,7 @@
64
64
  "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
65
65
  },
66
66
  "dependencies": {
67
- "@nanobpm/agentic": "^0.13.0",
67
+ "@nanobpm/agentic": "^0.14.0",
68
68
  "@nanobpm/urban": "^0.93.0",
69
69
  "bpmn-auto-layout": "^2.0.0-alpha.2"
70
70
  },