@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,185 @@
1
+ // Phase 2 keystone — runtime authority moved into the WAL'd row.
2
+ //
3
+ // These four guard the load-bearing invariants of the split:
4
+ // 1. concurrency — two writers of DIFFERENT runtime fields both land (the old
5
+ // whole-meta read-modify-write dropped one).
6
+ // 2. isolation — a single-column setter never disturbs the other runtime
7
+ // fields (single-statement atomicity).
8
+ // 3. persistence — meta.json on disk holds NO runtime fields, yet getNode
9
+ // still returns them (hydrated from the row).
10
+ // 4. backfill — a v2-shaped db with runtime still in meta migrates (v3) so
11
+ // runtime lands in the authoritative row.
12
+ import { test, before, beforeEach, after } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync, } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { DatabaseSync } from 'node:sqlite';
18
+ import { createNode, getNode, setStatus, setIntent, setPresence, recordPid, clearPid, } from '../canvas/canvas.js';
19
+ import { openDb, closeDb, MIGRATIONS } from '../canvas/db.js';
20
+ import { nodeDir, nodeMetaPath, canvasDbPath, ensureHome, } from '../canvas/paths.js';
21
+ let home;
22
+ function node(id, over = {}) {
23
+ return {
24
+ node_id: id,
25
+ name: id,
26
+ created: new Date().toISOString(),
27
+ cwd: '/tmp/work',
28
+ kind: 'general',
29
+ mode: 'base',
30
+ lifecycle: 'terminal',
31
+ status: 'active',
32
+ ...over,
33
+ };
34
+ }
35
+ before(() => {
36
+ home = mkdtempSync(join(tmpdir(), 'crtr-keystone-'));
37
+ process.env['CRTR_HOME'] = home;
38
+ });
39
+ beforeEach(() => {
40
+ closeDb();
41
+ rmSync(home, { recursive: true, force: true });
42
+ });
43
+ after(() => {
44
+ closeDb();
45
+ rmSync(home, { recursive: true, force: true });
46
+ delete process.env['CRTR_HOME'];
47
+ });
48
+ // 1. concurrency — both interleaved writers survive.
49
+ test('concurrency: an interleaved setStatus and recordPid both land', () => {
50
+ createNode(node('a', { status: 'active', pi_pid: null }));
51
+ // Two writers each take a STALE snapshot, exactly as the old cross-process
52
+ // race did (the daemon stamping pi_pid while the node flips status).
53
+ const snapDaemon = getNode('a');
54
+ const snapNode = getNode('a');
55
+ assert.equal(snapDaemon?.pi_pid ?? null, null);
56
+ assert.equal(snapNode?.status, 'active');
57
+ // Interleave their writes through the atomic per-column setters.
58
+ recordPid('a', 4321); // writer 1 (daemon)
59
+ setStatus('a', 'idle'); // writer 2 (node)
60
+ // Under the OLD whole-meta read-modify-write, writer 2 writing back its stale
61
+ // snapshot (pi_pid still null) would have CLOBBERED writer 1's pid. Atomic
62
+ // single-column UPDATEs serialized by WAL keep BOTH.
63
+ const m = getNode('a');
64
+ assert.equal(m?.pi_pid, 4321, 'the pid write survived');
65
+ assert.equal(m?.status, 'idle', 'the status write survived');
66
+ });
67
+ // 2. isolation — single-column atomicity in both directions.
68
+ test('isolation: setPresence and setStatus/setIntent never disturb each other', () => {
69
+ createNode(node('a', { status: 'active', intent: 'refresh', pi_pid: 99 }));
70
+ setPresence('a', { tmux_session: 's', window: '@1' });
71
+ let m = getNode('a');
72
+ assert.equal(m?.status, 'active', 'status undisturbed by setPresence');
73
+ assert.equal(m?.intent, 'refresh', 'intent undisturbed by setPresence');
74
+ assert.equal(m?.pi_pid, 99, 'pi_pid undisturbed by setPresence');
75
+ assert.equal(m?.tmux_session, 's');
76
+ assert.equal(m?.window, '@1');
77
+ // The reverse: flipping status + intent leaves presence and pid intact.
78
+ setStatus('a', 'done');
79
+ setIntent('a', 'done');
80
+ m = getNode('a');
81
+ assert.equal(m?.status, 'done');
82
+ assert.equal(m?.intent, 'done');
83
+ assert.equal(m?.tmux_session, 's', 'tmux_session undisturbed by setStatus/setIntent');
84
+ assert.equal(m?.window, '@1', 'window undisturbed by setStatus/setIntent');
85
+ assert.equal(m?.pi_pid, 99, 'pi_pid undisturbed by setStatus/setIntent');
86
+ clearPid('a');
87
+ assert.equal(getNode('a')?.pi_pid, null, 'clearPid nulls only the pid');
88
+ assert.equal(getNode('a')?.tmux_session, 's', 'clearPid leaves presence intact');
89
+ });
90
+ // 3. persistence-split — meta.json has no runtime; getNode still hydrates it.
91
+ test('persistence-split: meta.json holds no runtime fields, getNode hydrates them', () => {
92
+ createNode(node('a', {
93
+ status: 'active',
94
+ intent: 'refresh',
95
+ pi_pid: 7,
96
+ tmux_session: 's',
97
+ window: '@1',
98
+ }));
99
+ setStatus('a', 'done');
100
+ setPresence('a', { tmux_session: 's2', window: '@2' });
101
+ const raw = JSON.parse(readFileSync(nodeMetaPath('a'), 'utf8'));
102
+ for (const k of ['status', 'intent', 'pi_pid', 'window', 'tmux_session']) {
103
+ assert.ok(!(k in raw), `meta.json must NOT contain runtime field "${k}"`);
104
+ }
105
+ // identity is still on disk
106
+ assert.equal(raw['kind'], 'general');
107
+ assert.equal(raw['node_id'], 'a');
108
+ // ...while the hydrated view returns runtime from the authoritative row.
109
+ const m = getNode('a');
110
+ assert.equal(m?.status, 'done');
111
+ assert.equal(m?.tmux_session, 's2');
112
+ assert.equal(m?.window, '@2');
113
+ assert.equal(m?.intent, 'refresh');
114
+ assert.equal(m?.pi_pid, 7);
115
+ });
116
+ // 4. backfill — v2-shaped db with runtime in meta migrates so runtime lands in
117
+ // the row.
118
+ test('backfill (v3): runtime in legacy meta.json is copied into the row', () => {
119
+ ensureHome();
120
+ // A legacy meta with runtime STILL on disk (pre-keystone shape).
121
+ mkdirSync(nodeDir('n1'), { recursive: true });
122
+ writeFileSync(nodeMetaPath('n1'), JSON.stringify({
123
+ node_id: 'n1',
124
+ name: 'N1',
125
+ created: '2020-01-01T00:00:00Z',
126
+ cwd: '/tmp/work',
127
+ kind: 'general',
128
+ mode: 'base',
129
+ lifecycle: 'terminal',
130
+ // legacy runtime, still living in meta.json:
131
+ status: 'idle',
132
+ intent: 'refresh',
133
+ pi_pid: 555,
134
+ window: '@9',
135
+ tmux_session: 'sess',
136
+ }, null, 2));
137
+ // Hand-build a v2-shaped db: all columns present, runtime seeded NULL,
138
+ // user_version 2 (so the runner runs ONLY v3 = backfill).
139
+ const raw = new DatabaseSync(canvasDbPath());
140
+ raw.exec(`
141
+ CREATE TABLE nodes (
142
+ node_id TEXT PRIMARY KEY,
143
+ name TEXT NOT NULL,
144
+ kind TEXT NOT NULL,
145
+ mode TEXT NOT NULL DEFAULT 'base',
146
+ lifecycle TEXT NOT NULL DEFAULT 'terminal',
147
+ status TEXT NOT NULL DEFAULT 'active',
148
+ cwd TEXT NOT NULL,
149
+ parent TEXT,
150
+ created TEXT NOT NULL,
151
+ intent TEXT,
152
+ pi_pid INTEGER,
153
+ "window" TEXT,
154
+ tmux_session TEXT
155
+ );
156
+ `);
157
+ raw
158
+ .prepare('INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, parent, created) VALUES (?,?,?,?,?,?,?,?,?)')
159
+ .run('n1', 'N1', 'general', 'base', 'terminal', 'idle', '/tmp/work', null, '2020-01-01T00:00:00Z');
160
+ raw.exec('PRAGMA user_version = 2;');
161
+ // Precondition: runtime columns are NULL before the backfill.
162
+ const before = raw
163
+ .prepare('SELECT intent, pi_pid, "window", tmux_session FROM nodes WHERE node_id = ?')
164
+ .get('n1');
165
+ assert.equal(before['intent'], null);
166
+ assert.equal(before['pi_pid'], null);
167
+ raw.close();
168
+ // openDb() runs the migration runner forward → v3 backfill copies meta→row.
169
+ const db = openDb();
170
+ assert.equal(db.prepare('PRAGMA user_version').get().user_version, MIGRATIONS.length);
171
+ const r = db
172
+ .prepare('SELECT status, intent, pi_pid, "window", tmux_session FROM nodes WHERE node_id = ?')
173
+ .get('n1');
174
+ assert.equal(r['status'], 'idle', 'status already mirrored — untouched');
175
+ assert.equal(r['intent'], 'refresh', 'intent backfilled from meta');
176
+ assert.equal(r['pi_pid'], 555, 'pi_pid backfilled from meta');
177
+ assert.equal(r['window'], '@9', 'window backfilled from meta');
178
+ assert.equal(r['tmux_session'], 'sess', 'tmux_session backfilled from meta');
179
+ // And the hydrated view now reflects it.
180
+ const m = getNode('n1');
181
+ assert.equal(m?.intent, 'refresh');
182
+ assert.equal(m?.pi_pid, 555);
183
+ assert.equal(m?.window, '@9');
184
+ assert.equal(m?.tmux_session, 'sess');
185
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/kickoff.test.ts
2
+ //
3
+ // The fresh-revive kickoff split (runtime/kickoff.ts). drainBearings is the ONE
4
+ // consuming step (consume the yield note, advance the feed cursor); after it,
5
+ // buildReviveKickoff is a PURE string assembler — calling it twice produces the
6
+ // same string and mutates NOTHING (note stays gone, cursor stays put). This is
7
+ // the regression guard for the old "build had hidden side effects" smell.
8
+ import { test, before, after, beforeEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { createNode, subscribe } from '../canvas/canvas.js';
14
+ import { closeDb } from '../canvas/db.js';
15
+ import { drainBearings, buildReviveKickoff, writeYieldMessage, yieldMessagePath, } from '../runtime/kickoff.js';
16
+ import { appendInbox, readCursor } from '../feed/inbox.js';
17
+ let home;
18
+ function node(id) {
19
+ return {
20
+ node_id: id,
21
+ name: id,
22
+ created: new Date().toISOString(),
23
+ cwd: '/tmp/work',
24
+ kind: 'general',
25
+ mode: 'base',
26
+ lifecycle: 'terminal',
27
+ status: 'active',
28
+ };
29
+ }
30
+ before(() => {
31
+ home = mkdtempSync(join(tmpdir(), 'crtr-kickoff-'));
32
+ process.env['CRTR_HOME'] = home;
33
+ });
34
+ beforeEach(() => {
35
+ closeDb();
36
+ rmSync(home, { recursive: true, force: true });
37
+ });
38
+ after(() => {
39
+ closeDb();
40
+ rmSync(home, { recursive: true, force: true });
41
+ delete process.env['CRTR_HOME'];
42
+ });
43
+ test('drainBearings consumes the yield note + advances the cursor exactly once', () => {
44
+ const id = 'n1';
45
+ const meta = createNode(node(id));
46
+ writeYieldMessage(id, 'on wake, finish step 3');
47
+ appendInbox(id, { from: 'w1', tier: 'normal', kind: 'update', label: 'first', ref: '/tmp/a.md' });
48
+ const last = appendInbox(id, { from: 'w1', tier: 'normal', kind: 'update', label: 'second', ref: '/tmp/b.md' });
49
+ // First drain: captures the note + digests the unread feed.
50
+ const b1 = drainBearings(meta);
51
+ assert.equal(b1.yieldMsg?.trim(), 'on wake, finish step 3');
52
+ assert.ok(b1.unreadDigest !== null && b1.unreadDigest.length > 0);
53
+ assert.equal(existsSync(yieldMessagePath(id)), false, 'yield note deleted on read');
54
+ assert.equal(readCursor(id), last.ts, 'cursor advanced to the last unread entry');
55
+ // Second drain: the note is gone and the cursor is past the feed → nothing left.
56
+ const b2 = drainBearings(meta);
57
+ assert.equal(b2.yieldMsg, null);
58
+ assert.equal(b2.unreadDigest, null);
59
+ });
60
+ test('the feed block frames awaiting workers as alive + auto-waking, so a fresh revive has no reason to peek', () => {
61
+ const parent = createNode(node('p1'));
62
+ const child = createNode(node('c1')); // status 'active' by default
63
+ subscribe(parent.node_id, child.node_id); // parent awaits child
64
+ const msg = buildReviveKickoff(parent, drainBearings(parent));
65
+ // The roster names the live child...
66
+ assert.ok(msg.includes(child.node_id), 'awaiting roster lists the live child');
67
+ // ...and asserts aliveness + the automatic wake at the source, so the node
68
+ // does not burn a turn confirming with `feed read`/`feed peek`.
69
+ assert.ok(/alive and running/.test(msg), 'states the worker is alive and running');
70
+ assert.ok(/wake you the moment/.test(msg), 'states the wake is automatic on push');
71
+ assert.ok(/still working, not stalled/.test(msg), 'frames the empty feed as expected, not a problem');
72
+ });
73
+ test('buildReviveKickoff is pure — building twice eats nothing', () => {
74
+ const id = 'n2';
75
+ const meta = createNode(node(id));
76
+ writeYieldMessage(id, 'remember the invariant');
77
+ const last = appendInbox(id, { from: 'w', tier: 'normal', kind: 'update', label: 'r', ref: '/tmp/r.md' });
78
+ // Drain once (the consuming step), then build twice off the same bearings.
79
+ const bearings = drainBearings(meta);
80
+ const cursorAfterDrain = readCursor(id);
81
+ assert.equal(cursorAfterDrain, last.ts);
82
+ const s1 = buildReviveKickoff(meta, bearings);
83
+ const s2 = buildReviveKickoff(meta, bearings);
84
+ // Identical output, and the build mutated nothing: cursor unchanged, note gone.
85
+ assert.equal(s1, s2);
86
+ assert.ok(s1.includes('remember the invariant'), 'yield note surfaced in the message');
87
+ assert.equal(readCursor(id), cursorAfterDrain, 'build does not touch the cursor');
88
+ assert.equal(existsSync(yieldMessagePath(id)), false, 'build does not resurrect the note');
89
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,178 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/lifecycle.test.ts
2
+ //
3
+ // The status×intent state machine (runtime/lifecycle.ts). Every LEGAL event
4
+ // from every valid from-status lands the documented (status, intent), and every
5
+ // ILLEGAL move throws — illegal states are unrepresentable. This IS the old
6
+ // markCleanExitDone guard + the dozen scattered setStatus/setIntent pairs, now
7
+ // table-driven. All assertions read back through getNode() (the hydrated view).
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, getNode, setStatus, setIntent } from '../canvas/canvas.js';
14
+ import { closeDb } from '../canvas/db.js';
15
+ import { transition } from '../runtime/lifecycle.js';
16
+ let home;
17
+ function node(id) {
18
+ return {
19
+ node_id: id,
20
+ name: id,
21
+ created: new Date().toISOString(),
22
+ cwd: '/tmp/work',
23
+ kind: 'general',
24
+ mode: 'base',
25
+ lifecycle: 'terminal',
26
+ status: 'active',
27
+ };
28
+ }
29
+ /** Create a node forced into a precise (status, intent) start state. */
30
+ function mk(id, status, intent = null) {
31
+ createNode(node(id));
32
+ setStatus(id, status);
33
+ setIntent(id, intent);
34
+ }
35
+ before(() => {
36
+ home = mkdtempSync(join(tmpdir(), 'crtr-lifecycle-'));
37
+ process.env['CRTR_HOME'] = home;
38
+ });
39
+ beforeEach(() => {
40
+ closeDb();
41
+ rmSync(home, { recursive: true, force: true });
42
+ });
43
+ after(() => {
44
+ closeDb();
45
+ rmSync(home, { recursive: true, force: true });
46
+ delete process.env['CRTR_HOME'];
47
+ });
48
+ const ALL = ['active', 'idle', 'done', 'dead', 'canceled'];
49
+ const LIVE = ['active', 'idle'];
50
+ const TERMINAL = ['done', 'dead', 'canceled'];
51
+ // ---------------------------------------------------------------------------
52
+ // finalize → done + intent='done', legal only from active|idle
53
+ // ---------------------------------------------------------------------------
54
+ test('finalize: active|idle → done + intent=done', () => {
55
+ for (const from of LIVE) {
56
+ mk(`n_${from}`, from, 'refresh');
57
+ const m = transition(`n_${from}`, 'finalize');
58
+ assert.equal(m.status, 'done', `finalize from ${from}`);
59
+ assert.equal(m.intent, 'done', `finalize from ${from} sets intent=done`);
60
+ }
61
+ });
62
+ test('finalize: illegal from done|dead|canceled → throws, status untouched', () => {
63
+ for (const from of TERMINAL) {
64
+ mk(`n_${from}`, from);
65
+ assert.throws(() => transition(`n_${from}`, 'finalize'), /illegal lifecycle transition/);
66
+ assert.equal(getNode(`n_${from}`)?.status, from, `${from} unchanged on illegal finalize`);
67
+ }
68
+ });
69
+ // ---------------------------------------------------------------------------
70
+ // reap → done + intent cleared, legal from ANY status (forced teardown)
71
+ // ---------------------------------------------------------------------------
72
+ test('reap: any status → done + intent cleared', () => {
73
+ for (const from of ALL) {
74
+ mk(`n_${from}`, from, 'refresh');
75
+ const m = transition(`n_${from}`, 'reap');
76
+ assert.equal(m.status, 'done', `reap from ${from}`);
77
+ assert.equal(m.intent, null, `reap from ${from} clears intent`);
78
+ }
79
+ });
80
+ // ---------------------------------------------------------------------------
81
+ // cancel → canceled + intent cleared, legal from ANY status
82
+ // ---------------------------------------------------------------------------
83
+ test('cancel: any status → canceled + intent cleared', () => {
84
+ for (const from of ALL) {
85
+ mk(`n_${from}`, from, 'idle-release');
86
+ const m = transition(`n_${from}`, 'cancel');
87
+ assert.equal(m.status, 'canceled', `cancel from ${from}`);
88
+ assert.equal(m.intent, null, `cancel from ${from} clears intent`);
89
+ }
90
+ });
91
+ // ---------------------------------------------------------------------------
92
+ // crash → dead, intent UNCHANGED, legal only from active|idle
93
+ // ---------------------------------------------------------------------------
94
+ test('crash: active|idle → dead, intent preserved', () => {
95
+ for (const from of LIVE) {
96
+ mk(`n_${from}`, from, 'refresh');
97
+ const m = transition(`n_${from}`, 'crash');
98
+ assert.equal(m.status, 'dead', `crash from ${from}`);
99
+ assert.equal(m.intent, 'refresh', `crash from ${from} leaves intent untouched`);
100
+ }
101
+ });
102
+ test('crash: illegal from done|dead|canceled → throws', () => {
103
+ for (const from of TERMINAL) {
104
+ mk(`n_${from}`, from);
105
+ assert.throws(() => transition(`n_${from}`, 'crash'), /illegal lifecycle transition/);
106
+ }
107
+ });
108
+ // ---------------------------------------------------------------------------
109
+ // yield → intent='refresh', status UNCHANGED, legal only from active|idle
110
+ // ---------------------------------------------------------------------------
111
+ test('yield: active|idle → intent=refresh, status preserved', () => {
112
+ for (const from of LIVE) {
113
+ mk(`n_${from}`, from);
114
+ const m = transition(`n_${from}`, 'yield');
115
+ assert.equal(m.status, from, `yield from ${from} keeps status`);
116
+ assert.equal(m.intent, 'refresh', `yield from ${from} sets intent=refresh`);
117
+ }
118
+ });
119
+ test('yield: illegal from done|dead|canceled → throws', () => {
120
+ for (const from of TERMINAL) {
121
+ mk(`n_${from}`, from);
122
+ assert.throws(() => transition(`n_${from}`, 'yield'), /illegal lifecycle transition/);
123
+ }
124
+ });
125
+ // ---------------------------------------------------------------------------
126
+ // release → idle + intent='idle-release', legal only from active|idle
127
+ // ---------------------------------------------------------------------------
128
+ test('release: active|idle → idle + intent=idle-release', () => {
129
+ for (const from of LIVE) {
130
+ mk(`n_${from}`, from);
131
+ const m = transition(`n_${from}`, 'release');
132
+ assert.equal(m.status, 'idle', `release from ${from}`);
133
+ assert.equal(m.intent, 'idle-release', `release from ${from} sets intent=idle-release`);
134
+ }
135
+ });
136
+ test('release: illegal from done|dead|canceled → throws', () => {
137
+ for (const from of TERMINAL) {
138
+ mk(`n_${from}`, from);
139
+ assert.throws(() => transition(`n_${from}`, 'release'), /illegal lifecycle transition/);
140
+ }
141
+ });
142
+ // ---------------------------------------------------------------------------
143
+ // revive → active + intent cleared, legal from ANY status (the universal "back")
144
+ // ---------------------------------------------------------------------------
145
+ test('revive: any status → active + intent cleared', () => {
146
+ for (const from of ALL) {
147
+ mk(`n_${from}`, from, 'idle-release');
148
+ const m = transition(`n_${from}`, 'revive');
149
+ assert.equal(m.status, 'active', `revive from ${from}`);
150
+ assert.equal(m.intent, null, `revive from ${from} clears intent`);
151
+ }
152
+ });
153
+ // ---------------------------------------------------------------------------
154
+ // boot → active, intent UNCHANGED (reviveInPlace keeps the refresh safety net),
155
+ // legal only from active|idle
156
+ // ---------------------------------------------------------------------------
157
+ test('boot: active|idle → active, intent preserved (refresh net survives)', () => {
158
+ for (const from of LIVE) {
159
+ mk(`n_${from}`, from, 'refresh');
160
+ const m = transition(`n_${from}`, 'boot');
161
+ assert.equal(m.status, 'active', `boot from ${from}`);
162
+ assert.equal(m.intent, 'refresh', `boot from ${from} keeps intent (proof-of-boot net)`);
163
+ }
164
+ });
165
+ test('boot: illegal from done|dead|canceled → throws', () => {
166
+ for (const from of TERMINAL) {
167
+ mk(`n_${from}`, from);
168
+ assert.throws(() => transition(`n_${from}`, 'boot'), /illegal lifecycle transition/);
169
+ }
170
+ });
171
+ // ---------------------------------------------------------------------------
172
+ // unknown node → throws for every event
173
+ // ---------------------------------------------------------------------------
174
+ test('transition on an unknown node throws', () => {
175
+ for (const ev of ['finalize', 'reap', 'cancel', 'crash', 'yield', 'release', 'revive', 'boot']) {
176
+ assert.throws(() => transition('ghost', ev), /unknown node/);
177
+ }
178
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ // Completeness guard for the assembled parent-level listings.
2
+ // Run with: node --import tsx/esm --test src/core/__tests__/listing-completeness.test.ts
3
+ //
4
+ // Each non-hidden child surfaced in a branch's `help.listing` must carry a real
5
+ // self-description: both `description` and `whenToUse` non-empty. The listing is
6
+ // assembled by defineBranch from the child defs, so a blank attribute here means
7
+ // a child def forgot to declare its description/whenToUse. Walks the whole live
8
+ // command tree (buildRoot) so every shipped subtree is covered.
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { buildRoot } from '../../build-root.js';
12
+ test('every non-hidden listing child declares description + whenToUse', () => {
13
+ const root = buildRoot();
14
+ const missing = [];
15
+ const walk = (branch, path) => {
16
+ for (const child of branch.help.listing ?? []) {
17
+ if (child.tier === 'hidden')
18
+ continue;
19
+ if (child.description.trim() === '' || child.whenToUse.trim() === '') {
20
+ missing.push(`${path} ${child.name}: empty description/whenToUse`);
21
+ }
22
+ }
23
+ for (const child of branch.children) {
24
+ if (child.kind === 'branch')
25
+ walk(child, `${path} ${child.name}`);
26
+ }
27
+ };
28
+ for (const subtree of root.subtrees)
29
+ walk(subtree, subtree.name);
30
+ assert.deepEqual(missing, [], `listing children missing self-description:\n${missing.join('\n')}`);
31
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,152 @@
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.
10
+ //
11
+ // Run: node --import tsx/esm --test src/core/__tests__/memory.test.ts
12
+ import { test, before, beforeEach, after } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { closeDb } from '../canvas/db.js';
18
+ import { crtrHome } from '../canvas/paths.js';
19
+ import { mangleCwd } from '../artifact.js';
20
+ import { spawnNode } from '../runtime/nodes.js';
21
+ import { promote } from '../runtime/promote.js';
22
+ import { personaDrift } from '../runtime/persona.js';
23
+ 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
+ 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
+ before(() => {
30
+ home = mkdtempSync(join(tmpdir(), 'crtr-memory-'));
31
+ process.env['CRTR_HOME'] = home;
32
+ });
33
+ beforeEach(() => {
34
+ closeDb();
35
+ rmSync(home, { recursive: true, force: true });
36
+ });
37
+ after(() => {
38
+ closeDb();
39
+ rmSync(home, { recursive: true, force: true });
40
+ for (const d of scratch)
41
+ rmSync(d, { recursive: true, force: true });
42
+ delete process.env['CRTR_HOME'];
43
+ });
44
+ // ---------------------------------------------------------------------------
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
132
+ // ---------------------------------------------------------------------------
133
+ test('the orchestration kernel names the three stores and the type\u2192store mapping', () => {
134
+ const kernel = loadKernel();
135
+ for (const store of ['user-global', 'project', 'node-local']) {
136
+ assert.ok(kernel.includes(store), `kernel names the ${store} store`);
137
+ }
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', () => {
143
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
144
+ 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 ?? '';
147
+ for (const store of ['user-global', 'project', 'node-local']) {
148
+ assert.ok(guidance.includes(store), `guidance names the ${store} store`);
149
+ }
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');
152
+ });
@@ -0,0 +1 @@
1
+ export {};