let-them-talk 5.3.0 → 5.4.0

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 (166) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/README.md +158 -592
  3. package/SECURITY.md +3 -3
  4. package/USAGE.md +151 -0
  5. package/agent-contracts.js +447 -0
  6. package/api-agents.js +760 -0
  7. package/autonomy/decision-v2.js +380 -0
  8. package/autonomy/watchdog-policy.js +572 -0
  9. package/cli.js +454 -298
  10. package/conversation-templates/autonomous-feature.json +83 -22
  11. package/conversation-templates/code-review.json +69 -21
  12. package/conversation-templates/debug-squad.json +69 -21
  13. package/conversation-templates/feature-build.json +69 -21
  14. package/conversation-templates/research-write.json +69 -21
  15. package/dashboard.html +3148 -174
  16. package/dashboard.js +823 -786
  17. package/data-dir.js +58 -0
  18. package/docs/architecture/branch-semantics.md +157 -0
  19. package/docs/architecture/canonical-event-schema.md +88 -0
  20. package/docs/architecture/markdown-workspace.md +183 -0
  21. package/docs/architecture/runtime-contract.md +459 -0
  22. package/docs/architecture/runtime-migration-hardening.md +64 -0
  23. package/events/hooks.js +154 -0
  24. package/events/log.js +457 -0
  25. package/events/replay.js +33 -0
  26. package/events/schema.js +432 -0
  27. package/managed-team-integration.js +261 -0
  28. package/office/agents.js +704 -597
  29. package/office/animation.js +1 -1
  30. package/office/assets/arcade-cabinet.js +141 -0
  31. package/office/assets/archway.js +77 -0
  32. package/office/assets/bar-counter.js +91 -0
  33. package/office/assets/bar-stool.js +71 -0
  34. package/office/assets/beanbag.js +64 -0
  35. package/office/assets/bench.js +99 -0
  36. package/office/assets/bollard.js +87 -0
  37. package/office/assets/cactus.js +100 -0
  38. package/office/assets/carpet-tile.js +46 -0
  39. package/office/assets/chair.js +123 -0
  40. package/office/assets/chandelier.js +107 -0
  41. package/office/assets/coffee-machine.js +95 -0
  42. package/office/assets/coffee-table.js +81 -0
  43. package/office/assets/column.js +95 -0
  44. package/office/assets/desk-lamp.js +102 -0
  45. package/office/assets/desk.js +76 -0
  46. package/office/assets/dining-table.js +105 -0
  47. package/office/assets/door.js +70 -0
  48. package/office/assets/dual-monitor.js +72 -0
  49. package/office/assets/fence.js +76 -0
  50. package/office/assets/filing-cabinet.js +111 -0
  51. package/office/assets/floor-lamp.js +69 -0
  52. package/office/assets/floor-tile.js +54 -0
  53. package/office/assets/flower-pot.js +76 -0
  54. package/office/assets/foosball.js +95 -0
  55. package/office/assets/fridge.js +99 -0
  56. package/office/assets/gaming-chair.js +154 -0
  57. package/office/assets/gaming-desk.js +105 -0
  58. package/office/assets/glass-door.js +72 -0
  59. package/office/assets/glass-wall.js +64 -0
  60. package/office/assets/half-wall.js +49 -0
  61. package/office/assets/hanging-plant.js +112 -0
  62. package/office/assets/index.js +151 -0
  63. package/office/assets/indoor-tree.js +90 -0
  64. package/office/assets/l-sofa.js +153 -0
  65. package/office/assets/marble-floor.js +64 -0
  66. package/office/assets/materials.js +40 -0
  67. package/office/assets/meeting-table.js +88 -0
  68. package/office/assets/microwave.js +94 -0
  69. package/office/assets/monitor.js +67 -0
  70. package/office/assets/neon-strip.js +73 -0
  71. package/office/assets/painting.js +84 -0
  72. package/office/assets/palm-tree.js +108 -0
  73. package/office/assets/pc-tower.js +91 -0
  74. package/office/assets/pendant-light.js +67 -0
  75. package/office/assets/ping-pong.js +114 -0
  76. package/office/assets/plant.js +72 -0
  77. package/office/assets/planter-box.js +95 -0
  78. package/office/assets/pool-table.js +94 -0
  79. package/office/assets/printer.js +113 -0
  80. package/office/assets/reception-desk.js +133 -0
  81. package/office/assets/rug.js +78 -0
  82. package/office/assets/sculpture.js +85 -0
  83. package/office/assets/server-rack.js +98 -0
  84. package/office/assets/sink.js +109 -0
  85. package/office/assets/sofa.js +106 -0
  86. package/office/assets/speaker.js +83 -0
  87. package/office/assets/spotlight.js +83 -0
  88. package/office/assets/street-lamp.js +97 -0
  89. package/office/assets/trash-can.js +83 -0
  90. package/office/assets/treadmill.js +126 -0
  91. package/office/assets/trophy.js +89 -0
  92. package/office/assets/tv-screen.js +79 -0
  93. package/office/assets/vase.js +84 -0
  94. package/office/assets/wall-clock.js +84 -0
  95. package/office/assets/wall.js +53 -0
  96. package/office/assets/water-cooler.js +146 -0
  97. package/office/assets/whiteboard.js +115 -0
  98. package/office/assets.js +3 -431
  99. package/office/builder.js +791 -355
  100. package/office/campus-env.js +1012 -1119
  101. package/office/environment.js +2 -0
  102. package/office/gallery.js +997 -0
  103. package/office/index.js +165 -61
  104. package/office/navigation.js +173 -152
  105. package/office/player.js +178 -68
  106. package/office/robot-character.js +272 -0
  107. package/office/spectator-camera.js +33 -10
  108. package/office/state.js +2 -0
  109. package/office/world-save.js +35 -4
  110. package/package.json +57 -3
  111. package/providers/comfyui.js +383 -0
  112. package/providers/dalle.js +79 -0
  113. package/providers/gemini.js +181 -0
  114. package/providers/ollama.js +184 -0
  115. package/providers/replicate.js +115 -0
  116. package/providers/zai.js +183 -0
  117. package/runtime-descriptor.js +270 -0
  118. package/scripts/check-agent-contract-advisory.js +132 -0
  119. package/scripts/check-api-agent-parity.js +277 -0
  120. package/scripts/check-autonomy-v2-decision.js +207 -0
  121. package/scripts/check-autonomy-v2-execution.js +588 -0
  122. package/scripts/check-autonomy-v2-watchdog.js +224 -0
  123. package/scripts/check-branch-fork-snapshot.js +337 -0
  124. package/scripts/check-branch-isolation.js +787 -0
  125. package/scripts/check-branch-semantics.js +139 -0
  126. package/scripts/check-dashboard-control-plane.js +1304 -0
  127. package/scripts/check-docs-onboarding.js +490 -0
  128. package/scripts/check-event-schema.js +276 -0
  129. package/scripts/check-evidence-completion.js +239 -0
  130. package/scripts/check-invariants.js +992 -0
  131. package/scripts/check-lifecycle-hooks.js +525 -0
  132. package/scripts/check-managed-team-integration.js +166 -0
  133. package/scripts/check-markdown-workspace-export.js +548 -0
  134. package/scripts/check-markdown-workspace-safety.js +347 -0
  135. package/scripts/check-markdown-workspace.js +136 -0
  136. package/scripts/check-message-replay.js +429 -0
  137. package/scripts/check-migration-hardening.js +300 -0
  138. package/scripts/check-performance-indexing.js +272 -0
  139. package/scripts/check-provider-capabilities.js +316 -0
  140. package/scripts/check-runtime-contract.js +109 -0
  141. package/scripts/check-session-aware-context.js +172 -0
  142. package/scripts/check-session-lifecycle.js +210 -0
  143. package/scripts/export-markdown-workspace.js +84 -0
  144. package/scripts/fixtures/message-replay/clean.jsonl +2 -0
  145. package/scripts/fixtures/message-replay/corrupt-correction-payload.jsonl +1 -0
  146. package/scripts/fixtures/message-replay/corrupt-jsonl.jsonl +1 -0
  147. package/scripts/fixtures/message-replay/corrupt-payload.jsonl +1 -0
  148. package/scripts/fixtures/message-replay/out-of-order.jsonl +2 -0
  149. package/scripts/migrate-legacy-to-canonical.js +201 -0
  150. package/scripts/run-verification-suite.js +242 -0
  151. package/scripts/sync-packaged-docs.js +69 -0
  152. package/server.js +9546 -7216
  153. package/state/agents.js +161 -0
  154. package/state/canonical.js +3068 -0
  155. package/state/dashboard-queries.js +441 -0
  156. package/state/evidence.js +56 -0
  157. package/state/io.js +69 -0
  158. package/state/markdown-workspace.js +951 -0
  159. package/state/messages.js +669 -0
  160. package/state/sessions.js +683 -0
  161. package/state/tasks-workflows.js +92 -0
  162. package/templates/debate.json +2 -2
  163. package/templates/managed.json +4 -4
  164. package/templates/pair.json +2 -2
  165. package/templates/review.json +2 -2
  166. package/templates/team.json +3 -3
