@crouton-kit/crouter 0.3.16 → 0.3.18

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 (104) hide show
  1. package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
  2. package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
  3. package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
  4. package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
  5. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  6. package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
  7. package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
  8. package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
  9. package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
  10. package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
  11. package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
  12. package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
  13. package/dist/builtin-personas/spec/{base.md → PERSONA.md} +5 -1
  14. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
  15. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
  16. package/dist/commands/canvas-browse.d.ts +2 -0
  17. package/dist/commands/canvas-browse.js +45 -0
  18. package/dist/commands/canvas-prune.js +11 -2
  19. package/dist/commands/canvas.js +3 -2
  20. package/dist/commands/daemon.js +1 -1
  21. package/dist/commands/human/prompts.js +3 -9
  22. package/dist/commands/human/shared.d.ts +26 -1
  23. package/dist/commands/human/shared.js +48 -10
  24. package/dist/commands/node.js +66 -4
  25. package/dist/commands/skill/author.js +2 -2
  26. package/dist/core/__tests__/cascade-close.test.js +199 -0
  27. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  28. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  29. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  30. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  31. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  32. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  33. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  34. package/dist/core/__tests__/grace-clock.test.js +115 -0
  35. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  36. package/dist/core/__tests__/helpers/harness.js +406 -0
  37. package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
  38. package/dist/core/__tests__/human-surface-target.test.js +98 -0
  39. package/dist/core/__tests__/lifecycle.test.js +6 -13
  40. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  41. package/dist/core/__tests__/live-mutation.test.js +341 -0
  42. package/dist/core/__tests__/persona-subkind.test.js +18 -15
  43. package/dist/core/__tests__/placement-focus.test.js +53 -15
  44. package/dist/core/__tests__/relaunch.test.js +12 -12
  45. package/dist/core/__tests__/reset.test.js +11 -6
  46. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  47. package/dist/core/__tests__/spike-harness.test.js +241 -0
  48. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  49. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  50. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  51. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  52. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  53. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  54. package/dist/core/canvas/browse/app.d.ts +4 -0
  55. package/dist/core/canvas/browse/app.js +349 -0
  56. package/dist/core/canvas/browse/model.d.ts +97 -0
  57. package/dist/core/canvas/browse/model.js +258 -0
  58. package/dist/core/canvas/browse/render.d.ts +41 -0
  59. package/dist/core/canvas/browse/render.js +387 -0
  60. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  61. package/dist/core/canvas/browse/terminal.js +100 -0
  62. package/dist/core/canvas/canvas.d.ts +9 -2
  63. package/dist/core/canvas/canvas.js +41 -3
  64. package/dist/core/canvas/paths.d.ts +4 -1
  65. package/dist/core/canvas/paths.js +10 -4
  66. package/dist/core/canvas/render.d.ts +10 -0
  67. package/dist/core/canvas/render.js +25 -1
  68. package/dist/core/canvas/types.js +2 -2
  69. package/dist/core/feed/inbox.d.ts +0 -3
  70. package/dist/core/feed/inbox.js +1 -5
  71. package/dist/core/help.d.ts +6 -0
  72. package/dist/core/help.js +7 -0
  73. package/dist/core/personas/index.d.ts +4 -3
  74. package/dist/core/personas/index.js +3 -2
  75. package/dist/core/personas/loader.d.ts +34 -16
  76. package/dist/core/personas/loader.js +102 -29
  77. package/dist/core/personas/resolve.d.ts +4 -4
  78. package/dist/core/personas/resolve.js +16 -14
  79. package/dist/core/runtime/busy.d.ts +8 -0
  80. package/dist/core/runtime/busy.js +46 -0
  81. package/dist/core/runtime/lifecycle.d.ts +1 -1
  82. package/dist/core/runtime/lifecycle.js +12 -4
  83. package/dist/core/runtime/naming.d.ts +3 -3
  84. package/dist/core/runtime/naming.js +6 -6
  85. package/dist/core/runtime/placement.d.ts +32 -5
  86. package/dist/core/runtime/placement.js +81 -14
  87. package/dist/core/runtime/reset.d.ts +11 -8
  88. package/dist/core/runtime/reset.js +23 -18
  89. package/dist/core/spawn.d.ts +20 -1
  90. package/dist/core/spawn.js +52 -5
  91. package/dist/daemon/crtrd.js +43 -21
  92. package/dist/pi-extensions/canvas-nav.js +106 -55
  93. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  94. package/dist/pi-extensions/canvas-resume.js +35 -126
  95. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  96. package/dist/pi-extensions/canvas-stophook.js +16 -0
  97. package/dist/prompts/skill.js +6 -1
  98. package/package.json +1 -1
  99. package/dist/commands/__tests__/skill.test.js +0 -290
  100. package/dist/core/__tests__/pkg.test.js +0 -218
  101. package/dist/core/__tests__/sys.test.js +0 -208
  102. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  103. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  104. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -22,6 +22,7 @@ import { createNode, getNode, setPresence } from '../canvas/canvas.js';
