@crouton-kit/crouter 0.3.13 → 0.3.15

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 (233) 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/__tests__/human.test.js +73 -2
  35. package/dist/commands/attention.js +76 -7
  36. package/dist/commands/canvas-prune.d.ts +2 -0
  37. package/dist/commands/canvas-prune.js +66 -0
  38. package/dist/commands/canvas.js +5 -8
  39. package/dist/commands/chord.d.ts +2 -0
  40. package/dist/commands/chord.js +143 -0
  41. package/dist/commands/daemon.js +8 -5
  42. package/dist/commands/dashboard.js +2 -0
  43. package/dist/commands/human/prompts.js +28 -27
  44. package/dist/commands/human/queue.d.ts +1 -0
  45. package/dist/commands/human/queue.js +105 -2
  46. package/dist/commands/human/shared.d.ts +28 -18
  47. package/dist/commands/human/shared.js +53 -60
  48. package/dist/commands/human.js +6 -14
  49. package/dist/commands/node.d.ts +11 -0
  50. package/dist/commands/node.js +381 -87
  51. package/dist/commands/pkg/market-inspect.js +6 -4
  52. package/dist/commands/pkg/market-manage.js +10 -6
  53. package/dist/commands/pkg/market.js +2 -4
  54. package/dist/commands/pkg/plugin-inspect.js +6 -4
  55. package/dist/commands/pkg/plugin-manage.js +12 -7
  56. package/dist/commands/pkg/plugin.js +2 -4
  57. package/dist/commands/pkg.js +0 -4
  58. package/dist/commands/push.js +178 -15
  59. package/dist/commands/revive.js +5 -3
  60. package/dist/commands/skill/author.js +6 -4
  61. package/dist/commands/skill/find.js +8 -5
  62. package/dist/commands/skill/read.js +2 -0
  63. package/dist/commands/skill/state.js +6 -4
  64. package/dist/commands/skill.js +0 -6
  65. package/dist/commands/sys/config.js +21 -7
  66. package/dist/commands/sys/doctor.js +2 -0
  67. package/dist/commands/sys/update.js +4 -0
  68. package/dist/commands/sys.js +0 -6
  69. package/dist/commands/tmux-spread.d.ts +2 -0
  70. package/dist/commands/tmux-spread.js +130 -0
  71. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  72. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  73. package/dist/core/__tests__/child-followup.test.js +83 -0
  74. package/dist/core/__tests__/close.test.d.ts +1 -0
  75. package/dist/core/__tests__/close.test.js +148 -0
  76. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  77. package/dist/core/__tests__/context-intro.test.js +196 -0
  78. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  80. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  81. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  82. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  83. package/dist/core/__tests__/focuses.test.js +259 -0
  84. package/dist/core/__tests__/fork.test.d.ts +1 -0
  85. package/dist/core/__tests__/fork.test.js +91 -0
  86. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  87. package/dist/core/__tests__/home-session.test.js +153 -0
  88. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  89. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  90. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  91. package/dist/core/__tests__/keystone.test.js +185 -0
  92. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  93. package/dist/core/__tests__/kickoff.test.js +89 -0
  94. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  95. package/dist/core/__tests__/lifecycle.test.js +178 -0
  96. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  97. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  98. package/dist/core/__tests__/memory.test.d.ts +1 -0
  99. package/dist/core/__tests__/memory.test.js +152 -0
  100. package/dist/core/__tests__/migration.test.d.ts +1 -0
  101. package/dist/core/__tests__/migration.test.js +238 -0
  102. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  103. package/dist/core/__tests__/pane-column.test.js +153 -0
  104. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  105. package/dist/core/__tests__/passive-subscription.test.js +164 -0
  106. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  107. package/dist/core/__tests__/persona-compose.test.js +53 -0
  108. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  109. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  110. package/dist/core/__tests__/persona.test.d.ts +1 -0
  111. package/dist/core/__tests__/persona.test.js +107 -0
  112. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  113. package/dist/core/__tests__/placement-focus.test.js +244 -0
  114. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  115. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  116. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  117. package/dist/core/__tests__/placement-revive.test.js +238 -0
  118. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  119. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  120. package/dist/core/__tests__/prune.test.d.ts +1 -0
  121. package/dist/core/__tests__/prune.test.js +116 -0
  122. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  123. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  124. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  125. package/dist/core/__tests__/relaunch.test.js +328 -0
  126. package/dist/core/__tests__/reset.test.js +26 -7
  127. package/dist/core/__tests__/revive.test.d.ts +1 -0
  128. package/dist/core/__tests__/revive.test.js +217 -0
  129. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  130. package/dist/core/__tests__/spawn-root.test.js +73 -0
  131. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  132. package/dist/core/__tests__/steer-note.test.js +39 -0
  133. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  134. package/dist/core/__tests__/stop-guard.test.js +82 -0
  135. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  136. package/dist/core/__tests__/subcommand-tier.test.js +99 -0
  137. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  138. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  139. package/dist/core/__tests__/unknown-path.test.js +8 -2
  140. package/dist/core/canvas/attention.d.ts +10 -0
  141. package/dist/core/canvas/attention.js +40 -0
  142. package/dist/core/canvas/canvas.d.ts +66 -7
  143. package/dist/core/canvas/canvas.js +209 -21
  144. package/dist/core/canvas/db.d.ts +8 -0
  145. package/dist/core/canvas/db.js +206 -4
  146. package/dist/core/canvas/focuses.d.ts +22 -0
  147. package/dist/core/canvas/focuses.js +80 -0
  148. package/dist/core/canvas/index.d.ts +3 -0
  149. package/dist/core/canvas/index.js +3 -0
  150. package/dist/core/canvas/labels.d.ts +27 -0
  151. package/dist/core/canvas/labels.js +36 -0
  152. package/dist/core/canvas/paths.d.ts +4 -0
  153. package/dist/core/canvas/paths.js +6 -0
  154. package/dist/core/canvas/render.js +25 -10
  155. package/dist/core/canvas/telemetry.d.ts +14 -0
  156. package/dist/core/canvas/telemetry.js +35 -0
  157. package/dist/core/canvas/types.d.ts +115 -12
  158. package/dist/core/command.d.ts +25 -1
  159. package/dist/core/command.js +48 -7
  160. package/dist/core/config.js +36 -2
  161. package/dist/core/feed/feed.js +14 -12
  162. package/dist/core/feed/inbox.d.ts +3 -1
  163. package/dist/core/feed/inbox.js +45 -5
  164. package/dist/core/feed/passive.d.ts +17 -0
  165. package/dist/core/feed/passive.js +92 -0
  166. package/dist/core/help.d.ts +59 -13
  167. package/dist/core/help.js +73 -28
  168. package/dist/core/personas/index.d.ts +1 -1
  169. package/dist/core/personas/index.js +1 -1
  170. package/dist/core/personas/loader.d.ts +40 -1
  171. package/dist/core/personas/loader.js +63 -1
  172. package/dist/core/personas/resolve.d.ts +13 -6
  173. package/dist/core/personas/resolve.js +46 -34
  174. package/dist/core/runtime/bearings.d.ts +20 -0
  175. package/dist/core/runtime/bearings.js +92 -0
  176. package/dist/core/runtime/close.d.ts +14 -0
  177. package/dist/core/runtime/close.js +151 -0
  178. package/dist/core/runtime/demote.d.ts +14 -0
  179. package/dist/core/runtime/demote.js +120 -0
  180. package/dist/core/runtime/front-door.js +1 -1
  181. package/dist/core/runtime/kickoff.d.ts +32 -6
  182. package/dist/core/runtime/kickoff.js +111 -37
  183. package/dist/core/runtime/launch.d.ts +29 -6
  184. package/dist/core/runtime/launch.js +85 -13
  185. package/dist/core/runtime/lifecycle.d.ts +13 -0
  186. package/dist/core/runtime/lifecycle.js +86 -0
  187. package/dist/core/runtime/memory.d.ts +43 -0
  188. package/dist/core/runtime/memory.js +165 -0
  189. package/dist/core/runtime/naming.d.ts +22 -0
  190. package/dist/core/runtime/naming.js +166 -0
  191. package/dist/core/runtime/nodes.d.ts +32 -1
  192. package/dist/core/runtime/nodes.js +60 -10
  193. package/dist/core/runtime/persona.d.ts +25 -0
  194. package/dist/core/runtime/persona.js +139 -0
  195. package/dist/core/runtime/placement.d.ts +287 -0
  196. package/dist/core/runtime/placement.js +663 -0
  197. package/dist/core/runtime/presence.d.ts +7 -32
  198. package/dist/core/runtime/presence.js +90 -110
  199. package/dist/core/runtime/promote.d.ts +18 -7
  200. package/dist/core/runtime/promote.js +70 -65
  201. package/dist/core/runtime/reset.d.ts +47 -4
  202. package/dist/core/runtime/reset.js +223 -52
  203. package/dist/core/runtime/revive.d.ts +26 -2
  204. package/dist/core/runtime/revive.js +166 -39
  205. package/dist/core/runtime/roadmap.d.ts +5 -4
  206. package/dist/core/runtime/roadmap.js +9 -16
  207. package/dist/core/runtime/spawn.d.ts +20 -5
  208. package/dist/core/runtime/spawn.js +169 -44
  209. package/dist/core/runtime/stop-guard.d.ts +1 -1
  210. package/dist/core/runtime/stop-guard.js +18 -8
  211. package/dist/core/runtime/tmux.d.ts +106 -21
  212. package/dist/core/runtime/tmux.js +249 -45
  213. package/dist/core/spawn.js +15 -0
  214. package/dist/daemon/crtrd.d.ts +12 -1
  215. package/dist/daemon/crtrd.js +152 -34
  216. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  217. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  218. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  219. package/dist/pi-extensions/canvas-commands.js +103 -0
  220. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  221. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  222. package/dist/pi-extensions/canvas-goal-capture.d.ts +21 -0
  223. package/dist/pi-extensions/canvas-goal-capture.js +67 -0
  224. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  225. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  226. package/dist/pi-extensions/canvas-nav.js +586 -262
  227. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  228. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  229. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  230. package/dist/pi-extensions/canvas-stophook.js +344 -228
  231. package/dist/types.d.ts +28 -0
  232. package/dist/types.js +16 -0
  233. package/package.json +1 -1
