@north-light/crouter 0.3.249 → 0.3.250

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 (170) hide show
  1. package/README.md +1 -1
  2. package/dist/api/__tests__/integration/client.test.js +28 -7
  3. package/dist/api/client.d.ts +1 -1
  4. package/dist/api/client.js +2 -2
  5. package/dist/api/dto/lifecycle.d.ts +3 -0
  6. package/dist/api/dto/messages.d.ts +4 -0
  7. package/dist/api/dto/nodes.d.ts +4 -0
  8. package/dist/api/dto/reports.d.ts +7 -2
  9. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  10. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -0
  11. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +4 -3
  15. package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
  16. package/dist/clients/attach/session/chrome-refresh.js +1 -0
  17. package/dist/clients/attach/viewer.js +532 -532
  18. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.d.ts +1 -0
  19. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +53 -0
  20. package/dist/clients/inbox/controller.js +32 -5
  21. package/dist/clients/inbox/tui/ansi.d.ts +3 -1
  22. package/dist/clients/inbox/tui/ansi.js +14 -10
  23. package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -7
  24. package/dist/commands/api-client.d.ts +2 -2
  25. package/dist/commands/api-client.js +5 -5
  26. package/dist/commands/canvas-browse.js +1 -1
  27. package/dist/commands/node/create.js +3 -1
  28. package/dist/commands/node/inspect.js +1 -0
  29. package/dist/commands/node/lifecycle.js +1 -1
  30. package/dist/commands/node/message.js +6 -4
  31. package/dist/commands/node/wait.js +1 -1
  32. package/dist/commands/push.js +36 -37
  33. package/dist/commands/sys/daemon.js +21 -8
  34. package/dist/commands/sys/setup-core.js +2 -2
  35. package/dist/core/__tests__/broker-stream-watchdog-floor.test.d.ts +1 -0
  36. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +84 -0
  37. package/dist/core/__tests__/broker-turn-admission.test.d.ts +1 -0
  38. package/dist/core/__tests__/broker-turn-admission.test.js +44 -0
  39. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +5 -1
  40. package/dist/core/__tests__/child-death-wake.test.js +11 -7
  41. package/dist/core/__tests__/collectible.test.d.ts +1 -0
  42. package/dist/core/__tests__/collectible.test.js +88 -0
  43. package/dist/core/__tests__/context-intro.test.js +4 -41
  44. package/dist/core/__tests__/daemon-boot.test.js +30 -14
  45. package/dist/core/__tests__/daemon-wedge.test.js +25 -19
  46. package/dist/core/__tests__/fixtures/fake-engine.js +11 -0
  47. package/dist/core/__tests__/human-deliver.test.js +5 -5
  48. package/dist/core/__tests__/integration/deferred-no-wake.test.js +65 -4
  49. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +3 -3
  50. package/dist/core/__tests__/integration/revive.test.js +48 -0
  51. package/dist/core/__tests__/integration/spawn-root.test.js +36 -1
  52. package/dist/core/__tests__/integration/worktree-land.test.js +82 -3
  53. package/dist/core/__tests__/integration/worktree-reap.test.js +44 -6
  54. package/dist/core/__tests__/migration.test.js +30 -0
  55. package/dist/core/__tests__/passive-subscription.test.js +39 -1
  56. package/dist/core/__tests__/prune.test.js +12 -3
  57. package/dist/core/__tests__/relaunch-root.test.js +1 -1
  58. package/dist/core/__tests__/respawn-throttle.test.js +16 -146
  59. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +23 -11
  60. package/dist/core/__tests__/seam/broker-provider-retry.test.js +81 -2
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -1
  62. package/dist/core/__tests__/seam/self-close-teardown.test.d.ts +1 -0
  63. package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
  64. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
  65. package/dist/core/canvas/canvas.d.ts +9 -15
  66. package/dist/core/canvas/canvas.js +41 -47
  67. package/dist/core/canvas/crons.d.ts +5 -8
  68. package/dist/core/canvas/crons.js +5 -8
  69. package/dist/core/canvas/history.d.ts +5 -0
  70. package/dist/core/canvas/history.js +12 -1
  71. package/dist/core/canvas/migrations.js +58 -1
  72. package/dist/core/canvas/pid.d.ts +1 -1
  73. package/dist/core/canvas/pid.js +5 -2
  74. package/dist/core/canvas/types.d.ts +4 -0
  75. package/dist/core/feed/feed.d.ts +4 -0
  76. package/dist/core/feed/feed.js +13 -8
  77. package/dist/core/feed/inbox.d.ts +2 -1
  78. package/dist/core/feed/inbox.js +22 -3
  79. package/dist/core/git.d.ts +3 -0
  80. package/dist/core/git.js +3 -1
  81. package/dist/core/human/feedback-companion.js +1 -0
  82. package/dist/core/preview-registry.js +0 -1
  83. package/dist/core/review/companion.js +1 -0
  84. package/dist/core/review/realize.js +1 -0
  85. package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
  86. package/dist/core/runtime/broker/auth-reload.js +9 -3
  87. package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
  88. package/dist/core/runtime/broker/engine-drive.js +28 -23
  89. package/dist/core/runtime/broker/event-projection.d.ts +4 -0
  90. package/dist/core/runtime/broker/event-projection.js +43 -23
  91. package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
  92. package/dist/core/runtime/broker/fault-retry.js +94 -8
  93. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  94. package/dist/core/runtime/broker/frame-dispatch.js +4 -1
  95. package/dist/core/runtime/broker/inbox.js +4 -0
  96. package/dist/core/runtime/broker/rebind.js +18 -0
  97. package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
  98. package/dist/core/runtime/broker/turn-admission.js +31 -0
  99. package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
  100. package/dist/core/runtime/broker/turn-ignition.js +25 -15
  101. package/dist/core/runtime/broker.js +21 -2
  102. package/dist/core/runtime/close.d.ts +11 -9
  103. package/dist/core/runtime/close.js +23 -19
  104. package/dist/core/runtime/fault.d.ts +21 -0
  105. package/dist/core/runtime/fault.js +129 -19
  106. package/dist/core/runtime/fleet.d.ts +0 -6
  107. package/dist/core/runtime/host.d.ts +7 -1
  108. package/dist/core/runtime/host.js +20 -2
  109. package/dist/core/runtime/nodes.d.ts +5 -1
  110. package/dist/core/runtime/nodes.js +24 -1
  111. package/dist/core/runtime/placement.d.ts +6 -6
  112. package/dist/core/runtime/placement.js +15 -8
  113. package/dist/core/runtime/reset.js +2 -2
  114. package/dist/core/runtime/revive.js +40 -4
  115. package/dist/core/runtime/spawn.d.ts +3 -0
  116. package/dist/core/runtime/spawn.js +1 -0
  117. package/dist/core/runtime/warm-pool.d.ts +4 -0
  118. package/dist/core/runtime/warm-pool.js +2 -0
  119. package/dist/core/worktree.d.ts +4 -3
  120. package/dist/core/worktree.js +73 -27
  121. package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
  122. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
  123. package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
  124. package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
  125. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
  126. package/dist/daemon/api/handlers/bash-jobs.js +2 -0
  127. package/dist/daemon/api/handlers/broker-ops.js +23 -2
  128. package/dist/daemon/api/handlers/human.js +1 -1
  129. package/dist/daemon/api/handlers/messages.js +11 -2
  130. package/dist/daemon/api/handlers/nodes.js +13 -1
  131. package/dist/daemon/api/handlers/reports.js +14 -6
  132. package/dist/daemon/api/map.js +1 -0
  133. package/dist/daemon/api/server.d.ts +9 -12
  134. package/dist/daemon/api/server.js +30 -19
  135. package/dist/daemon/companion-retire.js +3 -9
  136. package/dist/daemon/cron/sinks.js +7 -2
  137. package/dist/daemon/crtrd.js +4 -1
  138. package/dist/daemon/fleet.d.ts +2 -16
  139. package/dist/daemon/fleet.js +63 -150
  140. package/dist/daemon/human/finish.js +2 -0
  141. package/dist/daemon/manage.d.ts +11 -0
  142. package/dist/daemon/manage.js +32 -3
  143. package/dist/daemon/messaging/node-message.js +7 -1
  144. package/dist/daemon/reconcilers/bash-deadline.js +2 -0
  145. package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
  146. package/dist/daemon/reconcilers/broker-supervision.js +22 -9
  147. package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
  148. package/dist/daemon/reconcilers/live-obligation.js +18 -6
  149. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
  150. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
  151. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
  152. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
  153. package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
  154. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
  155. package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
  156. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  157. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
  158. package/dist/pi-extensions/canvas-context-intro.js +4 -76
  159. package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
  160. package/dist/pi-extensions/canvas-review-boundary.js +4 -29
  161. package/dist/pi-extensions/canvas-stophook.js +1 -6
  162. package/dist/shared/birth-announcement.d.ts +12 -0
  163. package/dist/shared/birth-announcement.js +25 -0
  164. package/dist/shared/generated-context.d.ts +8 -4
  165. package/dist/shared/generated-context.js +15 -9
  166. package/package.json +4 -4
  167. package/runtime.lock.json +2 -2
  168. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
  169. package/dist/pi-extensions/truncate.d.ts +0 -5
  170. package/dist/pi-extensions/truncate.js +0 -14
