@nanobpm/nano-workforce 0.185.1 → 0.186.1

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.
@@ -661,6 +661,62 @@ test("pollUserTasks (engine-first): surfaces an inlined delivery-graph human tas
661
661
  assertEquals(byKey["35002"].subject_title, "release runbook");
662
662
  });
663
663
 
664
+ test("pollUserTasks: a parked delivery-human node carries its instruction as `question` (Decision context, issue #772)", async () => {
665
+ // The Tasks surface seeds no form variables, so a delivery-graph `human` node's instruction can only
666
+ // reach the operator through the read-model `question` (rendered as "Decision context"). Source it
667
+ // from the run's stamped `human_labels`, keyed by the parked user-task element id — else the panel is
668
+ // blank and the human has no idea what the run is waiting on.
669
+ const { data, stores } = memData({
670
+ delivery_graph_runs: [
671
+ {
672
+ run_key: "delivery-graph-403eb22e",
673
+ process_key: "dg-1",
674
+ status: "running",
675
+ title: "release runbook",
676
+ human_labels: JSON.stringify({
677
+ "delivery-human-task__n7": "Run the manual OTP publish for @nanobpm/urban",
678
+ }),
679
+ },
680
+ ],
681
+ });
682
+ const restore = stubUserTaskSearch([
683
+ { userTaskKey: "20411", elementId: "delivery-human-task__n7", processInstanceKey: "dg-1", state: "CREATED" },
684
+ // the bounded-timeout escalation twin parks on the `…__esc` id but resolves the same base label
685
+ { userTaskKey: "20412", elementId: "delivery-human-task__n7__esc", processInstanceKey: "dg-1", state: "CREATED" },
686
+ ]);
687
+ try {
688
+ await pollUserTasks(data, fakeEngine({}), REST);
689
+ } finally {
690
+ restore();
691
+ }
692
+
693
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
694
+ assertEquals(byKey["20411"].question, "Run the manual OTP publish for @nanobpm/urban");
695
+ assertEquals(byKey["20412"].question, "Run the manual OTP publish for @nanobpm/urban");
696
+ });
697
+
698
+ test("pollUserTasks: a delivery-human node with no stored label still gets a non-blank Decision context (issue #772)", async () => {
699
+ // An untracked run (or a run whose label wasn't stamped) must not leave the panel blank — a static,
700
+ // node-NEUTRAL fallback still tells the operator a delivery-graph step is waiting. It must read true
701
+ // for a non-human escalation twin too, so it must not claim "human step".
702
+ const { data, stores } = memData({});
703
+ const restore = stubUserTaskSearch([
704
+ { userTaskKey: "20411", elementId: "delivery-human-task__n1", processInstanceKey: "dg-9", state: "CREATED" },
705
+ // a bounded `agent`/`wait`/`connector` node's escalation twin: `isDeliveryHumanElement` matches it,
706
+ // but it carries no stored human label, so it gets the neutral fallback, not a "human step" claim.
707
+ { userTaskKey: "20499", elementId: "delivery-human-task__agent5__esc", processInstanceKey: "dg-9", state: "CREATED" },
708
+ ]);
709
+ try {
710
+ await pollUserTasks(data, fakeEngine({}), REST);
711
+ } finally {
712
+ restore();
713
+ }
714
+
715
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
716
+ assertEquals(byKey["20411"].question, "A scheduled delivery-graph step is waiting to be completed.");
717
+ assertEquals(byKey["20499"].question, "A scheduled delivery-graph step is waiting to be completed.");
718
+ });
719
+
664
720
  test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run still surfaces (bucketed `delivery`, instance fallback) (issue #442)", async () => {
665
721
  // Even with no `delivery_graph_runs` row referencing the instance, the kind implies its aggregate, so
666
722
  // the row renders and stays answerable — mirroring the orphaned-escalation guarantee (#358).
package/app/service.ts CHANGED
@@ -30,7 +30,7 @@ import { isUniqueConstraintFence } from "./dbFence.ts";
30
30
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
31
31
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
32
32
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
33
- import { isDeliveryHumanElement } from "./deliveryHuman.ts";
33
+ import { deliveryHumanContextQuestion, isDeliveryHumanElement } from "./deliveryHuman.ts";
34
34
  import { fleetSupportsDurableResume } from "./durableResume.ts";
35
35
  import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
36
36
  import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
@@ -2613,6 +2613,10 @@ export async function pollUserTasks(
2613
2613
  url?: string | null;
2614
2614
  deliveryLabel?: string | null;
2615
2615
  conformanceSummary?: string | null;
2616
+ /** The parked human-node instruction labels for a delivery run, keyed by user-task element id
2617
+ * (`delivery-human-task__<node>`) — the run row's stamped `human_labels`, denormalised so the
2618
+ * Tasks-inbox "Decision context" can explain a parked delivery-graph `human` node (issue #772). */
2619
+ humanLabels?: Record<string, string>;
2616
2620
  /** The subject's at-a-glance "waiting on <capability> · …" rollup, denormalised for the readiness
2617
2621
  * escalation question (issue #674): the wait-gate projection on `plans.wait_gate_label`, or the
2618
2622
  * feature run's `delivery_label` for the inline preflight. */
@@ -2640,7 +2644,7 @@ export async function pollUserTasks(
2640
2644
  // feature/plan/pr enrichment. The row's inlined `delivery-human-task__<node>` id is recognised by
2641
2645
  // the shared `userTaskKindLabel` predicate, and buckets as `delivery` (below).
2642
2646
  for (const run of await deliveryGraphRuns(data).all()) {
2643
- if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null });
2647
+ if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null, humanLabels: parseHumanLabels(run.human_labels) });
2644
2648
  }