@@ -0,0 +1,238 @@
1
+ import { test, before, beforeEach, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+ import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
8
+ import { canvasDbPath, ensureHome } from '../canvas/paths.js';
9
+ const RUNTIME_COLUMNS = ['intent', 'pi_pid', 'window', 'tmux_session'];
10
+ let home;
11
+ function userVersion(db) {
12
+ return db.prepare('PRAGMA user_version').get()
13
+ .user_version;
14
+ }
15
+ function nodeColumns(db) {
16
+ return db.prepare('PRAGMA table_info(nodes)').all().map((r) => r.name);
17
+ }
18
+ before(() => {
19
+ home = mkdtempSync(join(tmpdir(), 'crtr-migration-'));
20
+ process.env['CRTR_HOME'] = home;
21
+ });
22
+ beforeEach(() => {
23
+ // Fresh db + dirs per test for isolation.
24
+ closeDb();
25
+ rmSync(home, { recursive: true, force: true });
26
+ });
27
+ after(() => {
28
+ closeDb();
29
+ rmSync(home, { recursive: true, force: true });
30
+ delete process.env['CRTR_HOME'];
31
+ });
32
+ test('opening a fresh home migrates to the latest user_version', () => {
33
+ const db = openDb();
34
+ assert.equal(userVersion(db), MIGRATIONS.length);
35
+ });
36
+ test('a fresh db has the four additive runtime columns', () => {
37
+ const db = openDb();
38
+ const cols = nodeColumns(db);
39
+ for (const c of RUNTIME_COLUMNS) {
40
+ assert.ok(cols.includes(c), `expected nodes.${c} to exist`);
41
+ }
42
+ // `status` predates Phase 1 and must not be duplicated.
43
+ assert.equal(cols.filter((c) => c === 'status').length, 1, 'status must appear exactly once');
44
+ });
45
+ test('a simulated v0 db migrates forward without data loss', () => {
46
+ // Build a v0 db by hand: the baseline tables only, user_version 0, no runtime
47
+ // columns — exactly the shape of the live fleet before this phase.
48
+ ensureHome();
49
+ const raw = new DatabaseSync(canvasDbPath());
50
+ raw.exec(`
51
+ CREATE TABLE nodes (
52
+ node_id TEXT PRIMARY KEY,
53
+ name TEXT NOT NULL,
54
+ kind TEXT NOT NULL,
55
+ mode TEXT NOT NULL DEFAULT 'base',
56
+ lifecycle TEXT NOT NULL DEFAULT 'terminal',
57
+ status TEXT NOT NULL DEFAULT 'active',
58
+ cwd TEXT NOT NULL,
59
+ parent TEXT,
60
+ created TEXT NOT NULL
61
+ );
62
+ CREATE TABLE edges (
63
+ type TEXT NOT NULL,
64
+ from_id TEXT NOT NULL,
65
+ to_id TEXT NOT NULL,
66
+ active INTEGER NOT NULL DEFAULT 1,
67
+ created TEXT NOT NULL,
68
+ PRIMARY KEY (type, from_id, to_id)
69
+ );
70
+ `);
71
+ raw.exec('PRAGMA user_version = 0;');
72
+ raw
73
+ .prepare('INSERT INTO nodes (node_id, name, kind, status, cwd, created) VALUES (?,?,?,?,?,?)')
74
+ .run('n1', 'N1', 'general', 'done', '/tmp/work', '2020-01-01T00:00:00Z');
75
+ raw
76
+ .prepare('INSERT INTO edges (type, from_id, to_id, created) VALUES (?,?,?,?)')
77
+ .run('subscribes_to', 'n1', 'n1', '2020-01-01T00:00:00Z');
78
+ // Precondition: genuinely a v0 db with no runtime columns.
79
+ assert.equal(userVersion(raw), 0);
80
+ assert.ok(!nodeColumns(raw).includes('intent'));
81
+ raw.close();
82
+ // openDb() runs the migration runner forward.
83
+ const db = openDb();
84
+ assert.equal(userVersion(db), MIGRATIONS.length);
85
+ // Runtime columns now exist.
86
+ const cols = nodeColumns(db);
87
+ for (const c of RUNTIME_COLUMNS) {
88
+ assert.ok(cols.includes(c), `expected nodes.${c} after migration`);
89
+ }
90
+ // Pre-existing data is intact — no loss, no mutation of existing columns.
91
+ const row = db
92
+ .prepare('SELECT node_id, status, cwd FROM nodes WHERE node_id = ?')
93
+ .get('n1');
94
+ assert.equal(row.node_id, 'n1');
95
+ assert.equal(row.status, 'done');
96
+ assert.equal(row.cwd, '/tmp/work');
97
+ const edgeCount = db.prepare('SELECT COUNT(*) AS n FROM edges').get().n;
98
+ assert.equal(edgeCount, 1);
99
+ // The new columns are seeded NULL for the migrated row — nothing observed.
100
+ const rt = db
101
+ .prepare('SELECT intent, pi_pid, "window", tmux_session FROM nodes WHERE node_id = ?')
102
+ .get('n1');
103
+ assert.equal(rt['intent'], null);
104
+ assert.equal(rt['pi_pid'], null);
105
+ assert.equal(rt['window'], null);
106
+ assert.equal(rt['tmux_session'], null);
107
+ });
108
+ // --- v4: edges FK ON DELETE CASCADE (the one irreversible rebuild) ------------
109
+ /** Build a hand-rolled db at exactly v3 (baseline tables + the four runtime
110
+ * columns, edges with NO foreign key, `user_version = 3`) — the shape the live
111
+ * fleet reaches just before v4. Caller seeds it, we close it; the next openDb()
112
+ * runs only v4 over it. */
113
+ function buildV3Db() {
114
+ ensureHome();
115
+ const raw = new DatabaseSync(canvasDbPath());
116
+ raw.exec(`
117
+ CREATE TABLE nodes (
118
+ node_id TEXT PRIMARY KEY,
119
+ name TEXT NOT NULL,
120
+ kind TEXT NOT NULL,
121
+ mode TEXT NOT NULL DEFAULT 'base',
122
+ lifecycle TEXT NOT NULL DEFAULT 'terminal',
123
+ status TEXT NOT NULL DEFAULT 'active',
124
+ cwd TEXT NOT NULL,
125
+ parent TEXT,
126
+ created TEXT NOT NULL,
127
+ intent TEXT,
128
+ pi_pid INTEGER,
129
+ window TEXT,
130
+ tmux_session TEXT
131
+ );
132
+ CREATE TABLE edges (
133
+ type TEXT NOT NULL,
134
+ from_id TEXT NOT NULL,
135
+ to_id TEXT NOT NULL,
136
+ active INTEGER NOT NULL DEFAULT 1,
137
+ created TEXT NOT NULL,
138
+ PRIMARY KEY (type, from_id, to_id)
139
+ );
140
+ PRAGMA user_version = 3;
141
+ `);
142
+ return raw;
143
+ }
144
+ function insertNode(db, id) {
145
+ db.prepare('INSERT INTO nodes (node_id, name, kind, status, cwd, created) VALUES (?,?,?,?,?,?)').run(id, id, 'general', 'active', '/tmp/work', '2020-01-01T00:00:00Z');
146
+ }
147
+ function insertEdge(db, type, from, to) {
148
+ db.prepare('INSERT INTO edges (type, from_id, to_id, created) VALUES (?,?,?,?)').run(type, from, to, '2020-01-01T00:00:00Z');
149
+ }
150
+ function edgeCount(db) {
151
+ return db.prepare('SELECT COUNT(*) AS n FROM edges').get().n;
152
+ }
153
+ test('v4 migrates a db with a pre-existing orphan edge without throwing', () => {
154
+ const raw = buildV3Db();
155
+ insertNode(raw, 'n1');
156
+ insertNode(raw, 'n2');
157
+ insertEdge(raw, 'subscribes_to', 'n1', 'n2'); // non-orphan: both endpoints exist
158
+ insertEdge(raw, 'spawned_by', 'n1', 'ghost'); // ORPHAN: 'ghost' has no node row
159
+ assert.equal(userVersion(raw), 3);
160
+ assert.equal(edgeCount(raw), 2);
161
+ raw.close();
162
+ // openDb() runs v4. It must NOT throw on the orphan, and must drop it.
163
+ const db = openDb();
164
+ assert.equal(userVersion(db), MIGRATIONS.length); // migrated forward to the head
165
+ // The orphan edge is filtered; the non-orphan edge + its count are preserved.
166
+ assert.equal(edgeCount(db), 1);
167
+ const surviving = db
168
+ .prepare('SELECT type, from_id, to_id FROM edges')
169
+ .get();
170
+ assert.equal(surviving.type, 'subscribes_to');
171
+ assert.equal(surviving.from_id, 'n1');
172
+ assert.equal(surviving.to_id, 'n2');
173
+ });
174
+ test('v4 leaves the FK live: an edge to a missing node is rejected', () => {
175
+ const raw = buildV3Db();
176
+ insertNode(raw, 'n1');
177
+ raw.close();
178
+ const db = openDb();
179
+ assert.equal(userVersion(db), MIGRATIONS.length);
180
+ // Inserting an edge whose endpoint has no node row now violates the FK.
181
+ assert.throws(() => db
182
+ .prepare('INSERT INTO edges (type, from_id, to_id, active, created) VALUES (?,?,?,?,?)')
183
+ .run('spawned_by', 'n1', 'missing', 1, '2020-01-01T00:00:00Z'), /FOREIGN KEY|constraint/i);
184
+ // A fully-valid edge (both endpoints present) still inserts fine.
185
+ insertNode(db, 'n3');
186
+ assert.doesNotThrow(() => db
187
+ .prepare('INSERT INTO edges (type, from_id, to_id, active, created) VALUES (?,?,?,?,?)')
188
+ .run('subscribes_to', 'n1', 'n3', 1, '2020-01-01T00:00:00Z'));
189
+ });
190
+ test('v4 deleting a node cascade-deletes its edges', () => {
191
+ const raw = buildV3Db();
192
+ insertNode(raw, 'a');
193
+ insertNode(raw, 'b');
194
+ insertEdge(raw, 'subscribes_to', 'a', 'b');
195
+ insertEdge(raw, 'spawned_by', 'b', 'a');
196
+ raw.close();
197
+ const db = openDb();
198
+ assert.equal(edgeCount(db), 2);
199
+ // Delete one endpoint — every edge touching it cascades away.
200
+ db.prepare('DELETE FROM nodes WHERE node_id = ?').run('a');
201
+ assert.equal(edgeCount(db), 0);
202
+ });
203
+ test('v4 is idempotent on re-open', () => {
204
+ const raw = buildV3Db();
205
+ insertNode(raw, 'n1');
206
+ insertEdge(raw, 'subscribes_to', 'n1', 'n1');
207
+ raw.close();
208
+ const db = openDb();
209
+ assert.equal(userVersion(db), MIGRATIONS.length);
210
+ assert.equal(edgeCount(db), 1);
211
+ // Re-running migrate() directly is a no-op (gate skips applied steps); the
212
+ // edges table is NOT rebuilt again.
213
+ assert.doesNotThrow(() => migrate(db));
214
+ assert.equal(userVersion(db), MIGRATIONS.length);
215
+ assert.equal(edgeCount(db), 1);
216
+ // Re-opening from disk is likewise a no-op — the FK + data persist.
217
+ closeDb();
218
+ const db2 = openDb();
219
+ assert.equal(userVersion(db2), MIGRATIONS.length);
220
+ assert.equal(edgeCount(db2), 1);
221
+ });
222
+ test('migration is idempotent on re-open and on re-run', () => {
223
+ // First open migrates v0 -> latest.
224
+ const db = openDb();
225
+ assert.equal(userVersion(db), MIGRATIONS.length);
226
+ // Re-running migrate() directly is a no-op (the gate skips applied steps).
227
+ // If the gate were broken, the second ALTER ADD COLUMN would throw.
228
+ assert.doesNotThrow(() => migrate(db));
229
+ assert.equal(userVersion(db), MIGRATIONS.length);
230
+ // Re-opening from disk is likewise a no-op — no duplicate-column error.
231
+ closeDb();
232
+ const db2 = openDb();
233
+ assert.equal(userVersion(db2), MIGRATIONS.length);
234
+ const cols = nodeColumns(db2);
235
+ for (const c of RUNTIME_COLUMNS) {
236
+ assert.ok(cols.includes(c), `expected nodes.${c} to persist`);
237
+ }
238
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,153 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/pane-column.test.ts
2
+ //
3
+ // STEP 2 of the placement/focus migration: the `pane` runtime column —
4
+ // LOCATION's authoritative handle (the durable tmux `%pane_id`). This step is
5
+ // PURELY ADDITIVE: the column exists, setPresence writes it, getNode/getRow read
6
+ // it back, but nothing READS it for behavior yet (population wires up in Steps
7
+ // 3+). Covers:
8
+ // - migration v5 adds `pane` to a fresh db (and a legacy v4 db migrates up)
9
+ // - createNode seeds pane; setPresence writes it atomically with window/session
10
+ // - getNode (hydrated view) + getRow (row) both read pane back
11
+ // - a row never given a pane reads pane=null (legacy/back-compat)
12
+ // - the migration is idempotent / forward-only (re-open is a no-op)
13
+ import { test, before, beforeEach, after } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { DatabaseSync } from 'node:sqlite';
19
+ import { createNode, getNode, getRow, setPresence } from '../canvas/canvas.js';
20
+ import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
21
+ import { canvasDbPath, ensureHome } from '../canvas/paths.js';
22
+ let home;
23
+ function node(id, over = {}) {
24
+ return {
25
+ node_id: id,
26
+ name: id,
27
+ created: new Date().toISOString(),
28
+ cwd: '/tmp/work',
29
+ kind: 'general',
30
+ mode: 'base',
31
+ lifecycle: 'terminal',
32
+ status: 'active',
33
+ ...over,
34
+ };
35
+ }
36
+ function userVersion(db) {
37
+ return db.prepare('PRAGMA user_version').get().user_version;
38
+ }
39
+ function nodeColumns(db) {
40
+ return db.prepare('PRAGMA table_info(nodes)').all().map((r) => r.name);
41
+ }
42
+ before(() => {
43
+ home = mkdtempSync(join(tmpdir(), 'crtr-pane-'));
44
+ process.env['CRTR_HOME'] = home;
45
+ });
46
+ beforeEach(() => {
47
+ closeDb();
48
+ rmSync(home, { recursive: true, force: true });
49
+ });
50
+ after(() => {
51
+ closeDb();
52
+ rmSync(home, { recursive: true, force: true });
53
+ delete process.env['CRTR_HOME'];
54
+ });
55
+ // ---------------------------------------------------------------------------
56
+ // Migration v5 — the additive `pane` column.
57
+ // ---------------------------------------------------------------------------
58
+ test('a fresh db migrates to the latest version and has the pane column', () => {
59
+ const db = openDb();
60
+ assert.equal(userVersion(db), MIGRATIONS.length);
61
+ assert.ok(nodeColumns(db).includes('pane'), 'nodes.pane exists on a fresh db');
62
+ });
63
+ test('a v4 db migrates forward, adding pane=NULL without disturbing existing data', () => {
64
+ // Hand-build a db at exactly v4: baseline + the four v2 runtime columns +
65
+ // edges WITH the v4 FK shape, user_version=4 — the shape just before v5.
66
+ ensureHome();
67
+ const raw = new DatabaseSync(canvasDbPath());
68
+ raw.exec(`
69
+ CREATE TABLE nodes (
70
+ node_id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
71
+ mode TEXT NOT NULL DEFAULT 'base', lifecycle TEXT NOT NULL DEFAULT 'terminal',
72
+ status TEXT NOT NULL DEFAULT 'active', cwd TEXT NOT NULL, parent TEXT,
73
+ created TEXT NOT NULL,
74
+ intent TEXT, pi_pid INTEGER, window TEXT, tmux_session TEXT
75
+ );
76
+ CREATE TABLE edges (
77
+ type TEXT NOT NULL, from_id TEXT NOT NULL, to_id TEXT NOT NULL,
78
+ active INTEGER NOT NULL DEFAULT 1, created TEXT NOT NULL,
79
+ PRIMARY KEY (type, from_id, to_id),
80
+ FOREIGN KEY (from_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
81
+ FOREIGN KEY (to_id) REFERENCES nodes(node_id) ON DELETE CASCADE
82
+ );
83
+ PRAGMA user_version = 4;
84
+ `);
85
+ raw
86
+ .prepare('INSERT INTO nodes (node_id, name, kind, status, cwd, created, window, tmux_session) VALUES (?,?,?,?,?,?,?,?)')
87
+ .run('legacy', 'Legacy', 'general', 'idle', '/tmp/work', '2020-01-01T00:00:00Z', '@9', 'crtr');
88
+ assert.equal(userVersion(raw), 4);
89
+ assert.ok(!nodeColumns(raw).includes('pane'), 'precondition: no pane column at v4');
90
+ raw.close();
91
+ // openDb() runs v5 forward.
92
+ const db = openDb();
93
+ assert.equal(userVersion(db), MIGRATIONS.length);
94
+ assert.ok(nodeColumns(db).includes('pane'), 'pane column added by v5');
95
+ // The pre-existing row is intact; its pane backfills NULL (additive default).
96
+ const row = db
97
+ .prepare('SELECT status, window, tmux_session, pane FROM nodes WHERE node_id = ?')
98
+ .get('legacy');
99
+ assert.equal(row['status'], 'idle', 'existing status untouched');
100
+ assert.equal(row['window'], '@9', 'existing window untouched');
101
+ assert.equal(row['tmux_session'], 'crtr', 'existing session untouched');
102
+ assert.equal(row['pane'], null, 'pane defaults NULL for a legacy row');
103
+ });
104
+ test('the migration is idempotent / forward-only on re-run and re-open', () => {
105
+ const db = openDb();
106
+ assert.equal(userVersion(db), MIGRATIONS.length);
107
+ // Re-running migrate() is a no-op — the gate skips applied steps, so the v5
108
+ // ALTER ADD COLUMN never fires twice (which would throw "duplicate column").
109
+ assert.doesNotThrow(() => migrate(db));
110
+ assert.equal(userVersion(db), MIGRATIONS.length);
111
+ // Re-opening from disk is likewise a no-op.
112
+ closeDb();
113
+ const db2 = openDb();
114
+ assert.equal(userVersion(db2), MIGRATIONS.length);
115
+ assert.ok(nodeColumns(db2).includes('pane'));
116
+ });
117
+ // ---------------------------------------------------------------------------
118
+ // Round-trip: pane is a RUNTIME field, authoritative in the row, read back by
119
+ // both the hydrated view (getNode) and the row (getRow).
120
+ // ---------------------------------------------------------------------------
121
+ test('createNode seeds pane and getNode/getRow read it back', () => {
122
+ createNode(node('n', { tmux_session: 'crtr', window: '@1', pane: '%5' }));
123
+ assert.equal(getNode('n')?.pane, '%5', 'getNode hydrates pane from the row');
124
+ assert.equal(getRow('n')?.pane, '%5', 'getRow returns pane on the NodeRow');
125
+ });
126
+ test('a node created without a pane reads pane=null (legacy / back-compat)', () => {
127
+ createNode(node('n', { tmux_session: 'crtr', window: '@1' }));
128
+ assert.equal(getNode('n')?.pane, null, 'no pane given → null on the hydrated view');
129
+ assert.equal(getRow('n')?.pane, null, 'no pane given → null on the row');
130
+ });
131
+ test('setPresence writes pane atomically alongside window + session', () => {
132
+ createNode(node('n'));
133
+ setPresence('n', { tmux_session: 'user-sess', window: '@2', pane: '%7' });
134
+ const m = getNode('n');
135
+ assert.equal(m?.pane, '%7', 'setPresence wrote the pane');
136
+ assert.equal(m?.window, '@2', 'setPresence wrote the window in the same statement');
137
+ assert.equal(m?.tmux_session, 'user-sess', 'setPresence wrote the session in the same statement');
138
+ });
139
+ test('setPresence without pane writes null (the Step-2 contract: nobody reads pane yet)', () => {
140
+ createNode(node('n', { pane: '%7' }));
141
+ assert.equal(getNode('n')?.pane, '%7');
142
+ // A presence write that omits pane resets it to null — fine for Step 2 because
143
+ // no reader depends on pane until the placement layer lands (Steps 3+).
144
+ setPresence('n', { tmux_session: 'crtr', window: '@3' });
145
+ assert.equal(getNode('n')?.pane, null, 'omitted pane → null (no half-written LOCATION)');
146
+ });
147
+ test('pane is a RUNTIME field — it never leaks into meta.json identity', () => {
148
+ createNode(node('n', { pane: '%5' }));
149
+ setPresence('n', { pane: '%9', window: '@1', tmux_session: 'crtr' });
150
+ // meta.json is durable identity only; the runtime pane lives in the row.
151
+ const onDisk = openDb().prepare('SELECT pane FROM nodes WHERE node_id = ?').get('n');
152
+ assert.equal(onDisk.pane, '%9', 'pane is authoritative in the row');
153
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,164 @@
1
+ // End-to-end tests for passive subscriptions:
2
+ // 1. A passive subscriber's pushes land in passive.jsonl, NOT inbox.jsonl
3
+ // (so the inbox-watcher never wakes it); an active subscriber's land in
4
+ // inbox.jsonl as before.
5
+ // 2. drainPassive reads + clears the accumulator (surfaces exactly once).
6
+ // 3. canvas-passive-context formats drained entries as timestamped XML and
7
+ // transforms an `input` event into pre-text + the original message.
8
+ //
9
+ // Run: node --import tsx/esm --test src/core/__tests__/passive-subscription.test.ts
10
+ import { test, before, beforeEach, after } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, rmSync, existsSync, appendFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { createNode, subscribe } from '../canvas/canvas.js';
16
+ import { closeDb } from '../canvas/db.js';
17
+ import { inboxPath, passivePath } from '../canvas/paths.js';
18
+ import { push } from '../feed/feed.js';
19
+ import { appendPassive, readPassive, drainPassive } from '../feed/passive.js';
20
+ import { readInboxSince } from '../feed/inbox.js';
21
+ import registerCanvasPassiveContext, { formatPassive } from '../../pi-extensions/canvas-passive-context.js';
22
+ let home;
23
+ function node(id, over = {}) {
24
+ return {
25
+ node_id: id,
26
+ name: id,
27
+ created: new Date().toISOString(),
28
+ cwd: '/tmp/work',
29
+ kind: 'general',
30
+ mode: 'base',
31
+ lifecycle: 'terminal',
32
+ status: 'active',
33
+ ...over,
34
+ };
35
+ }
36
+ before(() => {
37
+ home = mkdtempSync(join(tmpdir(), 'crtr-passive-'));
38
+ process.env['CRTR_HOME'] = home;
39
+ });
40
+ beforeEach(() => {
41
+ closeDb();
42
+ rmSync(home, { recursive: true, force: true });
43
+ });
44
+ after(() => {
45
+ closeDb();
46
+ rmSync(home, { recursive: true, force: true });
47
+ delete process.env['CRTR_HOME'];
48
+ delete process.env['CRTR_NODE_ID'];
49
+ });
50
+ test('passive push accumulates in passive.jsonl, not inbox.jsonl', async () => {
51
+ createNode(node('pub'));
52
+ createNode(node('observer'));
53
+ subscribe('observer', 'pub', false); // PASSIVE
54
+ await push('pub', { kind: 'update', body: 'first observation\nmore detail' });
55
+ // No inbox entry → the inbox-watcher would never see it → no wake.
56
+ assert.equal(existsSync(inboxPath('observer')), false);
57
+ assert.equal(readInboxSince('observer').length, 0);
58
+ // It landed in the passive accumulator instead.
59
+ const acc = readPassive('observer');
60
+ assert.equal(acc.length, 1);
61
+ assert.equal(acc[0].from, 'pub');
62
+ assert.equal(acc[0].label, 'first observation');
63
+ assert.ok(acc[0].ref && acc[0].ref.endsWith('-update.md'));
64
+ });
65
+ test('active push still lands in inbox.jsonl (wakes)', async () => {
66
+ createNode(node('pub'));
67
+ createNode(node('worker-mgr'));
68
+ subscribe('worker-mgr', 'pub', true); // ACTIVE
69
+ await push('pub', { kind: 'update', body: 'active report' });
70
+ assert.equal(existsSync(passivePath('worker-mgr')), false);
71
+ const inbox = readInboxSince('worker-mgr');
72
+ assert.equal(inbox.length, 1);
73
+ assert.equal(inbox[0].from, 'pub');
74
+ });
75
+ test('mixed active + passive subscribers route to their own stores', async () => {
76
+ createNode(node('pub'));
77
+ createNode(node('active-sub'));
78
+ createNode(node('passive-sub'));
79
+ subscribe('active-sub', 'pub', true);
80
+ subscribe('passive-sub', 'pub', false);
81
+ const res = await push('pub', { kind: 'urgent', body: 'something happened' });
82
+ assert.deepEqual(new Set(res.deliveredTo), new Set(['active-sub', 'passive-sub']));
83
+ assert.equal(readInboxSince('active-sub').length, 1);
84
+ assert.equal(existsSync(passivePath('active-sub')), false);
85
+ assert.equal(readPassive('passive-sub').length, 1);
86
+ assert.equal(existsSync(inboxPath('passive-sub')), false);
87
+ });
88
+ test('drainPassive reads then clears (surfaces exactly once)', () => {
89
+ createNode(node('observer'));
90
+ appendPassive('observer', { from: 'a', tier: 'normal', kind: 'update', label: 'one' });
91
+ appendPassive('observer', { from: 'b', tier: 'normal', kind: 'update', label: 'two' });
92
+ const drained = drainPassive('observer');
93
+ assert.equal(drained.length, 2);
94
+ assert.deepEqual(drained.map((e) => e.label), ['one', 'two']); // oldest first
95
+ // Cleared — a second drain is empty.
96
+ assert.equal(drainPassive('observer').length, 0);
97
+ assert.equal(readPassive('observer').length, 0);
98
+ });
99
+ test('drainPassive tolerates a corrupt line — keeps every good entry, loses only the bad one', () => {
100
+ createNode(node('observer'));
101
+ appendPassive('observer', { from: 'a', tier: 'normal', kind: 'update', label: 'one' });
102
+ appendPassive('observer', { from: 'b', tier: 'normal', kind: 'update', label: 'two' });
103
+ // A torn/garbage line lands between good entries (a partial write, a crash
104
+ // mid-append). The whole feed must NOT be discarded over it.
105
+ appendFileSync(passivePath('observer'), '{not valid json\n', 'utf8');
106
+ appendPassive('observer', { from: 'c', tier: 'normal', kind: 'update', label: 'three' });
107
+ const drained = drainPassive('observer');
108
+ assert.equal(drained.length, 3, 'all three good entries survive the corrupt line');
109
+ assert.deepEqual(drained.map((e) => e.label), ['one', 'two', 'three']);
110
+ // And it still cleared: a second drain is empty (snapshot removed).
111
+ assert.equal(drainPassive('observer').length, 0);
112
+ assert.equal(existsSync(passivePath('observer')), false);
113
+ });
114
+ test('readPassive tolerates a corrupt line without clearing', () => {
115
+ createNode(node('observer'));
116
+ appendPassive('observer', { from: 'a', tier: 'normal', kind: 'update', label: 'one' });
117
+ appendFileSync(passivePath('observer'), 'garbage}\n', 'utf8');
118
+ appendPassive('observer', { from: 'b', tier: 'normal', kind: 'update', label: 'two' });
119
+ const read = readPassive('observer');
120
+ assert.deepEqual(read.map((e) => e.label), ['one', 'two']);
121
+ });
122
+ test('formatPassive renders timestamped XML update blocks', () => {
123
+ const entries = [
124
+ { ts: '2026-06-03T12:00:00.000Z', from: 'pub-a', tier: 'normal', kind: 'update', label: 'alpha happened' },
125
+ { ts: '2026-06-03T12:05:00.000Z', from: 'pub-b', tier: 'urgent', kind: 'final', label: 'beta done' },
126
+ ];
127
+ const xml = formatPassive(entries);
128
+ assert.match(xml, /<passive-subscription-backlog count="2"/);
129
+ assert.match(xml, /<update from="pub-a" kind="update" at="2026-06-03T12:00:00.000Z">/);
130
+ assert.match(xml, /alpha happened/);
131
+ assert.match(xml, /<update from="pub-b" kind="final" at="2026-06-03T12:05:00.000Z">/);
132
+ assert.match(xml, /<\/passive-subscription-backlog>/);
133
+ });
134
+ function makeFakePi() {
135
+ return { on(e, h) { if (e === 'input')
136
+ this.handler = h; } };
137
+ }
138
+ test('input handler injects drained backlog as pre-text, then clears it', async () => {
139
+ createNode(node('pub'));
140
+ createNode(node('observer'));
141
+ subscribe('observer', 'pub', false);
142
+ await push('pub', { kind: 'update', body: 'the body of the report\nsecond line' });
143
+ process.env['CRTR_NODE_ID'] = 'observer';
144
+ const pi = makeFakePi();
145
+ registerCanvasPassiveContext(pi);
146
+ assert.ok(pi.handler, 'input handler registered');
147
+ // First message → backlog drains in as pre-text before the user's text.
148
+ const out = pi.handler({ type: 'input', text: 'hey what happened', source: 'interactive' });
149
+ assert.equal(out.action, 'transform');
150
+ assert.match(out.text, /<passive-subscription-backlog/);
151
+ assert.match(out.text, /the body of the report/); // dereferenced report body
152
+ assert.match(out.text, /hey what happened$/); // original message preserved at the end
153
+ // Second message → nothing accumulated → left untouched.
154
+ const out2 = pi.handler({ type: 'input', text: 'still there?', source: 'interactive' });
155
+ assert.ok(out2 === undefined || out2.action === 'continue');
156
+ });
157
+ test('input handler is inert when nothing is accumulated', () => {
158
+ createNode(node('observer'));
159
+ process.env['CRTR_NODE_ID'] = 'observer';
160
+ const pi = makeFakePi();
161
+ registerCanvasPassiveContext(pi);
162
+ const out = pi.handler({ type: 'input', text: 'plain message', source: 'interactive' });
163
+ assert.ok(out === undefined || out.action === 'continue');
164
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ // Run: node --import tsx/esm --test src/core/__tests__/persona-compose.test.ts
2
+ //
3
+ // The static system-prompt composer (core/personas/resolve.ts) varies the
4
+ // runtime protocol on TWO axes beyond kind×mode:
5
+ // • lifecycle (terminal | resident) — the "how you end" contract.
6
+ // • spine position (hasManager) — whether the push-up family is taught AT ALL.
7
+ // These assert the four corners, especially the resident+no-manager root that
8
+ // must never hear about `push` (final OR update).
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { resolve } from '../personas/index.js';
12
+ // The lifecycle fragment is identified by its signature contract phrase, not by
13
+ // the raw "push final" substring — the resident fragment legitimately NAMES
14
+ // `push final` only to forbid it. The kind body is now lifecycle-neutral, so the
15
+ // only finish contract in the prompt is whichever fragment composed.
16
+ const TERMINAL_CONTRACT = /owe a final result and you reap when done/i;
17
+ const RESIDENT_CONTRACT = /never forced to submit a final result/i;
18
+ const PUSH_UP = /crtr push (update|urgent)/i;
19
+ test('terminal + has-manager (the default child worker): terminal finish + push up', () => {
20
+ const p = resolve('general', 'base', { lifecycle: 'terminal', hasManager: true });
21
+ assert.match(p.systemPrompt, TERMINAL_CONTRACT, 'a terminal node owes a final and finishes via push final');
22
+ assert.doesNotMatch(p.systemPrompt, RESIDENT_CONTRACT);
23
+ assert.match(p.systemPrompt, PUSH_UP, 'a managed node reports up via push update/urgent');
24
+ assert.equal(p.lifecycle, 'terminal');
25
+ });
26
+ test('resident + no-manager (the user-facing root): resident finish, and NO push-up family at all', () => {
27
+ const p = resolve('general', 'base', { lifecycle: 'resident', hasManager: false });
28
+ assert.match(p.systemPrompt, RESIDENT_CONTRACT, 'a resident root is never forced to submit');
29
+ assert.doesNotMatch(p.systemPrompt, TERMINAL_CONTRACT, 'it does not get the terminal finish contract');
30
+ assert.doesNotMatch(p.systemPrompt, PUSH_UP, 'a top-of-spine root has nobody to push up to');
31
+ assert.match(p.systemPrompt, /top of your spine/i, 'oriented as top-of-spine');
32
+ assert.match(p.systemPrompt, /dormant/i, 'told it goes dormant and wakes');
33
+ });
34
+ test('resident + has-manager (interactable sub-orchestrator): resident finish, but still push up', () => {
35
+ const p = resolve('general', 'base', { lifecycle: 'resident', hasManager: true });
36
+ assert.match(p.systemPrompt, RESIDENT_CONTRACT, 'resident is never forced to submit a final');
37
+ assert.doesNotMatch(p.systemPrompt, TERMINAL_CONTRACT);
38
+ assert.match(p.systemPrompt, PUSH_UP, 'still reports progress up to its manager');
39
+ });
40
+ test('terminal + no-manager (a terminal root): terminal finish, but no push up', () => {
41
+ const p = resolve('general', 'base', { lifecycle: 'terminal', hasManager: false });
42
+ assert.match(p.systemPrompt, TERMINAL_CONTRACT, 'terminal still finishes via push final (self-completes)');
43
+ assert.doesNotMatch(p.systemPrompt, PUSH_UP, 'nobody subscribes, so no report-up family');
44
+ });
45
+ test('the lifecycle-neutral base survives in every corner (delegate + human ask)', () => {
46
+ for (const hasManager of [true, false]) {
47
+ for (const lifecycle of ['terminal', 'resident']) {
48
+ const p = resolve('general', 'base', { lifecycle, hasManager });
49
+ assert.match(p.systemPrompt, /crtr node new/, `delegate verb present (${lifecycle}/${hasManager})`);
50
+ assert.match(p.systemPrompt, /crtr human ask/, `human-ask present (${lifecycle}/${hasManager})`);
51
+ }
52
+ }
53
+ });
@@ -0,0 +1 @@
1
+ export {};