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.
- package/.claude/settings.json +1 -11
- package/lib/workspace-changelog.js +182 -0
- package/lib/workspace-channel-server.js +75 -2
- package/lib/workspace-contracts.js +151 -1
- package/lib/workspace-events.js +383 -0
- package/lib/workspace-gates.js +740 -0
- package/lib/workspace-integration-tests.js +299 -0
- package/lib/workspace-intelligence.js +486 -1
- package/lib/workspace-locks.js +371 -0
- package/lib/workspace-messages.js +203 -3
- package/lib/workspace-routing.js +147 -2
- package/lib/workspace.js +8 -0
- package/package.json +1 -1
- package/scripts/flow-done-gates.js +70 -0
- package/scripts/hooks/entry/claude-code/permission-denied.js +111 -0
- package/scripts/postinstall.js +64 -2
- package/.claude/rules/_internal/README.md +0 -64
- package/.claude/rules/_internal/document-structure.md +0 -77
- package/.claude/rules/_internal/dual-repo-management.md +0 -174
- package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
- package/.claude/rules/_internal/github-releases.md +0 -71
- package/.claude/rules/_internal/model-management.md +0 -35
- package/.claude/rules/_internal/self-maintenance.md +0 -87
- package/.claude/rules/architecture/component-reuse.md +0 -38
- package/.claude/rules/code-style/naming-conventions.md +0 -107
- package/.claude/rules/operations/git-workflows.md +0 -92
- package/.claude/rules/operations/scratch-directory.md +0 -54
- package/.claude/rules/security/security-patterns.md +0 -176
- package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
- package/.workflow/specs/architecture.md.template +0 -24
- package/.workflow/specs/stack.md.template +0 -33
- 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
|
+
};
|