@crouton-kit/crouter 0.3.14 → 0.3.16

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 (224) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/attention.js +76 -7
  35. package/dist/commands/canvas-prune.d.ts +2 -0
  36. package/dist/commands/canvas-prune.js +66 -0
  37. package/dist/commands/canvas.js +5 -8
  38. package/dist/commands/chord.d.ts +2 -0
  39. package/dist/commands/chord.js +143 -0
  40. package/dist/commands/daemon.js +8 -5
  41. package/dist/commands/dashboard.js +2 -0
  42. package/dist/commands/human/prompts.js +28 -27
  43. package/dist/commands/human/queue.js +30 -14
  44. package/dist/commands/human/shared.d.ts +26 -21
  45. package/dist/commands/human/shared.js +45 -67
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -99
  49. package/dist/commands/pkg/market-inspect.js +6 -4
  50. package/dist/commands/pkg/market-manage.js +10 -6
  51. package/dist/commands/pkg/market.js +2 -4
  52. package/dist/commands/pkg/plugin-inspect.js +6 -4
  53. package/dist/commands/pkg/plugin-manage.js +12 -7
  54. package/dist/commands/pkg/plugin.js +2 -4
  55. package/dist/commands/pkg.js +0 -4
  56. package/dist/commands/push.js +178 -15
  57. package/dist/commands/revive.js +5 -3
  58. package/dist/commands/skill/author.js +6 -4
  59. package/dist/commands/skill/find.js +8 -5
  60. package/dist/commands/skill/read.js +2 -0
  61. package/dist/commands/skill/state.js +6 -4
  62. package/dist/commands/skill.js +0 -6
  63. package/dist/commands/sys/config.js +21 -7
  64. package/dist/commands/sys/doctor.js +2 -0
  65. package/dist/commands/sys/update.js +4 -0
  66. package/dist/commands/sys.js +0 -6
  67. package/dist/commands/tmux-spread.d.ts +2 -0
  68. package/dist/commands/tmux-spread.js +129 -0
  69. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  70. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  71. package/dist/core/__tests__/child-followup.test.js +83 -0
  72. package/dist/core/__tests__/close.test.d.ts +1 -0
  73. package/dist/core/__tests__/close.test.js +148 -0
  74. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  75. package/dist/core/__tests__/context-intro.test.js +196 -0
  76. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  77. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  78. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  80. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  81. package/dist/core/__tests__/focuses.test.js +196 -0
  82. package/dist/core/__tests__/fork.test.d.ts +1 -0
  83. package/dist/core/__tests__/fork.test.js +91 -0
  84. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  85. package/dist/core/__tests__/home-session.test.js +153 -0
  86. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  87. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  88. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  89. package/dist/core/__tests__/keystone.test.js +185 -0
  90. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  91. package/dist/core/__tests__/kickoff.test.js +89 -0
  92. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  93. package/dist/core/__tests__/lifecycle.test.js +178 -0
  94. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  95. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  96. package/dist/core/__tests__/memory.test.d.ts +1 -0
  97. package/dist/core/__tests__/memory.test.js +152 -0
  98. package/dist/core/__tests__/migration.test.d.ts +1 -0
  99. package/dist/core/__tests__/migration.test.js +238 -0
  100. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  101. package/dist/core/__tests__/pane-column.test.js +153 -0
  102. package/dist/core/__tests__/passive-subscription.test.js +24 -1
  103. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  104. package/dist/core/__tests__/persona-compose.test.js +53 -0
  105. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  106. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  107. package/dist/core/__tests__/persona.test.d.ts +1 -0
  108. package/dist/core/__tests__/persona.test.js +107 -0
  109. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  110. package/dist/core/__tests__/placement-focus.test.js +266 -0
  111. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  112. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  113. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  114. package/dist/core/__tests__/placement-revive.test.js +238 -0
  115. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  116. package/dist/core/__tests__/placement-teardown.test.js +178 -0
  117. package/dist/core/__tests__/prune.test.d.ts +1 -0
  118. package/dist/core/__tests__/prune.test.js +116 -0
  119. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  120. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  121. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  122. package/dist/core/__tests__/relaunch.test.js +334 -0
  123. package/dist/core/__tests__/reset.test.js +26 -7
  124. package/dist/core/__tests__/revive.test.d.ts +1 -0
  125. package/dist/core/__tests__/revive.test.js +217 -0
  126. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  127. package/dist/core/__tests__/spawn-root.test.js +73 -0
  128. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  129. package/dist/core/__tests__/steer-note.test.js +39 -0
  130. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  131. package/dist/core/__tests__/stop-guard.test.js +82 -0
  132. package/dist/core/__tests__/subcommand-tier.test.js +35 -33
  133. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  134. package/dist/core/__tests__/tmux-surface.test.js +105 -0
  135. package/dist/core/__tests__/unknown-path.test.js +8 -2
  136. package/dist/core/canvas/attention.d.ts +10 -0
  137. package/dist/core/canvas/attention.js +40 -0
  138. package/dist/core/canvas/canvas.d.ts +66 -7
  139. package/dist/core/canvas/canvas.js +209 -21
  140. package/dist/core/canvas/db.d.ts +8 -0
  141. package/dist/core/canvas/db.js +205 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +81 -0
  144. package/dist/core/canvas/index.d.ts +3 -0
  145. package/dist/core/canvas/index.js +3 -0
  146. package/dist/core/canvas/labels.d.ts +27 -0
  147. package/dist/core/canvas/labels.js +36 -0
  148. package/dist/core/canvas/render.js +25 -10
  149. package/dist/core/canvas/telemetry.d.ts +14 -0
  150. package/dist/core/canvas/telemetry.js +35 -0
  151. package/dist/core/canvas/types.d.ts +115 -12
  152. package/dist/core/command.d.ts +25 -1
  153. package/dist/core/command.js +23 -15
  154. package/dist/core/config.js +36 -2
  155. package/dist/core/feed/feed.js +3 -3
  156. package/dist/core/feed/inbox.d.ts +3 -1
  157. package/dist/core/feed/inbox.js +45 -5
  158. package/dist/core/feed/passive.js +24 -11
  159. package/dist/core/help.d.ts +26 -13
  160. package/dist/core/help.js +44 -37
  161. package/dist/core/personas/index.d.ts +1 -1
  162. package/dist/core/personas/index.js +1 -1
  163. package/dist/core/personas/loader.d.ts +40 -1
  164. package/dist/core/personas/loader.js +63 -1
  165. package/dist/core/personas/resolve.d.ts +13 -6
  166. package/dist/core/personas/resolve.js +46 -34
  167. package/dist/core/runtime/bearings.d.ts +20 -0
  168. package/dist/core/runtime/bearings.js +92 -0
  169. package/dist/core/runtime/close.d.ts +14 -0
  170. package/dist/core/runtime/close.js +151 -0
  171. package/dist/core/runtime/demote.js +24 -12
  172. package/dist/core/runtime/front-door.js +1 -1
  173. package/dist/core/runtime/kickoff.d.ts +23 -6
  174. package/dist/core/runtime/kickoff.js +92 -36
  175. package/dist/core/runtime/launch.d.ts +26 -12
  176. package/dist/core/runtime/launch.js +78 -19
  177. package/dist/core/runtime/lifecycle.d.ts +13 -0
  178. package/dist/core/runtime/lifecycle.js +86 -0
  179. package/dist/core/runtime/memory.d.ts +43 -0
  180. package/dist/core/runtime/memory.js +165 -0
  181. package/dist/core/runtime/naming.d.ts +22 -0
  182. package/dist/core/runtime/naming.js +166 -0
  183. package/dist/core/runtime/nodes.d.ts +39 -1
  184. package/dist/core/runtime/nodes.js +69 -10
  185. package/dist/core/runtime/persona.d.ts +25 -0
  186. package/dist/core/runtime/persona.js +139 -0
  187. package/dist/core/runtime/placement.d.ts +299 -0
  188. package/dist/core/runtime/placement.js +688 -0
  189. package/dist/core/runtime/promote.d.ts +14 -7
  190. package/dist/core/runtime/promote.js +57 -67
  191. package/dist/core/runtime/reset.d.ts +47 -4
  192. package/dist/core/runtime/reset.js +223 -52
  193. package/dist/core/runtime/revive.d.ts +26 -2
  194. package/dist/core/runtime/revive.js +166 -39
  195. package/dist/core/runtime/spawn.d.ts +20 -5
  196. package/dist/core/runtime/spawn.js +163 -43
  197. package/dist/core/runtime/stop-guard.d.ts +1 -1
  198. package/dist/core/runtime/stop-guard.js +18 -8
  199. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  200. package/dist/core/runtime/tmux-chrome.js +4 -0
  201. package/dist/core/runtime/tmux.d.ts +113 -20
  202. package/dist/core/runtime/tmux.js +221 -39
  203. package/dist/core/spawn.js +15 -0
  204. package/dist/daemon/crtrd.d.ts +12 -1
  205. package/dist/daemon/crtrd.js +152 -34
  206. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  207. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  208. package/dist/pi-extensions/canvas-commands.js +16 -13
  209. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  210. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  211. package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
  212. package/dist/pi-extensions/canvas-goal-capture.js +15 -1
  213. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  214. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  215. package/dist/pi-extensions/canvas-nav.js +594 -262
  216. package/dist/pi-extensions/canvas-resume.d.ts +22 -0
  217. package/dist/pi-extensions/canvas-resume.js +173 -0
  218. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  219. package/dist/pi-extensions/canvas-stophook.js +340 -228
  220. package/dist/types.d.ts +28 -0
  221. package/dist/types.js +16 -0
  222. package/package.json +2 -2
  223. package/dist/core/runtime/presence.d.ts +0 -38
  224. package/dist/core/runtime/presence.js +0 -154