22
22
  import { openFocusRow, getFocusByNode, getFocusById, listFocuses, } from '../canvas/focuses.js';
23
23
  import { closeDb } from '../canvas/db.js';
24
24
  import { outgoingDisposition, retargetFocus, openFocus, focus as placementFocus, registerRootFocus, focusByPane, detachToBackground, } from '../runtime/placement.js';
25
+ import { markBusy, isBusy } from '../runtime/busy.js';
25
26
  let home;
26
27
  let savedTmux;
27
28
  function node(id, over = {}) {
@@ -58,18 +59,27 @@ after(() => {
58
59
  process.env['TMUX'] = savedTmux;
59
60
  });
60
61
  // ---------------------------------------------------------------------------
61
- // 1a. PURE: outgoingDisposition — backstage (still generating) vs kill.
62
+ // 1a. PURE: outgoingDisposition — the 4-field truth table (kill / backstage /
63
+ // release). Each branch is provably non-vacuous (a wrong impl fails).
62
64
  // ---------------------------------------------------------------------------
63
- test('outgoingDisposition: a still-generating node → BACKSTAGE (F2: keeps running off-screen)', () => {
64
- assert.deepEqual(outgoingDisposition({ exists: true, generating: true }), { kind: 'backstage' });
65
- });
66
- test('outgoingDisposition: a dormant/done node → KILL (Invariant P: not-focused + not-generating ⇒ no pane)', () => {
67
- assert.deepEqual(outgoingDisposition({ exists: true, generating: false }), { kind: 'kill' });
68
- });
69
65
  test('outgoingDisposition: a HOLDER / vanished node (no row) → KILL (never backstaged)', () => {
70
66
  // A wrong impl that backstaged a holder would leak a sleep pane into crtr.
71
- assert.deepEqual(outgoingDisposition({ exists: false, generating: true }), { kind: 'kill' });
72
- assert.deepEqual(outgoingDisposition({ exists: false, generating: false }), { kind: 'kill' });
67
+ assert.deepEqual(outgoingDisposition({ exists: false, live: false, resident: false, generating: false }), { kind: 'kill' });
68
+ assert.deepEqual(outgoingDisposition({ exists: false, live: true, resident: true, generating: true }), { kind: 'kill' });
69
+ });
70
+ test('outgoingDisposition: a done/dead node (not live) → KILL (Invariant P: not-focused + not-live ⇒ no pane)', () => {
71
+ assert.deepEqual(outgoingDisposition({ exists: true, live: false, resident: false, generating: false }), { kind: 'kill' });
72
+ });
73
+ test('outgoingDisposition: a RESIDENT node (editor/root) → BACKSTAGE (human-driven, NEVER despawned on focus-away)', () => {
74
+ // Resident wins even when NOT mid-turn — a wrong impl here KILLS the user\'s editor.
75
+ assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: true, generating: false }), { kind: 'backstage' });
76
+ assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: true, generating: true }), { kind: 'backstage' });
77
+ });
78
+ test('outgoingDisposition: a still-generating terminal worker → BACKSTAGE (F2: keeps running off-screen)', () => {
79
+ assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: false, generating: true }), { kind: 'backstage' });
80
+ });
81
+ test('outgoingDisposition: a PARKED terminal viewer (live, not mid-turn) → RELEASE (despawn to dormant — the stuck-green fix)', () => {
82
+ assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: false, generating: false }), { kind: 'release' });
73
83
  });
74
84
  // ---------------------------------------------------------------------------
75
85
  // 2. Gated real-tmux: the hot-swap. Two isolated sessions: `user` (the user's
@@ -121,9 +131,11 @@ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put
121
131
  await withSessions('gen', async ({ user, back, userWindow, backWindow }) => {
122
132
  const focusPane = livePane(user, userWindow); // R's focus pane (the viewport)
123
133
  const backPane = livePane(back, backWindow); // A's live backstage pane
124
- // R is the outgoing occupant, generating (a live pi_pid). A is incoming.
125
- createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: back }));
134
+ // R is the outgoing occupant, generating: a terminal worker MID-TURN (live
135
+ // pi_pid AND the busy marker set). A is incoming.
136
+ createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', lifecycle: 'terminal', pi_pid: process.pid, home_session: back }));
126
137
  createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