2645
2649
 
2646
2650
  // Per-element subject type for an ORPHANED task (no subject row) — the kind implies its aggregate even
@@ -2719,6 +2723,13 @@ export async function pollUserTasks(
2719
2723
  question = readinessEscalationQuestion(subj?.waitGateLabel ?? null);
2720
2724
  break;
2721
2725
  }
2726
+ // A delivery-graph `human` node's user-task id is inlined per node (`delivery-human-task__<node>`
2727
+ // and its `…__esc` twin), so it can't be a static `case` above (issue #772). Fill its "Decision
2728
+ // context" from the run's stamped `human_labels` — otherwise the panel is blank and, because this
2729
+ // surface seeds no form variables, the operator has no idea what the run is waiting on.
2730
+ if (question === null && isDeliveryHumanElement(elementId)) {
2731
+ question = deliveryHumanContextQuestion(subj?.humanLabels, elementId);
2732
+ }
2722
2733
  return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
2723
2734
  };
2724
2735
 
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.1",
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
  },
@@ -4,10 +4,14 @@
4
4
  // renders the agentic transcript from ONE source of truth (#660). Regenerate with:
5
5
  // node --experimental-strip-types scripts/build-cockpit-browser.ts
6
6
 
7
- import { deriveViewFromChunks, optionKindAllows, } from "./transcript-events.js";
7
+ import { createDisplayProjection } from "./transcript-display.js";
8
+ import { deriveViewFromChunks, optionKindAllows, parseTranscriptEvent, utf8ByteLength, } from "./transcript-events.js";
8
9
  /**
9
- * Derive the structured view of a fetched transcript page by folding its stored chunks through the ONE
10
- * event parser + fold. Pure: the cockpit reads THIS instead of re-parsing raw frame bytes.
10
+ * Derive the structured (event-fold) view of a fetched transcript page the flat message/tool/permission
11
+ * history, per-turn structure and raw-byte accounting. This is the {@link DerivedView} fold, kept for the
12
+ * raw-fidelity footer and summary counts; the ordered, human-facing block SEQUENCE is the separate
13
+ * display projection {@link renderDerivedTranscript} draws. Pure: the cockpit reads THESE instead of
14
+ * re-parsing raw frame bytes.
11
15
  */