@@ -0,0 +1,196 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/focuses.test.ts
2
+ //
3
+ // The `focuses` table (canvas.db, migration v6) + its canvas setters + the
4
+ // placement reads that compose over them. The table is the CANONICAL focus store
5
+ // — there is no focus.ptr file and no dual-write bridge. Covers:
6
+ // - migration v6 adds `focuses` to a fresh db (and a legacy v5 db migrates up);
7
+ // idempotent / forward-only on re-run + re-open
8
+ // - canvas setters/reads round-trip: open / setOccupant / setPane / close;
9
+ // getFocusByNode / getFocusByPane / getFocusById / listFocuses
10
+ // - UNIQUE(node_id): a second focus row (and an occupant UPDATE) for one node
11
+ // is rejected (upholds "a node occupies <=1 focus", Q5)
12
+ // - independent focus rows don't contend
13
+ // - placement focusOf / isFocused / focusByPane / focusedNodes / listFocuses
14
+ // agree with the rows
15
+ import { test, before, beforeEach, after } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { DatabaseSync } from 'node:sqlite';
21
+ import { openFocusRow, setFocusOccupant, setFocusPane, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses, } from '../canvas/focuses.js';
22
+ import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
23
+ import { canvasDbPath, ensureHome } from '../canvas/paths.js';
24
+ import { focusOf, isFocused, focusByPane, focusedNodes, listFocuses as placementListFocuses, } from '../runtime/placement.js';
25
+ let home;
26
+ function userVersion(db) {
27
+ return db.prepare('PRAGMA user_version').get().user_version;
28
+ }
29
+ function tableNames(db) {
30
+ return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name);
31
+ }
32
+ before(() => {
33
+ home = mkdtempSync(join(tmpdir(), 'crtr-focuses-'));
34
+ process.env['CRTR_HOME'] = home;
35
+ });
36
+ beforeEach(() => {
37
+ closeDb();
38
+ rmSync(home, { recursive: true, force: true });
39
+ });
40
+ after(() => {
41
+ closeDb();
42
+ rmSync(home, { recursive: true, force: true });
43
+ delete process.env['CRTR_HOME'];
44
+ });
45
+ // ---------------------------------------------------------------------------
46
+ // Migration v6 — the additive `focuses` table.
47
+ // ---------------------------------------------------------------------------
48
+ test('a fresh db migrates to the latest version and has the focuses table', () => {
49
+ const db = openDb();
50
+ assert.equal(userVersion(db), MIGRATIONS.length);
51
+ assert.ok(tableNames(db).includes('focuses'), 'focuses table exists on a fresh db');
52
+ });
53
+ test('the focuses table has the v6 shape (UNIQUE node_id, nullable pane/session)', () => {
54
+ const db = openDb();
55
+ const cols = db.prepare('PRAGMA table_info(focuses)').all();
56
+ const byName = new Map(cols.map((c) => [c.name, c]));
57
+ assert.equal(byName.get('focus_id')?.pk, 1, 'focus_id is the primary key');
58
+ assert.equal(byName.get('node_id')?.notnull, 1, 'node_id is NOT NULL');
59
+ assert.equal(byName.get('pane')?.notnull, 0, 'pane is nullable');
60
+ assert.equal(byName.get('session')?.notnull, 0, 'session is nullable');
61
+ // node_id carries a UNIQUE index.
62
+ const idx = db.prepare('PRAGMA index_list(focuses)').all();
63
+ assert.ok(idx.some((i) => i.unique === 1), 'a UNIQUE index exists (the node_id constraint)');
64
+ });
65
+ test('a v5 db migrates forward, adding focuses without disturbing existing data', () => {
66
+ // Hand-build a db at exactly v5: baseline + the four v2 runtime columns + the
67
+ // v5 pane column + edges WITH the v4 FK shape, user_version=5 — the shape just
68
+ // before v6.
69
+ ensureHome();
70
+ const raw = new DatabaseSync(canvasDbPath());
71
+ raw.exec(`
72
+ CREATE TABLE nodes (
73
+ node_id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
74
+ mode TEXT NOT NULL DEFAULT 'base', lifecycle TEXT NOT NULL DEFAULT 'terminal',
75
+ status TEXT NOT NULL DEFAULT 'active', cwd TEXT NOT NULL, parent TEXT,
76
+ created TEXT NOT NULL,
77
+ intent TEXT, pi_pid INTEGER, window TEXT, tmux_session TEXT, pane TEXT
78
+ );
79
+ CREATE TABLE edges (
80
+ type TEXT NOT NULL, from_id TEXT NOT NULL, to_id TEXT NOT NULL,
81
+ active INTEGER NOT NULL DEFAULT 1, created TEXT NOT NULL,
82
+ PRIMARY KEY (type, from_id, to_id),
83
+ FOREIGN KEY (from_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
84
+ FOREIGN KEY (to_id) REFERENCES nodes(node_id) ON DELETE CASCADE
85
+ );
86
+ PRAGMA user_version = 5;
87
+ `);
88
+ raw
89
+ .prepare('INSERT INTO nodes (node_id, name, kind, status, cwd, created, window, tmux_session, pane) VALUES (?,?,?,?,?,?,?,?,?)')
90
+ .run('legacy', 'Legacy', 'general', 'idle', '/tmp/work', '2020-01-01T00:00:00Z', '@9', 'crtr', '%3');
91
+ assert.equal(userVersion(raw), 5);
92
+ assert.ok(!tableNames(raw).includes('focuses'), 'precondition: no focuses table at v5');
93
+ raw.close();
94
+ // openDb() runs v6 forward.
95
+ const db = openDb();
96
+ assert.equal(userVersion(db), MIGRATIONS.length);
97
+ assert.ok(tableNames(db).includes('focuses'), 'focuses table added by v6');
98
+ // The pre-existing node row is intact (the v6 migration only adds a table).
99
+ const row = db
100
+ .prepare('SELECT status, window, tmux_session, pane FROM nodes WHERE node_id = ?')
101
+ .get('legacy');
102
+ assert.equal(row['status'], 'idle', 'existing status untouched');
103
+ assert.equal(row['window'], '@9', 'existing window untouched');
104
+ assert.equal(row['tmux_session'], 'crtr', 'existing session untouched');
105
+ assert.equal(row['pane'], '%3', 'existing pane untouched');
106
+ // The fresh focuses table is empty.
107
+ const n = db.prepare('SELECT COUNT(*) AS n FROM focuses').get().n;
108
+ assert.equal(n, 0, 'a fresh focuses table starts empty');
109
+ });
110
+ test('the v6 migration is idempotent / forward-only on re-run and re-open', () => {
111
+ const db = openDb();
112
+ assert.equal(userVersion(db), MIGRATIONS.length);
113
+ // Re-running migrate() is a no-op — the gate skips applied steps. The CREATE
114
+ // TABLE IF NOT EXISTS would be harmless anyway, but the gate must not re-fire.
115
+ assert.doesNotThrow(() => migrate(db));
116
+ assert.equal(userVersion(db), MIGRATIONS.length);
117
+ // Re-opening from disk is likewise a no-op.
118
+ closeDb();
119
+ const db2 = openDb();
120
+ assert.equal(userVersion(db2), MIGRATIONS.length);
121
+ assert.ok(tableNames(db2).includes('focuses'));
122
+ });
123
+ // ---------------------------------------------------------------------------
124
+ // Canvas setters / reads — open / setOccupant / setPane / close round-trip.
125
+ // ---------------------------------------------------------------------------
126
+ test('open / setOccupant / close round-trip with the reads', () => {
127
+ openDb();
128
+ openFocusRow('f1', '%a', 'Sa', 'A');
129
+ // getFocusByNode / getFocusByPane / getFocusById all resolve the same row.
130
+ const byNode = getFocusByNode('A');
131
+ assert.deepEqual(byNode, { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'A' });
132
+ assert.deepEqual(getFocusByPane('%a'), byNode);
133
+ assert.deepEqual(getFocusById('f1'), byNode);
134
+ assert.deepEqual(listFocuses(), [byNode]);
135
+ // setFocusOccupant hot-swaps the occupant in place (same focus_id/pane).
136
+ setFocusOccupant('f1', 'B');
137
+ assert.equal(getFocusByNode('A'), null, 'A no longer occupies the focus');
138
+ assert.deepEqual(getFocusByNode('B'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'B' });
139
+ // setFocusPane re-points the pane + session cache (reconcileFocus, Step 6).
140
+ setFocusPane('f1', '%a2', 'Sa2');
141
+ assert.deepEqual(getFocusById('f1'), { focus_id: 'f1', pane: '%a2', session: 'Sa2', node_id: 'B' });
142
+ assert.equal(getFocusByPane('%a'), null, 'the old pane no longer resolves');
143
+ assert.deepEqual(getFocusByPane('%a2')?.node_id, 'B');
144
+ // closeFocusRow deletes the viewport.
145
+ closeFocusRow('f1');
146
+ assert.equal(getFocusByNode('B'), null);
147
+ assert.equal(getFocusById('f1'), null);
148
+ assert.deepEqual(listFocuses(), []);
149
+ });
150
+ test('UNIQUE(node_id): a second focus row for the same node is rejected', () => {
151
+ openDb();
152
+ openFocusRow('f1', '%a', 'Sa', 'A');
153
+ // A second viewport occupied by the SAME node violates UNIQUE(node_id) — this
154
+ // is the constraint that upholds "a node occupies <=1 focus" (Q5).
155
+ assert.throws(() => openFocusRow('f2', '%b', 'Sb', 'A'), /UNIQUE|constraint/i);
156
+ // The first row is untouched; no stray second row was created.
157
+ assert.deepEqual(listFocuses().map((f) => f.focus_id), ['f1']);
158
+ });
159
+ test('UNIQUE(node_id): hot-swapping an occupant onto an already-focused node is rejected', () => {
160
+ openDb();
161
+ openFocusRow('f1', '%a', 'Sa', 'A');
162
+ openFocusRow('f2', '%b', 'Sb', 'B');
163
+ // B already occupies f2 — moving it onto f1 via setFocusOccupant must throw
164
+ // (the Q5 vacate-first is retargetFocus's job, Step 6, not this setter's).
165
+ assert.throws(() => setFocusOccupant('f1', 'B'), /UNIQUE|constraint/i);
166
+ assert.deepEqual(getFocusByNode('A'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'A' });
167
+ assert.deepEqual(getFocusByNode('B'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
168
+ });
169
+ test('independent focus rows do not contend', () => {
170
+ openDb();
171
+ openFocusRow('f1', '%a', 'Sa', 'A');
172
+ openFocusRow('f2', '%b', 'Sb', 'B');
173
+ // Mutating one viewport leaves the other entirely intact.
174
+ setFocusOccupant('f1', 'C');
175
+ setFocusPane('f1', '%a2', 'Sa2');
176
+ assert.deepEqual(getFocusByNode('B'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
177
+ closeFocusRow('f1');
178
+ assert.deepEqual(getFocusByNode('B'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
179
+ assert.deepEqual(listFocuses().map((f) => f.focus_id), ['f2']);
180
+ });
181
+ // ---------------------------------------------------------------------------
182
+ // Placement reads — focusOf / isFocused / focusByPane / focusedNodes / listFocuses
183
+ // agree with the rows.
184
+ // ---------------------------------------------------------------------------
185
+ test('placement focus reads agree with the focus rows', () => {
186
+ openDb();
187
+ openFocusRow('f1', '%a', 'Sa', 'A');
188
+ openFocusRow('f2', '%b', 'Sb', 'B');
189
+ assert.deepEqual(focusOf('A'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'A' });
190
+ assert.equal(focusOf('Z'), null, 'an unfocused node has no focus');
191
+ assert.equal(isFocused('A'), true);
192
+ assert.equal(isFocused('Z'), false);
193
+ assert.deepEqual(focusByPane('%b'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
194
+ assert.deepEqual(focusedNodes(), new Set(['A', 'B']));
195
+ assert.deepEqual(placementListFocuses().map((f) => f.node_id), ['A', 'B']);
196
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/fork.test.ts
2
+ //
3
+ // Covers `crtr node new --fork-from`: the spawn-time branch where a new node is
4
+ // born as a COPY of an existing pi conversation. Two pure layers are unit-tested
5
+ // here — `buildPiArgv` emitting `--fork` (vs `--session`), and `resolveForkSource`
6
+ // turning a node id / path / uuid into the `--fork <path|id>` argument. The tmux
7
+ // spawn itself is exercised elsewhere (it needs a live pi + tmux).
8
+ import { test, before, after, beforeEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { createNode } from '../canvas/canvas.js';
14
+ import { closeDb } from '../canvas/db.js';
15
+ import { buildPiArgv } from '../runtime/launch.js';
16
+ import { resolveForkSource } from '../runtime/spawn.js';
17
+ let home;
18
+ function node(id, over = {}) {
19
+ return {
20
+ node_id: id,
21
+ name: id,
22
+ created: new Date().toISOString(),
23
+ cwd: '/tmp/work',
24
+ kind: 'developer',
25
+ mode: 'base',
26
+ lifecycle: 'terminal',
27
+ status: 'active',
28
+ ...over,
29
+ };
30
+ }
31
+ before(() => {
32
+ home = mkdtempSync(join(tmpdir(), 'crtr-fork-'));
33
+ process.env['CRTR_HOME'] = home;
34
+ });
35
+ beforeEach(() => {
36
+ closeDb();
37
+ rmSync(home, { recursive: true, force: true });
38
+ });
39
+ after(() => {
40
+ closeDb();
41
+ rmSync(home, { recursive: true, force: true });
42
+ delete process.env['CRTR_HOME'];
43
+ });
44
+ // ---------------------------------------------------------------------------
45
+ // buildPiArgv — `--fork` is the spawn-time branch (not `--session`)
46
+ // ---------------------------------------------------------------------------
47
+ test('buildPiArgv emits --fork <src> and delivers the kickoff prompt', () => {
48
+ const m = node('n');
49
+ const { argv } = buildPiArgv(m, { prompt: 'continue from here', forkFrom: '/abs/src.jsonl' });
50
+ const i = argv.indexOf('--fork');
51
+ assert.ok(i >= 0, 'argv carries --fork');
52
+ assert.equal(argv[i + 1], '/abs/src.jsonl', 'forks from the resolved source');
53
+ assert.ok(!argv.includes('--session'), 'a fork never also resumes');
54
+ assert.equal(argv[argv.length - 1], 'continue from here', 'the kickoff prompt is the last positional');
55
+ });
56
+ test('buildPiArgv prefers --fork over --session when both are somehow set', () => {
57
+ const m = node('n');
58
+ const { argv } = buildPiArgv(m, { forkFrom: '/abs/src.jsonl', resumeSessionPath: '/abs/own.jsonl' });
59
+ assert.ok(argv.includes('--fork'), 'fork wins');
60
+ assert.ok(!argv.includes('--session'), 'resume is suppressed when forking');
61
+ });
62
+ test('buildPiArgv without forkFrom is unchanged (fresh launch, no --fork)', () => {
63
+ const m = node('n');
64
+ const { argv } = buildPiArgv(m, { prompt: 'go' });
65
+ assert.ok(!argv.includes('--fork'), 'no fork on an ordinary fresh launch');
66
+ assert.ok(!argv.includes('--session'), 'no resume either');
67
+ });
68
+ // ---------------------------------------------------------------------------
69
+ // resolveForkSource — node id / path / uuid → the `--fork` argument
70
+ // ---------------------------------------------------------------------------
71
+ test('resolveForkSource resolves a node id to its absolute session FILE', () => {
72
+ createNode(node('src', { pi_session_id: 'uuid-1', pi_session_file: '/abs/src.jsonl' }));
73
+ assert.equal(resolveForkSource('src'), '/abs/src.jsonl');
74
+ });
75
+ test('resolveForkSource falls back to the bare session id when no file captured', () => {
76
+ createNode(node('src', { pi_session_id: 'uuid-1' }));
77
+ assert.equal(resolveForkSource('src'), 'uuid-1');
78
+ });
79
+ test('resolveForkSource throws when the node has no pi session to fork yet', () => {
80
+ createNode(node('fresh'));
81
+ assert.throws(() => resolveForkSource('fresh'), /no pi session yet/);
82
+ });
83
+ test('resolveForkSource passes a path straight through', () => {
84
+ assert.equal(resolveForkSource('/some/where/sess.jsonl'), '/some/where/sess.jsonl');
85
+ });
86
+ test('resolveForkSource passes an unknown bare/partial uuid through to pi', () => {
87
+ assert.equal(resolveForkSource('019e8ce3-322e'), '019e8ce3-322e');
88
+ });
89
+ test('resolveForkSource rejects an empty reference', () => {
90
+ assert.throws(() => resolveForkSource(' '), /requires a node id/);
91
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,153 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/home-session.test.ts
2
+ //
3
+ // STEP 1 of the placement/focus migration: the durable REVIVE-HOME field.
4
+ // `home_session` separates a node's revive target from its live LOCATION so a
5
+ // later step can kill the focus taint. This step only ADDS + POPULATES +
6
+ // DEFAULTS the field — no behavior change. Covers:
7
+ // - home_session round-trips through meta.json (it IS durable identity)
8
+ // - the birth-session decision (`resolveBirthSession`) for the child /
9
+ // inline-root / --root cases each site sets home_session from
10
+ // - demote-recycle + relaunch (pane-recycle) births populate home_session
11
+ // - a legacy meta with NO home_session defaults to tmux_session ?? nodeSession()
12
+ import { test, before, after, beforeEach } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { createNode, getNode, updateNode } from '../canvas/canvas.js';
18
+ import { nodeMetaPath } from '../canvas/paths.js';
19
+ import { closeDb } from '../canvas/db.js';
20
+ import { resolveBirthSession, homeSessionOf } from '../runtime/nodes.js';
21
+ import { nodeSession } from '../runtime/nodes.js';
22
+ import { relaunchRoot } from '../runtime/reset.js';
23
+ import { demoteNode } from '../runtime/demote.js';
24
+ let home;
25
+ function node(id, over = {}) {
26
+ return {
27
+ node_id: id,
28
+ name: id,
29
+ created: new Date().toISOString(),
30
+ cwd: '/tmp/work',
31
+ kind: 'general',
32
+ mode: 'base',
33
+ lifecycle: 'resident',
34
+ status: 'active',
35
+ ...over,
36
+ };
37
+ }
38
+ /** Make ensureDaemon (called by demoteNode) a no-op by faking a live daemon
39
+ * pidfile pointing at THIS test process — so no real daemon is ever spawned. */
40
+ function fakeLiveDaemon() {
41
+ writeFileSync(join(home, 'crtrd.pid'), String(process.pid), 'utf8');
42
+ }
43
+ before(() => {
44
+ home = mkdtempSync(join(tmpdir(), 'crtr-homesession-'));
45
+ process.env['CRTR_HOME'] = home;
46
+ });
47
+ beforeEach(() => {
48
+ closeDb();
49
+ rmSync(home, { recursive: true, force: true });
50
+ delete process.env['CRTR_ROOT_SESSION'];
51
+ delete process.env['CRTR_NODE_SESSION'];
52
+ });
53
+ after(() => {
54
+ closeDb();
55
+ rmSync(home, { recursive: true, force: true });
56
+ delete process.env['CRTR_HOME'];
57
+ delete process.env['CRTR_ROOT_SESSION'];
58
+ delete process.env['CRTR_NODE_SESSION'];
59
+ });
60
+ // ---------------------------------------------------------------------------
61
+ // Persistence: home_session is DURABLE IDENTITY (meta.json), round-tripped by
62
+ // createNode / getNode / updateNode — and NOT a runtime field.
63
+ // ---------------------------------------------------------------------------
64
+ test('home_session round-trips through meta.json and the hydrated view', () => {
65
+ createNode(node('n', { home_session: 'crtr', tmux_session: 'user-sess', window: '@1' }));
66
+ // On disk: meta.json carries home_session (identity), NOT the runtime LOCATION.
67
+ const raw = JSON.parse(readFileSync(nodeMetaPath('n'), 'utf8'));
68
+ assert.equal(raw['home_session'], 'crtr', 'home_session persisted to meta.json (durable identity)');
69
+ assert.ok(!('tmux_session' in raw), 'tmux_session stays a runtime field, not in meta.json');
70
+ // Hydrated view returns it; the live LOCATION is independent.
71
+ const m = getNode('n');
72
+ assert.equal(m?.home_session, 'crtr', 'getNode hydrates home_session');
73
+ assert.equal(m?.tmux_session, 'user-sess', 'home_session is distinct from the live LOCATION');
74
+ });
75
+ test('updateNode patches home_session (the demote rewriter path) and preserves it', () => {
76
+ createNode(node('n', { home_session: 'crtr' }));
77
+ updateNode('n', { home_session: 'recycled-sess' });
78
+ assert.equal(getNode('n')?.home_session, 'recycled-sess', 'home_session rewritten by updateNode');
79
+ // An unrelated identity patch leaves home_session intact (RMW round-trip).
80
+ updateNode('n', { description: 'a-handle' });
81
+ assert.equal(getNode('n')?.home_session, 'recycled-sess', 'home_session survives an unrelated identity edit');
82
+ });
83
+ // ---------------------------------------------------------------------------
84
+ // The legacy / back-compat DEFAULT: a meta with no home_session reads back
85
+ // tmux_session ?? nodeSession().
86
+ // ---------------------------------------------------------------------------
87
+ test('homeSessionOf: a present home_session is returned verbatim', () => {
88
+ createNode(node('n', { home_session: 'home-sess', tmux_session: 'live-sess' }));
89
+ assert.equal(homeSessionOf('n'), 'home-sess', 'the stored revive-home wins over the live LOCATION');
90
+ });
91
+ test('homeSessionOf: legacy meta (no home_session) defaults to tmux_session', () => {
92
+ createNode(node('legacy', { tmux_session: 'live-sess' }));
93
+ assert.equal(getNode('legacy')?.home_session, undefined, 'no home_session on a legacy node');
94
+ assert.equal(homeSessionOf('legacy'), 'live-sess', 'defaults to the last live LOCATION');
95
+ });
96
+ test('homeSessionOf: legacy meta with no LOCATION either defaults to nodeSession()', () => {
97
+ createNode(node('legacy', { tmux_session: null }));
98
+ assert.equal(homeSessionOf('legacy'), nodeSession(), 'falls through to the shared backstage');
99
+ });
100
+ test('homeSessionOf: the backstage default honors CRTR_NODE_SESSION', () => {
101
+ process.env['CRTR_NODE_SESSION'] = 'my-backstage';
102
+ createNode(node('legacy'));
103
+ assert.equal(homeSessionOf('legacy'), 'my-backstage', 'nodeSession() default is env-overridable');
104
+ });
105
+ test('homeSessionOf: unknown node falls back to the backstage', () => {
106
+ assert.equal(homeSessionOf('ghost'), nodeSession());
107
+ });
108
+ // ---------------------------------------------------------------------------
109
+ // The birth-session decision each site sets home_session from. Pure, so the
110
+ // child / inline-root / --root births are testable without a live tmux (the
111
+ // real spawnChild/bootRoot are tmux + pi + process.exit coupled).
112
+ // ---------------------------------------------------------------------------
113
+ test('birth: a managed child homes to the shared backstage (nodeSession), never a user session', () => {
114
+ // No CRTR_ROOT_SESSION inherited, not adopting the caller.
115
+ assert.equal(resolveBirthSession({ adoptCaller: false, here: { session: 'user-sess' }, rootSession: undefined }), nodeSession(), 'a child ignores the caller session and homes to crtr');
116
+ });
117
+ test('birth: a managed child inherits CRTR_ROOT_SESSION as its backstage', () => {
118
+ assert.equal(resolveBirthSession({ adoptCaller: false, here: null, rootSession: 'crtr-subtree' }), 'crtr-subtree', 'the inherited root session is the child backstage');
119
+ });
120
+ test('birth: an independent --root inside tmux homes to the caller current session', () => {
121
+ assert.equal(resolveBirthSession({ adoptCaller: true, here: { session: 'user-sess' }, rootSession: 'crtr' }), 'user-sess', 'a --root adopts the caller session where the spawner is working');
122
+ });
123
+ test('birth: a --root NOT inside tmux falls back to the backstage', () => {
124
+ assert.equal(resolveBirthSession({ adoptCaller: true, here: null, rootSession: undefined }), nodeSession(), 'no caller session → the backstage');
125
+ });
126
+ test('birth: the inline front door (bootRoot) homes to its adopted session', () => {
127
+ // bootRoot adopts the caller session when inside tmux, else nodeSession().
128
+ assert.equal(resolveBirthSession({ adoptCaller: true, here: { session: 'term-sess' }, rootSession: undefined }), 'term-sess', 'inline root adopts the terminal it took over');
129
+ assert.equal(resolveBirthSession({ adoptCaller: true, here: null, rootSession: undefined }), nodeSession(), 'inline root with no tmux homes to the backstage');
130
+ });
131
+ // ---------------------------------------------------------------------------
132
+ // Pane-recycle births populate home_session. relaunchRoot (option C) is fully
133
+ // unit-testable (injected respawn, no tmux); demoteNode runs to completion with
134
+ // no tmux (respawn dispatch just fails) once the daemon spawn is neutralized.
135
+ // ---------------------------------------------------------------------------
136
+ test('relaunch birth: the fresh root homes to the recycled pane session', () => {
137
+ createNode(node('root', { parent: null, lifecycle: 'resident', tmux_session: 'crtr', window: '@7' }));
138
+ // No live tmux → paneLocation(pane) is null → loc falls back to the old root's
139
+ // tmux_session ('crtr'); home_session must adopt it.
140
+ const res = relaunchRoot('root', 'test-pane', { relaunchRootInPane: () => { } });
141
+ assert.ok(res !== null, 'relaunchRoot minted a fresh root');
142
+ assert.equal(getNode(res.newNodeId)?.home_session, 'crtr', 'fresh root homes to the recycled pane session');
143
+ });
144
+ test('demote birth: the recycled root populates home_session (backstage when no pane location)', async () => {
145
+ createNode(node('M', { parent: null, lifecycle: 'resident', tmux_session: 'crtr', window: '@3' }));
146
+ fakeLiveDaemon(); // createNode ensured the home dir; now neutralize ensureDaemon
147
+ // No live tmux → paneLocation('%0') is null → home_session defaults to the
148
+ // backstage. The respawn dispatch fails (no tmux), but the fresh root is still
149
+ // born — and must carry a populated home_session.
150
+ const res = await demoteNode('M', '%0');
151
+ assert.ok(res.newRoot !== null, 'demote minted a fresh root');
152
+ assert.equal(getNode(res.newRoot)?.home_session, nodeSession(), 'recycled root homes to the backstage');
153
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/human-cancel-guard.test.ts
2
+ //
3
+ // N2 — humanCancel on an already-canceled (but unresolved) interaction node must
4
+ // short-circuit to {canceled:false, reason:'already_resolved'} instead of falling
5
+ // through to transition('finalize'), which is illegal from status='canceled' and
6
+ // would throw. Guards the one-line hardening in queue.ts's status guard.
7
+ import { test, before, after, beforeEach } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { createNode, setStatus } from '../canvas/canvas.js';
13
+ import { closeDb } from '../canvas/db.js';
14
+ import { humanCancel } from '../../commands/human/queue.js';
15
+ let home;
16
+ function node(id) {
17
+ return {
18
+ node_id: id,
19
+ name: id,
20
+ created: new Date().toISOString(),
21
+ cwd: '/tmp/work',
22
+ kind: 'general',
23
+ mode: 'base',
24
+ lifecycle: 'terminal',
25
+ status: 'active',
26
+ };
27
+ }
28
+ before(() => {
29
+ home = mkdtempSync(join(tmpdir(), 'crtr-humancancel-'));
30
+ process.env['CRTR_HOME'] = home;
31
+ });
32
+ beforeEach(() => {
33
+ closeDb();
34
+ rmSync(home, { recursive: true, force: true });
35
+ });
36
+ after(() => {
37
+ closeDb();
38
+ rmSync(home, { recursive: true, force: true });
39
+ delete process.env['CRTR_HOME'];
40
+ });
41
+ test('cancel on an already-canceled node is a no-op, never throws on the finalize', async () => {
42
+ const id = 'canceledJob';
43
+ createNode(node(id));
44
+ setStatus(id, 'canceled'); // canceled but no response.json written yet
45
+ const res = (await humanCancel.run({ job_id: id }));
46
+ assert.equal(res['canceled'], false);
47
+ assert.equal(res['reason'], 'already_resolved');
48
+ assert.equal(res['job_id'], id);
49
+ });
@@ -0,0 +1 @@
1
+ export {};