@@ -0,0 +1,53 @@
1
+ // A page ticket that settles out-of-band (answered from another surface, or a
2
+ // row this inbox failed to drop) must not read as "being edited by another
3
+ // inbox" when the reader presses Enter: its response.json already exists, so the
4
+ // row is stale and this activation must report the settlement and drop it.
5
+ import assert from 'node:assert/strict';
6
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { ticketDir } from '#core/human/root';
10
+ import { submitPage, takeoverAndCompletePage } from '#core/human/tickets';
11
+ import { claimPath } from '#core/human/convention';
12
+ import { InboxController } from '../../controller.js';
13
+ const key = (part = {}) => ({ ctrl: false, meta: false, upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, wordLeft: false, wordRight: false, home: false, end: false, pageUp: false, pageDown: false, del: false, return: false, newline: false, escape: false, tab: false, backspace: false, ...part });
14
+ function fakeHost() {
15
+ const handle = { close() { }, invalidate() { }, onClose() { } };
16
+ return { capabilities: { remote: false, canFocusNode: false, selfPane: undefined }, mount: () => handle, notice() { }, suspendForChild: () => () => { }, focusNode: async () => false, pickReviewFile: async () => null };
17
+ }
18
+ const status = (c) => c.status;
19
+ const temp = mkdtempSync(join(tmpdir(), 'crtr-stale-row-'));
20
+ const sources = mkdtempSync(join(tmpdir(), 'crtr-stale-src-'));
21
+ process.env['CRTR_HOME'] = temp;
22
+ const sourceFile = join(sources, 'ask.tsx');
23
+ writeFileSync(sourceFile, 'export default () => (\n <Page title="Stale row page" subtitle="Pick one">\n <UserQuestion id="choice" label="Restore?" body="Pick one." mode="single" options={[{ id: "yes", label: "Yes" }, { id: "no", label: "No" }]} />\n </Page>\n);\n');
24
+ const inboxDelivery = { placement: 'inline', inbox: true, reply: true };
25
+ try {
26
+ const dir = ticketDir('stale-page');
27
+ submitPage({ dir, sourceFile, delivery: inboxDelivery });
28
+ // Seed the controller's in-memory list from disk while the ticket is pending.
29
+ const options = { cols: 100, rows: 24 };
30
+ const controller = new InboxController({ host: fakeHost(), onDirty() { }, onClosed() { }, ...options });
31
+ assert.equal(controller.snapshot().items.length, 1, 'pending page is listed');
32
+ // Settle it out of band, without letting the controller rescan: this is the
33
+ // stale-row state the bug leaves behind.
34
+ takeoverAndCompletePage(dir, { choice: { selectedOptionIds: ['yes'], comments: [] } }, { host: 'daemon', pid: 1 });
35
+ assert.equal(existsSync(join(dir, 'response.json')), true, 'response is published on disk');
36
+ assert.equal(existsSync(claimPath(dir)), false, 'completion leaves no claim behind');
37
+ assert.equal(controller.snapshot().items.length, 1, 'the in-memory row is still stale');
38
+ // Pressing Enter on the stale row must report the settlement and drop it,
39
+ // never claim it is edited by another inbox.
40
+ controller.handleKey('', key({ return: true }));
41
+ const reported = status(controller) ?? '';
42
+ assert.match(reported, /already resolved/, `stale-row activation reports the settlement, got: ${JSON.stringify(reported)}`);
43
+ assert.doesNotMatch(reported, /being edited/, 'a settled row is never "edited by another inbox"');
44
+ assert.equal(controller.snapshot().items.length, 0, 'the stale row drops on activation');
45
+ assert.equal(controller.snapshot().screen, 'list', 'a settled row never opens the detail pane');
46
+ controller.close();
47
+ console.log('stale-row regression passed');
48
+ }
49
+ finally {
50
+ rmSync(temp, { recursive: true, force: true });
51
+ rmSync(sources, { recursive: true, force: true });
52
+ delete process.env['CRTR_HOME'];
53
+ }
@@ -1,4 +1,6 @@
1
- import { statSync, watch } from 'node:fs';
1
+ import { existsSync, statSync, watch } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { visibleWidth } from '@earendil-works/pi-tui';
2
4
  import { renderMarkdown } from '../../core/termrender/termrender.js';
