create-harness-vibe-coding 0.8.17 → 0.8.19

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 (150) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README-CN.md +2 -0
  3. package/README.md +3 -0
  4. package/package.json +5 -2
  5. package/src/generator.js +613 -97
  6. package/src/index.js +556 -309
  7. package/src/prompts.js +18 -0
  8. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  9. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  10. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  11. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  12. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  13. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  14. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  15. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  16. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  17. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  18. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  19. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  20. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  21. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  22. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  23. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  24. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  25. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  26. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  27. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  28. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  29. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  30. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  31. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  32. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  33. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  34. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  35. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  36. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  37. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  38. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  39. package/src/ui/dist/index.html +18 -0
  40. package/src/ui/index.html +17 -0
  41. package/src/ui/package.json +33 -0
  42. package/src/ui/pnpm-lock.yaml +1582 -0
  43. package/src/ui/pnpm-workspace.yaml +2 -0
  44. package/src/ui/src/App.tsx +86 -0
  45. package/src/ui/src/api.ts +93 -0
  46. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  47. package/src/ui/src/components/Footer.tsx +69 -0
  48. package/src/ui/src/components/Header.tsx +175 -0
  49. package/src/ui/src/components/LoadingView.tsx +22 -0
  50. package/src/ui/src/components/RolesRoute.tsx +169 -0
  51. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  52. package/src/ui/src/components/TaskList.tsx +388 -0
  53. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  54. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  55. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  56. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  57. package/src/ui/src/index.css +281 -0
  58. package/src/ui/src/main.tsx +11 -0
  59. package/src/ui/src/types.ts +108 -0
  60. package/src/ui/tsconfig.json +21 -0
  61. package/src/ui/vite.config.ts +19 -0
  62. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  63. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  64. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  65. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  66. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  67. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  68. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  69. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  70. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  71. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  72. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  73. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  74. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  75. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  76. package/src/wf-ui-server/a2a-store.mjs +296 -0
  77. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  78. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  79. package/src/wf-ui-server/runtime-config.mjs +153 -0
  80. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  81. package/src/wf-ui-server/security.mjs +67 -0
  82. package/src/wf-ui-server/server.mjs +849 -0
  83. package/src/wf-ui-server/session-registry.mjs +204 -0
  84. package/src/wf-ui-server/settings.mjs +100 -0
  85. package/src/wf-ui-server/task-parser.mjs +133 -0
  86. package/src/wf-ui-server/terminal-store.mjs +225 -0
  87. package/src/wf-ui-server/token.mjs +41 -0
  88. package/src/wf-ui-server/ws-events.mjs +354 -0
  89. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  90. package/templates/common/.claude/commands/wf-command-create.md +58 -0
  91. package/templates/common/.claude/commands/wf-help.md +5 -0
  92. package/templates/common/.claude/commands/wf-task-archive.md +26 -0
  93. package/templates/common/.claude/commands/wf-task-list.md +26 -0
  94. package/templates/common/.claude/commands/wf-task-record.md +24 -0
  95. package/templates/common/.claude/commands/wf-ui.md +26 -0
  96. package/templates/common/.claude/commands/wf-update.md +54 -9
  97. package/templates/common/.claude/rules/ecc/common.md +1 -1
  98. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
  99. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  100. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  101. package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
  102. package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
  103. package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
  104. package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
  105. package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
  106. package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
  107. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  108. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  109. package/templates/common/.harness-version +105 -47
  110. package/templates/common/.opencode/commands/wf-command-create.md +61 -0
  111. package/templates/common/.opencode/commands/wf-help.md +5 -0
  112. package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
  113. package/templates/common/.opencode/commands/wf-task-list.md +29 -0
  114. package/templates/common/.opencode/commands/wf-task-record.md +27 -0
  115. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  116. package/templates/common/.opencode/commands/wf-update.md +54 -9
  117. package/templates/common/CLAUDE.md +8 -6
  118. package/templates/common/Harness/MEMORY.md +11 -0
  119. package/templates/common/Harness/README.md +21 -38
  120. package/templates/common/Harness/a2a/role-graph.json +79 -0
  121. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  122. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  123. package/templates/common/Harness/ownership.manifest.json +142 -2
  124. package/templates/common/Harness/scripts/README.md +134 -0
  125. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  126. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  127. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  128. package/templates/common/Harness/scripts/task-state.mjs +949 -26
  129. package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
  130. package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
  131. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  132. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  133. package/templates/common/Harness/settings.json +35 -0
  134. package/templates/common/Harness/specs/guides/SETUP.md +8 -0
  135. package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
  136. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
  137. package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
  138. package/templates/common/Harness/specs/runtime/subagents.md +6 -0
  139. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  140. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  141. package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
  142. package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
  143. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  144. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  145. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  146. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  147. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  148. package/templates/common/Harness/tasks/_template/STATE.json +6 -0
  149. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  150. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -0,0 +1,419 @@