12
16
  export function deriveTranscript(data) {
13
17
  return deriveViewFromChunks(data.entries);
@@ -116,9 +120,11 @@ function detectDiff(tool) {
116
120
  return { lines: structured, source: "args" };
117
121
  return undefined;
118
122
  }
119
- /** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
120
- function renderTool(doc, tool) {
121
- const card = el(doc, "div", "cockpit-transcript-tool");
123
+ /** Fill an EXISTING tool card node with one tool's content (name, status, args/result, diff block). Clears
124
+ * the node first so it is safe to re-invoke in place when the tool's result later arrives (bounded to
125
+ * this one card no sibling block is touched). */
126
+ function applyTool(card, doc, tool) {
127
+ card.replaceChildren();
122
128
  card.setAttribute("data-tool", tool.name);
123
129
  card.setAttribute("data-offset", String(tool.offset));
124
130
  card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
@@ -152,6 +158,11 @@ function renderTool(doc, tool) {
152
158
  resEl.setAttribute("data-tool-result", "true");
153
159
  card.appendChild(resEl);
154
160
  }
161
+ }
162
+ /** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
163
+ function renderTool(doc, tool) {
164
+ const card = el(doc, "div", "cockpit-transcript-tool");
165
+ applyTool(card, doc, tool);
155
166
  return card;
156
167
  }
157
168
  /**
@@ -160,8 +171,8 @@ function renderTool(doc, tool) {
160
171
  * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
161
172
  * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
162
173
  */
163
- function renderPermission(doc, perm, options) {
164
- const card = el(doc, "div", "cockpit-transcript-permission");
174
+ function applyPermission(card, doc, perm, options) {
175
+ card.replaceChildren();
165
176
  card.setAttribute("data-permission", "request");
166
177
  card.setAttribute("data-policy", perm.policy);
167
178
  card.setAttribute("data-call-id", perm.callId);
@@ -181,13 +192,13 @@ function renderPermission(doc, perm, options) {
181
192
  if (perm.resolved.by !== undefined)
182
193
  settled.setAttribute("data-by", perm.resolved.by);
183
194
  card.appendChild(settled);
184
- return card;
195
+ return;
185
196
  }
186
197
  if (perm.policy === "yolo") {
187
198
  // Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
188
199
  card.setAttribute("data-status", "auto");
189
200
  card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
190
- return card;
201
+ return;
191
202
  }
192
203
  // Pending escalate: one interactive button per offered option, wired to the resolve seam.
193
204
  card.setAttribute("data-status", "pending");
@@ -206,55 +217,207 @@ function renderPermission(doc, perm, options) {
206
217
  actions.appendChild(button);
207
218
  }
208
219
  card.appendChild(actions);
220
+ }
221
+ /**
222
+ * Render one permission prompt card from a {@link DerivedPermission}:
223
+ * - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
224
+ * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
225
+ * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
226
+ */
227
+ function renderPermission(doc, perm, options) {
228
+ const card = el(doc, "div", "cockpit-transcript-permission");
229
+ applyPermission(card, doc, perm, options);
209
230
  return card;
210
231
  }
232
+ /** Fill an EXISTING text-block node with a coalesced message's text + offsets. The block is ONE growing
233
+ * node per logical message — a delta patches this node's `textContent` in place (never a new card per
234
+ * fragment), so a split word reconstructs into exactly that word. */
235
+ function applyText(node, block) {
236
+ node.setAttribute("data-role", block.role);
237
+ node.setAttribute("data-offset", String(block.startOffset));
238
+ node.setAttribute("data-end-offset", String(block.endOffset));
239
+ node.setAttribute("data-block-id", block.id);
240
+ if (block.messageId !== undefined)
241
+ node.setAttribute("data-message-id", block.messageId);
242
+ node.setAttribute("data-complete", String(block.complete));
243
+ node.textContent = block.text;
244
+ }
245
+ /** Render one coalesced-message text block (a single growing node). */
246
+ function renderText(doc, block) {
247
+ const node = el(doc, "div", "cockpit-transcript-message");
248
+ applyText(node, block);
249
+ return node;
250
+ }
251
+ /** Fill an EXISTING retention-gap node. A gap is a first-class visible break so a reattach that dropped
252
+ * chunks never implies the surrounding text is continuous; its `beforeOffset` is anchored once the first
253
+ * post-gap block opens. */
254
+ function applyGap(node, block) {
255
+ node.setAttribute("data-gap", "true");
256
+ node.setAttribute("data-block-id", block.id);
257
+ if (block.beforeOffset !== undefined)
258
+ node.setAttribute("data-before-offset", String(block.beforeOffset));
259
+ node.textContent = "⋯ retained-data gap — earlier output was evicted ⋯";
260
+ }
261
+ /** Render one retention-gap block. */
262
+ function renderGap(doc, block) {
263
+ const node = el(doc, "div", "cockpit-transcript-gap");
264
+ applyGap(node, block);
265
+ return node;
266
+ }
267
+ /** Build a fresh DOM node for any display block kind. */
268
+ function renderBlock(doc, block, options) {
269
+ switch (block.kind) {
270
+ case "text":
271
+ return renderText(doc, block);
272
+ case "tool":
273
+ return renderTool(doc, block.tool);
274
+ case "permission":
275
+ return renderPermission(doc, block.permission, options);
276
+ case "gap":
277
+ return renderGap(doc, block);
278
+ }
279
+ }
280
+ /** Patch an EXISTING block node in place (bounded to that one block — no sibling node is touched). */
281
+ function patchBlock(node, doc, block, options) {
282
+ switch (block.kind) {
283
+ case "text":
284
+ applyText(node, block);
285
+ return;
286
+ case "tool":
287
+ applyTool(node, doc, block.tool);
288
+ return;
289
+ case "permission":
290
+ applyPermission(node, doc, block.permission, options);
291
+ return;
292
+ case "gap":
293
+ applyGap(node, block);
294
+ return;
295
+ }
296
+ }
211
297
  /**
212
- * Render the DERIVED structured view of a fetched transcript into `host`, replacing whatever was there.
213
- * Draws per-turn sections with their derived messages, rich tool/diff cards and permission prompts, plus
214
- * a raw-fidelity footer (retained bytes/chunks) so the operator sees the byte-replay is preserved
215
- * alongside the structure. Idempotent — call again on each refresh. Everything it shows is a derivation
216
- * of the one event log. `options.onPermissionResolve`, when provided, is invoked by a pending
217
- * escalate-permission prompt's Allow/Deny buttons.
298
+ * Build the incremental renderer's stable DOM scaffold under `host` and return the mutable render state.
299
+ * Shared by {@link createIncrementalTranscript} (live) and {@link renderDerivedTranscript} (batch) so
300
+ * historical replay renders IDENTICALLY to the final live rendering.
218
301
  */
219
- export function renderDerivedTranscript(host, doc, data, options = {}) {
220
- const view = deriveTranscript(data);
302
+ export function createIncrementalTranscript(host, doc, stream, options = {}) {
221
303
  host.replaceChildren();
304
+ const projection = createDisplayProjection();
305
+ const nodes = new Map();
306
+ const tallies = { text: 0, tool: 0, permission: 0, gap: 0, turns: 0, rawBytes: 0, rawChunks: 0, lifecycle: "open" };
307
+ // A turn opens implicitly before the first structured block even without an explicit `turn` event
308
+ // (mirrors deriveView's implicit turn 0), so any structured content means at least one turn.
309
+ let structured = false;
222
310
  const root = el(doc, "div", "cockpit-transcript-derived");
223
- root.setAttribute("data-stream", data.stream);
224
- root.setAttribute("data-lifecycle", view.lifecycle);
225
- root.setAttribute("data-turn-count", String(view.turns.length));
226
- root.setAttribute("data-message-count", String(view.messages.length));
227
- root.setAttribute("data-tool-count", String(view.tools.length));
228
- root.setAttribute("data-permission-count", String(view.permissions.length));
229
- if (view.turns.length === 0) {
230
- const empty = el(doc, "div", "cockpit-transcript-empty", "No structured events derived — raw replay only.");
231
- empty.setAttribute("data-empty", "true");
232
- root.appendChild(empty);
311
+ root.setAttribute("data-stream", stream);
312
+ const blocksHost = el(doc, "div", "cockpit-transcript-blocks");
313
+ const empty = el(doc, "div", "cockpit-transcript-empty");
314
+ const footer = el(doc, "footer", "cockpit-transcript-raw");
315
+ root.appendChild(blocksHost);
316
+ root.appendChild(empty);
317
+ root.appendChild(footer);
318
+ host.appendChild(root);
319
+ function refreshSummary() {
320
+ const turnCount = tallies.turns > 0 ? tallies.turns : structured ? 1 : 0;
321
+ root.setAttribute("data-lifecycle", tallies.lifecycle);
322
+ root.setAttribute("data-turn-count", String(turnCount));
323
+ root.setAttribute("data-message-count", String(tallies.text));
324
+ root.setAttribute("data-tool-count", String(tallies.tool));
325
+ root.setAttribute("data-permission-count", String(tallies.permission));
326
+ root.setAttribute("data-gap-count", String(tallies.gap));
327
+ root.setAttribute("data-block-count", String(nodes.size));
328
+ const hasBlocks = tallies.text + tallies.tool + tallies.permission > 0;
329
+ // Toggle (never remove — ElementLike has no removeChild) so a live first block clears the empty note
330
+ // without rebuilding, and an all-raw page still shows exactly one data-empty="true" element.
331
+ empty.setAttribute("data-empty", String(!hasBlocks));
332
+ empty.textContent = hasBlocks ? "" : "No structured events derived — raw replay only.";
333
+ footer.setAttribute("data-raw-bytes", String(tallies.rawBytes));
334
+ footer.setAttribute("data-raw-chunks", String(tallies.rawChunks));
335
+ footer.textContent = `${tallies.rawChunks} raw chunk(s) · ${tallies.rawBytes} B retained for replay`;
336
+ }
337
+ function countAppended(block) {
338
+ structured = structured || block.kind !== "gap";
339
+ if (block.kind === "text")
340
+ tallies.text++;
341
+ else if (block.kind === "tool")
342
+ tallies.tool++;
343
+ else if (block.kind === "permission")
344
+ tallies.permission++;
345
+ else
346
+ tallies.gap++;
233
347
  }
234
- for (const turn of view.turns) {
235
- const section = el(doc, "section", "cockpit-transcript-turn");
236
- section.setAttribute("data-turn", String(turn.index));
237
- section.setAttribute("data-steps", String(turn.steps));
238
- section.appendChild(el(doc, "h3", "cockpit-transcript-turn-title", `Turn ${turn.index}`));
239
- for (const msg of turn.messages) {
240
- const row = el(doc, "div", "cockpit-transcript-message", msg.text);
241
- row.setAttribute("data-role", msg.role);
242
- row.setAttribute("data-offset", String(msg.offset));
243
- section.appendChild(row);
348
+ /** Reconcile ONE projection apply-result into the DOM: append a new node, patch an existing one, and/or
349
+ * patch a secondary now-anchored gap. Bounded to the touched block(s) no unaffected node is replaced. */
350
+ function reconcile(changed, appended, anchored) {
351
+ if (changed !== undefined) {
352
+ if (appended) {
353
+ const node = renderBlock(doc, changed, options);
354
+ nodes.set(changed.id, node);
355
+ blocksHost.appendChild(node);
356
+ countAppended(changed);
357
+ }
358
+ else {
359
+ const node = nodes.get(changed.id);
360
+ if (node !== undefined)
361
+ patchBlock(node, doc, changed, options);
362
+ }
244
363
  }
245
- for (const tool of turn.tools) {
246
- section.appendChild(renderTool(doc, tool));
364
+ if (anchored !== undefined) {
365
+ const node = nodes.get(anchored.id);
366
+ if (node !== undefined)
367
+ patchBlock(node, doc, anchored, options);
247
368
  }
248
- for (const perm of turn.permissions) {
249
- section.appendChild(renderPermission(doc, perm, options));
369
+ }
370
+ function applyEvent(event) {
371
+ // Raw bytes feed the byte-replay footer but produce no display block (the projection ignores them).
372
+ if (event.kind === "stream-chunk") {
373
+ tallies.rawChunks++;
374
+ tallies.rawBytes += utf8ByteLength(event.chunk);
375
+ }
376
+ else if (event.kind === "turn") {
377
+ tallies.turns++;
378
+ }
379
+ else if (event.kind === "lifecycle") {
380
+ tallies.lifecycle = event.phase;
250
381
  }
251
- root.appendChild(section);
382
+ const result = projection.apply(event);
383
+ reconcile(result.changed, result.appended, result.anchored);
252
384
  }
253
- const footer = el(doc, "footer", "cockpit-transcript-raw");
254
- footer.setAttribute("data-raw-bytes", String(view.rawByteLength));
255
- footer.setAttribute("data-raw-chunks", String(view.rawChunkCount));
256
- footer.textContent = `${view.rawChunkCount} raw chunk(s) · ${view.rawByteLength} B retained for replay`;
257
- root.appendChild(footer);
258
- host.appendChild(root);
259
- return { root };
385
+ refreshSummary();
386
+ return {
387
+ root,
388
+ applyChunk(chunk) {
389
+ applyEvent(parseTranscriptEvent(chunk));
390
+ refreshSummary();
391
+ },
392
+ noteGap() {
393
+ const result = projection.noteGap();
394
+ reconcile(result.changed, result.appended, result.anchored);
395
+ refreshSummary();
396
+ },
397
+ blocks() {
398
+ return projection.blocks();
399
+ },
400
+ };
401
+ }
402
+ /**
403
+ * Render the DERIVED, ORDERED display of a fetched transcript page into `host`, replacing whatever was
404
+ * there — the pure BATCH convenience over {@link createIncrementalTranscript}. Draws one growing text
405
+ * block per logical message, with tool/diff cards and permission prompts interleaved in chronological
406
+ * (offset) order, plus a raw-fidelity footer (retained bytes/chunks) so the byte-replay stays visibly
407
+ * preserved. A retention `gap` on the page (`data.gap`) renders a leading visible break. Idempotent —
408
+ * call again on each refresh. Everything it shows is a derivation of the one event log, and it renders
409
+ * IDENTICALLY to the final live rendering (same incremental fold). `options.onPermissionResolve`, when
410
+ * provided, is invoked by a pending escalate-permission prompt's Allow/Deny buttons.
411
+ */
412
+ export function renderDerivedTranscript(host, doc, data, options = {}) {
413
+ const incremental = createIncrementalTranscript(host, doc, data.stream, options);
414
+ // A page-level retention gap precedes the page's first chunk: note it before folding so a leading
415
+ // visible break renders (a reattach that dropped chunks never implies false continuity).
416
+ if (data.gap)
417
+ incremental.noteGap();
418
+ // The batch path folds the SAME chunks the live path does, in offset order, through the ONE projection,
419
+ // so historical and live rendering are byte-for-byte the same tree.
420
+ for (const chunk of [...data.entries].sort((a, b) => a.offset - b.offset))
421
+ incremental.applyChunk(chunk);
422
+ return { root: incremental.root };
260
423
  }