3
5
  import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from './tui/ansi.js';
4
6
  import { buildInboxLines, recommendedLabel } from './tui.js';
@@ -8,7 +10,8 @@ import { resolvePageComponents } from '../../core/config.js';
8
10
  import { watchInboxActivity } from '../../core/human/root.js';
9
11
  import { claimTicket, heartbeatClaim, releaseClaim } from '../../core/human/claim.js';
10
12
  import { readPageTicket, readTicketResult } from '../../core/human/tickets.js';
11
- import { clearProgress, pagePath } from '../../core/human/convention.js';
13
+ import { claimPath, clearProgress, isResolved, pagePath, responsePath } from '../../core/human/convention.js';
14
+ import { emitEvent } from '../../core/events/emit.js';
12
15
  import { projectPageDisplayMarkdown } from '../../core/human/page-markdown.js';
13
16
  import { projectHtmlDocumentMarkdown } from '../../core/human/page-html-markdown.js';
14
17
  import { pageBodyLines } from './tui/page-body.js';
@@ -200,7 +203,13 @@ export class InboxController {
200
203
  }
201
204
  const claim = claimTicket(item.dir);
202
205
  if (claim === null) {
203
- this.status = 'ticket is being edited by another inbox';
206
+ // A null claim means either the ticket already settled (its response.json
207
+ // exists) or a live claim is held. Only the latter is "another inbox";
208
+ // the former is a stale row this scan should drop.
209
+ const resolved = isResolved(item.dir);
210
+ this.status = resolved ? safeCanonicalResultStatus(item.dir) : 'ticket is being edited by another inbox';
211
+ if (resolved)
212
+ this.rescan();
204
213
  return;
205
214
  }
206
215
  const settled = item.fastAction.kind === 'acknowledge'
@@ -250,7 +259,10 @@ export class InboxController {
250
259
  return;
251
260
  const claim = claimTicket(item.dir);
252
261
  if (claim === null) {
253
- this.status = 'ticket is being edited by another inbox';
262
+ const resolved = isResolved(item.dir);
263
+ this.status = resolved ? safeCanonicalResultStatus(item.dir) : 'ticket is being edited by another inbox';
264
+ if (resolved)
265
+ this.rescan();
254
266
  return;
255
267
  }
256
268
  this.claim = { dir: item.dir, token: claim.token };
@@ -361,6 +373,22 @@ export class InboxController {
361
373
  this.submittingDir = undefined;
362
374
  this.leaveDetail(false);
363
375
  this.rescan();
376
+ // A settled page whose row survives its own post-resolve rescan is the
377
+ // "row won't leave" defect. This records the disk truth at the exact moment
378
+ // the row should have dropped, so a live recurrence is diagnosable without a
379
+ // reproduction: was the response published, does the scan still list it, and
380
+ // was the claim left behind.
381
+ const stillListed = this.items.some((item) => item.dir === claim.dir);
382
+ emitEvent({
383
+ level: stillListed ? 'warn' : 'info',
384
+ event: 'inbox.page.resolved',
385
+ fields: {
386
+ ticket_id: basename(claim.dir),
387
+ response_published: existsSync(responsePath(claim.dir)),
388
+ still_listed: stillListed,
389
+ claim_left_behind: existsSync(claimPath(claim.dir)),
390
+ },
391
+ });
364
392
  this.markDirty();
365
393
  }
366
394
  /** Edit the active buffer while the host releases the terminal to $EDITOR. */
@@ -527,7 +555,6 @@ export class InboxController {
527
555
  }
528
556
  }
529
557
  }
