@crouton-kit/crouter 0.3.18 → 0.3.19

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 (257) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/orchestrator.md +1 -1
  12. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  13. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  14. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  15. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  16. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  17. package/dist/builtin-personas/runtime-base.md +6 -3
  18. package/dist/builtin-personas/spec/orchestrator.md +1 -1
  19. package/dist/builtin-personas/waiting.md +8 -0
  20. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  21. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  22. package/dist/builtin-views/_lib/states.mjs +161 -0
  23. package/dist/builtin-views/canvas/client.mjs +303 -0
  24. package/dist/builtin-views/canvas/view.mjs +576 -0
  25. package/dist/builtin-views/git-pr/client.mjs +440 -0
  26. package/dist/builtin-views/git-pr/view.mjs +675 -0
  27. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  28. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  29. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  30. package/dist/builtin-views/inbox/view.mjs +889 -0
  31. package/dist/builtin-views/linkedin/client.mjs +610 -0
  32. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  34. package/dist/clients/attach/attach-cmd.js +354 -0
  35. package/dist/clients/attach/chat-view.d.ts +77 -0
  36. package/dist/clients/attach/chat-view.js +450 -0
  37. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  38. package/dist/clients/attach/clipboard-image.js +113 -0
  39. package/dist/clients/attach/config-load.d.ts +31 -0
  40. package/dist/clients/attach/config-load.js +113 -0
  41. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  42. package/dist/clients/attach/extension-dialogs.js +101 -0
  43. package/dist/clients/attach/input-controller.d.ts +54 -0
  44. package/dist/clients/attach/input-controller.js +204 -0
  45. package/dist/clients/attach/slash-commands.d.ts +36 -0
  46. package/dist/clients/attach/slash-commands.js +200 -0
  47. package/dist/clients/attach/view-socket.d.ts +48 -0
  48. package/dist/clients/attach/view-socket.js +126 -0
  49. package/dist/commands/attention.js +3 -3
  50. package/dist/commands/canvas-prune.js +1 -1
  51. package/dist/commands/daemon.js +3 -2
  52. package/dist/commands/human/prompts.js +1 -1
  53. package/dist/commands/human/queue.js +43 -8
  54. package/dist/commands/human/shared.d.ts +2 -0
  55. package/dist/commands/memory/find.d.ts +1 -0
  56. package/dist/commands/memory/find.js +180 -0
  57. package/dist/commands/memory/lint.d.ts +1 -0
  58. package/dist/commands/memory/lint.js +140 -0
  59. package/dist/commands/memory/list.d.ts +1 -0
  60. package/dist/commands/memory/list.js +79 -0
  61. package/dist/commands/memory/read.js +103 -0
  62. package/dist/commands/memory/shared.d.ts +30 -0
  63. package/dist/commands/memory/shared.js +122 -0
  64. package/dist/commands/memory/write.d.ts +1 -0
  65. package/dist/commands/memory/write.js +85 -0
  66. package/dist/commands/memory.d.ts +2 -0
  67. package/dist/commands/memory.js +27 -0
  68. package/dist/commands/node.d.ts +3 -2
  69. package/dist/commands/node.js +607 -61
  70. package/dist/commands/pkg/market-manage.js +1 -1
  71. package/dist/commands/push.js +6 -6
  72. package/dist/commands/revive.js +1 -1
  73. package/dist/commands/skill/author.js +1 -1
  74. package/dist/commands/skill/shared.d.ts +1 -8
  75. package/dist/commands/skill/shared.js +2 -55
  76. package/dist/commands/skill.js +9 -14
  77. package/dist/commands/sys/doctor.js +1 -1
  78. package/dist/commands/sys/update.js +1 -1
  79. package/dist/commands/view-cycle.d.ts +2 -0
  80. package/dist/commands/view-cycle.js +125 -0
  81. package/dist/commands/view-list.d.ts +2 -0
  82. package/dist/commands/view-list.js +66 -0
  83. package/dist/commands/view-new.d.ts +2 -0
  84. package/dist/commands/view-new.js +70 -0
  85. package/dist/commands/view-pick.d.ts +2 -0
  86. package/dist/commands/view-pick.js +119 -0
  87. package/dist/commands/view-run.d.ts +2 -0
  88. package/dist/commands/view-run.js +191 -0
  89. package/dist/commands/view.d.ts +2 -0
  90. package/dist/commands/view.js +29 -0
  91. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  92. package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
  93. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  94. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  95. package/dist/core/__tests__/cascade-close.test.js +12 -2
  96. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  97. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  98. package/dist/core/__tests__/context-intro.test.js +76 -62
  99. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  100. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  101. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  102. package/dist/core/__tests__/detach-focus.test.js +206 -0
  103. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  104. package/dist/core/__tests__/draw-style.test.js +258 -0
  105. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  106. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  107. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  108. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  109. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  110. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  111. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  112. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  113. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  114. package/dist/core/__tests__/helpers/harness.js +111 -1
  115. package/dist/core/__tests__/home-session.test.js +41 -8
  116. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  117. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  118. package/dist/core/__tests__/kickoff.test.js +37 -3
  119. package/dist/core/__tests__/live-mutation.test.js +50 -33
  120. package/dist/core/__tests__/memory.test.js +23 -115
  121. package/dist/core/__tests__/placement-focus.test.js +5 -0
  122. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  123. package/dist/core/__tests__/relaunch.test.js +4 -3
  124. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  125. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  126. package/dist/core/__tests__/spawn-root.test.js +10 -0
  127. package/dist/core/__tests__/spike-harness.test.js +1 -0
  128. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  129. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  130. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  131. package/dist/core/__tests__/wake-origin.test.js +110 -0
  132. package/dist/core/bootstrap.js +1 -1
  133. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  134. package/dist/core/canvas/browse/app.js +24 -2
  135. package/dist/core/canvas/browse/model.d.ts +38 -2
  136. package/dist/core/canvas/browse/model.js +134 -10
  137. package/dist/core/canvas/browse/render.d.ts +6 -12
  138. package/dist/core/canvas/browse/render.js +72 -104
  139. package/dist/core/canvas/canvas.js +15 -22
  140. package/dist/core/canvas/db.js +46 -0
  141. package/dist/core/canvas/index.d.ts +1 -0
  142. package/dist/core/canvas/index.js +1 -0
  143. package/dist/core/canvas/pid.d.ts +4 -0
  144. package/dist/core/canvas/pid.js +23 -0
  145. package/dist/core/canvas/render.d.ts +11 -2
  146. package/dist/core/canvas/render.js +69 -0
  147. package/dist/core/canvas/types.d.ts +85 -3
  148. package/dist/core/canvas/wakeups.d.ts +76 -0
  149. package/dist/core/canvas/wakeups.js +185 -0
  150. package/dist/core/config.js +4 -1
  151. package/dist/core/frontmatter.js +37 -124
  152. package/dist/core/memory-resolver.d.ts +49 -0
  153. package/dist/core/memory-resolver.js +141 -0
  154. package/dist/core/personas/loader.d.ts +7 -0
  155. package/dist/core/personas/loader.js +36 -5
  156. package/dist/core/personas/resolve.js +13 -3
  157. package/dist/core/predicate.d.ts +63 -0
  158. package/dist/core/predicate.js +189 -0
  159. package/dist/core/resolver.js +26 -5
  160. package/dist/core/runtime/bearings.d.ts +53 -12
  161. package/dist/core/runtime/bearings.js +132 -59
  162. package/dist/core/runtime/broker-cli.d.ts +1 -0
  163. package/dist/core/runtime/broker-cli.js +46 -0
  164. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  165. package/dist/core/runtime/broker-protocol.js +153 -0
  166. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  167. package/dist/core/runtime/broker-sdk.js +72 -0
  168. package/dist/core/runtime/broker.d.ts +55 -0
  169. package/dist/core/runtime/broker.js +1128 -0
  170. package/dist/core/runtime/close.js +35 -6
  171. package/dist/core/runtime/host.d.ts +53 -0
  172. package/dist/core/runtime/host.js +186 -0
  173. package/dist/core/runtime/kickoff.d.ts +2 -1
  174. package/dist/core/runtime/kickoff.js +91 -5
  175. package/dist/core/runtime/launch.d.ts +45 -2
  176. package/dist/core/runtime/launch.js +65 -2
  177. package/dist/core/runtime/lifecycle.js +23 -6
  178. package/dist/core/runtime/memory.d.ts +2 -42
  179. package/dist/core/runtime/memory.js +11 -162
  180. package/dist/core/runtime/nodes.d.ts +33 -0
  181. package/dist/core/runtime/nodes.js +59 -1
  182. package/dist/core/runtime/persona.js +21 -11
  183. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  184. package/dist/core/runtime/pi-vendored.js +49 -0
  185. package/dist/core/runtime/placement.d.ts +32 -14
  186. package/dist/core/runtime/placement.js +206 -52
  187. package/dist/core/runtime/promote.d.ts +0 -6
  188. package/dist/core/runtime/promote.js +1 -12
  189. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  190. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  191. package/dist/core/runtime/reset.js +8 -6
  192. package/dist/core/runtime/revive.d.ts +2 -0
  193. package/dist/core/runtime/revive.js +34 -34
  194. package/dist/core/runtime/spawn.d.ts +19 -0
  195. package/dist/core/runtime/spawn.js +75 -22
  196. package/dist/core/runtime/stop-guard.d.ts +1 -1
  197. package/dist/core/runtime/stop-guard.js +6 -1
  198. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  199. package/dist/core/runtime/tmux-chrome.js +1 -1
  200. package/dist/core/runtime/tmux.d.ts +28 -0
  201. package/dist/core/runtime/tmux.js +80 -6
  202. package/dist/core/scope.d.ts +11 -0
  203. package/dist/core/scope.js +39 -0
  204. package/dist/core/spawn.d.ts +5 -0
  205. package/dist/core/spawn.js +20 -2
  206. package/dist/core/substrate/gate.d.ts +13 -0
  207. package/dist/core/substrate/gate.js +21 -0
  208. package/dist/core/substrate/index.d.ts +7 -0
  209. package/dist/core/substrate/index.js +18 -0
  210. package/dist/core/substrate/on-read.d.ts +14 -0
  211. package/dist/core/substrate/on-read.js +292 -0
  212. package/dist/core/substrate/render.d.ts +25 -0
  213. package/dist/core/substrate/render.js +256 -0
  214. package/dist/core/substrate/schema.d.ts +76 -0
  215. package/dist/core/substrate/schema.js +124 -0
  216. package/dist/core/substrate/session-cache.d.ts +30 -0
  217. package/dist/core/substrate/session-cache.js +77 -0
  218. package/dist/core/substrate/subject.d.ts +41 -0
  219. package/dist/core/substrate/subject.js +54 -0
  220. package/dist/core/tui/contract.d.ts +83 -0
  221. package/dist/core/tui/contract.js +8 -0
  222. package/dist/core/tui/draw.d.ts +96 -0
  223. package/dist/core/tui/draw.js +339 -0
  224. package/dist/core/tui/host.d.ts +29 -0
  225. package/dist/core/tui/host.js +379 -0
  226. package/dist/core/tui/loader.d.ts +16 -0
  227. package/dist/core/tui/loader.js +94 -0
  228. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  229. package/dist/core/wake.d.ts +86 -0
  230. package/dist/core/wake.js +308 -0
  231. package/dist/daemon/crtrd.d.ts +29 -4
  232. package/dist/daemon/crtrd.js +662 -46
  233. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  234. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  235. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  236. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  237. package/dist/pi-extensions/canvas-commands.js +10 -0
  238. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  239. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  240. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  241. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  242. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  243. package/dist/pi-extensions/canvas-nav.js +87 -23
  244. package/dist/pi-extensions/canvas-stophook.js +17 -8
  245. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  246. package/dist/pi-extensions/canvas-view.js +75 -0
  247. package/dist/prompts/skill.js +19 -26
  248. package/dist/prompts/view.d.ts +7 -0
  249. package/dist/prompts/view.js +101 -0
  250. package/dist/types.d.ts +4 -0
  251. package/dist/types.js +1 -0
  252. package/package.json +9 -4
  253. package/dist/commands/skill/find.d.ts +0 -4
  254. package/dist/commands/skill/find.js +0 -257
  255. package/dist/commands/skill/read.js +0 -91
  256. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  257. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -7,10 +7,11 @@
