wogiflow 2.6.4 → 2.7.1

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 (32) hide show
  1. package/.claude/settings.json +1 -11
  2. package/lib/workspace-changelog.js +182 -0
  3. package/lib/workspace-channel-server.js +75 -2
  4. package/lib/workspace-contracts.js +151 -1
  5. package/lib/workspace-events.js +383 -0
  6. package/lib/workspace-gates.js +740 -0
  7. package/lib/workspace-integration-tests.js +299 -0
  8. package/lib/workspace-intelligence.js +486 -1
  9. package/lib/workspace-locks.js +371 -0
  10. package/lib/workspace-messages.js +203 -3
  11. package/lib/workspace-routing.js +147 -2
  12. package/lib/workspace.js +8 -0
  13. package/package.json +1 -1
  14. package/scripts/flow-done-gates.js +70 -0
  15. package/scripts/hooks/entry/claude-code/permission-denied.js +111 -0
  16. package/scripts/postinstall.js +64 -2
  17. package/.claude/rules/_internal/README.md +0 -64
  18. package/.claude/rules/_internal/document-structure.md +0 -77
  19. package/.claude/rules/_internal/dual-repo-management.md +0 -174
  20. package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
  21. package/.claude/rules/_internal/github-releases.md +0 -71
  22. package/.claude/rules/_internal/model-management.md +0 -35
  23. package/.claude/rules/_internal/self-maintenance.md +0 -87
  24. package/.claude/rules/architecture/component-reuse.md +0 -38
  25. package/.claude/rules/code-style/naming-conventions.md +0 -107
  26. package/.claude/rules/operations/git-workflows.md +0 -92
  27. package/.claude/rules/operations/scratch-directory.md +0 -54
  28. package/.claude/rules/security/security-patterns.md +0 -176
  29. package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
  30. package/.workflow/specs/architecture.md.template +0 -24
  31. package/.workflow/specs/stack.md.template +0 -33
  32. package/.workflow/specs/testing.md.template +0 -36
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Workspace — Event Bus
5
+ *
6
+ * Persistent event stream for real-time cross-repo coordination.
7
+ * Workers subscribe to events from peers and react to them.
8
+ *
9
+ * Event types:
10
+ * - task-started — a worker began working on a task
11
+ * - task-completed — a worker finished a task
12
+ * - contract-changed — a contract was updated
13
+ * - lock-acquired — a shared interface was locked
14
+ * - lock-released — a shared interface was unlocked
15
+ * - test-failed — integration tests failed
16
+ * - decision-added — a new workspace-wide decision was added
17
+ * - sync-requested — workspace manifest needs refresh
18
+ *
19
+ * Architecture:
20
+ * - File-based event log (.workspace/state/events.json)
21
+ * - SSE endpoint on channel server (GET /events)
22
+ * - Workers poll or subscribe via SSE
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const crypto = require('node:crypto');
30
+
31
+ // ============================================================
32
+ // Constants
33
+ // ============================================================
34
+
35
+ const EVENT_TYPES = [
36
+ 'task-started',
37
+ 'task-completed',
38
+ 'contract-changed',
39
+ 'lock-acquired',
40
+ 'lock-released',
41
+ 'test-failed',
42
+ 'decision-added',
43
+ 'sync-requested'
44
+ ];
45
+
46
+ const EVENTS_FILE = 'events.json';
47
+ const MAX_EVENTS = 500; // Keep last 500 events
48
+ const EVENT_ID_PATTERN = /^evt-[a-f0-9]{8}$/;
49
+
50
+ // ============================================================
51
+ // Event Creation
52
+ // ============================================================
53
+
54
+ /**
55
+ * Create an event object.
56
+ *
57
+ * @param {Object} params
58
+ * @param {string} params.type — one of EVENT_TYPES
59
+ * @param {string} params.source — repo name that emitted the event
60
+ * @param {Object} [params.data] — event-specific payload
61
+ * @returns {Object} event object
62
+ */
63
+ function createEvent(params) {
64
+ const { type, source, data = {} } = params;
65
+
66
+ if (!EVENT_TYPES.includes(type)) {
67
+ throw new Error(`Invalid event type: ${type}. Must be one of: ${EVENT_TYPES.join(', ')}`);
68
+ }
69
+
70
+ return {
71
+ id: 'evt-' + crypto.randomBytes(4).toString('hex'),
72
+ type,
73
+ source,
74
+ data,
75
+ timestamp: new Date().toISOString()
76
+ };
77
+ }
78
+
79
+ // ============================================================
80
+ // Event Persistence
81
+ // ============================================================
82
+
83
+ /**
84
+ * Emit an event to the workspace event log.
85
+ * Uses append-only NDJSON format to avoid read-modify-write races
86
+ * when multiple workspace repos emit events concurrently.
87
+ *
88
+ * @param {string} workspaceRoot
89
+ * @param {Object} event — from createEvent()
90
+ * @returns {Object} the saved event
91
+ */
92
+ function emitEvent(workspaceRoot, event) {
93
+ const eventsPath = path.join(workspaceRoot, '.workspace', 'state', EVENTS_FILE);
94
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
95
+
96
+ // Append-only: one JSON object per line (NDJSON format)
97
+ // This is safe for concurrent writers — appendFileSync is atomic for small writes
98
+ fs.appendFileSync(eventsPath, JSON.stringify(event) + '\n');
99
+
100
+ // Periodic trim: only when file exceeds 2x MAX_EVENTS lines (amortized cost)
101
+ try {
102
+ const stat = fs.statSync(eventsPath);
103
+ // Rough estimate: ~200 bytes per event line
104
+ if (stat.size > MAX_EVENTS * 400) {
105
+ trimEventLog(eventsPath);
106
+ }
107
+ } catch (_err) {
108
+ // Non-critical
109
+ }
110
+
111
+ return event;
112
+ }
113
+
114
+ /**
115
+ * Trim the event log to MAX_EVENTS entries.
116
+ * Called periodically by emitEvent when the file grows too large.
117
+ *
118
+ * @param {string} eventsPath
119
+ */
120
+ function trimEventLog(eventsPath) {
121
+ try {
122
+ const content = fs.readFileSync(eventsPath, 'utf-8');
123
+ const lines = content.trim().split('\n').filter(Boolean);
124
+ if (lines.length > MAX_EVENTS) {
125
+ const trimmed = lines.slice(lines.length - MAX_EVENTS);
126
+ fs.writeFileSync(eventsPath, trimmed.join('\n') + '\n');
127
+ }
128
+ } catch (_err) {
129
+ // Non-critical — will try again next time
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Read events from the event log.
135
+ *
136
+ * @param {string} workspaceRoot
137
+ * @param {Object} [filter]
138
+ * @param {string} [filter.type] — filter by event type
139
+ * @param {string} [filter.source] — filter by source repo
140
+ * @param {string} [filter.since] — ISO date string, only events after this time
141
+ * @param {number} [filter.limit] — max events to return (default: 50)
142
+ * @returns {Array<Object>} events (newest first)
143
+ */
144
+ function readEvents(workspaceRoot, filter = {}) {
145
+ const eventsPath = path.join(workspaceRoot, '.workspace', 'state', EVENTS_FILE);
146
+ let events = [];
147
+
148
+ try {
149
+ if (fs.existsSync(eventsPath)) {
150
+ const content = fs.readFileSync(eventsPath, 'utf-8');
151
+ // Support both NDJSON (one JSON per line) and legacy JSON array format
152
+ if (content.trimStart().startsWith('[')) {
153
+ events = JSON.parse(content);
154
+ if (!Array.isArray(events)) events = [];
155
+ } else {
156
+ // NDJSON format
157
+ events = content.trim().split('\n').filter(Boolean).map(line => {
158
+ try { return JSON.parse(line); } catch (_err) { return null; }
159
+ }).filter(Boolean);
160
+ }
161
+ }
162
+ } catch (_err) {
163
+ return [];
164
+ }
165
+
166
+ // Apply filters
167
+ if (filter.type) {
168
+ events = events.filter(e => e.type === filter.type);
169
+ }
170
+ if (filter.source) {
171
+ events = events.filter(e => e.source === filter.source);
172
+ }
173
+ if (filter.since) {
174
+ const sinceTime = new Date(filter.since).getTime();
175
+ events = events.filter(e => new Date(e.timestamp).getTime() > sinceTime);
176
+ }
177
+
178
+ // Newest first
179
+ events.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
180
+
181
+ // Limit
182
+ const limit = filter.limit ?? 50;
183
+ if (limit > 0) {
184
+ events = events.slice(0, limit);
185
+ }
186
+
187
+ return events;
188
+ }
189
+
190
+ // ============================================================
191
+ // Event Subscriptions (Reactive Workflows)
192
+ // ============================================================
193
+
194
+ /**
195
+ * A subscription defines a reaction to a specific event type.
196
+ * Subscriptions are stored in .workspace/state/subscriptions.json.
197
+ *
198
+ * @param {Object} params
199
+ * @param {string} params.subscriber — repo name subscribing
200
+ * @param {string} params.eventType — event type to react to
201
+ * @param {string} params.sourceFilter — only events from this source (or '*' for all)
202
+ * @param {Object} params.action — what to do: { type: 'create-task', taskTemplate: {...} }
203
+ * @returns {Object} subscription object
204
+ */
205
+ function createSubscription(params) {
206
+ const { subscriber, eventType, sourceFilter = '*', action } = params;
207
+
208
+ return {
209
+ id: 'sub-' + crypto.randomBytes(4).toString('hex'),
210
+ subscriber,
211
+ eventType,
212
+ sourceFilter,
213
+ action,
214
+ createdAt: new Date().toISOString(),
215
+ active: true
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Save a subscription.
221
+ *
222
+ * @param {string} workspaceRoot
223
+ * @param {Object} subscription
224
+ * @returns {Object} saved subscription
225
+ */
226
+ function saveSubscription(workspaceRoot, subscription) {
227
+ const subsPath = path.join(workspaceRoot, '.workspace', 'state', 'subscriptions.json');
228
+ let subs = [];
229
+
230
+ try {
231
+ if (fs.existsSync(subsPath)) {
232
+ subs = JSON.parse(fs.readFileSync(subsPath, 'utf-8'));
233
+ if (!Array.isArray(subs)) subs = [];
234
+ }
235
+ } catch (_err) {
236
+ subs = [];
237
+ }
238
+
239
+ subs.push(subscription);
240
+ fs.mkdirSync(path.dirname(subsPath), { recursive: true });
241
+ fs.writeFileSync(subsPath, JSON.stringify(subs, null, 2));
242
+
243
+ return subscription;
244
+ }
245
+
246
+ /**
247
+ * Get all active subscriptions.
248
+ *
249
+ * @param {string} workspaceRoot
250
+ * @returns {Array<Object>} active subscriptions
251
+ */
252
+ function getActiveSubscriptions(workspaceRoot) {
253
+ const subsPath = path.join(workspaceRoot, '.workspace', 'state', 'subscriptions.json');
254
+
255
+ try {
256
+ if (fs.existsSync(subsPath)) {
257
+ const subs = JSON.parse(fs.readFileSync(subsPath, 'utf-8'));
258
+ return (Array.isArray(subs) ? subs : []).filter(s => s.active);
259
+ }
260
+ } catch (_err) {
261
+ // Non-critical
262
+ }
263
+
264
+ return [];
265
+ }
266
+
267
+ /**
268
+ * Remove a subscription.
269
+ *
270
+ * @param {string} workspaceRoot
271
+ * @param {string} subscriptionId
272
+ * @returns {boolean} true if found and removed
273
+ */
274
+ function removeSubscription(workspaceRoot, subscriptionId) {
275
+ const subsPath = path.join(workspaceRoot, '.workspace', 'state', 'subscriptions.json');
276
+
277
+ try {
278
+ if (!fs.existsSync(subsPath)) return false;
279
+ let subs = JSON.parse(fs.readFileSync(subsPath, 'utf-8'));
280
+ if (!Array.isArray(subs)) return false;
281
+
282
+ const before = subs.length;
283
+ subs = subs.filter(s => s.id !== subscriptionId);
284
+ if (subs.length === before) return false;
285
+
286
+ fs.writeFileSync(subsPath, JSON.stringify(subs, null, 2));
287
+ return true;
288
+ } catch (_err) {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Process an event against all active subscriptions.
295
+ * Returns actions that should be taken.
296
+ *
297
+ * @param {string} workspaceRoot
298
+ * @param {Object} event
299
+ * @returns {Array<Object>} triggered actions
300
+ */
301
+ function processEventSubscriptions(workspaceRoot, event) {
302
+ const subs = getActiveSubscriptions(workspaceRoot);
303
+ const triggeredActions = [];
304
+
305
+ for (const sub of subs) {
306
+ // Check if this subscription matches the event
307
+ if (sub.eventType !== event.type) continue;
308
+ if (sub.sourceFilter !== '*' && sub.sourceFilter !== event.source) continue;
309
+ if (sub.subscriber === event.source) continue; // Don't trigger on own events
310
+
311
+ triggeredActions.push({
312
+ subscription: sub,
313
+ event,
314
+ action: sub.action
315
+ });
316
+ }
317
+
318
+ return triggeredActions;
319
+ }
320
+
321
+ // ============================================================
322
+ // SSE Helpers (for channel server integration)
323
+ // ============================================================
324
+
325
+ /**
326
+ * Format an event as an SSE (Server-Sent Events) message.
327
+ *
328
+ * @param {Object} event
329
+ * @returns {string} SSE-formatted string
330
+ */
331
+ function formatAsSSE(event) {
332
+ const data = JSON.stringify(event);
333
+ return `id: ${event.id}\nevent: ${event.type}\ndata: ${data}\n\n`;
334
+ }
335
+
336
+ /**
337
+ * Get events since a specific event ID (for SSE Last-Event-ID reconnection).
338
+ *
339
+ * @param {string} workspaceRoot
340
+ * @param {string} lastEventId — the last event ID the client received
341
+ * @returns {Array<Object>} events after the given ID (oldest first)
342
+ */
343
+ function getEventsSince(workspaceRoot, lastEventId) {
344
+ const MAX_REPLAY_EVENTS = 50;
345
+ // Reuse readEvents which handles both NDJSON and legacy formats
346
+ const allEvents = readEvents(workspaceRoot, { limit: 0 });
347
+ // readEvents returns newest-first; reverse for oldest-first replay
348
+ allEvents.reverse();
349
+
350
+ if (!lastEventId) return allEvents.slice(-MAX_REPLAY_EVENTS);
351
+
352
+ const idx = allEvents.findIndex(e => e.id === lastEventId);
353
+ if (idx === -1) {
354
+ // Unknown ID — return only recent events, not the entire history
355
+ return allEvents.slice(-MAX_REPLAY_EVENTS);
356
+ }
357
+ return allEvents.slice(idx + 1);
358
+ }
359
+
360
+ // ============================================================
361
+ // Exports
362
+ // ============================================================
363
+
364
+ module.exports = {
365
+ // Constants
366
+ EVENT_TYPES,
367
+
368
+ // Event creation & persistence
369
+ createEvent,
370
+ emitEvent,
371
+ readEvents,
372
+
373
+ // Subscriptions
374
+ createSubscription,
375
+ saveSubscription,
376
+ getActiveSubscriptions,
377
+ removeSubscription,
378
+ processEventSubscriptions,
379
+
380
+ // SSE helpers
381
+ formatAsSSE,
382
+ getEventsSince
383
+ };