530
- function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
531
558
  function canonicalResultStatus(result) {
532
559
  if (result === null)
533
560
  return 'ticket was already resolved';
@@ -33,7 +33,9 @@ export declare function hardWrap(text: string, maxWidth: number): string[];
33
33
  * them as cheap no-ops.
34
34
  */
35
35
  export declare function centerHorizontal(lines: string[], cols: number, contentWidth: number): string[];
36
- /** Visible width of a line in display cells (string-width ignores ANSI). */
36
+ /** Visible width of a line in display cells. pi-tui's helper is the one
37
+ * terminal-cell contract: it accounts for graphemes and wide cells while
38
+ * ignoring SGR, OSC, and APC sequences. */
37
39
  export declare function visibleWidth(line: string): number;
38
40
  /**
39
41
  * The one display-cell containment primitive: the visible cells
@@ -1,4 +1,5 @@
1
1
  import stringWidth from 'string-width';
2
+ import { visibleWidth as piVisibleWidth } from '@earendil-works/pi-tui';
2
3
  // ── ANSI escape constants ─────────────────────────────────────────────────────
3
4
  // The single home for these — both the page renderer (render.ts) and the inbox
4
5
  // picker (inbox/tui.ts) import from here rather than keeping their own copies.
@@ -172,31 +173,34 @@ export function centerHorizontal(lines, cols, contentWidth) {
172
173
  const pad = ' '.repeat(extraPad);
173
174
  return lines.map((line) => (line === '' ? '' : pad + line));
174
175
  }
175
- /** Visible width of a line in display cells (string-width ignores ANSI). */
176
+ /** Visible width of a line in display cells. pi-tui's helper is the one
177
+ * terminal-cell contract: it accounts for graphemes and wide cells while
178
+ * ignoring SGR, OSC, and APC sequences. */
176
179
  export function visibleWidth(line) {
177
- return stringWidth(line);
180
+ return piVisibleWidth(line);
178
181
  }
179
182
  const ANSI_TOKEN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-^]/g;
180
183
  const OSC8_TOKEN = /\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(\x07|\x1b\\)/g;
181
184
  const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
182
185
  /** ANSI escapes are tokens of their own; all printable text is segmented into
183
186
  * extended grapheme clusters before widths or slice boundaries are calculated.
184
- * `string-width` assigns display width to the complete cluster (not its
187
+ * `visibleWidth` assigns display width to the complete cluster (not its
185
188
  * constituent code points), which keeps ZWJ emoji, flags, skin tones and
186
- * combining sequences indivisible while panning. */
189
+ * combining sequences indivisible while panning, and counts a tab as the same
190
+ * cells the rest of the rectangle measures it at. */
187
191
  function displayTokens(line) {
188
192
  const tokens = [];
189
193
  let at = 0;
190
194
  ANSI_TOKEN.lastIndex = 0;
191
195
  for (let match = ANSI_TOKEN.exec(line); match !== null; match = ANSI_TOKEN.exec(line)) {
192
196
  for (const segment of GRAPHEMES.segment(line.slice(at, match.index))) {
193
- tokens.push({ ansi: false, text: segment.segment, width: stringWidth(segment.segment) });
197
+ tokens.push({ ansi: false, text: segment.segment, width: visibleWidth(segment.segment) });
194
198
  }
195
199
  tokens.push({ ansi: true, text: match[0] });
196
200
  at = match.index + match[0].length;
197
201
  }
198
202
  for (const segment of GRAPHEMES.segment(line.slice(at))) {
199
- tokens.push({ ansi: false, text: segment.segment, width: stringWidth(segment.segment) });
203
+ tokens.push({ ansi: false, text: segment.segment, width: visibleWidth(segment.segment) });
200
204
  }
201
205
  return tokens;
202
206
  }