138
+ markBusy('R'); // R is genuinely mid-turn (isGenerating = busy && pidAlive)
127
139
  openFocusRow('f1', focusPane, user, 'R');
128
140
  const userBefore = windowIds(user).length;
129
141
  const res = retargetFocus('f1', 'A', NOREVIVE);
@@ -145,18 +157,44 @@ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put
145
157
  assert.equal(focusByPane(backPane)?.node_id, 'A', 'the focus row tracks A after the retarget');
146
158
  });
147
159
  });
148
- test('retargetFocus: outgoing DORMANT (no live pi) → its now-backstage pane is REAPED (Invariant P)', { skip: !hasTmux() }, async () => {
160
+ test('retargetFocus: outgoing DONE/dead (not live) → its now-backstage pane is REAPED (Invariant P)', { skip: !hasTmux() }, async () => {
149
161
  await withSessions('kill', async ({ user, back, userWindow, backWindow }) => {
150
162
  const focusPane = livePane(user, userWindow);
151
163
  const backPane = livePane(back, backWindow);
152
- // R is NOT generating (pi_pid null) → its pane must be reaped after the swap.
153
- createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: null, home_session: back }));
164
+ // R is NOT live (status done) → the KILL path: its pane must be reaped after
165
+ // the swap, with NO lifecycle transition.
166
+ createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'done', pi_pid: null, home_session: back }));
167
+ createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
168
+ openFocusRow('f1', focusPane, user, 'R');
169
+ retargetFocus('f1', 'A', NOREVIVE);
170
+ assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
171
+ assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
172
+ assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was KILLED (not live ⇒ no pane)');
173
+ assert.equal(getNode('R').status, 'done', 'R stays done (KILL does not transition status)');
174
+ assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
175
+ assert.equal(getNode('R').tmux_session, null);
176
+ });
177
+ });
178
+ test('retargetFocus: outgoing PARKED terminal viewer (live pi, NOT mid-turn) → RELEASED to dormant (status idle, pane reaped) — the stuck-green fix', { skip: !hasTmux() }, async () => {
179
+ await withSessions('park', async ({ user, back, userWindow, backWindow }) => {
180
+ const focusPane = livePane(user, userWindow);
181
+ const backPane = livePane(back, backWindow);
182
+ // R is a terminal node revived only for VIEWING: live pi (pidAlive) but NOT
183
+ // mid-turn (no busy marker). It was the stuck-green bug: misread as
184
+ // generating, backstaged, and left status='active' forever. It must instead
185
+ // be RELEASED to dormant.
186
+ createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', lifecycle: 'terminal', pi_pid: process.pid, home_session: back }));
154
187
  createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
188
+ assert.equal(isBusy('R'), false, 'precondition: R is parked between turns (not mid-turn)');
155
189
  openFocusRow('f1', focusPane, user, 'R');
156
190
  retargetFocus('f1', 'A', NOREVIVE);
157
191
  assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
158
192
  assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
159
- assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was KILLED (dormant no pane)');
193
+ // The fix: focus-away flips R back to dormant (idle + idle-release) and reaps
194
+ // its pane — so the editor stops drawing it green/active.
195
+ assert.equal(getNode('R').status, 'idle', 'R was RELEASED back to dormant (no longer stuck active/green)');
196
+ assert.equal(getNode('R').intent, 'idle-release', 'R carries idle-release so the daemon revives it on its inbox');
197
+ assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was REAPED (parked viewer ⇒ no pane)');
160
198
  assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
161
199
  assert.equal(getNode('R').tmux_session, null);
162
200
  });
@@ -65,7 +65,7 @@ after(() => {
65
65
  // ---------------------------------------------------------------------------
66
66
  // #1 — relaunchRoot parks the old root, keeps edges, creates a fresh root
67
67
  // ---------------------------------------------------------------------------
68
- test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a fresh root', () => {
68
+ test('relaunchRoot parks the old root (canceled, edges intact, no wipe) and mints a fresh root', () => {
69
69
  createNode(node('root', {
70
70
  parent: null,
71
71
  lifecycle: 'resident',
@@ -91,18 +91,18 @@ test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a
91
91
  assert.notEqual(newId, 'root', 'a FRESH id, not the old one');
92
92
  // Respawn was dispatched against the NEW node in the given pane.
93
93
  assert.deepEqual(respawn.calls, [{ nodeId: newId, pane: 'test-pane' }]);
94
- // Old root: parked done, window detached, pi_session_id UNCHANGED (resumable).
94
+ // Old root: parked canceled, window detached, pi_session_id UNCHANGED (resumable).
95
95
  const old = getNode('root');
96
- assert.equal(old?.status, 'done', 'old root parked done');
96
+ assert.equal(old?.status, 'canceled', 'old root parked canceled');
97
97
  assert.equal(old?.window, null, 'old root window detached');
98
98
  assert.equal(old?.tmux_session, null, 'old root tmux_session detached');
99
99
  assert.equal(old?.intent, null, 'old root intent cleared');
100
100
  assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
101
101
  assert.equal(old?.pi_session_file, '/abs/root-sess.jsonl', 'pi_session_file preserved (resumable by path)');
102
102
  assert.equal(old?.parent, null, 'old root stays a root');
103
- // Descendants: DONE (not dead), but edges intact.
104
- assert.equal(getNode('child')?.status, 'done', 'child marked done (not a fault)');
105
- assert.equal(getNode('grand')?.status, 'done', 'grand marked done (not a fault)');
103
+ // Descendants: CANCELED (not dead), but edges intact.
104
+ assert.equal(getNode('child')?.status, 'canceled', 'child marked canceled (not a fault)');
105
+ assert.equal(getNode('grand')?.status, 'canceled', 'grand marked canceled (not a fault)');
106
106
  assert.deepEqual(subscriptionsOf('root').map((s) => s.node_id), ['child'], 'root→child edge intact');
107
107
  assert.deepEqual(subscriptionsOf('child').map((s) => s.node_id), ['grand'], 'child→grand edge intact');
108
108
  // Old root working state PRESERVED (history, no wipe).
@@ -152,10 +152,10 @@ test('handleNewSession on a root with a pane returns path:relaunch + parks old,
152
152
  assert.deepEqual(respawn.calls, [{ nodeId: res.newNodeId, pane: 'test-pane' }]);
153
153
  // Parked-old + fresh-new end state.
154
154
  const old = getNode('root');
155
- assert.equal(old?.status, 'done', 'old root parked done');
155
+ assert.equal(old?.status, 'canceled', 'old root parked canceled');
156
156
  assert.equal(old?.window, null, 'old root window detached');
157
157
  assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
158
- assert.equal(getNode('child')?.status, 'done', 'descendant reaped done');
158
+ assert.equal(getNode('child')?.status, 'canceled', 'descendant reaped canceled');
159
159
  const fresh = getNode(res.newNodeId);
160
160
  assert.equal(fresh?.parent, null, 'new node is a root');
161
161
  assert.equal(fresh?.status, 'active', 'fresh root active');
@@ -198,7 +198,7 @@ test('handleNewSession on a root with no pane falls back to in-place resetRoot',
198
198
  assert.equal(root?.lifecycle, 'resident');
199
199
  assert.equal(root?.pi_session_id, 'newsess');
200
200
  assert.equal(view('root').length, 0, 'root view emptied');
201
- assert.equal(getNode('child')?.status, 'done', 'descendant marked done');
201
+ assert.equal(getNode('child')?.status, 'canceled', 'descendant marked canceled');
202
202
  assert.equal(existsSync(roadmapPath('root')), false, 'working state wiped');
203
203
  });
204
204
  // ---------------------------------------------------------------------------
@@ -210,13 +210,13 @@ test('a second relaunchRoot on an already-parked root is a no-op', () => {
210
210
  const first = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
211
211
  assert.ok(first !== null, 'first /new parks + relaunches');
212
212
  const afterFirst = listNodes().length;
213
- // Old root is now `done`; a second session_start in the dying old pi must
213
+ // Old root is now `canceled`; a second session_start in the dying old pi must
214
214
  // no-op (no second parked node, no zombie new node).
215
215
  const second = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
216
216
  assert.equal(second, null, 'second relaunch is a no-op');
217
217
  assert.equal(listNodes().length, afterFirst, 'no second new node minted');
218
218
  assert.equal(respawn.calls.length, 1, 'respawn dispatched only once');
219
- assert.equal(getNode('root')?.status, 'done', 'old root unchanged (still parked)');
219
+ assert.equal(getNode('root')?.status, 'canceled', 'old root unchanged (still parked)');
220
220
  });
221
221
  // ---------------------------------------------------------------------------
222
222
  // #5 — /new before the root ever spawned children
@@ -227,7 +227,7 @@ test('relaunchRoot on a childless root: reap is a no-op, new node minted', () =>
227
227
  const respawn = okRespawn();
228
228
  const res = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
229
229
  assert.ok(res !== null, 'new node minted without throwing');
230
- assert.equal(getNode('root')?.status, 'done', 'old root parked');
230
+ assert.equal(getNode('root')?.status, 'canceled', 'old root parked');
231
231
  assert.equal(getNode(res.newNodeId)?.status, 'active', 'fresh root active');
232
232
  });
233
233
  // ---------------------------------------------------------------------------
@@ -56,9 +56,14 @@ test('resetRoot empties the root view, reaps descendants, and wipes working stat
56
56
  // Graph is empty from the root's view.
57
57
  assert.equal(view('root').length, 0, 'root view is empty after reset');
58
58
  assert.equal(subscriptionsOf('root').length, 0, 'no outgoing edges remain');
59
- // Descendants are done (clean teardown, not a fault; daemon skips them).
60
- assert.equal(getNode('child')?.status, 'done');
61
- assert.equal(getNode('grand')?.status, 'done');
59
+ // Descendants are CANCELED (A5, human-confirmed 2026-06-06): an externally-
60
+ // reaped node — via reset/relaunch OR close — did not finish its OWN work, so
61
+ // it unifies on `canceled`; `done` is reserved for finalize. Daemon skips them.
62
+ assert.equal(getNode('child')?.status, 'canceled');
63
+ assert.equal(getNode('grand')?.status, 'canceled');
64
+ // Regression: a reset-reaped descendant ends EXACTLY {canceled, null} — byte-
65
+ // for-byte identical to the close path (mirrors cascade-close.test.ts's tuple).
66
+ assert.deepEqual({ status: getNode('grand')?.status, intent: getNode('grand')?.intent ?? null }, { status: 'canceled', intent: null }, 'reset-reaped descendant: (status,intent) === (canceled, null), same as close');
62
67
  // Working state wiped.
63
68
  assert.equal(existsSync(roadmapPath('root')), false, 'roadmap wiped');
64
69
  assert.equal(existsSync(inboxPath('root')), false, 'inbox wiped');
@@ -99,7 +104,7 @@ test('Step 7: resetRoot reaps a FOCUSED descendant through tearDownNode (closes
99
104
  // getFocusByNode('desc') would still return fD.
100
105
  assert.equal(getFocusByNode('desc'), null, 'descendant focus row closed by tearDownNode');
101
106
  const d = getNode('desc');
102
- assert.equal(d.status, 'done', 'descendant reaped (done)');
107
+ assert.equal(d.status, 'canceled', 'descendant reaped (canceled — A5 unified)');
103
108
  assert.equal(d.pane ?? null, null, 'descendant pane nulled');
104
109
  assert.equal(d.tmux_session ?? null, null, 'descendant session nulled');
105
110
  });
@@ -115,10 +120,10 @@ test('reaped descendants keep their meta on disk (orphaned, not deleted)', () =>
115
120
  subscribe('root', 'child', true);
116
121
  setStatus('child', 'idle');
117
122
  resetRoot('root', 'new');
118
- // The node record persists (we detach + mark done, we don't delete the node).
123
+ // The node record persists (we detach + mark canceled, we don't delete the node).
119
124
  const child = getNode('child');
120
125
  assert.ok(child, 'child meta still on disk');
121
- assert.equal(child?.status, 'done');
126
+ assert.equal(child?.status, 'canceled');
122
127
  // It is just unreachable from the root.
123
128
  assert.equal(view('root').length, 0);
124
129
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,241 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/spike-harness.test.ts
2
+ //
3
+ // SPIKE — a throwaway-grade proof that a faithful integration harness for the
4
+ // node/canvas runtime is feasible. It drives the REAL `crtr` CLI into an
5
+ // isolated REAL tmux session, substitutes a FAKE-PI vehicle (the fake-pi-host
6
+ // fixture) via the CRTR_PI_BINARY seam, and proves the spawned window actually
7
+ // exec's the fake pi with the right argv+env, that the fake loads the REAL
8
+ // extensions, and that one real lifecycle hook drives a real canvas transition.
9
+ //
10
+ // Milestones (de-risk order):
11
+ // 1. SEAM — piCommand substitutes CRTR_PI_BINARY only when set (unit).
12
+ // 2. ROUND-TRIP— real `node new` → isolated tmux window → fake pi boots with
13
+ // CRTR_NODE_ID + the -e env intact (GO/NO-GO).
14
+ // 3. REAL HOOKS— the fake pi loads the real stophook and a clean /quit drives
15
+ // status=done via the real session_shutdown handler.
16
+ // 4. TEARDOWN — the isolated session + fake-pi procs are killed; no strays.
17
+ import { test, before, after } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join, dirname } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { createRequire } from 'node:module';
25
+ import { createNode, getNode } from '../canvas/canvas.js';
26
+ import { closeDb } from '../canvas/db.js';
27
+ import { piCommand } from '../runtime/placement.js';
28
+ import { CANVAS_EXTENSIONS } from '../runtime/launch.js';
29
+ // --- locations --------------------------------------------------------------
30
+ const HERE = dirname(fileURLToPath(import.meta.url)); // src/core/__tests__
31
+ const CROUTER = join(HERE, '..', '..', '..'); // package root
32
+ const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
33
+ const FAKE_PI_HOST = join(HERE, 'fixtures', 'fake-pi-host.ts');
34
+ const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
35
+ // A multi-word launcher baked verbatim ahead of the (shell-quoted) argv.
36
+ const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
37
+ function hasTmux() {
38
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
39
+ }
40
+ function tmuxSessionExists(session) {
41
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
42
+ }
43
+ // --- env isolation: scrub every canvas var the harness itself runs under, so
44
+ // the spawned CLI cannot leak into the real canvas. -------------------------
45
+ const CANVAS_ENV_KEYS = [
46
+ 'CRTR_NODE_ID',
47
+ 'CRTR_HOME',
48
+ 'CRTR_ROOT_SESSION',
49
+ 'CRTR_NODE_SESSION',
50
+ 'CRTR_PARENT_NODE_ID',
51
+ 'CRTR_FRONT_DOOR',
52
+ 'CRTR_KIND',
53
+ 'CRTR_MODE',
54
+ 'CRTR_LIFECYCLE',
55
+ 'CRTR_NODE_CWD',
56
+ 'CRTR_PI_BINARY',
57
+ 'TMUX',
58
+ 'TMUX_PANE',
59
+ ];
60
+ function cleanBaseEnv() {
61
+ const e = {};
62
+ for (const [k, v] of Object.entries(process.env))
63
+ if (v !== undefined)
64
+ e[k] = v;
65
+ for (const k of CANVAS_ENV_KEYS)
66
+ delete e[k];
67
+ // Contain per-invocation bootstrap + auto-update side effects.
68
+ e['CRTR_NO_BOOTSTRAP'] = '1';
69
+ e['CRTR_NO_AUTO_UPDATE'] = '1';
70
+ e['CRTR_NO_BOOT_SKILL'] = '1';
71
+ e['CRTR_NO_MODE_CMDS'] = '1';
72
+ e['CRTR_NO_AUTO_INIT'] = '1';
73
+ return e;
74
+ }
75
+ function node(id, over = {}) {
76
+ return {
77
+ node_id: id,
78
+ name: id,
79
+ created: new Date().toISOString(),
80
+ cwd: CROUTER,
81
+ kind: 'general',
82
+ mode: 'base',
83
+ lifecycle: 'resident',
84
+ status: 'active',
85
+ parent: null,
86
+ ...over,
87
+ };
88
+ }
89
+ async function waitFor(probe, opts = {}) {
90
+ const timeoutMs = opts.timeoutMs ?? 20_000;
91
+ const intervalMs = opts.intervalMs ?? 150;
92
+ const deadline = Date.now() + timeoutMs;
93
+ for (;;) {
94
+ const v = probe();
95
+ if (v)
96
+ return v;
97
+ if (Date.now() > deadline)
98
+ throw new Error(`waitFor timed out: ${opts.label ?? 'condition'}`);
99
+ await new Promise((r) => setTimeout(r, intervalMs));
100
+ }
101
+ }
102
+ // --- harness state ----------------------------------------------------------
103
+ let home;
104
+ let tmpHome;
105
+ let origHome;
106
+ const sessionsToKill = new Set();
107
+ const pidsToKill = new Set();
108
+ before(() => {
109
+ origHome = process.env['CRTR_HOME'];
110
+ home = mkdtempSync(join(tmpdir(), 'crtr-spike-home-'));
111
+ tmpHome = mkdtempSync(join(tmpdir(), 'crtr-spike-HOME-'));
112
+ // The harness reads/writes the isolated canvas in-process.
113
+ process.env['CRTR_HOME'] = home;
114
+ closeDb();
115
+ });
116
+ after(() => {
117
+ for (const s of sessionsToKill)
118
+ spawnSync('tmux', ['kill-session', '-t', s], { stdio: 'ignore' });
119
+ for (const p of pidsToKill) {
120
+ try {
121
+ process.kill(p, 'SIGKILL');
122
+ }
123
+ catch {
124
+ /* already gone */
125
+ }
126
+ }
127
+ closeDb();
128
+ if (home)
129
+ rmSync(home, { recursive: true, force: true });
130
+ if (tmpHome)
131
+ rmSync(tmpHome, { recursive: true, force: true });
132
+ if (origHome === undefined)
133
+ delete process.env['CRTR_HOME'];
134
+ else
135
+ process.env['CRTR_HOME'] = origHome;
136
+ });
137
+ // ===========================================================================
138
+ // MILESTONE 1 — the CRTR_PI_BINARY seam (always runs; no tmux needed).
139
+ // ===========================================================================
140
+ test('M1 seam: piCommand exec\'s `pi` when CRTR_PI_BINARY is unset, substitutes when set', () => {
141
+ const saved = process.env['CRTR_PI_BINARY'];
142
+ try {
143
+ delete process.env['CRTR_PI_BINARY'];
144
+ const unset = piCommand(['-e', '/abs/ext.ts', '-n', 'label']);
145
+ assert.equal(unset, "pi '-e' '/abs/ext.ts' '-n' 'label'", 'unset → identical to exec pi');
146
+ assert.ok(unset.startsWith('pi '), 'unset → leads with the literal pi binary');
147
+ process.env['CRTR_PI_BINARY'] = '/tmp/fake-pi';
148
+ const set = piCommand(['-e', '/abs/ext.ts']);
149
+ assert.ok(set.startsWith('/tmp/fake-pi '), 'set → leads with the substituted binary');
150
+ assert.ok(!set.startsWith('pi '), 'set → no longer the literal pi');
151
+ assert.equal(set, "/tmp/fake-pi '-e' '/abs/ext.ts'", 'argv still shell-quoted after the substitution');
152
+ // A multi-word launcher is spliced verbatim (argv stays quoted).
153
+ process.env['CRTR_PI_BINARY'] = 'node --import tsx/esm host.ts';
154
+ assert.equal(piCommand(['-n', 'x']), "node --import tsx/esm host.ts '-n' 'x'", 'multi-word binary spliced ahead of the quoted argv');
155
+ // An explicit binary arg still overrides the env.
156
+ assert.ok(piCommand(['-n', 'x'], 'pi').startsWith('pi '), 'explicit binary arg wins over the env seam');
157
+ }
158
+ finally {
159
+ if (saved === undefined)
160
+ delete process.env['CRTR_PI_BINARY'];
161
+ else
162
+ process.env['CRTR_PI_BINARY'] = saved;
163
+ }
164
+ });
165
+ // ===========================================================================
166
+ // MILESTONES 2 + 3 — real CLI → isolated tmux → fake pi → real hooks.
167
+ // THE GO/NO-GO. Shares one spawned child across both milestones.
168
+ // ===========================================================================
169
+ test('M2+M3 round-trip: real `node new` reaches the fake pi via the seam, and a real hook drives status=done', { skip: !hasTmux() }, async () => {
170
+ const session = `crtr-spike-${process.pid}-rt`;
171
+ sessionsToKill.add(session);
172
+ // Pre-create the isolated session (default tmux server — the runtime shells
173
+ // `tmux` with no -L, so an -L server would be invisible to the real CLI).
174
+ spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 600'], {
175
+ stdio: 'ignore',
176
+ });
177
+ assert.ok(tmuxSessionExists(session), 'isolated tmux session created');
178
+ // Bootstrap the acting node in the isolated canvas (the parent `node new`
179
+ // spawns under). createNode shares the harness CRTR_HOME.
180
+ createNode(node('A', { name: 'acting-root' }));
181
+ // Drive the REAL CLI: `crtr node new` AS node A, into the isolated session,
182
+ // with the fake-pi seam. Body passed as a positional (dodges the stdin hang).
183
+ const env = cleanBaseEnv();
184
+ env['CRTR_HOME'] = home;
185
+ env['HOME'] = tmpHome;
186
+ env['CRTR_NODE_ID'] = 'A';
187
+ env['CRTR_NODE_SESSION'] = session;
188
+ env['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
189
+ const res = spawnSync(process.execPath, ['--import', TSX_ESM, CLI_SRC, 'node', 'new', 'spike task', '--parent', 'A', '--cwd', CROUTER], { cwd: CROUTER, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 });
190
+ assert.equal(res.status, 0, `node new should exit 0\n--- stdout ---\n${res.stdout}\n--- stderr ---\n${res.stderr}`);
191
+ // Find the spawned child: the only node dir that isn't the acting root.
192
+ closeDb();
193
+ const nodesDir = join(home, 'nodes');
194
+ const childId = readdirSync(nodesDir).find((d) => d !== 'A');
195
+ assert.ok(childId, 'a child node dir was created by node new');
196
+ // ---- MILESTONE 2 assertions: the round-trip reached the fake pi --------
197
+ const bootPath = join(nodesDir, childId, 'fake-pi.boot.json');
198
+ const errPath = join(nodesDir, childId, 'fake-pi.error');
199
+ await waitFor(() => existsSync(bootPath), {
200
+ timeoutMs: 30_000,
201
+ label: `fake-pi boot proof at ${bootPath}${existsSync(errPath) ? ` (error file: ${readFileSync(errPath, 'utf8')})` : ''}`,
202
+ });
203
+ const boot = JSON.parse(readFileSync(bootPath, 'utf8'));
204
+ if (typeof boot.pid === 'number')
205
+ pidsToKill.add(boot.pid);
206
+ // env delivered via tmux -e arrived in the fake pi's process.env.
207
+ assert.equal(boot.env.CRTR_NODE_ID, childId, 'CRTR_NODE_ID is the CHILD id, intact');
208
+ assert.equal(boot.env.CRTR_HOME, home, 'CRTR_HOME isolated value intact');
209
+ assert.ok(boot.env.CRTR_KIND, 'CRTR_KIND present');
210
+ assert.ok(boot.env.CRTR_MODE, 'CRTR_MODE present');
211
+ assert.ok(boot.env.CRTR_LIFECYCLE, 'CRTR_LIFECYCLE present');
212
+ assert.equal(boot.env.CRTR_FRONT_DOOR, '1', 'CRTR_FRONT_DOOR overlay present');
213
+ // argv from buildPiArgv arrived: every canvas -e extension + the kickoff.
214
+ // Assert against the live CANVAS_EXTENSIONS count (8 at current HEAD — the
215
+ // placement-v3 refactor added canvas-resume) so this never drifts again.
216
+ assert.equal(boot.extPaths.length, CANVAS_EXTENSIONS.length, `all ${CANVAS_EXTENSIONS.length} canvas -e extension paths in argv`);
217
+ assert.ok(boot.loaded.some((p) => p.includes('canvas-stophook')), 'real stophook module loaded by the fake pi');
218
+ assert.ok(boot.loaded.some((p) => p.includes('canvas-inbox-watcher')), 'real inbox-watcher module loaded by the fake pi');
219
+ assert.equal(boot.failedExt.length, 0, `no extension failed to load: ${JSON.stringify(boot.failedExt)}`);
220
+ assert.equal(boot.resuming, false, 'fresh start (no --session)');
221
+ assert.equal(boot.prompt, 'spike task', 'kickoff prompt is the last positional');
222
+ // The REAL stophook session_start handler ran inside the fake pi and wrote
223
+ // shared canvas state (proves the hook chain, not just the boot).
224
+ closeDb();
225
+ const afterBoot = getNode(childId);
226
+ assert.ok(afterBoot, 'child node readable from the shared canvas');
227
+ assert.equal(afterBoot.pi_session_id, boot.sessionId, 'stophook captured pi_session_id');
228
+ assert.equal(afterBoot.status, 'active', 'child active after boot');
229
+ // ---- MILESTONE 3: a clean /quit drives a real transition to done -------
230
+ writeFileSync(join(nodesDir, childId, 'fake-pi.cmd'), JSON.stringify({ cmd: 'shutdown' }));
231
+ const done = await waitFor(() => {
232
+ closeDb();
233
+ return getNode(childId)?.status === 'done' ? true : false;
234
+ }, { timeoutMs: 20_000, label: 'child status=done after clean /quit' });
235
+ assert.ok(done, 'real session_shutdown hook resolved the node to done');
236
+ assert.equal(getNode(childId)?.status, 'done', 'status=done via the real stophook');
237
+ // ---- MILESTONE 4: teardown leaves no stray session ---------------------
238
+ spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
239
+ sessionsToKill.delete(session);
240
+ assert.ok(!tmuxSessionExists(session), 'isolated session killed, no stray left');
241
+ });