7
7
  // the regression guard for the old "build had hidden side effects" smell.
8
8
  import { test, before, after, beforeEach } from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
- import { mkdtempSync, rmSync, existsSync } from 'node:fs';
11
- import { tmpdir } from 'node:os';
10
+ import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  import { createNode, subscribe } from '../canvas/canvas.js';
14
+ import { reportsDir } from '../canvas/paths.js';
14
15
  import { closeDb } from '../canvas/db.js';
15
16
  import { drainBearings, buildReviveKickoff, writeYieldMessage, yieldMessagePath, } from '../runtime/kickoff.js';
16
17
  import { appendInbox, readCursor } from '../feed/inbox.js';
@@ -28,7 +29,9 @@ function node(id) {
28
29
  };
29
30
  }
30
31
  before(() => {
31
- home = mkdtempSync(join(tmpdir(), 'crtr-kickoff-'));
32
+ // Under homedir (not tmpdir) so report paths fall under the home prefix and
33
+ // exercise the tildify() collapse in reportHistoryLines.
34
+ home = mkdtempSync(join(homedir(), '.crtr-kickoff-'));
32
35
  process.env['CRTR_HOME'] = home;
33
36
  });
34
37
  beforeEach(() => {
@@ -70,6 +73,37 @@ test('the feed block frames awaiting workers as alive + auto-waking, so a fresh
70
73
  assert.ok(/wake you the moment/.test(msg), 'states the wake is automatic on push');
71
74
  assert.ok(/still working, not stalled/.test(msg), 'frames the empty feed as expected, not a problem');
72
75
  });
76
+ test('a fresh revive is pointed at its subscriptions\' on-disk report history (catch-up bug)', () => {
77
+ // Regression for the fresh-revive catch-up bug: on a refresh-yield
78
+ // (resume:false) the old conversation is gone AND the inbox cursor has already
79
+ // advanced past everything drained pre-yield, so the revived node loses sight
80
+ // of reports its subscriptions pushed BEFORE the yield. The bodies persist
81
+ // forever at reports/<ts>-<kind>.md; the kickoff must point the node at those
82
+ // existing paths so it can catch up. Asserts the paths are rendered.
83
+ const parent = createNode(node('p1'));
84
+ const worker = createNode(node('w1'));
85
+ subscribe(parent.node_id, worker.node_id);
86
+ // The worker pushed two reports BEFORE the parent's refresh-yield.
87
+ const dir = reportsDir(worker.node_id);
88
+ mkdirSync(dir, { recursive: true });
89
+ const older = join(dir, '20260608T120000-update.md');
90
+ const newer = join(dir, '20260608T130000-final.md');
91
+ writeFileSync(older, '---\n---\nearly progress', 'utf8');
92
+ writeFileSync(newer, '---\n---\nthe result', 'utf8');
93
+ const msg = buildReviveKickoff(parent, drainBearings(parent));
94
+ // Both existing report PATHS surface (most recent first), so the revived node
95
+ // can dereference the history its advanced cursor would otherwise hide. They
96
+ // render home-relative with a leading ~ (the absolute home prefix is wasted
97
+ // context tokens; ~ is still dereferenceable by the revived node).
98
+ const olderRel = '~' + older.slice(homedir().length);
99
+ const newerRel = '~' + newer.slice(homedir().length);
100
+ assert.ok(msg.includes(olderRel), 'older report path surfaced, home-relative (~)');
101
+ assert.ok(msg.includes(newerRel), 'newer report path surfaced, home-relative (~)');
102
+ assert.ok(!msg.includes(older), 'absolute home prefix not rendered');
103
+ assert.ok(msg.indexOf(newerRel) < msg.indexOf(olderRel), 'most recent report listed first');
104
+ // ...and the hint that the full inbox history is replayable cursor-independently.
105
+ assert.ok(/feed read --all/.test(msg), 'points at the cursor-independent full-history replay');
106
+ });
73
107
  test('buildReviveKickoff is pure — building twice eats nothing', () => {
74
108
  const id = 'n2';
75
109
  const meta = createNode(node(id));
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // AXIS: LIVE MUTATION of the 2×2 state vector (mode {base,orchestrator} ×
4
4
  // lifecycle {terminal,resident}) while a node is ACTIVE/LIVE — driven through
5
- // the REAL `crtr node lifecycle` / `node promote` / `node demote` CLI verbs
6
- // against a live fake-pi, with the REAL stophook / kickoff / daemon hooks doing
5
+ // the REAL `crtr node lifecycle` / `node promote` / `node demote` / `node recycle`
6
+ // CLI verbs against a live fake-pi, with the REAL stophook / kickoff / daemon hooks doing
7
7
  // the work. Every assertion reads the canvas data layer and is checked against
8
8
  // the state-model ORACLE (mq1su40t .../state-model.md).
9
9
  //
@@ -219,54 +219,71 @@ test('live mode flip: promote base→orchestrator recomposes persona_ack at turn
219
219
  }
220
220
  });
221
221
  // ===========================================================================
222
- // (b) MODE FLIP demote. FLAG vs the task framing ("demote orchestrator back
223
- // to base; assert the mode field changes"): the `node demote` verb does NOT
224
- // flip the SAME live node's mode orchestrator→base. Per ORACLE §4 (which
225
- // matches the code: demote.ts) it FINISHES the node (push final done) and
226
- // RECYCLES the pane into a FRESH general/base/resident root — a DIFFERENT
227
- // node. The demoted node keeps mode=orchestrator (it is merely `done`).
228
- // There is NO live verb that flips a node orchestrator→base, so the
229
- // persona.ts `baseModeGuidance` (orchestrator→base) is effectively
230
- // UNREACHABLE via live mutation. This test pins the real behavior so the
231
- // contradiction is visible; production is NOT changed.
222
+ // (b) THE demote / recycle SPLIT two DISTINCT verbs after the rename:
223
+ // `node demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE it keeps
224
+ // its pane, its MODE, and its parentage, keeps running, is NOT finalized; it
225
+ // now merely owes a final up the spine (vision F5). It is NOT an
226
+ // orchestrator→base mode flip MODE is untouched (so persona.ts
227
+ // `baseModeGuidance` stays unreachable via live mutation, as before).
228
+ // `node recycle` is FINISH+RECYCLE push final done, then recycle the
229
+ // pane into a FRESH general/base/resident root (a DIFFERENT node). The
230
+ // recycled node keeps mode=orchestrator (it is merely `done`).
231
+ // This test drives BOTH real verbs on one live node and pins each behavior.
232
232
  // ===========================================================================
233
- test('node demote is FINISH+RECYCLE, not an orchestratorbase mode flip (current behavior vs task framing)', { skip: SKIP, timeout: 120_000 }, async () => {
233
+ test('node demote flips lifecycleterminal IN PLACE; node recycle is FINISH+RECYCLE', { skip: SKIP, timeout: 120_000 }, async () => {
234
234
  const h = await createHarness({ sessionPrefix: 'crtr-live-demote' });
235
235
  try {
236
236
  const A = h.spawnRoot('resident root');
237
237
  const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
238
- // Promote B so it is genuinely an orchestrator before we demote it.
238
+ // Make B resident + orchestrator so the demote's flip→terminal is visible
239
+ // and we can prove MODE/parentage survive it.
240
+ assert.equal(h.cli(B, ['node', 'lifecycle', 'resident', '--node', B]).code, 0, 'B → resident');
239
241
  assert.equal(h.cli(B, ['node', 'promote', '--kind', 'developer']).code, 0, 'promote B');
240
- const b0 = h.node(B);
241
- assert.equal(b0.mode, 'orchestrator', 'B is orchestrator before demote');
242
- // Resolve B's live %pane_id from its window (the row's `pane` is null until
243
- // a reconcile; the spawn path records only window+session).
244
- const pane = firstPaneOf(b0.window);
242
+ {
243
+ const b = h.node(B);
244
+ assert.equal(b.lifecycle, 'resident', 'B resident before demote');
245
+ assert.equal(b.mode, 'orchestrator', 'B orchestrator before demote');
246
+ }
247
+ const bParent = h.node(B).parent;
248
+ // --- node demote: flip-to-terminal IN PLACE. Keeps B alive, MODE/parentage
249
+ // untouched, NOT finalized.
250
+ const dem = h.cli(B, ['node', 'demote', '--node', B]);
251
+ assert.equal(dem.code, 0, `node demote exit 0\n${dem.stderr}`);
252
+ assert.match(dem.stdout, /<demoted /, `demote rendered\n${dem.stdout}`);
253
+ {
254
+ const b = h.node(B);
255
+ assert.equal(b.lifecycle, 'terminal', 'demote flips lifecycle→terminal IN PLACE');
256
+ assert.equal(b.mode, 'orchestrator', 'demote leaves MODE untouched (not an orchestrator→base flip)');
257
+ assert.equal(b.parent, bParent, 'demote leaves parentage unchanged');
258
+ assert.equal(b.status, 'active', 'demote does NOT finish B — it keeps running in place');
259
+ assert.notEqual(b.intent ?? null, 'done', 'demote does NOT finalize B');
260
+ }
261
+ // --- node recycle: FINISH + RECYCLE the SAME pane into a fresh root.
262
+ // Resolve B's live %pane_id from its window (the row's `pane` is null
263
+ // until a reconcile; the spawn path records only window+session).
264
+ const pane = firstPaneOf(h.node(B).window);
245
265
  assert.ok(typeof pane === 'string' && pane !== '', 'B has a live pane to recycle');
246
- // DEMOTE via the real verb (TMUX_PANE is scrubbed from child env → pass --pane).
247
- const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', pane]);
248
- assert.equal(res.code, 0, `demote exit 0\n${res.stderr}`);
249
- // The leaf renders `<demoted ... finalized=".." new_root=".."/>` (not JSON).
250
- assert.match(res.stdout, /<demoted /, `demote recycled the pane\n${res.stdout}`);
266
+ // RECYCLE via the real verb (TMUX_PANE is scrubbed from child env → pass --pane).
267
+ const res = h.cli(B, ['node', 'recycle', '--node', B, '--pane', pane]);
268
+ assert.equal(res.code, 0, `recycle exit 0\n${res.stderr}`);
269
+ // The leaf renders `<recycled ... finalized=".." new_root=".."/>` (not JSON).
270
+ assert.match(res.stdout, /<recycled /, `recycle recycled the pane\n${res.stdout}`);
251
271
  const newRoot = /new_root="([^"]+)"/.exec(res.stdout)?.[1];
252
272
  const finalized = /finalized="true"/.test(res.stdout);
253
- // The demoted node is FINISHED, not mode-flipped.
273
+ // The recycled node is FINISHED, not mode-flipped.
254
274
  {
255
275
  const b = h.node(B);
256
- assert.equal(b.status, 'done', 'demoted node → done (finished), NOT re-roled');
276
+ assert.equal(b.status, 'done', 'recycled node → done (finished), NOT re-roled');
257
277
  assert.equal(b.intent, 'done', 'intent=done (finalize), per the push-final path');
258
- assert.equal(b.mode, 'orchestrator', ' demoted node KEEPS mode=orchestrator — demote is NOT an orchestrator→base flip');
259
- assert.ok(finalized, 'demote pushed a final for the node');
278
+ assert.equal(b.mode, 'orchestrator', 'recycled node KEEPS mode=orchestrator — recycle is NOT a mode flip');
279
+ assert.ok(finalized, 'recycle pushed a final for the node');
260
280
  }
261
- // The fresh root is a DIFFERENT, BASE×RESIDENT node — that is where "base"
262
- // comes from, not a mutation of B.
281
+ // The fresh root is a DIFFERENT, BASE×RESIDENT node.
263
282
  assert.ok(typeof newRoot === 'string' && newRoot !== B, 'a fresh root (≠ B) was minted');
264
283
  {
265
284
  const fresh = h.node(newRoot);
266
285
  assert.deepEqual(persona(fresh), { mode: 'base', lifecycle: 'resident' }, 'recycled root is born base×resident (general)');
267
- // Born acked to its own persona it will never see an orchestrator→base
268
- // drift steer: that persona path is unreachable through live mutation.
269
- assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident — no orchestrator→base drift will ever fire');
286
+ assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident');
270
287
  }
271
288
  }
272
289
  finally {
@@ -1,31 +1,20 @@
1
- // Tests for the three-tier scoped memory (src/core/runtime/memory.ts):
2
- // 1. The three store paths land under crtrHome (user-global / project / node).
3
- // 2. projectKey resolves the git-repo-root (walk up for .git), and falls back
4
- // to the cwd itself when not inside a repo.
5
- // 3. seed* are guarded/idempotent (never clobber an evolved index).
6
- // 4. promote() seeds ALL THREE stores, guarded across re-promotion.
7
- // 5. The kernel + promotion guidance name the type\u2192store mapping.
8
- //
9
- // CRTR_HOME isolation, like context-intro.test.ts.
1
+ // Tests for the substrate memory surface in the orchestration kernel:
2
+ // 1. The kernel names the three memory scopes and the document substrate flow
3
+ // (crtr memory write/list/find/read).
4
+ // 2. Promotion guidance names the three stores without re-listing their dirs.
10
5
  //
11
6
  // Run: node --import tsx/esm --test src/core/__tests__/memory.test.ts
12
7
  import { test, before, beforeEach, after } from 'node:test';
13
8
  import assert from 'node:assert/strict';
14
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
15
10
  import { tmpdir } from 'node:os';
16
11
  import { join } from 'node:path';
17
12
  import { closeDb } from '../canvas/db.js';
18
- import { crtrHome } from '../canvas/paths.js';
19
- import { mangleCwd } from '../artifact.js';
20
13
  import { spawnNode } from '../runtime/nodes.js';
21
14
  import { promote } from '../runtime/promote.js';
22
15
  import { personaDrift } from '../runtime/persona.js';
23
16
  import { loadKernel } from '../personas/index.js';
24
- import { memoryDir, memoryPath, userMemoryDir, userMemoryPath, hasUserMemory, readUserMemory, seedUserMemory, projectKey, projectMemoryDir, projectMemoryPath, hasProjectMemory, readProjectMemory, seedProjectMemory, hasMemory, USER_MEMORY_TEMPLATE, PROJECT_MEMORY_TEMPLATE, } from '../runtime/memory.js';
25
17
  let home;
26
- // Scratch repos created per-test for the git-root keying cases; tracked so they
27
- // can be removed in `after` regardless of which test created them.
28
- const scratch = [];
29
18
  before(() => {
30
19
  home = mkdtempSync(join(tmpdir(), 'crtr-memory-'));
31
20
  process.env['CRTR_HOME'] = home;
@@ -37,116 +26,35 @@ beforeEach(() => {
37
26
  after(() => {
38
27
  closeDb();
39
28
  rmSync(home, { recursive: true, force: true });
40
- for (const d of scratch)
41
- rmSync(d, { recursive: true, force: true });
42
29
  delete process.env['CRTR_HOME'];
43
30
  });
44
31
  // ---------------------------------------------------------------------------
45
- // Store paths
46
- // ---------------------------------------------------------------------------
47
- test('the three stores all live under crtrHome, at their scoped paths', () => {
48
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
49
- // user-global: <crtrHome>/memory/
50
- assert.equal(userMemoryDir(), join(crtrHome(), 'memory'));
51
- assert.equal(userMemoryPath(), join(crtrHome(), 'memory', 'MEMORY.md'));
52
- // project: <crtrHome>/projects/<key>/memory/
53
- assert.equal(projectMemoryDir('/tmp/work'), join(crtrHome(), 'projects', projectKey('/tmp/work'), 'memory'));
54
- assert.equal(projectMemoryPath('/tmp/work'), join(projectMemoryDir('/tmp/work'), 'MEMORY.md'));
55
- // node-local: <crtrHome>/nodes/<id>/context/memory/ (unchanged)
56
- assert.ok(memoryDir(meta.node_id).startsWith(join(crtrHome(), 'nodes', meta.node_id)));
57
- assert.ok(memoryPath(meta.node_id).endsWith('/context/memory/MEMORY.md'));
58
- });
59
- // ---------------------------------------------------------------------------
60
- // projectKey: git-root vs not-in-a-repo fallback
61
- // ---------------------------------------------------------------------------
62
- test('projectKey resolves the git-repo-root by walking up for a .git entry', () => {
63
- const repo = mkdtempSync(join(tmpdir(), 'crtr-repo-'));
64
- scratch.push(repo);
65
- mkdirSync(join(repo, '.git')); // a .git directory marks the repo root
66
- const nested = join(repo, 'pkg', 'src');
67
- mkdirSync(nested, { recursive: true });
68
- // A cwd deep inside the repo keys to the repo ROOT, not the cwd.
69
- assert.equal(projectKey(nested), mangleCwd(repo));
70
- assert.equal(projectKey(repo), mangleCwd(repo));
71
- // A .git FILE (worktree/submodule) is recognized the same way.
72
- const wt = mkdtempSync(join(tmpdir(), 'crtr-wt-'));
73
- scratch.push(wt);
74
- writeFileSync(join(wt, '.git'), 'gitdir: /elsewhere\n');
75
- assert.equal(projectKey(join(wt, 'a')), mangleCwd(wt));
76
- });
77
- test('projectKey falls back to the mangled cwd when not inside a repo', () => {
78
- const bare = mkdtempSync(join(tmpdir(), 'crtr-bare-'));
79
- scratch.push(bare);
80
- const sub = join(bare, 'x', 'y');
81
- mkdirSync(sub, { recursive: true });
82
- // No .git anywhere up the tree \u2192 the cwd itself is the key.
83
- assert.equal(projectKey(sub), mangleCwd(sub));
84
- });
85
- // ---------------------------------------------------------------------------
86
- // seed*: guarded / idempotent
87
- // ---------------------------------------------------------------------------
88
- test('seedUserMemory / seedProjectMemory write the template once, then never clobber', () => {
89
- assert.equal(hasUserMemory(), false);
90
- assert.equal(seedUserMemory(), true, 'first seed writes');
91
- assert.equal(readUserMemory(), USER_MEMORY_TEMPLATE);
92
- assert.equal(hasUserMemory(), true);
93
- const evolved = '# Memory\n\n- [Likes terse replies](terse.md) \u2014 keep it short\n';
94
- writeFileSync(userMemoryPath(), evolved);
95
- assert.equal(seedUserMemory(), false, 'second seed is a no-op');
96
- assert.equal(readUserMemory(), evolved, 'evolved index left untouched');
97
- assert.equal(seedProjectMemory('/tmp/work'), true, 'project first seed writes');
98
- assert.equal(readProjectMemory('/tmp/work'), PROJECT_MEMORY_TEMPLATE);
99
- assert.equal(seedProjectMemory('/tmp/work'), false, 'project second seed is a no-op');
100
- });
101
- // ---------------------------------------------------------------------------
102
- // promote(): seeds all three, guarded across re-promotion
103
- // ---------------------------------------------------------------------------
104
- test('promote() seeds all three stores and surfaces all three paths', () => {
105
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
106
- assert.equal(hasMemory(meta.node_id), false);
107
- assert.equal(hasUserMemory(), false);
108
- assert.equal(hasProjectMemory('/tmp/work'), false);
109
- const res = promote(meta.node_id);
110
- assert.ok(hasMemory(meta.node_id), 'node-local seeded');
111
- assert.ok(hasUserMemory(), 'user-global seeded');
112
- assert.ok(hasProjectMemory('/tmp/work'), 'project seeded');
113
- assert.ok(existsSync(userMemoryDir()), 'user dir created for direct writes');
114
- assert.ok(existsSync(projectMemoryDir('/tmp/work')), 'project dir created for direct writes');
115
- assert.equal(res.memoryPath, memoryPath(meta.node_id));
116
- assert.equal(res.userMemoryPath, userMemoryPath());
117
- assert.equal(res.projectMemoryPath, projectMemoryPath('/tmp/work'));
118
- });
119
- test('promote() is idempotent across re-promotion \u2014 never clobbers evolved stores', () => {
120
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
121
- promote(meta.node_id);
122
- const evolvedUser = '# Memory\n\n- [CTO, terse](cto.md) \u2014 senior, wants density\n';
123
- const evolvedProject = '# Memory\n\n- [ESM only](esm.md) \u2014 .js extensions required\n';
124
- writeFileSync(userMemoryPath(), evolvedUser);
125
- writeFileSync(projectMemoryPath('/tmp/work'), evolvedProject);
126
- promote(meta.node_id); // re-promote
127
- assert.equal(readUserMemory(), evolvedUser, 'user store survived re-promotion');
128
- assert.equal(readProjectMemory('/tmp/work'), evolvedProject, 'project store survived re-promotion');
129
- });
130
- // ---------------------------------------------------------------------------
131
- // The kernel + guidance name the type\u2192store mapping
32
+ // Kernel + guidance: substrate flow, not MEMORY.md
132
33
  // ---------------------------------------------------------------------------
133
- test('the orchestration kernel names the three stores and the type\u2192store mapping', () => {
34
+ test('the orchestration kernel names the three stores and the substrate commands', () => {
134
35
  const kernel = loadKernel();
135
36
  for (const store of ['user-global', 'project', 'node-local']) {
136
37
  assert.ok(kernel.includes(store), `kernel names the ${store} store`);
137
38
  }
138
- // The type taxonomy still drives placement.
139
- assert.match(kernel, /`type`/, 'kernel still frames the type taxonomy');
140
- assert.match(kernel, /user.*\u2192.*user-global|`user` \u2192 user-global/, 'maps user \u2192 user-global');
141
- });
142
- test('promotion guidance names all three stores so a base-spawned node learns where to write', () => {
39
+ // The substrate commands must be present.
40
+ assert.ok(kernel.includes('crtr memory write'), 'kernel mentions crtr memory write');
41
+ assert.ok(kernel.includes('crtr memory list'), 'kernel mentions crtr memory list');
42
+ assert.ok(kernel.includes('crtr memory find'), 'kernel mentions crtr memory find');
43
+ assert.ok(kernel.includes('crtr memory read'), 'kernel mentions crtr memory read');
44
+ // The old MEMORY.md pointer-line flow must NOT be present.
45
+ assert.ok(!kernel.includes('MEMORY.md'), 'kernel does not mention MEMORY.md');
46
+ assert.ok(!kernel.includes('pointer line'), 'kernel does not teach the pointer-line flow');
47
+ });
48
+ test('promotion guidance references the three stores; no <memory> block', () => {
143
49
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
144
50
  promote(meta.node_id);
145
- // Guidance is now built by the persona injector, not returned by promote().
146
- const guidance = personaDrift(meta.node_id)?.guidance ?? '';
51
+ const drift = personaDrift(meta.node_id);
52
+ assert.ok(drift !== null, 'promotion drifts the persona');
53
+ const guidance = drift.guidance;
54
+ // It NAMES the three stores so a promoting node knows its memory scopes.
147
55
  for (const store of ['user-global', 'project', 'node-local']) {
148
56
  assert.ok(guidance.includes(store), `guidance names the ${store} store`);
149
57
  }
150
- assert.ok(guidance.includes(userMemoryDir()), 'guidance names the user-global dir');
151
- assert.ok(guidance.includes(projectMemoryDir('/tmp/work')), 'guidance names the project dir');
58
+ // The old <memory> block reference must NOT be present in guidance.
59
+ assert.ok(!guidance.includes('<memory>'), 'guidance does not point at the removed <memory> block');
152
60
  });
@@ -267,6 +267,11 @@ test('detachToBackground: a FOCUSED node sent to the backstage CLOSES its focus
267
267
  createNode(node('N', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: back }));
268
268
  openFocusRow('f1', focusPane, user, 'N'); // N is the focus occupant
269
269
  assert.equal(getFocusByNode('N')?.focus_id, 'f1', 'precondition: N is focused');
270
+ // N must read as GENERATING for the relocate-to-backstage path: detach now
271
+ // gates the break-pane (Bug 1 / Invariant P) on isGenerating = busy &&
272
+ // pidAlive. pi_pid is this (alive) process; set the busy marker so N is
273
+ // genuinely mid-turn (otherwise the gate would RELEASE + reap it instead).
274
+ markBusy('N');
270
275
  const ok = detachToBackground('N', focusPane);
271
276
  assert.equal(ok, true, 'the break to the backstage succeeded');
272
277
  // The fix: N is now generating-but-UNFOCUSED, so its focus row is CLOSED.
@@ -5,9 +5,11 @@
5
5
  // never realized, so closePane no-ops and only the DB effects are asserted):
6
6
  //
7
7
  // • handFocusToManager(focusId, managerId) — the §1.6 manager-takeover, a PURE
8
- // DB occupant swap. Returns true (TAKEOVER) only when there is a distinct,
9
- // not-already-focused manager; false (caller closes the focus) in each of the
10
- // three guard cases. Each guard is asserted distinctly.
8
+ // DB occupant swap. Returns true (TAKEOVER) only when a successor will
9
+ // GENUINELY claim the frozen pane a live manager (swapped in) or a dormant
10
+ // manager the daemon will revive (status='idle' && intent='idle-release').
11
+ // Returns false (caller disarms the freeze + closes the focus) for every
12
+ // other case; each guard is asserted distinctly.
11
13
  // • tearDownNode(nodeId) — close/reset teardown: close the focus row it
12
14
  // occupies and null its LOCATION.
13
15
  import { test, before, after, beforeEach } from 'node:test';
@@ -62,10 +64,13 @@ after(() => {
62
64
  // ---------------------------------------------------------------------------
63
65
  // handFocusToManager (pure DB) — §1.6 manager-takeover + its three false-guards.
64
66
  // ---------------------------------------------------------------------------
65
- test('handFocusToManager: distinct, free manager → TAKEOVER (repoints the row, returns true)', () => {
67
+ test('handFocusToManager: dormant idle-release manager → TAKEOVER (repoints the row, returns true)', () => {
66
68
  openFocusRow('f', '%m', 'Sa', 'M');
67
- createNode(node('mgr'));
68
- assert.equal(handFocusToManager('f', 'mgr'), true, 'a valid manager takes the focus');
69
+ // The daemon's superviseTick second pass revives a node ONLY when it is idle +
70
+ // idle-release (crtrd.ts ~309) that is the ONLY dormant manager that will be
71
+ // brought into the frozen %m, so it is the ONLY dormant takeover that returns true.
72
+ createNode(node('mgr', { status: 'idle', intent: 'idle-release' }));
73
+ assert.equal(handFocusToManager('f', 'mgr'), true, 'a manager the daemon WILL revive takes the focus');
69
74
  assert.equal(getFocusByNode('mgr')?.focus_id, 'f', 'the row now shows the manager');
70
75
  assert.equal(getFocusByNode('M'), null, 'the finished node no longer occupies it');
71
76
  // Non-vacuous: a no-op impl returns false / leaves M as occupant → both the
@@ -140,12 +145,14 @@ test('handFocusToManager: LIVE backstage manager → swaps its pane INTO the foc
140
145
  spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
141
146
  }
142
147
  });
143
- test('handFocusToManager: DORMANT manager (dead pane) → occupant repointed, NO swap, focus pane UNCHANGED', () => {
148
+ test('handFocusToManager: DORMANT idle-release manager (dead pane) → occupant repointed, NO swap, focus pane UNCHANGED', () => {
144
149
  openFocusRow('f', '%focus', 'Suser', 'M');
145
- // The manager has a pane recorded but it is NOT a live tmux pane (its dead-pi
146
- // window collapsed). isNodePaneAlive(mgr) is therefore false, so the live-swap
147
- // is skipped the external daemon later respawns it into the frozen %focus.
148
- createNode(node('mgr', { pane: '%mgr-dead', tmux_session: 'back', window: '@wb', pi_pid: process.pid }));
150
+ // The manager is dormant + idle-release (the ONLY dormant manager the daemon
151
+ // revives): its pane is recorded but NOT a live tmux pane (its dead-pi window
152
+ // collapsed) and its pi is dead. isNodePaneAlive(mgr) is therefore false, so the
153
+ // live-swap is skipped the external daemon later respawns it into the frozen
154
+ // %focus, exactly because status='idle' && intent='idle-release'.
155
+ createNode(node('mgr', { pane: '%mgr-dead', tmux_session: 'back', window: '@wb', pi_pid: null, status: 'idle', intent: 'idle-release' }));
149
156
  assert.equal(handFocusToManager('f', 'mgr'), true, 'still a takeover (occupant repointed)');
150
157
  assert.equal(getFocusByNode('mgr')?.focus_id, 'f', 'the manager occupies the focus row (DB repoint)');
151
158
  assert.equal(getFocusByNode('M'), null, 'the finished node no longer occupies it');
@@ -162,6 +169,42 @@ test('handFocusToManager: DORMANT manager (dead pane) → occupant repointed, NO
162
169
  // manager having a pane.
163
170
  });
164
171
  // ---------------------------------------------------------------------------
172
+ // BUG REGRESSION (dead-focus-pane): handFocusToManager must return FALSE — so
173
+ // the stophook caller runs closeFocusToShell to DISARM remain-on-exit and the
174
+ // pane REAPS on exit — whenever NO successor will actually claim the frozen pane.
175
+ // Pre-fix it repointed the occupant and returned true for ANY non-null,
176
+ // not-already-focused manager (even done/dead/canceled, idle-but-not-idle-
177
+ // release, or a live-but-paneless inline root). The daemon revives NONE of those
178
+ // into the frozen pane, so closeFocusToShell was skipped, remain-on-exit stayed
179
+ // ON, and the focus pane FROZE forever as a dead pane with no reaper.
180
+ // Diagnosis: nodes/mq32wjve-68de0c31/context/dead-focus-pane-fix.md
181
+ // ---------------------------------------------------------------------------
182
+ test('handFocusToManager (BUG REGRESSION): a manager the daemon will NEVER revive → false, occupant UNCHANGED (caller disarms + reaps)', () => {
183
+ openFocusRow('f', '%focus', 'Suser', 'M'); // M occupies the frozen focus pane
184
+ // (a) DONE manager — the daemon ignores done nodes entirely (the most common
185
+ // trigger: demoting a child to terminal whose manager already finished).
186
+ createNode(node('mgrDone', { status: 'done', pane: '%d', tmux_session: 'back', window: '@wd' }));
187
+ assert.equal(handFocusToManager('f', 'mgrDone'), false, 'a DONE manager will never be revived → caller must disarm + reap');
188
+ assert.equal(getFocusByNode('mgrDone'), null, 'a DONE manager is NOT repointed into the focus row');
189
+ // (b) DORMANT but NOT idle-release — idle with a different intent; crtrd's
190
+ // second pass (idle && idle-release) skips it, so it never enters the pane.
191
+ createNode(node('mgrIdleDone', { status: 'idle', intent: 'done', pane: '%i', tmux_session: 'back', window: '@wi' }));
192
+ assert.equal(handFocusToManager('f', 'mgrIdleDone'), false, 'idle but NOT idle-release → daemon never revives it → false');
193
+ assert.equal(getFocusByNode('mgrIdleDone'), null, 'a non-idle-release manager is NOT repointed');
194
+ // (c) LIVE-but-PANELESS manager (an inline root, pane == null) — the live-swap
195
+ // branch is skipped (no pane to swap) and, being active not idle-release,
196
+ // the daemon never revives it either.
197
+ createNode(node('mgrPaneless', { status: 'active', pi_pid: process.pid, pane: null }));
198
+ assert.equal(handFocusToManager('f', 'mgrPaneless'), false, 'a live-but-paneless inline root cannot claim the pane → false');
199
+ assert.equal(getFocusByNode('mgrPaneless'), null, 'a paneless manager is NOT repointed');
200
+ // The occupant is untouched across all three → the caller (canvas-stophook
201
+ // agent_end done-branch) runs closeFocusToShell, disarming the freeze.
202
+ assert.equal(getFocusById('f')?.node_id, 'M', 'M still occupies its focus → caller disarms remain-on-exit + closes the row');
203
+ // Non-vacuous: the pre-fix impl repointed the occupant + returned true for each
204
+ // of (a)/(b)/(c), so every false return AND every "NOT repointed" assert fails
205
+ // against it — and that stray true would have stranded the pane frozen forever.
206
+ });
207
+ // ---------------------------------------------------------------------------
165
208
  // tearDownNode (pure DB; no tmux — pane is null so closePane never runs).
166
209
  // ---------------------------------------------------------------------------
167
210
  test('tearDownNode: closes the focus row M occupied and nulls its LOCATION', () => {
@@ -109,8 +109,9 @@ test('relaunchRoot parks the old root (canceled, edges intact, no wipe) and mint
109
109
  assert.equal(existsSync(roadmapPath('root')), true, 'roadmap preserved');
110
110
  assert.equal(existsSync(inboxPath('root')), true, 'inbox preserved');
111
111
  assert.equal(existsSync(join(reportsDir('root'), '20260101T000000-update.md')), true, 'report preserved');
112
- // New root: fresh base resident, active, intent=refresh, empty context dir,
113
- // spawned_by=old, focused.
112
+ // New root: fresh base resident, active, intent=refresh, a clean-slate context
113
+ // dir (only the born-with memory store, nothing inherited), spawned_by=old,
114
+ // focused.
114
115
  const fresh = getNode(newId);
115
116
  assert.equal(fresh?.parent, null, 'new node is a root');
116
117
  assert.equal(fresh?.mode, 'base');
@@ -122,7 +123,7 @@ test('relaunchRoot parks the old root (canceled, edges intact, no wipe) and mint
122
123
  assert.equal(fresh?.tmux_session, 'crtr', 'adopted the old root window location');
123
124
  assert.equal(fresh?.window, '@7');
124
125
  assert.ok(fresh?.launch, 'a fresh base launch spec was written');
125
- assert.equal(readdirSync(contextDir(newId)).length, 0, 'fresh empty context dir');
126
+ assert.deepEqual(readdirSync(contextDir(newId)).sort(), ['memory'], 'fresh context dir holds only the born-with node-local memory store — nothing inherited from the old root');
126
127
  // Focus follows content: the focus row the old root held now shows the new root.
127
128
  assert.equal(getFocusByNode(newId)?.focus_id, 'fRoot', 'focus row repointed to the new root');
128
129
  assert.equal(getFocusByNode('root'), null, 'old root no longer occupies the focus');