@@ -284,8 +288,8 @@ function closeActiveOsc8(line) {
284
288
  export function clipLine(line, maxWidth) {
285
289
  if (maxWidth < 1)
286
290
  return '';
287
- if (stringWidth(line) <= maxWidth)
288
- return line; // string-width ignores ANSI
291
+ if (visibleWidth(line) <= maxWidth)
292
+ return line; // visibleWidth ignores ANSI
289
293
  return sliceCells(line, 0, maxWidth);
290
294
  }
291
295
  /**
@@ -300,7 +304,7 @@ export function clipLine(line, maxWidth) {
300
304
  export function panLine(line, offset, width) {
301
305
  if (width < 1)
302
306
  return '';
303
- const w = stringWidth(line);
307
+ const w = visibleWidth(line);
304
308
  if (w <= width)
305
309
  return line;
306
310
  const max = w - width;
@@ -313,5 +317,5 @@ export function panLine(line, offset, width) {
313
317
  }
314
318
  /** Cells of `line` that fall outside a `width`-wide rectangle. */
315
319
  export function rowOverflow(line, width) {
316
- return Math.max(0, stringWidth(line) - width);
320
+ return Math.max(0, visibleWidth(line) - width);
317
321
  }
@@ -2,7 +2,7 @@
2
2
  // `node inspect list`/etc used to surface a bare `daemon_unavailable` on a
3
3
  // cold-start timeout, discarding the real cause. `readColdStartDiagnostic`
4
4
  // (`../api-client.ts`) is what `cliClient()` wires into `CrtrClient` as
5
- // `coldStartDiagnostic` — this proves it reads a BOUNDED tail of crtrd.err
5
+ // `coldStartDiagnostic` — this proves it reads a BOUNDED tail of crtrd.log
6
6
  // (never the whole file) and degrades to `undefined` (not a throw) when the
7
7
  // log is missing. The final test proves the READ itself is bounded (not just
8
8
  // the returned string) — the prior implementation `readFileSync`'d the whole
@@ -30,21 +30,21 @@ after(() => {
30
30
  process.env.CRTR_HOME = previousHome;
31
31
  rmSync(home, { recursive: true, force: true });
32
32
  });
33
- test('returns undefined when crtrd.err does not exist', () => {
33
+ test('returns undefined when crtrd.log does not exist', () => {
34
34
  assert.equal(readColdStartDiagnostic(), undefined);
35
35
  });
36
- test('returns undefined for an empty/whitespace-only crtrd.err', () => {
37
- writeFileSync(join(home, 'crtrd.err'), ' \n\n \n');
36
+ test('returns undefined for an empty/whitespace-only crtrd.log', () => {
37
+ writeFileSync(join(home, 'crtrd.log'), ' \n\n \n');
38
38
  assert.equal(readColdStartDiagnostic(), undefined);
39
39
  });
40
40
  test('surfaces the tail of a real startup failure', () => {
41
- writeFileSync(join(home, 'crtrd.err'), 'Error: listen EADDRINUSE: address already in use :::0\n');
41
+ writeFileSync(join(home, 'crtrd.log'), 'Error: listen EADDRINUSE: address already in use :::0\n');
42
42
  const diagnostic = readColdStartDiagnostic();
43
43
  assert.match(diagnostic ?? '', /EADDRINUSE/);
44
44
  });
45
45
  test('bounds the tail instead of leaking the whole log', () => {
46
46
  const lines = Array.from({ length: 5_000 }, (_, i) => `line ${i}: ${'x'.repeat(80)}`);
47
- writeFileSync(join(home, 'crtrd.err'), lines.join('\n'));
47
+ writeFileSync(join(home, 'crtrd.log'), lines.join('\n'));
48
48
  const diagnostic = readColdStartDiagnostic();
49
49
  assert.ok(diagnostic !== undefined);
50
50
  // Bounded on both axes: never the full 5,000-line log, and never unbounded bytes.
@@ -62,7 +62,7 @@ test('bounds the READ itself — the tail window starts at an exact byte offset,
62
62
  const afterSentinel = 'AFTER-WINDOW-SENTINEL';
63
63
  const prefixFiller = 'p'.repeat(50_000 - beforeSentinel.length);
64
64
  const tailFiller = 'q'.repeat(COLD_START_DIAGNOSTIC_TAIL_BYTES - afterSentinel.length);
65
- writeFileSync(join(home, 'crtrd.err'), prefixFiller + beforeSentinel + afterSentinel + tailFiller);
65
+ writeFileSync(join(home, 'crtrd.log'), prefixFiller + beforeSentinel + afterSentinel + tailFiller);
66
66
  const diagnostic = readColdStartDiagnostic() ?? '';
67
67
  assert.match(diagnostic, /AFTER-WINDOW-SENTINEL/);
68
68
  assert.doesNotMatch(diagnostic, /BEFORE-WINDOW-SENTINEL/);
@@ -20,12 +20,12 @@ export declare function rethrowAsCliError(err: unknown, next?: string): never;
20
20
  * 404-only→null mapping as `ApiCanvasSource.orNull`, but returning the raw DTO
21
21
  * and rethrowing non-404s as CLI errors rather than swallowing them. */
22
22
  export declare function getNodeOrNull(nodeId: string): Promise<NodeDetailDTO | null>;
23
- /** Bounded tail of crtrd.err surfaced on a cold-start `/healthz` timeout.
23
+ /** Bounded tail of crtrd.log surfaced on a cold-start `/healthz` timeout.
24
24
  * Large enough to catch a real startup failure/stack trace,
25
25
  * small enough that a runaway daemon log can never leak unbounded into a CLI
26
26
  * error message. */
27
27
  export declare const COLD_START_DIAGNOSTIC_TAIL_BYTES = 4096;
28
- /** Read a bounded tail of crtrd's stderr log to enrich a `daemon_unavailable`
28
+ /** Read a bounded tail of crtrd's event log to enrich a `daemon_unavailable`
29
29
  * cold-start timeout, so the actual startup failure is not discarded behind a
30
30
  * bare message. Bounds the I/O itself — opens the file and reads only the
31
31
  * final `COLD_START_DIAGNOSTIC_TAIL_BYTES` bytes via `fstatSync`+`readSync`,
@@ -56,13 +56,13 @@ export async function getNodeOrNull(nodeId) {
56
56
  }
57
57
  }
58
58
  // cliClient — the one place the CLI constructs a daemon client
59
- /** Bounded tail of crtrd.err surfaced on a cold-start `/healthz` timeout.
59
+ /** Bounded tail of crtrd.log surfaced on a cold-start `/healthz` timeout.
60
60
  * Large enough to catch a real startup failure/stack trace,
61
61
  * small enough that a runaway daemon log can never leak unbounded into a CLI
62
62
  * error message. */
63
63
  export const COLD_START_DIAGNOSTIC_TAIL_BYTES = 4_096;
64
64
  const COLD_START_DIAGNOSTIC_TAIL_LINES = 20;
65
- /** Read a bounded tail of crtrd's stderr log to enrich a `daemon_unavailable`
65
+ /** Read a bounded tail of crtrd's event log to enrich a `daemon_unavailable`
66
66
  * cold-start timeout, so the actual startup failure is not discarded behind a
67
67
  * bare message. Bounds the I/O itself — opens the file and reads only the
68
68
  * final `COLD_START_DIAGNOSTIC_TAIL_BYTES` bytes via `fstatSync`+`readSync`,
@@ -73,7 +73,7 @@ const COLD_START_DIAGNOSTIC_TAIL_LINES = 20;
73
73
  export function readColdStartDiagnostic() {
74
74
  let fd;
75
75
  try {
76
- fd = openSync(join(crtrHome(), 'crtrd.err'), 'r');
76
+ fd = openSync(join(crtrHome(), 'crtrd.log'), 'r');
77
77
  const { size } = fstatSync(fd);
78
78
  const start = Math.max(0, size - COLD_START_DIAGNOSTIC_TAIL_BYTES);
79
79
  const length = size - start;
@@ -83,7 +83,7 @@ export function readColdStartDiagnostic() {
83
83
  readSync(fd, buffer, 0, length, start);
84
84
  const lines = buffer.toString('utf8').split('\n').filter((line) => line.trim() !== '');
85
85
  const tail = lines.slice(-COLD_START_DIAGNOSTIC_TAIL_LINES).join('\n').trim();
86
- return tail === '' ? undefined : `crtrd.err (tail):\n${tail}`;
86
+ return tail === '' ? undefined : `crtrd.log (tail):\n${tail}`;
87
87
  }
88
88
  catch {
89
89
  return undefined;
@@ -124,7 +124,7 @@ export function cliClient(opts) {
124
124
  ensureDaemon();
125
125
  },
126
126
  // On a cold-start timeout, enrich the bare `daemon_unavailable` with the
127
- // tail of crtrd's own stderr log instead of discarding it.
127
+ // tail of crtrd's event log instead of discarding it.
128
128
  coldStartDiagnostic: readColdStartDiagnostic,
129
129
  coldStartPollWindowMs: DAEMON_STARTUP_WINDOW_MS,
130
130
  });
@@ -64,7 +64,7 @@ export const browseLeaf = defineLeaf({
64
64
  profileId,
65
65
  source,
66
66
  viewedNodeIds: async () => new Set((await cliClient().listFocuses()).map((focus) => focus.node_id)),
67
- closeNode: (id) => cliClient().closeNode(id, { finish: true }).then((r) => ({ closed: r.closed })),
67
+ closeNode: (id) => cliClient().closeNode(id, { finish: true, caller_pid: process.ppid }).then((r) => ({ closed: r.closed })),
68
68
  });
69
69
  },
70
70
  });
@@ -211,7 +211,8 @@ async function runNodeCreation(input) {
211
211
  if (noKickoff && mode === 'orchestrator') {
212
212
  throw new InputError({ error: 'no_kickoff_orchestrator_unsupported', message: '--no-kickoff cannot be combined with --mode orchestrator', field: 'no-kickoff', next: 'Drop --no-kickoff and provide a prompt, or spawn in base mode.' });
213
213
  }
214
- const parentId = parent ?? envNodeId();
214
+ const creator = envNodeId();
215
+ const parentId = parent ?? creator;
215
216
  mark('node_new.create_start', { root });
216
217
  const detail = await cliClient().createNode({
217
218
  ...(kind !== undefined ? { kind } : {}),
@@ -224,6 +225,7 @@ async function runNodeCreation(input) {
224
225
  ...(name !== undefined ? { name } : {}),
225
226
  prompt,
226
227
  ...(root ? { root: true } : { parent: parentId ?? null }),
228
+ ...(creator !== undefined ? { creator } : {}),
227
229
  ...(resident ? { root_lifecycle: 'resident' } : {}),
228
230
  ...(worktreeBase !== undefined ? { worktree: worktreeBase } : {}),
229
231
  ...(forkFrom !== undefined ? { fork_from: forkFrom } : {}),
@@ -214,6 +214,7 @@ const nodeShow = defineLeaf({
214
214
  ];
215
215
  if (node['parent'])
216
216
  meta.push(`- parent: ${node['parent']}`);
217
+ meta.push(`- creator: ${node['creator'] ?? 'an external process'}`);
217
218
  if (node['waiting_for'])
218
219
  meta.push(`- waiting for message: ${node['waiting_for']}`);
219
220
  parts.push(meta.join('\n'));
@@ -100,7 +100,7 @@ const nodeClose = defineLeaf({
100
100
  throw new InputError({ error: 'no_node', message: 'no node found in this pane to close', next: 'Pass --node <id>, or run from inside the agent\'s pane.' });
101
101
  }
102
102
  const res = await cliClient()
103
- .closeNode(id)
103
+ .closeNode(id, { caller_pid: process.ppid })
104
104
  .catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
105
105
  return { node_id: res.root, count: res.closed.length, closed_ids: res.closed, spared: res.spared };
106
106
  },
@@ -46,7 +46,7 @@ function messageSendParams() {
46
46
  { kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. Required unless --situational-context is supplied.' },
47
47
  { kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
48
48
  { kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
49
- { kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, default: 'normal', constraint: 'Delivery urgency. Deferred never wakes an idle node.' },
49
+ { kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, default: 'normal', constraint: 'Delivery urgency. Deferred never wakes an idle node, except a terminal one, which has no later cycle to read it on and is raised to urgent.' },
50
50
  { kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Non-empty hidden ambient context delivered beside the message, or alone.' },
51
51
  { kind: 'flag', name: 'reopen', type: 'bool', required: false, constraint: 'Re-task the target: commits it resident and clears any finalization latch before delivery. Valid for live, parked, and finalized targets.' },
52
52
  ];
@@ -97,9 +97,11 @@ async function runMessageEngine(input) {
97
97
  }).catch(rethrowAsCliError);
98
98
  const guidance = res.revived
99
99
  ? 'Delivered — the dormant target was revived to receive it.'
100
- : tier === 'deferred'
101
- ? "Delivered — held for the target's next natural cycle; not revived."
102
- : 'Delivered.';
100
+ : tier !== 'deferred'
101
+ ? 'Delivered.'
102
+ : res.delivered_tier === undefined
103
+ ? "Delivered — held for the target's next natural cycle; not revived."
104
+ : 'Delivered — the target is terminal and takes no later cycle, so it was raised to urgent rather than held.';
103
105
  return { revived: res.revived, guidance };
104
106
  }
105
107
  const nodeMessageSend = defineLeaf({
@@ -46,7 +46,7 @@ function deadlineHeredoc(body, nodeId) {
46
46
  const lines = new Set(body.split('\n'));
47
47
  while (lines.has(delimiter))
48
48
  delimiter = `CRTR_DEADLINE_${++suffix}`;
49
- return `crtr node message send --to ${shellQuote(nodeId)} --tier urgent <<'${delimiter}'\n${body}\n${delimiter}`;
49
+ return `crtr cron cancel "$CRTR_CRON_ID" && crtr node message send --to ${shellQuote(nodeId)} --tier urgent <<'${delimiter}'\n${body}\n${delimiter}`;
50
50
  }
51
51
  const nodeWaitDeadline = defineLeaf({
52
52
  name: 'deadline',
@@ -1,15 +1,14 @@
1
1
  // `crtr push` — the one verb up the graph.
2
2
  //
3
- // crtr push update [body] — routine progress to subscribers
4
- // crtr push urgent [body] — force-wake subscribers
3
+ // crtr push update [body] — report progress to subscribers
5
4
  // crtr push final [body] — finish: write result, mark node done, close window
6
5
  //
7
- // "push" is THE verb a node uses to talk to its managers; the tier is the
8
- // subcommand. The caller's node is resolved from CRTR_NODE_ID (injected by the
9
- // runtime into every node process). Absent ⇒ InputError — push is only
10
- // meaningful inside a live node.
6
+ // "push" is THE verb a node uses to talk to its managers. `update` reports
7
+ // progress; its --tier sets delivery urgency. The caller's node is resolved from
8
+ // CRTR_NODE_ID (injected by the runtime into every node process). Absent ⇒
9
+ // InputError — push is only meaningful inside a live node.
11
10
  //
12
- // STAGE B-5: this verb is a PURE crtrd API client. Every tier goes through
11
+ // STAGE B-5: this verb is a PURE crtrd API client. Every report goes through
13
12
  // `POST /nodes/{id}/reports` (`client.pushReport`); the report file write, the
14
13
  // per-subscriber inbox fan, and (final only) the lifecycle transition all happen
15
14
  // server-side inside feed `push()` (spec §7.4). This file reaches ZERO canvas
@@ -32,34 +31,32 @@ function requireCallerNode() {
32
31
  }
33
32
  return id.trim();
34
33
  }
35
- const TIER_BLURB = {
36
- update: 'routine progress — fans a pointer to subscribers, no forced wake',
37
- urgent: 'force-wake subscribers (inbox tier urgent)',
34
+ const PUSH_BLURB = {
35
+ update: 'report progress to subscribers',
38
36
  final: 'finish: write the canonical result, mark the node done, close its window',
39
37
  };
40
- const TIER_WHENTOUSE = {
41
- update: 'you have routine progress worth surfacing to your managers but nothing that needs them to act right now — a checkpoint, a finished sub-step, a status note while you keep working, a heads-up on a decision you made. Fans a lightweight pointer to every subscriber without forcing a wake. Nothing is pushed automatically — your managers see only what you push explicitly, so reach for this whenever you want a progress signal to reach them. Use push urgent instead when a manager must see it immediately, push final when the work is actually done.',
42
- urgent: 'something your managers must see and act on immediately — you are blocked and need a decision, you hit an error that derails the plan, a discovery changes the scope, or a child reported something that has to travel further up the chain now. Same report mechanism as push update, but it force-wakes every subscriber instead of waiting for them to drain their feed on their own time. Use push update instead for progress that can wait, push final when you are handing back a finished result rather than raising an alarm.',
43
- final: 'the work this node was spawned to do is complete and you are ready to hand back the canonical result — this writes that result, marks the node done, and closes its window, so it is the LAST thing you do here, not a progress note. Any node finishes this way. Use push update or push urgent instead while work is still in flight, and do not reach for this on a node working directly with the user: it has no manager to report up to and would close mid-conversation (the guard blocks it unless you pass --force after the user confirms).',
38
+ const PUSH_WHEN_TO_USE = {
39
+ update: 'you have progress worth surfacing to your managers while work remains in flight. Use normal delivery unless it can wait for their next real wake (--tier deferred) or needs immediate attention (--tier urgent); use push final only when the work is actually done.',
40
+ final: 'the work this node was spawned to do is complete and you are ready to hand back the canonical result — this writes that result, marks the node done, and closes its window, so it is the LAST thing you do here, not a progress note. Use push update while work is still in flight, and do not reach for final on a node working directly with the user: it has no manager to report up to and would close mid-conversation (the guard blocks it unless you pass --force after the user confirms).',
44
41
  };
45
- function makeTierLeaf(tier) {
42
+ function makePushLeaf(kind) {
46
43
  return defineLeaf({
47
- name: tier,
48
- description: TIER_BLURB[tier],
49
- whenToUse: TIER_WHENTOUSE[tier],
44
+ name: kind,
45
+ description: PUSH_BLURB[kind],
46
+ whenToUse: PUSH_WHEN_TO_USE[kind],
50
47
  help: {
51
- name: `push ${tier}`,
52
- summary: TIER_BLURB[tier],
48
+ name: `push ${kind}`,
49
+ summary: PUSH_BLURB[kind],
53
50
  params: [
54
51
  { kind: 'stdin', name: 'body', required: true, constraint: "Report body (markdown). Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes." },
55
- ...(tier === 'final'
56
- ? [{ kind: 'flag', name: 'force', type: 'bool', required: false, default: false, constraint: 'Override the guard that blocks `push final` on a node the user is attending (a resident with no one to report up to). Only pass this after the user explicitly confirms they want this node finished.' }]
57
- : []),
52
+ ...(kind === 'update'
53
+ ? [{ kind: 'flag', name: 'tier', type: 'enum', choices: ['deferred', 'normal', 'urgent'], required: false, default: 'normal', constraint: 'Delivery urgency: deferred holds for the receiver’s next non-deferred wake; normal wakes normally; urgent steers a live receiver and wakes an idle receiver.' }]
54
+ : [{ kind: 'flag', name: 'force', type: 'bool', required: false, default: false, constraint: 'Override the guard that blocks `push final` on a node the user is attending (a resident with no one to report up to). Only pass this after the user explicitly confirms they want this node finished.' }]),
58
55
  ],
59
56
  output: [
60
57
  { name: 'report_path', type: 'string', required: true, constraint: 'Absolute path of the written report.' },
61
58
  { name: 'notified', type: 'string[]', required: true, constraint: 'Subscriber node ids that received an inbox pointer.' },
62
- ...(tier === 'final'
59
+ ...(kind === 'final'
63
60
  ? [
64
61
  { name: 'transitioned', type: 'object', required: false, constraint: 'Present only for a final that flipped the node — { from, to } lifecycle-status transition applied server-side.' },
65
62
  { name: 'worktree_auto_dropped', type: 'boolean', required: false, constraint: 'Present only when this node owned a managed worktree with zero commits ahead of its base and no uncommitted changes — the server auto-dropped it instead of requiring `node worktree close` first (see worktree_auto_dropped_path).' },
@@ -69,9 +66,9 @@ function makeTierLeaf(tier) {
69
66
  ],
70
67
  outputKind: 'object',
71
68
  effects: [
72
- 'Writes nodes/<nodeId>/reports/<ts>-<tier>.md (server-side).',
69
+ 'Writes nodes/<nodeId>/reports/<ts>-<kind>.md (server-side).',
73
70
  'Appends one inbox pointer per subscriber.',
74
- ...(tier === 'final'
71
+ ...(kind === 'final'
75
72
  ? [
76
73
  'Marks the node done (status + intent) server-side; its engine shuts down on next stop.',
77
74
  'When this node owns a managed worktree with zero commits ahead of its base and no uncommitted changes, the server auto-drops it instead of blocking on `node worktree close`; a worktree with real commits or real uncommitted changes still blocks with open_managed_worktree.',
@@ -96,7 +93,7 @@ function makeTierLeaf(tier) {
96
93
  // subscribers was deliberately terminalized to owe a final — it
97
94
  // self-completes server-side. This preflight reads the node through the
98
95
  // API (NodeDetailDTO), never canvas.db — no state-store reach.
99
- if (tier === 'final' && input['force'] !== true) {
96
+ if (kind === 'final' && input['force'] !== true) {
100
97
  let detail = null;
101
98
  try {
102
99
  detail = await client.getNode(nodeId);
@@ -118,8 +115,13 @@ function makeTierLeaf(tier) {
118
115
  }
119
116
  }
120
117
  try {
121
- const result = await client.pushReport(nodeId, { tier, body });
122
- if (tier === 'final')
118
+ const deliveryTier = input['tier'];
119
+ const result = await client.pushReport(nodeId, {
120
+ tier: kind,
121
+ body,
122
+ ...(deliveryTier === undefined ? {} : { delivery_tier: deliveryTier }),
123
+ });
124
+ if (kind === 'final')
123
125
  markStopSignal(nodeId, 'pushedFinal');
124
126
  return {
125
127
  report_path: result.report_path,
@@ -136,30 +138,27 @@ function makeTierLeaf(tier) {
136
138
  },
137
139
  render: (r) => {
138
140
  const n = Array.isArray(r['notified']) ? r['notified'].length : 0;
139
- const line = tier === 'final'
141
+ const line = kind === 'final'
140
142
  ? 'Result recorded — node finished; its engine shuts down on next stop. Nothing more to do here: STOP your turn immediately. Reply with just "Done." and nothing else.'
141
- : tier === 'urgent'
142
- ? `Urgent report fanned to ${n} subscriber(s) — they are force-woken.`
143
- : `Progress report fanned to ${n} subscriber(s).`;
143
+ : `Progress report fanned to ${n} subscriber(s).`;
144
144
  const dropNote = r['worktree_auto_dropped'] === true ? `\n\nAuto-dropped a clean, zero-commit managed worktree at ${String(r['worktree_auto_dropped_path'])} — nothing to land.` : '';
145
145
  return `${line}\n\n- report: ${r['report_path']}${dropNote}`;
146
146
  },
147
147
  });
148
148
  }
149
- // Registration
150
149
  export function registerPush() {
151
150
  return defineBranch({
152
151
  name: 'push',
153
152
  rootEntry: {
154
153
  concept: 'the one verb up the graph — a node reports to whoever subscribes to it',
155
- desc: 'push a report (update / urgent / final) to your subscribers',
154
+ desc: 'push an update or final report to your subscribers',
156
155
  useWhen: 'sharing progress, raising an alert, or finishing your work',
157
156
  },
158
157
  help: {
159
158
  name: 'push',
160
159
  summary: 'push a report to your subscribers',
161
- model: "A push writes a markdown report to the node's reports/ history and fans a lightweight pointer to every subscriber's inbox. Delivery inlines reports under 1,000 characters with their absolute path; larger reports remain pointers to dereference. Nothing is pushed automatically — the feed contains only what a node pushes explicitly, so push whenever you want a manager to see something. Pipe markdown via a single-quoted heredoc (`crtr push <tier> <<'EOF' … EOF`) so it reaches the report literally. `push final` is how ANY node finishes: write the canonical result, mark done, close the window.",
160
+ model: "A push writes a markdown report to the node's reports/ history and fans a lightweight pointer to every subscriber's inbox. Delivery inlines reports under 1,000 characters with their absolute path; larger reports remain pointers to dereference. Nothing is pushed automatically — the feed contains only what a node pushes explicitly, so push whenever you want a manager to see something. Pipe markdown via a single-quoted heredoc so it reaches the report literally. `push final` is how ANY node finishes: write the canonical result, mark done, close the window.",
162
161
  },
163
- children: [makeTierLeaf('update'), makeTierLeaf('urgent'), makeTierLeaf('final')],
162
+ children: [makePushLeaf('update'), makePushLeaf('final')],
164
163
  });
165
164
  }