@@ -0,0 +1,1304 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const net = require('net');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawn, spawnSync } = require('child_process');
8
+
9
+ const { createCanonicalEventLog } = require(path.resolve(__dirname, '..', 'events', 'log.js'));
10
+ const { createCanonicalState } = require(path.resolve(__dirname, '..', 'state', 'canonical.js'));
11
+
12
+ const DASHBOARD_FILE = path.resolve(__dirname, '..', 'dashboard.js');
13
+ const DASHBOARD_HTML_FILE = path.resolve(__dirname, '..', 'dashboard.html');
14
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
15
+ const FIXTURE_PORT_HOST = '127.0.0.1';
16
+ const USAGE = 'Usage: node agent-bridge/scripts/check-dashboard-control-plane.js [--scenario healthy|edit-delete-semantic-gap]';
17
+
18
+ function parseArgs(argv) {
19
+ let scenario = 'healthy';
20
+
21
+ for (let index = 0; index < argv.length; index++) {
22
+ const arg = argv[index];
23
+ if (arg === '--scenario') {
24
+ if (index + 1 >= argv.length) fail([USAGE], 2);
25
+ scenario = argv[index + 1];
26
+ index += 1;
27
+ continue;
28
+ }
29
+
30
+ fail([USAGE], 2);
31
+ }
32
+
33
+ if (!['healthy', 'edit-delete-semantic-gap'].includes(scenario)) {
34
+ fail([
35
+ `Unknown scenario: ${scenario}`,
36
+ 'Supported scenarios: healthy, edit-delete-semantic-gap',
37
+ USAGE,
38
+ ], 2);
39
+ }
40
+
41
+ return { scenario };
42
+ }
43
+
44
+ function fail(lines, exitCode = 1) {
45
+ fs.writeSync(2, lines.join('\n') + '\n');
46
+ process.exit(exitCode);
47
+ }
48
+
49
+ function assert(condition, message, problems) {
50
+ if (!condition) problems.push(message);
51
+ }
52
+
53
+ function readJson(filePath, fallback) {
54
+ if (!fs.existsSync(filePath)) return fallback;
55
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
56
+ }
57
+
58
+ function readJsonl(filePath) {
59
+ if (!fs.existsSync(filePath)) return [];
60
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
61
+ if (!raw) return [];
62
+ return raw.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
63
+ }
64
+
65
+ function writeJson(filePath, value) {
66
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
67
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
68
+ }
69
+
70
+ function writeJsonl(filePath, entries) {
71
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
72
+ const lines = entries.map((entry) => JSON.stringify(entry));
73
+ fs.writeFileSync(filePath, lines.length > 0 ? `${lines.join('\n')}\n` : '');
74
+ }
75
+
76
+ function getScopedBranchFile(dataDir, branchName, suffix) {
77
+ return branchName === 'main'
78
+ ? path.join(dataDir, suffix)
79
+ : path.join(dataDir, `branch-${branchName}-${suffix}`);
80
+ }
81
+
82
+ function getChannelHistoryFixtureFile(dataDir, channelName, branchName = 'main') {
83
+ return branchName === 'main'
84
+ ? path.join(dataDir, `channel-${channelName}-history.jsonl`)
85
+ : path.join(dataDir, `branch-${branchName}-channel-${channelName}-history.jsonl`);
86
+ }
87
+
88
+ function getScopedWorkspacesDir(dataDir, branchName = 'main') {
89
+ return branchName === 'main'
90
+ ? path.join(dataDir, 'workspaces')
91
+ : path.join(dataDir, `branch-${branchName}-workspaces`);
92
+ }
93
+
94
+ function getScopedWorkspaceFile(dataDir, agentName, branchName = 'main') {
95
+ return path.join(getScopedWorkspacesDir(dataDir, branchName), `${agentName}.json`);
96
+ }
97
+
98
+ function readMessageEvents(eventLog, branchName = 'main') {
99
+ return eventLog.readBranchEvents(branchName, { typePrefix: 'message.' });
100
+ }
101
+
102
+ function readRuleEvents(eventLog, branchName = 'main') {
103
+ return eventLog.readBranchEvents(branchName, { typePrefix: 'rule.' });
104
+ }
105
+
106
+ function sleep(ms) {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }
109
+
110
+ function getFreePort() {
111
+ return new Promise((resolve, reject) => {
112
+ const server = net.createServer();
113
+ server.unref();
114
+ server.on('error', reject);
115
+ server.listen(0, FIXTURE_PORT_HOST, () => {
116
+ const address = server.address();
117
+ const port = address && typeof address === 'object' ? address.port : null;
118
+ server.close((error) => {
119
+ if (error) {
120
+ reject(error);
121
+ return;
122
+ }
123
+ resolve(port);
124
+ });
125
+ });
126
+ });
127
+ }
128
+
129
+ async function requestJson(baseUrl, pathname, options = {}) {
130
+ const controller = new AbortController();
131
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 5000);
132
+ try {
133
+ const response = await fetch(baseUrl + pathname, {
134
+ method: options.method || 'GET',
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ 'X-LTT-Request': 'dashboard-control-plane-fixture',
138
+ ...(options.headers || {}),
139
+ },
140
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
141
+ signal: controller.signal,
142
+ });
143
+
144
+ const text = await response.text();
145
+ let json = null;
146
+ try {
147
+ json = text ? JSON.parse(text) : null;
148
+ } catch {
149
+ json = text;
150
+ }
151
+
152
+ return {
153
+ status: response.status,
154
+ body: json,
155
+ raw: text,
156
+ };
157
+ } finally {
158
+ clearTimeout(timeout);
159
+ }
160
+ }
161
+
162
+ async function waitForDashboard(baseUrl, child, capture) {
163
+ const startedAt = Date.now();
164
+ while (Date.now() - startedAt < 10000) {
165
+ if (child.exitCode !== null) {
166
+ throw new Error([
167
+ `Dashboard exited before becoming ready (exit ${child.exitCode}).`,
168
+ capture.stdout ? `stdout:\n${capture.stdout.trimEnd()}` : 'stdout: <empty>',
169
+ capture.stderr ? `stderr:\n${capture.stderr.trimEnd()}` : 'stderr: <empty>',
170
+ ].join('\n'));
171
+ }
172
+
173
+ try {
174
+ const response = await requestJson(baseUrl, '/api/server-info', { timeoutMs: 1000 });
175
+ if (response.status === 200) return;
176
+ } catch {}
177
+
178
+ await sleep(100);
179
+ }
180
+
181
+ throw new Error([
182
+ 'Dashboard did not become ready within 10 seconds.',
183
+ capture.stdout ? `stdout:\n${capture.stdout.trimEnd()}` : 'stdout: <empty>',
184
+ capture.stderr ? `stderr:\n${capture.stderr.trimEnd()}` : 'stderr: <empty>',
185
+ ].join('\n'));
186
+ }
187
+
188
+ async function stopDashboard(child) {
189
+ if (!child || child.exitCode !== null) return;
190
+
191
+ child.kill('SIGTERM');
192
+
193
+ await Promise.race([
194
+ new Promise((resolve) => child.once('exit', resolve)),
195
+ sleep(2000),
196
+ ]);
197
+
198
+ if (child.exitCode !== null) return;
199
+
200
+ if (process.platform === 'win32') {
201
+ spawnSync('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
202
+ return;
203
+ }
204
+
205
+ child.kill('SIGKILL');
206
+ }
207
+
208
+ function buildAgents(now) {
209
+ return {
210
+ alpha: {
211
+ pid: 1001,
212
+ provider: 'claude',
213
+ timestamp: now,
214
+ last_activity: now,
215
+ },
216
+ beta: {
217
+ pid: 1002,
218
+ provider: 'codex',
219
+ timestamp: now,
220
+ last_activity: now,
221
+ },
222
+ };
223
+ }
224
+
225
+ function buildTask(now) {
226
+ return [{
227
+ id: 'task_dashboard_control',
228
+ title: 'Dashboard control-plane task',
229
+ description: 'Representative task route mutation fixture',
230
+ status: 'pending',
231
+ assignee: 'alpha',
232
+ created_by: 'alpha',
233
+ created_at: now,
234
+ updated_at: now,
235
+ notes: [],
236
+ }];
237
+ }
238
+
239
+ function buildWorkflows(now) {
240
+ return [
241
+ {
242
+ id: 'wf_plan_control',
243
+ name: 'Dashboard plan control',
244
+ status: 'active',
245
+ autonomous: true,
246
+ parallel: false,
247
+ created_by: 'alpha',
248
+ created_at: now,
249
+ updated_at: now,
250
+ steps: [
251
+ {
252
+ id: 1,
253
+ description: 'Current autonomous step',
254
+ assignee: 'alpha',
255
+ depends_on: [],
256
+ status: 'in_progress',
257
+ started_at: now,
258
+ completed_at: null,
259
+ notes: '',
260
+ },
261
+ {
262
+ id: 2,
263
+ description: 'Next autonomous step',
264
+ assignee: 'alpha',
265
+ depends_on: [1],
266
+ status: 'pending',
267
+ started_at: null,
268
+ completed_at: null,
269
+ notes: '',
270
+ },
271
+ ],
272
+ },
273
+ {
274
+ id: 'wf_dashboard_skip',
275
+ name: 'Dashboard workflow skip',
276
+ status: 'active',
277
+ autonomous: false,
278
+ parallel: false,
279
+ created_by: 'alpha',
280
+ created_at: now,
281
+ updated_at: now,
282
+ steps: [
283
+ {
284
+ id: 1,
285
+ description: 'Dashboard skip current step',
286
+ assignee: 'alpha',
287
+ depends_on: [],
288
+ status: 'in_progress',
289
+ started_at: now,
290
+ completed_at: null,
291
+ notes: '',
292
+ },
293
+ {
294
+ id: 2,
295
+ description: 'Dashboard activated next step',
296
+ assignee: 'beta',
297
+ depends_on: [1],
298
+ status: 'pending',
299
+ started_at: null,
300
+ completed_at: null,
301
+ notes: '',
302
+ },
303
+ ],
304
+ },
305
+ ];
306
+ }
307
+
308
+ function buildBranchTaskWorkflowFixture(canonicalState) {
309
+ const featureBranch = 'feature-dashboard';
310
+ const featureTask = {
311
+ id: 'task_feature_dashboard_control',
312
+ title: 'Feature branch dashboard task',
313
+ description: 'Branch-local dashboard task mutation fixture',
314
+ status: 'pending',
315
+ assignee: 'beta',
316
+ created_by: 'alpha',
317
+ created_at: '2026-04-16T05:31:00.000Z',
318
+ updated_at: '2026-04-16T05:31:00.000Z',
319
+ notes: [],
320
+ };
321
+ const featureWorkflow = {
322
+ id: 'wf_feature_dashboard_skip',
323
+ name: 'Feature dashboard workflow skip',
324
+ status: 'active',
325
+ autonomous: false,
326
+ parallel: false,
327
+ created_by: 'alpha',
328
+ created_at: '2026-04-16T05:32:00.000Z',
329
+ updated_at: '2026-04-16T05:32:00.000Z',
330
+ branch_id: featureBranch,
331
+ steps: [
332
+ {
333
+ id: 1,
334
+ description: 'Feature branch current step',
335
+ assignee: 'beta',
336
+ depends_on: [],
337
+ status: 'in_progress',
338
+ started_at: '2026-04-16T05:32:00.000Z',
339
+ completed_at: null,
340
+ notes: '',
341
+ },
342
+ {
343
+ id: 2,
344
+ description: 'Feature branch next step',
345
+ assignee: 'alpha',
346
+ depends_on: [1],
347
+ status: 'pending',
348
+ started_at: null,
349
+ completed_at: null,
350
+ notes: '',
351
+ },
352
+ ],
353
+ };
354
+ const featurePlanWorkflow = {
355
+ id: 'wf_feature_plan_control',
356
+ name: 'Feature branch plan control',
357
+ status: 'active',
358
+ autonomous: true,
359
+ parallel: false,
360
+ created_by: 'alpha',
361
+ created_at: '2026-04-16T05:33:00.000Z',
362
+ updated_at: '2026-04-16T05:33:00.000Z',
363
+ branch_id: featureBranch,
364
+ steps: [
365
+ {
366
+ id: 1,
367
+ description: 'Feature branch autonomous step',
368
+ assignee: 'alpha',
369
+ depends_on: [],
370
+ status: 'in_progress',
371
+ started_at: '2026-04-16T05:33:00.000Z',
372
+ completed_at: null,
373
+ notes: '',
374
+ },
375
+ {
376
+ id: 2,
377
+ description: 'Feature branch autonomous follow-up',
378
+ assignee: 'beta',
379
+ depends_on: [1],
380
+ status: 'pending',
381
+ started_at: null,
382
+ completed_at: null,
383
+ notes: '',
384
+ },
385
+ ],
386
+ };
387
+
388
+ const createTaskResult = canonicalState.createTask({
389
+ task: featureTask,
390
+ actor: 'alpha',
391
+ branch: featureBranch,
392
+ sessionId: 'sess_feature_dashboard',
393
+ correlationId: featureTask.id,
394
+ });
395
+ if (createTaskResult.error) {
396
+ throw new Error(`Failed to create feature-branch task fixture: ${createTaskResult.error}`);
397
+ }
398
+
399
+ for (const workflow of [featureWorkflow, featurePlanWorkflow]) {
400
+ const createWorkflowResult = canonicalState.createWorkflow({
401
+ workflow,
402
+ actor: 'alpha',
403
+ branch: featureBranch,
404
+ sessionId: 'sess_feature_dashboard',
405
+ correlationId: workflow.id,
406
+ });
407
+ if (createWorkflowResult.error) {
408
+ throw new Error(`Failed to create feature-branch workflow fixture ${workflow.id}: ${createWorkflowResult.error}`);
409
+ }
410
+ }
411
+
412
+ return {
413
+ featureBranch,
414
+ featureTaskId: featureTask.id,
415
+ featureWorkflowId: featureWorkflow.id,
416
+ featurePlanWorkflowId: featurePlanWorkflow.id,
417
+ };
418
+ }
419
+
420
+ function buildBranchReadFixture(canonicalState, dataDir) {
421
+ const featureBranch = 'feature-dashboard';
422
+ const mainMessage = {
423
+ id: 'msg_main_branch_fixture',
424
+ from: 'alpha',
425
+ to: 'beta',
426
+ content: 'Main branch baseline message for Task 7C',
427
+ timestamp: '2026-04-16T05:00:00.000Z',
428
+ };
429
+ const featureMessage = {
430
+ id: 'msg_feature_branch_fixture',
431
+ from: 'alpha',
432
+ to: 'beta',
433
+ content: 'Feature branch needle 7C runtime message',
434
+ timestamp: '2026-04-16T05:01:00.000Z',
435
+ };
436
+ const mainChannelMessage = {
437
+ id: 'msg_main_ops_fixture',
438
+ from: 'beta',
439
+ to: 'alpha',
440
+ channel: 'ops',
441
+ content: 'Main ops channel message for Task 7C',
442
+ timestamp: '2026-04-16T05:00:30.000Z',
443
+ };
444
+ const featureChannelMessage = {
445
+ id: 'msg_feature_lab_fixture',
446
+ from: 'beta',
447
+ to: 'alpha',
448
+ channel: 'featurelab',
449
+ content: 'Feature lab channel message for Task 7C',
450
+ timestamp: '2026-04-16T05:01:30.000Z',
451
+ };
452
+
453
+ canonicalState.appendMessage(mainMessage, { branch: 'main' });
454
+ canonicalState.appendMessage(featureMessage, { branch: featureBranch });
455
+ canonicalState.appendScopedMessage(mainChannelMessage, { branch: 'main', channel: 'ops' });
456
+ canonicalState.appendScopedMessage(featureChannelMessage, { branch: featureBranch, channel: 'featurelab' });
457
+
458
+ writeJson(getScopedBranchFile(dataDir, 'main', 'acks.json'), {
459
+ [mainMessage.id]: true,
460
+ });
461
+ writeJson(getScopedBranchFile(dataDir, featureBranch, 'acks.json'), {
462
+ [featureMessage.id]: true,
463
+ });
464
+
465
+ writeJson(getScopedBranchFile(dataDir, 'main', 'channels.json'), {
466
+ general: {
467
+ description: 'General channel',
468
+ members: ['*'],
469
+ },
470
+ ops: {
471
+ description: 'Main branch ops channel',
472
+ members: ['alpha', 'beta'],
473
+ },
474
+ });
475
+ writeJson(getScopedBranchFile(dataDir, featureBranch, 'channels.json'), {
476
+ general: {
477
+ description: 'General channel',
478
+ members: ['*'],
479
+ },
480
+ featurelab: {
481
+ description: 'Feature branch lab channel',
482
+ members: ['alpha', 'beta'],
483
+ },
484
+ });
485
+
486
+ writeJson(getScopedBranchFile(dataDir, 'main', 'config.json'), {
487
+ conversation_mode: 'managed',
488
+ });
489
+ writeJson(getScopedBranchFile(dataDir, featureBranch, 'config.json'), {
490
+ conversation_mode: 'direct',
491
+ });
492
+
493
+ writeJson(getScopedWorkspaceFile(dataDir, 'alpha', 'main'), {
494
+ _status: 'Main branch workspace status',
495
+ retry_history: [
496
+ { attempt: 1, task: 'main-retry-task', timestamp: '2026-04-16T05:00:40.000Z' },
497
+ ],
498
+ draft: { content: 'Main branch workspace draft' },
499
+ });
500
+ writeJson(getScopedWorkspaceFile(dataDir, 'alpha', featureBranch), {
501
+ _status: 'Feature branch alpha status',
502
+ draft: { content: 'Feature branch workspace draft' },
503
+ });
504
+ writeJson(getScopedWorkspaceFile(dataDir, 'beta', featureBranch), {
505
+ _status: 'Feature branch beta status',
506
+ retry_history: [
507
+ { attempt: 2, task: 'feature-retry-task', timestamp: '2026-04-16T05:01:40.000Z' },
508
+ ],
509
+ });
510
+
511
+ return {
512
+ featureBranch,
513
+ mainMessage,
514
+ featureMessage,
515
+ mainChannelMessage,
516
+ featureChannelMessage,
517
+ mainWorkspaceStatus: 'Main branch workspace status',
518
+ featureConversationMode: 'direct',
519
+ featureWorkspaceStatus: 'Feature branch beta status',
520
+ featureWorkspaceDraft: 'Feature branch workspace draft',
521
+ featureRetryTask: 'feature-retry-task',
522
+ };
523
+ }
524
+
525
+ function buildRespawnPromptFixture(canonicalState, dataDir) {
526
+ const featureBranch = 'feature-dashboard';
527
+
528
+ canonicalState.ensureAgentSession({
529
+ agentName: 'alpha',
530
+ branch: 'main',
531
+ sessionId: 'sess_respawn_alpha_main',
532
+ reason: 'dashboard_respawn_fixture',
533
+ });
534
+ canonicalState.transitionLatestSessionForAgent({
535
+ agentName: 'alpha',
536
+ branch: 'main',
537
+ state: 'interrupted',
538
+ reason: 'dashboard_respawn_fixture',
539
+ recoverySnapshotFile: 'recovery-alpha-main.json',
540
+ });
541
+ writeJson(path.join(dataDir, 'recovery-alpha-main.json'), {
542
+ agent: 'alpha',
543
+ branch: 'main',
544
+ died_at: '2026-04-16T05:35:00.000Z',
545
+ locked_files: ['main-only-lock.js'],
546
+ decisions_made: [
547
+ { decision: 'Main-only recovery decision', reasoning: 'Main branch recovery context' },
548
+ ],
549
+ last_messages_sent: [
550
+ { to: 'beta', content: 'Main-only recovery message', timestamp: '2026-04-16T05:35:30.000Z' },
551
+ ],
552
+ });
553
+
554
+ writeJson(path.join(dataDir, 'recovery-beta.json'), {
555
+ agent: 'beta',
556
+ branch: 'main',
557
+ died_at: '2026-04-16T05:36:00.000Z',
558
+ locked_files: ['main-branch-legacy-leak.js'],
559
+ decisions_made: [
560
+ { decision: 'Main legacy recovery leak', reasoning: 'Should never appear in feature respawn prompt' },
561
+ ],
562
+ last_messages_sent: [
563
+ { to: 'alpha', content: 'Main legacy recovery message', timestamp: '2026-04-16T05:36:30.000Z' },
564
+ ],
565
+ });
566
+
567
+ return {
568
+ featureBranch,
569
+ mainRecoveryLock: 'main-only-lock.js',
570
+ mainRecoveryDecision: 'Main-only recovery decision',
571
+ leakedRecoveryLock: 'main-branch-legacy-leak.js',
572
+ leakedRecoveryDecision: 'Main legacy recovery leak',
573
+ };
574
+ }
575
+
576
+ async function assertBranchAwareRespawnPrompt(baseUrl, branchFixture, respawnFixture, problems) {
577
+ const dashboardHtml = fs.readFileSync(DASHBOARD_HTML_FILE, 'utf8');
578
+ const branchAwareRespawnUiPattern = /function respawnAgent\(agentName\)\s*\{[\s\S]*?lttFetch\(scopedApiUrl\('\/api\/agents\/' \+ encodeURIComponent\(agentName\) \+ '\/respawn-prompt'\), \{/;
579
+
580
+ assert(branchAwareRespawnUiPattern.test(dashboardHtml), 'Dashboard respawn UI must call /api/agents/:name/respawn-prompt through scopedApiUrl() so the active branch is included.', problems);
581
+
582
+ const mainRespawnResponse = await requestJson(baseUrl, '/api/agents/alpha/respawn-prompt?branch=main');
583
+ assert(mainRespawnResponse.status === 200, `GET /api/agents/alpha/respawn-prompt?branch=main should return 200, got ${mainRespawnResponse.status}.`, problems);
584
+ assert(mainRespawnResponse.body && mainRespawnResponse.body.has_recovery === true, 'Main-branch respawn prompt should load matching main-branch recovery context.', problems);
585
+ assert(mainRespawnResponse.body && typeof mainRespawnResponse.body.prompt === 'string' && mainRespawnResponse.body.prompt.includes(respawnFixture.mainRecoveryLock), 'Main-branch respawn prompt should include the main-branch recovery lock context.', problems);
586
+ assert(mainRespawnResponse.body && typeof mainRespawnResponse.body.prompt === 'string' && mainRespawnResponse.body.prompt.includes(respawnFixture.mainRecoveryDecision), 'Main-branch respawn prompt should include the main-branch recovery decision context.', problems);
587
+
588
+ const featureRespawnResponse = await requestJson(baseUrl, `/api/agents/beta/respawn-prompt?branch=${respawnFixture.featureBranch}`);
589
+ assert(featureRespawnResponse.status === 200, `GET /api/agents/beta/respawn-prompt?branch=${respawnFixture.featureBranch} should return 200, got ${featureRespawnResponse.status}.`, problems);
590
+ assert(featureRespawnResponse.body && featureRespawnResponse.body.has_recovery === false, 'Feature-branch respawn prompt should ignore mismatched main-branch recovery snapshots.', problems);
591
+ assert(featureRespawnResponse.body && typeof featureRespawnResponse.body.prompt === 'string' && featureRespawnResponse.body.prompt.includes(branchFixture.featureConversationMode), 'Feature-branch respawn prompt should use the feature-branch conversation config.', problems);
592
+ assert(featureRespawnResponse.body && typeof featureRespawnResponse.body.prompt === 'string' && featureRespawnResponse.body.prompt.includes('Feature branch dashboard task'), 'Feature-branch respawn prompt should include the feature-branch assigned task.', problems);
593
+ assert(featureRespawnResponse.body && typeof featureRespawnResponse.body.prompt === 'string' && featureRespawnResponse.body.prompt.includes('Feature branch needle 7C runtime message'), 'Feature-branch respawn prompt should include feature-branch history only.', problems);
594
+ assert(featureRespawnResponse.body && typeof featureRespawnResponse.body.prompt === 'string' && featureRespawnResponse.body.prompt.includes(branchFixture.featureWorkspaceStatus), 'Feature-branch respawn prompt should include the feature-branch workspace status.', problems);
595
+ assert(featureRespawnResponse.body && typeof featureRespawnResponse.body.prompt === 'string' && !featureRespawnResponse.body.prompt.includes('Dashboard control-plane task'), 'Feature-branch respawn prompt must exclude main-branch task context.', problems);
596
+ assert(featureRespawnResponse.body && typeof featureRespawnResponse.body.prompt === 'string' && !featureRespawnResponse.body.prompt.includes('Main branch baseline message for Task 7C'), 'Feature-branch respawn prompt must exclude main-branch history context.', problems);
597
+ assert(featureRespawnResponse.body && typeof featureRespawnResponse.body.prompt === 'string' && !featureRespawnResponse.body.prompt.includes('Main branch workspace status'), 'Feature-branch respawn prompt must exclude main-branch workspace state.', problems);
598
+ assert(featureRespawnResponse.body && typeof featureRespawnResponse.body.prompt === 'string' && !featureRespawnResponse.body.prompt.includes(respawnFixture.leakedRecoveryLock), 'Feature-branch respawn prompt must exclude mismatched main-branch recovery lock context.', problems);
599
+ assert(featureRespawnResponse.body && typeof featureRespawnResponse.body.prompt === 'string' && !featureRespawnResponse.body.prompt.includes(respawnFixture.leakedRecoveryDecision), 'Feature-branch respawn prompt must exclude mismatched main-branch recovery decision context.', problems);
600
+ }
601
+
602
+ function assertDashboardScopedMessageTaskUi(problems) {
603
+ const dashboardHtml = fs.readFileSync(DASHBOARD_HTML_FILE, 'utf8');
604
+ const clearMessagesUiPattern = /function clearMessages\(\)\s*\{[\s\S]*?scopedApiUrl\('\/api\/clear-messages'\)[\s\S]*?showToast\('Clear messages failed:/;
605
+ const fetchTasksBranchAwarePattern = /function fetchTasks\(\)\s*\{[\s\S]*?lttFetch\(scopedApiUrl\('\/api\/tasks'\)\)/;
606
+ const fetchTasksMainGatePattern = /function fetchTasks\(\)\s*\{[\s\S]*?renderMainBranchOnlyView\('tasks-area', 'Tasks'\)/;
607
+ const renderTasksMainGatePattern = /function renderTasks\(\)\s*\{[\s\S]*?mainBranchOnlyViewHtml\('Tasks'\)/;
608
+ const updateTaskStatusMainGatePattern = /function updateTaskStatus\(taskId, newStatus\)\s*\{[\s\S]*?Tasks only support the main branch right now\./;
609
+ const injectTargetHelperPattern = /function getEligibleInjectTargets\(agentNames, agents\)\s*\{/;
610
+ const injectTargetPopulationPattern = /updateInjectTargets\(getEligibleInjectTargets\(filtered, agents\)\);/;
611
+ const staleInjectTargetPattern = /updateInjectTargets\(keys\);/;
612
+
613
+ assert(clearMessagesUiPattern.test(dashboardHtml), 'Dashboard Clear Messages UI must call the scoped branch-aware clear route and surface clear failures instead of failing silently.', problems);
614
+ assert(fetchTasksBranchAwarePattern.test(dashboardHtml), 'Dashboard Tasks UI must fetch tasks through scopedApiUrl(/api/tasks) so the active branch is honored.', problems);
615
+ assert(!fetchTasksMainGatePattern.test(dashboardHtml), 'Dashboard Tasks UI must not block non-main branches before fetching branch-local tasks.', problems);
616
+ assert(!renderTasksMainGatePattern.test(dashboardHtml), 'Dashboard Tasks UI must not render the legacy main-branch-only placeholder for branch-local tasks.', problems);
617
+ assert(!updateTaskStatusMainGatePattern.test(dashboardHtml), 'Dashboard Tasks UI must not reject task updates on non-main branches before calling the branch-aware API.', problems);
618
+ assert(injectTargetHelperPattern.test(dashboardHtml), 'Dashboard Send To population must derive an eligible inject-target list before rendering options.', problems);
619
+ assert(injectTargetPopulationPattern.test(dashboardHtml), 'Dashboard Send To dropdown must be populated from the eligible visible agent set instead of raw agent keys.', problems);
620
+ assert(!staleInjectTargetPattern.test(dashboardHtml), 'Dashboard Send To dropdown must not be repopulated directly from raw agent keys.', problems);
621
+ }
622
+
623
+ async function assertBranchScopedDashboardReads(baseUrl, fixture, problems) {
624
+ const mainHistoryResponse = await requestJson(baseUrl, '/api/history?branch=main&limit=10');
625
+ assert(mainHistoryResponse.status === 200, `GET /api/history?branch=main should return 200, got ${mainHistoryResponse.status}.`, problems);
626
+ assert(Array.isArray(mainHistoryResponse.body), 'GET /api/history?branch=main should return an array.', problems);
627
+
628
+ const mainHistory = Array.isArray(mainHistoryResponse.body) ? mainHistoryResponse.body : [];
629
+ const mainIds = new Set(mainHistory.map((message) => message.id));
630
+ const mainAckedMessage = mainHistory.find((message) => message.id === fixture.mainMessage.id);
631
+ assert(mainHistory.length === 2, `GET /api/history?branch=main should return exactly 2 main-branch messages, found ${mainHistory.length}.`, problems);
632
+ assert(mainIds.has(fixture.mainMessage.id), 'Main-branch history should include the main baseline message.', problems);
633
+ assert(mainIds.has(fixture.mainChannelMessage.id), 'Main-branch history should include the main non-general channel message.', problems);
634
+ assert(!mainIds.has(fixture.featureMessage.id), 'Main-branch history must exclude the feature-branch general message.', problems);
635
+ assert(!mainIds.has(fixture.featureChannelMessage.id), 'Main-branch history must exclude the feature-branch channel message.', problems);
636
+ assert(mainAckedMessage && mainAckedMessage.acked === true, 'Main-branch history should read acknowledgements from main acks.json.', problems);
637
+
638
+ const featureHistoryResponse = await requestJson(baseUrl, `/api/history?branch=${fixture.featureBranch}&limit=10`);
639
+ assert(featureHistoryResponse.status === 200, `GET /api/history?branch=${fixture.featureBranch} should return 200, got ${featureHistoryResponse.status}.`, problems);
640
+ assert(Array.isArray(featureHistoryResponse.body), `GET /api/history?branch=${fixture.featureBranch} should return an array.`, problems);
641
+
642
+ const featureHistory = Array.isArray(featureHistoryResponse.body) ? featureHistoryResponse.body : [];
643
+ const featureIds = new Set(featureHistory.map((message) => message.id));
644
+ const featureAckedMessage = featureHistory.find((message) => message.id === fixture.featureMessage.id);
645
+ assert(featureHistory.length === 2, `GET /api/history?branch=${fixture.featureBranch} should return exactly 2 feature-branch messages, found ${featureHistory.length}.`, problems);
646
+ assert(featureIds.has(fixture.featureMessage.id), 'Feature-branch history should include the feature baseline message.', problems);
647
+ assert(featureIds.has(fixture.featureChannelMessage.id), 'Feature-branch history should include the feature non-general channel message.', problems);
648
+ assert(!featureIds.has(fixture.mainMessage.id), 'Feature-branch history must exclude the main-branch general message.', problems);
649
+ assert(!featureIds.has(fixture.mainChannelMessage.id), 'Feature-branch history must exclude the main-branch channel message.', problems);
650
+ assert(featureAckedMessage && featureAckedMessage.acked === true, 'Feature-branch history should read acknowledgements from branch-scoped acks.', problems);
651
+
652
+ const mainChannelsResponse = await requestJson(baseUrl, '/api/channels?branch=main');
653
+ assert(mainChannelsResponse.status === 200, `GET /api/channels?branch=main should return 200, got ${mainChannelsResponse.status}.`, problems);
654
+ assert(mainChannelsResponse.body && mainChannelsResponse.body.ops && mainChannelsResponse.body.ops.message_count === 1, 'Main-branch channels should report the main ops channel count from main projections.', problems);
655
+ assert(mainChannelsResponse.body && !('featurelab' in mainChannelsResponse.body), 'Main-branch channels must exclude feature-only channel metadata.', problems);
656
+
657
+ const featureChannelsResponse = await requestJson(baseUrl, `/api/channels?branch=${fixture.featureBranch}`);
658
+ assert(featureChannelsResponse.status === 200, `GET /api/channels?branch=${fixture.featureBranch} should return 200, got ${featureChannelsResponse.status}.`, problems);
659
+ assert(featureChannelsResponse.body && featureChannelsResponse.body.featurelab && featureChannelsResponse.body.featurelab.message_count === 1, 'Feature-branch channels should report the feature channel count from branch-scoped projections.', problems);
660
+ assert(featureChannelsResponse.body && !('ops' in featureChannelsResponse.body), 'Feature-branch channels must exclude main-only channel metadata.', problems);
661
+
662
+ const mainWorkspacesResponse = await requestJson(baseUrl, '/api/workspaces?branch=main');
663
+ assert(mainWorkspacesResponse.status === 200, `GET /api/workspaces?branch=main should return 200, got ${mainWorkspacesResponse.status}.`, problems);
664
+ assert(mainWorkspacesResponse.body && mainWorkspacesResponse.body.alpha && mainWorkspacesResponse.body.alpha._status === fixture.mainWorkspaceStatus, 'Main-branch workspaces should return the main branch workspace projection.', problems);
665
+ assert(!JSON.stringify(mainWorkspacesResponse.body || {}).includes(fixture.featureRetryTask), 'Main-branch workspaces must exclude feature-only workspace retry history.', problems);
666
+
667
+ const featureWorkspacesResponse = await requestJson(baseUrl, `/api/workspaces?branch=${fixture.featureBranch}`);
668
+ assert(featureWorkspacesResponse.status === 200, `GET /api/workspaces?branch=${fixture.featureBranch} should return 200, got ${featureWorkspacesResponse.status}.`, problems);
669
+ assert(featureWorkspacesResponse.body && featureWorkspacesResponse.body.alpha && featureWorkspacesResponse.body.alpha.draft && featureWorkspacesResponse.body.alpha.draft.content === fixture.featureWorkspaceDraft, 'Feature-branch workspaces should return the feature branch workspace projection.', problems);
670
+ assert(featureWorkspacesResponse.body && featureWorkspacesResponse.body.beta && Array.isArray(featureWorkspacesResponse.body.beta.retry_history) && featureWorkspacesResponse.body.beta.retry_history[0].task === fixture.featureRetryTask, 'Feature-branch workspaces should include feature-only retry history.', problems);
671
+ assert(featureWorkspacesResponse.body && featureWorkspacesResponse.body.alpha && featureWorkspacesResponse.body.alpha._status !== fixture.mainWorkspaceStatus, 'Feature-branch workspaces must exclude main-branch workspace state.', problems);
672
+
673
+ const featureWorkspaceAgentResponse = await requestJson(baseUrl, `/api/workspaces?branch=${fixture.featureBranch}&agent=beta`);
674
+ assert(featureWorkspaceAgentResponse.status === 200, `GET /api/workspaces?branch=${fixture.featureBranch}&agent=beta should return 200, got ${featureWorkspaceAgentResponse.status}.`, problems);
675
+ assert(featureWorkspaceAgentResponse.body && featureWorkspaceAgentResponse.body.beta && Array.isArray(featureWorkspaceAgentResponse.body.beta.retry_history) && featureWorkspaceAgentResponse.body.beta.retry_history[0].task === fixture.featureRetryTask, 'Feature-branch single-agent workspace reads should stay branch-local.', problems);
676
+
677
+ const mainRetriesResponse = await requestJson(baseUrl, '/api/plan/retries?branch=main');
678
+ assert(mainRetriesResponse.status === 200, `GET /api/plan/retries?branch=main should return 200, got ${mainRetriesResponse.status}.`, problems);
679
+ assert(mainRetriesResponse.body && mainRetriesResponse.body.count === 1 && mainRetriesResponse.body.retries[0].agent === 'alpha', 'Main-branch retry view should read retry_history only from main-branch workspaces.', problems);
680
+
681
+ const featureRetriesResponse = await requestJson(baseUrl, `/api/plan/retries?branch=${fixture.featureBranch}`);
682
+ assert(featureRetriesResponse.status === 200, `GET /api/plan/retries?branch=${fixture.featureBranch} should return 200, got ${featureRetriesResponse.status}.`, problems);
683
+ assert(featureRetriesResponse.body && featureRetriesResponse.body.count === 1 && featureRetriesResponse.body.retries[0].agent === 'beta' && featureRetriesResponse.body.retries[0].task === fixture.featureRetryTask, 'Feature-branch retry view should read retry_history only from feature-branch workspaces.', problems);
684
+
685
+ const featureSearchResponse = await requestJson(baseUrl, `/api/search?branch=${fixture.featureBranch}&q=${encodeURIComponent('needle 7C')}`);
686
+ assert(featureSearchResponse.status === 200, `GET /api/search?branch=${fixture.featureBranch} should return 200, got ${featureSearchResponse.status}.`, problems);
687
+ assert(featureSearchResponse.body && featureSearchResponse.body.results_count === 1, 'Feature-branch search should only find the feature-specific query hit.', problems);
688
+ assert(featureSearchResponse.body && Array.isArray(featureSearchResponse.body.results) && featureSearchResponse.body.results[0] && featureSearchResponse.body.results[0].id === fixture.featureMessage.id, 'Feature-branch search should return the feature-branch general message, not main history.', problems);
689
+
690
+ const exportJsonResponse = await requestJson(baseUrl, `/api/export-json?branch=${fixture.featureBranch}`);
691
+ assert(exportJsonResponse.status === 200, `GET /api/export-json?branch=${fixture.featureBranch} should return 200, got ${exportJsonResponse.status}.`, problems);
692
+ assert(exportJsonResponse.body && exportJsonResponse.body.branch === fixture.featureBranch, 'JSON export should report the requested non-main branch.', problems);
693
+ assert(exportJsonResponse.body && exportJsonResponse.body.summary && exportJsonResponse.body.summary.message_count === 2, 'Feature-branch JSON export should count only feature-branch messages.', problems);
694
+ assert(exportJsonResponse.body && exportJsonResponse.body.channels && exportJsonResponse.body.channels.featurelab && exportJsonResponse.body.channels.featurelab.message_count === 1, 'Feature-branch JSON export should include feature-only channel counts.', problems);
695
+ assert(exportJsonResponse.body && exportJsonResponse.body.channels && !('ops' in exportJsonResponse.body.channels), 'Feature-branch JSON export must exclude main-only channels.', problems);
696
+ const exportJsonIds = new Set(exportJsonResponse.body && Array.isArray(exportJsonResponse.body.messages)
697
+ ? exportJsonResponse.body.messages.map((message) => message.id)
698
+ : []);
699
+ assert(exportJsonIds.has(fixture.featureMessage.id), 'Feature-branch JSON export should include the feature general message.', problems);
700
+ assert(exportJsonIds.has(fixture.featureChannelMessage.id), 'Feature-branch JSON export should include the feature channel message.', problems);
701
+ assert(!exportJsonIds.has(fixture.mainMessage.id), 'Feature-branch JSON export must exclude the main general message.', problems);
702
+ assert(!exportJsonIds.has(fixture.mainChannelMessage.id), 'Feature-branch JSON export must exclude the main channel message.', problems);
703
+
704
+ const exportHtmlResponse = await requestJson(baseUrl, `/api/export?branch=${fixture.featureBranch}`);
705
+ assert(exportHtmlResponse.status === 200, `GET /api/export?branch=${fixture.featureBranch} should return 200, got ${exportHtmlResponse.status}.`, problems);
706
+ assert(exportHtmlResponse.raw.includes(fixture.featureMessage.content), 'HTML export should render the feature general message content.', problems);
707
+ assert(exportHtmlResponse.raw.includes(fixture.featureChannelMessage.content), 'HTML export should render the feature channel message content.', problems);
708
+ assert(!exportHtmlResponse.raw.includes(fixture.mainMessage.content), 'HTML export must exclude main-branch general content when exporting a feature branch.', problems);
709
+ assert(!exportHtmlResponse.raw.includes(fixture.mainChannelMessage.content), 'HTML export must exclude main-branch channel content when exporting a feature branch.', problems);
710
+
711
+ const exportReplayResponse = await requestJson(baseUrl, `/api/export-replay?branch=${fixture.featureBranch}`);
712
+ assert(exportReplayResponse.status === 200, `GET /api/export-replay?branch=${fixture.featureBranch} should return 200, got ${exportReplayResponse.status}.`, problems);
713
+ assert(exportReplayResponse.raw.includes(fixture.featureMessage.content), 'Replay export should embed the feature general message content.', problems);
714
+ assert(exportReplayResponse.raw.includes(fixture.featureChannelMessage.content), 'Replay export should embed the feature channel message content.', problems);
715
+ assert(!exportReplayResponse.raw.includes(fixture.mainMessage.content), 'Replay export must exclude main-branch general content when exporting a feature branch.', problems);
716
+ assert(!exportReplayResponse.raw.includes(fixture.mainChannelMessage.content), 'Replay export must exclude main-branch channel content when exporting a feature branch.', problems);
717
+ }
718
+
719
+ async function assertAssistantInjectRouting(baseUrl, dataDir, eventLog, problems) {
720
+ const messagesFile = path.join(dataDir, 'messages.jsonl');
721
+ const historyFile = path.join(dataDir, 'history.jsonl');
722
+ const assistantMessagesFile = path.join(dataDir, 'assistant-messages.jsonl');
723
+
724
+ const beforeMessages = readJsonl(messagesFile);
725
+ const beforeHistory = readJsonl(historyFile);
726
+ const beforeAssistantMessages = readJsonl(assistantMessagesFile);
727
+ const beforeMessageEvents = readMessageEvents(eventLog);
728
+
729
+ const defaultResponse = await requestJson(baseUrl, '/api/inject', {
730
+ method: 'POST',
731
+ body: {
732
+ to: 'Assistant',
733
+ content: 'Assistant default canonical route validation',
734
+ },
735
+ });
736
+ assert(defaultResponse.status === 200, `POST /api/inject to Assistant without opt-in should return 200, got ${defaultResponse.status}.`, problems);
737
+ assert(defaultResponse.body && defaultResponse.body.success === true, 'POST /api/inject to Assistant without opt-in should succeed.', problems);
738
+
739
+ const afterDefaultMessages = readJsonl(messagesFile);
740
+ const afterDefaultHistory = readJsonl(historyFile);
741
+ const afterDefaultAssistantMessages = readJsonl(assistantMessagesFile);
742
+ const afterDefaultMessageEvents = readMessageEvents(eventLog);
743
+ const defaultMessageId = defaultResponse.body && defaultResponse.body.messageId;
744
+ const defaultCanonicalMessage = afterDefaultMessages.find((message) => message.id === defaultMessageId);
745
+
746
+ assert(afterDefaultMessages.length === beforeMessages.length + 1, 'Assistant default inject should append to the canonical messages projection.', problems);
747
+ assert(afterDefaultHistory.length === beforeHistory.length + 1, 'Assistant default inject should append to the canonical history projection.', problems);
748
+ assert(afterDefaultAssistantMessages.length === beforeAssistantMessages.length, 'Assistant default inject must not write to assistant-messages.jsonl without explicit opt-in.', problems);
749
+ assert(defaultCanonicalMessage && defaultCanonicalMessage.to === 'Assistant', 'Assistant default inject should keep the Assistant target in canonical projections.', problems);
750
+ assert(afterDefaultMessageEvents.length === beforeMessageEvents.length + 1, 'Assistant default inject should append one canonical message event.', problems);
751
+ assert(afterDefaultMessageEvents.some((event) => event.type === 'message.sent' && event.payload && event.payload.message && event.payload.message.id === defaultMessageId), 'Assistant default inject should be recorded in the canonical message event log.', problems);
752
+
753
+ const privateResponse = await requestJson(baseUrl, '/api/inject', {
754
+ method: 'POST',
755
+ body: {
756
+ to: 'Assistant',
757
+ content: 'Assistant private opt-in validation',
758
+ assistant_private: true,
759
+ },
760
+ });
761
+ assert(privateResponse.status === 200, `POST /api/inject to Assistant with assistant_private=true should return 200, got ${privateResponse.status}.`, problems);
762
+ assert(privateResponse.body && privateResponse.body.success === true, 'POST /api/inject to Assistant with assistant_private=true should succeed.', problems);
763
+
764
+ const afterPrivateMessages = readJsonl(messagesFile);
765
+ const afterPrivateHistory = readJsonl(historyFile);
766
+ const afterPrivateAssistantMessages = readJsonl(assistantMessagesFile);
767
+ const afterPrivateMessageEvents = readMessageEvents(eventLog);
768
+ const privateMessageId = privateResponse.body && privateResponse.body.messageId;
769
+ const privateAssistantMessage = afterPrivateAssistantMessages.find((message) => message.id === privateMessageId);
770
+
771
+ assert(afterPrivateMessages.length === afterDefaultMessages.length, 'Assistant private opt-in inject must not append to canonical messages.jsonl.', problems);
772
+ assert(afterPrivateHistory.length === afterDefaultHistory.length, 'Assistant private opt-in inject must not append to canonical history.jsonl.', problems);
773
+ assert(afterPrivateAssistantMessages.length === afterDefaultAssistantMessages.length + 1, 'Assistant private opt-in inject should append exactly one private assistant message.', problems);
774
+ assert(privateAssistantMessage && privateAssistantMessage.to === 'Assistant', 'Assistant private opt-in inject should persist the Assistant-targeted private message.', problems);
775
+ assert(afterPrivateMessageEvents.length === afterDefaultMessageEvents.length, 'Assistant private opt-in inject must not append canonical message events.', problems);
776
+ assert(!afterPrivateMessageEvents.some((event) => event.payload && event.payload.message && event.payload.message.id === privateMessageId), 'Assistant private opt-in inject must stay out of the canonical branch event log.', problems);
777
+
778
+ return {
779
+ mainMessageCount: afterPrivateMessages.length,
780
+ mainHistoryCount: afterPrivateHistory.length,
781
+ messageEventCount: afterPrivateMessageEvents.length,
782
+ };
783
+ }
784
+
785
+ async function assertDashboardRuleRoutes(baseUrl, canonicalState, eventLog, problems) {
786
+ const beforeRules = canonicalState.listRules({ branch: 'main' });
787
+ const beforeRuleEvents = readRuleEvents(eventLog);
788
+
789
+ const addResponse = await requestJson(baseUrl, '/api/rules', {
790
+ method: 'POST',
791
+ body: {
792
+ action: 'add',
793
+ text: 'Dashboard canonical rule fixture',
794
+ category: 'workflow',
795
+ },
796
+ });
797
+ assert((addResponse.status === 200 || addResponse.status === 201), `POST /api/rules should return 200 or 201, got ${addResponse.status}.`, problems);
798
+ const addedRule = addResponse.body && addResponse.body.rule ? addResponse.body.rule : addResponse.body;
799
+ assert(addedRule && addedRule.id, 'POST /api/rules should return the created rule.', problems);
800
+
801
+ const rulesAfterAdd = canonicalState.listRules({ branch: 'main' });
802
+ const storedRuleAfterAdd = Array.isArray(rulesAfterAdd) ? rulesAfterAdd.find((rule) => rule.id === (addedRule && addedRule.id)) : null;
803
+ const ruleEventsAfterAdd = readRuleEvents(eventLog);
804
+ assert(Array.isArray(rulesAfterAdd) && rulesAfterAdd.length === beforeRules.length + 1, 'Adding a dashboard rule should append one rule projection row.', problems);
805
+ assert(storedRuleAfterAdd && storedRuleAfterAdd.text === 'Dashboard canonical rule fixture', 'Dashboard rule add should persist the rule text in canonical branch-local rule state.', problems);
806
+ assert(ruleEventsAfterAdd.length === beforeRuleEvents.length + 1, 'Dashboard rule add should append one canonical rule event.', problems);
807
+ assert(ruleEventsAfterAdd.some((event) => event.type === 'rule.added' && event.payload && event.payload.rule_id === addedRule.id), 'Dashboard rule add should emit rule.added for the created rule.', problems);
808
+
809
+ const toggleResponse = await requestJson(baseUrl, `/api/rules/${addedRule.id}/toggle`, { method: 'POST' });
810
+ assert(toggleResponse.status === 200, `POST /api/rules/:id/toggle should return 200, got ${toggleResponse.status}.`, problems);
811
+ assert(toggleResponse.body && toggleResponse.body.id === addedRule.id, 'Rule toggle should return the toggled rule payload.', problems);
812
+
813
+ const rulesAfterToggle = canonicalState.listRules({ branch: 'main' });
814
+ const toggledRule = Array.isArray(rulesAfterToggle) ? rulesAfterToggle.find((rule) => rule.id === addedRule.id) : null;
815
+ const ruleEventsAfterToggle = readRuleEvents(eventLog);
816
+ assert(toggledRule && toggledRule.active === false, 'Dashboard rule toggle should persist the inactive rule state.', problems);
817
+ assert(ruleEventsAfterToggle.length === ruleEventsAfterAdd.length + 1, 'Dashboard rule toggle should append one canonical rule event.', problems);
818
+ assert(ruleEventsAfterToggle.some((event) => event.type === 'rule.toggled' && event.payload && event.payload.rule_id === addedRule.id && event.payload.active === false), 'Dashboard rule toggle should emit rule.toggled with the new active state.', problems);
819
+
820
+ const deleteResponse = await requestJson(baseUrl, `/api/rules/${addedRule.id}`, { method: 'DELETE' });
821
+ assert(deleteResponse.status === 200, `DELETE /api/rules/:id should return 200, got ${deleteResponse.status}.`, problems);
822
+ assert(deleteResponse.body && deleteResponse.body.success === true, 'Rule delete should report success.', problems);
823
+
824
+ const rulesAfterDelete = canonicalState.listRules({ branch: 'main' });
825
+ const ruleEventsAfterDelete = readRuleEvents(eventLog);
826
+ assert(Array.isArray(rulesAfterDelete) && !rulesAfterDelete.some((rule) => rule.id === addedRule.id), 'Dashboard rule delete should remove the rule from canonical branch-local rule state.', problems);
827
+ assert(ruleEventsAfterDelete.length === ruleEventsAfterToggle.length + 1, 'Dashboard rule delete should append one canonical rule event.', problems);
828
+ assert(ruleEventsAfterDelete.some((event) => event.type === 'rule.removed' && event.payload && event.payload.rule_id === addedRule.id), 'Dashboard rule delete should emit rule.removed for the deleted rule.', problems);
829
+ }
830
+
831
+ async function withDashboardFixture(runScenario) {
832
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'letthemtalk-dashboard-control-'));
833
+ const dataDir = path.join(tempRoot, '.agent-bridge');
834
+ const capture = { stdout: '', stderr: '' };
835
+ let dashboardChild = null;
836
+
837
+ try {
838
+ fs.mkdirSync(dataDir, { recursive: true });
839
+
840
+ const now = '2026-04-16T05:30:00.000Z';
841
+ writeJson(path.join(dataDir, 'agents.json'), buildAgents(now));
842
+ writeJson(path.join(dataDir, 'tasks.json'), buildTask(now));
843
+ writeJson(path.join(dataDir, 'workflows.json'), buildWorkflows(now));
844
+
845
+ const canonicalState = createCanonicalState({ dataDir, processPid: process.pid });
846
+ const eventLog = createCanonicalEventLog({ dataDir });
847
+ const port = await getFreePort();
848
+ const baseUrl = `http://${FIXTURE_PORT_HOST}:${port}`;
849
+
850
+ dashboardChild = spawn(process.execPath, [DASHBOARD_FILE], {
851
+ cwd: PACKAGE_ROOT,
852
+ env: {
853
+ ...process.env,
854
+ AGENT_BRIDGE_DATA: dataDir,
855
+ AGENT_BRIDGE_DATA_DIR: dataDir,
856
+ AGENT_BRIDGE_PORT: String(port),
857
+ },
858
+ stdio: ['ignore', 'pipe', 'pipe'],
859
+ });
860
+
861
+ dashboardChild.stdout.on('data', (chunk) => {
862
+ capture.stdout += chunk.toString();
863
+ });
864
+ dashboardChild.stderr.on('data', (chunk) => {
865
+ capture.stderr += chunk.toString();
866
+ });
867
+
868
+ await waitForDashboard(baseUrl, dashboardChild, capture);
869
+ return await runScenario({ baseUrl, dataDir, canonicalState, eventLog });
870
+ } finally {
871
+ await stopDashboard(dashboardChild);
872
+ fs.rmSync(tempRoot, { recursive: true, force: true });
873
+ }
874
+ }
875
+
876
+ async function runHealthyScenario() {
877
+ const problems = [];
878
+
879
+ try {
880
+ await withDashboardFixture(async ({ baseUrl, dataDir, canonicalState, eventLog }) => {
881
+ const branchFixture = buildBranchReadFixture(canonicalState, dataDir);
882
+ const respawnFixture = buildRespawnPromptFixture(canonicalState, dataDir);
883
+ const branchTaskWorkflowFixture = buildBranchTaskWorkflowFixture(canonicalState);
884
+ assertDashboardScopedMessageTaskUi(problems);
885
+ await assertBranchScopedDashboardReads(baseUrl, branchFixture, problems);
886
+ await assertBranchAwareRespawnPrompt(baseUrl, branchFixture, respawnFixture, problems);
887
+ const assistantBaseline = await assertAssistantInjectRouting(baseUrl, dataDir, eventLog, problems);
888
+ await assertDashboardRuleRoutes(baseUrl, canonicalState, eventLog, problems);
889
+
890
+ const messagesFile = path.join(dataDir, 'messages.jsonl');
891
+ const historyFile = path.join(dataDir, 'history.jsonl');
892
+ const mainMessageCountBeforeInject = readJsonl(messagesFile).length;
893
+ const mainHistoryCountBeforeInject = readJsonl(historyFile).length;
894
+ const messageEventCountBeforeInject = readMessageEvents(eventLog).length;
895
+
896
+ const injectResponse = await requestJson(baseUrl, '/api/inject', {
897
+ method: 'POST',
898
+ body: { to: 'alpha', content: 'Dashboard fixture hello' },
899
+ });
900
+ assert(injectResponse.status === 200, `POST /api/inject should return 200, got ${injectResponse.status}.`, problems);
901
+ assert(injectResponse.body && injectResponse.body.success === true, 'POST /api/inject should succeed.', problems);
902
+ assert(typeof injectResponse.body.messageId === 'string' && injectResponse.body.messageId.length > 0, 'POST /api/inject should return a messageId.', problems);
903
+
904
+ const injectedMessageId = injectResponse.body && injectResponse.body.messageId;
905
+ const afterInjectMessages = readJsonl(messagesFile);
906
+ const afterInjectHistory = readJsonl(historyFile);
907
+ assert(afterInjectMessages.length === mainMessageCountBeforeInject + 1, 'Inject route should append one canonical message projection row.', problems);
908
+ assert(afterInjectHistory.length === mainHistoryCountBeforeInject + 1, 'Inject route should append one canonical history projection row.', problems);
909
+ assert(afterInjectMessages[afterInjectMessages.length - 1] && afterInjectMessages[afterInjectMessages.length - 1].id === injectedMessageId, 'Injected message should appear in messages.jsonl with the returned messageId.', problems);
910
+ assert(afterInjectMessages[afterInjectMessages.length - 1] && afterInjectMessages[afterInjectMessages.length - 1].from === 'Dashboard', 'Injected message should originate from Dashboard.', problems);
911
+
912
+ const editResponse = await requestJson(baseUrl, '/api/message', {
913
+ method: 'PUT',
914
+ body: { id: injectedMessageId, content: 'Dashboard fixture edited content' },
915
+ });
916
+ assert(editResponse.status === 200, `PUT /api/message should return 200, got ${editResponse.status}.`, problems);
917
+ assert(editResponse.body && editResponse.body.success === true, 'PUT /api/message should succeed.', problems);
918
+
919
+ const afterEditMessages = readJsonl(messagesFile);
920
+ const afterEditHistory = readJsonl(historyFile);
921
+ const editedMessage = afterEditMessages.find((message) => message.id === injectedMessageId);
922
+ const editedHistoryMessage = afterEditHistory.find((message) => message.id === injectedMessageId);
923
+ assert(!!editedMessage, 'Edited message should still exist in messages.jsonl.', problems);
924
+ assert(editedMessage && editedMessage.content === 'Dashboard fixture edited content', 'Edited message should persist updated content.', problems);
925
+ assert(editedMessage && typeof editedMessage.edited_at === 'string', 'Edited message should persist edited_at metadata.', problems);
926
+ assert(
927
+ editedHistoryMessage
928
+ && Array.isArray(editedHistoryMessage.edit_history)
929
+ && editedHistoryMessage.edit_history[0]
930
+ && editedHistoryMessage.edit_history[0].content === 'Dashboard fixture hello'
931
+ && typeof editedHistoryMessage.edit_history[0].edited_at === 'string',
932
+ 'Edited message history should retain structured edit history in history.jsonl.',
933
+ problems
934
+ );
935
+
936
+ const deleteResponse = await requestJson(baseUrl, '/api/message', {
937
+ method: 'DELETE',
938
+ body: { id: injectedMessageId },
939
+ });
940
+ assert(deleteResponse.status === 200, `DELETE /api/message should return 200, got ${deleteResponse.status}.`, problems);
941
+ assert(deleteResponse.body && deleteResponse.body.success === true, 'DELETE /api/message should succeed for dashboard-authored messages.', problems);
942
+ assert(readJsonl(messagesFile).length === mainMessageCountBeforeInject, 'Delete route should restore the canonical message projection count to its pre-inject baseline.', problems);
943
+ assert(readJsonl(historyFile).length === mainHistoryCountBeforeInject, 'Delete route should restore the canonical history projection count to its pre-inject baseline.', problems);
944
+
945
+ const taskResponse = await requestJson(baseUrl, '/api/tasks', {
946
+ method: 'POST',
947
+ body: {
948
+ task_id: 'task_dashboard_control',
949
+ status: 'in_progress',
950
+ notes: 'Claimed from dashboard fixture',
951
+ },
952
+ });
953
+ assert(taskResponse.status === 200, `POST /api/tasks should return 200, got ${taskResponse.status}.`, problems);
954
+ assert(taskResponse.body && taskResponse.body.success === true, 'POST /api/tasks should succeed for a non-terminal task update.', problems);
955
+
956
+ const tasks = readJson(path.join(dataDir, 'tasks.json'), []);
957
+ const updatedTask = Array.isArray(tasks) ? tasks.find((task) => task.id === 'task_dashboard_control') : null;
958
+ assert(!!updatedTask, 'Updated dashboard task should still exist in tasks.json.', problems);
959
+ assert(updatedTask && updatedTask.status === 'in_progress', 'Task route should mutate canonical task status.', problems);
960
+ assert(updatedTask && Array.isArray(updatedTask.notes) && updatedTask.notes.length === 1, 'Task route should append one dashboard note.', problems);
961
+ assert(updatedTask && updatedTask.notes[0] && updatedTask.notes[0].by === 'Dashboard', 'Task route should attribute notes to Dashboard.', problems);
962
+ assert(updatedTask && updatedTask.notes[0] && updatedTask.notes[0].text === 'Claimed from dashboard fixture', 'Task route should persist the provided note text.', problems);
963
+
964
+ const workflowSkipResponse = await requestJson(baseUrl, '/api/workflows', {
965
+ method: 'POST',
966
+ body: {
967
+ action: 'skip',
968
+ workflow_id: 'wf_dashboard_skip',
969
+ step_id: 1,
970
+ },
971
+ });
972
+ assert(workflowSkipResponse.status === 200, `POST /api/workflows should return 200 for skip, got ${workflowSkipResponse.status}.`, problems);
973
+ assert(workflowSkipResponse.body && workflowSkipResponse.body.success === true, 'POST /api/workflows skip should succeed.', problems);
974
+
975
+ let workflows = readJson(path.join(dataDir, 'workflows.json'), []);
976
+ let dashboardSkipWorkflow = Array.isArray(workflows) ? workflows.find((workflow) => workflow.id === 'wf_dashboard_skip') : null;
977
+ assert(!!dashboardSkipWorkflow, 'Workflow route should preserve the targeted workflow entry.', problems);
978
+ assert(dashboardSkipWorkflow && dashboardSkipWorkflow.steps[0].status === 'done', 'Workflow skip route should mark the current step done.', problems);
979
+ assert(dashboardSkipWorkflow && dashboardSkipWorkflow.steps[1].status === 'in_progress', 'Workflow skip route should activate the next pending step.', problems);
980
+
981
+ const pauseResponse = await requestJson(baseUrl, '/api/plan/pause', { method: 'POST' });
982
+ assert(pauseResponse.status === 200, `POST /api/plan/pause should return 200, got ${pauseResponse.status}.`, problems);
983
+ assert(pauseResponse.body && pauseResponse.body.success === true, 'POST /api/plan/pause should succeed.', problems);
984
+
985
+ workflows = readJson(path.join(dataDir, 'workflows.json'), []);
986
+ let planWorkflow = Array.isArray(workflows) ? workflows.find((workflow) => workflow.id === 'wf_plan_control') : null;
987
+ assert(!!planWorkflow, 'Pause route should preserve the autonomous workflow entry.', problems);
988
+ assert(planWorkflow && planWorkflow.paused === true, 'Pause route should set workflow.paused.', problems);
989
+ assert(planWorkflow && typeof planWorkflow.paused_at === 'string', 'Pause route should stamp paused_at.', problems);
990
+
991
+ const resumeResponse = await requestJson(baseUrl, '/api/plan/resume', { method: 'POST' });
992
+ assert(resumeResponse.status === 200, `POST /api/plan/resume should return 200, got ${resumeResponse.status}.`, problems);
993
+ assert(resumeResponse.body && resumeResponse.body.success === true, 'POST /api/plan/resume should succeed.', problems);
994
+
995
+ workflows = readJson(path.join(dataDir, 'workflows.json'), []);
996
+ planWorkflow = Array.isArray(workflows) ? workflows.find((workflow) => workflow.id === 'wf_plan_control') : null;
997
+ assert(planWorkflow && planWorkflow.paused === false, 'Resume route should clear workflow.paused.', problems);
998
+ assert(planWorkflow && !('paused_at' in planWorkflow), 'Resume route should remove paused_at.', problems);
999
+
1000
+ const planSkipResponse = await requestJson(baseUrl, '/api/plan/skip/1', {
1001
+ method: 'POST',
1002
+ body: { workflow_id: 'wf_plan_control' },
1003
+ });
1004
+ assert(planSkipResponse.status === 200, `POST /api/plan/skip/1 should return 200, got ${planSkipResponse.status}.`, problems);
1005
+ assert(planSkipResponse.body && planSkipResponse.body.success === true, 'POST /api/plan/skip/1 should succeed.', problems);
1006
+
1007
+ workflows = readJson(path.join(dataDir, 'workflows.json'), []);
1008
+ planWorkflow = Array.isArray(workflows) ? workflows.find((workflow) => workflow.id === 'wf_plan_control') : null;
1009
+ assert(planWorkflow && planWorkflow.steps[0].status === 'done', 'Plan skip route should mark the targeted step done.', problems);
1010
+ assert(planWorkflow && planWorkflow.steps[0].skipped === true, 'Plan skip route should mark the targeted step skipped.', problems);
1011
+ assert(planWorkflow && typeof planWorkflow.steps[0].notes === 'string' && planWorkflow.steps[0].notes.includes('[Skipped from dashboard]'), 'Plan skip route should append the dashboard skip note.', problems);
1012
+ assert(planWorkflow && planWorkflow.steps[1].status === 'in_progress', 'Plan skip route should activate dependency-ready steps.', problems);
1013
+
1014
+ const reassignResponse = await requestJson(baseUrl, '/api/plan/reassign/2', {
1015
+ method: 'POST',
1016
+ body: {
1017
+ workflow_id: 'wf_plan_control',
1018
+ new_assignee: 'beta',
1019
+ },
1020
+ });
1021
+ assert(reassignResponse.status === 200, `POST /api/plan/reassign/2 should return 200, got ${reassignResponse.status}.`, problems);
1022
+ assert(reassignResponse.body && reassignResponse.body.success === true, 'POST /api/plan/reassign/2 should succeed.', problems);
1023
+
1024
+ workflows = readJson(path.join(dataDir, 'workflows.json'), []);
1025
+ planWorkflow = Array.isArray(workflows) ? workflows.find((workflow) => workflow.id === 'wf_plan_control') : null;
1026
+ assert(planWorkflow && planWorkflow.steps[1].assignee === 'beta', 'Plan reassign route should mutate the assignee through canonical workflow state.', problems);
1027
+
1028
+ const stopResponse = await requestJson(baseUrl, '/api/plan/stop', { method: 'POST' });
1029
+ assert(stopResponse.status === 200, `POST /api/plan/stop should return 200, got ${stopResponse.status}.`, problems);
1030
+ assert(stopResponse.body && stopResponse.body.success === true, 'POST /api/plan/stop should succeed.', problems);
1031
+
1032
+ workflows = readJson(path.join(dataDir, 'workflows.json'), []);
1033
+ planWorkflow = Array.isArray(workflows) ? workflows.find((workflow) => workflow.id === 'wf_plan_control') : null;
1034
+ assert(planWorkflow && planWorkflow.status === 'stopped', 'Plan stop route should mutate workflow.status to stopped.', problems);
1035
+ assert(planWorkflow && typeof planWorkflow.stopped_at === 'string', 'Plan stop route should stamp stopped_at.', problems);
1036
+
1037
+ const finalMessages = readJsonl(messagesFile);
1038
+ const finalHistory = readJsonl(historyFile);
1039
+ const pauseMessages = finalHistory.filter((message) => typeof message.content === 'string' && message.content.startsWith('[PLAN PAUSED]'));
1040
+ const resumeMessages = finalHistory.filter((message) => typeof message.content === 'string' && message.content.startsWith('[PLAN RESUMED]'));
1041
+ const reassignMessages = finalHistory.filter((message) => typeof message.content === 'string' && message.content.startsWith('[REASSIGNED]'));
1042
+ const stopMessages = finalHistory.filter((message) => typeof message.content === 'string' && message.content.startsWith('[PLAN STOPPED]'));
1043
+
1044
+ assert(finalMessages.length === assistantBaseline.mainMessageCount + 7, `Plan control routes should leave ${assistantBaseline.mainMessageCount + 7} live main-branch messages, found ${finalMessages.length}.`, problems);
1045
+ assert(finalHistory.length === assistantBaseline.mainHistoryCount + 7, `Plan control routes should leave ${assistantBaseline.mainHistoryCount + 7} canonical main-branch history rows after deleting the injected dashboard message, found ${finalHistory.length}.`, problems);
1046
+ assert(pauseMessages.length === 2, 'Pause route should broadcast one message per registered agent.', problems);
1047
+ assert(resumeMessages.length === 2, 'Resume route should broadcast one message per registered agent.', problems);
1048
+ assert(reassignMessages.length === 1 && reassignMessages[0].to === 'beta', 'Reassign route should inject one direct message to the new assignee.', problems);
1049
+ assert(stopMessages.length === 2, 'Stop route should broadcast one message per registered agent.', problems);
1050
+
1051
+ const messageEvents = readMessageEvents(eventLog);
1052
+ assert(messageEvents.length === messageEventCountBeforeInject + 10, `Canonical main-branch event log should retain ${messageEventCountBeforeInject + 10} message events including correction/redaction history, found ${messageEvents.length}.`, problems);
1053
+ assert(messageEvents.filter((event) => event.type === 'message.corrected').length === 1, 'Dashboard control-plane fixture should emit one canonical message.corrected event for the dashboard edit path.', problems);
1054
+ assert(messageEvents.filter((event) => event.type === 'message.redacted').length === 1, 'Dashboard control-plane fixture should emit one canonical message.redacted event for the dashboard delete path.', problems);
1055
+ assert(messageEvents.some((event) => event.payload && event.payload.message && event.payload.message.id === injectedMessageId), 'Canonical event log should retain the original injected message even after projection deletion.', problems);
1056
+ assert(messageEvents.filter((event) => event.payload && event.payload.message && typeof event.payload.message.content === 'string' && event.payload.message.content.startsWith('[PLAN PAUSED]')).length === 2, 'Canonical event log should record the plan pause broadcasts.', problems);
1057
+ assert(messageEvents.filter((event) => event.payload && event.payload.message && typeof event.payload.message.content === 'string' && event.payload.message.content.startsWith('[PLAN STOPPED]')).length === 2, 'Canonical event log should record the plan stop broadcasts.', problems);
1058
+
1059
+ const featureBranch = branchTaskWorkflowFixture.featureBranch;
1060
+ const featureTasksResponse = await requestJson(baseUrl, `/api/tasks?branch=${featureBranch}`);
1061
+ assert(featureTasksResponse.status === 200, `GET /api/tasks?branch=${featureBranch} should return 200, got ${featureTasksResponse.status}.`, problems);
1062
+ assert(Array.isArray(featureTasksResponse.body), `GET /api/tasks?branch=${featureBranch} should return an array.`, problems);
1063
+ assert(featureTasksResponse.body && featureTasksResponse.body.length === 1 && featureTasksResponse.body[0].id === branchTaskWorkflowFixture.featureTaskId, `GET /api/tasks?branch=${featureBranch} should return only the feature-branch task fixture.`, problems);
1064
+
1065
+ const featureTaskUpdateResponse = await requestJson(baseUrl, `/api/tasks?branch=${featureBranch}`, {
1066
+ method: 'POST',
1067
+ body: {
1068
+ task_id: branchTaskWorkflowFixture.featureTaskId,
1069
+ status: 'in_progress',
1070
+ notes: 'Claimed on feature dashboard branch',
1071
+ },
1072
+ });
1073
+ assert(featureTaskUpdateResponse.status === 200, `POST /api/tasks?branch=${featureBranch} should return 200, got ${featureTaskUpdateResponse.status}.`, problems);
1074
+ assert(featureTaskUpdateResponse.body && featureTaskUpdateResponse.body.success === true, `POST /api/tasks?branch=${featureBranch} should succeed.`, problems);
1075
+
1076
+ const featureTasks = canonicalState.listTasks({ branch: featureBranch });
1077
+ const mainTasks = canonicalState.listTasks({ branch: 'main' });
1078
+ const updatedFeatureTask = Array.isArray(featureTasks) ? featureTasks.find((task) => task.id === branchTaskWorkflowFixture.featureTaskId) : null;
1079
+ assert(!!updatedFeatureTask, 'Feature-branch task update should remain visible in canonical branch-local task state.', problems);
1080
+ assert(updatedFeatureTask && updatedFeatureTask.status === 'in_progress', 'Feature-branch task update should mutate only the feature-branch task status.', problems);
1081
+ assert(updatedFeatureTask && Array.isArray(updatedFeatureTask.notes) && updatedFeatureTask.notes.some((note) => note && note.text === 'Claimed on feature dashboard branch'), 'Feature-branch task update should append the provided dashboard note.', problems);
1082
+ assert(Array.isArray(mainTasks) && !mainTasks.some((task) => task.id === branchTaskWorkflowFixture.featureTaskId), 'Feature-branch task fixture must not leak into canonical main-branch task reads.', problems);
1083
+
1084
+ const featureWorkflowsResponse = await requestJson(baseUrl, `/api/workflows?branch=${featureBranch}`);
1085
+ assert(featureWorkflowsResponse.status === 200, `GET /api/workflows?branch=${featureBranch} should return 200, got ${featureWorkflowsResponse.status}.`, problems);
1086
+ assert(Array.isArray(featureWorkflowsResponse.body), `GET /api/workflows?branch=${featureBranch} should return an array.`, problems);
1087
+ assert(Array.isArray(featureWorkflowsResponse.body) && featureWorkflowsResponse.body.length === 2, `GET /api/workflows?branch=${featureBranch} should return the two feature-branch workflows.`, problems);
1088
+ assert(Array.isArray(featureWorkflowsResponse.body) && featureWorkflowsResponse.body.every((workflow) => workflow && workflow.branch_id === featureBranch), `GET /api/workflows?branch=${featureBranch} should only surface feature-branch workflows.`, problems);
1089
+
1090
+ const featureWorkflowSkipResponse = await requestJson(baseUrl, `/api/workflows?branch=${featureBranch}`, {
1091
+ method: 'POST',
1092
+ body: {
1093
+ action: 'skip',
1094
+ workflow_id: branchTaskWorkflowFixture.featureWorkflowId,
1095
+ step_id: 1,
1096
+ },
1097
+ });
1098
+ assert(featureWorkflowSkipResponse.status === 200, `POST /api/workflows?branch=${featureBranch} should return 200 for skip, got ${featureWorkflowSkipResponse.status}.`, problems);
1099
+ assert(featureWorkflowSkipResponse.body && featureWorkflowSkipResponse.body.success === true, `POST /api/workflows?branch=${featureBranch} skip should succeed.`, problems);
1100
+
1101
+ const featureWorkflows = canonicalState.listWorkflows({ branch: featureBranch });
1102
+ const mainWorkflowsAfterFeatureSkip = canonicalState.listWorkflows({ branch: 'main' });
1103
+ const updatedFeatureWorkflow = Array.isArray(featureWorkflows) ? featureWorkflows.find((workflow) => workflow.id === branchTaskWorkflowFixture.featureWorkflowId) : null;
1104
+ assert(!!updatedFeatureWorkflow, 'Feature-branch workflow skip should preserve the targeted feature workflow entry.', problems);
1105
+ assert(updatedFeatureWorkflow && updatedFeatureWorkflow.steps[0].status === 'done', 'Feature-branch workflow skip should mark the current feature step done.', problems);
1106
+ assert(updatedFeatureWorkflow && updatedFeatureWorkflow.steps[1].status === 'in_progress', 'Feature-branch workflow skip should activate the next dependency-ready feature step.', problems);
1107
+ assert(Array.isArray(mainWorkflowsAfterFeatureSkip) && !mainWorkflowsAfterFeatureSkip.some((workflow) => workflow.id === branchTaskWorkflowFixture.featureWorkflowId), 'Feature-branch workflow fixture must not leak into canonical main-branch workflow reads.', problems);
1108
+
1109
+ const featurePlanStatusResponse = await requestJson(baseUrl, `/api/plan/status?branch=${featureBranch}`);
1110
+ assert(featurePlanStatusResponse.status === 200, `GET /api/plan/status?branch=${featureBranch} should return 200, got ${featurePlanStatusResponse.status}.`, problems);
1111
+ assert(featurePlanStatusResponse.body && Array.isArray(featurePlanStatusResponse.body.workflows), `GET /api/plan/status?branch=${featureBranch} should return a workflows array.`, problems);
1112
+ assert(featurePlanStatusResponse.body && Array.isArray(featurePlanStatusResponse.body.workflows) && featurePlanStatusResponse.body.workflows.every((workflow) => workflow && workflow.branch_id === featureBranch), `GET /api/plan/status?branch=${featureBranch} should only surface feature-branch workflows.`, problems);
1113
+ assert(featurePlanStatusResponse.body && Array.isArray(featurePlanStatusResponse.body.workflows) && featurePlanStatusResponse.body.workflows.some((workflow) => workflow.id === branchTaskWorkflowFixture.featurePlanWorkflowId), `GET /api/plan/status?branch=${featureBranch} should include the feature autonomous workflow.`, problems);
1114
+
1115
+ const featurePlanReportResponse = await requestJson(baseUrl, `/api/plan/report?branch=${featureBranch}`);
1116
+ assert(featurePlanReportResponse.status === 200, `GET /api/plan/report?branch=${featureBranch} should return 200, got ${featurePlanReportResponse.status}.`, problems);
1117
+ assert(featurePlanReportResponse.body && Array.isArray(featurePlanReportResponse.body.workflows), `GET /api/plan/report?branch=${featureBranch} should return a workflows array.`, problems);
1118
+ assert(featurePlanReportResponse.body && Array.isArray(featurePlanReportResponse.body.workflows) && featurePlanReportResponse.body.workflows.every((workflow) => workflow && workflow.branch_id === featureBranch), `GET /api/plan/report?branch=${featureBranch} should only report feature-branch workflows.`, problems);
1119
+
1120
+ const mainPauseMessagesBeforeFeaturePause = canonicalState.getConversationMessages({ branch: 'main' }).filter((message) => typeof message.content === 'string' && message.content.startsWith('[PLAN PAUSED]')).length;
1121
+ const mainResumeMessagesBeforeFeatureResume = canonicalState.getConversationMessages({ branch: 'main' }).filter((message) => typeof message.content === 'string' && message.content.startsWith('[PLAN RESUMED]')).length;
1122
+
1123
+ const featurePauseResponse = await requestJson(baseUrl, `/api/plan/pause?branch=${featureBranch}`, { method: 'POST' });
1124
+ assert(featurePauseResponse.status === 200, `POST /api/plan/pause?branch=${featureBranch} should return 200, got ${featurePauseResponse.status}.`, problems);
1125
+ assert(featurePauseResponse.body && featurePauseResponse.body.success === true, `POST /api/plan/pause?branch=${featureBranch} should succeed.`, problems);
1126
+
1127
+ let featurePlanWorkflows = canonicalState.listWorkflows({ branch: featureBranch });
1128
+ let mainPlanWorkflows = canonicalState.listWorkflows({ branch: 'main' });
1129
+ let featurePlanWorkflow = Array.isArray(featurePlanWorkflows) ? featurePlanWorkflows.find((workflow) => workflow.id === branchTaskWorkflowFixture.featurePlanWorkflowId) : null;
1130
+ let mainPlanWorkflow = Array.isArray(mainPlanWorkflows) ? mainPlanWorkflows.find((workflow) => workflow.id === 'wf_plan_control') : null;
1131
+ let featurePauseMessages = canonicalState.getConversationMessages({ branch: featureBranch }).filter((message) => typeof message.content === 'string' && message.content.startsWith('[PLAN PAUSED]'));
1132
+ assert(featurePlanWorkflow && featurePlanWorkflow.paused === true, 'Feature-branch plan pause should set workflow.paused on the feature autonomous workflow only.', problems);
1133
+ assert(mainPlanWorkflow && mainPlanWorkflow.paused !== true, 'Feature-branch plan pause must not pause the main autonomous workflow.', problems);
1134
+ assert(featurePauseMessages.length === 2, 'Feature-branch plan pause should broadcast one pause message per registered agent on the feature branch.', problems);
1135
+ assert(canonicalState.getConversationMessages({ branch: 'main' }).filter((message) => typeof message.content === 'string' && message.content.startsWith('[PLAN PAUSED]')).length === mainPauseMessagesBeforeFeaturePause, 'Feature-branch plan pause must not add pause broadcasts to main-branch conversation history.', problems);
1136
+
1137
+ const featureResumeResponse = await requestJson(baseUrl, `/api/plan/resume?branch=${featureBranch}`, { method: 'POST' });
1138
+ assert(featureResumeResponse.status === 200, `POST /api/plan/resume?branch=${featureBranch} should return 200, got ${featureResumeResponse.status}.`, problems);
1139
+ assert(featureResumeResponse.body && featureResumeResponse.body.success === true, `POST /api/plan/resume?branch=${featureBranch} should succeed.`, problems);
1140
+
1141
+ featurePlanWorkflows = canonicalState.listWorkflows({ branch: featureBranch });
1142
+ mainPlanWorkflows = canonicalState.listWorkflows({ branch: 'main' });
1143
+ featurePlanWorkflow = Array.isArray(featurePlanWorkflows) ? featurePlanWorkflows.find((workflow) => workflow.id === branchTaskWorkflowFixture.featurePlanWorkflowId) : null;
1144
+ mainPlanWorkflow = Array.isArray(mainPlanWorkflows) ? mainPlanWorkflows.find((workflow) => workflow.id === 'wf_plan_control') : null;
1145
+ const featureResumeMessages = canonicalState.getConversationMessages({ branch: featureBranch }).filter((message) => typeof message.content === 'string' && message.content.startsWith('[PLAN RESUMED]'));
1146
+ assert(featurePlanWorkflow && featurePlanWorkflow.paused === false, 'Feature-branch plan resume should clear paused on the feature autonomous workflow.', problems);
1147
+ assert(featurePlanWorkflow && !('paused_at' in featurePlanWorkflow), 'Feature-branch plan resume should remove paused_at from the feature autonomous workflow.', problems);
1148
+ assert(mainPlanWorkflow && mainPlanWorkflow.paused !== true, 'Feature-branch plan resume must not mutate the main autonomous workflow pause state.', problems);
1149
+ assert(featureResumeMessages.length === 2, 'Feature-branch plan resume should broadcast one resume message per registered agent on the feature branch.', problems);
1150
+ assert(canonicalState.getConversationMessages({ branch: 'main' }).filter((message) => typeof message.content === 'string' && message.content.startsWith('[PLAN RESUMED]')).length === mainResumeMessagesBeforeFeatureResume, 'Feature-branch plan resume must not add resume broadcasts to main-branch conversation history.', problems);
1151
+
1152
+ const invalidEditTarget = readJsonl(messagesFile)[0];
1153
+ const invalidEditEventCountBefore = readMessageEvents(eventLog).length;
1154
+ let invalidEditError = null;
1155
+ try {
1156
+ canonicalState.editMessage({
1157
+ id: invalidEditTarget && invalidEditTarget.id,
1158
+ content: { invalid: true },
1159
+ actor: 'Dashboard',
1160
+ maxEditHistory: 10,
1161
+ });
1162
+ } catch (error) {
1163
+ invalidEditError = error;
1164
+ }
1165
+ assert(!!invalidEditTarget && typeof invalidEditTarget.id === 'string', 'Invalid edit payload guard needs a live message fixture to target.', problems);
1166
+ assert(!!invalidEditError && typeof invalidEditError.message === 'string' && invalidEditError.message.includes('payload.content to be a string'), 'Invalid edit payloads must fail before canonical append instead of poisoning replay state.', problems);
1167
+ assert(readMessageEvents(eventLog).length === invalidEditEventCountBefore, 'Invalid edit payload rejection must not append new canonical message events.', problems);
1168
+
1169
+ const mainMessagesBeforeClear = canonicalState.getConversationMessages({ branch: 'main' });
1170
+ const featureMessagesBeforeClear = canonicalState.getConversationMessages({ branch: featureBranch });
1171
+ const featureMessageEventsBeforeClear = readMessageEvents(eventLog, featureBranch);
1172
+ const featureClearResponse = await requestJson(baseUrl, `/api/clear-messages?branch=${featureBranch}`, {
1173
+ method: 'POST',
1174
+ body: { confirm: true },
1175
+ });
1176
+ assert(featureClearResponse.status === 200, `POST /api/clear-messages?branch=${featureBranch} should return 200, got ${featureClearResponse.status}.`, problems);
1177
+ assert(featureClearResponse.body && featureClearResponse.body.success === true, `POST /api/clear-messages?branch=${featureBranch} should succeed.`, problems);
1178
+ assert(featureClearResponse.body && featureClearResponse.body.branch === featureBranch, 'Clear Messages should report the cleared non-main branch.', problems);
1179
+ assert(featureClearResponse.body && featureClearResponse.body.cleared_messages === featureMessagesBeforeClear.length, 'Clear Messages should report the number of cleared branch-local messages.', problems);
1180
+ assert(canonicalState.getConversationMessages({ branch: featureBranch }).length === 0, 'Clear Messages should remove all live messages from the targeted non-main branch.', problems);
1181
+ assert(canonicalState.getConversationMessages({ branch: 'main' }).length === mainMessagesBeforeClear.length, 'Clear Messages on a feature branch must not remove main-branch messages.', problems);
1182
+ assert(readMessageEvents(eventLog, featureBranch).length === featureMessageEventsBeforeClear.length + featureMessagesBeforeClear.length, 'Clear Messages should append one canonical message.redacted event per cleared branch-local message.', problems);
1183
+ assert(readMessageEvents(eventLog, featureBranch).filter((event) => event.type === 'message.redacted').length === featureMessagesBeforeClear.length, 'Clear Messages should record canonical redactions for every cleared branch-local message.', problems);
1184
+
1185
+ const archiveResult = canonicalState.archiveCurrentConversation();
1186
+ assert(archiveResult && archiveResult.fail_closed === true, 'archiveCurrentConversation() must fail closed while projection-only archive rotation is unsupported.', problems);
1187
+
1188
+ const conversationsDir = path.join(dataDir, 'conversations');
1189
+ fs.mkdirSync(conversationsDir, { recursive: true });
1190
+ writeJsonl(path.join(conversationsDir, 'conversation-fixture.jsonl'), [
1191
+ { id: 'archived-fixture-message', from: 'alpha', to: 'beta', content: 'archived fixture', timestamp: '2026-04-16T06:00:00.000Z' },
1192
+ ]);
1193
+ const loadResult = canonicalState.loadConversation('conversation-fixture');
1194
+ assert(loadResult && loadResult.fail_closed === true, 'loadConversation() must fail closed instead of replay-poisoning canonical state from archived projections.', problems);
1195
+ assert(readJsonl(getScopedBranchFile(dataDir, featureBranch, 'messages.jsonl')).length === 0, 'Fail-closed loadConversation() must not rewrite cleared feature-branch message projections.', problems);
1196
+
1197
+ const runtimeDir = path.join(dataDir, 'runtime');
1198
+ const resetResult = canonicalState.resetRuntime();
1199
+ assert(resetResult && resetResult.success === true, 'resetRuntime() should still succeed as a full canonical reset.', problems);
1200
+ assert(!fs.existsSync(runtimeDir), 'resetRuntime() must delete canonical runtime data instead of leaving event truth behind.', problems);
1201
+ assert(!fs.existsSync(messagesFile), 'resetRuntime() must clear main messages.jsonl during a full canonical reset.', problems);
1202
+ assert(!fs.existsSync(historyFile), 'resetRuntime() must clear main history.jsonl during a full canonical reset.', problems);
1203
+ });
1204
+ } catch (error) {
1205
+ problems.push(error.stack || error.message);
1206
+ }
1207
+
1208
+ if (problems.length > 0) {
1209
+ fail(['Dashboard control-plane runtime validation failed.', ...problems.map((problem) => `- ${problem}`)], 1);
1210
+ }
1211
+
1212
+ console.log([
1213
+ 'Dashboard control-plane runtime validation passed.',
1214
+ 'Validated real dashboard HTTP routes for branch-scoped history/channels/search/export reads, Assistant default-vs-private inject routing, non-terminal task mutation, workflow skip, and representative plan pause/resume/skip/reassign/stop control against a temp canonical runtime.',
1215
+ 'Validated canonical projection state plus branch-local message.sent and rule.* events for dashboard-originated control-plane mutations without widening into browser/UI automation.',
1216
+ 'Validated invalid message edits fail before canonical append, branch-aware Clear Messages redacts canonical history instead of failing closed, archive/load helpers still fail closed, and full reset removes canonical runtime truth instead of only deleting projections.',
1217
+ ].join('\n'));
1218
+ }
1219
+
1220
+ async function runEditDeleteSemanticGapScenario() {
1221
+ const problems = [];
1222
+
1223
+ try {
1224
+ await withDashboardFixture(async ({ baseUrl, dataDir, canonicalState, eventLog }) => {
1225
+ const messagesFile = path.join(dataDir, 'messages.jsonl');
1226
+ const historyFile = path.join(dataDir, 'history.jsonl');
1227
+
1228
+ const editSeedResponse = await requestJson(baseUrl, '/api/inject', {
1229
+ method: 'POST',
1230
+ body: { to: 'alpha', content: 'Dashboard semantic gap edit seed' },
1231
+ });
1232
+ assert(editSeedResponse.status === 200, `POST /api/inject edit seed should return 200, got ${editSeedResponse.status}.`, problems);
1233
+ const editSeedId = editSeedResponse.body && editSeedResponse.body.messageId;
1234
+
1235
+ const editResponse = await requestJson(baseUrl, '/api/message', {
1236
+ method: 'PUT',
1237
+ body: { id: editSeedId, content: 'Dashboard semantic gap edited content' },
1238
+ });
1239
+ assert(editResponse.status === 200, `PUT /api/message should return 200 during semantic-gap validation, got ${editResponse.status}.`, problems);
1240
+ const editedProjection = readJsonl(messagesFile).find((message) => message.id === editSeedId);
1241
+ const editedHistoryProjection = readJsonl(historyFile).find((message) => message.id === editSeedId);
1242
+ assert(editedProjection && editedProjection.content === 'Dashboard semantic gap edited content', 'Edit/delete invariant expected PUT /api/message to rewrite the live message projection from canonical replay.', problems);
1243
+ assert(editedHistoryProjection && Array.isArray(editedHistoryProjection.edit_history) && editedHistoryProjection.edit_history.length === 1, 'Edit/delete invariant expected PUT /api/message to materialize edit_history metadata into history.jsonl.', problems);
1244
+
1245
+ const editEvents = readMessageEvents(eventLog);
1246
+ assert(editEvents.some((event) => event.type === 'message.corrected'), 'PUT /api/message should emit a canonical message.corrected event instead of relying only on projection rewrites.', problems);
1247
+
1248
+ fs.unlinkSync(messagesFile);
1249
+ fs.unlinkSync(historyFile);
1250
+ canonicalState.rebuildMessageProjections({ branch: 'main' });
1251
+ const rebuiltEditedProjection = readJsonl(messagesFile).find((message) => message.id === editSeedId);
1252
+ const rebuiltEditedHistoryProjection = readJsonl(historyFile).find((message) => message.id === editSeedId);
1253
+ assert(rebuiltEditedProjection && rebuiltEditedProjection.content === 'Dashboard semantic gap edited content', 'Edited content must survive a branch projection rebuild from canonical events.', problems);
1254
+ assert(rebuiltEditedHistoryProjection && Array.isArray(rebuiltEditedHistoryProjection.edit_history) && rebuiltEditedHistoryProjection.edit_history.length === 1, 'Edited history metadata must survive a branch projection rebuild from canonical events.', problems);
1255
+
1256
+ const deleteSeedResponse = await requestJson(baseUrl, '/api/inject', {
1257
+ method: 'POST',
1258
+ body: { to: 'alpha', content: 'Dashboard semantic gap delete seed' },
1259
+ });
1260
+ assert(deleteSeedResponse.status === 200, `POST /api/inject delete seed should return 200, got ${deleteSeedResponse.status}.`, problems);
1261
+ const deleteSeedId = deleteSeedResponse.body && deleteSeedResponse.body.messageId;
1262
+
1263
+ const deleteResponse = await requestJson(baseUrl, '/api/message', {
1264
+ method: 'DELETE',
1265
+ body: { id: deleteSeedId },
1266
+ });
1267
+ assert(deleteResponse.status === 200, `DELETE /api/message should return 200 during semantic-gap validation, got ${deleteResponse.status}.`, problems);
1268
+ assert(!readJsonl(messagesFile).some((message) => message.id === deleteSeedId), 'Edit/delete invariant expected DELETE /api/message to remove the live message projection row.', problems);
1269
+ assert(!readJsonl(historyFile).some((message) => message.id === deleteSeedId), 'Edit/delete invariant expected DELETE /api/message to remove the live history projection row.', problems);
1270
+
1271
+ const deleteEvents = readMessageEvents(eventLog);
1272
+ assert(deleteEvents.some((event) => event.type === 'message.redacted'), 'DELETE /api/message should emit a canonical message.redacted event instead of deleting projections in place.', problems);
1273
+
1274
+ fs.unlinkSync(messagesFile);
1275
+ fs.unlinkSync(historyFile);
1276
+ canonicalState.rebuildMessageProjections({ branch: 'main' });
1277
+ assert(!readJsonl(messagesFile).some((message) => message.id === deleteSeedId), 'Deleted messages must remain absent after replay/materialization rebuild.', problems);
1278
+ assert(!readJsonl(historyFile).some((message) => message.id === deleteSeedId), 'Deleted history rows must remain absent after replay/materialization rebuild.', problems);
1279
+ });
1280
+ } catch (error) {
1281
+ problems.push(error.stack || error.message);
1282
+ }
1283
+
1284
+ if (problems.length > 0) {
1285
+ fail(['Dashboard edit/delete semantic-gap invariant failed.', ...problems.map((problem) => `- ${problem}`)], 1);
1286
+ }
1287
+
1288
+ console.log([
1289
+ 'Dashboard edit/delete semantic-gap invariant passed.',
1290
+ 'Dashboard edit/delete routes emitted canonical correction/redaction events and rebuild from canonical message events preserved the edited/deleted state.',
1291
+ ].join('\n'));
1292
+ }
1293
+
1294
+ async function main() {
1295
+ const { scenario } = parseArgs(process.argv.slice(2));
1296
+ if (scenario === 'edit-delete-semantic-gap') {
1297
+ await runEditDeleteSemanticGapScenario();
1298
+ return;
1299
+ }
1300
+
1301
+ await runHealthyScenario();
1302
+ }
1303
+
1304
+ main();