1
+ import test, { before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import net from 'node:net';
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { startServer, stopServer } from '../server.mjs';
9
+ import { attachEventsWs } from '../ws-events.mjs';
10
+
11
+ // ── Minimal WebSocket frame helpers ──
12
+
13
+ /**
14
+ * Parse a single unfragmented text frame from server->client data.
15
+ * Server frames are unmasked. Returns null if incomplete.
16
+ */
17
+ function tryParseFrame(buf) {
18
+ if (buf.length < 2) return null;
19
+ const opcode = buf[0] & 0x0f;
20
+ const masked = (buf[1] & 0x80) !== 0;
21
+ let payloadLen = buf[1] & 0x7f;
22
+ let offset = 2;
23
+
24
+ if (payloadLen === 126) {
25
+ if (buf.length < 4) return null;
26
+ payloadLen = buf.readUInt16BE(2);
27
+ offset = 4;
28
+ } else if (payloadLen === 127) {
29
+ if (buf.length < 10) return null;
30
+ payloadLen = Number(buf.readBigUInt64BE(2));
31
+ offset = 10;
32
+ }
33
+
34
+ // For testing we only handle unmasked server frames
35
+ if (masked) return null;
36
+ if (buf.length < offset + payloadLen) return null;
37
+
38
+ return {
39
+ opcode,
40
+ payload: buf.slice(offset, offset + payloadLen).toString('utf8'),
41
+ totalLen: offset + payloadLen,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Build a valid sec-websocket-accept response header value.
47
+ */
48
+ function computeAccept(key) {
49
+ const hash = crypto.createHash('sha1')
50
+ .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
51
+ .digest('base64');
52
+ return hash;
53
+ }
54
+
55
+ function sendMaskedControlFrame(sock, opcode, payloadText = '') {
56
+ const data = Buffer.from(payloadText, 'utf8');
57
+ const maskKey = crypto.randomBytes(4);
58
+ const masked = Buffer.allocUnsafe(data.length);
59
+ for (let i = 0; i < data.length; i++) {
60
+ masked[i] = data[i] ^ maskKey[i % 4];
61
+ }
62
+ const header = Buffer.alloc(6);
63
+ header[0] = 0x80 | opcode;
64
+ header[1] = 0x80 | data.length;
65
+ maskKey.copy(header, 2);
66
+ sock.write(Buffer.concat([header, masked]));
67
+ }
68
+
69
+ /**
70
+ * Connect to a WS endpoint using raw TCP.
71
+ * Returns an object with:
72
+ * - `sock` (net.Socket) — the raw connection
73
+ * - `readMessage(timeout)` — Promise<object> next text frame JSON
74
+ * - `close()` — destroy socket
75
+ * Or for non-101 responses: { status, body }
76
+ */
77
+ async function wsConnect(port, tokenValue) {
78
+ return new Promise((resolve, reject) => {
79
+ const sock = net.createConnection({ host: '127.0.0.1', port }, () => {
80
+ const wsKey = 'dGhlIHNhbXBsZSBub25jZQ==';
81
+ const q = tokenValue !== undefined && tokenValue !== null
82
+ ? 'token=' + encodeURIComponent(tokenValue) : '';
83
+ sock.write(
84
+ 'GET /ws/events?' + q + ' HTTP/1.1\r\n' +
85
+ 'Host: 127.0.0.1\r\n' +
86
+ 'Upgrade: websocket\r\n' +
87
+ 'Connection: Upgrade\r\n' +
88
+ 'Sec-WebSocket-Key: ' + wsKey + '\r\n' +
89
+ 'Sec-WebSocket-Version: 13\r\n\r\n'
90
+ );
91
+ });
92
+
93
+ let resolved = false;
94
+ const resolveOnce = (v) => { if (!resolved) { resolved = true; resolve(v); } };
95
+
96
+ const timeout = setTimeout(() => {
97
+ sock.destroy();
98
+ resolveOnce({ status: 0, body: 'timeout' });
99
+ }, 10000);
100
+
101
+ // Accumulate all data after the HTTP upgrade response
102
+ let frameBuf = Buffer.alloc(0);
103
+ let httpHeaderDone = false;
104
+
105
+ // Pending readMessage waiters
106
+ let pendingRead = null;
107
+
108
+ sock.on('data', (chunk) => {
109
+ if (!httpHeaderDone) {
110
+ // Still reading the HTTP upgrade response
111
+ frameBuf = Buffer.concat([frameBuf, chunk]);
112
+ const headerEnd = frameBuf.indexOf('\r\n\r\n');
113
+ if (headerEnd === -1) return; // wait for more
114
+
115
+ const header = frameBuf.slice(0, headerEnd).toString();
116
+ const rest = frameBuf.slice(headerEnd + 4);
117
+ const statusCode = parseInt(header.split(' ')[1] || '0', 10);
118
+
119
+ clearTimeout(timeout);
120
+
121
+ if (statusCode === 101) {
122
+ // Successful WS upgrade
123
+ httpHeaderDone = true;
124
+ frameBuf = rest; // remaining data after HTTP headers = WS frames
125
+
126
+ // Build the connection object
127
+ const conn = {
128
+ sock,
129
+ close() { sock.destroy(); },
130
+ };
131
+
132
+ conn.readMessage = (ms = 10000) => new Promise((res, rej) => {
133
+ function consumeBuffer() {
134
+ const frame = tryParseFrame(frameBuf);
135
+ if (frame) {
136
+ if (frame.opcode === 0x8) {
137
+ // Close frame
138
+ sock.destroy();
139
+ return rej(new Error('Connection closed'));
140
+ }
141
+ if (frame.opcode === 0x1) {
142
+ frameBuf = frameBuf.slice(frame.totalLen);
143
+ try { res(JSON.parse(frame.payload)); } catch (e) { rej(e); }
144
+ return true;
145
+ }
146
+ // Unknown/unhandled opcode — skip and try again
147
+ frameBuf = frameBuf.slice(frame.totalLen);
148
+ return false;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ // Try immediately
154
+ if (consumeBuffer()) return;
155
+
156
+ // Wait for more data
157
+ const tid = setTimeout(() => rej(new Error('timeout')), ms);
158
+ const onData = () => consumeBuffer() && cleanup();
159
+ const onClose = () => { cleanup(); rej(new Error('Connection closed')); };
160
+ const cleanup = () => {
161
+ clearTimeout(tid);
162
+ sock.off('data', onData);
163
+ sock.off('close', onClose);
164
+ };
165
+ sock.on('data', onData);
166
+ sock.on('close', onClose);
167
+ });
168
+
169
+ conn.readFrame = (ms = 10000) => new Promise((res, rej) => {
170
+ function consumeBuffer() {
171
+ const frame = tryParseFrame(frameBuf);
172
+ if (frame) {
173
+ frameBuf = frameBuf.slice(frame.totalLen);
174
+ if (frame.opcode === 0x8) {
175
+ sock.destroy();
176
+ return rej(new Error('Connection closed'));
177
+ }
178
+ res(frame);
179
+ return true;
180
+ }
181
+ return false;
182
+ }
183
+
184
+ if (consumeBuffer()) return;
185
+
186
+ const tid = setTimeout(() => rej(new Error('timeout')), ms);
187
+ const onData = () => consumeBuffer() && cleanup();
188
+ const onClose = () => { cleanup(); rej(new Error('Connection closed')); };
189
+ const cleanup = () => {
190
+ clearTimeout(tid);
191
+ sock.off('data', onData);
192
+ sock.off('close', onClose);
193
+ };
194
+ sock.on('data', onData);
195
+ sock.on('close', onClose);
196
+ });
197
+
198
+ conn.sendPong = () => sendMaskedControlFrame(sock, 0xA);
199
+
200
+ resolveOnce(conn);
201
+ } else {
202
+ sock.destroy();
203
+ resolveOnce({ status: statusCode, body: rest.toString() });
204
+ }
205
+ } else {
206
+ // WS data frame after upgrade
207
+ if (pendingRead) {
208
+ const p = pendingRead;
209
+ pendingRead = null;
210
+ // Try to consume on next tick
211
+ // Actually, push to frameBuf and let the readMessage's data handler pick it up
212
+ // But we need to trigger the onData handler...
213
+ }
214
+ // Just accumulate
215
+ frameBuf = Buffer.concat([frameBuf, chunk]);
216
+ }
217
+ });
218
+
219
+ sock.on('close', () => {
220
+ clearTimeout(timeout);
221
+ resolveOnce({ status: 0, body: 'closed' });
222
+ });
223
+ sock.on('error', () => { /* ignore ECONNRESET on close */ });
224
+ });
225
+ }
226
+
227
+ // ── Send a WebSocket pong frame ──
228
+ function sendPong(sock) {
229
+ // Pong frame: opcode 0xA, FIN=1, mask=0, payload length 0
230
+ const frame = Buffer.alloc(2);
231
+ frame[0] = 0x8A; // FIN + opcode 0xA (pong)
232
+ frame[1] = 0x00; // no payload
233
+ sock.write(frame);
234
+ }
235
+
236
+ // ── Test globals ──
237
+
238
+ let server;
239
+ let token;
240
+ let port;
241
+ let tempRoot;
242
+ let wsHandle;
243
+
244
+ before(async () => {
245
+ tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wf-ui-ws-'));
246
+ const tasksDir = path.join(tempRoot, 'Harness', 'tasks', 'task-test-a');
247
+ fs.mkdirSync(tasksDir, { recursive: true });
248
+ fs.writeFileSync(path.join(tasksDir, 'STATE.json'), JSON.stringify({
249
+ schemaVersion: 1, taskId: 'task-test-a', status: 'active', phase: 'test',
250
+ updatedAt: '2026-07-29T00:00:00.000Z',
251
+ acceptance: [],
252
+ links: { dependsOn: [], blocks: [] },
253
+ }));
254
+
255
+ const r = await startServer({ projectRoot: tempRoot, host: '127.0.0.1', port: 0 });
256
+ server = r.server;
257
+ token = r.token;
258
+ port = r.port;
259
+
260
+ // Attach WS with short keepalive for testing; suppress logs
261
+ const origLog = console.log;
262
+ console.log = () => {};
263
+ wsHandle = attachEventsWs(server, token, tempRoot, { keepaliveInterval: 2000, pingTimeout: 3000 });
264
+ console.log = origLog;
265
+ });
266
+
267
+ after(async () => {
268
+ if (wsHandle) await wsHandle.close();
269
+ if (server) await stopServer(server);
270
+ if (tempRoot) {
271
+ try { fs.rmSync(tempRoot, { recursive: true, force: true }); } catch {}
272
+ }
273
+ });
274
+
275
+ // ── Tests ──
276
+
277
+ test('connects with valid token, receives server.connected handshake', async () => {
278
+ const conn = await wsConnect(port, token);
279
+ assert.ok(conn.sock, 'Should establish WS connection');
280
+ assert.ok(conn.sock.writable, 'Socket should be writable');
281
+ assert.ok(typeof conn.readMessage === 'function');
282
+
283
+ const msg = await conn.readMessage(3000);
284
+ assert.equal(msg.type, 'server.connected');
285
+ assert.equal(msg.seq, 0);
286
+ assert.ok(msg.ts, 'Should have timestamp');
287
+ assert.ok(msg.connectionId, 'Should have connectionId');
288
+
289
+ conn.close();
290
+ });
291
+
292
+ test('connection with no token gets rejected', async () => {
293
+ const conn = await wsConnect(port, undefined);
294
+ assert.equal(conn.status, 401, 'No token should get 401');
295
+ });
296
+
297
+ test('connection with invalid token gets rejected', async () => {
298
+ const conn = await wsConnect(port, 'bad-token-1234567890abcdef');
299
+ assert.equal(conn.status, 401, 'Invalid token should get 401');
300
+ });
301
+
302
+ test('writing a STATE.json emits task.updated with incremented seq', async () => {
303
+ const conn = await wsConnect(port, token);
304
+ const handshake = await conn.readMessage(3000);
305
+ assert.equal(handshake.type, 'server.connected');
306
+
307
+ // Write a new STATE.json
308
+ const taskDir = path.join(tempRoot, 'Harness', 'tasks', 'task-test-b');
309
+ fs.mkdirSync(taskDir, { recursive: true });
310
+ fs.writeFileSync(path.join(taskDir, 'STATE.json'), JSON.stringify({
311
+ schemaVersion: 1, taskId: 'task-test-b', status: 'active', phase: 'test',
312
+ updatedAt: '2026-07-29T00:00:00.000Z',
313
+ acceptance: [],
314
+ links: { dependsOn: [], blocks: [] },
315
+ }));
316
+
317
+ const msg = await conn.readMessage(5000);
318
+ assert.equal(msg.type, 'task.updated');
319
+ assert.ok(msg.seq >= 1, `seq ${msg.seq} should be >= 1`);
320
+ assert.ok(msg.ts, 'Should have timestamp');
321
+ assert.equal(msg.taskId, null);
322
+ assert.ok(msg.payload.taskCount >= 2, `taskCount ${msg.payload.taskCount} should be >= 2`);
323
+
324
+ conn.close();
325
+ });
326
+
327
+ test('seq is strictly monotonic across multiple events', async () => {
328
+ const conn = await wsConnect(port, token);
329
+ await conn.readMessage(3000); // drain handshake
330
+
331
+ // Trigger first event
332
+ const taskDir = path.join(tempRoot, 'Harness', 'tasks', 'task-test-c');
333
+ fs.mkdirSync(taskDir, { recursive: true });
334
+ fs.writeFileSync(path.join(taskDir, 'STATE.json'), JSON.stringify({
335
+ schemaVersion: 1, taskId: 'task-test-c', status: 'active', phase: 'test',
336
+ acceptance: [],
337
+ links: { dependsOn: [], blocks: [] },
338
+ }));
339
+
340
+ const msg1 = await conn.readMessage(5000);
341
+ assert.equal(msg1.type, 'task.updated');
342
+
343
+ // Trigger second event
344
+ const taskDir2 = path.join(tempRoot, 'Harness', 'tasks', 'task-test-d');
345
+ fs.mkdirSync(taskDir2, { recursive: true });
346
+ fs.writeFileSync(path.join(taskDir2, 'STATE.json'), JSON.stringify({
347
+ schemaVersion: 1, taskId: 'task-test-d', status: 'active', phase: 'test',
348
+ acceptance: [],
349
+ links: { dependsOn: [], blocks: [] },
350
+ }));
351
+
352
+ const msg2 = await conn.readMessage(5000);
353
+ assert.equal(msg2.type, 'task.updated');
354
+ assert.ok(msg2.seq > msg1.seq, `seq ${msg2.seq} should be > ${msg1.seq}`);
355
+
356
+ conn.close();
357
+ });
358
+
359
+ test('ping keepalive frame received after idle period', async () => {
360
+ const conn = await wsConnect(port, token);
361
+ await conn.readMessage(3000); // drain handshake
362
+
363
+ // Wait for ping (keepalive is 2000ms, so should arrive within ~4000)
364
+ const frame = await conn.readFrame(10000);
365
+ assert.equal(frame.opcode, 0x9);
366
+ conn.sendPong();
367
+
368
+ conn.close();
369
+ });
370
+
371
+ test('connection re-established after forced close', async () => {
372
+ const conn1 = await wsConnect(port, token);
373
+ await conn1.readMessage(3000); // drain
374
+
375
+ // Don't respond to ping — server should force close after ~5s
376
+ // keepaliveInterval=2000, pingTimeout=3000
377
+ await new Promise(r => setTimeout(r, 7000));
378
+
379
+ // Old connection should be dead
380
+ try {
381
+ await conn1.readMessage(2000);
382
+ assert.fail('Should have thrown');
383
+ } catch {
384
+ // Expected
385
+ }
386
+
387
+ // Reconnect
388
+ const conn2 = await wsConnect(port, token);
389
+ const msg2 = await conn2.readMessage(3000);
390
+ assert.equal(msg2.type, 'server.connected');
391
+ assert.ok(msg2.seq >= 0, 'reconnect seq should still be >= 0');
392
+
393
+ conn2.close();
394
+ });
395
+
396
+ test('closing WS cleans up client set', async () => {
397
+ const conn1 = await wsConnect(port, token);
398
+ await conn1.readMessage(3000); // drain
399
+
400
+ const conn2 = await wsConnect(port, token);
401
+ await conn2.readMessage(3000); // drain
402
+
403
+ conn1.close();
404
+ await new Promise(r => setTimeout(r, 500));
405
+
406
+ // conn2 should still receive events
407
+ const taskDir = path.join(tempRoot, 'Harness', 'tasks', 'task-test-e');
408
+ fs.mkdirSync(taskDir, { recursive: true });
409
+ fs.writeFileSync(path.join(taskDir, 'STATE.json'), JSON.stringify({
410
+ schemaVersion: 1, taskId: 'task-test-e', status: 'active', phase: 'test',
411
+ acceptance: [],
412
+ links: { dependsOn: [], blocks: [] },
413
+ }));
414
+
415
+ const msg = await conn2.readMessage(5000);
416
+ assert.equal(msg.type, 'task.updated');
417
+
418
+ conn2.close();
419
+ });